2e138a199b
Two changes ship together here.
1. One shared herdr workspace. The lead and every worker now live in one
workspace called "fleet", so the operator sees one "session" with many
windows, not two. Before, the lead sat in a "leads" workspace and workers
in "bridged-workers", which read as two sessions. The lead is still told
apart from workers by its exact tab label ("lead: <name>"), so putting them
in one space is safe. LeadTabScanner keeps the exclude-by-label mechanism
for split layouts; Fleetd now passes an empty exclude set.
2. Rename the daemon from "bridged" to "fleetd" (the binary, config, scripts,
launchd/systemd units, module dir, and MCP mount).
- Module dir bridged/ -> fleetd/; jar finalName -> fleetd.jar.
- Log line, comments, docs, and CLAUDE.md updated to say fleetd.
- Scripts renamed: redeploy-bridged.sh -> redeploy-fleetd.sh,
bridged-launchd-wrapper.sh -> fleetd-launchd-wrapper.sh.
- Deploy units renamed: dev.ltms.bridged.plist -> dev.ltms.fleetd.plist,
bridged.service -> fleetd.service; launchd Label -> dev.ltms.fleetd.
- Config default bridged.yaml -> fleetd.yaml; the legacy bridged.yaml is
still read as a fallback, and still gitignored.
- MCP: drop the deprecated bridge_* tool twins; only fleet_* remain. The
server name is "fleet". The mount name in the local .mcp.json becomes
"fleet" (gitignored, not in this commit).
- Env var defaults BRIDGED_API_TOKEN -> FLEETD_API_TOKEN, fixture
BRIDGED_WORKER_TOKEN -> FLEETD_WORKER_TOKEN.
Kept on purpose: the BRIDGED_MEMBER marker. Renaming it is a coupled change to
the credential-scrub security control (an operator secrets.sh may guard on it),
so it stays until that migration is done on its own.
Metrics were already fleet_* (CB-632); MetricNamesTest still guards that no
name says bridged_.
The canonical CLAUDE.md block and the wiki template stay byte-identical
(wiki working tree edited, committed to the wiki repo separately).
949 tests pass (mvn clean install). 4 fewer than before = the 4 removed
bridge_* alias tests.
144 lines
7.5 KiB
Markdown
144 lines
7.5 KiB
Markdown
# 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.*
|