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 c7039af..b5e3d3f 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/lead/LeadRollover.java +++ b/fleetd/src/main/java/dev/ltms/fleet/lead/LeadRollover.java @@ -10,6 +10,8 @@ import java.io.IOException; import java.io.UncheckedIOException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.Collections; +import java.util.LinkedHashMap; import java.util.Map; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; @@ -181,6 +183,68 @@ public final class LeadRollover { } } + /** + * How many finished tokens {@link #outcomes} remembers before it starts evicting the oldest — + * bounded so a long-running daemon never grows this map without limit. Chosen generously + * rather than tightly: production rolls are rare (this class's own ticket found exactly ONE + * completed roll ever logged on this host), and each entry is a handful of short strings, so + * even a full cap costs a few tens of kilobytes — nowhere near a reason to make it configurable. + * 200 entries comfortably outlasts any operator's own memory of "did that roll I asked for + * actually happen", which is the whole reason {@link #status} exists. + */ + static final int OUTCOME_HISTORY_CAP = 200; + + /** + * What is known about one token, right now — the answer {@link #status} gives. Distinguishes + * three terminal outcomes an approved roll can finish with, plus the two non-terminal answers: + * still pending, or nothing known about this token at all. + */ + public enum RollState { + /** + * {@code token} is still open: either {@link #open} was called and {@link #confirm} has not + * been (or not successfully) yet, or a {@link #confirm} call failed one of its gate checks + * and left the token pending for a retry — see {@link #confirm}'s javadoc ("token stays + * pending"). Indistinguishable from a genuinely fresh request; a caller wanting to know + * WHICH gate most recently refused should read the {@link RollDecision} that {@link + * #confirm} itself returned, not this status. + */ + PENDING, + /** + * {@link #confirm} was approved and the deferred continuation completed the entire roll: + * the calling lead's turn settled, {@code /clear} was sent and settled, and {@code + * bootstrapText} was sent. + */ + ROLLED, + /** + * {@link #confirm} was approved, but the calling lead's own turn never reached a boundary + * (IDLE or DONE) within {@code turnSettleSeconds} — no {@code /clear} was ever sent, at + * all. This is the branch the fleetd #480 correction exists to make safe, and the one this + * status exists to make VISIBLE: before this, a lead that hit this case had no way to find + * out, and would carry on believing it was about to be replaced. See this class's javadoc. + */ + TURN_NEVER_SETTLED, + /** + * {@link #confirm} was approved and {@code /clear} was sent, but the pane never re-settled + * within {@code clearSettleSeconds} — {@code bootstrapText} was never sent. + */ + CLEAR_NEVER_SETTLED, + /** + * {@code token} names nothing this instance currently knows about: never issued by {@link + * #open}, dropped by {@link #cancel}, or aged out of {@link #outcomes}'s bounded history. + * These three causes are not distinguished — all of them mean "there is nothing to tell + * you", which is the entire content of a clean answer here. + */ + UNKNOWN + } + + /** + * The answer {@link #status} gives for one token: a {@link RollState} and a human-readable + * {@code detail}. For {@link RollState#TURN_NEVER_SETTLED}, {@code detail} names {@code + * turnSettleSeconds} and its configured value explicitly, so a reader who sees this knows what + * to raise. + */ + public record RollStatus(RollState state, String detail) {} + private final AgentControl agents; private final Supplier configSupplier; /** @@ -201,6 +265,23 @@ public final class LeadRollover { */ private final Consumer continuationRunner; private final Map pending = new ConcurrentHashMap<>(); + /** + * Finished tokens → what actually happened, for {@link #status}. Bounded by {@link + * #OUTCOME_HISTORY_CAP}, oldest evicted first ({@code removeEldestEntry} on an insertion-order + * {@link LinkedHashMap}). Wrapped in {@link Collections#synchronizedMap} because entries are + * written from whatever thread {@code continuationRunner} runs the roll on (a fresh virtual + * thread in production, the calling test thread under {@code Runnable::run}) and read from + * whatever thread calls {@link #status} (the MCP handler thread) — a plain {@code + * LinkedHashMap} is not safe for that, and {@code removeEldestEntry} additionally requires + * external synchronization even for a thread-safe map that merely wraps it. + */ + private final Map outcomes = Collections.synchronizedMap( + new LinkedHashMap<>(16, 0.75f, false) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > OUTCOME_HISTORY_CAP; + } + }); /** Production constructor — wall clock, real sleep between settle polls, a real virtual thread. */ public LeadRollover(AgentControl agents, Supplier configSupplier, @@ -393,6 +474,11 @@ public final class LeadRollover { + "turn is still live and clearing it now would destroy live context " + "(token={}, configured={}s elapsed={}ms)", lead, p.token(), cfg.turnSettleSeconds(), turnResult.elapsedMillis()); + outcomes.put(p.token(), new RollStatus(RollState.TURN_NEVER_SETTLED, + "the calling lead's own turn never reached a boundary (IDLE or DONE) within " + + "turnSettleSeconds=" + cfg.turnSettleSeconds() + "s (measured elapsed=" + + turnResult.elapsedMillis() + "ms) — no /clear was ever sent. If this " + + "keeps happening, raise turnSettleSeconds in fleetd.yaml")); return; } @@ -411,11 +497,17 @@ public final class LeadRollover { + "elapsed={}ms nudges={})", lead, p.token(), cfg.clearSettleSeconds(), clearResult.elapsedMillis(), clearResult.nudges()); + outcomes.put(p.token(), new RollStatus(RollState.CLEAR_NEVER_SETTLED, + "/clear was sent, but the pane never re-settled within clearSettleSeconds=" + + cfg.clearSettleSeconds() + "s (measured elapsed=" + clearResult.elapsedMillis() + + "ms, nudges=" + clearResult.nudges() + ") — bootstrapText was never sent")); return; } agents.send(lead, cfg.bootstrapTextFor(p.handoverPath())); long rollElapsedMillis = nowMillis.getAsLong() - rollStartMillis; log.info("lead-rollover: rolled token={} lead={} elapsedMs={}", p.token(), lead, rollElapsedMillis); + outcomes.put(p.token(), new RollStatus(RollState.ROLLED, + "rolled successfully in " + rollElapsedMillis + "ms")); } /** Drop a pending request without rolling. @return whether a pending request existed for {@code token} */ @@ -423,6 +515,41 @@ public final class LeadRollover { return pending.remove(token) != null; } + /** + * Read-only: what is currently known about {@code token}. Never sends anything, never + * schedules, cancels, or retries a roll — a caller may poll this as often as it likes + * with no side effect at all, which is exactly why it exists: every failure past {@link + * #confirm} used to be a {@code log.warn} a lead can never read (see this class's javadoc), and + * this is the only route back. + * + * @param token the token {@link #open} returned; {@code null} or blank is a clean {@link + * RollState#UNKNOWN}, never a {@link NullPointerException} — {@link #pending} is a + * {@link ConcurrentHashMap}, which throws on a {@code null} key lookup, so this + * short-circuits before ever reaching it + * @return {@link RollState#PENDING} while {@code token} is still open (including one left + * pending by a {@link #confirm} gate refusal — see that method's javadoc); a terminal + * state once the approved roll's continuation has finished, read from the bounded + * {@link #outcomes} history; or {@link RollState#UNKNOWN} for a token never issued, + * cancelled, or aged out of that history + */ + public RollStatus status(String token) { + if (token == null || token.isBlank()) { + return new RollStatus(RollState.UNKNOWN, "no token given"); + } + if (pending.containsKey(token)) { + return new RollStatus(RollState.PENDING, "open() has been called for this token and " + + "it has not yet been confirmed — or a confirm() gate check failed and left it " + + "pending, so the same token may be retried once the problem is fixed"); + } + RollStatus finished = outcomes.get(token); + if (finished != null) { + return finished; + } + return new RollStatus(RollState.UNKNOWN, "token names no pending or finished rollover " + + "request known to this instance — never issued, cancelled, or aged out of the " + + "bounded history (cap=" + OUTCOME_HISTORY_CAP + ")"); + } + /** * The three handover-file checks, in order: exists, not empty, fresh (modified after * {@link #open}'s timestamp and not older than {@code maxDocAgeSeconds}). Stats {@code diff --git a/fleetd/src/main/java/dev/ltms/fleet/mcp/FleetMcp.java b/fleetd/src/main/java/dev/ltms/fleet/mcp/FleetMcp.java index 6fa0559..066d203 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/mcp/FleetMcp.java +++ b/fleetd/src/main/java/dev/ltms/fleet/mcp/FleetMcp.java @@ -1248,14 +1248,16 @@ public final class FleetMcp { Map args) { String action = str(args, "action"); if (isBlank(action)) { - return error("action is required: \"open\", \"confirm\" or \"cancel\""); + return error("action is required: \"open\", \"confirm\", \"cancel\" or \"status\""); } return switch (action) { case "open" -> handoverOpen(leadRollover, callerTerminal, str(args, "reason")); case "confirm" -> handoverConfirm(leadRollover, callerTerminal, str(args, "token"), truthy(args, "operatorConfirmed")); case "cancel" -> handoverCancel(leadRollover, str(args, "token")); - default -> error("unknown action \"" + action + "\" — must be \"open\", \"confirm\" or \"cancel\""); + case "status" -> handoverStatus(leadRollover, str(args, "token")); + default -> error("unknown action \"" + action + + "\" — must be \"open\", \"confirm\", \"cancel\" or \"status\""); }; } @@ -1325,6 +1327,29 @@ public final class FleetMcp { return text(json(m)); } + /** + * {@code action: "status"}. Read-only — see {@link LeadRollover#status}: never schedules, + * cancels, or retries anything, and it is the only way for a lead to find out what happened to + * a token past {@code confirm()}, since every outcome after that point is otherwise logged only + * (see {@link LeadRollover}'s class javadoc). + */ + private static McpSchema.CallToolResult handoverStatus(LeadRollover leadRollover, String token) { + if (leadRollover == null) { + Map m = new LinkedHashMap<>(); + m.put("state", "NOT_CONFIGURED"); + m.put("detail", "leadRollover: is not configured"); + return text(json(m)); + } + if (isBlank(token)) { + return error("token is required for action \"status\""); + } + LeadRollover.RollStatus s = leadRollover.status(token); + Map m = new LinkedHashMap<>(); + m.put("state", s.state().name()); + m.put("detail", s.detail()); + return text(json(m)); + } + /** The one shared {@code NOT_CONFIGURED} refusal shape for {@code open}/{@code confirm}. */ private static McpSchema.CallToolResult notConfigured() { return refusalJson(false, "NOT_CONFIGURED", "leadRollover: is not configured"); @@ -2265,20 +2290,25 @@ public final class FleetMcp { return tool(FleetTool.HANDOVER.wireName(), "Replace your OWN lead session once its context is full: write a handover file, " + "then use this to have fleetd clear your pane and bootstrap a fresh lead " - + "session against it. Three actions: 'open' (requests a token and the " + + "session against it. Four actions: 'open' (requests a token and the " + "handoverPath you must write the handover file to before confirming), " + "'confirm' (validates every gate and — only if every one passes — schedules " + "the roll; it does NOT itself clear the pane, the roll runs once this call's " - + "own turn ends), and 'cancel' (drops a pending request without rolling). " - + "Primary-only. There is deliberately no terminal/session/leadTerminal " - + "parameter: the pane to roll is always resolved from YOUR OWN connection, " - + "never a value you pass, so you can only ever roll yourself — never another " - + "lead. Requires leadRollover: to be configured; when it is not, every action " - + "returns a clean refusal naming NOT_CONFIGURED instead of failing.", + + "own turn ends), 'cancel' (drops a pending request without rolling), and " + + "'status' (read-only: what happened to a token after 'confirm' — the roll " + + "completed, the calling turn never settled within turnSettleSeconds so no " + + "/clear was ever sent, or /clear itself never settled so bootstrapText was " + + "never sent; never schedules, cancels or retries anything). Primary-only. " + + "There is deliberately no terminal/session/leadTerminal parameter: the pane " + + "to roll is always resolved from YOUR OWN connection, never a value you " + + "pass, so you can only ever roll yourself — never another lead. Requires " + + "leadRollover: to be configured; when it is not, every action returns a " + + "clean refusal naming NOT_CONFIGURED instead of failing.", objectSchema(Map.of( - "action", stringProp("\"open\", \"confirm\" or \"cancel\""), + "action", stringProp("\"open\", \"confirm\", \"cancel\" or \"status\""), "reason", stringProp("Free-text audit note for \"open\" (optional, logged only)"), - "token", stringProp("The token \"open\" returned — required for \"confirm\" and \"cancel\""), + "token", stringProp("The token \"open\" returned — required for \"confirm\", " + + "\"cancel\" and \"status\""), "operatorConfirmed", Map.of("type", "boolean", "description", "For \"confirm\": your answer to \"has the human operator " + "confirmed this wipe\" (default false; only consulted when " 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 5eabaf7..da39888 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/lead/LeadRolloverTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/lead/LeadRolloverTest.java @@ -1004,6 +1004,177 @@ class LeadRolloverTest { } } + // ---- CB-... : LeadRollover#status makes the outcome of a confirmed roll readable ----------- + + @Test + @DisplayName("[STATUS 1] after the calling turn never settles, status() reports " + + "TURN_NEVER_SETTLED for that token — this assertion could not even be written before " + + "status() existed") + void statusReportsTurnNeverSettledAfterTheRollIsAbandoned() 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"); + 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); + 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), "sanity: /clear was never sent"); + + LeadRollover.RollStatus status = rollover.status(pending.token()); + assertEquals(LeadRollover.RollState.TURN_NEVER_SETTLED, status.state()); + assertTrue(status.detail().contains("turnSettleSeconds"), "the detail must name the knob a " + + "reader needs to raise: " + status.detail()); + assertTrue(status.detail().toLowerCase().contains("no /clear"), "the detail must say plainly " + + "that no /clear was ever sent: " + status.detail()); + } + + @Test + @DisplayName("[STATUS 2] a roll that completes reports ROLLED for its token") + void statusReportsRolledForACompletedRoll() throws IOException { + FakeHerdr herdr = new FakeHerdr(); // default idle — a full successful roll + 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(LEAD, pending.token(), true); + assertTrue(decision.accepted(), "expected approval; got: " + decision.reason() + " / " + decision.detail()); + assertEquals(2, promptCallCount(herdr), "sanity: /clear then bootstrapText were both sent"); + + LeadRollover.RollStatus status = rollover.status(pending.token()); + assertEquals(LeadRollover.RollState.ROLLED, status.state()); + assertNotNull(status.detail()); + } + + @Test + @DisplayName("[STATUS 3] a roll where /clear never settles reports CLEAR_NEVER_SETTLED — " + + "distinct from both ROLLED and TURN_NEVER_SETTLED") + void statusReportsClearNeverSettledDistinctFromTheOtherTwoStates() throws IOException { + // Idle until /clear is sent, then permanently working — the SECOND wait never settles. + 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, 20, 1 /*clearSettleSeconds*/, "boot text"); + AtomicLong clock = new AtomicLong(1_000); + LeadRollover rollover = newRollover(flipsAfterClear, config, () -> clock.addAndGet(500)); + + LeadRollover.PendingRollover pending = rollover.open(LEAD, "context is full"); + LeadRollover.RollDecision decision = rollover.confirm(LEAD, pending.token(), true); + assertTrue(decision.accepted(), "every synchronous gate passes; the refusal is logged only, " + + "deep inside the deferred continuation"); + assertEquals(1, promptCallCount(fake), "sanity: only /clear was sent, never bootstrapText"); + + LeadRollover.RollStatus status = rollover.status(pending.token()); + assertEquals(LeadRollover.RollState.CLEAR_NEVER_SETTLED, status.state()); + assertNotEquals(LeadRollover.RollState.ROLLED, status.state()); + assertNotEquals(LeadRollover.RollState.TURN_NEVER_SETTLED, status.state()); + assertTrue(status.detail().contains("clearSettleSeconds"), status.detail()); + } + + @Test + @DisplayName("[STATUS 4] a token that was never issued, or was cancelled, gives a clean " + + "UNKNOWN answer rather than an exception or a false ROLLED") + void statusOnUnissuedOrCancelledTokenIsCleanNotAnExceptionOrFalseSuccess() 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)); + + // never issued at all + LeadRollover.RollStatus neverIssued = assertDoesNotThrow(() -> rollover.status("no-such-token")); + assertEquals(LeadRollover.RollState.UNKNOWN, neverIssued.state()); + assertNotEquals(LeadRollover.RollState.ROLLED, neverIssued.state()); + + // null / blank must not throw either — pending is a ConcurrentHashMap, which throws on a + // null-key lookup unless status() guards it first + assertEquals(LeadRollover.RollState.UNKNOWN, assertDoesNotThrow(() -> rollover.status(null)).state()); + assertEquals(LeadRollover.RollState.UNKNOWN, assertDoesNotThrow(() -> rollover.status(" ")).state()); + + // opened, then cancelled + LeadRollover.PendingRollover pending = rollover.open(LEAD, "context is full"); + assertTrue(rollover.cancel(pending.token())); + LeadRollover.RollStatus cancelled = assertDoesNotThrow(() -> rollover.status(pending.token())); + assertEquals(LeadRollover.RollState.UNKNOWN, cancelled.state()); + assertNotEquals(LeadRollover.RollState.ROLLED, cancelled.state()); + } + + @Test + @DisplayName("[STATUS 5] the bounded outcome history never grows past its cap") + void statusHistoryDoesNotGrowPastItsCap() throws IOException { + FakeHerdr herdr = new FakeHerdr(); // default idle — every roll completes + Path handover = writeHandover("handover contents"); // one file, reused by every roll below — + // checkHandover only compares its mtime against each open()'s OWN requestedAtMillis (the + // fake clock, in the low thousands), and the file's real (wall-clock) mtime is always far + // larger than that, so freshness passes on every iteration without rewriting the file. + AtomicLong clock = new AtomicLong(1_000); + LeadRollover rollover = newRollover(herdr, cfg(handover.toString()), () -> clock.addAndGet(1)); + + int rolls = LeadRollover.OUTCOME_HISTORY_CAP + 50; + String[] tokens = new String[rolls]; + for (int i = 0; i < rolls; i++) { + LeadRollover.PendingRollover pending = rollover.open(LEAD, "context is full #" + i); + LeadRollover.RollDecision decision = rollover.confirm(LEAD, pending.token(), true); + assertTrue(decision.accepted(), "roll #" + i + " should have been approved: " + decision.detail()); + tokens[i] = pending.token(); + } + + assertEquals(LeadRollover.RollState.ROLLED, rollover.status(tokens[rolls - 1]).state(), + "the most recently finished roll's outcome must still be in the bounded history"); + + // There is no direct size accessor for the bounded history, so boundedness is asserted + // indirectly and behaviourally: the OLDEST finished roll's outcome must have been evicted + // (reads back as a clean UNKNOWN, exactly like a token that was never issued) once more than + // OUTCOME_HISTORY_CAP rolls have gone through this instance. If the cap were not enforced, + // tokens[0] would still read back ROLLED here, and this assertion would fail. + assertEquals(LeadRollover.RollState.UNKNOWN, rollover.status(tokens[0]).state(), + "the oldest finished roll's outcome must have been evicted once the cap was " + + "exceeded — otherwise the bounded history is not actually bounded"); + } + + @Test + @DisplayName("[STATUS 6] a status() call against a pending roll is read-only — no herdr call is " + + "made and the pending request is left untouched") + void statusCallAgainstAPendingRollIsReadOnlyAndTouchesNothing() 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.RollStatus status = rollover.status(pending.token()); + assertEquals(LeadRollover.RollState.PENDING, status.state()); + assertEquals(0, herdr.calls.size(), "status() must never make any herdr call at all — not " + + "just no agent.prompt — since it must never schedule, cancel, or retry anything"); + + // the pending request must be left exactly as it was: the SAME token can still be confirmed + // afterwards, as if status() had never been called. + LeadRollover.RollDecision decision = rollover.confirm(LEAD, pending.token(), true); + assertTrue(decision.accepted(), "status() must not have consumed or otherwise disturbed the " + + "pending request: " + decision.reason() + " / " + decision.detail()); + } + @Test @DisplayName("[fleetd #494 follow-up] the turn-settle timeout warn line prints the MEASURED " + "elapsed time next to the configured budget, never the configured value alone") diff --git a/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpHandoverTest.java b/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpHandoverTest.java index a93a129..eebad38 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpHandoverTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpHandoverTest.java @@ -258,5 +258,51 @@ class FleetMcpHandoverTest { assertTrue(textOf(r).contains("\"cancelled\":true"), textOf(r)); } + // --- new: action "status" — makes the outcome of a confirm() readable through the tool ---- + + @Test + @DisplayName("status on a null LeadRollover is a clean NOT_CONFIGURED refusal, never a throw") + void statusWithNullLeadRolloverRefusesCleanly() { + McpSchema.CallToolResult r = assertDoesNotThrow(() -> FleetMcp.handover(null, LEAD, + Map.of("action", "status", "token", "whatever"))); + assertFalse(r.isError()); + assertTrue(textOf(r).contains("NOT_CONFIGURED"), textOf(r)); + } + + @Test + @DisplayName("status on a token that was never opened reports UNKNOWN") + void statusOnUnknownTokenReportsUnknown() { + LeadRollover rollover = newRollover(tmp.resolve("h.md").toString()); + + McpSchema.CallToolResult r = FleetMcp.handover(rollover, LEAD, + Map.of("action", "status", "token", "does-not-exist")); + assertFalse(r.isError()); + assertTrue(textOf(r).contains("\"state\":\"UNKNOWN\""), textOf(r)); + } + + @Test + @DisplayName("status on a token that is still pending (opened, not confirmed) reports PENDING") + void statusOnPendingTokenReportsPending() { + LeadRollover rollover = newRollover(tmp.resolve("h.md").toString()); + String token = extractToken(textOf(FleetMcp.handover(rollover, LEAD, Map.of("action", "open")))); + + McpSchema.CallToolResult r = FleetMcp.handover(rollover, LEAD, + Map.of("action", "status", "token", token)); + assertFalse(r.isError()); + assertTrue(textOf(r).contains("\"state\":\"PENDING\""), textOf(r)); + } + + @Test + @DisplayName("status is registered on the tool's schema and the schema still names no caller-identity parameter") + void statusActionIsAdvertisedOnTheSchema() { + FleetMcp m = mcp(null); + McpSchema.Tool tool = m.registeredTools().stream() + .filter(t -> "fleet_handover".equals(t.name())) + .findFirst() + .orElseThrow(() -> new AssertionError("fleet_handover was not registered")); + assertTrue(tool.description().contains("'status'"), + "the tool's own description must advertise the 'status' action: " + tool.description()); + } + // --- acceptance 7 (wiring) is covered by FleetdLeadRolloverWiringTest, unchanged ----------- }