Files
fleetd/docs/CB-306-Spawn-Readiness-Gate.md
T
Dai Ha 76e4b577a0
CI / build (pull_request) Successful in 1m6s
CI / contract (pull_request) Successful in 1m6s
CB-622: rename bridge_* tool names to fleet_* in Markdown docs
Rename the eleven MCP tool names (bridge_ack/ask/list/poll/profiles/reply/
send/spawn/status/stop/whoami) to their fleet_* names across the Markdown
documentation. fleet_* is written as the normal name; one deprecation note
in README.md says bridge_* still works for one release.

docs/MCP-Contract.md is renamed only inside section 6 (lines 210-293):
sections 1-5 and 7-11 are stale pre-build design text (CB-609) and are
deliberately left with old names so dead text does not look maintained.

Also renames e2e/bridge_ask_transcript.md to e2e/fleet_ask_transcript.md
to match its content. CLAUDE.md, wiki/, plugin/skills/setup/SKILL.md and
.claude/skills/port-to-opencode/SKILL.md are owned by other units and are
untouched.
2026-08-22 21:52:24 +02:00

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

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.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. 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 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 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 BridgeMcp/BridgedApp 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.bridged.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

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.