diff --git a/fleetd/fleetd.example.yaml b/fleetd/fleetd.example.yaml index b9f86bf..4e4f7ea 100644 --- a/fleetd/fleetd.example.yaml +++ b/fleetd/fleetd.example.yaml @@ -94,8 +94,12 @@ bind: # # 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. +# once present, nothing but an explicit confirm() call — one that passes every check — can ever +# cause a /clear: there is no recurring timer, heartbeat or scheduler anywhere in this feature that +# fires one on its own initiative. confirm() itself is called FROM the calling lead's own turn, so +# it cannot clear the pane inline (that pane is still WORKING); instead it schedules a one-shot +# continuation that waits for the SAME confirm() call's turn to end, then does the actual work. See +# dev.ltms.fleet.lead.LeadRollover's class javadoc for the exact order (fleetd #480 correction). # # 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 @@ -103,15 +107,20 @@ bind: # requireOperatorConfirm: true # default true — confirm() refuses unless the caller also passes # # operatorConfirmed: true # maxDocAgeSeconds: 3600 # default 3600 — refuse a handover file older than this +# turnSettleSeconds: 20 # default 20 — how long the deferred roll waits for the CALLING +# # lead's own turn to end (its pane to report injectable again) +# # before sending /clear at all. If this elapses, /clear is NEVER +# # sent — a lead that never goes idle is still doing real work. # 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) +# # again AFTER /clear before giving up (never sends bootstrapText +# # if this elapses). A separate, second wait from turnSettleSeconds. # bootstrapText: "..." # default names handoverPath — sent to the lead once its pane # # settles after /clear # leadRollover: # handoverPath: /path/to/handover.md # requireOperatorConfirm: true # maxDocAgeSeconds: 3600 +# turnSettleSeconds: 20 # clearSettleSeconds: 20 # bootstrapText: "Fresh lead session: read the handover file and carry on." diff --git a/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java b/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java index 1c05a59..3e8fe3c 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java +++ b/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java @@ -593,10 +593,14 @@ public final class Fleetd { } // 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); + // Unlike heartbeat above, this has no recurring scheduler of its own — nothing but an + // explicit confirm() call (wired to an MCP tool by a later ticket; nothing calls it yet) + // that passes every gate can ever schedule a roll. It does not take primaryRegistry: the + // lead terminal to roll comes from the caller of open()/confirm() (resolved by the MCP + // layer from the connection, the same way auth/CallerResolver#resolve builds a + // Principal.leader(...)), never from a single-slot lookup — see LeadRollover's class + // javadoc, fleetd #480 correction 2. + LeadRollover leadRollover = leadRollover(cfg, router.leadAgents(), config); MessageService messages = new MessageService(router, injector, rendezvous, replyInbox, pushLoop, metrics); @@ -1075,22 +1079,27 @@ public final class Fleetd { * 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 + *
Deliberately does NOT take {@link PrimaryRegistry}: fleetd #480 correction 2 found that a
+ * single-slot lookup lets one lead's {@code confirm()} clear a DIFFERENT lead's pane on a
+ * daemon with more than one labelled lead tab. The lead terminal to roll instead comes from
+ * whoever calls {@code open()}/{@code confirm()} — the later MCP-tool unit must resolve it from
+ * the connection and pass it in, never take it as a request field. See {@link LeadRollover}'s
+ * class javadoc.
+ *
+ * @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 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) {
+ static LeadRollover leadRollover(FleetConfig cfg, AgentControl leadAgents, ConfigRef config) {
if (cfg.leadRollover() == null) {
return null;
}
- return new LeadRollover(primaryRegistry, leadAgents, () -> config.get().leadRollover());
+ return new LeadRollover(leadAgents, () -> config.get().leadRollover());
}
/**
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 6cb595e..96e1ab1 100644
--- a/fleetd/src/main/java/dev/ltms/fleet/config/ConfigRef.java
+++ b/fleetd/src/main/java/dev/ltms/fleet/config/ConfigRef.java
@@ -67,8 +67,10 @@ import java.util.function.Supplier;
* {@code models:} above: {@code dev.ltms.fleet.lead.LeadRollover} holds a
* {@code Supplier Hot, not deferred (see {@code ConfigRef}'s class doc): every field below
* is read live, through a {@code Supplier {@code turnSettleSeconds} (fleetd #480 correction): {@code confirm()} is
+ * called FROM the calling lead's own turn, so its pane is still {@code WORKING} the instant
+ * {@code confirm()} validates every gate and schedules the roll. {@code
+ * dev.ltms.fleet.lead.LeadRollover}'s deferred continuation waits up to this many seconds for
+ * that SAME pane to report an injectable state again — i.e. for the calling turn to actually
+ * end — before it sends {@code /clear} at all. If that wait times out, no {@code /clear} is
+ * ever sent: a lead that never goes idle is still doing real work, and clearing it would
+ * destroy live context. This is a separate wait from {@code clearSettleSeconds} below, which
+ * bounds the SECOND wait, for the pane to re-settle AFTER {@code /clear} has already gone out.
+ *
* @param handoverPath required when this block is present — where the handover file a fresh
* lead session reads must live. There is no sane non-null default for an
* operator-specific path, so a present block with a {@code null}/blank
@@ -1366,6 +1376,9 @@ public record FleetConfig(
* @param maxDocAgeSeconds default 3600 — refuse a handover file whose modified time is older
* than this many seconds, so a stale leftover from an earlier rollover
* attempt can never be mistaken for a fresh one.
+ * @param turnSettleSeconds default 20 — bound on how long the deferred roll waits for the
+ * CALLING lead's own turn to end (its pane to report injectable again)
+ * before sending {@code /clear} at all. See the paragraph above.
* @param clearSettleSeconds default 20 — bound on how long to wait for the lead's pane to
* report an injectable state again after {@code /clear} before giving up. A
* roll that times out here never sends {@code bootstrapText}.
@@ -1375,10 +1388,12 @@ public record FleetConfig(
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record LeadRollover(String handoverPath, Boolean requireOperatorConfirm,
- Integer maxDocAgeSeconds, Integer clearSettleSeconds, String bootstrapText) {
+ Integer maxDocAgeSeconds, Integer turnSettleSeconds,
+ Integer clearSettleSeconds, String bootstrapText) {
public LeadRollover {
requireOperatorConfirm = requireOperatorConfirm == null || requireOperatorConfirm;
maxDocAgeSeconds = (maxDocAgeSeconds == null || maxDocAgeSeconds <= 0) ? 3600 : maxDocAgeSeconds;
+ turnSettleSeconds = (turnSettleSeconds == null || turnSettleSeconds <= 0) ? 20 : turnSettleSeconds;
clearSettleSeconds = (clearSettleSeconds == null || clearSettleSeconds <= 0) ? 20 : clearSettleSeconds;
bootstrapText = (bootstrapText == null || bootstrapText.isBlank())
? "Fresh lead session: read the handover file at " + handoverPath
diff --git a/fleetd/src/main/java/dev/ltms/fleet/lead/LeadRollover.java b/fleetd/src/main/java/dev/ltms/fleet/lead/LeadRollover.java
index f570277..979956f 100644
--- a/fleetd/src/main/java/dev/ltms/fleet/lead/LeadRollover.java
+++ b/fleetd/src/main/java/dev/ltms/fleet/lead/LeadRollover.java
@@ -3,7 +3,6 @@ package dev.ltms.fleet.lead;
import dev.ltms.fleet.config.FleetConfig;
import dev.ltms.fleet.herdr.AgentControl;
import dev.ltms.fleet.herdr.AgentStatus;
-import dev.ltms.fleet.mcp.PrimaryRegistry;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -12,49 +11,82 @@ import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
-import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
+import java.util.function.Consumer;
import java.util.function.LongSupplier;
import java.util.function.Supplier;
/**
* fleetd #480: replace a lead session that has decided it is ready to be rolled over, without an
* operator doing it by hand. A lead writes a handover file, calls {@link #open}, and then — once
- * the three handover-file checks and (by default) an explicit operator confirmation all pass —
- * {@link #confirm} clears the lead's own pane and bootstraps a fresh session against that file.
+ * every gate ({@link #confirm}'s own checks) has passed — a deferred, single-shot continuation
+ * clears the lead's own pane and bootstraps a fresh session against that file.
*
* 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 confirm()} cannot roll inline — a fleetd #480 correction. The first
+ * version of this class called {@code agents.send(lead, "/clear")} directly from inside {@code
+ * confirm()}, then polled for the pane to become injectable again. That is wrong, because {@code
+ * confirm()} is called BY the lead, FROM the lead's own turn: the lead's pane is {@code WORKING}
+ * for the whole duration of that call and cannot possibly report injectable until {@code confirm()}
+ * itself returns. The poll always timed out — but only after the {@code /clear} had already been
+ * sent and queued in the pane, where it fired the instant the turn ended anyway. The result was the
+ * worst outcome this feature can produce: a silently destroyed lead context with no fresh session
+ * ever started, and a refusal return value that claimed nothing had happened.
*
- * {@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.
+ * The fix: {@link #confirm} validates every gate, then does no I/O against the lead's own pane
+ * at all — it only records that the request is approved and hands a one-shot continuation to
+ * {@code continuationRunner} before returning. That continuation is what actually touches the pane,
+ * once the calling turn has ended, in this order:
+ * The safety invariant survives this change, restated precisely. The ticket
+ * that first defined this class required "no timer, no scheduler, no background thread" so that
+ * nothing but an explicit {@link #confirm} call could ever cause a {@code /clear}. That invariant
+ * is about INITIATIVE, not about synchronicity, and this correction keeps it: {@code
+ * continuationRunner} launches a single-shot task that exists only because one specific,
+ * already-approved {@link #confirm} call created it — it is not recurring, it is not started at
+ * construction time or on any schedule, and no two invocations of it ever share state. A recurring
+ * heartbeat or timer that could decide on its own initiative to roll a pane is still, and will
+ * always be, absent from this class. Nothing but an explicit {@link #confirm} call that
+ * passes every gate can ever cause a {@code /clear} — that call may simply finish its own work
+ * slightly later than the method return, as a continuation of the same approved request, rather
+ * than entirely inside the method body.
+ *
+ * Identity is resolved by the caller, never looked up here — a second fleetd #480
+ * correction. The first version resolved the pane to clear via {@code
+ * PrimaryRegistry#primaryTerminal()}. That is correct for a background loop with no caller (see
+ * {@code dev.ltms.fleet.msg.LeadHeartbeatLoop}), but wrong here and a violation of this project's
+ * own charter invariant 3 — "identity comes from the connection, never an argument." This daemon
+ * can hold more than one labelled lead tab (see {@code LeadLauncher}'s fleetd #359 two-reading
+ * dead-tab cleanup), so a single-slot lookup lets lead X's {@link #confirm} clear lead Y's pane: an
+ * unrecoverable loss of someone else's context, and a different lead's context at that. Both
+ * {@link #open} and {@link #confirm} now take the lead's terminal id as a parameter instead —
+ * {@link #open} stores it on the {@link PendingRollover}, and {@link #confirm} refuses with {@link
+ * RefusalReason#NOT_YOUR_ROLLOVER} unless the caller's terminal matches the one {@link #open}
+ * recorded. The terminal id passed to both methods must come from the MCP layer's own
+ * connection-based caller resolution — the same source {@code auth/CallerResolver#resolve} uses to
+ * build a {@code Principal.leader(...)} (see its use of {@code ConnectionIdentity.Caller#terminal})
+ * — never a value the client supplies or chooses. The later MCP-tool unit that wires
+ * {@link #open}/{@link #confirm} must pass the resolved caller terminal, not a request field.
*/
public final class LeadRollover {
@@ -63,15 +95,26 @@ public final class LeadRollover {
/** 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) {}
+ /**
+ * One request opened by {@link #open}, pending its {@link #confirm} (or {@link #cancel}).
+ *
+ * @param leadTerminal the lead pane that opened this request — the only terminal that may
+ * later {@link #confirm} it (see {@link RefusalReason#NOT_YOUR_ROLLOVER})
+ */
+ public record PendingRollover(String token, String leadTerminal, 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. */
+ /** {@code token} names no pending request: never opened, already confirmed, or cancelled. */
UNKNOWN_TOKEN,
+ /**
+ * The caller's terminal does not match the terminal that {@link #open} recorded for this
+ * token. Only the lead that opened a request may confirm it (fleetd #480 correction 2).
+ */
+ NOT_YOUR_ROLLOVER,
/** {@code requireOperatorConfirm: true} and the caller passed {@code operatorConfirmed: false}. */
OPERATOR_NOT_CONFIRMED,
/** The handover file does not exist. */
@@ -82,56 +125,61 @@ public final class LeadRollover {
* 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
+ HANDOVER_STALE
}
- /** 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");
+ /**
+ * The outcome of a {@link #confirm} call. {@link #approved()} means every gate passed and the
+ * roll has been handed to a one-shot continuation — not that the pane has been
+ * cleared; the continuation may still be waiting for the calling turn to end when this returns.
+ * Whether the deferred roll itself later goes on to clear the pane, refuse for never going
+ * idle, or refuse for never re-settling after {@code /clear} is logged only (see this class's
+ * javadoc) — there is deliberately no synchronous caller left by that point to hand a result to.
+ */
+ public record RollDecision(boolean accepted, RefusalReason reason, String detail) {
+ static RollDecision approved() {
+ return new RollDecision(true, null, "confirmed; the roll will run once the calling turn ends");
}
- static RollResult refused(RefusalReason reason, String detail) {
- return new RollResult(false, reason, detail);
+ static RollDecision refused(RefusalReason reason, String detail) {
+ return new RollDecision(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.
+ * Order: token lookup, then ownership ({@code callerTerminal} must match the terminal
+ * {@link #open} recorded — {@link RefusalReason#NOT_YOUR_ROLLOVER}), then the
+ * operator-confirmation gate, then the three handover-file checks (exists, not empty, fresh —
+ * see {@link #checkHandover}). The first failing check is returned and {@code token} stays
+ * pending (so a caller can fix the problem — e.g. rewrite the handover file — and retry with
+ * the same token); it is consumed only once every gate passes and the continuation is launched.
*
+ * @param callerTerminal the CALLING lead's terminal id, resolved by the MCP layer from the
+ * connection — never a client-supplied value (see this class's javadoc)
* @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) {
+ public RollDecision confirm(String callerTerminal, String token, boolean operatorConfirmed) {
FleetConfig.LeadRollover cfg = configSupplier.get();
if (cfg == null) {
- return RollResult.refused(RefusalReason.NOT_CONFIGURED, "leadRollover: is not configured");
+ return RollDecision.refused(RefusalReason.NOT_CONFIGURED, "leadRollover: is not configured");
}
PendingRollover p = pending.get(token);
if (p == null) {
- return RollResult.refused(RefusalReason.UNKNOWN_TOKEN,
+ return RollDecision.refused(RefusalReason.UNKNOWN_TOKEN,
"token " + token + " names no pending rollover request");
}
+ if (!p.leadTerminal().equals(callerTerminal)) {
+ return RollDecision.refused(RefusalReason.NOT_YOUR_ROLLOVER,
+ "token " + token + " was opened by a different lead terminal");
+ }
if (cfg.requireOperatorConfirm() && !operatorConfirmed) {
- return RollResult.refused(RefusalReason.OPERATOR_NOT_CONFIRMED,
+ return RollDecision.refused(RefusalReason.OPERATOR_NOT_CONFIRMED,
"requireOperatorConfirm is true and operatorConfirmed was false");
}
- RollResult docCheck = checkHandover(p, cfg);
+ RollDecision 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
+ * mutation that deletes the {@code leadRollover(...)} call from {@code main} — or replaces one of
+ * its arguments with something that silently compiles, e.g. {@code router.leadAgents()} 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.
*
@@ -49,12 +49,14 @@ class FleetdLeadRolloverWiringTest {
void mainStillCallsTheLeadRolloverFactory() throws Exception {
String source = fleetdSource();
assertTrue(source.contains(
- "LeadRollover leadRollover = leadRollover(cfg, primaryRegistry, router.leadAgents(), config);"),
+ "LeadRollover leadRollover = leadRollover(cfg, 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.");
+ + "router.leadAgents(), config);`. Dropping this call, or swapping one of its "
+ + "arguments for something that still compiles (e.g. null in place of "
+ + "router.leadAgents()), leaves every behavioural test green — this source check is "
+ + "what must go red instead. fleetd #480 correction 2 deliberately dropped "
+ + "primaryRegistry from this call — see LeadRollover's class javadoc for why a "
+ + "single-slot lookup was wrong here.");
}
@Test
diff --git a/fleetd/src/test/java/dev/ltms/fleet/config/FleetConfigWithDefaultsPreservesEveryComponentTest.java b/fleetd/src/test/java/dev/ltms/fleet/config/FleetConfigWithDefaultsPreservesEveryComponentTest.java
index 359dc34..8de6f66 100644
--- a/fleetd/src/test/java/dev/ltms/fleet/config/FleetConfigWithDefaultsPreservesEveryComponentTest.java
+++ b/fleetd/src/test/java/dev/ltms/fleet/config/FleetConfigWithDefaultsPreservesEveryComponentTest.java
@@ -103,7 +103,7 @@ class FleetConfigWithDefaultsPreservesEveryComponentTest {
// comment there), same as broker/primary/leadHeartbeat/... above — a real, non-null value
// here proves it, rather than leaving it null and proving nothing.
v.put("leadRollover", new FleetConfig.LeadRollover(
- "/handover/guard.md", true, 3600, 20, "read the handover file"));
+ "/handover/guard.md", true, 3600, 20, 20, "read the handover file"));
assertNamesMatchComponents(v);
return v;
}
diff --git a/fleetd/src/test/java/dev/ltms/fleet/lead/LeadRolloverTest.java b/fleetd/src/test/java/dev/ltms/fleet/lead/LeadRolloverTest.java
index d011de0..7f6eec4 100644
--- a/fleetd/src/test/java/dev/ltms/fleet/lead/LeadRolloverTest.java
+++ b/fleetd/src/test/java/dev/ltms/fleet/lead/LeadRolloverTest.java
@@ -1,9 +1,11 @@
package dev.ltms.fleet.lead;
+import com.fasterxml.jackson.databind.JsonNode;
import dev.ltms.fleet.config.FleetConfig;
import dev.ltms.fleet.herdr.AgentControl;
import dev.ltms.fleet.herdr.FakeHerdr;
-import dev.ltms.fleet.mcp.PrimaryRegistry;
+import dev.ltms.fleet.herdr.HerdrClient;
+import dev.ltms.fleet.herdr.HerdrException;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
@@ -17,8 +19,9 @@ import java.util.function.LongSupplier;
import static org.junit.jupiter.api.Assertions.*;
/**
- * fleetd #480 Unit A — the lead-rollover executor. Six hard requirements, each pinned by a test
- * named after the rule it protects (see class javadoc on {@link LeadRollover}):
+ * fleetd #480 Unit A — the lead-rollover executor. Six hard requirements from the original ticket,
+ * plus two more from the fleetd #480 correction round (see {@link LeadRollover}'s class javadoc for
+ * the full story of both corrections):
* Every test below injects {@code Runnable::run} as {@link LeadRollover}'s continuation runner,
+ * so the post-{@code confirm()} continuation that fleetd #480's correction moved out of {@code
+ * confirm()} runs synchronously, inline, on the test thread. Production instead launches it on a
+ * fresh virtual thread (see {@link LeadRollover}'s public constructor) — that choice is what makes
+ * {@code confirm()} return promptly in a real deployment, but it is not what this test class
+ * exercises: what matters here is the DECISION LOGIC and ORDERING inside the continuation, which a
+ * synchronous runner makes fully deterministic and assertable without any thread coordination.
*/
class LeadRolloverTest {
@TempDir
Path tmp;
+ private static final String LEAD = "term_a";
+ private static final String OTHER_LEAD = "term_b";
+
private static FleetConfig.LeadRollover cfg(String handoverPath) {
- return new FleetConfig.LeadRollover(handoverPath, true, 3600, 20, "read the handover file");
+ return new FleetConfig.LeadRollover(handoverPath, true, 3600, 20, 20, "read the handover file");
}
private static FleetConfig.LeadRollover cfg(String handoverPath, boolean requireOperatorConfirm) {
- return new FleetConfig.LeadRollover(handoverPath, requireOperatorConfirm, 3600, 20,
+ return new FleetConfig.LeadRollover(handoverPath, requireOperatorConfirm, 3600, 20, 20,
"read the handover file");
}
@@ -49,11 +68,10 @@ class LeadRolloverTest {
return millis::get;
}
- private static LeadRollover newRollover(FakeHerdr herdr, FleetConfig.LeadRollover config,
+ private static LeadRollover newRollover(HerdrClient herdr, FleetConfig.LeadRollover config,
LongSupplier nowMillis) {
AgentControl agents = new AgentControl(herdr);
- PrimaryRegistry registry = new PrimaryRegistry("term_a");
- return new LeadRollover(registry, agents, () -> config, nowMillis, () -> { });
+ return new LeadRollover(agents, () -> config, nowMillis, () -> { }, Runnable::run);
}
private Path writeHandover(String content) throws IOException {
@@ -62,6 +80,11 @@ class LeadRolloverTest {
return p;
}
+ /** Every {@code agent.prompt} call {@code herdr} recorded, regardless of outcome. */
+ private static long promptCallCount(FakeHerdr herdr) {
+ return herdr.calls.stream().filter(c -> "agent.prompt".equals(c.method())).count();
+ }
+
// ---- 1. no config block, no object ----------------------------------------------------
@Test
@@ -74,7 +97,7 @@ class LeadRolloverTest {
assertNull(none, "sanity: an absent block really is null");
}
- // ---- 2. only confirm() can roll --------------------------------------------------------
+ // ---- 2. only confirm() can (eventually) roll -------------------------------------------
@Test
@DisplayName("[HARD REQ 2] nothing but an explicit confirm() call can ever roll a pane")
@@ -84,12 +107,13 @@ class LeadRolloverTest {
AtomicLong clock = new AtomicLong(1_000);
LeadRollover rollover = newRollover(herdr, cfg(handover.toString()), fixedClock(clock));
- LeadRollover.PendingRollover pending = rollover.open("context is full");
+ LeadRollover.PendingRollover pending = rollover.open(LEAD, "context is full");
assertNotNull(pending.token());
- assertTrue(herdr.calls.stream().noneMatch(c -> "agent.prompt".equals(c.method())),
+ assertEquals(0, promptCallCount(herdr),
"open() alone must never send anything — no timer, no heartbeat, no background "
- + "thread in this class ever calls agents.send; only confirm() may");
+ + "thread in this class ever calls agents.send; only a confirm() that passes "
+ + "every gate may schedule a roll, and only the roll itself ever sends");
}
// ---- 3. the three handover-file checks, one test each ---------------------------------
@@ -102,12 +126,12 @@ class LeadRolloverTest {
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);
+ LeadRollover.PendingRollover pending = rollover.open(LEAD, "context is full");
+ LeadRollover.RollDecision decision = rollover.confirm(LEAD, pending.token(), true);
- assertFalse(result.rolled());
- assertEquals(LeadRollover.RefusalReason.HANDOVER_MISSING, result.reason());
- assertTrue(herdr.calls.stream().noneMatch(c -> "agent.prompt".equals(c.method())));
+ assertFalse(decision.accepted());
+ assertEquals(LeadRollover.RefusalReason.HANDOVER_MISSING, decision.reason());
+ assertEquals(0, promptCallCount(herdr));
}
@Test
@@ -118,11 +142,11 @@ class LeadRolloverTest {
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);
+ LeadRollover.PendingRollover pending = rollover.open(LEAD, "context is full");
+ LeadRollover.RollDecision decision = rollover.confirm(LEAD, pending.token(), true);
- assertFalse(result.rolled());
- assertEquals(LeadRollover.RefusalReason.HANDOVER_EMPTY, result.reason());
+ assertFalse(decision.accepted());
+ assertEquals(LeadRollover.RefusalReason.HANDOVER_EMPTY, decision.reason());
}
@Test
@@ -135,16 +159,16 @@ class LeadRolloverTest {
// 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");
+ new FleetConfig.LeadRollover(handover.toString(), true, 1 /*maxDocAgeSeconds*/, 20, 20, "text");
LeadRollover rollover = newRollover(herdr, config, fixedClock(clock));
- LeadRollover.PendingRollover pending = rollover.open("context is full");
+ LeadRollover.PendingRollover pending = rollover.open(LEAD, "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);
+ LeadRollover.RollDecision decision = rollover.confirm(LEAD, pending.token(), true);
- assertFalse(result.rolled());
- assertEquals(LeadRollover.RefusalReason.HANDOVER_STALE, result.reason());
+ assertFalse(decision.accepted());
+ assertEquals(LeadRollover.RefusalReason.HANDOVER_STALE, decision.reason());
}
// ---- 4. requireOperatorConfirm ---------------------------------------------------------
@@ -157,13 +181,13 @@ class LeadRolloverTest {
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);
+ LeadRollover.PendingRollover pending = rollover.open(LEAD, "context is full");
+ LeadRollover.RollDecision decision = rollover.confirm(LEAD, 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");
+ assertFalse(decision.accepted());
+ assertEquals(LeadRollover.RefusalReason.OPERATOR_NOT_CONFIRMED, decision.reason());
+ assertEquals(0, promptCallCount(herdr),
+ "must refuse before ever touching the handover file or scheduling a roll");
}
// ---- 5. wall clock, not nanoTime --------------------------------------------------------
@@ -171,7 +195,7 @@ class LeadRolloverTest {
@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();
+ FakeHerdr herdr = new FakeHerdr(); // default agentStatus is "idle" — both waits settle immediately
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
@@ -181,17 +205,74 @@ class LeadRolloverTest {
FleetConfig.LeadRollover config = cfg(handover.toString());
LeadRollover rollover = newRollover(herdr, config, fixedClock(clock));
- LeadRollover.PendingRollover pending = rollover.open("context is full");
+ LeadRollover.PendingRollover pending = rollover.open(LEAD, "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);
+ LeadRollover.RollDecision decision = rollover.confirm(LEAD, pending.token(), true);
- assertTrue(result.rolled(), "expected a successful roll; got refusal: " + result.reason()
- + " (" + result.detail() + ")");
+ assertTrue(decision.accepted(), "expected approval; got refusal: " + decision.reason()
+ + " (" + decision.detail() + ")");
+ assertEquals(2, promptCallCount(herdr), "a passing freshness check lets the (synchronous, "
+ + "in this test) continuation run all the way through to /clear + bootstrapText");
+ }
+
+ // ---- Correction 1: the calling lead's own turn must end before /clear is ever sent ----
+
+ @Test
+ @DisplayName("[CORRECTION 1 — the branch that matters most] the calling lead's turn never settling means /clear is NEVER sent, at all")
+ void turnThatNeverSettlesSendsNoClearAtAll() throws IOException {
+ FakeHerdr herdr = new FakeHerdr();
+ herdr.agentStatus("working"); // the calling lead's own pane — never goes idle in this test
+ Path handover = writeHandover("handover contents");
+ FleetConfig.LeadRollover config =
+ new FleetConfig.LeadRollover(handover.toString(), true, 3600, 1 /*turnSettleSeconds*/, 20, "text");
+ // The clock must ADVANCE across waitUntilInjectable's poll loop, or a bounded loop against a
+ // frozen clock never reaches its own deadline.
+ AtomicLong clock = new AtomicLong(1_000);
+ LeadRollover rollover = newRollover(herdr, config, () -> clock.addAndGet(500));
+
+ LeadRollover.PendingRollover pending = rollover.open(LEAD, "context is full");
+ LeadRollover.RollDecision decision = rollover.confirm(LEAD, pending.token(), true);
+
+ // confirm() itself only validates and schedules — every gate here passes, so it reports
+ // approved — but the (synchronously-run, in this test) continuation must have refused to
+ // send ANYTHING once the turn-settle wait timed out.
+ assertTrue(decision.accepted(), "every synchronous gate should pass; the refusal happens "
+ + "only inside the deferred continuation, which this test's synchronous runner has "
+ + "already run to completion by the time confirm() returns");
+ assertEquals(0, promptCallCount(herdr),
+ "the calling lead's own pane never went idle, so /clear must NEVER be sent — sending "
+ + "it while the turn that requested the roll is still live would destroy that "
+ + "same live context");
+ }
+
+ // ---- Correction 2: only the terminal that opened a request may confirm it -------------
+
+ @Test
+ @DisplayName("[CORRECTION 2] a different lead terminal cannot confirm another lead's rollover")
+ void aDifferentLeadTerminalCannotConfirmAnotherLeadsRollover() 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(LEAD, "context is full");
+ LeadRollover.RollDecision decision = rollover.confirm(OTHER_LEAD, pending.token(), true);
+
+ assertFalse(decision.accepted());
+ assertEquals(LeadRollover.RefusalReason.NOT_YOUR_ROLLOVER, decision.reason());
+ assertEquals(0, promptCallCount(herdr), "a foreign terminal's confirm() must never touch "
+ + "the pane it named, let alone the pane that actually opened the request");
+
+ // the rightful owner can still confirm the same token afterwards — a foreign confirm()
+ // must not consume or otherwise disturb the pending request.
+ LeadRollover.RollDecision ownerDecision = rollover.confirm(LEAD, pending.token(), true);
+ assertTrue(ownerDecision.accepted(), "the actual opener must still be able to confirm after "
+ + "a foreign terminal's confirm() was refused");
}
// ---- extra coverage: cancel(), CLEAR_DID_NOT_SETTLE, and the full success path ---------
@@ -204,13 +285,13 @@ class LeadRolloverTest {
AtomicLong clock = new AtomicLong(1_000);
LeadRollover rollover = newRollover(herdr, cfg(handover.toString()), fixedClock(clock));
- LeadRollover.PendingRollover pending = rollover.open("context is full");
+ LeadRollover.PendingRollover pending = rollover.open(LEAD, "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());
+ LeadRollover.RollDecision decision = rollover.confirm(LEAD, pending.token(), true);
+ assertFalse(decision.accepted());
+ assertEquals(LeadRollover.RefusalReason.UNKNOWN_TOKEN, decision.reason());
}
@Test
@@ -218,35 +299,59 @@ class LeadRolloverTest {
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, () -> { });
+ LeadRollover rollover = new LeadRollover(agents, () -> null, () -> 1_000L, () -> { }, Runnable::run);
- assertThrows(IllegalStateException.class, () -> rollover.open("context is full"));
+ assertThrows(IllegalStateException.class, () -> rollover.open(LEAD, "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 {
+ @DisplayName("open() throws IllegalArgumentException when leadTerminal is null or blank")
+ void openThrowsWhenLeadTerminalIsBlank() throws IOException {
FakeHerdr herdr = new FakeHerdr();
- herdr.agentStatus("working"); // never becomes injectable
+ Path handover = writeHandover("handover contents");
+ LeadRollover rollover = newRollover(herdr, cfg(handover.toString()), () -> 1_000L);
+
+ assertThrows(IllegalArgumentException.class, () -> rollover.open(null, "context is full"));
+ assertThrows(IllegalArgumentException.class, () -> rollover.open(" ", "context is full"));
+ }
+
+ @Test
+ @DisplayName("a pane that never re-settles after /clear refuses to send bootstrapText")
+ void clearThatNeverSettlesAfterwardsNeverSendsBootstrapText() throws IOException {
+ // Idle until /clear is sent, then permanently working — isolates the SECOND wait
+ // (clearSettleSeconds) from the first (turnSettleSeconds), which passes immediately here.
+ FakeHerdr fake = new FakeHerdr();
+ HerdrClient flipsAfterClear = new HerdrClient() {
+ @Override
+ public JsonNode call(String method, Object params) throws HerdrException {
+ JsonNode result = fake.call(method, params);
+ if ("agent.prompt".equals(method) && String.valueOf(params).contains("/clear")) {
+ fake.agentStatus("working");
+ }
+ return result;
+ }
+
+ @Override
+ public void close() {
+ fake.close();
+ }
+ };
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.
+ new FleetConfig.LeadRollover(handover.toString(), true, 3600, 20, 1 /*clearSettleSeconds*/, "boot text");
AtomicLong clock = new AtomicLong(1_000);
- LeadRollover rollover = newRollover(herdr, config, () -> clock.addAndGet(500));
+ LeadRollover rollover = newRollover(flipsAfterClear, config, () -> clock.addAndGet(500));
- LeadRollover.PendingRollover pending = rollover.open("context is full");
- LeadRollover.RollResult result = rollover.confirm(pending.token(), true);
+ LeadRollover.PendingRollover pending = rollover.open(LEAD, "context is full");
+ LeadRollover.RollDecision decision = rollover.confirm(LEAD, pending.token(), true);
- assertFalse(result.rolled());
- assertEquals(LeadRollover.RefusalReason.CLEAR_DID_NOT_SETTLE, result.reason());
- long bootstrapSends = herdr.calls.stream()
+ assertTrue(decision.accepted(), "every synchronous gate passes; the refusal is logged only, "
+ + "deep inside the deferred continuation");
+ assertEquals(1, promptCallCount(fake), "exactly one agent.prompt call — the /clear — and "
+ + "nothing else");
+ long bootstrapSends = fake.calls.stream()
.filter(c -> "agent.prompt".equals(c.method()))
- .filter(c -> c.params().toString().contains("boot text"))
+ .filter(c -> String.valueOf(c.params()).contains("boot text"))
.count();
assertEquals(0, bootstrapSends, "bootstrapText must never be sent when /clear did not settle");
}
@@ -260,17 +365,18 @@ class LeadRolloverTest {
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);
+ LeadRollover.PendingRollover pending = rollover.open(LEAD, "context is full");
+ LeadRollover.RollDecision decision = rollover.confirm(LEAD, pending.token(), true);
- assertTrue(result.rolled(), "expected success; got: " + result.reason() + " / " + result.detail());
+ assertTrue(decision.accepted(), "expected approval; got: " + decision.reason() + " / " + decision.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);
+ // token is consumed once confirm() approves — a second confirm() with the same token is
+ // UNKNOWN_TOKEN, even though the deferred roll's own outcome is decided later.
+ LeadRollover.RollDecision again = rollover.confirm(LEAD, pending.token(), true);
assertEquals(LeadRollover.RefusalReason.UNKNOWN_TOKEN, again.reason());
}
}
- *
+ *
+ *
+ * A {@link #confirm} that returns {@link RollDecision#approved()} therefore means "every gate
+ * passed and the roll is scheduled", never "the pane has been cleared" — the pane may
+ * still be mid-turn, possibly for a long time, when the caller gets that answer back.
+ *
+ *
*
- * The sixth (the {@code Fleetd.java} source-text pin) lives in
+ * The sixth original requirement (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.
+ *
+ *