You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Deferred from the #368 review (correctness profile). Both are P3, both in the
same reflowChangesReceiptFold defer/drain machinery, both narrower than the
race #368 closed. Grouped because they share one surface.
1. A turn that starts and ends inside the fetch window still commits stale history
#368's guard samples turnActive once, at commit time. That catches a turn
still running when the fetch resolves. It does not catch one that opens and
closes entirely inside the window:
Terminal event N releases the deferred reflow; its agentChatHistory fetch starts.
A drain (or manual send) starts a turn — optimistic user row appended.
The provider fast-fails it (turnFailed) before the fetch resolves.
At that turnFailed, pendingChangesReceiptReflow.delete(chatId) is a no-op
(the re-add only happens at the commit-site guard, which has not run yet),
and nothing bumps ensureGenerations.
The fetch resolves: stale() is false, turnActive is false → it commits
history fetched before the user message was persisted, wiping it. No
pending entry remains to repair it.
Needs the history fetch to outlast a full send→turnFailed round trip, so it is
much narrower than #368. The send-error path is safe — rollbackFailedSend
removes the optimistic row itself — so the exposure is backend-side instant turnFailed only.
Fix shape: snapshot a per-chat monotonic marker before the fetch (e.g. nextSeqByChat.get(chatId)) and re-defer if it advanced during the await,
rather than sampling turnActive alone.
2. A pending re-fold strands when turnActive is cleared without a terminal event
pendingChangesReceiptReflow is drained only by the live turnDone/turnFailed
branch in trackTurnLifecycle, by disposeAgentChat, and by a fresh flag flip.
Two paths clear turnActive directly with no terminal event:
retryAgentChatConnection — clears every other pending map
(ensurePromises, hydratePromises, pendingSetModel/Mode) but not this
set. Its subsequent ensureAgentChat will not re-fold either, since historyLoaded is still true.
No leak (a Set of chatIds, cleared on dispose) and no data loss, but the flag
flip's re-fold silently never runs — the chat keeps the stale fold shape, which
per the module's own doc comment mis-indexes changes_list_turn_change_sets
ordinals, until some later turn completes.
The stranding mechanism pre-exists (the call-site defer has it too); #368 adds a
second producer of pending entries, which widens the exposure.
Fix shape: in both functions, after clearing turnActive, run if (pendingChangesReceiptReflow.delete(chatId)) void reflowChangesReceiptFold(chatId)
(for the retry path, after the ensure).
Validation
Extend tests/unit/agentChat.test.ts's changesReview flag flip re-hydration
block: for (1) a turn that starts and turnFaileds inside a held fetch, with
the message row asserted to survive; for (2) a deferred reflow followed by rollbackFailedSend / retryAgentChatConnection, asserting the re-fold still
runs.
Deferred from the #368 review (correctness profile). Both are P3, both in the
same
reflowChangesReceiptFolddefer/drain machinery, both narrower than therace #368 closed. Grouped because they share one surface.
1. A turn that starts and ends inside the fetch window still commits stale history
#368's guard samples
turnActiveonce, at commit time. That catches a turnstill running when the fetch resolves. It does not catch one that opens and
closes entirely inside the window:
agentChatHistoryfetch starts.turnFailed) before the fetch resolves.turnFailed,pendingChangesReceiptReflow.delete(chatId)is a no-op(the re-add only happens at the commit-site guard, which has not run yet),
and nothing bumps
ensureGenerations.stale()is false,turnActiveis false → it commitshistory fetched before the user message was persisted, wiping it. No
pending entry remains to repair it.
Needs the history fetch to outlast a full send→
turnFailedround trip, so it ismuch narrower than #368. The send-error path is safe —
rollbackFailedSendremoves the optimistic row itself — so the exposure is backend-side instant
turnFailedonly.Fix shape: snapshot a per-chat monotonic marker before the fetch (e.g.
nextSeqByChat.get(chatId)) and re-defer if it advanced during the await,rather than sampling
turnActivealone.2. A pending re-fold strands when
turnActiveis cleared without a terminal eventpendingChangesReceiptReflowis drained only by the liveturnDone/turnFailedbranch in
trackTurnLifecycle, bydisposeAgentChat, and by a fresh flag flip.Two paths clear
turnActivedirectly with no terminal event:rollbackFailedSend— a failed send. feat(chat): queue messages while an agent turn is running #370's drain deliberately holds thequeue after a failure, so no further terminal event is coming.
retryAgentChatConnection— clears every other pending map(
ensurePromises,hydratePromises,pendingSetModel/Mode) but not thisset. Its subsequent
ensureAgentChatwill not re-fold either, sincehistoryLoadedis still true.No leak (a Set of chatIds, cleared on dispose) and no data loss, but the flag
flip's re-fold silently never runs — the chat keeps the stale fold shape, which
per the module's own doc comment mis-indexes
changes_list_turn_change_setsordinals, until some later turn completes.
The stranding mechanism pre-exists (the call-site defer has it too); #368 adds a
second producer of pending entries, which widens the exposure.
Fix shape: in both functions, after clearing
turnActive, runif (pendingChangesReceiptReflow.delete(chatId)) void reflowChangesReceiptFold(chatId)(for the retry path, after the ensure).
Validation
tests/unit/agentChat.test.ts'schangesReview flag flip re-hydrationblock: for (1) a turn that starts and
turnFaileds inside a held fetch, withthe message row asserted to survive; for (2) a deferred reflow followed by
rollbackFailedSend/retryAgentChatConnection, asserting the re-fold stillruns.
Notes
drain after
retryAgentChatConnection. This is about the re-fold strandingon the same path; resolving one does not resolve the other.
Refs #368, #369