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>
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user