Files
orchestra/internal/store/cursor.go
T
kami 7f12c7fc37 v3 workflow: intent, phases, review, submission, enforcement, burn-in
The v3 stack, previously an uncommitted working tree, plus this session's two
units and the burn-in instrument. This commit is the burn-in build identity:
coordinator and worker must both report this revision before a task is created.

Workflow (earlier sessions, uncommitted until now): human decision events and
reduction, source cursors and reconcile-before-launch, turn-boundary
reconciliation, internal/agentctx as the single renderer, ace-fca phases with
sealed artifacts, the trajectory gate, bounded grilling, independent review,
task pr enforcement, and human review reflection.

Capability restrictions at the agent boundary: an authz.Agent surface at
GatedWrite may ask and may not act. It also fixes two bugs the unit exposed --
gated surfaces could not reach the two endpoints written for them, and
RequestHumanDecision would block an unowned task while rejecting a question
from the session that did own it.

Turn-boundary reconcile-failure escalation: a streak of consecutive failures
asks the session to hand off, fenced on the lease epoch, with reconcile_failure
as a real handoff reason. The worker was dropping the coordinator's verdict on
the floor; it now acts on it.

Burn-in: herdr.WriteLaunchContext dumps the exact agentctx.Build result to
<worktree>/.orchestra/launch.md at every launch, local and federated. BURNIN.md
is the runbook. deploy/build.sh stamps both binaries from one commit.

go build, go vet and go test ./... pass, 20 packages.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 18:31:20 +04:00

129 lines
3.5 KiB
Go

package store
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"orchestra/internal/domain"
)
// SourceCursor is how far a task has been reconciled against one external
// human-input source. Its meaning belongs to the provider: a Gitea comment
// id, a Vikunja activity id, a web command sequence. Orchestra only requires
// that the provider can resume from it.
//
// The cursor is an efficiency bound, never the correctness guarantee. A
// cursor that fails to persist after a decision was appended must not create
// a second decision, so provenance uniqueness on (provider, external_id) is
// what actually prevents duplicates. See Store.DecisionForSource.
type SourceCursor struct {
TaskID string `json:"task_id"`
Provider string `json:"provider"`
Cursor string `json:"cursor"`
}
func cursorKey(taskID, provider string) string { return taskID + "\x00" + provider }
func (s *Store) SourceCursor(taskID, provider string) (SourceCursor, bool) {
s.mu.Lock()
defer s.mu.Unlock()
v, ok := s.cursors[cursorKey(taskID, provider)]
if !ok {
return SourceCursor{TaskID: taskID, Provider: provider}, false
}
return SourceCursor{TaskID: taskID, Provider: provider, Cursor: v}, true
}
// SetSourceCursor persists the cursor before returning. A caller must only
// advance it after every event it derived from that input is durable.
func (s *Store) SetSourceCursor(c SourceCursor) error {
if strings.TrimSpace(c.TaskID) == "" || strings.TrimSpace(c.Provider) == "" {
return fmt.Errorf("%w: cursor needs task_id and provider", domain.ErrInvalid)
}
s.mu.Lock()
defer s.mu.Unlock()
prior, had := s.cursors[cursorKey(c.TaskID, c.Provider)]
s.cursors[cursorKey(c.TaskID, c.Provider)] = c.Cursor
if err := s.writeCursorsLocked(); err != nil {
if had {
s.cursors[cursorKey(c.TaskID, c.Provider)] = prior
} else {
delete(s.cursors, cursorKey(c.TaskID, c.Provider))
}
return err
}
return nil
}
// DecisionForSource resolves the decision already recorded for one external
// human utterance, so a refetch after a lost cursor write is a skip rather
// than a second decision.
func (s *Store) DecisionForSource(provider, externalID string) (string, bool) {
s.mu.Lock()
defer s.mu.Unlock()
id, ok := s.decisionSource[provider+"\x00"+externalID]
return id, ok
}
func (s *Store) writeCursorsLocked() error {
keys := make([]string, 0, len(s.cursors))
for k := range s.cursors {
keys = append(keys, k)
}
sort.Strings(keys)
out := make([]SourceCursor, 0, len(keys))
for _, k := range keys {
task, provider, _ := strings.Cut(k, "\x00")
out = append(out, SourceCursor{TaskID: task, Provider: provider, Cursor: s.cursors[k]})
}
b, err := json.Marshal(out)
if err != nil {
return err
}
tmp := s.cursorPath + ".tmp"
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644)
if err != nil {
return err
}
if _, err = f.Write(b); err == nil {
err = f.Sync()
}
if closeErr := f.Close(); err == nil {
err = closeErr
}
if err != nil {
return err
}
if err := os.Rename(tmp, s.cursorPath); err != nil {
return err
}
dir, err := os.Open(filepath.Dir(s.cursorPath))
if err != nil {
return err
}
defer dir.Close()
return dir.Sync()
}
func (s *Store) loadCursors() error {
b, err := os.ReadFile(s.cursorPath)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return err
}
var in []SourceCursor
if err := json.Unmarshal(b, &in); err != nil {
return fmt.Errorf("source cursors: %w", err)
}
for _, c := range in {
s.cursors[cursorKey(c.TaskID, c.Provider)] = c.Cursor
}
return nil
}