fix(coding-agent): handle stale extension contexts

fixes #3606
This commit is contained in:
Mario Zechner
2026-04-23 22:06:55 +02:00
parent 0cb8ff3843
commit f0cf8a59d2
9 changed files with 77 additions and 17 deletions

View File

@@ -66,6 +66,7 @@ function extractUserMessageText(content: string | Array<{ type: string; text?: s
*/
export class AgentSessionRuntime {
private rebindSession?: (session: AgentSession) => Promise<void>;
private beforeSessionInvalidate?: () => void;
constructor(
private _session: AgentSession,
@@ -99,6 +100,18 @@ export class AgentSessionRuntime {
this.rebindSession = rebindSession;
}
/**
* Set a synchronous callback that runs after `session_shutdown` handlers finish
* but before the current session is invalidated.
*
* This is for host-owned UI teardown that must not yield to the event loop,
* such as detaching extension-provided TUI components before the old extension
* context becomes stale.
*/
setBeforeSessionInvalidate(beforeSessionInvalidate?: () => void): void {
this.beforeSessionInvalidate = beforeSessionInvalidate;
}
private async emitBeforeSwitch(
reason: "new" | "resume",
targetSessionFile?: string,
@@ -139,6 +152,7 @@ export class AgentSessionRuntime {
reason,
targetSessionFile,
});
this.beforeSessionInvalidate?.();
this.session.dispose();
}
@@ -354,6 +368,7 @@ export class AgentSessionRuntime {
type: "session_shutdown",
reason: "quit",
});
this.beforeSessionInvalidate?.();
this.session.dispose();
}
}

View File

@@ -724,7 +724,7 @@ export class AgentSession {
*/
dispose(): void {
this._extensionRunner.invalidate(
"This extension instance is stale after session replacement or reload. Use the provided replacement-session context instead.",
"This extension ctx is stale after session replacement or reload. Do not use a captured pi or command ctx after ctx.newSession(), ctx.fork(), ctx.switchSession(), or ctx.reload(). For newSession, fork, and switchSession, move post-replacement work into withSession and use the ctx passed to withSession. For reload, do not use the old ctx after await ctx.reload().",
);
this._disconnectFromAgent();
this._eventListeners = [];

View File

@@ -164,7 +164,7 @@ export function createExtensionRuntime(): ExtensionRuntime {
invalidate: (message) => {
state.staleMessage ??=
message ??
"This extension instance is stale after session replacement or reload. Use the provided replacement-session context instead.";
"This extension ctx is stale after session replacement or reload. Do not use a captured pi or command ctx after ctx.newSession(), ctx.fork(), ctx.switchSession(), or ctx.reload(). For newSession, fork, and switchSession, move post-replacement work into withSession and use the ctx passed to withSession. For reload, do not use the old ctx after await ctx.reload().";
},
// Pre-bind: queue registrations so bindCore() can flush them once the
// model registry is available. bindCore() replaces both with direct calls.

View File

@@ -458,7 +458,9 @@ export class ExtensionRunner {
return this.shortcutDiagnostics;
}
invalidate(message = "This extension instance is stale after session replacement or reload."): void {
invalidate(
message = "This extension ctx is stale after session replacement or reload. Do not use a captured pi or command ctx after ctx.newSession(), ctx.fork(), ctx.switchSession(), or ctx.reload(). For newSession, fork, and switchSession, move post-replacement work into withSession and use the ctx passed to withSession. For reload, do not use the old ctx after await ctx.reload().",
): void {
if (!this.staleMessage) {
this.staleMessage = message;
this.runtime.invalidate(message);

View File

@@ -362,6 +362,9 @@ export class InteractiveMode {
private options: InteractiveModeOptions = {},
) {
this.runtimeHost = runtimeHost;
this.runtimeHost.setBeforeSessionInvalidate(() => {
this.resetExtensionUI();
});
this.runtimeHost.setRebindSession(async () => {
await this.rebindCurrentSession();
});
@@ -1594,7 +1597,6 @@ export class InteractiveMode {
}
private async rebindCurrentSession(): Promise<void> {
this.resetExtensionUI();
this.unsubscribe?.();
this.unsubscribe = undefined;
this.applyRuntimeSettings();
@@ -3219,7 +3221,6 @@ export class InteractiveMode {
if (this.isShuttingDown) return;
this.isShuttingDown = true;
this.unregisterSignalHandlers();
this.resetExtensionUI();
await this.runtimeHost.dispose();
// Wait for any pending renders to complete