Part of #145 (CB-632). Documentation only, plus one internal literal. Unit 1 renamed the package and classes, which left every doc describing classes that no longer exist. This fixes the prose across README.md, docs/ and bridged/docs/ -- 18 files. Renamed: dev.ltms.bridged -> dev.ltms.fleet, the five class names, and "bridged" where it names the daemon as a product rather than a path. Also renamed two literals, because a doc that disagrees with the code is worse than one that is out of date: - bridged-local-noauth -> fleetd-local-noauth. A placeholder apiKey OpenCodeLauncher sends when a profile resolves no token, to a local endpoint that does not check it. No test asserts the old string. - the vnd.ltms.bridged.* media type in the M4 design doc. It appears in no Java file, so nothing implements it yet. Deliberately NOT renamed, because each is still literally true today and changes only at the cutover: - paths: bridged/, bridged.yaml, bridged.example.yaml, bridged.jar, .bridged-worktrees, deploy/dev.ltms.bridged.plist, scripts/redeploy-bridged.sh, bridged-launchd-wrapper.sh - bridged_* metric names -- renaming these after the monitoring is wired would break dashboard continuity, so they move before it is - bridge_* MCP tool names, which answer alongside fleet_* on purpose - BRIDGED_* environment variables, read by a file outside this repo Method note: perl, not sed. BSD sed has no \b and no lookaround, and a word-boundary expression there fails silently. The prose replace uses (?<![\w./-])bridged(?![\w./-]) so it cannot touch a path or an identifier, then every remaining hit was read by hand. Verified: mvn clean install green, 51 classes, 878 tests, 0 failures.
7.5 KiB
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_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.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. 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 +
FleetConfig:
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
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. fleet_spawn(MCP verb) must return an error result carrying the exception message, not a success with a dead session. TraceFleetMcp/FleetAppspawn 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.spawnblocks until injectable or throwsPeerUnreachableException+ self-reaps the pane; gate disabled when timeout is 0.- New
PeerUnreachableExceptionindev.ltms.fleet.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.