7f12c7fc37
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>
121 lines
3.9 KiB
Go
121 lines
3.9 KiB
Go
package orchestrator_test
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"orchestra/internal/authz"
|
|
"orchestra/internal/domain"
|
|
"orchestra/internal/orchestrator"
|
|
"orchestra/internal/store"
|
|
)
|
|
|
|
func remoteLeased(t *testing.T) (*orchestrator.Coordinator, *store.Store, domain.Task) {
|
|
t.Helper()
|
|
s, err := store.Open(t.TempDir())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.Append(domain.Event{ID: domain.NewID(), Type: "TaskCreated", TaskID: "t1", Surface: string(authz.System), Payload: mustJSON(map[string]any{
|
|
"source": "gitea", "external_id": "381", "project": "p",
|
|
})}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
task := s.Tasks()[0]
|
|
if _, err := s.Lease(task.ID, "workpc-opencode", time.Minute); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// No Worktrees and no Adapters: the coordinator never touches a remote pane.
|
|
c := &orchestrator.Coordinator{Store: s, StatePath: t.TempDir() + "/sessions.json"}
|
|
got, _ := s.Task(task.ID)
|
|
return c, s, got
|
|
}
|
|
|
|
// A worker at a verified boundary reconciles through the coordinator and gets
|
|
// the decisions its session has not seen.
|
|
func TestRemoteTurnReconcilesAndReturnsUndeliveredDecisions(t *testing.T) {
|
|
c, s, task := remoteLeased(t)
|
|
reconciled := 0
|
|
c.ReconcileHumanInput = func(_ context.Context, taskID string) error {
|
|
reconciled++
|
|
if reconciled == 1 {
|
|
recordDecision(t, s, taskID, "d1", "no, use b")
|
|
}
|
|
return nil
|
|
}
|
|
verdict, decisions, err := c.RemoteTurn(context.Background(), task.ID, task.Lease.Epoch, orchestrator.TurnContinue, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if verdict != orchestrator.TurnContinue {
|
|
t.Fatalf("verdict = %q", verdict)
|
|
}
|
|
if len(decisions) != 1 || decisions[0].Value != "no, use b" {
|
|
t.Fatalf("decisions = %+v", decisions)
|
|
}
|
|
|
|
// Delivered once. The worker reports what it has shown, so the same
|
|
// decision is not returned twice.
|
|
_, again, err := c.RemoteTurn(context.Background(), task.ID, task.Lease.Epoch, orchestrator.TurnContinue, []string{decisions[0].ID})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(again) != 0 {
|
|
t.Fatalf("decision returned twice: %+v", again)
|
|
}
|
|
}
|
|
|
|
// Rotating sessions get no decisions: the successor picks them up at re-lease.
|
|
func TestRemoteTurnWithholdsDecisionsWhenRotating(t *testing.T) {
|
|
c, s, task := remoteLeased(t)
|
|
once := 0
|
|
c.ReconcileHumanInput = func(_ context.Context, taskID string) error {
|
|
once++
|
|
if once == 1 {
|
|
recordDecision(t, s, taskID, "d1", "no, use b")
|
|
}
|
|
return nil
|
|
}
|
|
for _, verdict := range []string{orchestrator.TurnRotateNow, orchestrator.TurnPrepareHandoff, orchestrator.TurnRefuse} {
|
|
got, decisions, err := c.RemoteTurn(context.Background(), task.ID, task.Lease.Epoch, verdict, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got != verdict || len(decisions) != 0 {
|
|
t.Fatalf("verdict %q returned %+v", verdict, decisions)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fenced like every other worker-driven call.
|
|
func TestRemoteTurnRefusesStaleEpoch(t *testing.T) {
|
|
c, _, task := remoteLeased(t)
|
|
if _, _, err := c.RemoteTurn(context.Background(), task.ID, "stale", orchestrator.TurnContinue, nil); !errors.Is(err, domain.ErrConflict) {
|
|
t.Fatalf("want ErrConflict, got %v", err)
|
|
}
|
|
if _, _, err := c.RemoteTurn(context.Background(), "missing", "e", orchestrator.TurnContinue, nil); !errors.Is(err, domain.ErrNotFound) {
|
|
t.Fatalf("want ErrNotFound, got %v", err)
|
|
}
|
|
}
|
|
|
|
// Same contract as the local boundary: a source failure is observable and the
|
|
// session keeps running.
|
|
func TestRemoteTurnReconcileFailureIsObservableNotFatal(t *testing.T) {
|
|
c, _, task := remoteLeased(t)
|
|
c.ReconcileHumanInput = func(context.Context, string) error { return context.DeadlineExceeded }
|
|
verdict, _, err := c.RemoteTurn(context.Background(), task.ID, task.Lease.Epoch, orchestrator.TurnContinue, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if verdict != orchestrator.TurnContinue {
|
|
t.Fatalf("verdict = %q", verdict)
|
|
}
|
|
h := c.MonitorHealth().Sessions[task.ID]
|
|
if !strings.Contains(h.LastError, "reconcile human input") {
|
|
t.Fatalf("failure not observable: %+v", h)
|
|
}
|
|
}
|