# CB-306 — Spawn-Readiness Gate (launcher-owned terminal readiness) **Status:** design note / delegation spec (branch `worker/cb-306-readiness`) **Issue:** gitea `fleet/fleetd` #4 **Owner of the behaviour:** `ClaudeCodeLauncher` (the `PeerLauncher` adapter) — NOT core. ## 1. Problem `fleet_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 `fleet_send` to 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 `SPAWNING` forever; 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**: 1. Start the pane exactly as today (`spawn(profile, cwd, callerCwd) → Agent`, build env + guard + argv, `spawnInTab`/`spawnAsPane`, unique-named). 2. **Poll** `agentControl.status(paneId)` every `pollIntervalMs` (~300 ms) until it is `injectable()` or `spawnReadyTimeoutMs` elapses. 3. **Ready** → return the `WorkerHandle(paneId, terminalId)` as today. 4. **Timeout** → the launcher **closes the pane it started** (and its tab, via the same path `release`/`stop` uses) and throws **`PeerUnreachableException`** (new, in `dev.ltms.fleet.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. a `Sleeper`/`Waiter` functional interface, or reuse whatever `StatusPoller` already uses), both defaulting to the real implementations in the production constructor and overridable in tests. Unit tests (add to the existing `ClaudeCodeLauncher` test): - fake `AgentControl` returns `UNKNOWN` a few times then `IDLE` → `spawn` returns the handle; assert no `close` was called. - fake `AgentControl` always `UNKNOWN` → `spawn` throws `PeerUnreachableException`; assert the pane **was closed** (verify `close(paneId)` invoked) and the fake clock advanced past the timeout. - `spawnReadyTimeoutMs == 0` → `spawn` returns immediately without polling (legacy path). ## 5. Config Add to the launcher-level config (a fleetd-level knob, not per-profile) in `fleetd.yaml` + `FleetConfig`: ```yaml 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 `FleetConfig`. ## 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 `spawn` throws — verify the new exception flows through it (worktree removed, nothing registered). - The **non-worktree** path registers the session only *after* `spawn` returns, so a throw means no half-live `SPAWNING` session is ever registered — confirm this and add a test. - `fleet_spawn` (MCP verb) must return an **error result** carrying the exception message, not a success with a dead session. Trace `FleetMcp`/`FleetApp` spawn 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 `fleet_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.spawn` blocks until injectable or throws `PeerUnreachableException` + self-reaps the pane; gate disabled when timeout is 0. - New `PeerUnreachableException` in `dev.ltms.fleet.peer`. - Config knobs wired (`spawn_ready_timeout_ms`, `spawn_ready_poll_ms`) with safe defaults. - Existing `SPAWNING→READY` MCP-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 install` gate — self-reports are not verified facts). ## 8. Sequence ```mermaid 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.*