fix(session): skip reasonless-abort retry while disposing

A dispose-driven bare abort() yields the same empty/reason-less aborted turn as a transient provider abort, but with #isDisposed set and #abortInProgress unset. #isRetryableReasonlessAbort matched it and routed it through #handleRetryableError, which created #retryPromise and scheduled a continuation the disposed guard then skipped without resolving the promise — hanging the in-flight prompt() in #waitForPostPromptRecovery during shutdown.

Guard the predicate on !#isDisposed so lifecycle aborts settle the turn, and add a regression test. Addresses review feedback on #2689.
This commit is contained in:
can1357
2026-06-16 14:37:02 +02:00
parent eca8d120dd
commit b7a3d01439
2 changed files with 75 additions and 4 deletions
@@ -9143,19 +9143,29 @@ export class AgentSession {
// =========================================================================
/**
* Check if an error is retryable (transient errors or usage limits).
* Context overflow is NOT retryable (handled by compaction instead).
* Usage-limit errors are retryable because the retry handler performs credential switching.
* Retry an empty, reason-less provider abort: a turn that ended `aborted`
* with no content and the generic sentinel (bare `abort()`), but only while
* the session is neither aborting nor tearing down. A user/lifecycle abort
* (`#abortInProgress`) or a dispose-driven abort (`#isDisposed`) is deliberate
* and MUST settle the turn instead: routing it through retry would orphan
* `#retryPromise` on a continuation that the disposed/aborting guard skips,
* hanging the in-flight `prompt()` in `#waitForPostPromptRecovery`.
*/
#isRetryableReasonlessAbort(message: AssistantMessage): boolean {
return (
message.stopReason === "aborted" &&
message.content.length === 0 &&
message.errorMessage === GENERIC_ABORT_SENTINEL &&
!this.#abortInProgress
!this.#abortInProgress &&
!this.#isDisposed
);
}
/**
* Check if an error is retryable (transient errors or usage limits).
* Context overflow is NOT retryable (handled by compaction instead).
* Usage-limit errors are retryable because the retry handler performs credential switching.
*/
#isRetryableError(message: AssistantMessage): boolean {
if (message.stopReason !== "error" || !message.errorMessage) return false;
@@ -690,6 +690,67 @@ describe("AgentSession retry delay cap", () => {
expect(last.content).toContainEqual({ type: "text", text: "partial" });
});
it("does not auto-retry empty reasonless aborts once the session is disposing", async () => {
const model = getBundledModel("anthropic", "claude-sonnet-4-5");
if (!model) {
throw new Error("Expected bundled Anthropic test model to exist");
}
// A dispose-driven abort produces the same empty/reason-less shape as a
// transient provider abort. It MUST settle the turn instead of entering
// auto-retry: a retry here schedules a continuation that the disposed guard
// skips without resolving #retryPromise, hanging prompt() during shutdown.
const mock = createMockModel({
responses: [
{ stopReason: "aborted", errorMessage: "Request was aborted" },
{ content: ["should not be reached after dispose"] },
],
});
const agent = new Agent({
getApiKey: provider => `${provider}-test-key`,
initialState: {
model,
systemPrompt: ["Test"],
tools: [],
messages: [],
},
streamFn: mock.stream,
});
const settings = Settings.isolated({
"compaction.enabled": false,
"retry.baseDelayMs": 5,
"retry.maxDelayMs": 5_000,
"retry.maxRetries": 1,
});
settings.setModelRole("default", `${model.provider}/${model.id}`);
session = new AgentSession({
agent,
sessionManager: SessionManager.inMemory(),
settings,
modelRegistry,
});
vi.spyOn(scheduler, "wait").mockResolvedValue(undefined);
const retryStartEvents: AutoRetryStartEvent[] = [];
session.subscribe(event => {
if (event.type === "auto_retry_start") retryStartEvents.push(event);
});
// Enter the disposing window before the empty abort lands. Without the
// #isDisposed guard this prompt would hang on an orphaned retry promise.
session.beginDispose();
await session.prompt("Trigger empty aborted turn while disposing");
await session.waitForIdle();
expect(retryStartEvents).toHaveLength(0);
// No retry continuation fired, so the second scripted response is untouched.
expect(mock.calls).toHaveLength(1);
const last = lastAssistant(session);
expect(last.stopReason).toBe("aborted");
});
it("defaults 502 auto-retry to ten capped backoff attempts", async () => {
const model = getBundledModel("anthropic", "claude-sonnet-4-5");
if (!model) {