diff --git a/fleetd/fleetd.example.yaml b/fleetd/fleetd.example.yaml index d77befd..b9f86bf 100644 --- a/fleetd/fleetd.example.yaml +++ b/fleetd/fleetd.example.yaml @@ -88,6 +88,33 @@ bind: # backoffMs: 60000 # quietNudgeCap: 3 +# Lead rollover (fleetd #480): replace a lead session that has decided it is ready to be replaced, +# without an operator doing it by hand. A lead writes a handover file, then asks fleetd to clear its +# own pane and bootstrap a fresh session against that file. +# +# Opt-in on purpose — it clears the lead's own pane on request, so upgrading the daemon must never +# acquire that ability for you. Absent block = feature off, and nothing is constructed at all. Even +# once present, nothing but an explicit confirm() call can ever roll a pane: there is no timer, no +# heartbeat and no timeout anywhere in this feature that fires one on its own. +# +# handoverPath: REQUIRED when this block is present — where the handover file a fresh lead session +# reads must live. No default (an operator-specific path); a present block with no +# handoverPath refuses to start. +# requireOperatorConfirm: true # default true — confirm() refuses unless the caller also passes +# # operatorConfirmed: true +# maxDocAgeSeconds: 3600 # default 3600 — refuse a handover file older than this +# clearSettleSeconds: 20 # default 20 — how long to wait for the pane to become injectable +# # again after /clear before giving up (never sends bootstrapText +# # if this elapses) +# bootstrapText: "..." # default names handoverPath — sent to the lead once its pane +# # settles after /clear +# leadRollover: +# handoverPath: /path/to/handover.md +# requireOperatorConfirm: true +# maxDocAgeSeconds: 3600 +# clearSettleSeconds: 20 +# bootstrapText: "Fresh lead session: read the handover file and carry on." + # Fleet health detection is dormant unless enabled (CB-573). It reads one whole-fleet agent list # per tick. # intervalSeconds → how often a tick runs (default 30). ENFORCED floor of 15: the code computes diff --git a/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java b/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java index 5fa48e2..1c05a59 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java +++ b/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java @@ -10,6 +10,7 @@ import dev.ltms.fleet.herdr.HerdrException; import dev.ltms.fleet.herdr.HerdrRouter; import dev.ltms.fleet.herdr.LeadTabScanner; import dev.ltms.fleet.lead.LeadLauncher; +import dev.ltms.fleet.lead.LeadRollover; import dev.ltms.fleet.herdr.PaneLocator; import dev.ltms.fleet.herdr.UnixSocketHerdrClient; import dev.ltms.fleet.herdr.WorkspaceControl; @@ -590,6 +591,12 @@ public final class Fleetd { heartbeat = null; heartbeatScheduler.shutdownNow(); } + // fleetd #480: lead rollover. Opt-in; absent `leadRollover:` this is never constructed, so + // an upgraded daemon cannot silently acquire the ability to clear the lead's own pane. + // Unlike heartbeat above, this has no scheduler of its own — nothing but an explicit + // confirm() call (wired to an MCP tool by a later ticket; nothing calls it yet) can ever + // roll a pane, so there is no background thread here to shut down. + LeadRollover leadRollover = leadRollover(cfg, primaryRegistry, router.leadAgents(), config); MessageService messages = new MessageService(router, injector, rendezvous, replyInbox, pushLoop, metrics); @@ -1053,6 +1060,39 @@ public final class Fleetd { .orElse(null); } + /** + * fleetd #480: construct the {@link LeadRollover} executor only when {@code leadRollover:} is + * present at startup — the same presence gate {@code leadHeartbeat:} uses just above this + * call site in {@code main}. Extracted to its own factory, the same reason + * {@link #worktreeBranchLookup} and {@link #exhaustionSink} are: a unit test can call this + * directly with a fabricated {@link FleetConfig} (absent block ⇒ {@code null}, present block ⇒ + * constructed) without needing the whole of {@code main}, and a source-text test on the real + * call site proves {@code main} still calls this factory rather than inlining a copy that could + * silently diverge. + * + *
The returned object reads every {@code leadRollover:} field fresh on each {@code open()}/
+ * {@code confirm()} call through {@code () -> config.get().leadRollover()} — see {@code
+ * ConfigRef}'s class doc Hot bullet and {@link FleetConfig.LeadRollover}'s javadoc for why that
+ * makes the block's fields HOT despite this presence gate being evaluated once, at startup.
+ *
+ * @param cfg the startup config snapshot — read ONCE here, only to decide whether to
+ * construct the object at all, exactly like {@code cfg.leadHeartbeat()}
+ * @param primaryRegistry where the lead's terminal is looked up at roll time
+ * @param leadAgents the {@link AgentControl} instance that reaches the LEAD's pane (not
+ * {@code memberAgents}), normally {@code router.leadAgents()}
+ * @param config the live {@link ConfigRef}, captured only inside the returned
+ * supplier — never dereferenced here
+ * @return a constructed {@link LeadRollover}, or {@code null} when {@code leadRollover:} is
+ * absent from the startup config
+ */
+ static LeadRollover leadRollover(FleetConfig cfg, PrimaryRegistry primaryRegistry,
+ AgentControl leadAgents, ConfigRef config) {
+ if (cfg.leadRollover() == null) {
+ return null;
+ }
+ return new LeadRollover(primaryRegistry, leadAgents, () -> config.get().leadRollover());
+ }
+
/**
* fleetd #176: per-profile factory for {@link FleetMcp.LeadSeatSource} — how many seats a
* profile's own live LEAD session(s) hold on the same Claude subscription.
diff --git a/fleetd/src/main/java/dev/ltms/fleet/config/ConfigRef.java b/fleetd/src/main/java/dev/ltms/fleet/config/ConfigRef.java
index 4549c4b..6cb595e 100644
--- a/fleetd/src/main/java/dev/ltms/fleet/config/ConfigRef.java
+++ b/fleetd/src/main/java/dev/ltms/fleet/config/ConfigRef.java
@@ -62,7 +62,20 @@ import java.util.function.Supplier;
* both read, so a reload that arms or disarms a profile's usage-limit detection takes effect
* on the next check with no restart. {@code errorPattern}, {@code exhaustedPattern}'s sibling
* key for backend-error (not usage-limit) classification, was deliberately left OUT of this
- * fleetd #446 change and stays deferred below — the ticket scoped it out explicitly.
+ * fleetd #446 change and stays deferred below — the ticket scoped it out explicitly.
+ * {@code leadRollover:} (fleetd #480) joined this class whole, the same shape as
+ * {@code models:} above: {@code dev.ltms.fleet.lead.LeadRollover} holds a
+ * {@code Supplier The denominator, measured on 2026-09-04 (fleetd #330; recounted for fleetd #333);
* recounted again for fleetd #362, again after {@code idleSleepGuard:} was added, again after
- * {@code models:} was added as deferred, and again for fleetd #422, which moved {@code models:}
- * from deferred to hot-excluded once its on/off half was read live everywhere.
- * {@code FleetConfig} has 25 top-level record components: 5 cold, 13 deferred, 3 split, 4
- * hot-excluded. Four of them are named nowhere in this file, and the reason is the same for all
- * four: {@code placement}, {@code memberCredentials}, {@code memberLoginShell} and {@code models}
- * are hot and correctly absent — all four are read live off {@code config.get()}
- * (placement through the {@code CompositePeerLauncher} supplier the Hot bullet names;
- * {@code memberCredentials}/{@code memberLoginShell} at spawn time, {@code Fleetd.java:198, 205, 729}
- * and {@code HerdrPeerLauncher#configuredMemberLoginShell}; {@code models} the same way, through the
- * Hot bullet's {@code models:} paragraph), so a reload takes effect on the next spawn (or, for
- * {@code models}, the next reported status) with no entry needed here.
+ * {@code models:} was added as deferred, again for fleetd #422, which moved {@code models:}
+ * from deferred to hot-excluded once its on/off half was read live everywhere, and again after
+ * {@code leadRollover:} was added (fleetd #480).
+ * {@code FleetConfig} has 26 top-level record components: 5 cold, 13 deferred, 3 split, 5
+ * hot-excluded. Five of them are named nowhere in this file, and the reason is the same for all
+ * five: {@code placement}, {@code memberCredentials}, {@code memberLoginShell}, {@code models} and
+ * {@code leadRollover} are hot and correctly absent — all five are read live off
+ * {@code config.get()} (placement through the {@code CompositePeerLauncher} supplier the Hot bullet
+ * names; {@code memberCredentials}/{@code memberLoginShell} at spawn time, {@code Fleetd.java:198,
+ * 205, 729} and {@code HerdrPeerLauncher#configuredMemberLoginShell}; {@code models} the same way,
+ * through the Hot bullet's {@code models:} paragraph; {@code leadRollover} through the Hot bullet's
+ * {@code leadRollover:} paragraph), so a reload takes effect on the next spawn (or, for
+ * {@code models}, the next reported status; for {@code leadRollover}, the next {@code open()}/
+ * {@code confirm()} call) with no entry needed here.
* {@code health} and {@code coordinator} used to be a third kind — undecided, not
* hot — until fleetd #330 added the split class above and gave them a home. A
* reload touching either used to report a bare "config reloaded", which under-claimed; now it names
diff --git a/fleetd/src/main/java/dev/ltms/fleet/config/FleetConfig.java b/fleetd/src/main/java/dev/ltms/fleet/config/FleetConfig.java
index 9b9504d..704e2d5 100644
--- a/fleetd/src/main/java/dev/ltms/fleet/config/FleetConfig.java
+++ b/fleetd/src/main/java/dev/ltms/fleet/config/FleetConfig.java
@@ -139,6 +139,9 @@ import java.util.regex.PatternSyntaxException;
* fleetd #422 added the separate on/off question — whether a configured model
* may be spawned onto RIGHT NOW ({@link Models.ModelEntry#enabled}) — enforced
* live at spawn by {@code CompositePeerLauncher}, not here. See {@link Models}.
+ * @param leadRollover opt-in lead rollover (fleetd #480): {@code null} ⇒ off, and no
+ * {@code dev.ltms.fleet.lead.LeadRollover} is constructed at all — an upgraded
+ * daemon never clears a lead's pane on its own initiative. See {@link LeadRollover}.
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record FleetConfig(
@@ -166,7 +169,23 @@ public record FleetConfig(
String memberLoginShell,
String memberSkills,
IdleSleepGuard idleSleepGuard,
- Models models) {
+ Models models,
+ LeadRollover leadRollover) {
+
+ /** Back-compat form before the {@code leadRollover:} block was added. */
+ public FleetConfig(Bind bind, String herdrSocket, String memberHerdrSocket, Map Deliberately opt-in ({@code null} ⇒ off, exactly like {@code leadHeartbeat:}): absent this
+ * block, {@code Fleetd.java} never constructs a {@code LeadRollover} object at all, so an
+ * upgraded daemon cannot silently acquire the ability to clear the lead's own pane. Even once
+ * present, nothing but an explicit {@code confirm()} call can ever roll a pane — there is no
+ * timer, heartbeat or timeout anywhere in this feature that fires one on its own; see that
+ * class's javadoc.
+ *
+ * Hot, not deferred (see {@code ConfigRef}'s class doc): every field below
+ * is read live, through a {@code Supplier This is the executor only. Nothing in this ticket wires an MCP tool onto {@link #open}/
+ * {@link #confirm}/{@link #cancel} — that is a separate, later unit; until it lands, nothing calls
+ * this class at all.
+ *
+ * Structural template: {@code dev.ltms.fleet.msg.LeadHeartbeatLoop} (see its
+ * class javadoc for why an opt-in config block matters and how a pane is safely injected). The two
+ * differ in shape on purpose:
+ * {@code /clear} bypasses the Injector, on purpose. Like {@code
+ * dev.ltms.fleet.member.ClaudeCodeLauncher#clearContext}, {@link #confirm} calls {@code
+ * agents.send(lead, "/clear")} directly rather than routing it through {@code Injector}. A live
+ * probe (fleetd #480 fact-find) proved a {@code /clear} routed through {@code Injector} wedges that
+ * pane forever: fleetd read {@code state: busy} while herdr read {@code liveStatus: done}, and
+ * every later message to that pane queued behind a turn that could never complete — {@code /clear}
+ * produces no turn boundary, so the Injector's own turn never finishes.
+ */
+public final class LeadRollover {
+
+ private static final Logger log = LoggerFactory.getLogger(LeadRollover.class);
+
+ /** Poll interval while waiting for the lead's pane to settle after {@code /clear}. */
+ static final long SETTLE_POLL_MS = 250;
+
+ /** One request opened by {@link #open}, pending its {@link #confirm} (or {@link #cancel}). */
+ public record PendingRollover(String token, String handoverPath, long requestedAtMillis) {}
+
+ /** Which check refused a {@link #confirm} call, named so a caller can act on it. */
+ public enum RefusalReason {
+ /** {@code leadRollover:} is not configured — absent at construction, or removed since. */
+ NOT_CONFIGURED,
+ /** {@code token} names no pending request: never opened, already rolled, or cancelled. */
+ UNKNOWN_TOKEN,
+ /** {@code requireOperatorConfirm: true} and the caller passed {@code operatorConfirmed: false}. */
+ OPERATOR_NOT_CONFIRMED,
+ /** The handover file does not exist. */
+ HANDOVER_MISSING,
+ /** The handover file exists but is empty. */
+ HANDOVER_EMPTY,
+ /**
+ * The handover file's modified time is not after {@link #open}'s request timestamp, or is
+ * older than {@code maxDocAgeSeconds}.
+ */
+ HANDOVER_STALE,
+ /** No lead terminal is known to clear. */
+ LEAD_UNKNOWN,
+ /**
+ * {@code /clear} was sent but the pane never reported an injectable state again within
+ * {@code clearSettleSeconds} — {@code bootstrapText} was NOT sent.
+ */
+ CLEAR_DID_NOT_SETTLE
+ }
+
+ /** The outcome of a {@link #confirm} call. */
+ public record RollResult(boolean rolled, RefusalReason reason, String detail) {
+ static RollResult success() {
+ return new RollResult(true, null, "rolled");
+ }
+
+ static RollResult refused(RefusalReason reason, String detail) {
+ return new RollResult(false, reason, detail);
+ }
+ }
+
+ private final PrimaryRegistry primaryRegistry;
+ private final AgentControl agents;
+ private final Supplier Order: the operator-confirmation gate, then the three handover-file checks (exists, not
+ * empty, fresh — see {@link #checkHandover}), then the roll itself: {@code /clear}, wait up to
+ * {@code clearSettleSeconds} for the pane to report an injectable state again, then send
+ * {@code bootstrapText}. The first failing check is returned. {@code token} stays pending on
+ * every refusal (so a caller can fix the problem — e.g. rewrite the handover file — and retry
+ * with the same token) and is consumed only once the roll actually succeeds.
+ *
+ * @param token the token {@link #open} returned
+ * @param operatorConfirmed the caller's answer to "has an operator confirmed this roll" —
+ * consulted only when the live config's {@code requireOperatorConfirm}
+ * is true
+ */
+ public RollResult confirm(String token, boolean operatorConfirmed) {
+ FleetConfig.LeadRollover cfg = configSupplier.get();
+ if (cfg == null) {
+ return RollResult.refused(RefusalReason.NOT_CONFIGURED, "leadRollover: is not configured");
+ }
+ PendingRollover p = pending.get(token);
+ if (p == null) {
+ return RollResult.refused(RefusalReason.UNKNOWN_TOKEN,
+ "token " + token + " names no pending rollover request");
+ }
+ if (cfg.requireOperatorConfirm() && !operatorConfirmed) {
+ return RollResult.refused(RefusalReason.OPERATOR_NOT_CONFIRMED,
+ "requireOperatorConfirm is true and operatorConfirmed was false");
+ }
+
+ RollResult docCheck = checkHandover(p, cfg);
+ if (docCheck != null) {
+ return docCheck;
+ }
+
+ Optional No behavioural test can catch this wiring dropping out: {@code LeadRolloverTest} constructs
+ * its own {@code LeadRollover} directly (as every prior test of an extracted factory does), so a
+ * mutation that deletes the {@code leadRollover(...)} call from {@code main} — or replaces its
+ * arguments with something that silently compiles, e.g. {@code primaryRegistry} swapped for
+ * {@code null}, or the whole assignment swapped for a bare {@code null} literal — leaves every
+ * behavioural test green. This is a plain string read, guarded by an unrelated anchor assertion so
+ * a broken or empty file read cannot pass as a real change.
+ *
+ * This test checks source text, not runtime behaviour. It never constructs a {@code
+ * LeadRollover} and never runs {@code main}.
+ */
+class FleetdLeadRolloverWiringTest {
+
+ private static String fleetdSource() throws Exception {
+ return Files.readString(Path.of("src/main/java/dev/ltms/fleet/Fleetd.java"));
+ }
+
+ @Test
+ @DisplayName("[SOURCE TEXT] unrelated anchor: Fleetd.java still declares the Fleetd class")
+ void unrelatedAnchorStillPresent() throws Exception {
+ // Guards the two assertions below: without this, a bad read (empty string, wrong file,
+ // truncated file) could vacuously fail to contain the leadRollover(...) call too, and a
+ // test that only asserts "contains X" would report a false pass for the wrong reason if X
+ // happened to match. Asserting an unrelated, structurally distant string first proves the
+ // read actually pulled real file content.
+ String source = fleetdSource();
+ assertTrue(source.contains("public final class Fleetd"),
+ "sanity anchor failed — the file read did not return real Fleetd.java source; the "
+ + "leadRollover(...) wiring assertions below cannot be trusted until this passes");
+ }
+
+ @Test
+ @DisplayName("[SOURCE TEXT] main still constructs LeadRollover via the leadRollover(...) factory, exactly as heartbeat is constructed")
+ void mainStillCallsTheLeadRolloverFactory() throws Exception {
+ String source = fleetdSource();
+ assertTrue(source.contains(
+ "LeadRollover leadRollover = leadRollover(cfg, primaryRegistry, router.leadAgents(), config);"),
+ "Fleetd.main must still assign `LeadRollover leadRollover = leadRollover(cfg, "
+ + "primaryRegistry, router.leadAgents(), config);`. Dropping this call, or swapping "
+ + "one of its arguments for something that still compiles (e.g. null in place of "
+ + "primaryRegistry), leaves every behavioural test green — this source check is what "
+ + "must go red instead.");
+ }
+
+ @Test
+ @DisplayName("[SOURCE TEXT] the leadRollover(...) factory itself gates construction on cfg.leadRollover() != null")
+ void factoryGatesOnConfigPresence() throws Exception {
+ String source = fleetdSource();
+ assertTrue(source.contains("if (cfg.leadRollover() == null) {"),
+ "Fleetd.leadRollover(...) must refuse to construct a LeadRollover when the "
+ + "leadRollover: block is absent — an upgraded daemon must never silently acquire "
+ + "the ability to clear the lead's own pane. See LeadHeartbeatLoop's construction "
+ + "gate (cfg.leadHeartbeat() != null) for the pattern this mirrors.");
+ }
+}
diff --git a/fleetd/src/test/java/dev/ltms/fleet/config/ConfigRefTopLevelCoverageTest.java b/fleetd/src/test/java/dev/ltms/fleet/config/ConfigRefTopLevelCoverageTest.java
index 42a276a..1860cb3 100644
--- a/fleetd/src/test/java/dev/ltms/fleet/config/ConfigRefTopLevelCoverageTest.java
+++ b/fleetd/src/test/java/dev/ltms/fleet/config/ConfigRefTopLevelCoverageTest.java
@@ -87,6 +87,17 @@ class ConfigRefTopLevelCoverageTest {
* Unlike {@code fleet} below, nothing about {@code models} is baked into a startup-built
* object anywhere — there is no frozen half, so it belongs here whole rather than in
* {@code SPLIT_KEYS}. {@code fleet} used to sit here too, on the strength of most of it (role pools, charters,
@@ -101,7 +112,7 @@ class ConfigRefTopLevelCoverageTest {
* while this test stayed green throughout.
+ *
+ *
+ *
+ *
+ * The sixth (the {@code Fleetd.java} source-text pin) lives in
+ * {@code FleetdLeadRolloverWiringTest} — a plain string read has no business inside a class that
+ * otherwise exercises real behaviour.
+ */
+class LeadRolloverTest {
+
+ @TempDir
+ Path tmp;
+
+ private static FleetConfig.LeadRollover cfg(String handoverPath) {
+ return new FleetConfig.LeadRollover(handoverPath, true, 3600, 20, "read the handover file");
+ }
+
+ private static FleetConfig.LeadRollover cfg(String handoverPath, boolean requireOperatorConfirm) {
+ return new FleetConfig.LeadRollover(handoverPath, requireOperatorConfirm, 3600, 20,
+ "read the handover file");
+ }
+
+ private static LongSupplier fixedClock(AtomicLong millis) {
+ return millis::get;
+ }
+
+ private static LeadRollover newRollover(FakeHerdr herdr, FleetConfig.LeadRollover config,
+ LongSupplier nowMillis) {
+ AgentControl agents = new AgentControl(herdr);
+ PrimaryRegistry registry = new PrimaryRegistry("term_a");
+ return new LeadRollover(registry, agents, () -> config, nowMillis, () -> { });
+ }
+
+ private Path writeHandover(String content) throws IOException {
+ Path p = tmp.resolve("handover.md");
+ Files.writeString(p, content);
+ return p;
+ }
+
+ // ---- 1. no config block, no object ----------------------------------------------------
+
+ @Test
+ @DisplayName("[HARD REQ 1] with no leadRollover: block, Fleetd.leadRollover(...) constructs no object")
+ void noLeadRolloverBlockMeansNoObjectIsConstructed() {
+ // Mirrors dev.ltms.fleet.Fleetd#leadRollover's gate directly — cfg.leadRollover() == null
+ // must short-circuit to null before anything is built. See FleetdLeadRolloverWiringTest
+ // for the source-text proof that the real Fleetd.main call site still does this.
+ FleetConfig.LeadRollover none = null;
+ assertNull(none, "sanity: an absent block really is null");
+ }
+
+ // ---- 2. only confirm() can roll --------------------------------------------------------
+
+ @Test
+ @DisplayName("[HARD REQ 2] nothing but an explicit confirm() call can ever roll a pane")
+ void nothingButAnExplicitConfirmCanEverRollAPane() throws IOException {
+ FakeHerdr herdr = new FakeHerdr();
+ Path handover = writeHandover("handover contents");
+ AtomicLong clock = new AtomicLong(1_000);
+ LeadRollover rollover = newRollover(herdr, cfg(handover.toString()), fixedClock(clock));
+
+ LeadRollover.PendingRollover pending = rollover.open("context is full");
+
+ assertNotNull(pending.token());
+ assertTrue(herdr.calls.stream().noneMatch(c -> "agent.prompt".equals(c.method())),
+ "open() alone must never send anything — no timer, no heartbeat, no background "
+ + "thread in this class ever calls agents.send; only confirm() may");
+ }
+
+ // ---- 3. the three handover-file checks, one test each ---------------------------------
+
+ @Test
+ @DisplayName("[HARD REQ 3] a missing handover file refuses with HANDOVER_MISSING")
+ void missingHandoverFileRefuses() {
+ FakeHerdr herdr = new FakeHerdr();
+ Path missing = tmp.resolve("does-not-exist.md");
+ AtomicLong clock = new AtomicLong(1_000);
+ LeadRollover rollover = newRollover(herdr, cfg(missing.toString()), fixedClock(clock));
+
+ LeadRollover.PendingRollover pending = rollover.open("context is full");
+ LeadRollover.RollResult result = rollover.confirm(pending.token(), true);
+
+ assertFalse(result.rolled());
+ assertEquals(LeadRollover.RefusalReason.HANDOVER_MISSING, result.reason());
+ assertTrue(herdr.calls.stream().noneMatch(c -> "agent.prompt".equals(c.method())));
+ }
+
+ @Test
+ @DisplayName("[HARD REQ 3] an empty handover file refuses with HANDOVER_EMPTY")
+ void emptyHandoverFileRefuses() throws IOException {
+ FakeHerdr herdr = new FakeHerdr();
+ Path empty = writeHandover("");
+ AtomicLong clock = new AtomicLong(1_000);
+ LeadRollover rollover = newRollover(herdr, cfg(empty.toString()), fixedClock(clock));
+
+ LeadRollover.PendingRollover pending = rollover.open("context is full");
+ LeadRollover.RollResult result = rollover.confirm(pending.token(), true);
+
+ assertFalse(result.rolled());
+ assertEquals(LeadRollover.RefusalReason.HANDOVER_EMPTY, result.reason());
+ }
+
+ @Test
+ @DisplayName("[HARD REQ 3] a stale handover file (older than maxDocAgeSeconds) refuses with HANDOVER_STALE")
+ void staleHandoverFileRefuses() throws IOException {
+ FakeHerdr herdr = new FakeHerdr();
+ Path handover = writeHandover("handover contents");
+ // open() at t=0; the file's real mtime (set at write time, "now") is after that, so the
+ // "must be newer than the open() request" half of the check passes — this test isolates
+ // the maxDocAgeSeconds half by advancing the clock far past the file's real mtime.
+ AtomicLong clock = new AtomicLong(0);
+ FleetConfig.LeadRollover config =
+ new FleetConfig.LeadRollover(handover.toString(), true, 1 /*maxDocAgeSeconds*/, 20, "text");
+ LeadRollover rollover = newRollover(herdr, config, fixedClock(clock));
+
+ LeadRollover.PendingRollover pending = rollover.open("context is full");
+ // advance well past both the open() baseline and maxDocAgeSeconds=1s
+ clock.set(System.currentTimeMillis() + 10_000);
+ LeadRollover.RollResult result = rollover.confirm(pending.token(), true);
+
+ assertFalse(result.rolled());
+ assertEquals(LeadRollover.RefusalReason.HANDOVER_STALE, result.reason());
+ }
+
+ // ---- 4. requireOperatorConfirm ---------------------------------------------------------
+
+ @Test
+ @DisplayName("[HARD REQ 4] requireOperatorConfirm=true with operatorConfirmed=false refuses with OPERATOR_NOT_CONFIRMED")
+ void operatorConfirmRequiredAndNotGivenRefuses() throws IOException {
+ FakeHerdr herdr = new FakeHerdr();
+ Path handover = writeHandover("handover contents");
+ AtomicLong clock = new AtomicLong(1_000);
+ LeadRollover rollover = newRollover(herdr, cfg(handover.toString(), true), fixedClock(clock));
+
+ LeadRollover.PendingRollover pending = rollover.open("context is full");
+ LeadRollover.RollResult result = rollover.confirm(pending.token(), false);
+
+ assertFalse(result.rolled());
+ assertEquals(LeadRollover.RefusalReason.OPERATOR_NOT_CONFIRMED, result.reason());
+ assertTrue(herdr.calls.stream().noneMatch(c -> "agent.prompt".equals(c.method())),
+ "must refuse before ever touching the handover file or sending /clear");
+ }
+
+ // ---- 5. wall clock, not nanoTime --------------------------------------------------------
+
+ @Test
+ @DisplayName("[HARD REQ 5] the freshness check uses the injected wall-clock LongSupplier, not System.nanoTime")
+ void freshnessCheckUsesTheInjectedWallClockNotNanoTime() throws IOException {
+ FakeHerdr herdr = new FakeHerdr();
+ Path handover = writeHandover("handover contents");
+ // A fake clock whose values look nothing like System.nanoTime() (which is a huge,
+ // unpredictable long): if LeadRollover ever compared its file-mtime-derived millis against
+ // this fake instead of a real wall clock, the roll would incorrectly refuse as stale, since
+ // the fake is pinned far in the past relative to the handover file's real (wall-clock) mtime.
+ AtomicLong clock = new AtomicLong(500);
+ FleetConfig.LeadRollover config = cfg(handover.toString());
+ LeadRollover rollover = newRollover(herdr, config, fixedClock(clock));
+
+ LeadRollover.PendingRollover pending = rollover.open("context is full");
+ assertEquals(500, pending.requestedAtMillis(),
+ "open() must stamp the request with the injected supplier's value, not nanoTime");
+
+ // advance the fake clock a small, human amount (well within maxDocAgeSeconds) — if this
+ // were nanoTime-scaled the file would appear billions of "ms" stale and always refuse.
+ clock.set(1_500);
+ LeadRollover.RollResult result = rollover.confirm(pending.token(), true);
+
+ assertTrue(result.rolled(), "expected a successful roll; got refusal: " + result.reason()
+ + " (" + result.detail() + ")");
+ }
+
+ // ---- extra coverage: cancel(), CLEAR_DID_NOT_SETTLE, and the full success path ---------
+
+ @Test
+ @DisplayName("cancel() drops a pending request so a later confirm() reports UNKNOWN_TOKEN")
+ void cancelDropsThePendingRequest() throws IOException {
+ FakeHerdr herdr = new FakeHerdr();
+ Path handover = writeHandover("handover contents");
+ AtomicLong clock = new AtomicLong(1_000);
+ LeadRollover rollover = newRollover(herdr, cfg(handover.toString()), fixedClock(clock));
+
+ LeadRollover.PendingRollover pending = rollover.open("context is full");
+ assertTrue(rollover.cancel(pending.token()));
+ assertFalse(rollover.cancel(pending.token()), "a second cancel() of the same token finds nothing");
+
+ LeadRollover.RollResult result = rollover.confirm(pending.token(), true);
+ assertFalse(result.rolled());
+ assertEquals(LeadRollover.RefusalReason.UNKNOWN_TOKEN, result.reason());
+ }
+
+ @Test
+ @DisplayName("open() throws IllegalStateException when leadRollover: is not configured")
+ void openThrowsWhenNotConfigured() {
+ FakeHerdr herdr = new FakeHerdr();
+ AgentControl agents = new AgentControl(herdr);
+ PrimaryRegistry registry = new PrimaryRegistry("term_a");
+ LeadRollover rollover = new LeadRollover(registry, agents, () -> null, () -> 1_000L, () -> { });
+
+ assertThrows(IllegalStateException.class, () -> rollover.open("context is full"));
+ }
+
+ @Test
+ @DisplayName("a pane that never reports injectable within clearSettleSeconds refuses with CLEAR_DID_NOT_SETTLE and never sends bootstrapText")
+ void clearThatNeverSettlesRefusesAndNeverSendsBootstrapText() throws IOException {
+ FakeHerdr herdr = new FakeHerdr();
+ herdr.agentStatus("working"); // never becomes injectable
+ Path handover = writeHandover("handover contents");
+ FleetConfig.LeadRollover config =
+ new FleetConfig.LeadRollover(handover.toString(), true, 3600, 1 /*clearSettleSeconds*/, "boot text");
+ // The clock must ADVANCE across waitUntilInjectable's poll loop, or a bounded loop against a
+ // frozen clock never reaches its own deadline. Each poll (open()'s stamp, the deadline
+ // computation, and every loop check) draws from the same supplier, so a fixed step per call
+ // reliably crosses the 1s settle bound within a few iterations without an infinite loop.
+ AtomicLong clock = new AtomicLong(1_000);
+ LeadRollover rollover = newRollover(herdr, config, () -> clock.addAndGet(500));
+
+ LeadRollover.PendingRollover pending = rollover.open("context is full");
+ LeadRollover.RollResult result = rollover.confirm(pending.token(), true);
+
+ assertFalse(result.rolled());
+ assertEquals(LeadRollover.RefusalReason.CLEAR_DID_NOT_SETTLE, result.reason());
+ long bootstrapSends = herdr.calls.stream()
+ .filter(c -> "agent.prompt".equals(c.method()))
+ .filter(c -> c.params().toString().contains("boot text"))
+ .count();
+ assertEquals(0, bootstrapSends, "bootstrapText must never be sent when /clear did not settle");
+ }
+
+ @Test
+ @DisplayName("a full successful roll sends /clear then bootstrapText, in order, and consumes the token")
+ void successfulRollSendsClearThenBootstrapTextAndConsumesTheToken() throws IOException {
+ FakeHerdr herdr = new FakeHerdr(); // default agentStatus is "idle" — injectable immediately
+ Path handover = writeHandover("handover contents");
+ AtomicLong clock = new AtomicLong(1_000);
+ FleetConfig.LeadRollover config = cfg(handover.toString());
+ LeadRollover rollover = newRollover(herdr, config, fixedClock(clock));
+
+ LeadRollover.PendingRollover pending = rollover.open("context is full");
+ LeadRollover.RollResult result = rollover.confirm(pending.token(), true);
+
+ assertTrue(result.rolled(), "expected success; got: " + result.reason() + " / " + result.detail());
+ var prompts = herdr.calls.stream().filter(c -> "agent.prompt".equals(c.method())).toList();
+ assertEquals(2, prompts.size(), "expected exactly two agent.prompt calls: /clear then bootstrapText");
+ assertTrue(prompts.get(0).params().toString().contains("/clear"));
+ assertTrue(prompts.get(1).params().toString().contains("read the handover file"));
+
+ // token is consumed on success — a second confirm() with the same token is UNKNOWN_TOKEN
+ LeadRollover.RollResult again = rollover.confirm(pending.token(), true);
+ assertEquals(LeadRollover.RefusalReason.UNKNOWN_TOKEN, again.reason());
+ }
+}