fix(herdr): close Phase 4 item 2 — actually ask the agent for a handoff
Release already validated and uploaded a §6.1 handoff, but nothing ever told the agent the .orchestra-handoff.json convention existed, so the file it waited on never got written. rotate() now prompts the agent once via a new optional herdr.HandoffRequester capability (CLIAdapter.RequestHandoff) when the file is missing, and defers Release until it appears, mirroring the .orchestra-report.md/B3 ask pattern rather than inventing a handoff. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W1rkJ2hBMybnJctPbcy4tT
This commit is contained in:
@@ -717,7 +717,28 @@ cooperation):
|
|||||||
record the deviation" as the alternative to half-implementing that. Taking
|
record the deviation" as the alternative to half-implementing that. Taking
|
||||||
that option rather than bolting on an undesigned notification path.
|
that option rather than bolting on an undesigned notification path.
|
||||||
|
|
||||||
**Still open from Phase 4**: item 2 (handoff production / stop-hook write of
|
**Phase 4 item 2 — closed 2026-07-27.** The remaining gap after B5 was not
|
||||||
`.orchestra-handoff.json` for non-Claude harnesses is untouched; Claude's
|
"which harness" (Release was always harness-agnostic) — it was that *nothing,
|
||||||
own stop-hook convention exists per B3/B5 but nothing yet drives Codex/
|
for any harness*, ever told the agent the `.orchestra-handoff.json` convention
|
||||||
opencode to write one).
|
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.
|
||||||
|
|||||||
@@ -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 {
|
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)
|
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":"<any stable string for this handoff>"},"anchor":{"git_sha":"<current HEAD via git rev-parse HEAD>","branch":"<current branch>","dirty":[{"path":"<repo-relative path>","sha256":"<sha256 of its current contents>"}, ...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
|
// 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
|
// 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
|
// HEAD, uploads it to CAS, and only then releases herdr's claim on the pane
|
||||||
|
|||||||
@@ -153,6 +153,10 @@ type Session struct {
|
|||||||
// session's lease was created — the immutable-spec hash continuity's
|
// session's lease was created — the immutable-spec hash continuity's
|
||||||
// pickup validation compares against on the next rotation (§6.2).
|
// pickup validation compares against on the next rotation (§6.2).
|
||||||
TaskFileSHA string `json:"task_file_sha,omitempty"`
|
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 {
|
func (c *Client) Prompt(ctx context.Context, pane, text string, wait time.Duration) error {
|
||||||
|
|||||||
@@ -499,6 +499,20 @@ func (c *Coordinator) rotate(ctx context.Context, hard float64) {
|
|||||||
} else {
|
} else {
|
||||||
c.recordTurnBoundaryDegraded()
|
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)
|
ref, err := a.Release(ctx, session)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"orchestra/internal/herdr"
|
"orchestra/internal/herdr"
|
||||||
"orchestra/internal/orchestrator"
|
"orchestra/internal/orchestrator"
|
||||||
"orchestra/internal/store"
|
"orchestra/internal/store"
|
||||||
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -396,3 +397,82 @@ func TestNoTurnBoundarySupportDegradesVisibly(t *testing.T) {
|
|||||||
t.Fatal("missing Face B support was not recorded as degraded")
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+16
-2
@@ -137,8 +137,22 @@ Fixed so far:
|
|||||||
half-wired, per AUDIT.md's explicit "delete and record the deviation"
|
half-wired, per AUDIT.md's explicit "delete and record the deviation"
|
||||||
option. New tests: `TestReleaseScratchCommitsDirtyFilesBeforeUpload`,
|
option. New tests: `TestReleaseScratchCommitsDirtyFilesBeforeUpload`,
|
||||||
`TestReleaseRefusesOnStaleDirtyFile` (internal/herdr/adapter_test.go).
|
`TestReleaseRefusesOnStaleDirtyFile` (internal/herdr/adapter_test.go).
|
||||||
**Not done:** Phase 4 item 2 (handoff production for Codex/opencode —
|
**Was still open:** Phase 4 item 2 — nothing drove *any* harness to write
|
||||||
nothing yet drives those harnesses to write `.orchestra-handoff.json`).
|
`.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),
|
Not yet started: B7 (quota projection has no producer),
|
||||||
Codex/opencode completion producers, the turn-decision endpoint, S2–S4,
|
Codex/opencode completion producers, the turn-decision endpoint, S2–S4,
|
||||||
|
|||||||
Reference in New Issue
Block a user