diff --git a/AUDIT.md b/AUDIT.md index 4cba454..4055f78 100644 --- a/AUDIT.md +++ b/AUDIT.md @@ -910,6 +910,43 @@ a threshold comparison, not attempted this 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) 1. **Percentages are a level, not a delta.** diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go index 5d6e134..6f8a88f 100644 --- a/internal/orchestrator/orchestrator.go +++ b/internal/orchestrator/orchestrator.go @@ -536,6 +536,22 @@ func (c *Coordinator) expire(ctx context.Context) ([]domain.Event, error) { } 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) { c.loadSessions() c.mu.Lock() @@ -554,45 +570,55 @@ func (c *Coordinator) rotate(ctx context.Context, hard float64) { continue } reason := "threshold" - occupancy, err := a.Occupancy(session) - if err != nil || occupancy < c.soft() { - continue - } - if occupancy < hard { - // Soft threshold (§5.3): request a handoff early, advisory 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() + // Agent-initiated ROTATE (§5.3): the agent itself wrote a handoff + // with reason=manual — "a coherent unit finished and the next is + // independent". That is the boundary signal in its own right; skip + // occupancy and the turn-boundary probe and go straight to release. + manual := handoffReason(session.Worktree) == "manual" + if manual { + reason = "manual" + } else { + occupancy, err := a.Occupancy(session) + if err != nil || occupancy < c.soft() { + continue + } + if occupancy < hard { + // Soft threshold (§5.3): request a handoff early, advisory + // 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 but fails to - // answer it blocks this tick's release rather than silently - // proceeding as if mid-turn interruption were safe. Only an - // adapter that genuinely does not implement TurnBoundary at all - // falls back to occupancy-only thresholding, and that fallback is - // recorded so it is observable (MonitorHealth.TurnBoundaryDegraded) - // instead of invisible. - if boundary, ok := a.(herdr.TurnBoundary); ok { - atBoundary, boundaryErr := boundary.AtTurnBoundary(ctx, session) - if boundaryErr != nil { + // Face B is treated as required, not best-effort (spec + // §5.2/§5.3): an adapter that supports the turn-boundary probe + // but fails to answer it blocks this tick's release rather than + // silently proceeding as if mid-turn interruption were safe. + // Only an adapter that genuinely does not implement + // TurnBoundary at all falls back to occupancy-only + // thresholding, and that fallback is recorded so it is + // observable (MonitorHealth.TurnBoundaryDegraded) instead of + // invisible. + if boundary, ok := a.(herdr.TurnBoundary); ok { + atBoundary, boundaryErr := boundary.AtTurnBoundary(ctx, session) + if boundaryErr != nil { + c.recordTurnBoundaryDegraded() + continue + } + if !atBoundary { + continue + } + } else { c.recordTurnBoundaryDegraded() - continue } - if !atBoundary { - continue - } - } else { - c.recordTurnBoundaryDegraded() } if requester, ok := a.(herdr.HandoffRequester); ok { 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 { 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) if err != nil { 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 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) if err != nil || ref == "" { 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. 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)} if err := c.Store.Append(e); err != nil { return TurnRefuse, nil diff --git a/internal/orchestrator/rotation_test.go b/internal/orchestrator/rotation_test.go index b67d5e4..becc3f2 100644 --- a/internal/orchestrator/rotation_test.go +++ b/internal/orchestrator/rotation_test.go @@ -257,6 +257,54 @@ func TestTurnDecision(t *testing.T) { 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: diff --git a/progress.md b/progress.md index 5cfbd58..c96584a 100644 --- a/progress.md +++ b/progress.md @@ -282,8 +282,21 @@ Fixed so far: rotation, thrash detection, agent-initiated `ROTATE` — see AUDIT.md's S11 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 -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 herdr instance at `192.168.1.105:9245` — verified by hand (raw JSON-RPC