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
+94 -2
View File
@@ -307,8 +307,100 @@ Fixed so far:
`queued`, an unknown-`corrects` rejection, and both events surviving a
snapshot+replay reopen.
Not yet started: Codex/opencode Stop-hook-equivalent scripts, S11's
milestone/thrash pieces. See `AUDIT.md` for the full plan.
- **Codex/opencode Stop-hook-equivalent scripts** — the server side
(`/v1/harness/turn`, `/v1/harness/complete` dispatching by `harness`) has
existed since the turn-decision endpoint and the codex/opencode dispatch
commit, but nothing called it for either harness: both lack a native Stop
hook, so `orchestra-stop.sh`'s per-turn-boundary call never had an
equivalent. Added `deploy/hooks/orchestra-codex-poll.sh` and
`deploy/hooks/orchestra-opencode-poll.sh` — background poll loops (default
60s, `ORCHESTRA_POLL_INTERVAL`) meant to run alongside the harness process
in its pane. Each tick: find the newest session-state file (codex: newest
`rollout-*.jsonl` under `~/.codex/sessions`, matching `CodexActiveUsage`'s
own "most recently touched" heuristic; opencode: newest file under
`~/.local/share/opencode/storage/message/`, the same backstop
`OpenCodeUsage` reads, since a session id for the SSE fast path isn't
reliably available outside the opencode process itself); if
`.orchestra-report.md` exists, POST it plus the discovered path to
`/v1/harness/complete` with the right `harness` value and remove the
marker; otherwise POST task id to `/v1/harness/turn` and log (not act on)
`refuse`/`rotate_now` — unlike the Claude Stop hook's `exit 2`, there's no
turn boundary to refuse *at* from outside the harness process for either
of these, so this is advisory-only until real app-server/SSE integration
exists. No Go changes; nothing new to build/vet/test.
- **S11 (milestone + thrash detection) — closed, 2026-07-28.** The last two
of S11's four rotation triggers, previously deferred as needing "transcript/
tool-call introspection this repo doesn't have a source for" — that source
now exists. New `internal/herdr/activity.go`:
- `ToolCall{Name, Kind, Key, Success, IsTest}` normalizes one tool/function
call across harnesses (`Kind` is `"file"` or `"command"`, `Key` the path
or shell command — the same two field names, `file_path`/`command`, both
Claude's `tool_use.input` and Codex's function-call `arguments` use).
- `ClaudeActivity` parses the same transcript `ClaudeUsage`/`ClaudeSessionFile`
already open, pairing `tool_use`/`tool_result` blocks by `tool_use_id`
(an unresolved tool_use — mid-turn — is dropped, not reported).
- `CodexActivity` parses the rollout's `function_call`/`function_call_output`
payload pairs, mirroring `CodexUsage`'s existing `payload.type` wrapper —
**explicitly marked best-effort/unverified** in its doc comment, same bar
AUDIT.md's Phase 0 set for herdr methods: this hasn't been checked against
a live rollout, only against `CodexUsage`'s already-confirmed shape.
- `OpenCodeActivity` **refuses outright** rather than guess: opencode's
on-disk message storage is only confirmed to carry aggregate token counts
(what `OpenCodeUsage` already reads), not per-tool-call records, so
fabricating a parser against an unconfirmed shape would repeat the exact
mistake this repo's own audit exists to catch.
- `DetectThrash(calls, ThrashConfig)` implements all three §5.3 rules — N
consecutive failed test runs (regex-matched shell commands: go/pytest/npm/
yarn/cargo/make/jest/mvn/rspec/ctest), the same file edited M times
(`Edit`/`Write`/`MultiEdit`/`NotebookEdit`, explicitly *not* `Read`
caught by a test that initially failed because rule 3 didn't exclude
reads), and the identical tool call repeated K times back-to-back
(explicitly excluding test re-runs and file reads, since re-running the
same test after a fix attempt is expected behavior, not thrashing — caught
by another initially-failing test). Each rule that trips populates a
`continuity.DeadEnd`, handed straight to the new prompt below rather than
left for the agent to invent. Defaults: 3/5/4, overridable via
`Coordinator.Thrash`.
- `DetectMilestone(calls)` — deliberately narrow: only "the most recent
call was a successful `git commit`". Spec-fuzzier definitions (a passing
test suite, a finished subtask) were **not** guessed at; same restraint
the rest of this audit has applied to unverified behavior.
- `CLIAdapter.Activity` (adapter.go) resolves the session file the same way
`Occupancy` does and dispatches to the right parser; `ActivityReader` is
an optional capability like every other Face-B interface in this package.
- New `ReasonedHandoffRequester`/`CLIAdapter.RequestHandoffReason` — like
`RequestHandoff` but names *why* (thrash's specific dead ends, or the
milestone reasoning) instead of the generic "context budget reached"
framing, and asks the agent to write `meta.reason` accordingly.
- `Coordinator.rotate()` and `TurnDecision()`: the existing "reason=manual
bypasses occupancy" shortcut generalized to `manual`/`milestone`/`thrash`
alike — once a handoff exists carrying one of these reasons, that **is**
the boundary signal, same treatment as agent-initiated ROTATE already
got. Before falling through to occupancy/soft/hard, both now call new
`checkActivityTriggers` (adapter implements `ActivityReader`? read
calls, thrash takes priority over milestone) and, on a hit,
`requestReasonedHandoff` (same `HandoffRequested`-guarded ask-once pattern
the occupancy path already uses) — request only, never release, exactly
like the soft-threshold path's `prepare_handoff`.
- Tests: `internal/herdr/activity_test.go` (parser + all three detector
rules, including the two cases that caught real bugs above) and
`internal/orchestrator/rotation_test.go`'s new
`TestActivityTriggersRequestReasonedHandoffWithoutReleasing` (thrash and
milestone each request-without-releasing via `TurnDecision`, a
thrash-reasoned handoff already on disk bypasses occupancy and releases,
and `rotate()`'s periodic path does the same request-without-release).
- Codex/opencode Stop-hook-equivalent poll scripts
(`deploy/hooks/orchestra-codex-poll.sh`,
`orchestra-opencode-poll.sh`, added earlier this session) already call
`/v1/harness/turn`, so `TurnDecision`'s new thrash/milestone checks reach
those harnesses for free wherever `Activity` is implemented (Codex, once
its unverified parser is checked; opencode not until a real per-tool-call
source is found).
Not yet started: live verification of Phase 1 occupancy against a real
session, verifying `CodexActivity`'s parser shape against a live rollout, and
the two-machine federation run. 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