Fix worker auto-launch for herdr 0.8's existing-pane API (1.11.1) - #53
Open
gering wants to merge 12 commits into
Open
Fix worker auto-launch for herdr 0.8's existing-pane API (1.11.1)#53gering wants to merge 12 commits into
gering wants to merge 12 commits into
Conversation
Modern herdr (0.7.5+) starts agents in an already-open pane via `agent start --kind <kind> --pane <id>`, which the launcher cannot derive from a selector name or by parsing argv[0]. Each registry entry now declares it explicitly: - `herdr_mode=agent-start` + `herdr_kind` for claude/codex/grok, where argv[0] equals the kind so the launcher drops exactly that word - `herdr_mode=pane-run` + `herdr_kind=kimi` for the two-phase wrapper, plus `herdr_marker=WORKER_SEED_FAILED` for seed-failure detection - the metadata is generic, so a future dynamic wrapper entry needs no launcher change Also make a failed kimi seed unambiguous: it prints the machine-readable marker, states TASK.md was not started, and exits with the seed's own code instead of waiting for Enter and opening an empty `kimi -c --auto` session that looks like a healthy worker. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AurDZwFXYtjzi5BUcMaxaW
herdr 0.7.5 changed `agent start` to require an already-open pane, so work-system 1.11.0 fails on 0.8 with "unknown option: --workspace". The launcher now reads which contract this herdr speaks from `agent start --help` (read-only, never a version compare — 0.7.x spans both) and refuses to touch anything if it recognizes neither. Modern path: create the final background tab first, then start the worker in its root pane. - native workers (claude/codex/grok) go through `--kind`: argv[0] must equal the registry's declared kind and is dropped, every remaining argument keeps its order and boundaries, and the returned pane id must match the requested one - `agent_pane_busy` — the real failure right after `tab create`, while the login shell is still running its rc files — is retried bounded, and no other error is ever retried - wrapper workers (kimi) are delivered as one `pane run` command after a bounded shell-prompt wait, then confirmed by polling until herdr detects the expected kind in that exact pane - definitive failures (usage error, no such pane, busy exhausted, seed marker) roll the created tab back exactly once via herdr-teardown's close-then-verify; ambiguous ones (readiness timeout, unknown error, pane-id mismatch, wrong detected kind) leave the tab alone and return the new fail-closed `blocked=unverified` result - an unconfirmed rollback also downgrades to `blocked=unverified` rather than claiming a clean failure The legacy 0.7.0-0.7.4 sequence, diagnostics and stdout contract are untouched, and `moved=yes` is kept on the modern path for callers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AurDZwFXYtjzi5BUcMaxaW
/kickoff now branches on `blocked=unverified` FIRST — inspect the tab, never relaunch, never print the manual command, never persist a project default — then moved=yes, legacy moved=no, selector errors, and generic failures. /adopt points at that one ordering instead of restating it. Also correct the lifecycle claims: on herdr 0.7.5+ the kickoff worker runs inside a shell pane, so a clean /exit does NOT close the tab. The armed marker plus the SessionEnd hook is the primary teardown there, not a safety net. No teardown behavior changes — both scenarios already work through the pane id and the marker. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AurDZwFXYtjzi5BUcMaxaW
The modern native and wrapper cases resolved through agent-registry's live availability probes, so they skipped wherever codex/grok/kimi are absent (CI) and went flaky where a `grok models` probe is slow. Stub the worker CLIs and their auth files alongside the herdr stub, so every launch path is asserted the same way everywhere. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AurDZwFXYtjzi5BUcMaxaW
Live on herdr 0.8 a healthy `--kimi` launch was rolled back within two seconds: a pane echoes the command it was given, so the wrapper's own script text carried the marker and the launcher's pane grep matched it immediately. The wrapper now builds the token from two halves at runtime, so the typed command never contains the literal while the printed failure line still does. Tests pin both sides: the literal must be absent from the script text and present in the executed output, and a pane whose only content is the echoed command must read as "starting", never as a failed seed. Verified live on herdr 0.8 in disposable tabs: native `--grok` (tab create → agent start --kind grok --pane, same pane back, detected as grok) and wrapper `--kimi` (pane run → detected as kimi in ~5s), the seed-failure rollback, and that an unrecognizable API creates no tab. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AurDZwFXYtjzi5BUcMaxaW
- herdr-kickoff-automation: new section on the 0.7.5 contract change — feature detection over version compare, registry-declared transport, the bounded agent_pane_busy retry as the modern equivalent of argv-exec's race-freedom, rollback vs blocked=unverified, and the "a pane echoes what you type into it" marker gotcha found live - kickoff-agent-selection: the registry now owns transport metadata too, and kimi's seed failure is a hard stop rather than a keypress - herdr-close-automation: which side closes the tab flipped — on 0.7.5+ the armed marker plus SessionEnd hook is primary, and agent_status still populates because the worker is a registered agent Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AurDZwFXYtjzi5BUcMaxaW
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AurDZwFXYtjzi5BUcMaxaW
Swarm review (11 Claude lenses) found the seed-failure marker was still
forgeable and three smaller gaps. Fixes, most severe first:
- the marker is only definitive when it carries this launch's random seed
token, passed as a `WORK_SYSTEM_SEED_TOKEN=` prefix on the pane-run
command. A pane's text is worker-controlled: the seed reads repository
files, and this repo's TASK.md, CHANGELOG and tests all name the marker
constant — a bare match would tear down healthy workers
- the marker is checked before the kind match, since herdr detects the
wrapper's CLI seconds into the seed and both signals can be true at
once; the window it can and cannot cover is now documented, not implied
- the legacy path confirms a wrapper's pane is still alive before
reporting moved=yes: there the wrapper is the tab's root process, so a
dead seed closes the tab we were about to call running. Native legacy
launches keep their byte-identical fast path
- `unknown` shell readiness now spends the whole bounded window instead
of typing immediately, which had handed back the rc-file keystroke race
- a failed `--help` probe reports itself as a probe failure, not as an
unrecognizable herdr contract (both still fail closed)
- the tab-create parser resolves each id independently, so a wrong-shaped
`tab` field no longer discards a usable pane id
- sibling scripts resolve via a real SCRIPT_DIR; `${0%/*}` silently
disabled rollback whenever $0 had no slash
- `run_bounded` moves to lib-bounded.sh, sourced by both scripts (the two
copies had already drifted), and now also wraps every polling read
- the three registry row readers collapse into one, so a column change
cannot be applied to two of them and forgotten in the third
- /adopt references kickoff's result contract instead of restating it
Two findings were rejected: gating on `interactive_ready` (herdr's start
contract already means detected-and-ready) and routing the no-selector
default through the registry (it deliberately omits --model so the user's
own default model applies).
Re-verified live on herdr 0.8: a --kimi launch still comes up in ~5s and
the echoed command does not contain the grep target.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AurDZwFXYtjzi5BUcMaxaW
Second swarm round, all findings new — eight of them consequences of round one's own edits: - the legacy liveness check now reads the pane LIST and returns a tri-state: `gone` is a definitive seed failure, but an unreadable herdr is `blocked=unverified`, not death. Round one wrote "treat this as inconclusive" in the helper's own contract and then treated it as definitive two lines later — which would have sent /kickoff down its manual path and invited a second unattended worker onto the worktree - that check is a real poll now: it stops the moment the pane is gone, instead of sleeping the whole window and looking once - `extract_shell_ready` only says `busy` on a positively parsed answer; a renamed process_info field is drift and must degrade to "proceed", not hard-fail and roll back every wrapper launch - the shell-prompt budget goes to ~30s, comparable to what the native path gets — a login shell running nvm/sdkman/conda routinely outlasts the old 10s, and here a still-busy shell is a hard failure - the seed token is validated as plain hex before being spliced into the pane command; an inherited hostile value would otherwise execute in the worktree under the user's own account - the token's env var NAME comes from the registry (`herdr_marker_env=`) like the marker itself, so a rename can't leave the wrapper printing `:none` while the launcher greps for a nonce - a marker rollback now relays the pane's visible tail first, since closing the tab destroys the worker's own error message - README, the registry's printed usage header and the knowledge index all still described the pre-fix kimi wrapper Verified live on herdr 0.8: a --kimi launch still comes up, and a hostile `WORK_SYSTEM_HERDR_SEED_TOKEN` is dropped without executing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AurDZwFXYtjzi5BUcMaxaW
Third swarm round. The seed-failure marker had now been patched twice and was still both forgeable and unreliable: the per-launch nonce that salted it has to be handed to the very process it supervises (so the worker can print its own death certificate), and a rendered pane snapshot is width-wrapped, so in a narrow pane the match silently misses anyway. Terminal text cannot be a supervisor's signal. Process state replaces it — and the first attempt at that was wrong too, which a live measurement caught: the busy→ready transition is never observable, because a seed that fails on startup is gone before the first poll. The steady state is what works. A pane back at its own shell prompt, with no worker detected, for IDLE_STREAK consecutive polls means nothing is running there. Live: a dead wrapper now fails in ~5s with its tab rolled back, a healthy one is confirmed in ~2s. That removes the nonce, the env hand-off, the pane reads, and the relay of worker-controlled text into the manager's context. Also this round: - a tab created but never identified no longer reports as cleanly rolled back — it would have left a blank tab open while the caller told the user everything was undone - the capability probe no longer merges stderr into its command substitution, which handed the capture pipe to the child and defeated run_bounded's whole hang guard - every poll loop carries a wall-clock deadline, so "60 tries" cannot mean twenty minutes against a wedged herdr - `case` replaces four printf|grep pipelines in the probe - the README scopes kimi seed-failure recovery per herdr version, and lib-bounded.sh names the two bounded-runner copies still outstanding Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AurDZwFXYtjzi5BUcMaxaW
Fourth swarm round — no critical findings; what is left is mostly the seams of round three's own edits. - the agent_pane_busy retry was the one poll loop without a wall-clock deadline, right under a comment claiming every loop has one. Bounded by try count alone it meant ~10s when `agent start` rejects instantly (under the rc-startup window this file documents) or ~600s of silence when each try consumes its own timeout - the idle-pane death signal is now gated on elapsed time as well as on the poll streak: the streak's justification was written in seconds while nothing verified that a fractional `sleep` actually sleeps - `extract_shell_ready` coerces both pids strictly, so a re-serialized field (a pid as a string, a negative pgid) degrades to unknown instead of to `busy` — which is a hard state that fails the launch - `resume` waits for the pane's shell prompt before typing `claude -c` into it: the same just-created-pane race the launch path guards, in the one place that still had it - the knowledge entry, its index line, the lib-bounded rationale and the test file all still described the nonce-salted marker that round three deleted Two findings were not applied: typing on unverified readiness stays (it is a documented degrade — blocking would make kimi unlaunchable on a herdr without process-info), and the resume orphan cleanup keeps its own close, because routing it through rollback_tab would drop the herdr error an earlier review round deliberately surfaced there. Live on herdr 0.8: healthy launch 4s, dead wrapper 7s with a confirmed rollback, no leftover tabs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AurDZwFXYtjzi5BUcMaxaW
Fifth swarm round, no critical findings. The one that matters is a premise I had not questioned: readiness was `foreground_process_group_id == shell_pid`, which only means "idle" when job control is on. A shell started with `set +m` leaves children in its own group, so a running wrapper would read idle — and the detection loop tears down idle panes. It now compares the foreground processes' pids against the shell's, which answers the same question without the assumption. Also this round: - the resume shell-busy path no longer exits before focusing the tab it just created; it falls through like the send-failed path, differing only in `resumed=` - an unreadable pane state degrades after a few polls instead of burning the whole 45s window on every launch and resume — the wait exists to cover a seconds-long rc window, not to wait that long for nothing - the modern `agent start` invocation is built once instead of written out twice around an empty-array guard - the seed marker drops its runtime reassembly: that only ever existed to dodge a launcher-side pane grep, which no longer exists - five stale comments and doc lines that still described the removed grep, the removed `herdr_marker=` output, or the pre-fix `;` wrapper shape — including one test comment contradicting another ten lines below it, and the knowledge index line trimmed back to sibling length Two design findings stand, both documented in place: pane_state keeps its own guard chain (pane-scoped, where herdr-teardown's is tab-scoped), and the two remaining bounded-runner copies are still named as a follow-up rather than migrated in a launch-path fix. Live on herdr 0.8: healthy launch 4s, dead wrapper 7s with rollback. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AurDZwFXYtjzi5BUcMaxaW
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
A real
/kickoffunder work-system 1.11.0 dies on herdr 0.8:herdr 0.7.5 (still current in 0.8.0) redefined
agent start: it no longer placesthe agent, it starts one in an already-open pane at an interactive shell prompt
—
agent start <NAME> --kind <KIND> --pane <ID> [-- <args>]— and placement movedto a preceding
tab create. Since 0.7.0–0.7.4 still speak the old form, this is acompatibility fix, not a migration.
What
herdr agent start --help(read-only,bounded,
$WORK_SYSTEM_HERDR_API-overridable), never from a version string —0.7.x spans both contracts. Neither recognizable → stop before creating or
starting anything.
its root pane; no
pane move.moved=yesis kept for caller compatibility.agent-registry.sh resolveemitsherdr_mode=agent-start|pane-run+herdr_kind(+herdr_marker=for wrappers).Native entries assert argv[0] equals the kind, drop exactly that word, and pass
the remaining arguments through unchanged; kimi's two-phase wrapper goes out as
one
pane runcommand. Unknown mode fails closed beforetab create. The shapeis generic, so PR Offer PATH-detected cc-harness agents at /kickoff (work-system 1.12.0) #52's dynamic cc-harness entries slot in as
pane-run+herdr_kind=claudewithout touching the launcher.agent_pane_busyis retried, bounded, and alone — it is the real failureright after
tab create, while the pane's login shell is still running its rcfiles. Wrapper workers additionally wait (via
pane process-info) until the shellitself holds the foreground before anything is typed, then are confirmed by
polling until herdr detects the expected kind in that exact pane.
exhausted, seed marker) close the created tab exactly once through
herdr-teardown.sh close-tab. Ambiguous ones (readiness timeout, unclassifiableerror, pane-id mismatch, wrong detected kind) leave the tab alone and return the
new
blocked=unverifiedresult — as does a rollback that cannot be confirmed./kickoffand/adoptbranch onblockedfirst and must not relaunch, printthe manual command, or persist a project default there.
started", exit with the seed's own code — no keypress wait, no empty
kimi -c --autosession masquerading as a healthy worker.a clean
/exitdoes not close its tab;/close's marker +SessionEndhook isthe primary teardown. No teardown behavior changed — the worker is still a
registered agent, so
agent_statuspolling is unaffected.The legacy 0.7.0–0.7.4 sequence, diagnostics, stale-workspace hint, exit codes and
stdout contract are untouched.
Verification
test_herdr_launch.pywas rewritten around a stateful herdr stub (per-callresponses) and now covers capability detection (modern/legacy/unrecognizable, the
last mutating nothing), the modern native path per CLI (exact kind/pane, argv
boundaries, no duplicated executable, no
pane move), the wrapper path (one-commanddelivery, bounded detection, marker, wrong kind, timeout), busy retry and
exhaustion, partial
tab createresponses, pane-id mismatch, exactly-once cleanupand unconfirmed cleanup. Worker CLIs are stubbed too, so these no longer skip on CI
or go flaky locally.
test_agent_registry.pygained transport-metadata andseed-failure coverage.
scripts/check-structure.pyis green.Live on herdr 0.8, in disposable tabs, all cleaned up:
--grok:tab create→agent start --kind grok --pane, same panereturned, detected as
grok, clean stdout contract--kimi:pane run→ detected askimiin ~5sThe live run caught a real defect the hermetic tests could not: a pane echoes the
command it was given, so the marker literal inside the wrapper script made a
healthy launch roll itself back after 2s. The wrapper now assembles the token at
runtime, with regression tests on both sides.
Sequencing
Lands before PR #52 (cc-harness agents), which carries the same stale herdr call
and rebases onto this.
🤖 Generated with Claude Code
https://claude.ai/code/session_01AurDZwFXYtjzi5BUcMaxaW