7.5 KiB
CB-306 — Spawn-Readiness Gate (launcher-owned terminal readiness)
Status: design note / delegation spec (branch worker/cb-306-readiness)
Issue: gitea lms/claude-bridge #4
Owner of the behaviour: ClaudeCodeLauncher (the PeerLauncher adapter) — NOT core.
1. Problem
bridge_spawn today returns a session the instant the herdr pane is started. The pane is not
yet a usable Claude REPL — it may still be sitting at the folder-trust prompt, or the CLI may
never come up at all. Nothing blocks or times out on that. Consequences:
- A
bridge_sendto a not-yet-ready worker surfaces as a ~60 s MCP-client timeout (the send blocks waiting for a turn that can't start) instead of a fast, explicit spawn failure. - A worker stuck at the folder-trust prompt lingers in
SPAWNINGforever; nothing fails it.
We want fail-fast spawn: spawn() returns only once the peer is genuinely up and usable in
its terminal, or throws a clean error (and leaves no orphan pane) within a bounded timeout.
2. What already exists (do NOT rebuild)
SessionManager + PresenceBridge.markPresent() already flip a session SPAWNING → READY on
any MCP contact from the worker (onReady(terminal) → transitionByTerminal(SPAWNING, READY),
SessionManager ~line 249, "worker became available on the bridge MCP"). That is the delivery
lifecycle and it stays exactly as-is. CB-306 does not touch it and does not replace it.
CB-306 adds a complementary, launcher-side gate: the launcher guarantees the terminal is a live, interactive REPL before it hands a handle back. The two signals are layered:
| Signal | Owner | Means | CB-306 |
|---|---|---|---|
terminal reaches interactive REPL (herdr IDLE) |
ClaudeCodeLauncher (this ticket) |
pane is past folder-trust, CLI is up | NEW — the spawn gate |
first MCP contact → SPAWNING→READY |
core (SessionManager/PresenceBridge) |
worker spoke to the bridge | unchanged |
3. The readiness predicate (herdr status)
AgentStatus.fromWire maps herdr's wire strings to IDLE | WORKING | BLOCKED | DONE | UNKNOWN.
A freshly started pane that has not reached an interactive Claude — including one stalled at
the folder-trust prompt — reports UNKNOWN (herdr has not detected a Claude REPL yet). Once
the CLI is up and settled at its prompt it reports IDLE.
Predicate: the pane is ready when AgentControl.status(target) first returns an
injectable state (IDLE, BLOCKED, or DONE — reuse AgentStatus.injectable()). UNKNOWN
= not ready. WORKING alone is ambiguous this early and should not by itself satisfy readiness;
wait for an injectable state. (We do not need to know why a pane isn't ready — a trust stall,
a crash, and a slow start all present as "never becomes injectable" and all correctly time out.)
4. Contract change on spawn(SpawnRequest)
ClaudeCodeLauncher.spawn(SpawnRequest) becomes block-until-ready-or-throw:
- Start the pane exactly as today (
spawn(profile, cwd, callerCwd) → Agent, build env + guard + argv,spawnInTab/spawnAsPane, unique-named). - Poll
agentControl.status(paneId)everypollIntervalMs(~300 ms) until it isinjectable()orspawnReadyTimeoutMselapses. - Ready → return the
WorkerHandle(paneId, terminalId)as today. - Timeout → the launcher closes the pane it started (and its tab, via the same path
release/stopuses) and throwsPeerUnreachableException(new, indev.ltms.bridged.peer). No orphan pane is left behind — the launcher cleans up its own failed birth.
spawnReadyTimeoutMs == 0 (or unset) disables the gate = legacy non-blocking behaviour, so the
change is opt-in per deployment and existing tests that don't configure it keep their old semantics.
Testability seam (required)
Do not call Thread.sleep directly in the poll loop against a real clock — unit tests must not
real-sleep. Introduce a small injectable seam, mirroring the existing StatusPoller style:
- a
LongSupplier nowMillis(monotonic clock) and a sleep/wait hook (e.g. aSleeper/Waiterfunctional interface, or reuse whateverStatusPolleralready uses), both defaulting to the real implementations in the production constructor and overridable in tests.
Unit tests (add to the existing ClaudeCodeLauncher test):
- fake
AgentControlreturnsUNKNOWNa few times thenIDLE→spawnreturns the handle; assert noclosewas called. - fake
AgentControlalwaysUNKNOWN→spawnthrowsPeerUnreachableException; assert the pane was closed (verifyclose(paneId)invoked) and the fake clock advanced past the timeout. spawnReadyTimeoutMs == 0→spawnreturns immediately without polling (legacy path).
5. Config
Add to the launcher-level config (a bridged-level knob, not per-profile) in bridged.yaml +
BridgedConfig:
spawn_ready_timeout_ms: 20000 # 0 disables the gate (legacy non-blocking spawn)
spawn_ready_poll_ms: 300
Jackson ignores unknown keys, so omitting them in existing YAML is safe; pick sane defaults in code
(20000 / 300). Keep the names consistent with existing config field style in BridgedConfig.
6. Core / MCP propagation
SessionManager.acquire(...) already calls launcher.spawn(req). A thrown
PeerUnreachableException must propagate out as a clean spawn failure:
- The worktree acquire path already has a try/catch that cleans up a provisioned worktree when
spawnthrows — verify the new exception flows through it (worktree removed, nothing registered). - The non-worktree path registers the session only after
spawnreturns, so a throw means no half-liveSPAWNINGsession is ever registered — confirm this and add a test. bridge_spawn(MCP verb) must return an error result carrying the exception message, not a success with a dead session. TraceBridgeMcp/BridgedAppspawn handlers and make sure the exception becomes a clean tool error, not an uncaught 500 with a stack trace.
Out of scope (do NOT do here): gating bridge_send on session READY (existing status-gate +
this spawn gate already close the window), MCP-handshake-as-readiness signal, the CB-307 broker,
any config kind: discriminator, any second adapter.
7. Definition of done
ClaudeCodeLauncher.spawnblocks until injectable or throwsPeerUnreachableException+ self-reaps the pane; gate disabled when timeout is 0.- New
PeerUnreachableExceptionindev.ltms.bridged.peer. - Config knobs wired (
spawn_ready_timeout_ms,spawn_ready_poll_ms) with safe defaults. - Existing
SPAWNING→READYMCP-contact transition untouched. - New unit tests (ready / timeout+reap / disabled) green; all existing tests still pass unchanged.
- Build clean via the worker's own
mvn(primary re-runs the authoritative IDE +mvn clean installgate — self-reports are not verified facts).
8. Sequence
sequenceDiagram
participant SM as SessionManager.acquire
participant L as ClaudeCodeLauncher.spawn
participant T as herdr (AgentControl)
SM->>L: spawn(SpawnRequest)
L->>T: start pane (env+guard+argv)
loop until injectable or timeout
L->>T: status(paneId)
T-->>L: UNKNOWN / IDLE
end
alt reached injectable
L-->>SM: PeerHandle(id, terminalId)
else timed out
L->>T: close(paneId) + tab
L-->>SM: throw PeerUnreachableException
SM-->>SM: no session registered / worktree cleaned
end
Figure — the launcher blocks in spawn until the pane is a usable REPL, else self-reaps and throws.