Skip to content

Commit eab22a9

Browse files
authored
fix(auto-reply): clear pending-final state before honoring post-send abort (#89115) (#93201)
In dispatchReplyFromConfig the user-message success branch ran throwIfDispatchOperationAborted() *before* clearPendingFinalDeliveryAfterSuccess(). If stuck-session recovery aborted the run in the window between the final reply shipping and the clear, the message was delivered but pendingFinalDelivery stayed true forever — the get-reply redelivery short-circuit then silently blocked every future inbound and the agent "went silent" (#89115). Reorder so the durable pending-final bookkeeping is cleared first, then honor the abort afterwards (preserving abort reporting). Also clear the stranded pendingFinalDeliveryIntentId field — agent-command.ts already clears it but the success helper did not.
1 parent 773ffd8 commit eab22a9

2 files changed

Lines changed: 56 additions & 1 deletion

File tree

src/auto-reply/reply/dispatch-from-config.reply-dispatch.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,56 @@ describe("dispatchReplyFromConfig reply_dispatch hook", () => {
225225
expect(sessionStoreMocks.currentEntry?.pendingFinalDeliveryContext).toBeUndefined();
226226
});
227227

228+
it("clears pending final delivery when abort fires after a successful final send (#89115)", async () => {
229+
// Regression for #89115: an abort that lands after the final reply has
230+
// shipped (here, during sendFinalReply) must still clear the pending-final
231+
// bookkeeping — otherwise pendingFinalDelivery stays true and the get-reply
232+
// redelivery short-circuit silently blocks every later inbound.
233+
hookMocks.runner.hasHooks.mockReturnValue(false);
234+
sessionStoreMocks.currentEntry = {
235+
sessionKey: "agent:test:session",
236+
pendingFinalDelivery: true,
237+
pendingFinalDeliveryText: "durable reply",
238+
pendingFinalDeliveryCreatedAt: 1,
239+
pendingFinalDeliveryLastAttemptAt: 2,
240+
pendingFinalDeliveryAttemptCount: 3,
241+
pendingFinalDeliveryLastError: "previous failure",
242+
pendingFinalDeliveryContext: { source: "heartbeat" },
243+
pendingFinalDeliveryIntentId: "intent-89115",
244+
};
245+
sessionStoreMocks.resolveSessionStoreEntry.mockReturnValue({
246+
existing: sessionStoreMocks.currentEntry,
247+
});
248+
const abortController = new AbortController();
249+
const dispatcher = createDispatcher();
250+
vi.mocked(dispatcher.sendFinalReply).mockImplementation(() => {
251+
abortController.abort();
252+
return true;
253+
});
254+
255+
const result = await dispatchReplyFromConfig({
256+
ctx: createHookCtx(),
257+
cfg: emptyConfig,
258+
dispatcher,
259+
replyOptions: { abortSignal: abortController.signal },
260+
replyResolver: async () => ({ text: "durable reply" }),
261+
});
262+
263+
// Abort landed after delivery: the run is still surfaced as aborted
264+
// (queuedFinal:false), but the pending-final state is fully cleared.
265+
expect(dispatcher.sendFinalReply).toHaveBeenCalledOnce();
266+
expect(result.queuedFinal).toBe(false);
267+
expect(sessionStoreMocks.updateSessionStoreEntry).toHaveBeenCalledOnce();
268+
expect(sessionStoreMocks.currentEntry?.pendingFinalDelivery).toBeUndefined();
269+
expect(sessionStoreMocks.currentEntry?.pendingFinalDeliveryText).toBeUndefined();
270+
expect(sessionStoreMocks.currentEntry?.pendingFinalDeliveryCreatedAt).toBeUndefined();
271+
expect(sessionStoreMocks.currentEntry?.pendingFinalDeliveryLastAttemptAt).toBeUndefined();
272+
expect(sessionStoreMocks.currentEntry?.pendingFinalDeliveryAttemptCount).toBeUndefined();
273+
expect(sessionStoreMocks.currentEntry?.pendingFinalDeliveryLastError).toBeUndefined();
274+
expect(sessionStoreMocks.currentEntry?.pendingFinalDeliveryContext).toBeUndefined();
275+
expect(sessionStoreMocks.currentEntry?.pendingFinalDeliveryIntentId).toBeUndefined();
276+
});
277+
228278
it("preserves pending final delivery when final dispatch fails", async () => {
229279
hookMocks.runner.hasHooks.mockReturnValue(false);
230280
sessionStoreMocks.currentEntry = {

src/auto-reply/reply/dispatch-from-config.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -753,6 +753,7 @@ async function clearPendingFinalDeliveryAfterSuccess(params: {
753753
pendingFinalDeliveryAttemptCount: undefined,
754754
pendingFinalDeliveryLastError: undefined,
755755
pendingFinalDeliveryContext: undefined,
756+
pendingFinalDeliveryIntentId: undefined,
756757
updatedAt: Date.now(),
757758
};
758759
},
@@ -3297,11 +3298,15 @@ export async function dispatchReplyFromConfig(
32973298
}
32983299

32993300
if (attemptedFinalDelivery && !finalDeliveryFailed) {
3300-
throwIfDispatchOperationAborted();
3301+
// The final reply already shipped, so clear the durable pending-final
3302+
// bookkeeping before honoring a late abort. A stuck-session recovery abort
3303+
// racing this window (#89115) otherwise strands pendingFinalDelivery=true,
3304+
// and the get-reply redelivery short-circuit then silently blocks all inbound.
33013305
await clearPendingFinalDeliveryAfterSuccess({
33023306
storePath: sessionStoreEntry.storePath,
33033307
sessionKey: sessionStoreEntry.sessionKey ?? sessionKey,
33043308
});
3309+
throwIfDispatchOperationAborted();
33053310
}
33063311

33073312
if (!suppressDelivery) {

0 commit comments

Comments
 (0)