Skip to content

Fix worker auto-launch for herdr 0.8's existing-pane API (1.11.1) - #53

Open
gering wants to merge 12 commits into
mainfrom
task/fix-herdr-0-8-worker-launch
Open

Fix worker auto-launch for herdr 0.8's existing-pane API (1.11.1)#53
gering wants to merge 12 commits into
mainfrom
task/fix-herdr-0-8-worker-launch

Conversation

@gering

@gering gering commented Aug 12, 2026

Copy link
Copy Markdown
Owner

Why

A real /kickoff under work-system 1.11.0 dies on herdr 0.8:

herdr error: unknown option: --workspace
herdr agent start did not return a pane id

herdr 0.7.5 (still current in 0.8.0) redefined agent start: it no longer places
the agent, it starts one in an already-open pane at an interactive shell prompt
agent start <NAME> --kind <KIND> --pane <ID> [-- <args>] — and placement moved
to a preceding tab create. Since 0.7.0–0.7.4 still speak the old form, this is a
compatibility fix, not a migration.

What

  • Feature-detect the contract from 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.
  • Modern path: create the final background tab first, then start the worker in
    its root pane; no pane move. moved=yes is kept for caller compatibility.
  • Transport is declared, not inferred: agent-registry.sh resolve emits
    herdr_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 run command. Unknown mode fails closed before tab create. The shape
    is 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=claude without touching the launcher.
  • agent_pane_busy is retried, bounded, and alone — it is the real failure
    right after tab create, while the pane's login shell is still running its rc
    files. Wrapper workers additionally wait (via pane process-info) until the shell
    itself holds the foreground before anything is typed, then are confirmed by
    polling until herdr detects the expected kind in that exact pane.
  • Rollback vs. fail-closed: definitive failures (usage error, no such pane, busy
    exhausted, seed marker) close the created tab exactly once through
    herdr-teardown.sh close-tab. Ambiguous ones (readiness timeout, unclassifiable
    error, pane-id mismatch, wrong detected kind) leave the tab alone and return the
    new blocked=unverified result — as does a rollback that cannot be confirmed.
    /kickoff and /adopt branch on blocked first and must not relaunch, print
    the manual command, or persist a project default there.
  • Kimi seed failure is a hard stop: machine-readable marker, "TASK.md was not
    started", exit with the seed's own code — no keypress wait, no empty
    kimi -c --auto session masquerading as a healthy worker.
  • Lifecycle claims corrected: on 0.7.5+ the worker runs inside a shell pane, so
    a clean /exit does not close its tab; /close's marker + SessionEnd hook is
    the primary teardown. No teardown behavior changed — the worker is still a
    registered agent, so agent_status polling 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.py was rewritten around a stateful herdr stub (per-call
responses) 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-command
delivery, bounded detection, marker, wrong kind, timeout), busy retry and
exhaustion, partial tab create responses, pane-id mismatch, exactly-once cleanup
and unconfirmed cleanup. Worker CLIs are stubbed too, so these no longer skip on CI
or go flaky locally. test_agent_registry.py gained transport-metadata and
seed-failure coverage. scripts/check-structure.py is green.

Live on herdr 0.8, in disposable tabs, all cleaned up:

  • native --grok: tab createagent start --kind grok --pane, same pane
    returned, detected as grok, clean stdout contract
  • wrapper --kimi: pane run → detected as kimi in ~5s
  • unrecognizable API: exit 1, tab count unchanged
  • the seed-failure rollback path, which is how the marker bug below surfaced

The 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

gering and others added 12 commits August 11, 2026 15:32
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant