feat(orchestrator): agent-initiated ROTATE via handoff reason=manual (S11)

rotate() and TurnDecision now check the agent's own handoff for
meta.reason=="manual" before evaluating occupancy/turn-boundary — per
spec §5.3, that reason is itself the boundary signal ("a coherent unit
finished and the next is independent"), so it bypasses both checks and
releases immediately. Extracted the shared release-and-anchor-certify
tail into Coordinator.finishRelease so the manual path gets the same
anchor safety guarantee as the threshold path.

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 23:44:04 +04:00
parent 84d09ce114
commit 86cc0b9276
4 changed files with 175 additions and 36 deletions
+37
View File
@@ -910,6 +910,43 @@ a threshold comparison, not attempted this pass.
`go build ./...`, `go vet ./...`, `go test ./...` all pass. `go build ./...`, `go vet ./...`, `go test ./...` all pass.
## S11 — agent-initiated ROTATE, landed 2026-07-27
The last of S11's four pieces that fits an in-band signal (milestone and
thrash both need transcript/tool-call introspection this repo has no source
for — still open). §5.3: *"agent-initiated `ROTATE` → emitted when a coherent
unit finishes and the next is independent."* `continuity.Handoff`'s schema
already anticipated this — `reasons["manual"]` was valid since B5 landed, but
nothing ever checked for it.
New `handoffReason(worktree)` (`internal/orchestrator/orchestrator.go`) reads
`HandoffFile` if present and returns its decoded `meta.reason`, or `""` if
absent/invalid — never `"manual"` on a bad read, so a malformed handoff can't
accidentally short-circuit rotation. Both `rotate()` and `TurnDecision` check
this first: if the agent already wrote a handoff with `reason=manual`, that
**is** the boundary signal, so occupancy and the turn-boundary probe are
skipped entirely and release proceeds straight away with `reason: "manual"`
in the emitted `TaskReleased`. Below that check, the existing
threshold/soft/hard logic is unchanged.
Extracted the release tail (`Release` → anchor certification → `TaskReleased`
append) shared between the threshold and manual paths in `TurnDecision` into
`Coordinator.finishRelease`, since the manual path needed to reach the exact
same anchor-safety logic (never emit a payload with an uncertifiable anchor)
without going through occupancy/boundary gating first.
Covered by `TestTurnDecision/"manual reason bypasses occupancy and turn
boundary"` (`internal/orchestrator/rotation_test.go`): occupancy=0,
boundary=false (both would refuse/continue under every other path), a
`.orchestra-handoff.json` with `reason=manual` written directly to the
worktree, and asserts `TurnRotateNow` + `Release` invoked + task state
`StateQueued`.
**Still open from S11:** milestone rotation and thrash detection — both need
a source of transcript/tool-call data this repo doesn't have yet.
`go build ./...`, `go vet ./...`, `go test ./...` all pass.
### Design consequences (not yet implemented) ### Design consequences (not yet implemented)
1. **Percentages are a level, not a delta.** 1. **Percentages are a level, not a delta.**
+76 -35
View File
@@ -536,6 +536,22 @@ func (c *Coordinator) expire(ctx context.Context) ([]domain.Event, error) {
} }
return events, nil return events, nil
} }
// handoffReason reads HandoffFile from the worktree, if present, and returns
// its meta.reason ("threshold|milestone|thrash|manual" per §6.1). An unread­
// able or invalid file returns "" — callers treat that as "no signal yet",
// never as "manual".
func handoffReason(worktree string) string {
b, err := os.ReadFile(filepath.Join(worktree, herdr.HandoffFile))
if err != nil {
return ""
}
h, err := continuity.Decode(b)
if err != nil {
return ""
}
return h.Meta.Reason
}
func (c *Coordinator) rotate(ctx context.Context, hard float64) { func (c *Coordinator) rotate(ctx context.Context, hard float64) {
c.loadSessions() c.loadSessions()
c.mu.Lock() c.mu.Lock()
@@ -554,45 +570,55 @@ func (c *Coordinator) rotate(ctx context.Context, hard float64) {
continue continue
} }
reason := "threshold" reason := "threshold"
occupancy, err := a.Occupancy(session) // Agent-initiated ROTATE (§5.3): the agent itself wrote a handoff
if err != nil || occupancy < c.soft() { // with reason=manual — "a coherent unit finished and the next is
continue // independent". That is the boundary signal in its own right; skip
} // occupancy and the turn-boundary probe and go straight to release.
if occupancy < hard { manual := handoffReason(session.Worktree) == "manual"
// Soft threshold (§5.3): request a handoff early, advisory only if manual {
// — no release, no turn-boundary requirement. reason = "manual"
if requester, ok := a.(herdr.HandoffRequester); ok { } else {
if _, statErr := os.Stat(filepath.Join(session.Worktree, herdr.HandoffFile)); statErr != nil && !session.HandoffRequested { occupancy, err := a.Occupancy(session)
if reqErr := requester.RequestHandoff(ctx, session); reqErr == nil { if err != nil || occupancy < c.soft() {
session.HandoffRequested = true continue
c.mu.Lock() }
c.sessions[taskID] = session if occupancy < hard {
_ = c.saveSessionsLocked() // Soft threshold (§5.3): request a handoff early, advisory
c.mu.Unlock() // only — no release, no turn-boundary requirement.
if requester, ok := a.(herdr.HandoffRequester); ok {
if _, statErr := os.Stat(filepath.Join(session.Worktree, herdr.HandoffFile)); statErr != nil && !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
} }
continue // Face B is treated as required, not best-effort (spec
} // §5.2/§5.3): an adapter that supports the turn-boundary probe
// Face B is treated as required, not best-effort (spec §5.2/§5.3): // but fails to answer it blocks this tick's release rather than
// an adapter that supports the turn-boundary probe but fails to // silently proceeding as if mid-turn interruption were safe.
// answer it blocks this tick's release rather than silently // Only an adapter that genuinely does not implement
// proceeding as if mid-turn interruption were safe. Only an // TurnBoundary at all falls back to occupancy-only
// adapter that genuinely does not implement TurnBoundary at all // thresholding, and that fallback is recorded so it is
// falls back to occupancy-only thresholding, and that fallback is // observable (MonitorHealth.TurnBoundaryDegraded) instead of
// recorded so it is observable (MonitorHealth.TurnBoundaryDegraded) // invisible.
// instead of invisible. if boundary, ok := a.(herdr.TurnBoundary); ok {
if boundary, ok := a.(herdr.TurnBoundary); ok { atBoundary, boundaryErr := boundary.AtTurnBoundary(ctx, session)
atBoundary, boundaryErr := boundary.AtTurnBoundary(ctx, session) if boundaryErr != nil {
if boundaryErr != nil { c.recordTurnBoundaryDegraded()
continue
}
if !atBoundary {
continue
}
} else {
c.recordTurnBoundaryDegraded() c.recordTurnBoundaryDegraded()
continue
} }
if !atBoundary {
continue
}
} else {
c.recordTurnBoundaryDegraded()
} }
if requester, ok := a.(herdr.HandoffRequester); ok { if requester, ok := a.(herdr.HandoffRequester); ok {
if _, statErr := os.Stat(filepath.Join(session.Worktree, herdr.HandoffFile)); statErr != nil { if _, statErr := os.Stat(filepath.Join(session.Worktree, herdr.HandoffFile)); statErr != nil {
@@ -668,6 +694,12 @@ func (c *Coordinator) TurnDecision(ctx context.Context, taskID string) (string,
if err != nil { if err != nil {
return "", fmt.Errorf("orchestrator: adapter: %w", err) return "", fmt.Errorf("orchestrator: adapter: %w", err)
} }
// Agent-initiated ROTATE (§5.3): a handoff already written with
// reason=manual is itself the boundary signal — skip occupancy and the
// turn-boundary probe and release immediately.
if handoffReason(session.Worktree) == "manual" {
return c.finishRelease(ctx, taskID, task, session, a, "manual")
}
occupancy, err := a.Occupancy(session) occupancy, err := a.Occupancy(session)
if err != nil { if err != nil {
return "", fmt.Errorf("orchestrator: occupancy: %w", err) return "", fmt.Errorf("orchestrator: occupancy: %w", err)
@@ -720,6 +752,15 @@ func (c *Coordinator) TurnDecision(ctx context.Context, taskID string) (string,
return TurnPrepareHandoff, nil return TurnPrepareHandoff, nil
} }
} }
return c.finishRelease(ctx, taskID, task, session, a, "threshold")
}
// finishRelease runs the common release tail shared by TurnDecision's
// threshold path and its agent-initiated-ROTATE (reason=manual) shortcut:
// call Adapter.Release, certify the anchor against the real worktree HEAD,
// and emit TaskReleased. Any failure refuses rather than emitting a
// TaskReleased payload that would fail validation and strand the session.
func (c *Coordinator) finishRelease(ctx context.Context, taskID string, task domain.Task, session herdr.Session, a herdr.Adapter, reason string) (string, error) {
ref, err := a.Release(ctx, session) ref, err := a.Release(ctx, session)
if err != nil || ref == "" { if err != nil || ref == "" {
return TurnRefuse, nil return TurnRefuse, nil
@@ -730,7 +771,7 @@ func (c *Coordinator) TurnDecision(ctx context.Context, taskID string) (string,
// invalid TaskReleased payload, same as rotate()'s bare continue. // invalid TaskReleased payload, same as rotate()'s bare continue.
return TurnRefuse, nil return TurnRefuse, nil
} }
b, _ := json.Marshal(map[string]string{"handoff_ref": ref, "reason": "threshold", "anchor_sha": anchorSHA}) b, _ := json.Marshal(map[string]string{"handoff_ref": ref, "reason": reason, "anchor_sha": anchorSHA})
e := domain.Event{ID: domain.NewID(), Type: "TaskReleased", TaskID: taskID, Version: task.Version + 1, Payload: b, Surface: string(authz.System)} e := domain.Event{ID: domain.NewID(), Type: "TaskReleased", TaskID: taskID, Version: task.Version + 1, Payload: b, Surface: string(authz.System)}
if err := c.Store.Append(e); err != nil { if err := c.Store.Append(e); err != nil {
return TurnRefuse, nil return TurnRefuse, nil
+48
View File
@@ -257,6 +257,54 @@ func TestTurnDecision(t *testing.T) {
t.Fatalf("task state=%v ok=%v, want leased", got.State, ok) t.Fatalf("task state=%v ok=%v, want leased", got.State, ok)
} }
}) })
// Agent-initiated ROTATE (spec §5.3): a handoff the agent wrote with
// reason=manual is itself the boundary signal ("a coherent unit
// finished and the next is independent") — it must release immediately,
// bypassing occupancy and the turn-boundary probe entirely, not wait for
// either to agree.
t.Run("manual reason bypasses occupancy and turn boundary", func(t *testing.T) {
a := &fakeAdapter{occupancy: 0, boundary: false}
c, st, task := newCoordinator(a)
artifactRef, err := st.PutArtifact([]byte("handoff"))
if err != nil {
t.Fatal(err)
}
a.ref = artifactRef
head, err := herdr.HeadSHA(repo)
if err != nil {
t.Fatal(err)
}
handoff := map[string]any{
"meta": map[string]any{"id": "h2", "reason": "manual", "rotation_index": 0},
"anchor": map[string]any{"git_sha": head, "branch": "orchestra/t1"},
"goal": "g", "done_when": []string{"x"}, "action": "prompt", "command": "go test ./...",
}
b, err := json.Marshal(handoff)
if err != nil {
t.Fatal(err)
}
handoffPath := repo + "/" + herdr.HandoffFile
if err := os.WriteFile(handoffPath, b, 0o644); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.Remove(handoffPath) })
decision, err := c.TurnDecision(context.Background(), task.ID)
if err != nil {
t.Fatal(err)
}
if decision != orchestrator.TurnRotateNow {
t.Fatalf("decision=%q want %q", decision, orchestrator.TurnRotateNow)
}
if a.releases == 0 {
t.Fatal("Release was never invoked for an agent-initiated manual rotate")
}
got, ok := st.Task(task.ID)
if !ok || got.State != domain.StateQueued {
t.Fatalf("task state=%v ok=%v, want queued", got.State, ok)
}
})
} }
// TestStartBlocksOnInvalidPickup guards AUDIT.md's B6/Phase 4 item 4: // TestStartBlocksOnInvalidPickup guards AUDIT.md's B6/Phase 4 item 4:
+14 -1
View File
@@ -282,8 +282,21 @@ Fixed so far:
rotation, thrash detection, agent-initiated `ROTATE` — see AUDIT.md's S11 rotation, thrash detection, agent-initiated `ROTATE` — see AUDIT.md's S11
section for why those are bigger than a threshold check. section for why those are bigger than a threshold check.
- **S11 (agent-initiated ROTATE)** — the schema already accepted
`meta.reason: "manual"` (since B5) but nothing checked for it. New
`handoffReason(worktree)` reads a written `.orchestra-handoff.json` and, if
its reason is `"manual"`, both `rotate()` and `TurnDecision` skip occupancy
and the turn-boundary probe entirely and release immediately — the agent's
own handoff *is* the boundary signal per §5.3 ("a coherent unit finished and
the next is independent"). Extracted the shared release-and-certify tail
into `Coordinator.finishRelease` so the manual path reaches the same
anchor-safety guarantee as the threshold path. Covered by a new
`TestTurnDecision` subtest (occupancy=0, boundary=false — every other path
would refuse or continue — asserting rotation happens anyway once a
`reason=manual` handoff exists).
Not yet started: Codex/opencode Stop-hook-equivalent scripts, S8, S11's Not yet started: Codex/opencode Stop-hook-equivalent scripts, S8, S11's
milestone/thrash/agent-`ROTATE` pieces. See `AUDIT.md` for the full plan. milestone/thrash pieces. See `AUDIT.md` for the full plan.
**Phase 0 done (2026-07-27):** this box has live TCP reachability to the real **Phase 0 done (2026-07-27):** this box has live TCP reachability to the real
herdr instance at `192.168.1.105:9245` — verified by hand (raw JSON-RPC herdr instance at `192.168.1.105:9245` — verified by hand (raw JSON-RPC