fleet_send{turnId} (MessageService.answer) blocks the primary only for its
own bounded MCP-call window (25s default, 120s max) — far shorter than a
worker's resumed turn can genuinely take. When that window expires, answer()
closes its rendezvous waiter, so the worker's eventual fleet_reply has no
live waiter to resolve and falls back to the session inbox. The async
ticket's future was never completed by that path, so fleet_poll{ticket}
stayed PENDING until fleet_stop's abandon() forced it FAILED with a
misleading "the worker session was released before it replied" reason,
even though the reply had genuinely arrived.
- MessageService.reply(): before falling to the inbox, look for the async
task this exact turn belongs to (already answered — question cleared,
turnId still stamped — but not yet resolved) and complete it directly
with the real reply, so fleet_poll{ticket} returns it.
- MessageService.abandon(): defense in depth, independent of the above —
never write a false WORKER_FAILED once a reply reached the inbox for
this target; recover and use its real content instead.
- Two new tests drive the full delegation path (async send -> ask ->
answer with a short timeout -> reply -> poll/abandon), not a reply sink
directly; both fail with the fix disabled and pass with it restored.
11 KiB
CB-137 / fleetd issue #137 — report
Real root cause (not the hypothesis in the ticket)
I read MessageService.java and Rendezvous.java before changing anything. The mechanism is real,
but the exact place it happens is MessageService.answer(), not "the reply goes to the inbox on
purpose" in general.
- A lead delegates with
fleet_send{wait:false}→sendAsync()creates aTaskand runssend()on a background virtual thread with a 30-minute internal budget (ASYNC_TIMEOUT_MS). - The worker calls
fleet_ask. That resolves the open rendezvous waiter withKind.QUESTION, sosend()returns immediately and theTaskis left open (itsfuturestays unresolved — see the comment insendAsync's lambda: "Keep the accepted owner until answer() finishes it"). - The lead answers with
fleet_send{turnId, content}. This callsFleetMcp.answer()→MessageService.answer(turnId, content, timeout). Thetimeouthere is not the generous 30-minute async budget — it is the MCP tool's own bounded wait:DEFAULT_TIMEOUT_MS = 25_000, clamped to at mostMAX_TIMEOUT_MS = 120_000(FleetMcp.java:71-72,495,512). This is the same ~60–120s window every blockingfleet_sendcall is capped at (documented elsewhere as "the caller's own MCP client call timeout"). answer()opens a fresh rendezvous waiter for the worker session and blocks on it for at most that window. If the worker's resumed turn takes longer than that to actually finish (very plausible — the resumed turn can mean more edits, a build, a commit, a push, opening a PR), the wait times out. On timeout,answer()'sfinallyblock unconditionally callsrendezvous.close(workerSession, reply), removing the waiter from the map, and returnsOutcome.TIMED_OUT_WORKINGto the lead.- The worker keeps working, unaware anything happened, and eventually calls
fleet_reply. That reachesMessageService.reply(session, content), which triesrendezvous.resolve(session, content)— but the waiter was already closed in step 4, soresolvereturnsfalse.reply()then falls back toinbox.publish(...)and marksstrandedReplies.put(session, true)(CB-640 bookkeeping) — the reply is safely held, but the asyncTask'sfutureis never completed. fleet_poll{ticket}keeps returningPENDINGforever (theTasknever resolves) — until the lead eventually callsfleet_stop. That firessessions.onRelease→messages.abandon(target, reason)(Fleetd.java:481-497), wherereasonis built with the exact text from the bug report ("the worker session was released before it replied; worktree=... branch=... snapshot=...",Fleetd.java:484-487).abandon()'s loop finds the still-openTask(question == null, future not done) and completes it asWORKER_FAILEDwith that misleading reason — even though the worker's real reply is sitting, intact, in the inbox the whole time.
So: the reported behaviour is correct, and the specific trigger is answer()'s own bounded wait
being shorter than the worker's real resumed-turn time — not anything to do with the ~55s
fleet_ask window itself (that part, issue #61, is untouched).
Fix
Two changes in fleetd/src/main/java/dev/ltms/fleet/msg/MessageService.java, both scoped to the
ticket/reply routing and the terminal-state text — fleet_ask's own window and mechanics are
untouched.
1. reply() — priority 1 (the ticket resolves with the real reply).
Before falling back to the inbox, reply() now looks for an async Task that is specifically in the
"already answered but not yet resolved" state (question == null, turnId != null — set once
answer() has cleared the question but before anything completed the future, !future.isDone()).
If one exists for this target, the worker's reply completes that Task's future directly as
Outcome.REPLIED with the real content, and the reply never touches the inbox at all. A task that
was never asked has turnId == null and can never match, so ordinary (no-fleet_ask) delegations
are unaffected — they already resolve through the pre-existing rendezvous fast path.
I chose this over leaving answer()'s own timeout behaviour untouched and instead keeping its
rendezvous waiter open in the background: that alternative works but reopens the "at most one
waiter per session" invariant (Rendezvous.open throws on a double-open) to a new class of races
with a fresh send arriving mid-window. The send() path already guards against sending into an
answered-but-still-resolving worker via hasAsyncQuestion(target) (checks asyncTasksByTurn,
which still holds the task until it resolves), so routing through reply() gets the same protection
without touching answer()'s waiter lifecycle at all — the smaller, safer diff.
2. abandon() — priority 3 (required independently, "even if you fix (1)").
Before marking any of a released target's still-open tasks WORKER_FAILED, abandon() now checks
hasStrandedReply(target) (the existing CB-640 fact — true whenever the last reply() for this
target fell through to the inbox). If true, it drains the inbox (recoverStrandedReply) and — if it
actually finds a message — completes the task as REPLIED with that real content instead of writing
the failure. This is deliberately a separate check from fix 1: fix 1 already prevents the
inbox-stranding from happening in the exact scenario this ticket describes, so by the time
abandon() runs the task is normally already resolved and abandon()'s complete() call is a
harmless no-op. This second check exists so that if some other future path ever strands a reply
in the inbox without resolving its ticket, abandon() still refuses to report a false failure —
"if a reply reached any sink for that turn, the terminal state is done," per the ticket. I verified
both are required by disabling each independently and confirming the two new tests fail (see below).
Priority 4 (the snapshot/worktree hint). Handled as a consequence of both fixes rather than a
separate branch: once a task resolves as REPLIED (via either fix), abandon() never calls
new Reply(Outcome.WORKER_FAILED, reason) for that task at all, so the "the worker session was
released before it replied; worktree=... branch=... snapshot=..." text is never constructed or
attached to that ticket's outcome. It still appears, correctly, for a task that never got a reply
(the existing abandonFailsEveryPendingAsyncTicketForTheReleasedTarget /
anAbandonedAsyncTaskPollsAsFailedNotPending tests still pass unchanged).
Priority 2 was not needed — fix 1 makes fleet_poll{ticket} return the actual reply (the
higher-priority option), so I did not fall back to "the ticket merely resolves as done with no
content."
Tests — driven through the real delegation path, not the reply sink directly
Both new tests in fleetd/src/test/java/dev/ltms/fleet/msg/MessageServiceTest.java go through
sendAsync → injectDelivery → ask → answer (with a short timeout, so it genuinely times out,
mirroring the ~25–120s real MCP-call bound vs. a longer resumed turn) → reply → poll/abandon.
No test constructs a Reply and hands it to a sink directly.
aReplyAfterAnswerTimesOutStillCompletesTheAsyncTicket— assertsfleet_poll{ticket}(viamessages.poll) reachesPhase.DONEwith the worker's actual reply text andreplySource() == "reply", and thathasStrandedReply(T)staysfalse(proves the reply never touched the inbox at all — fix 1 caught it).fleetStopAfterAnOrphanedReplyDoesNotFailTheTicket— same setup, then callsabandon(T, "the worker session was released before it replied")(whatfleet_stoptriggers) and asserts it returnsfalse(no failure recorded) and the ticket still pollsDONEwith the real reply.
Proof both fail without the change. I temporarily short-circuited both new private methods
(askAnsweredAsyncTask → always null, recoverStrandedReply → always null) — i.e. disabled
both fixes — and ran just these two tests:
[ERROR] Tests run: 2, Failures: 2, Errors: 0, Skipped: 0
dev.ltms.fleet.msg.MessageServiceTest.aReplyAfterAnswerTimesOutStillCompletesTheAsyncTicket
org.opentest4j.AssertionFailedError: expected: <DONE> but was: <PENDING>
dev.ltms.fleet.msg.MessageServiceTest.fleetStopAfterAnOrphanedReplyDoesNotFailTheTicket
org.opentest4j.AssertionFailedError: a reply already arrived, so nothing here is a genuine failure
==> expected: <false> but was: <true>
This is the exact bug: the ticket stays PENDING forever, and abandon() reports true (a
failure) even though a reply had already arrived. I then restored both fixes (verified with
grep -n "TEMP #137-proof" finding nothing) and re-ran — both pass.
Build
Ran from fleetd/, unpiped, full output read (not | tail):
mvn clean install
...
[INFO] Tests run: 1039, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
[INFO] Total time: 36.315 s
Main was at 1037 tests; this branch adds the 2 new tests above → 1039, all green, exit=0.
What I could NOT check
- No IDE tooling is mounted for me (worker), so no
ide_diagnostics/IntelliJ inspection pass — onlymvn clean install(compiler + full test suite), as the worker procedure allows. - I cannot restart the daemon or dogfood this live — I have no forge/daemon control. This is
unverified against a real herdr pane, a real MCP client's ~60s call cap, or a real worker session;
everything above is verified only through the JUnit fixture's simulated timing
(
FakeHerdr/injector.onStatus/directmessages.answer(...,150)calls), not a live fleet. A primary should still consider a short live dogfood (an async delegation that asks, gets answered, and takes longer than ~2 minutes to reply) before calling this closed. - I did not touch, and did not re-verify, the
fleet_ask~55s window itself (issue #61) — out of scope per the brief.
Scope note (not investigated further)
answer()'s nested/double-fleet_ask case (the worker asks a second question before ever
replying to the first answer) has some pre-existing behaviour around which turnId a QUESTION
resolution gets attributed to that I did not fully untangle — it predates this change, my fix does
not touch it, and it is unrelated to the reported defect. Flagging only; not investigated further.
Handoff
- Branch:
worker/cb-137-ask-ticket-e7760c-2 - Worktree root:
/Users/dai.ha/LTMS/.bridged-worktrees/734324-2 - Files changed:
fleetd/src/main/java/dev/ltms/fleet/msg/MessageService.javafleetd/src/test/java/dev/ltms/fleet/msg/MessageServiceTest.javaREPORT-cb137.md(this file)
- Build:
Tests run: 1039, Failures: 0, Errors: 0, Skipped: 0/BUILD SUCCESS(verbatim above)