fleetd #480 Unit E: BLOCKED is not a settled turn boundary #484

Merged
ltms merged 2 commits from worker/480-e-blocked-is-not-settled-e3d8e7-7 into main 2026-09-11 02:21:06 +02:00
Member

What

LeadRollover.waitUntilInjectable used AgentStatus#injectable(), which accepts BLOCKED. A BLOCKED pane is a live turn paused on a prompt (e.g. an approval), not a settled turn. Reusing injectable() as the safety gate let /clear (or bootstrapText after it) fire into an open prompt within the turnSettleSeconds/clearSettleSeconds window, destroying the lead's live context — exactly the failure the gate exists to prevent.

Fix

  • Renamed the private helper waitUntilInjectable -> waitUntilAtTurnBoundary.
  • Both waits (the pre-/clear wait and the post-/clear wait) now require AgentStatus.IDLE or AgentStatus.DONE only. BLOCKED, WORKING, and UNKNOWN all mean "not settled - keep polling".
  • Added a javadoc comment explaining why this class deliberately does not reuse injectable(): it answers the Injector's question ("may I deliver"), while this class asks a stricter one ("has the turn actually ended").
  • No change to AgentStatus.java, Fleetd.java, FleetMcp, or config — scope is LeadRollover.java plus its test file only.

Tests added (LeadRolloverTest)

  • blockedTheWholeTurnSettleWindowSendsNoClearAtAll — pane reports blocked for the whole turnSettleSeconds window; asserts 0 sends.
  • blockedAfterClearNeverSendsBootstrapText — pane goes idle (so /clear is sent), then blocked; asserts exactly 1 send (/clear only), bootstrapText never sent.
  • doneStatusStillCompletesTheFullRoll — pane reports done the whole time; asserts the full roll completes (2 sends: /clear then bootstrapText) — proves the fix did not over-tighten to IDLE-only.
  • All 14 pre-existing tests in LeadRolloverTest are unchanged and still pass.

Mutation proof (ticket-mandated)

  1. Control count against the unmodified (HEAD) file: git show HEAD:fleetd/src/main/java/dev/ltms/fleet/lead/LeadRollover.java | grep -c "status != null && status.injectable()" = 1.
  2. Broke the fix on purpose: restored if (status != null && status.injectable()) in place of the IDLE/DONE check.
  3. Proved the mutation landed with two different greps: grep -n "status != null && status.injectable()" found it present; grep -c "status == AgentStatus.IDLE || status == AgentStatus.DONE" = 0 (fixed line gone).
  4. Ran mvn -o test -Dtest=LeadRolloverTest: 2 failures, exactly the two new tests:
    • LeadRolloverTest.blockedAfterClearNeverSendsBootstrapText: expected <1> but was <2>
    • LeadRolloverTest.blockedTheWholeTurnSettleWindowSendsNoClearAtAll: expected <0> but was <2>
      All other 15 tests still passed against the mutant, as expected (they don't exercise BLOCKED).
  5. Restored the fix (the mutation was applied on top of an uncommitted working copy, so git checkout -- reverted to the pre-fix HEAD; re-applied the real fix from a saved backup) and confirmed git status --porcelain was clean before staging/committing.

Build

mvn clean install from the fleetd module: BUILD SUCCESS, Tests run: 1654, Failures: 0, Errors: 0, Skipped: 0.

Out of scope (reported, not fixed per ticket instructions)

Checked every other call site of AgentStatus#injectable() in fleetd/src/main/java: LeadCoordLoop, LeadHeartbeatLoop, ReplyPushLoop, Injector, and HerdrPeerLauncher's two spawn-readiness checks. All six are message-delivery gates asking "may I inject now", which is exactly what injectable() is for (queuing behind BLOCKED is safe for delivery). None of them need a turn-boundary rule instead.

## What LeadRollover.waitUntilInjectable used AgentStatus#injectable(), which accepts BLOCKED. A BLOCKED pane is a live turn paused on a prompt (e.g. an approval), not a settled turn. Reusing injectable() as the safety gate let /clear (or bootstrapText after it) fire into an open prompt within the turnSettleSeconds/clearSettleSeconds window, destroying the lead's live context — exactly the failure the gate exists to prevent. ## Fix - Renamed the private helper waitUntilInjectable -> waitUntilAtTurnBoundary. - Both waits (the pre-/clear wait and the post-/clear wait) now require AgentStatus.IDLE or AgentStatus.DONE only. BLOCKED, WORKING, and UNKNOWN all mean "not settled - keep polling". - Added a javadoc comment explaining why this class deliberately does not reuse injectable(): it answers the Injector's question ("may I deliver"), while this class asks a stricter one ("has the turn actually ended"). - No change to AgentStatus.java, Fleetd.java, FleetMcp, or config — scope is LeadRollover.java plus its test file only. ## Tests added (LeadRolloverTest) - blockedTheWholeTurnSettleWindowSendsNoClearAtAll — pane reports blocked for the whole turnSettleSeconds window; asserts 0 sends. - blockedAfterClearNeverSendsBootstrapText — pane goes idle (so /clear is sent), then blocked; asserts exactly 1 send (/clear only), bootstrapText never sent. - doneStatusStillCompletesTheFullRoll — pane reports done the whole time; asserts the full roll completes (2 sends: /clear then bootstrapText) — proves the fix did not over-tighten to IDLE-only. - All 14 pre-existing tests in LeadRolloverTest are unchanged and still pass. ## Mutation proof (ticket-mandated) 1. Control count against the unmodified (HEAD) file: `git show HEAD:fleetd/src/main/java/dev/ltms/fleet/lead/LeadRollover.java | grep -c "status != null && status.injectable()"` = 1. 2. Broke the fix on purpose: restored `if (status != null && status.injectable())` in place of the IDLE/DONE check. 3. Proved the mutation landed with two different greps: `grep -n "status != null && status.injectable()"` found it present; `grep -c "status == AgentStatus.IDLE || status == AgentStatus.DONE"` = 0 (fixed line gone). 4. Ran `mvn -o test -Dtest=LeadRolloverTest`: 2 failures, exactly the two new tests: - LeadRolloverTest.blockedAfterClearNeverSendsBootstrapText: expected <1> but was <2> - LeadRolloverTest.blockedTheWholeTurnSettleWindowSendsNoClearAtAll: expected <0> but was <2> All other 15 tests still passed against the mutant, as expected (they don't exercise BLOCKED). 5. Restored the fix (the mutation was applied on top of an uncommitted working copy, so `git checkout --` reverted to the pre-fix HEAD; re-applied the real fix from a saved backup) and confirmed `git status --porcelain` was clean before staging/committing. ## Build `mvn clean install` from the fleetd module: BUILD SUCCESS, Tests run: 1654, Failures: 0, Errors: 0, Skipped: 0. ## Out of scope (reported, not fixed per ticket instructions) Checked every other call site of AgentStatus#injectable() in fleetd/src/main/java: LeadCoordLoop, LeadHeartbeatLoop, ReplyPushLoop, Injector, and HerdrPeerLauncher's two spawn-readiness checks. All six are message-delivery gates asking "may I inject now", which is exactly what injectable() is for (queuing behind BLOCKED is safe for delivery). None of them need a turn-boundary rule instead.
agent added 1 commit 2026-09-11 02:02:30 +02:00
fleetd #480 Unit E: BLOCKED is not a settled turn boundary
CI / contract (pull_request) Successful in 49s
CI / build (pull_request) Successful in 1m55s
802c0ab701
LeadRollover's waitUntilInjectable used AgentStatus#injectable(), which
accepts BLOCKED. A BLOCKED pane is paused mid-turn on a prompt, not
settled — reusing injectable() let /clear (or the bootstrap text after
it) fire into an open approval prompt within the 20s settle window,
destroying the lead's live context.

Renamed the helper to waitUntilAtTurnBoundary and restricted both waits
to IDLE or DONE only, with a comment explaining why this class does not
reuse injectable() (it answers "may I deliver", not "has the turn
ended"). Added tests for BLOCKED-forever on both waits (zero sends /
exactly one send) and for DONE still completing the full roll.
agent added 1 commit 2026-09-11 02:18:59 +02:00
fleetd #480 Unit E correction: retire "injectable" wording from the log lines
CI / build (pull_request) Successful in 1m28s
CI / contract (pull_request) Successful in 1m27s
e2a91e883e
Both waitUntilAtTurnBoundary guard messages still said "never went idle" /
"did not become injectable" — the old mental model the rename was meant to
retire. Made both say what the code now actually waits for: a turn boundary
(IDLE or DONE).
ltms merged commit a0eed6f01b into main 2026-09-11 02:21:06 +02:00
ltms deleted branch worker/480-e-blocked-is-not-settled-e3d8e7-7 2026-09-11 02:21:06 +02:00
Sign in to join this conversation.