diff --git a/AUDIT.md b/AUDIT.md index 3b1cc6a..4cba454 100644 --- a/AUDIT.md +++ b/AUDIT.md @@ -882,6 +882,34 @@ and `TaskAmended` in `store.apply` now populate/update all four fields as amendable. Covered by `TestTaskAmendedAppliesAllFields` (`internal/store/store_test.go`). +## S11 — partial fix, 2026-07-27 (soft threshold) + +Of S11's four missing pieces (soft ~55% threshold, milestone rotation, thrash +detection, agent-initiated `ROTATE`), only the first is landed here. + +`Coordinator` gained a `Soft float64` field (default 0.55 via `soft()` when +unset, configurable through `ORCHESTRA_OCCUPANCY_SOFT`). Both `rotate()` (the +periodic ticker) and `TurnDecision` (the synchronous per-turn path) now check +occupancy against `Soft` before `Hard`: at or above soft but below hard, they +call the adapter's `HandoffRequester.RequestHandoff` once (same +"ask, don't invent" convention as the hard-threshold path, +`Session.HandoffRequested` guarding re-prompts) and — for `TurnDecision` — +return `prepare_handoff` without requiring a turn boundary, since this is +advisory: the agent keeps working, the task stays leased. Only once occupancy +clears `Hard` does the existing boundary-check/release path run. Covered by +`TestTurnDecision/"prepare_handoff at soft threshold, below hard, without a +turn boundary"` (internal/orchestrator/rotation_test.go), asserting a +handoff request fires and the task remains `StateLeased`. + +**Still open from S11:** milestone rotation, thrash detection (N failed test +runs / same file M times / identical tool calls as a circuit breaker with +`reason=thrash` and populated `dead_ends`), and agent-initiated `ROTATE`. All +three need either transcript/tool-call introspection this repo doesn't yet +have a source for, or an explicit in-band signal from the agent — bigger than +a threshold comparison, not attempted this pass. + +`go build ./...`, `go vet ./...`, `go test ./...` all pass. + ### Design consequences (not yet implemented) 1. **Percentages are a level, not a delta.** diff --git a/cmd/orchestra/main.go b/cmd/orchestra/main.go index a907e28..9685322 100644 --- a/cmd/orchestra/main.go +++ b/cmd/orchestra/main.go @@ -120,6 +120,9 @@ func main() { if v, parseErr := strconv.ParseFloat(os.Getenv("ORCHESTRA_OCCUPANCY_HARD"), 64); parseErr == nil && v > 0 && v < 1 { hard = v } + if v, parseErr := strconv.ParseFloat(os.Getenv("ORCHESTRA_OCCUPANCY_SOFT"), 64); parseErr == nil && v > 0 && v < 1 { + coordinator.Soft = v + } go func() { if monitorErr := coordinator.Monitor(context.Background(), hard, 30*time.Second); monitorErr != nil { log.Printf("orchestrator monitor: %v", monitorErr) diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go index dbff2b7..5d6e134 100644 --- a/internal/orchestrator/orchestrator.go +++ b/internal/orchestrator/orchestrator.go @@ -180,6 +180,23 @@ type Coordinator struct { // counterpart driven by the Face-B stop hook) evaluates the same // threshold rather than needing its own copy passed in by the caller. Hard float64 + // Soft is the advisory occupancy threshold (spec §5.3: "soft ~55% + // threshold") at which TurnDecision starts asking the agent to prepare a + // handoff — non-blocking, doesn't require a turn boundary — well before + // Hard forces one. Zero means "use the package default" (see + // defaultSoft), so existing callers that never set this field keep + // working unchanged. + Soft float64 +} + +// defaultSoft is used whenever Coordinator.Soft is unset (zero value). +const defaultSoft = 0.55 + +func (c *Coordinator) soft() float64 { + if c.Soft > 0 { + return c.Soft + } + return defaultSoft } type MonitorHealth struct { @@ -538,7 +555,23 @@ func (c *Coordinator) rotate(ctx context.Context, hard float64) { } reason := "threshold" occupancy, err := a.Occupancy(session) - if err != nil || occupancy < hard { + 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 } // Face B is treated as required, not best-effort (spec §5.2/§5.3): @@ -639,9 +672,28 @@ func (c *Coordinator) TurnDecision(ctx context.Context, taskID string) (string, if err != nil { return "", fmt.Errorf("orchestrator: occupancy: %w", err) } - if occupancy < c.Hard { + if occupancy < c.soft() { return TurnContinue, nil } + if occupancy < c.Hard { + // Soft threshold (§5.3): advisory only. Ask the agent to start + // preparing a handoff well before Hard forces one, but don't block + // the turn on a boundary check — the agent is free to keep working. + 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() + } + } + } + } + return TurnPrepareHandoff, nil + } if boundary, ok := a.(herdr.TurnBoundary); ok { atBoundary, boundaryErr := boundary.AtTurnBoundary(ctx, session) if boundaryErr != nil { diff --git a/internal/orchestrator/rotation_test.go b/internal/orchestrator/rotation_test.go index e580533..b67d5e4 100644 --- a/internal/orchestrator/rotation_test.go +++ b/internal/orchestrator/rotation_test.go @@ -218,6 +218,26 @@ func TestTurnDecision(t *testing.T) { } }) + t.Run("prepare_handoff at soft threshold, below hard, without a turn boundary", func(t *testing.T) { + a := &handoffRequestingAdapter{fakeAdapter: fakeAdapter{occupancy: .6, boundary: false}} + c, st, task := newCoordinator(&a.fakeAdapter) + c.Adapters = adapters{a} + decision, err := c.TurnDecision(context.Background(), task.ID) + if err != nil { + t.Fatal(err) + } + if decision != orchestrator.TurnPrepareHandoff { + t.Fatalf("decision=%q want %q", decision, orchestrator.TurnPrepareHandoff) + } + if a.requests == 0 { + t.Fatal("RequestHandoff was never invoked at the soft threshold") + } + got, ok := st.Task(task.ID) + if !ok || got.State != domain.StateLeased { + t.Fatalf("task state=%v ok=%v, want still leased (soft threshold is advisory)", got.State, ok) + } + }) + t.Run("prepare_handoff requests handoff without releasing", func(t *testing.T) { a := &handoffRequestingAdapter{fakeAdapter: fakeAdapter{occupancy: .95, boundary: true}} c, st, task := newCoordinator(&a.fakeAdapter) diff --git a/progress.md b/progress.md index 8e1918b..5cfbd58 100644 --- a/progress.md +++ b/progress.md @@ -273,8 +273,17 @@ Fixed so far: correct admit token accepted, same-id-different-token rejected as a hijack, same-id-same-token (legitimate restart) still succeeds. -Not yet started: Codex/opencode Stop-hook-equivalent scripts, S8, S11. See -`AUDIT.md` for the full plan. +- **S11 (partial — soft threshold only)** — added `Coordinator.Soft` + (default 0.55, `ORCHESTRA_OCCUPANCY_SOFT` override). Both `rotate()` and + `TurnDecision` now request a handoff once occupancy crosses `Soft`, well + before `Hard` forces one; `TurnDecision` returns `prepare_handoff` for this + advisory case without requiring a turn boundary, leaving the task leased. + Covered by a new `TestTurnDecision` subtest. **Not done:** milestone + rotation, thrash detection, agent-initiated `ROTATE` — see AUDIT.md's S11 + section for why those are bigger than a threshold check. + +Not yet started: Codex/opencode Stop-hook-equivalent scripts, S8, S11's +milestone/thrash/agent-`ROTATE` 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