diff --git a/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java b/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java index f9b42d6..62da133 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java +++ b/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java @@ -577,13 +577,18 @@ public final class Fleetd { // (keyed by configDir+sessionId), so this costs at most one extra bounded tail read per // TTL window, never a shared-mutable-state hazard between the two callers. var leadContextGauge = new LeadContextGauge(); + // fleetd #621: the context-high notice's own wording must track this same effective + // value — LeadRollover.confirm(...) already gates the roll on it (LeadRollover.java:480), + // and absent `leadRollover:` entirely the roll is unusable regardless (NOT_CONFIGURED), + // so `true` (the FleetConfig.LeadRollover default) is the safe, byte-identical fallback. + boolean requireOperatorConfirm = cfg.leadRollover() == null || cfg.leadRollover().requireOperatorConfirm(); heartbeat = new LeadHeartbeatLoop(primaryRegistry, router.leadAgents(), replyInbox, sessions::roster, pushLoop, heartbeatScheduler, System::nanoTime, TimeUnit.SECONDS.toNanos(hb.idleAfterSeconds()), hb.backoffMs(), hb.quietNudgeCap(), metrics, leadContextSource(leadContextGauge, router.leadAgents(), leads, leadConfigDirLookup(() -> config.get().profiles(), leaders)), - Boolean.TRUE.equals(hb.contextHighNudge())); + Boolean.TRUE.equals(hb.contextHighNudge()), requireOperatorConfirm); heartbeat.start(); } else { heartbeat = null; diff --git a/fleetd/src/main/java/dev/ltms/fleet/msg/LeadHeartbeatLoop.java b/fleetd/src/main/java/dev/ltms/fleet/msg/LeadHeartbeatLoop.java index c40f31d..37c6caa 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/msg/LeadHeartbeatLoop.java +++ b/fleetd/src/main/java/dev/ltms/fleet/msg/LeadHeartbeatLoop.java @@ -76,6 +76,7 @@ public final class LeadHeartbeatLoop { private final Metrics metrics; // CB-512 pattern: nullable — no registry in unit tests private final LeadContextSource contextSource; // fleetd #609 private final boolean contextHighNudge; // fleetd #609: opt-in, like the loop itself + private final boolean requireOperatorConfirm; // fleetd #621: mirrors leadRollover.requireOperatorConfirm /** When the current idle stretch began (nanos), or {@link #NOT_IDLE}. Single scheduler thread only. */ private long idleSinceNanos = NOT_IDLE; @@ -106,12 +107,32 @@ public final class LeadHeartbeatLoop { * fleetd #609: as above, plus the lead's own context source and whether a HIGH reading should * append a hand-over notice to the loop's nudge. Pass {@link LeadContextSource#none()} and * {@code false} to keep the pre-#609 behaviour exactly (both existing public constructors do). + * + *

fleetd #621: delegates to the full constructor with {@code requireOperatorConfirm=true} — + * the pre-#621 wording ("ask the operator ... only the operator can approve the roll") assumed + * the config default, so every caller of this overload keeps that text byte-identical. */ public LeadHeartbeatLoop(PrimaryRegistry primaryRegistry, AgentControl agents, ReplyInbox inbox, Supplier> roster, ReplyPushLoop pushLoop, ScheduledExecutorService scheduler, LongSupplier clock, long idleAfterNanos, long backoffMs, int quietNudgeCap, Metrics metrics, LeadContextSource contextSource, boolean contextHighNudge) { + this(primaryRegistry, agents, inbox, roster, pushLoop, scheduler, clock, + idleAfterNanos, backoffMs, quietNudgeCap, metrics, contextSource, contextHighNudge, true); + } + + /** + * fleetd #621: as above, plus the daemon's effective {@code leadRollover.requireOperatorConfirm} + * value — threaded into {@link #contextNotice(boolean, LeadContextGauge.Reading, boolean, boolean)} + * so the notice's wording tracks the config the daemon actually enforces (see {@code + * LeadRollover.confirm}) instead of always asserting the operator gate is on. + */ + public LeadHeartbeatLoop(PrimaryRegistry primaryRegistry, AgentControl agents, ReplyInbox inbox, + Supplier> roster, ReplyPushLoop pushLoop, + ScheduledExecutorService scheduler, LongSupplier clock, + long idleAfterNanos, long backoffMs, int quietNudgeCap, Metrics metrics, + LeadContextSource contextSource, boolean contextHighNudge, + boolean requireOperatorConfirm) { this.primaryRegistry = primaryRegistry; this.agents = agents; this.inbox = inbox; @@ -125,6 +146,7 @@ public final class LeadHeartbeatLoop { this.metrics = metrics; this.contextSource = contextSource; this.contextHighNudge = contextHighNudge; + this.requireOperatorConfirm = requireOperatorConfirm; } /** @@ -344,7 +366,7 @@ public final class LeadHeartbeatLoop { // d.contextNotified() is the value to persist once delivery is confirmed, not the value the text // itself should be built from. Otherwise a HIGH stretch that is still latched would never see the // notice at all, defeating the very check this fixes. - String notice = contextNotice(contextHighNudge, reading, contextNotified); + String notice = contextNotice(contextHighNudge, reading, contextNotified, requireOperatorConfirm); var lead = primaryRegistry.primaryTerminal(); boolean sent = lead.isPresent() && trySend(lead.get(), fleet.nudgeText() + notice, notice); // The latch becomes true only when all three hold: decide() chose to notify, a notice was @@ -381,8 +403,9 @@ public final class LeadHeartbeatLoop { /** * fleetd #609: the text appended to a nudge when the lead's own context is full — {@code ""} * whenever the notice does not apply, so callers can unconditionally append this without an extra - * branch. Wording stays plain (CEFR B1) and honest that only the operator approves a roll — this - * loop only ever prints text, it never calls {@code fleet_handover} itself. + * branch. Wording stays plain (CEFR B1) and honest about who actually gates the roll — see the + * {@code requireOperatorConfirm} overload (fleetd #621) for which check that is. This loop only + * ever prints text, it never calls {@code fleet_handover} itself. * * @param enabled the {@code leadHeartbeat.contextHighNudge} config flag * @param reading the lead's current {@link LeadContextGauge} reading @@ -401,9 +424,33 @@ public final class LeadHeartbeatLoop { * closing sentence ("You will not be told again until your context reads ok.") false. {@link * #injectNudge} is the only caller that passes a non-default {@code alreadyNotified}. * + *

fleetd #621: delegates with {@code requireOperatorConfirm=true} — the pre-#621 default and the + * value every existing caller of this overload (including every test written before #621) already + * assumed, so the text this overload returns stays byte-identical. + * * @param alreadyNotified whether the lead has already been told about the current HIGH stretch */ static String contextNotice(boolean enabled, LeadContextGauge.Reading reading, boolean alreadyNotified) { + return contextNotice(enabled, reading, alreadyNotified, true); + } + + /** + * fleetd #621: as {@link #contextNotice(boolean, LeadContextGauge.Reading, boolean)}, but the closing + * instructions also track the daemon's effective {@code leadRollover.requireOperatorConfirm} value, + * instead of always asserting that only the operator can approve the roll. + * + *

{@code LeadRollover.confirm(...)} already honours this flag: when it is {@code false}, the daemon + * itself gates the roll on the three handover-file checks alone (exists, modified after the {@code + * open()} request, and no older than {@code maxDocAgeSeconds}) and never consults {@code + * operatorConfirmed}. Before this parameter existed, this notice told the lead to ask the operator + * regardless — so a lead that followed its own instructions asked anyway, and setting the config knob + * to {@code false} stopped the daemon refusing the roll without stopping the operator being + * interrupted. This parameter is how the text is kept honest about which gate is actually live. + * + * @param requireOperatorConfirm the effective {@code leadRollover.requireOperatorConfirm} value + */ + static String contextNotice(boolean enabled, LeadContextGauge.Reading reading, boolean alreadyNotified, + boolean requireOperatorConfirm) { if (!enabled || alreadyNotified || reading.state() != LeadContextGauge.State.HIGH) { return ""; } @@ -420,10 +467,18 @@ public final class LeadHeartbeatLoop { sb.append(" (").append(reading.compactions()).append(' ').append(compactionWord) .append(" so far)."); } - sb.append(" A fresh session would work better. To hand over: call fleet_handover(action=\"open\"), " - + "write the file it names, ask the operator, then call fleet_handover(action=\"confirm\", " - + "token, operatorConfirmed). Only the operator can approve the roll. You will not be told " - + "again until your context reads ok."); + if (requireOperatorConfirm) { + sb.append(" A fresh session would work better. To hand over: call fleet_handover(action=\"open\"), " + + "write the file it names, ask the operator, then call fleet_handover(action=\"confirm\", " + + "token, operatorConfirmed). Only the operator can approve the roll. You will not be told " + + "again until your context reads ok."); + } else { + sb.append(" A fresh session would work better. To hand over: call fleet_handover(action=\"open\"), " + + "write the file it names, then call fleet_handover(action=\"confirm\", token). Decide for " + + "yourself when to confirm: the roll goes through if the handover file exists, was " + + "changed after you opened it, and is not older than maxDocAgeSeconds. You will not be " + + "told again until your context reads ok."); + } return sb.toString(); } diff --git a/fleetd/src/test/java/dev/ltms/fleet/msg/LeadHeartbeatLoopTest.java b/fleetd/src/test/java/dev/ltms/fleet/msg/LeadHeartbeatLoopTest.java index 9f48af9..4cee349 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/msg/LeadHeartbeatLoopTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/msg/LeadHeartbeatLoopTest.java @@ -476,6 +476,26 @@ class LeadHeartbeatLoopTest { assertTrue(notice.contains("2 compactions"), notice); } + // ── fleetd #621: the notice must track the effective requireOperatorConfirm value ───────────── + + @Test + void contextNoticeKeepsAskingTheOperatorWhenRequireOperatorConfirmIsTrue() { + var reading = new LeadContextGauge.Reading(LeadContextGauge.State.HIGH, 260_771L, 1); + String notice = LeadHeartbeatLoop.contextNotice(true, reading, false, true); + assertTrue(notice.contains("ask the operator"), notice); + assertTrue(notice.contains("Only the operator can approve the roll"), notice); + } + + @Test + void contextNoticeDropsTheOperatorAskWhenRequireOperatorConfirmIsFalse() { + var reading = new LeadContextGauge.Reading(LeadContextGauge.State.HIGH, 260_771L, 1); + String notice = LeadHeartbeatLoop.contextNotice(true, reading, false, false); + assertFalse(notice.contains("ask the operator"), notice); + assertFalse(notice.contains("Only the operator can approve the roll"), notice); + assertTrue(notice.contains("fleet_handover"), notice); + assertTrue(notice.contains("maxDocAgeSeconds"), notice); + } + // ── fleetd #609 review: the latch must mean "the notice reached the pane" ──────────────────── // // These four drive LeadHeartbeatLoop.tick() directly (package-private, same reasoning as