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:
kami
2026-07-27 22:10:22 +04:00
parent 62bb17e05d
commit 3fe3aee5b7
6 changed files with 161 additions and 6 deletions
+14
View File
@@ -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
+80
View File
@@ -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")
}
}