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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user