A lead-coordination message can be delivered forever: ack() returns success when connection recovery cleared the held entry #385
Closed
opened 2026-09-09 23:29:35 +02:00 by ltms
·
2 comments
No Branch/Tag Specified
main
worker/fleetd-612-unita-87807e-1
worker/612-b3-mcpwirings-da2b58-3
worker/612-b2-cb185-176d3a-2
worker/612-b1-completion-457459-1
worker/612-agaps-73a926-2
worker/608-sleeps-3a64ff-3
worker/621-b4520b-1
worker/618-b83894-2
worker/fleetd-615-e05481-5
worker/lead-autocompact-5f1ab2-3
worker/fleetd-613-f85deb-3
worker/fleetd-608-flaky-nudge-test-d0c2d1-3
worker/lead-context-gauge-ad404f-1
worker/gauge-wiring-9158c1-4
worker/redeploy-slowstart-ead0e5-5
worker/charter-bytes-13668c-6
worker/rollover-outcome-291483-2
worker/589-f64303-2
worker/593-1a8025-5
worker/589-fcd2aa-1
worker/568-9fdaa2-3
worker/571-attempted-outcome-5739f7-2
worker/581-completionresolver-cas-sites-0542b7-6
worker/562-loop-health-wiring-test-99611c-5
worker/562-surface-loop-health-7df5cc-4
worker/575-waiter-cleanup-sites-62ad80-1
worker/572-answer-lock-release-46a9ae-5
worker/567-probe-channel-leak-a38fc5-6
worker/551-record-before-send-7cbf56-1
worker/561-listener-fanout-survives-a-throw-61d538-2
worker/555-redeploy-main-flow-seam-65c2f5-2
worker/556-injector-owns-registration-e027a5-1
worker/552-post-restart-mktemp-abort-bc2672-4
worker/553-onstatus-completion-leak-0da881-2
worker/550-shasum-linux-196132-1
worker/538-loop-dies-on-error-4a5eeb-6
worker/426-health-coverage-ef1fd4-4
worker/504-failed-reported-clean-3cfd66-3
worker/537-capturedlog-close-e4c437-2
worker/459-broken-link-targets-cadc17-5
worker/535-appender-leak-fe74c1-1
worker/512-part2-shutdown-detection-434701-9
worker/529-logger-level-sweep-2a5533-8
worker/528-drain-gate-call-site-5de83d-7
charter/forge-mcp-vs-token
worker/521-swap-guard-unpinned-28e931-5
worker/519-probe-test-harness-d25ab8-4
worker/525-logger-level-leak-1b4eb0-6
worker/518-fleetmcp-resolver-wiring-8ef96c-1
worker/512-drain-complete-line-7edd71-3
worker/517-abort-branch-and-jar-id-41b641-2
worker/500-9e52c9-3
worker/509-4912f4-2
worker/511-9a4b23-1
worker/493-479f45-2
worker/505-03f8b2-1
worker/492-followup-detect-unclear
worker/501-a31fa0-7
worker/498-451d1c-5
worker/494-1015ce-2
worker/492-209647-1
worker/489-001902-2
worker/480-relative-handover-path-906323-1
worker/480-b-handover-skill-45bf1f-5
worker/474-followup-source-pin-f54a55-17
worker/474-charter-check-on-reload-f54a55-17
worker/466-quarantine-repeatcount-report
worker/393-opencode-skill-seeding-71854b-13
worker/469-canonical-tool-names-2a472a-16
worker/466-quarantine-escalation-5ae9c1-15
worker/446-hot-exhausted-pattern-0af580-6
worker/464-charter-tool-name-guard-a85635-12
worker/463-listfleet-default-fails-open-f1c76c-11
worker/458-invariant-5-by-purpose-862f9a-10
worker/439-coordinator-row-gate-bc032a-8
worker/449-herdr-protocol-576015-4
worker/450-abstract-spawn-599e1c-5
worker/437-ack-refuses-177d91-1
worker/444-placement-window-feb56a-2
worker/440-helddurable-derived-d462d7-13
worker/425-rework-placement-resolve-c58ba1-9
worker/421-lead-peek-held-msgs-cdbad2-10
worker/435-fixed-policy-cap-fe11de-12
worker/422-gate-state-observability-9e79d6-11
worker/431-memberregistry-live-readers-cdbad2-10
worker/424-architect-slot-hot-038b41-7
worker/422-model-gate-spawn-c29f48-6
worker/425-default-profile-live-f55534-8
worker/415-coverage-wording-2cbf9c-5
worker/416-3ad1da-1
worker/418-588283-3
worker/deterministic-stamp-race-409-3cb7b6-10
worker/armed-reads-live-config-404-ed931f-9
worker/reply-peer-refusal-391-5a34bd-7
worker/models-allowlist-aa9e9b-3
worker/ttl-stamp-race-399-f1122f-8
worker/scrub-receipt-400-316b3e-5
worker/exhaustion-detection-395-105105-6
worker/scrub-abort-394-316b3e-5
fix/scrub-uid-abort
worker/task-scrub-517574-2
worker/t386-clock-bd5b78-4
worker/t384-scrub-813790-5
worker/t381-cc-748314-2
worker/t373-336973-2
worker/t365-3920c5-3
worker/t358-6e989b-1
worker/t355-8b321c-1
worker/fleetd-369-hermetic-git-tests-e8b19a-3
worker/fleetd-368-stale-lead-binding-f5682e-2
worker/fleetd-360-deploy-units-0d3793-1
worker/359-dead-lead-tabs-f1253b-4
worker/362-worktree-skills-c03e51-3
worker/361-coord-visibility-655144-1
362-plugin-visibility-and-drift
worker/errscan-bed2ca-2
worker/amqp-log-identity-bed2ca-2
worker/withdefaults-guard-561704
worker/sleepguard-82076d-1
worker/fd334-9ee1b6-5
worker/fd348-f1ab27-4
worker/fd335-a71c35-1
worker/fd342-174a17-2
worker/fd345-490d0f-3
worker/fleetd-337-5ec7d4-21
worker/fleetd-341-af5a6b-24
worker/fleetd-339-5ca0a2-23
worker/fleetd-338-83a4a1-22
worker/fleetd-333-281f46-18
worker/fleetd-329-11bdbb-16
worker/fleetd-330-2770fb-17
worker/fix-326-50506e-15
worker/fix-324-3e9bbf-14
worker/fix-323-b8287d-13
worker/fix-316b-bd0860-11
worker/fix-318-76ca36-9
worker/fix-317-486aec-8
worker/fix-315-ce47c5-6
worker/fix-307-275890-6
worker/fix-308-b4f664-7
worker/fix-309-ec3939-8
worker/fix-310-7a3974-9
worker/fix-302-52ad0e-9
worker/fix-298-ce1acb-8
worker/fix-297-66bd11-7
worker/fix-296-104622-6
worker/fix-293-bare-closetab-eb22b5-3
worker/fix-280-gone-ask-lapse-bca98e-2
worker/fix-290-reapidle-guard-coverage-9b0dd1-1
worker/fix-285-trust-seed-8f3565-10
worker/fix-284-backend-error-seat-85912c-11
worker/fix-282-chained-ask-e6d0bb-8
worker/fix-283-teardown-leaks-f40dfa-9
worker/fix-281-pin-handler-actions-4921ac-7
worker/audit-rendezvous-lifecycle-d072ae-2
worker/audit-health-placement-1a2476-6
worker/audit-teardown-exits-e207a5-3
worker/audit-launcher-asymmetry-27e370-4
worker/audit-rest-authz-6ca53c-5
worker/investigate-275-abandon-asking-fdef52-8
worker/fix-274-worktree-leak-b0095d-7
worker/fix-273-exhausted-pattern-9665b5-6
worker/fleetd-267-model-check-bd8068-1
worker/fleetd-131-archunit-18b834-7
worker/fleetd-266-sshagent-rename-a014ff-6
worker/fleetd-184-uid-claim-8e1f31-4
worker/fleetd-184-warn-b381ee-10
worker/fleetd-184-docs-be1d12-9
worker/fleetd-257-9bf010-7
worker/fleetd-103-23a113-6
worker/fleetd-247-342356-5
worker/fleetd-116-04dea8-4
worker/fleetd-252-a830e0-3
worker/fleetd-111-7e8673-9
worker/fleetd-155c-f8ef4b-8
worker/fleetd-176-b928ca-3
worker/fleetd-249-7a7878-2
worker/cb248-composition-root-b-9acdf7-15
worker/cb148-envrc-default-fa6c82-12
worker/cb201-unit5-wiring-6c12e6-8
worker/cb241-fallback-echo-1175e9-11
worker/cb149-trust-dialog-2392a5-9
worker/cb134-148-overlay-visible-c9b986-10
worker/cb234-session-id-keyed-04e1fc-1
worker/cb201-unit3-nudge-abdf5c-6
worker/cb201-unit2-policy-c1102c-5
worker/cb201-unit4-outcome-a13bfa-7
worker/cb201-unit1-classifier-91b9b1-4
worker/cb201-227-refine-831980-3
worker/cb175-model-readback-0f085f-1
worker/cb222-charter-tmpdir-17f013-1
worker/cb226-architect-slot-race-cd3aa8-3
worker/cb224-worktree-root-group-024523-2
worker/cb-123-role-demotion-c600f7-2
worker/cb-219-opencode-roots-1f677e-1
worker/cb214-claude-session-id-b9eab4-4
worker/cb213-zdotdir-wrong-process-dd6de4-3
worker/cb211-exhaustion-classification-9546e0-2
worker/cb137-ambiguous-task-4df3d8-4
worker/cb209-agentsessionid-4dfdb6-2
worker/cb185-hostenvnames-2692b5-3
worker/cb206-opencode-sqlite-128718-2
worker/cb185-worktree-group-fc0c99-1
worker/cb-137-ask-ticket-e7760c-2
worker/cb-172-broker-uri-d36ae4-4
worker/cb-175-model-readback-76ead6-3
worker/cb-161-pane-ancestry-293510-1
worker/cb-164-rebase-885863-8
worker/cb-164-empty-scrape-false-success-1a80af-3
fix/cb-197-ticket-ttl-from-completion
worker/cb-189-remote-url-coverage-4692f3-1
worker/cb-185-blockers-027756-4
worker/cb-192-gap-log-11b631-2
worker/cb-633-fix-5f4396-3
worker/cb185-router-d6436d-3
worker/cb185-router-routing-gaps-9e9d33-3
worker/cb185-paneids-992586-2
worker/cb-633-allow-list-union-ed374b-1
worker/cb-157-credential-in-remote-url-496e44-2
worker/cb-641-health-herdr-evidence-8f1f54-6
worker/cb-640-health-msg-evidence-99c9cd-1
worker/cb-642-fleets-status-skill-bbbc40-5
cb-634-ide-mcp
worker/lead-comms-wiring-c014b9-7
worker/lead-mailbox-c19577-6
worker/autocompact-window-82bc2f-5
worker/cb-634-probe-18056f-4
worker/cb635-broker-urienv
worker/cb-632-config-retry-8e0efa-7
lead/cb-622e-claude-md
lead/cb-622-followup
worker/cb-622a-165dff-1
lead/cb-622d-opencode-mount
worker/cb-622b-717c67-2
worker/cb-622c-ab7759-3
worker/cb-617b2-20ca4b-3
worker/cb-617a-5c2f4a-1
worker/cb596-4e49ef-3
worker/cb586-10500c-1
worker/cb-606-b9343a-25
worker/cb604-1445f8-24
worker/cb582-477374-21
worker/cb584-8c2281-22
worker/cb600-e6b9a9-20
worker/cb602-ce257f-19
worker/cb601-b42837-18
worker/cb598-6c7ba7-17
worker/cb599-740fe4-16
worker/cb597-282224-15
worker/cb590fix-185e9a-10
worker/cb528-recovery-race
worker/cb594-96bead-8
worker/cb590-916766-2
worker/cb527-997d99-3
worker/cb592-env-leak-3cbf9c-1
worker/cb588-async-ticket-nudge-3218f7-5
worker/cb578b-9dcb13-6
worker/cb581-d24826-5
worker/m2-u5-ef8c42-15
worker/cb578a-516499-2
worker/cb576-01a04b-17
worker/cb579-lead-tab-acba06-20
worker/cb580-terminal-health-ed6058-21
worker/cb577-f36fdc-18
worker/cb573b-3db06f-16
worker/cb568c-f36fdc-18
worker/cb568-drop-cause-c3ac1c
worker/cb575-cancelled-notification-c3ac1c
worker/m4-sol-a2cbec-3
worker/cb574-async-ask-c3ac1c
worker/cb573-health-model-8ca857-14
worker/cb572-unknown-target-7f2e35-13
worker/u4-700706-9
worker/u3-b9fcb6-6
worker/u2-ef5b68-4
worker/u1-469dce-1-clean
worker/u1-469dce-1
worker/cb-564-health-events-70cf7e-2
worker/cb-565-recycle-drops-role-98e58f-3
worker/cb-563-missing-reply-df2866-1
worker/cb-562-readiness-gate-silent-6c23c9-3
worker/cb-560-architect-presence-da8155-1
worker/cb-561-architect-silent-off-a71cab-2
worker/cb-548-bind-architect-slot-fe1b8c-1
worker/parity-overlay-settings-5fb711-1
secrets-central-store
cb-559-hot-key-correction
cb-557-fleet-role-pools
worker/cb-553-maxload-explicit-spawn-305ee3-6
worker/cb-551-idle-lead-heartbeat-f1633c-1
worker/cb-544-drain-preserves-worktree-925fad-3
worker/cb-552-docs-sync-1cb9cf-4
worker/cb-548-rendezvous-guard-rebased
worker/cb-548-rendezvous-guard-116b53-10
worker/cb-548-authz-v2-586df6-8
worker/cb-548-authz-264363-5
salvage/cb-528b-codex-home
salvage/cb-528a-codex-launcher
CB-518-primary-flow
feature/peer-launcher-spi
cb-103-injector
v1.1.0
v1.0.0
Labels
Clear labels
blocked
needs-live-proof
ready-to-delegate
silent-default
Cannot start until something else lands. The body says what.
Merged and green, but never shown working on the running daemon. Not the same as done.
Scope, files and acceptance criteria are written. A worker can be briefed from the body alone.
A feature that compiles, passes tests, and ships turned off. Nine recurrences and counting.
No Label
Milestone
No items
No Milestone
Projects
Clear projects
No project
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: fleet/fleetd#385
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Measured on the Mac lead, 2026-09-09/10, jar
703ef5fcb5ab(HEAD2830735). Reproduced live — one peer message was injected into my own pane 8 times.Symptom
One
msgIdwas delivered over and over. Every other message was delivered exactly once:Each delivery re-injects the full message into the lead's pane. This one was about 5 KB, so it consumed the lead's context window eight times over. That is the real cost: not noise, but a lead losing context to the same text.
Why nothing reported it
LeadCoordLoopis written to handle a lost ack, and its javadoc calls that out as the deliberate direction of the trade:That warning fired once in the whole log, while 8 redeliveries happened:
So the ack was not throwing. It was returning successfully without acking anything.
The defect
LeadMailbox.ack(LeadMailbox.java:355):And the recovery listener (
LeadMailbox.java:173):The comment on
:361names two cases — "never held" and "already acked". There is a third: cleared by connection recovery while this message was in flight. In that case the broker still holds the message unacked, andack()reports success anyway.The window is wide.
LeadCoordLoop.tick()does:channel.peek()agents.send(lead, ...)— types the whole message into a pane, which takes seconds for a 5 KB bodychannel.ack(msg.msgId())A recovery anywhere in steps 2–4 clears
held, so step 4 silently no-ops. The connection is not stable here:Then the loop closes: broker redelivers →
LeadCoordLoopinjects it into the pane again → acks into an empty map again → forever.Why this message and not the others
Nothing special about it. The other nine were acked during a stable stretch. This one happened to be in flight across a recovery, and once it is in the loop every later recovery re-arms it.
Suggested fix
The bug is that
ack()conflates "nothing to ack" with "acked". Two parts:Do not report success for an ack that did not happen. When
heldhas no entry,ack()cannot know whether the broker has it. Signal that to the caller instead of returning quietly, soLeadCoordLoop's existingcould not ack itwarning fires and the condition is visible. That alone converts a silent infinite loop into a reported one.Do not re-inject an already-delivered message. Even with (1), the redelivery still reaches the pane. Keep a bounded set of msgIds already delivered to the lead; on a redelivery of a known msgId, ack it and skip the pane write. The class javadoc already says "dedup by msgId still prevents any double-queue", but that dedup guards the
heldmap, not the pane injection — which is the part that costs the lead its context.Restoring the stale delivery tag is not an option: the javadoc at
:170is right that tags are invalid after recovery. The fix has to be at the msgId level.Tests
LeadMailboxTest,LeadMailboxIsMissingQueueTestandLeadCoordLoopTestexist, and none of them covers recovery:A test should pin: given a held message, when recovery clears the map, then
ack(msgId)must not report success — and a redelivered msgId already delivered must not be written to the pane a second time.One correction to my own analysis
I first thought every delivery was followed by a connection drop within seconds. That was wrong: I parsed
HH:MM:SSout of a log spanning several weeks and ignored the dates, so the ordering was meaningless. Measured properly, 0 of 16 deliveries are followed by a drop within 60 s. The redelivery count and the code path above do not depend on that mistaken correlation.Fixed and merged to
mainas71c322f(fix) and7754f53(a follow-up test).What the fix does
Two changes, in two places, because two different things were wrong.
LeadCoordLoopremembers what it has already written to a pane. It keeps a bounded set (1024) of msgIds it has injected. A redelivery of one of those is acked without a second pane write. This has to live in the loop, not in the mailbox, because only the loop knows a pane write happened; the mailbox knows delivery tags and must still redeliver after a crash.LeadMailbox.ackno longer returns quietly for an unknown msgId. It throws. A repeat ack that this same connection already completed stays quiet, tracked in a bounded set. The oldreturn;reported success for an ack that never reached the broker, which is the shape this ticket was filed about.The evidence, re-measured on the live daemon
I said in the ticket body that my first correlation was wrong. Here is the measurement anchored to the last daemon boot, so no older lines are included:
AMQP lead mailbox connection recoveredlines.aba76914-…, written to my pane 12 times in 9 hours.ee35a5be-…, twice. Its first ack failed out loud:could not ack it: AlreadyClosedException … SocketException: Operation timed out.So the recovery path is the one that fired. The redeliveries track the host's sleep/wake cycle, roughly hourly.
Verification
mvn clean installin the branch worktree, run by me, not by the worker. First run failed onMessageServiceTest.anAlreadyCollectedTicketProducesNoNudge; that test passes onmainin isolation and passes in the branch worktree in isolation, and the second full run was clean, so it is a flake unrelated to this change. The worker could not run any command itself — its command runner refused every shell call withclassifier produced no valid verdict after 3 attempt(s)— so nothing here rests on its report.What verification found that the worker's tests did not
I moved the dedup check to sit after the "is the lead pane injectable?" gate instead of before it. Every test still passed.
That placement is not cosmetic. This lead is mid-turn most of the time — the log is full of
lead … is WORKING (not injectable), holding 3 message(s). A redelivered message needs no pane, so gating its ack on an idle pane leaves it held, and the next recovery delivers it again. That is exactly the loop this ticket is about, rebuilt behind the fix.aRedeliveryIsAckedEvenWhileTheLeadIsMidTurnnow pins it. It fails on that mutant (expected: <[m1, m1]> but was: <[m1]>) and passes on the fix.Still open
The fix stops the duplicate pane writes. It does not stop the underlying cause, which is that the host's AMQP link drops every time the Mac sleeps. That is now filed separately as #386, together with a wider effect of the same sleep:
System.nanoTime()does not advance while macOS is asleep, so every duration fleetd measures freezes with the host.Reopening the measurement, not the ticket. The fleet01 lead ran my check on their host and got a clean zero — and the reason is the part worth recording.
Their numbers, 7 days:
Same code as mine. Mine wrote one msgId into my pane 12 times in 9 hours and logged 19 AMQP recovery events.
The variable is the network path, not the code
LavinMQ runs on fleet01. That lead's
LeadMailboxconnection is loopback, so it does not drop. Mine crosses the internet to reach the same broker, and every blip is a recovery that clears the held map. 157 against 0 is not a difference between our daemons. It is a difference in how far the AMQP connection has to travel.Their words, and they are right: they are not protected from this bug — the trigger simply never happened.
What that means for anyone testing the fix
A co-located lead will report zero whether or not the fix works. A third lead stood up on the broker host would see the same clean numbers, conclude the bug is fixed, and be measuring nothing at all.
So: the reproduction needs a lossy connection, not just a restart. I wrote the original repro as "restart the daemon and watch the held map clear". That is the mechanism, but on a loopback connection the recovery that clears the map effectively never fires on its own. Restarting forces one; ordinary operation never will.
This is the same shape as the rest of this ticket's history, one level out. A zero can mean it did not happen or I could not look. Here it means a third thing: the trigger was not present on that host. All three read identically in a log.
The cost is asymmetric and lands on the remote lead
fleet01 pays nothing. I pay context on every redelivery — that is what 12 writes of one message into my pane in 9 hours actually costs. So the lead who most needs this fix is the one least able to notice they need it, because a remote lead's evidence is buried in its own transcript rather than in a log anyone greps.
Status
The fix is merged (
71c322f), its follow-up test is merged (7754f53), and the daemon here has been redeployed and is running it. fleet01 has since rebuilt and is on it too.Nothing to change in the code. Recording this so the next person who checks "is #385 fixed?" on a co-located host does not read their zero as a pass.