fleetd #176: fail-fast + safe UNKNOWN refinement in the spawn-readiness gate #233
Reference in New Issue
Block a user
Delete Branch "worker/cb176-spawn-gate-3d75c8-2"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
fleetd #176 — spawn-readiness gate (HerdrPeerLauncher.waitUntilInjectableOrThrow)
Scope was limited to the two fixes described in the brief. The "count the subscription seat" idea from the ticket was ignored per instructions (comment 2 already disproved it) — no seat accounting was built.
Fix 1 — fail fast on a herdr
*_not_foundmid-wait (implemented)agents.get(paneId)inside the poll loop was unguarded. When herdr answers*_not_found(the backend process exited, not "slow"), that exception used to propagate raw out ofwaitUntilInjectableOrThrow:stop(paneId)was skipped (pane/tab leaked) and the caller got aHerdrExceptioninstead ofPeerUnreachableException.Now: the
agents.getcall is wrapped in try/catch.isAlreadyGone(e)(the existing helper) routes into a newfailFastOnGoneBackendthat:spawnReadyTimeoutMs,stop(paneId)),PeerUnreachableExceptionnaming the pane, elapsed ms, and the pane tail (via the existingreadPaneQuietly), and says the process exited, not that the pane was slow.Any other
HerdrExceptionstill propagates unchanged (verified by a dedicated test).Fix 2 — safe UNKNOWN refinement (implemented)
Wired
StatusRefiner(the same oneStatusPolleruses) into the gate, but only under two guards:namePrefix.equals("claude").StatusRefiner.classifyis documented as Claude-Code-TUI-specific; the opencode adapter (namePrefix = "opencode") never reaches it, so a confidently-wrong classification on the wrong TUI cannot happen.agents.get(paneId)sample also reports a non-nullagentType. This closes the trap named in the brief: a pane whose backend already exited settles at a bare shell prompt that can also contain❯, whichStatusRefiner.classifyreads as "idle at the Claude Code TUI prompt". Without theagentTypecorroboration that dead pane would misclassify as injectable — worse than today's timeout.Status and agentType are read from one
agents.get(paneId)call (I switched the loop fromagents.status(paneId)toagents.get(paneId)—status()was already justget(target).status()under the hood, so this is the same underlying herdr call, not an extra one).Refinement runs only when the raw status is UNKNOWN — verified with a dedicated test (
refinementNeverFiresWhenRawStatusIsAlreadyInjectable) assertingagent.readis never called when the raw status is already IDLE. A healthy spawn therefore adds zero extra herdr calls; this matchesStatusRefiner's own documented cost model (one extraagent.readper poll only while persistently UNKNOWN).What I found about
agentTypeAgent.agentType(JSON field"agent") is documented as "detected agent kind... or null before herdr has detected it". I did not independently verify against a live herdr daemon that it is also null for a pane sitting at a bare shell after the backend exited (the brief flagged this as unverified too) — I trusted the brief's stated belief and built the corroboration on it, since it is the only liveness signal available in the sameagent.getsample. This should be confirmed against real herdr behavior before this gate is trusted in a dogfood run.spawnReadyTimeoutMs/ config defaultsUnchanged — no config or default touched.
Tests
Added to
ClaudeCodeLauncherTest(and matching fixtures added toFakeHerdr:agentType(String),agentGetFailsWithAfter(int okCalls, String code)):spawnFailsFastWhenBackendProcessExitsMidWaitInsteadOfBurningTheTimeout— timeout set to 60s, backend reportspane_not_foundafter one normal poll; asserts the clock never reaches anywhere near 60s, exactly 2agent.getcalls happened (no further polling), the message says "exited", and the pane is closed. This is acceptance criterion 2.spawnLetsAnUnrelatedHerdrErrorPropagateUnchanged— aninternal_errorcode must NOT be treated as fail-fast; it propagates asHerdrExceptionand the gate does not tear the pane down itself (extra coverage beyond the brief, to prove fix 1 doesn't over-fire).refinedIdleIsNotAcceptedWhenAgentTypeIsNull— pane content contains❯,agentTypeisnull, status stays UNKNOWN forever; asserts the gate waits out the full timeout and closes the pane rather than accepting the trap. This is acceptance criterion 3.refinedIdleIsAcceptedWhenAgentTypeCorroboratesLiveness— the positive case: UNKNOWN + non-nullagentType+ a genuine idle-footer marker (? for shortcuts) resolves to injectable well before the timeout. (Not explicitly required by the acceptance criteria, added for confidence that fix 2 is not a no-op.)refinementNeverFiresWhenRawStatusIsAlreadyInjectable— raw status already IDLE, pane content deliberately looks likeWORKING(esc to interrupt); asserts the spawn still succeeds andagent.readwas never called. This is acceptance criterion 4.Build
Ran unpiped from the repo root:
EXIT=0. Full suite:Tests run: 1122, Failures: 0, Errors: 0, Skipped: 0—BUILD SUCCESS.Caveats for review
agentType-is-null-on-a-bare-shell assumption is not verified against live herdr — see above."claude".equals(namePrefix)check inside the base class rather than a new adapter-overridable hook — kept minimal sinceHerdrPeerLauncheralready ownsnamePrefixand this avoids touchingClaudeCodeLauncher/OpenCodeLauncher.checkPaneCommandFitsandstartAwaitingShellPrompt'sSHELL_READY_RETRIESloop poll on herdr but don't obviously distinguish "shell still booting" from "shell will never become available" beyond a fixed retry count — same "slow vs dead" shape as this ticket, in a different place. Not fixed here.Worktree root:
/Users/dai.ha/LTMS/.bridged-worktrees/169147-2Branch:
worker/cb176-spawn-gate-3d75c8-2Files changed:
fleetd/src/main/java/dev/ltms/fleet/member/HerdrPeerLauncher.javafleetd/src/test/java/dev/ltms/fleet/herdr/FakeHerdr.javafleetd/src/test/java/dev/ltms/fleet/member/ClaudeCodeLauncherTest.javaUpdate — review follow-up (guard-a coverage)
The lead reviewed the production diff and confirmed it independently (NAME_PREFIX values, the
status()==get().status()equivalence, and that*_not_foundalready propagated out of spawn before this change). One gap was found before merge: guard (a) — the"claude".equals(namePrefix)per-adapter check inrefinedInjectable— had no test. Guard (b) (theagentTypecorroboration) had both a positive and a negative test, but nothing proved guard (a) actually does anything, and a live opencode pane always reports a non-nullagentType, so guard (b) alone can't catch a broken guard (a).Added
opencodePaneIsNeverRefinedEvenWhenItsContentLooksLikeAnIdleClaudePrompttoOpenCodeLauncherTest:agentType("opencode")— non-null, satisfies guard (b) on its own, so it cannot be what makes the test pass❯— whatStatusRefiner.classifywould read as IDLEPeerUnreachableException, clock reaches the full timeout)agent.readcall usedsource="detection"(StatusRefiner.PROBE_SOURCE) — proving the refiner was never even reached for this pane, not merely that its answer was discardedNote on that last assertion: the lead's suggested
assertFalse(herdr.called("agent.read"))does not hold here —HerdrPeerLauncher.readPaneQuietlyreads the pane tail (source="recent") for the timeout-log message on every timeout, regardless of adapter, soagent.readgenuinely is called elsewhere in this same test. I scoped the assertion to the refiner's own probe source ("detection") instead, which is strictly stronger evidence for the actual claim (the refiner itself was never reached) and does hold.Mutation check performed and reverted (not committed): temporarily changed
if (!"claude".equals(namePrefix))toif (false)inHerdrPeerLauncher.refinedInjectable. Reran just the new test — it failed:org.opentest4j.AssertionFailedError: Expected dev.ltms.fleet.peer.PeerUnreachableException to be thrown, but nothing was thrown.Restored the guard, reran —Tests run: 1, Failures: 0, Errors: 0, Skipped: 0. Confirmed the guard's own file (HerdrPeerLauncher.java) has no diff before this commit (git status/git diffclean on it).Build:
mvn clean install > /tmp/cb176-r2.log 2>&1; echo "EXIT=$?"→EXIT=0. Full log:Tests run: 1123, Failures: 0, Errors: 0, Skipped: 0—BUILD SUCCESS.Files changed in this follow-up:
fleetd/src/test/java/dev/ltms/fleet/member/OpenCodeLauncherTest.javaonly.The agentType corroboration (guard b) had positive and negative tests, but the per-adapter namePrefix guard (guard a) had none — nothing proved that an opencode pane can never reach StatusRefiner.classify, only that a claude pane with a null agentType is rejected. Since a live opencode pane always reports a non-null agentType ("opencode"), guard (b) alone cannot catch a broken guard (a). Added opencodePaneIsNeverRefinedEvenWhenItsContentLooksLikeAnIdleClaudePrompt to OpenCodeLauncherTest: agentType("opencode") (non-null, satisfies guard b on its own) + pane content containing "❯" (would classify as IDLE) + raw status UNKNOWN throughout. Asserts the gate still times out, and that no agent.read call used source=detection (StatusRefiner.PROBE_SOURCE) — proving the refiner was never even reached, not just that its answer was discarded. A blanket "agent.read is never called" does not hold here: HerdrPeerLauncher.readPaneQuietly reads the pane tail (source=recent) for the timeout log on every timeout, regardless of adapter, so the assertion is scoped to the refiner's own probe source instead. Mutation check performed and reverted before this commit: temporarily changed `if (!"claude".equals(namePrefix))` to `if (false)` in HerdrPeerLauncher.refinedInjectable — the new test failed ("Expected PeerUnreachableException to be thrown, but nothing was thrown"), confirming it actually exercises the guard. Restored the guard and reran — test passes again (1/1). No production code changed in this commit. mvn clean install: Tests run: 1123, Failures: 0, Errors: 0, Skipped: 0 -- BUILD SUCCESS