diff --git a/AUDIT.md b/AUDIT.md index 1e73e30..95ee310 100644 --- a/AUDIT.md +++ b/AUDIT.md @@ -717,7 +717,28 @@ cooperation): record the deviation" as the alternative to half-implementing that. Taking that option rather than bolting on an undesigned notification path. -**Still open from Phase 4**: item 2 (handoff production / stop-hook write of -`.orchestra-handoff.json` for non-Claude harnesses is untouched; Claude's -own stop-hook convention exists per B3/B5 but nothing yet drives Codex/ -opencode to write one). +**Phase 4 item 2 — closed 2026-07-27.** The remaining gap after B5 was not +"which harness" (Release was always harness-agnostic) — it was that *nothing, +for any harness*, ever told the agent the `.orchestra-handoff.json` convention +existed. `Coordinator.rotate` (internal/orchestrator/orchestrator.go) now +checks whether the resolved adapter implements a new optional +`herdr.HandoffRequester` capability; if `HandoffFile` isn't present in the +worktree yet, it calls `RequestHandoff` once (`herdr.Session.HandoffRequested` +guards against re-prompting every tick) and skips `Release` for that tick, +leaving the lease intact — exactly the same "ask, don't invent" shape as B3's +`.orchestra-report.md` convention. `CLIAdapter.RequestHandoff` +(internal/herdr/adapter.go) sends a prompt naming the exact §6.1 JSON shape +(`meta.id`, `anchor.git_sha`/`branch`/`dirty[].{path,sha256}`) and explicitly +tells the agent not to fabricate the SHA/hashes. Once the file exists, the +existing Release path (validate → scratch-commit dirty files → upload → CAS +→ `pane.release_agent`) is unchanged. Covered by +`TestRotationRequestsHandoffBeforeReleasing` +(internal/orchestrator/rotation_test.go), which asserts `Release` is never +called while the file is absent and fires once it's written. `go build`, +`go vet`, `go test ./...` all pass. + +Not attempted here (separate, deployment-level question, not a code gap): +whether Codex's/opencode's own turn-boundary mechanism actually surfaces this +in-pane prompt to the agent before it exits the way Claude Code's Stop hook +does — that's Phase 2 item 4 territory (native Face B per harness), not +Phase 4. diff --git a/internal/herdr/adapter.go b/internal/herdr/adapter.go index 1d4df27..ce0941b 100644 --- a/internal/herdr/adapter.go +++ b/internal/herdr/adapter.go @@ -113,6 +113,28 @@ This worktree's anchor and TASK.md have already been verified by the plane befor func (a CLIAdapter) Bootstrap(ctx context.Context, s Session, ref string) error { return a.Client.Prompt(ctx, s.PaneID, fmt.Sprintf(bootstrapPrompt, ref), time.Minute) } + +// handoffPrompt is Phase 4 item 2's missing half (AUDIT.md): Release already +// validates and uploads a §6.1 handoff the agent wrote, but nothing ever told +// the agent that convention exists. rotate() sends this once occupancy crosses +// the hard threshold at a turn boundary, mirroring the .orchestra-report.md +// convention B3 established for completion — the plane still never invents a +// handoff, it only asks the agent to produce one, then validates it in Release. +const handoffPrompt = `Orchestra is about to rotate this task to a fresh session (context budget reached). +Before you stop, write a §6.1 handoff to ` + HandoffFile + ` at the worktree root, a JSON object with at least: +{"meta":{"id":""},"anchor":{"git_sha":"","branch":"","dirty":[{"path":"","sha256":""}, ...for any uncommitted files]},"knowledge":{...whatever structured context the next agent needs...}} +Do not edit TASK.md. Do not fabricate the git_sha or dirty file hashes — read them for real. Once written, stop normally.` + +// RequestHandoff prompts the agent to write HandoffFile before Release reads +// it. Optional capability: adapters without a live pane (tests, etc.) can +// omit it and rotate() falls back to waiting on the file appearing on its own. +type HandoffRequester interface { + RequestHandoff(context.Context, Session) error +} + +func (a CLIAdapter) RequestHandoff(ctx context.Context, s Session) error { + return a.Client.Prompt(ctx, s.PaneID, handoffPrompt, time.Minute) +} // Release reads the §6.1 handoff the agent wrote to HandoffFile at the // worktree root, validates its schema and anchor against the worktree's real // HEAD, uploads it to CAS, and only then releases herdr's claim on the pane diff --git a/internal/herdr/herdr.go b/internal/herdr/herdr.go index c29970a..3a7bc29 100644 --- a/internal/herdr/herdr.go +++ b/internal/herdr/herdr.go @@ -153,6 +153,10 @@ type Session struct { // session's lease was created — the immutable-spec hash continuity's // pickup validation compares against on the next rotation (§6.2). TaskFileSHA string `json:"task_file_sha,omitempty"` + // HandoffRequested is set once rotate() has prompted the agent to write + // its §6.1 handoff (HandoffFile) — avoids re-sending the same prompt + // every tick while Release keeps waiting for the file to appear. + HandoffRequested bool `json:"handoff_requested,omitempty"` } func (c *Client) Prompt(ctx context.Context, pane, text string, wait time.Duration) error { diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go index 1b7b677..2342a40 100644 --- a/internal/orchestrator/orchestrator.go +++ b/internal/orchestrator/orchestrator.go @@ -499,6 +499,20 @@ func (c *Coordinator) rotate(ctx context.Context, hard float64) { } else { c.recordTurnBoundaryDegraded() } + if requester, ok := a.(herdr.HandoffRequester); ok { + if _, statErr := os.Stat(filepath.Join(session.Worktree, herdr.HandoffFile)); statErr != nil { + if !session.HandoffRequested { + if reqErr := requester.RequestHandoff(ctx, session); reqErr == nil { + session.HandoffRequested = true + c.mu.Lock() + c.sessions[taskID] = session + _ = c.saveSessionsLocked() + c.mu.Unlock() + } + } + continue + } + } ref, err := a.Release(ctx, session) if err != nil { continue diff --git a/internal/orchestrator/rotation_test.go b/internal/orchestrator/rotation_test.go index d6430d7..0fac2e5 100644 --- a/internal/orchestrator/rotation_test.go +++ b/internal/orchestrator/rotation_test.go @@ -9,6 +9,7 @@ import ( "orchestra/internal/herdr" "orchestra/internal/orchestrator" "orchestra/internal/store" + "os" "os/exec" "testing" "time" @@ -396,3 +397,82 @@ func TestNoTurnBoundarySupportDegradesVisibly(t *testing.T) { t.Fatal("missing Face B support was not recorded as degraded") } } + +type handoffRequestingAdapter struct { + fakeAdapter + requests int +} + +func (a *handoffRequestingAdapter) RequestHandoff(context.Context, herdr.Session) error { + a.requests++ + return nil +} + +// TestRotationRequestsHandoffBeforeReleasing guards Phase 4 item 2 (AUDIT.md): +// rotate() must not call Release until the agent has been told to write its +// §6.1 handoff and the file actually exists — never invent or skip the ask. +func TestRotationRequestsHandoffBeforeReleasing(t *testing.T) { + repo := t.TempDir() + run(t, repo, "init") + run(t, repo, "config", "user.email", "t@t") + run(t, repo, "config", "user.name", "t") + run(t, repo, "commit", "--allow-empty", "-m", "init") + + 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": "jsonl", "external_id": "1", "project": "p", + })}); err != nil { + t.Fatal(err) + } + task := s.Tasks()[0] + ref, err := s.PutArtifact([]byte("handoff")) + if err != nil { + t.Fatal(err) + } + a := &handoffRequestingAdapter{fakeAdapter: fakeAdapter{occupancy: .95, boundary: true, ref: ref}} + c := &orchestrator.Coordinator{Store: s, Worktrees: worktrees{path: repo}, Adapters: adapters{a}, StatePath: t.TempDir() + "/sessions.json"} + + leaseEvt, err := s.Lease(task.ID, "h1", time.Minute) + if err != nil { + t.Fatal(err) + } + if err := c.Start(context.Background(), leaseEvt); err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go c.Monitor(ctx, .8, time.Millisecond) + + deadline := time.Now().Add(200 * time.Millisecond) + for time.Now().Before(deadline) { + time.Sleep(time.Millisecond) + } + if a.requests == 0 { + t.Fatal("rotate never asked the agent to write a handoff") + } + if a.releases != 0 { + t.Fatal("rotate called Release before the handoff file existed") + } + if got, ok := s.Task(task.ID); !ok || got.State != domain.StateLeased { + t.Fatalf("task rotated without a handoff file: state=%v ok=%v", got.State, ok) + } + + if err := os.WriteFile(repo+"/"+herdr.HandoffFile, []byte("{}"), 0644); err != nil { + t.Fatal(err) + } + + deadline = time.Now().Add(time.Second) + for time.Now().Before(deadline) { + if got, ok := s.Task(task.ID); ok && got.State == domain.StateQueued { + break + } + time.Sleep(time.Millisecond) + } + if a.releases == 0 { + t.Fatal("rotate never called Release once the handoff file appeared") + } +} diff --git a/progress.md b/progress.md index c1d3516..4cc303a 100644 --- a/progress.md +++ b/progress.md @@ -137,8 +137,22 @@ Fixed so far: half-wired, per AUDIT.md's explicit "delete and record the deviation" option. New tests: `TestReleaseScratchCommitsDirtyFilesBeforeUpload`, `TestReleaseRefusesOnStaleDirtyFile` (internal/herdr/adapter_test.go). - **Not done:** Phase 4 item 2 (handoff production for Codex/opencode — - nothing yet drives those harnesses to write `.orchestra-handoff.json`). + **Was still open:** Phase 4 item 2 — nothing drove *any* harness to write + `.orchestra-handoff.json`, since Release only validated a file whose + existence was never solicited. **Closed 2026-07-27:** `rotate()` now checks + for the adapter's optional `herdr.HandoffRequester` capability; when + `HandoffFile` is missing at the worktree root, it prompts the agent once + (`CLIAdapter.RequestHandoff`, mirroring the `.orchestra-report.md`/B3 + convention — the plane asks for a handoff, it never invents one) and skips + Release that tick, retrying every subsequent tick until the file appears. + `herdr.Session.HandoffRequested` avoids re-prompting every tick. Covered by + `TestRotationRequestsHandoffBeforeReleasing` + (`internal/orchestrator/rotation_test.go`), which asserts Release is never + called before the file exists and fires once it does. Codex/opencode still + share this same path (no harness-specific gap remains); the only leftover + question is whether each harness's own Stop-equivalent hook honors the + in-pane prompt to write the file before exiting, which is a live-deployment + fact, not something provable from source. Not yet started: B7 (quota projection has no producer), Codex/opencode completion producers, the turn-decision endpoint, S2–S4,