diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index 2050c6123..5b7b99a51 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -90,7 +90,12 @@ import planModeCompactInstructionsPrompt from "../prompts/system/plan-mode-compa type: "text", }; import type { AgentRegistry } from "../registry/agent-registry"; -import type { AgentSession, AgentSessionEvent, ResolvedRoleModel } from "../session/agent-session"; +import { + SHUTDOWN_CONSOLIDATE_BUDGET_MS, + type AgentSession, + type AgentSessionEvent, + type ResolvedRoleModel, +} from "../session/agent-session"; import type { CompactMode } from "../session/compact-modes"; import { HistoryStorage } from "../session/history-storage"; import type { SessionContext } from "../session/session-context"; @@ -789,7 +794,7 @@ export class InteractiveMode implements InteractiveModeContext { getDraftText: () => this.editor.getText(), beginDispose: () => this.session.beginDispose(), saveDraft: text => this.sessionManager.saveDraft(text), - disposeSession: () => this.session.dispose(), + disposeSession: () => this.session.dispose({ mnemopiConsolidateTimeoutMs: SHUTDOWN_CONSOLIDATE_BUDGET_MS }), }); this.#cleanupUnsubscribe = postmortem.register("session-teardown", () => this.#signalTeardown!()); @@ -3319,7 +3324,7 @@ export class InteractiveMode implements InteractiveModeContext { if (this.#signalTeardown) { await this.#signalTeardown(); } else { - await this.session.dispose(); + await this.session.dispose({ mnemopiConsolidateTimeoutMs: SHUTDOWN_CONSOLIDATE_BUDGET_MS }); } // Do not force a final render during teardown: disposed session/UI state can diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index ad02c5373..62cace742 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -446,17 +446,18 @@ const UNEXPECTED_STOP_TIMEOUT_MS = 4000; const EMPTY_STOP_MAX_RETRIES = 3; const RETRY_BACKOFF_MAX_DELAY_MS = 8_000; /** - * Cap how long {@link AgentSession.dispose} waits for - * `MnemopiSessionState.dispose()` to finish its consolidate pass on the - * user-visible `/quit` / `/exit` shutdown path. Consolidate fires fresh - * LLM fact extractions, each a 1–3 s round-trip, so an unbounded await - * stalled `/quit` for many seconds even on minimal-activity sessions - * (issue #3641). Anything still in flight when the budget elapses is - * detached to the background; the SQLite handles close once it settles. - * Per-turn `maybeRetainOnAgentEnd` already retained earlier turns, so - * the worst case is losing episodic promotion for the LAST few turns. + * Budget for callers on the user-visible `/quit` / `/exit` shutdown path that + * want to cap how long they wait for `MnemopiSessionState.dispose()` to finish + * its consolidate pass. Consolidate fires fresh LLM fact extractions, each a + * 1–3 s round-trip, so interactive shutdown passes this budget to keep the + * UI responsive. Callers that keep the process/session host alive must omit it + * so dispose still awaits the full consolidate-then-close pipeline. */ -const SHUTDOWN_CONSOLIDATE_BUDGET_MS = 1_500; +export const SHUTDOWN_CONSOLIDATE_BUDGET_MS = 1_500; + +export interface AgentSessionDisposeOptions { + mnemopiConsolidateTimeoutMs?: number; +} type CompactionCheckResult = Readonly<{ deferredHandoff: boolean; @@ -5327,12 +5328,12 @@ export class AgentSession { * double-drain the owned `AsyncJobManager` (issue #4080). */ #disposeCall?: Promise; - dispose(): Promise { - if (!this.#disposeCall) this.#disposeCall = this.#doDispose(); + dispose(options: AgentSessionDisposeOptions = {}): Promise { + if (!this.#disposeCall) this.#disposeCall = this.#doDispose(options); return this.#disposeCall; } - async #doDispose(): Promise { + async #doDispose(options: AgentSessionDisposeOptions = {}): Promise { this.beginDispose(); this.#recordSessionExit("dispose"); this.#cancelExitRecorder?.(); @@ -5449,7 +5450,7 @@ export class AgentSession { this.setHindsightSessionState(undefined); hindsightState?.dispose(); const mnemopiState = setMnemopiSessionState(this, undefined); - await mnemopiState?.dispose({ timeoutMs: SHUTDOWN_CONSOLIDATE_BUDGET_MS }); + await mnemopiState?.dispose({ timeoutMs: options.mnemopiConsolidateTimeoutMs }); // Tear down the embeddings subprocess AFTER mnemopi state.dispose: // consolidate-on-dispose may still call `embed()` to store the final // memories, and that round-trips through the worker we are about to