feat(orchestrator): milestone rotation and thrash detection (S11)

Closes the last two S11 triggers. internal/herdr/activity.go normalizes
tool/function calls per harness (ClaudeActivity verified against the
existing transcript format, CodexActivity best-effort/unverified,
OpenCodeActivity refuses — no confirmed per-tool-call source exists) and
implements the three thrash rules plus a narrow milestone check
(successful git commit as the last call).

CLIAdapter.RequestHandoffReason asks the agent to write a handoff with
meta.reason set, same "ask, don't invent" pattern as the existing handoff/
report requests. rotate() and TurnDecision generalize the manual-bypass
shortcut to manual/milestone/thrash and request (never directly release)
on a detected trigger.

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-28 00:09:57 +04:00
parent d678959d65
commit c85fb81663
7 changed files with 978 additions and 16 deletions
+61 -3
View File
@@ -174,7 +174,7 @@ worth a deliberate decision rather than an accident.)
| S8 | §3.1 | No compensation-event mechanism exists. Append-only holds, but the spec's *correction* path ("a compensating event is appended") has no implementation. |
| S9 | `main.go:482` | Two independent lease-expiry loops (`main.go` 1s ticker and `Coordinator.expire` 30s) race on the same reclaim. Harmless today only because version CAS rejects the loser. |
| S10 | federation | `federation.Registry.Register` accepts a self-declared `id` + self-chosen `token` from any caller — registration is admission-control-free. |
| S11 | §5.3 | Thrash detection, the soft ~55% threshold, milestone rotation, and agent-initiated `ROTATE` are all absent. Only the hard threshold exists, and it's unreachable (B1). |
| S11 | §5.3 | ~~Thrash detection, the soft ~55% threshold, milestone rotation, and agent-initiated `ROTATE` are all absent. Only the hard threshold exists, and it's unreachable (B1).~~ Closed 2026-07-28 — soft threshold, agent-initiated `ROTATE`, milestone, and thrash detection all landed. Codex's activity parser is unverified against a live rollout; opencode has no verified tool-call source and refuses rather than guess. |
---
@@ -942,8 +942,66 @@ boundary=false (both would refuse/continue under every other path), a
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.
**Still open from S11 (at this point):** 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.
## S11 — milestone + thrash detection, closed, 2026-07-28
The transcript/tool-call source both preceding entries said this repo
lacked now exists: `internal/herdr/activity.go`. `ToolCall{Name, Kind, Key,
Success, IsTest}` normalizes one tool/function call, harness-agnostically.
`ClaudeActivity` reads the same transcript file `ClaudeSessionFile`/
`ClaudeUsage` already open, pairing `tool_use`/`tool_result` blocks by
`tool_use_id` (an unresolved tool_use is dropped, not reported — the session
is still mid-turn). `CodexActivity` mirrors the `payload.type` wrapper
`CodexUsage` already reads, parsing `function_call`/`function_call_output`
pairs — **explicitly marked best-effort/unverified**, same bar Phase 0 set
for herdr methods: not yet checked against a live rollout. `OpenCodeActivity`
**refuses outright** — opencode's on-disk message storage is only confirmed
to carry aggregate token counts, not per-tool-call records, so this doesn't
guess at an unconfirmed shape (same convention as
`CLIAdapter.resolveSessionFile`'s opencode case).
`DetectThrash(calls, ThrashConfig)` implements all three §5.3 rules — N
consecutive failed test runs, the same file edited M times (edit tools only,
explicitly excluding `Read`), and an identical tool call repeated K times
back-to-back (excluding test re-runs and file reads, since those are
expected, not thrashing). Two false positives were caught by tests before
being trusted: rule 3 initially tripped on repeated test-command re-runs and
on repeated `Read`s of the same file; both fixed by narrowing rule 3's
"relevant" calls to non-test commands and edit-tool file calls only.
`DetectMilestone(calls)` is deliberately narrow — only "the last call was a
successful `git commit`" — fuzzier definitions (a passing suite, a finished
subtask) were not guessed at.
New `ReasonedHandoffRequester`/`CLIAdapter.RequestHandoffReason`
(adapter.go) — like `RequestHandoff` but names the specific reason
(thrash's dead ends, or the milestone framing) and asks the agent to write
`meta.reason` accordingly, continuing the "ask, don't invent" pattern
already used for `.orchestra-report.md` and `.orchestra-handoff.json`.
`Coordinator.rotate()` and `TurnDecision` generalize the existing
"`reason=manual` bypasses occupancy" shortcut to `manual`/`milestone`/
`thrash` alike, and both call new `checkActivityTriggers` (skips cleanly if
the adapter doesn't implement `ActivityReader`) before falling through to
occupancy/soft/hard; a hit calls `requestReasonedHandoff` (same
`HandoffRequested`-guarded ask-once pattern the occupancy path already
uses) — request only, never release, matching the soft-threshold path's
`prepare_handoff` behavior.
Covered by `internal/herdr/activity_test.go` (parser + all three detector
rules, including the two cases above that caught real bugs) and
`internal/orchestrator/rotation_test.go`'s
`TestActivityTriggersRequestReasonedHandoffWithoutReleasing` (thrash and
milestone each request-without-releasing via `TurnDecision`; a
thrash-reasoned handoff already on disk bypasses occupancy and releases;
`rotate()`'s periodic path does the same request-without-release).
**Still open:** verifying `CodexActivity`'s parser shape against a live
rollout, and finding a real per-tool-call source for opencode (currently
refuses rather than guessing).
`go build ./...`, `go vet ./...`, `go test ./...` all pass.