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,289 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"strings"
|
||||
|
||||
"orchestra/internal/domain"
|
||||
"orchestra/internal/herdr"
|
||||
"orchestra/internal/human"
|
||||
"orchestra/internal/orchestrator"
|
||||
"orchestra/internal/router"
|
||||
"orchestra/internal/store"
|
||||
)
|
||||
|
||||
// trace records the real order of operations across the launch path, which is
|
||||
// the property under test: reconciliation must be upstream of the agent, not
|
||||
// merely present somewhere in the process.
|
||||
type trace struct {
|
||||
mu sync.Mutex
|
||||
steps []string
|
||||
}
|
||||
|
||||
func (tr *trace) add(step string) {
|
||||
tr.mu.Lock()
|
||||
tr.steps = append(tr.steps, step)
|
||||
tr.mu.Unlock()
|
||||
}
|
||||
|
||||
func (tr *trace) snapshot() []string {
|
||||
tr.mu.Lock()
|
||||
defer tr.mu.Unlock()
|
||||
return append([]string(nil), tr.steps...)
|
||||
}
|
||||
|
||||
type tracingSource struct {
|
||||
tr *trace
|
||||
inputs []human.Input
|
||||
next string
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *tracingSource) FetchAfter(_ context.Context, task domain.Task, cursor store.SourceCursor) ([]human.Input, store.SourceCursor, error) {
|
||||
s.tr.add("fetch")
|
||||
if s.err != nil {
|
||||
return nil, store.SourceCursor{}, s.err
|
||||
}
|
||||
return s.inputs, store.SourceCursor{TaskID: task.ID, Provider: "gitea", Cursor: s.next}, nil
|
||||
}
|
||||
|
||||
// The milestone test: a comment written while the task was queued is durable
|
||||
// and standing before the agent that will act on it is started.
|
||||
func TestHumanInputReconciledBeforeAgentStarts(t *testing.T) {
|
||||
s, reg, _ := setup(t)
|
||||
task := ingest(t, s, "381")
|
||||
tr := &trace{}
|
||||
src := &tracingSource{tr: tr, next: "918", inputs: []human.Input{
|
||||
{Provider: "gitea", ExternalID: "918", Author: "kami", Body: "no, use b"},
|
||||
}}
|
||||
rec := &human.Reconciler{Store: s, Sources: map[string]human.Source{"gitea": src}}
|
||||
s.PreLease = func(id string) error { return rec.Reconcile(context.Background(), id) }
|
||||
|
||||
c := &orchestrator.Coordinator{Store: s, Worktrees: worktrees{}, Adapters: adapters{&harness{}}, StatePath: t.TempDir() + "/sessions.json"}
|
||||
rt := router.Router{Store: s, Registry: reg, Reachability: alwaysReachable{}, OnLease: func(e domain.Event) error {
|
||||
tr.add("agent.start")
|
||||
return c.Start(context.Background(), e)
|
||||
}}
|
||||
leased, err := rt.AssignPending()
|
||||
if err != nil || len(leased) != 1 {
|
||||
t.Fatalf("leased=%d err=%v", len(leased), err)
|
||||
}
|
||||
|
||||
if got := tr.snapshot(); len(got) != 2 || got[0] != "fetch" || got[1] != "agent.start" {
|
||||
t.Fatalf("order = %v, want fetch before agent.start", got)
|
||||
}
|
||||
intent, err := s.EffectiveIntent(task.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(intent.Decisions) != 1 || intent.Decisions[0].Value != "no, use b" {
|
||||
t.Fatalf("standing set = %+v", intent.Decisions)
|
||||
}
|
||||
if c, ok := s.SourceCursor(task.ID, "gitea"); !ok || c.Cursor != "918" {
|
||||
t.Fatalf("cursor = %+v ok=%v", c, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// Fail closed. If Orchestra cannot establish whether newer human input
|
||||
// exists, no lease is minted, so nothing downstream can start an agent from
|
||||
// the older intent.
|
||||
func TestUnreachableSourceRefusesTheLease(t *testing.T) {
|
||||
s, reg, _ := setup(t)
|
||||
task := ingest(t, s, "381")
|
||||
tr := &trace{}
|
||||
src := &tracingSource{tr: tr, err: errors.New("gitea unreachable")}
|
||||
rec := &human.Reconciler{Store: s, Sources: map[string]human.Source{"gitea": src}}
|
||||
s.PreLease = func(id string) error { return rec.Reconcile(context.Background(), id) }
|
||||
|
||||
h := &harness{}
|
||||
c := &orchestrator.Coordinator{Store: s, Worktrees: worktrees{}, Adapters: adapters{h}, StatePath: t.TempDir() + "/sessions.json"}
|
||||
rt := router.Router{Store: s, Registry: reg, Reachability: alwaysReachable{}, OnLease: func(e domain.Event) error {
|
||||
tr.add("agent.start")
|
||||
return c.Start(context.Background(), e)
|
||||
}}
|
||||
leased, err := rt.AssignPending()
|
||||
if len(leased) != 0 {
|
||||
t.Fatalf("a lease was minted despite unreachable human input: %v (err=%v)", leased, err)
|
||||
}
|
||||
for _, step := range tr.snapshot() {
|
||||
if step == "agent.start" {
|
||||
t.Fatal("an agent was started without reconciliation")
|
||||
}
|
||||
}
|
||||
got, _ := s.Task(task.ID)
|
||||
if got.State != domain.StateQueued {
|
||||
t.Fatalf("task state = %s, want queued so a later attempt retries", got.State)
|
||||
}
|
||||
if got.Lease != nil {
|
||||
t.Fatal("task must not hold a lease")
|
||||
}
|
||||
}
|
||||
|
||||
// The successor case: a comment arriving after session 1 released must be
|
||||
// standing before session 2 is leased, not merged in later.
|
||||
func TestSuccessorLeaseReconcilesBeforeResume(t *testing.T) {
|
||||
s, reg, _ := setup(t)
|
||||
task := ingest(t, s, "381")
|
||||
tr := &trace{}
|
||||
src := &tracingSource{tr: tr}
|
||||
rec := &human.Reconciler{Store: s, Sources: map[string]human.Source{"gitea": src}}
|
||||
s.PreLease = func(id string) error { return rec.Reconcile(context.Background(), id) }
|
||||
|
||||
if _, err := s.Lease(task.ID, "h1", 30*time.Minute); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ref, err := s.PutArtifact([]byte("handoff: next, implement a"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
leasedTask, _ := s.Task(task.ID)
|
||||
if err := s.Append(domain.Event{ID: domain.NewID(), Type: "TaskReleased", TaskID: task.ID, Version: leasedTask.Version + 1, Surface: "system", Payload: mustJSON(map[string]any{
|
||||
"handoff_ref": ref,
|
||||
"anchor_sha": "0123456789012345678901234567890123456789",
|
||||
"harness_id": leasedTask.Lease.HarnessID,
|
||||
"lease_epoch": leasedTask.Lease.Epoch,
|
||||
"expected_version": leasedTask.Version,
|
||||
})}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// The human comments while the task sits queued between sessions.
|
||||
src.inputs = []human.Input{{Provider: "gitea", ExternalID: "918", Author: "kami", Body: "no, use b"}}
|
||||
src.next = "918"
|
||||
|
||||
rt := router.Router{Store: s, Registry: reg, Reachability: alwaysReachable{}, OnLease: func(e domain.Event) error {
|
||||
intent, err := s.EffectiveIntent(e.TaskID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Read at the moment ownership begins: the successor's authority must
|
||||
// already contain the correction, before it reads any handoff.
|
||||
if len(intent.Decisions) != 1 || intent.Decisions[0].Value != "no, use b" {
|
||||
t.Errorf("successor launched with standing set %+v", intent.Decisions)
|
||||
}
|
||||
if intent.Task.HandoffRef != ref {
|
||||
t.Errorf("handoff ref = %q, want %q", intent.Task.HandoffRef, ref)
|
||||
}
|
||||
return nil
|
||||
}}
|
||||
leased, err := rt.AssignPending()
|
||||
if err != nil || len(leased) != 1 {
|
||||
t.Fatalf("leased=%d err=%v", len(leased), err)
|
||||
}
|
||||
}
|
||||
|
||||
// capturingAdapter records the exact launch instruction the agent receives.
|
||||
type capturingAdapter struct {
|
||||
*harness
|
||||
mu sync.Mutex
|
||||
prompt string
|
||||
}
|
||||
|
||||
func (a *capturingAdapter) LeasePrompt(_ context.Context, _, worktree, prompt string) (herdr.Session, error) {
|
||||
a.mu.Lock()
|
||||
a.prompt = prompt
|
||||
a.mu.Unlock()
|
||||
return herdr.Session{Harness: "h1", PaneID: "pane-1", Worktree: worktree}, nil
|
||||
}
|
||||
|
||||
type capturingAdapters struct{ a herdr.Adapter }
|
||||
|
||||
func (c capturingAdapters) Adapter(string) (herdr.Adapter, error) { return c.a, nil }
|
||||
|
||||
// The live proof: the contract says implement a, the human says use b, and the
|
||||
// agent's launch instruction presents b as authority.
|
||||
func TestAgentLaunchInstructionCarriesTheCorrection(t *testing.T) {
|
||||
s, reg, _ := setup(t)
|
||||
task := ingest(t, s, "381")
|
||||
amend, _ := s.Task(task.ID)
|
||||
if err := s.Append(domain.Event{ID: domain.NewID(), Type: "TaskAmended", TaskID: task.ID, Version: amend.Version + 1, Surface: "system", Payload: mustJSON(map[string]any{
|
||||
"description": "implement a",
|
||||
})}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
src := &tracingSource{tr: &trace{}, next: "918", inputs: []human.Input{
|
||||
{Provider: "gitea", ExternalID: "918", Author: "kami", Body: "no, use b"},
|
||||
}}
|
||||
rec := &human.Reconciler{Store: s, Sources: map[string]human.Source{"gitea": src}}
|
||||
s.PreLease = func(id string) error { return rec.Reconcile(context.Background(), id) }
|
||||
|
||||
a := &capturingAdapter{harness: &harness{}}
|
||||
c := &orchestrator.Coordinator{Store: s, Worktrees: worktrees{}, Adapters: capturingAdapters{a}, StatePath: t.TempDir() + "/sessions.json"}
|
||||
rt := router.Router{Store: s, Registry: reg, Reachability: alwaysReachable{}, OnLease: func(e domain.Event) error {
|
||||
return c.Start(context.Background(), e)
|
||||
}}
|
||||
if leased, err := rt.AssignPending(); err != nil || len(leased) != 1 {
|
||||
t.Fatalf("leased=%d err=%v", len(leased), err)
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
prompt := a.prompt
|
||||
a.mu.Unlock()
|
||||
if prompt == "" {
|
||||
t.Fatal("no launch instruction was sent")
|
||||
}
|
||||
decision := strings.Index(prompt, "no, use b")
|
||||
goal := strings.Index(prompt, "implement a")
|
||||
if decision < 0 || goal < 0 {
|
||||
t.Fatalf("prompt missing goal or decision:\n%s", prompt)
|
||||
}
|
||||
if !strings.Contains(prompt, "## Current human decisions") {
|
||||
t.Fatalf("prompt has no decisions section:\n%s", prompt)
|
||||
}
|
||||
if !strings.Contains(prompt, "Authority order") {
|
||||
t.Fatalf("prompt does not state the authority order:\n%s", prompt)
|
||||
}
|
||||
if goal > decision {
|
||||
t.Fatal("goal must precede the decisions section")
|
||||
}
|
||||
}
|
||||
|
||||
// tempWorktrees gives one test its own worktree, so what a launch writes into
|
||||
// it can be read back.
|
||||
type tempWorktrees struct{ path string }
|
||||
|
||||
func (w tempWorktrees) Create(context.Context, domain.Task) (string, error) { return w.path, nil }
|
||||
|
||||
// Burn-in depends on this: the exact instruction a session was launched with is
|
||||
// on disk, not only in pane scrollback the harness has reflowed.
|
||||
func TestLaunchWritesTheContextItSent(t *testing.T) {
|
||||
s, reg, _ := setup(t)
|
||||
task := ingest(t, s, "381")
|
||||
src := &tracingSource{tr: &trace{}, next: "918", inputs: []human.Input{
|
||||
{Provider: "gitea", ExternalID: "918", Author: "kami", Body: "no, use b"},
|
||||
}}
|
||||
rec := &human.Reconciler{Store: s, Sources: map[string]human.Source{"gitea": src}}
|
||||
s.PreLease = func(id string) error { return rec.Reconcile(context.Background(), id) }
|
||||
|
||||
worktree := t.TempDir()
|
||||
a := &capturingAdapter{harness: &harness{}}
|
||||
c := &orchestrator.Coordinator{Store: s, Worktrees: tempWorktrees{path: worktree}, Adapters: capturingAdapters{a}, StatePath: t.TempDir() + "/sessions.json"}
|
||||
rt := router.Router{Store: s, Registry: reg, Reachability: alwaysReachable{}, OnLease: func(e domain.Event) error {
|
||||
return c.Start(context.Background(), e)
|
||||
}}
|
||||
if leased, err := rt.AssignPending(); err != nil || len(leased) != 1 {
|
||||
t.Fatalf("leased=%d err=%v", len(leased), err)
|
||||
}
|
||||
|
||||
b, err := os.ReadFile(filepath.Join(worktree, herdr.LaunchContextFile))
|
||||
if err != nil {
|
||||
t.Fatalf("no launch context recorded for task %s: %v", task.ID, err)
|
||||
}
|
||||
a.mu.Lock()
|
||||
sent := a.prompt
|
||||
a.mu.Unlock()
|
||||
if string(b) != sent {
|
||||
t.Fatalf("recorded context differs from what was sent:\nrecorded:\n%s\nsent:\n%s", b, sent)
|
||||
}
|
||||
if !strings.Contains(string(b), "no, use b") {
|
||||
t.Fatalf("recorded context missing the standing decision:\n%s", b)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user