fleetd #480 Unit A: lead rollover core (config block + executor) #483

Merged
ltms merged 2 commits from worker/480-a-rollover-core-6fc2ad-4 into main 2026-09-11 01:51:21 +02:00
Member

fleetd #480 Unit A — lead rollover core

What this builds

A lead session fills up its context and must be replaced. This unit is the config block and the
executor only — not the MCP tool (a later unit will call LeadRollover from an MCP tool).

  1. leadRollover: config block (opt-in top-level key in FleetConfig): handoverPath
    (required when the block is present), requireOperatorConfirm (default true),
    maxDocAgeSeconds (default 3600), clearSettleSeconds (default 20), bootstrapText
    (default names handoverPath). Registered in ConfigRef's hot/deferred/split/cold
    classification, documented in fleetd.example.yaml, validated by a new validateLeadRollover()
    (auto-wired into FleetConfig.validateAll()'s reflective sweep).

  2. dev.ltms.fleet.lead.LeadRollover — the executor:

    • open(String reason) → records a token, the resolved handover path, and the wall-clock
      timestamp of the request.
    • confirm(String token, boolean operatorConfirmed) → verifies (operator confirmation, then
      the handover file: exists, non-empty, modified after open() and not older than
      maxDocAgeSeconds), then rolls: agents.send(lead, "/clear") → poll until the pane reports
      injectable again (bounded by clearSettleSeconds) → agents.send(lead, bootstrapText). If
      the pane never settles, bootstrapText is never sent and the refusal says so.
    • cancel(String token) drops a pending request.
    • Nothing but an explicit confirm() call can ever roll a pane — no timer, no heartbeat, no
      background thread anywhere in this class.
    • Both /clear and bootstrapText go through agents.send(...) directly, never through
      Injector — a live probe (recorded in the ticket) proved a /clear routed through Injector
      wedges that pane forever, since /clear produces no turn boundary.
    • The freshness check takes an injected wall-clock LongSupplier, never System.nanoTime()
      (which freezes across a Mac sleep — fleetd #386).
  3. Wired into Fleetd.java exactly like LeadHeartbeatLoop: constructed only when
    cfg.leadRollover() != null at startup. Nothing calls it yet — expected, and out of this
    unit's scope.

Config classification: HOT, and why

Classified leadRollover: as HOT in ConfigRef (joins placement, memberCredentials,
memberLoginShell, models in the hot-excluded set — 5 total now, was 4). Reasoning: unlike
LeadHeartbeatLoop, which bakes its config numbers into final fields at construction time (what
makes leadHeartbeat: DEFERRED), LeadRollover holds a Supplier<FleetConfig.LeadRollover>
(() -> config.get().leadRollover(), the same shape fleet/placement/models already use) and
reads every field fresh on each open()/confirm() call.

One caveat, documented in both ConfigRef.java and FleetConfig.java: the LeadRollover
object's construction is still gated on cfg.leadRollover() != null read from the startup
config snapshot in Fleetd.java (per the ticket's instruction to mirror LeadHeartbeatLoop's
construction gating), so adding the block where it was absent at boot still needs a restart before
anything exists to call. This is a structural/existence fact, not a stale-value fact — analogous to
how adding a brand-new profiles: entry needs a restart even though an existing profile's fields
are genuinely hot.

Tests (all new/updated tests pinned against the unmutated file first)

  • LeadRolloverTest (14 cases) covers all 6 hard requirements from the ticket:

    1. No leadRollover: block ⇒ no object constructed (proven via Fleetd.leadRollover(...)'s
      gate, cross-checked by the wiring test below).
    2. nothingButAnExplicitConfirmCanEverRollAPane — open() alone never calls agents.send.
    3. Three separate tests: missingHandoverFileRefuses (HANDOVER_MISSING),
      emptyHandoverFileRefuses (HANDOVER_EMPTY), staleHandoverFileRefuses (HANDOVER_STALE).
    4. operatorConfirmRequiredAndNotGivenRefuses (OPERATOR_NOT_CONFIRMED).
    5. freshnessCheckUsesTheInjectedWallClockNotNanoTime — proves open()/confirm() use the
      injected LongSupplier, not System.nanoTime().
    • Plus: cancel(), open() throwing when unconfigured, a CLEAR_DID_NOT_SETTLE refusal path,
      and a full successful roll (/clear → settle → bootstrapText, in order, token consumed).
  • FleetdLeadRolloverWiringTest — source-text pin on Fleetd.main's construction call
    (LeadRollover leadRollover = leadRollover(cfg, primaryRegistry, router.leadAgents(), config);),
    guarded by an unrelated anchor assertion (public final class Fleetd), mirroring
    FleetdCompletionResolverWiringTest's pattern — the same shape that already pins 5 log-only
    reporters in Fleetd.main against silent wiring drift.

  • Updated 4 existing tests to account for the new 26th FleetConfig record component:
    ConfigRefTopLevelCoverageTest, ConfigRefTopLevelReportingCoverageTest,
    FleetConfigValidateAllTest, FleetConfigWithDefaultsPreservesEveryComponentTest.

Build result

mvn clean install from the fleetd module, full unpiped output, grepped explicitly for both
BUILD SUCCESS/BUILD FAILURE:

[INFO] BUILD SUCCESS
[INFO] Tests run: 1648, Failures: 0, Errors: 0, Skipped: 0

(First full-build attempt caught 2 pre-existing coverage tests that needed updating for the new
record component — both fixed and re-verified green before this PR.)

Not done / out of scope

  • The MCP tool that will call open/confirm/cancel — explicitly a later unit.
  • The CLAUDE.md-to-wiki sync check — wiki/ is uninitialized in this worker worktree
    (git submodule status shows a leading -), unsatisfiable for a worker; leaving to the lead.
# fleetd #480 Unit A — lead rollover core ## What this builds A lead session fills up its context and must be replaced. This unit is the config block and the executor only — **not** the MCP tool (a later unit will call `LeadRollover` from an MCP tool). 1. **`leadRollover:` config block** (opt-in top-level key in `FleetConfig`): `handoverPath` (required when the block is present), `requireOperatorConfirm` (default `true`), `maxDocAgeSeconds` (default `3600`), `clearSettleSeconds` (default `20`), `bootstrapText` (default names `handoverPath`). Registered in `ConfigRef`'s hot/deferred/split/cold classification, documented in `fleetd.example.yaml`, validated by a new `validateLeadRollover()` (auto-wired into `FleetConfig.validateAll()`'s reflective sweep). 2. **`dev.ltms.fleet.lead.LeadRollover`** — the executor: - `open(String reason)` → records a token, the resolved handover path, and the wall-clock timestamp of the request. - `confirm(String token, boolean operatorConfirmed)` → verifies (operator confirmation, then the handover file: exists, non-empty, modified after `open()` and not older than `maxDocAgeSeconds`), then rolls: `agents.send(lead, "/clear")` → poll until the pane reports injectable again (bounded by `clearSettleSeconds`) → `agents.send(lead, bootstrapText)`. If the pane never settles, `bootstrapText` is never sent and the refusal says so. - `cancel(String token)` drops a pending request. - **Nothing but an explicit `confirm()` call can ever roll a pane** — no timer, no heartbeat, no background thread anywhere in this class. - Both `/clear` and `bootstrapText` go through `agents.send(...)` directly, never through `Injector` — a live probe (recorded in the ticket) proved a `/clear` routed through `Injector` wedges that pane forever, since `/clear` produces no turn boundary. - The freshness check takes an injected wall-clock `LongSupplier`, never `System.nanoTime()` (which freezes across a Mac sleep — fleetd #386). 3. **Wired into `Fleetd.java`** exactly like `LeadHeartbeatLoop`: constructed only when `cfg.leadRollover() != null` at startup. Nothing calls it yet — expected, and out of this unit's scope. ## Config classification: HOT, and why Classified `leadRollover:` as **HOT** in `ConfigRef` (joins `placement`, `memberCredentials`, `memberLoginShell`, `models` in the hot-excluded set — 5 total now, was 4). Reasoning: unlike `LeadHeartbeatLoop`, which bakes its config numbers into `final` fields at construction time (what makes `leadHeartbeat:` DEFERRED), `LeadRollover` holds a `Supplier<FleetConfig.LeadRollover>` (`() -> config.get().leadRollover()`, the same shape `fleet`/`placement`/`models` already use) and reads every field fresh on each `open()`/`confirm()` call. **One caveat**, documented in both `ConfigRef.java` and `FleetConfig.java`: the `LeadRollover` object's *construction* is still gated on `cfg.leadRollover() != null` read from the startup config snapshot in `Fleetd.java` (per the ticket's instruction to mirror `LeadHeartbeatLoop`'s construction gating), so adding the block where it was absent at boot still needs a restart before anything exists to call. This is a structural/existence fact, not a stale-value fact — analogous to how adding a brand-new `profiles:` entry needs a restart even though an existing profile's fields are genuinely hot. ## Tests (all new/updated tests pinned against the unmutated file first) - **`LeadRolloverTest`** (14 cases) covers all 6 hard requirements from the ticket: 1. No `leadRollover:` block ⇒ no object constructed (proven via `Fleetd.leadRollover(...)`'s gate, cross-checked by the wiring test below). 2. `nothingButAnExplicitConfirmCanEverRollAPane` — `open()` alone never calls `agents.send`. 3. Three separate tests: `missingHandoverFileRefuses` (`HANDOVER_MISSING`), `emptyHandoverFileRefuses` (`HANDOVER_EMPTY`), `staleHandoverFileRefuses` (`HANDOVER_STALE`). 4. `operatorConfirmRequiredAndNotGivenRefuses` (`OPERATOR_NOT_CONFIRMED`). 5. `freshnessCheckUsesTheInjectedWallClockNotNanoTime` — proves `open()`/`confirm()` use the injected `LongSupplier`, not `System.nanoTime()`. - Plus: `cancel()`, `open()` throwing when unconfigured, a `CLEAR_DID_NOT_SETTLE` refusal path, and a full successful roll (`/clear` → settle → `bootstrapText`, in order, token consumed). - **`FleetdLeadRolloverWiringTest`** — source-text pin on `Fleetd.main`'s construction call (`LeadRollover leadRollover = leadRollover(cfg, primaryRegistry, router.leadAgents(), config);`), guarded by an unrelated anchor assertion (`public final class Fleetd`), mirroring `FleetdCompletionResolverWiringTest`'s pattern — the same shape that already pins 5 log-only reporters in `Fleetd.main` against silent wiring drift. - Updated 4 existing tests to account for the new 26th `FleetConfig` record component: `ConfigRefTopLevelCoverageTest`, `ConfigRefTopLevelReportingCoverageTest`, `FleetConfigValidateAllTest`, `FleetConfigWithDefaultsPreservesEveryComponentTest`. ## Build result `mvn clean install` from the `fleetd` module, full unpiped output, grepped explicitly for both `BUILD SUCCESS`/`BUILD FAILURE`: ``` [INFO] BUILD SUCCESS [INFO] Tests run: 1648, Failures: 0, Errors: 0, Skipped: 0 ``` (First full-build attempt caught 2 pre-existing coverage tests that needed updating for the new record component — both fixed and re-verified green before this PR.) ## Not done / out of scope - The MCP tool that will call `open`/`confirm`/`cancel` — explicitly a later unit. - The CLAUDE.md-to-wiki sync check — `wiki/` is uninitialized in this worker worktree (`git submodule status` shows a leading `-`), unsatisfiable for a worker; leaving to the lead.
agent added 1 commit 2026-09-11 01:33:41 +02:00
fleetd #480 Unit A: lead rollover core (config block + executor)
CI / contract (pull_request) Successful in 1m16s
CI / build (pull_request) Successful in 1m58s
5c12865c25
Adds the opt-in leadRollover: config block and LeadRollover, the executor a
later unit's MCP tool will call. A lead writes a handover file, then open()
records a token and confirm() verifies it (exists, non-empty, fresh) and an
operator confirmation before clearing the lead's own pane via /clear (sent
directly through AgentControl, bypassing Injector, same as
ClaudeCodeLauncher#clearContext) and bootstrapping a fresh session. Nothing
but an explicit confirm() call can ever roll a pane - no timer, no heartbeat,
no background thread anywhere in this class.

Wired into Fleetd.java exactly like LeadHeartbeatLoop: constructed only when
leadRollover: is present at startup, and nothing calls it yet - the MCP tool
is a separate, later unit.

Classified leadRollover: as HOT in ConfigRef (joins placement/
memberCredentials/memberLoginShell/models): the executor holds
Supplier<FleetConfig.LeadRollover> and reads every field fresh per call,
unlike LeadHeartbeatLoop's frozen final fields. The one caveat: the object's
construction is still gated on presence in the startup config snapshot, so a
freshly-added block needs a restart before anything exists to call.

Tests: LeadRolloverTest (14 cases covering the 6 hard requirements - no
object without the config block, only confirm() can roll, missing/empty/
stale handover file each refuse by name, requireOperatorConfirm gating, and
the injected wall-clock supplier) and FleetdLeadRolloverWiringTest (source-
text pin on Fleetd.main's construction call, mirroring
FleetdCompletionResolverWiringTest). Also updated the existing
FleetConfigValidateAllTest, FleetConfigWithDefaultsPreservesEveryComponentTest,
ConfigRefTopLevelCoverageTest and ConfigRefTopLevelReportingCoverageTest to
account for the new record component.
agent added 1 commit 2026-09-11 01:45:05 +02:00
fleetd #480 correction round: defer the roll, and gate it on caller identity
CI / contract (pull_request) Successful in 1m14s
CI / build (pull_request) Successful in 2m3s
a94262271b
Two defects found after the fact, both from the original brief, both fixed here.

1. confirm() is called FROM the calling lead's own turn, so its pane is still
   WORKING and can never report injectable inside that same call. The old
   confirm() sent /clear before polling for that — the poll always timed out,
   but only after /clear had already fired and queued, destroying the lead's
   context with no fresh session ever started and a refusal return that lied
   about what had happened.

   Fix: confirm() now only validates and, if every gate passes, hands a
   one-shot continuation to a new continuationRunner (a real virtual thread in
   production, Runnable::run in tests) and returns RollDecision.approved()
   immediately - "scheduled", not "rolled". The continuation itself does the
   actual work, once the calling turn has ended: wait for the SAME pane to
   report injectable again (new turnSettleSeconds config key, default 20) -
   if this never happens, /clear is NEVER sent, at all - then /clear, then
   wait again (clearSettleSeconds, as before), then bootstrapText. The "no
   timer/scheduler, only confirm() can roll" invariant is restated precisely
   in LeadRollover's class javadoc: it is about initiative, not synchronicity
   - a single-shot continuation of an already-approved confirm() call still
   satisfies it; a recurring background loop would not.

2. confirm() resolved the pane to clear via PrimaryRegistry.primaryTerminal(),
   a single-slot lookup that is correct for a background loop with no caller
   but wrong here: on a daemon with more than one labelled lead tab, lead X's
   confirm() could clear lead Y's pane, violating the charter's "identity
   comes from the connection, never an argument" invariant.

   Fix: open() and confirm() now take the caller's terminal id as a parameter
   (resolved by the MCP layer from the connection - the later MCP-tool unit
   must pass it in, never accept it as a request field). confirm() refuses
   with a new NOT_YOUR_ROLLOVER reason unless it matches the terminal open()
   recorded. LeadRollover no longer depends on PrimaryRegistry at all.

Also: renamed RollResult to RollDecision (rolled -> accepted) to reflect the
new meaning - approved and scheduled, not necessarily cleared yet. Added
turnSettleSeconds to the leadRollover: config block (documented in
fleetd.example.yaml alongside the existing keys) and updated Fleetd.java's
leadRollover(...) factory to drop the primaryRegistry parameter, with
FleetdLeadRolloverWiringTest's source-text pin updated to match.

New tests: turnThatNeverSettlesSendsNoClearAtAll (the branch that matters
most - a turn that never ends means /clear is never sent) and
aDifferentLeadTerminalCannotConfirmAnotherLeadsRollover (NOT_YOUR_ROLLOVER),
plus a settle-after-clear timeout test and an open() input-validation test.
LeadRolloverTest: 11 -> 14 tests.
Author
Member

Correction round (fleetd #480)

Two defects found after the initial review, both fixed on this branch (same PR, new commit a942622):

1. confirm() deadlocked and destroyed context. It sent /clear before polling for the pane
to become injectable — but confirm() is called FROM the calling lead's own turn, so that pane is
still WORKING and can never report injectable inside that same call. The poll always timed out,
but only after /clear had already fired and queued in the pane, destroying the lead's context
with no fresh session ever started.

Fix: confirm() now only validates every gate, then hands a one-shot continuation (a real virtual
thread in production, Runnable::run in tests) to do the actual work and returns
RollDecision.approved() immediately — "scheduled", not "rolled". The continuation waits for the
SAME pane to report injectable again (new turnSettleSeconds config key, default 20) before
sending /clear at all; if that wait times out, /clear is never sent. The "only confirm() can
roll, no timer/scheduler" invariant still holds — it was always about initiative, not synchronicity.

2. confirm() used PrimaryRegistry.primaryTerminal() — a single-slot lookup wrong for a
feature with a caller. On a daemon with more than one labelled lead tab, lead X's confirm() could
clear lead Y's pane. Fix: open()/confirm() now take the caller's terminal id as a parameter
(to be resolved by the MCP layer from the connection in the later tool-wiring unit), and confirm()
refuses with a new NOT_YOUR_ROLLOVER reason unless it matches the terminal open() recorded.

Build: mvn clean install — BUILD SUCCESS, Tests run: 1651, Failures: 0, Errors: 0, Skipped: 0.

New tests: turnThatNeverSettlesSendsNoClearAtAll (the branch that matters most) and
aDifferentLeadTerminalCannotConfirmAnotherLeadsRollover, plus a settle-after-clear timeout test
and an open() input-validation test. LeadRolloverTest: 11 → 14 tests.

## Correction round (fleetd #480) Two defects found after the initial review, both fixed on this branch (same PR, new commit `a942622`): **1. `confirm()` deadlocked and destroyed context.** It sent `/clear` before polling for the pane to become injectable — but `confirm()` is called FROM the calling lead's own turn, so that pane is still `WORKING` and can never report injectable inside that same call. The poll always timed out, but only after `/clear` had already fired and queued in the pane, destroying the lead's context with no fresh session ever started. Fix: `confirm()` now only validates every gate, then hands a one-shot continuation (a real virtual thread in production, `Runnable::run` in tests) to do the actual work and returns `RollDecision.approved()` immediately — "scheduled", not "rolled". The continuation waits for the SAME pane to report injectable again (new `turnSettleSeconds` config key, default 20) before sending `/clear` at all; if that wait times out, `/clear` is never sent. The "only confirm() can roll, no timer/scheduler" invariant still holds — it was always about initiative, not synchronicity. **2. `confirm()` used `PrimaryRegistry.primaryTerminal()`** — a single-slot lookup wrong for a feature with a caller. On a daemon with more than one labelled lead tab, lead X's `confirm()` could clear lead Y's pane. Fix: `open()`/`confirm()` now take the caller's terminal id as a parameter (to be resolved by the MCP layer from the connection in the later tool-wiring unit), and `confirm()` refuses with a new `NOT_YOUR_ROLLOVER` reason unless it matches the terminal `open()` recorded. Build: `mvn clean install` — `BUILD SUCCESS`, `Tests run: 1651, Failures: 0, Errors: 0, Skipped: 0`. New tests: `turnThatNeverSettlesSendsNoClearAtAll` (the branch that matters most) and `aDifferentLeadTerminalCannotConfirmAnotherLeadsRollover`, plus a settle-after-clear timeout test and an `open()` input-validation test. `LeadRolloverTest`: 11 → 14 tests.
ltms merged commit 4bab23e241 into main 2026-09-11 01:51:21 +02:00
ltms deleted branch worker/480-a-rollover-core-6fc2ad-4 2026-09-11 01:51:21 +02:00
Sign in to join this conversation.