diff --git a/packages/coding-agent/src/core/agent-session-runtime.ts b/packages/coding-agent/src/core/agent-session-runtime.ts index 5d606d035..ffa57d9be 100644 --- a/packages/coding-agent/src/core/agent-session-runtime.ts +++ b/packages/coding-agent/src/core/agent-session-runtime.ts @@ -1,7 +1,7 @@ import { copyFileSync, existsSync, mkdirSync } from "node:fs"; import { basename, join, resolve } from "node:path"; import { resolvePath } from "../utils/paths.ts"; -import type { AgentSession } from "./agent-session.ts"; +import type { AgentSession, PlanContinuation, PlanContinuationPromptOptions, PromptOptions } from "./agent-session.ts"; import type { AgentSessionRuntimeDiagnostic, AgentSessionServices } from "./agent-session-services.ts"; import type { ProjectTrustContext, @@ -25,6 +25,13 @@ export interface CreateAgentSessionRuntimeResult extends CreateAgentSessionResul diagnostics: AgentSessionRuntimeDiagnostic[]; } +export type ValidateSessionTarget = (sessionManager: SessionManager, leafId: string | null) => void; + +interface PreparedRuntimePrompt { + completion: Promise; + accepted: boolean; +} + /** * Creates a full runtime for a target cwd and session manager. * @@ -67,9 +74,8 @@ function extractUserMessageText(content: string | Array<{ type: string; text?: s /** * Owns the current AgentSession plus its cwd-bound services. * - * Session replacement methods tear down the current runtime first, then create - * and apply the next runtime. If creation fails, the error is propagated to the - * caller. The caller is responsible for user-facing error handling. + * Session replacement methods serialize against in-flight prompts and plan + * continuations. Errors are propagated to the caller for user-facing handling. */ export class AgentSessionRuntime { private rebindSession?: (session: AgentSession) => Promise; @@ -78,8 +84,13 @@ export class AgentSessionRuntime { private _services: AgentSessionServices; private readonly createRuntime: CreateAgentSessionRuntimeFactory; private _diagnostics: AgentSessionRuntimeDiagnostic[]; + private replacementDiagnostics: AgentSessionRuntimeDiagnostic[] = []; private _modelFallbackMessage?: string; private disposePromise: Promise | undefined; + private promptPreparationQueue: Promise = Promise.resolve(); + private exclusiveRuntimeOperation: object | undefined; + private exclusiveRuntimeOperationSettled: Promise = Promise.resolve(); + private disposeRequested = false; constructor( _session: AgentSession, @@ -131,6 +142,36 @@ export class AgentSessionRuntime { this.beforeSessionInvalidate = beforeSessionInvalidate; } + private async runSessionReplacement(operation: () => Promise): Promise { + return this.runExclusiveRuntimeOperation(operation); + } + + private async runExclusiveRuntimeOperation(operation: (owner: object) => Promise): Promise { + this.assertExclusiveRuntimeIdle(); + const owner = {}; + let settleOperation!: () => void; + const settled = new Promise((resolveSettled) => { + settleOperation = resolveSettled; + }); + this.exclusiveRuntimeOperation = owner; + this.exclusiveRuntimeOperationSettled = settled; + try { + return await operation(owner); + } finally { + if (this.exclusiveRuntimeOperation === owner) this.exclusiveRuntimeOperation = undefined; + settleOperation(); + } + } + + private assertExclusiveRuntimeIdle(): void { + if (this.disposeRequested) { + throw new Error("Agent session runtime is disposing."); + } + if (this.exclusiveRuntimeOperation) { + throw new Error("Another session replacement or exclusive runtime operation is already in progress."); + } + } + private async emitBeforeSwitch( reason: "new" | "resume", targetSessionFile?: string, @@ -165,6 +206,14 @@ export class AgentSessionRuntime { return { cancelled: result?.cancel === true }; } + private appendDiagnostic(message: string, carryAcrossReplacement = false): void { + const diagnostic: AgentSessionRuntimeDiagnostic = { type: "warning", message }; + this._diagnostics = [...this._diagnostics, diagnostic]; + if (carryAcrossReplacement) { + this.replacementDiagnostics.push(diagnostic); + } + } + private async teardownCurrent(reason: SessionShutdownEvent["reason"], targetSessionFile?: string): Promise { const session = this.session; const failures: unknown[] = []; @@ -206,7 +255,8 @@ export class AgentSessionRuntime { private apply(result: CreateAgentSessionRuntimeResult): void { this._session = result.session; this._services = result.services; - this._diagnostics = result.diagnostics; + this._diagnostics = [...result.diagnostics, ...this.replacementDiagnostics]; + this.replacementDiagnostics = []; this._modelFallbackMessage = result.modelFallbackMessage; } @@ -219,162 +269,306 @@ export class AgentSessionRuntime { } } + private async commitReservedRuntime( + result: CreateAgentSessionRuntimeResult, + outgoingSession: AgentSession, + releaseReplacement: () => void, + options: { + reason: SessionShutdownEvent["reason"]; + targetSessionFile?: string; + beforeCommit?: () => void; + restoreRejectedMetadata?: () => void; + withSession?: (ctx: ReplacedSessionContext) => Promise; + }, + ): Promise { + let shutdownStarted = false; + let adopted = false; + let hasPostShutdownError = false; + let postShutdownError: unknown; + const postShutdownDiagnostics: string[] = []; + const recordPostShutdownError = (stage: string, error: unknown): void => { + if (!hasPostShutdownError) postShutdownError = error; + hasPostShutdownError = true; + postShutdownDiagnostics.push( + `Session replacement ${stage} failed after shutdown began: ${error instanceof Error ? error.message : String(error)}`, + ); + }; + + try { + options.beforeCommit?.(); + shutdownStarted = true; + try { + await emitSessionShutdownEvent(outgoingSession.extensionRunner, { + type: "session_shutdown", + reason: options.reason, + targetSessionFile: options.targetSessionFile, + }); + } catch (error) { + recordPostShutdownError("shutdown hook", error); + } + try { + this.beforeSessionInvalidate?.(); + } catch (error) { + recordPostShutdownError("invalidation", error); + } + try { + await outgoingSession.dispose(); + } catch (error) { + recordPostShutdownError("disposal", error); + } + try { + this.apply(result); + } catch (error) { + recordPostShutdownError("activation", error); + } + adopted = this.session === result.session; + if (!adopted) { + this._session = result.session; + this._services = result.services; + adopted = true; + } + try { + await this.finishSessionReplacement(options.withSession); + } catch (error) { + recordPostShutdownError("host rebind", error); + } + for (const diagnostic of postShutdownDiagnostics) { + this.appendDiagnostic(diagnostic); + } + if (hasPostShutdownError) throw postShutdownError; + } finally { + if (!shutdownStarted) { + releaseReplacement(); + if (!adopted) { + try { + await result.session.dispose(); + } finally { + options.restoreRejectedMetadata?.(); + } + } + } + // A disposed outgoing session intentionally retains its replacement lease. + // This keeps stale references from mutating a SessionManager shared with the adopted runtime. + } + } + + private async replaceCurrentRuntime( + createRuntime: () => Promise, + options: { + reason: SessionShutdownEvent["reason"]; + targetSessionFile?: string; + restoreRejectedMetadata?: () => void; + withSession?: (ctx: ReplacedSessionContext) => Promise; + }, + ): Promise { + // Settle any active response first so the aborted turn, including tool + // results, is persisted before the outgoing session is replaced. + const outgoingSession = this.session; + await outgoingSession.abort(); + const releaseReplacement = outgoingSession.reserveRuntimeReplacement(); + let result: CreateAgentSessionRuntimeResult; + try { + result = await createRuntime(); + } catch (error) { + releaseReplacement(); + options.restoreRejectedMetadata?.(); + throw error; + } + await this.commitReservedRuntime(result, outgoingSession, releaseReplacement, options); + } + async switchSession( sessionPath: string, options?: { cwdOverride?: string; withSession?: (ctx: ReplacedSessionContext) => Promise; projectTrustContextFactory?: (cwd: string) => ProjectTrustContext; + validateTarget?: ValidateSessionTarget; }, ): Promise<{ cancelled: boolean }> { - const beforeResult = await this.emitBeforeSwitch("resume", sessionPath); - if (beforeResult.cancelled) { - return beforeResult; - } + return this.runSessionReplacement(async () => { + const beforeResult = await this.emitBeforeSwitch("resume", sessionPath); + if (beforeResult.cancelled) { + return beforeResult; + } - const previousSessionFile = this.session.sessionFile; - const sessionManager = SessionManager.open(sessionPath, undefined, options?.cwdOverride); - assertSessionCwdExists(sessionManager, this.cwd); - await this.teardownCurrent("resume", sessionManager.getSessionFile()); - this.apply( - await this.createRuntime({ - cwd: sessionManager.getCwd(), - agentDir: this.services.agentDir, - sessionManager, - sessionStartEvent: { type: "session_start", reason: "resume", previousSessionFile }, - projectTrustContext: options?.projectTrustContextFactory?.(sessionManager.getCwd()), - }), - ); - await this.finishSessionReplacement(options?.withSession); - return { cancelled: false }; + const previousSessionFile = this.session.sessionFile; + const sessionManager = SessionManager.open(sessionPath, undefined, options?.cwdOverride); + assertSessionCwdExists(sessionManager, this.cwd); + options?.validateTarget?.(sessionManager, sessionManager.getLeafId()); + await this.replaceCurrentRuntime( + () => + this.createRuntime({ + cwd: sessionManager.getCwd(), + agentDir: this.services.agentDir, + sessionManager, + sessionStartEvent: { type: "session_start", reason: "resume", previousSessionFile }, + projectTrustContext: options?.projectTrustContextFactory?.(sessionManager.getCwd()), + }), + { + reason: "resume", + targetSessionFile: sessionManager.getSessionFile(), + withSession: options?.withSession, + }, + ); + return { cancelled: false }; + }); } async newSession(options?: { parentSession?: string; setup?: (sessionManager: SessionManager) => Promise; withSession?: (ctx: ReplacedSessionContext) => Promise; + validateTarget?: ValidateSessionTarget; }): Promise<{ cancelled: boolean }> { - const beforeResult = await this.emitBeforeSwitch("new"); - if (beforeResult.cancelled) { - return beforeResult; - } + return this.runSessionReplacement(async () => { + const beforeResult = await this.emitBeforeSwitch("new"); + if (beforeResult.cancelled) { + return beforeResult; + } - const previousSessionFile = this.session.sessionFile; - const sessionDir = this.session.sessionManager.getSessionDir(); - const sessionManager = this.session.sessionManager.isPersisted() - ? SessionManager.create(this.cwd, sessionDir) - : SessionManager.inMemory(this.cwd); - if (options?.parentSession) { - sessionManager.newSession({ parentSession: options.parentSession }); - } + const previousSessionFile = this.session.sessionFile; + const sessionDir = this.session.sessionManager.getSessionDir(); + const sessionManager = this.session.sessionManager.isPersisted() + ? SessionManager.create(this.cwd, sessionDir) + : SessionManager.inMemory(this.cwd); + if (options?.parentSession) { + sessionManager.newSession({ + parentSession: options.parentSession, + }); + } + if (options?.setup) { + await options.setup(sessionManager); + } + options?.validateTarget?.(sessionManager, sessionManager.getLeafId()); - await this.teardownCurrent("new", sessionManager.getSessionFile()); - this.apply( - await this.createRuntime({ - cwd: this.cwd, - agentDir: this.services.agentDir, - sessionManager, - sessionStartEvent: { type: "session_start", reason: "new", previousSessionFile }, - }), - ); - if (options?.setup) { - await options.setup(this.session.sessionManager); - this.session.agent.state.messages = this.session.sessionManager.buildSessionContext().messages; - } - await this.finishSessionReplacement(options?.withSession); - return { cancelled: false }; + await this.replaceCurrentRuntime( + () => + this.createRuntime({ + cwd: this.cwd, + agentDir: this.services.agentDir, + sessionManager, + sessionStartEvent: { + type: "session_start", + reason: "new", + previousSessionFile, + }, + }), + { + reason: "new", + targetSessionFile: sessionManager.getSessionFile(), + withSession: options?.withSession, + }, + ); + return { cancelled: false }; + }); } async fork( entryId: string, - options?: { position?: "before" | "at"; withSession?: (ctx: ReplacedSessionContext) => Promise }, + options?: { + position?: "before" | "at"; + withSession?: (ctx: ReplacedSessionContext) => Promise; + validateTarget?: ValidateSessionTarget; + }, ): Promise<{ cancelled: boolean; selectedText?: string }> { - const position = options?.position ?? "before"; - const beforeResult = await this.emitBeforeFork(entryId, { position }); - if (beforeResult.cancelled) { - return { cancelled: true }; - } - let targetLeafId: string | null; - let selectedText: string | undefined; - - const selectedEntry = this.session.sessionManager.getEntry(entryId); - if (!selectedEntry) { - throw new Error("Invalid entry ID for forking"); - } + return this.runSessionReplacement(async () => { + const position = options?.position ?? "before"; + const beforeResult = await this.emitBeforeFork(entryId, { position }); + if (beforeResult.cancelled) { + return { cancelled: true }; + } + let targetLeafId: string | null; + let selectedText: string | undefined; - if (position === "at") { - targetLeafId = selectedEntry.id; - } else { - if (selectedEntry.type !== "message" || selectedEntry.message.role !== "user") { + const selectedEntry = this.session.sessionManager.getEntry(entryId); + if (!selectedEntry) { throw new Error("Invalid entry ID for forking"); } - targetLeafId = selectedEntry.parentId; - selectedText = extractUserMessageText(selectedEntry.message.content); - } - const previousSessionFile = this.session.sessionFile; - if (this.session.sessionManager.isPersisted()) { - const currentSessionFile = this.session.sessionFile; - if (!currentSessionFile) { - throw new Error("Persisted session is missing a session file"); + if (position === "at") { + targetLeafId = selectedEntry.id; + } else { + if (selectedEntry.type !== "message" || selectedEntry.message.role !== "user") { + throw new Error("Invalid entry ID for forking"); + } + targetLeafId = selectedEntry.parentId; + selectedText = extractUserMessageText(selectedEntry.message.content); } - const sessionDir = this.session.sessionManager.getSessionDir(); - if (!targetLeafId) { - const sessionManager = SessionManager.create(this.cwd, sessionDir); - sessionManager.newSession({ parentSession: currentSessionFile }); - await this.teardownCurrent("fork", sessionManager.getSessionFile()); - this.apply( - await this.createRuntime({ + options?.validateTarget?.(this.session.sessionManager, targetLeafId); + + const previousSessionFile = this.session.sessionFile; + if (this.session.sessionManager.isPersisted()) { + const currentSessionFile = this.session.sessionFile; + if (!currentSessionFile) { + throw new Error("Persisted session is missing a session file"); + } + const sessionDir = this.session.sessionManager.getSessionDir(); + if (!targetLeafId) { + const sessionManager = SessionManager.create(this.cwd, sessionDir, { + parentSession: currentSessionFile, + }); + await this.replaceCurrentRuntime( + () => + this.createRuntime({ + cwd: this.cwd, + agentDir: this.services.agentDir, + sessionManager, + sessionStartEvent: { type: "session_start", reason: "fork", previousSessionFile }, + }), + { + reason: "fork", + targetSessionFile: sessionManager.getSessionFile(), + withSession: options?.withSession, + }, + ); + return { cancelled: false, selectedText }; + } + + if (!existsSync(currentSessionFile)) { + throw new Error( + "This session has not been saved yet. Wait for the first assistant response before cloning or forking it.", + ); + } + const sessionManager = SessionManager.open(currentSessionFile, sessionDir); + const forkedSessionPath = sessionManager.createBranchedSession(targetLeafId); + if (!forkedSessionPath) { + throw new Error("Failed to create forked session"); + } + await this.replaceCurrentRuntime( + () => + this.createRuntime({ + cwd: sessionManager.getCwd(), + agentDir: this.services.agentDir, + sessionManager, + sessionStartEvent: { type: "session_start", reason: "fork", previousSessionFile }, + }), + { + reason: "fork", + targetSessionFile: sessionManager.getSessionFile(), + withSession: options?.withSession, + }, + ); + return { cancelled: false, selectedText }; + } + + const sessionManager = this.session.sessionManager.createInMemoryBranch(targetLeafId); + await this.replaceCurrentRuntime( + () => + this.createRuntime({ cwd: this.cwd, agentDir: this.services.agentDir, sessionManager, sessionStartEvent: { type: "session_start", reason: "fork", previousSessionFile }, }), - ); - await this.finishSessionReplacement(options?.withSession); - return { cancelled: false, selectedText }; - } - - if (!existsSync(currentSessionFile)) { - throw new Error( - "This session has not been saved yet. Wait for the first assistant response before cloning or forking it.", - ); - } - const sessionManager = SessionManager.open(currentSessionFile, sessionDir); - const forkedSessionPath = sessionManager.createBranchedSession(targetLeafId); - if (!forkedSessionPath) { - throw new Error("Failed to create forked session"); - } - await this.teardownCurrent("fork", sessionManager.getSessionFile()); - this.apply( - await this.createRuntime({ - cwd: sessionManager.getCwd(), - agentDir: this.services.agentDir, - sessionManager, - sessionStartEvent: { type: "session_start", reason: "fork", previousSessionFile }, - }), + { + reason: "fork", + targetSessionFile: sessionManager.getSessionFile(), + withSession: options?.withSession, + }, ); - await this.finishSessionReplacement(options?.withSession); return { cancelled: false, selectedText }; - } - - const sessionManager = this.session.sessionManager; - if (!targetLeafId) { - sessionManager.newSession({ parentSession: this.session.sessionFile }); - } else { - sessionManager.createBranchedSession(targetLeafId); - } - await this.teardownCurrent("fork", sessionManager.getSessionFile()); - this.apply( - await this.createRuntime({ - cwd: this.cwd, - agentDir: this.services.agentDir, - sessionManager, - sessionStartEvent: { type: "session_start", reason: "fork", previousSessionFile }, - }), - ); - await this.finishSessionReplacement(options?.withSession); - return { cancelled: false, selectedText }; + }); } /** @@ -385,44 +579,192 @@ export class AgentSessionRuntime { * @throws {MissingSessionCwdError} When the imported session cwd cannot be resolved and no override is provided. */ async importFromJsonl(inputPath: string, cwdOverride?: string): Promise<{ cancelled: boolean }> { - const resolvedPath = resolvePath(inputPath); - if (!existsSync(resolvedPath)) { - throw new SessionImportFileNotFoundError(resolvedPath); - } + return this.runSessionReplacement(async () => { + const resolvedPath = resolvePath(inputPath); + if (!existsSync(resolvedPath)) { + throw new SessionImportFileNotFoundError(resolvedPath); + } - const sessionDir = this.session.sessionManager.getSessionDir(); - if (!existsSync(sessionDir)) { - mkdirSync(sessionDir, { recursive: true }); - } + const sessionDir = this.session.sessionManager.getSessionDir(); + if (!existsSync(sessionDir)) { + mkdirSync(sessionDir, { recursive: true }); + } - const destinationPath = join(sessionDir, basename(resolvedPath)); - const beforeResult = await this.emitBeforeSwitch("resume", destinationPath); - if (beforeResult.cancelled) { - return beforeResult; - } + const destinationPath = join(sessionDir, basename(resolvedPath)); + const beforeResult = await this.emitBeforeSwitch("resume", destinationPath); + if (beforeResult.cancelled) { + return beforeResult; + } - const previousSessionFile = this.session.sessionFile; - if (resolve(destinationPath) !== resolvedPath) { - copyFileSync(resolvedPath, destinationPath); - } + const previousSessionFile = this.session.sessionFile; + if (resolve(destinationPath) !== resolvedPath) { + copyFileSync(resolvedPath, destinationPath); + } + + const sessionManager = SessionManager.open(destinationPath, sessionDir, cwdOverride); + assertSessionCwdExists(sessionManager, this.cwd); + await this.replaceCurrentRuntime( + () => + this.createRuntime({ + cwd: sessionManager.getCwd(), + agentDir: this.services.agentDir, + sessionManager, + sessionStartEvent: { type: "session_start", reason: "resume", previousSessionFile }, + }), + { + reason: "resume", + targetSessionFile: sessionManager.getSessionFile(), + }, + ); + return { cancelled: false }; + }); + } + + private enqueuePromptPreparation(operation: () => Promise): Promise { + const queued = this.promptPreparationQueue.then(operation, operation); + this.promptPreparationQueue = queued.then( + () => undefined, + () => undefined, + ); + return queued; + } - const sessionManager = SessionManager.open(destinationPath, sessionDir, cwdOverride); - assertSessionCwdExists(sessionManager, this.cwd); - await this.teardownCurrent("resume", sessionManager.getSessionFile()); - this.apply( - await this.createRuntime({ - cwd: sessionManager.getCwd(), - agentDir: this.services.agentDir, - sessionManager, - sessionStartEvent: { type: "session_start", reason: "resume", previousSessionFile }, - }), + private async startSessionPrompt(text: string, options: PromptOptions | undefined): Promise { + let settlePreflight: ((success: boolean) => void) | undefined; + const preflight = new Promise((resolvePreflight) => { + settlePreflight = resolvePreflight; + }); + let preflightSettled = false; + const settle = (success: boolean) => { + if (preflightSettled) return; + preflightSettled = true; + settlePreflight?.(success); + }; + const completion = this.session.prompt(text, { + ...options, + preflightResult: (success) => { + try { + options?.preflightResult?.(success); + } finally { + settle(success); + } + }, + }); + void completion.then( + () => settle(true), + () => settle(false), + ); + const accepted = await preflight; + return { completion, accepted }; + } + + private async startSessionPlanContinuation( + continuation: PlanContinuation, + options: PlanContinuationPromptOptions | undefined, + ): Promise { + let settlePreflight: ((success: boolean) => void) | undefined; + const preflight = new Promise((resolvePreflight) => { + settlePreflight = resolvePreflight; + }); + let preflightSettled = false; + const settle = (success: boolean) => { + if (preflightSettled) return; + preflightSettled = true; + settlePreflight?.(success); + }; + const completion = this.session.promptPlanContinuation(continuation, { + ...options, + preflightResult: (success) => { + try { + options?.preflightResult?.(success); + } finally { + settle(success); + } + }, + }); + void completion.then( + () => settle(true), + () => settle(false), ); - await this.finishSessionReplacement(); - return { cancelled: false }; + const accepted = await preflight; + return { completion, accepted }; + } + + private async preparePrompt(text: string, options: PromptOptions | undefined): Promise { + this.assertExclusiveRuntimeIdle(); + return this.startSessionPrompt(text, options); + } + + /** Submit an accepted plan's implementation prompt exactly once, under exclusive runtime ownership. */ + async promptPlanContinuation( + continuation: PlanContinuation, + options?: PlanContinuationPromptOptions, + ): Promise { + let preflightReported = false; + const reportPreflight = (success: boolean): void => { + if (preflightReported) return; + preflightReported = true; + try { + options?.preflightResult?.(success); + } catch { + // Observation callbacks must not change routing or trusted prompt control flow. + } + }; + const entrySession = this.session; + try { + this.assertExclusiveRuntimeIdle(); + if (entrySession.pendingPlanContinuation !== continuation) { + throw new Error("Plan continuation is no longer pending."); + } + const prepared = await this.enqueuePromptPreparation(() => + this.runExclusiveRuntimeOperation(async () => { + if (this.session !== entrySession || entrySession.pendingPlanContinuation !== continuation) { + throw new Error("Session changed while the plan continuation was waiting to be submitted."); + } + + const activeContinuation = this.session.pendingPlanContinuation; + if (!activeContinuation) { + throw new Error("Plan continuation was lost while preparing its implementation prompt."); + } + if (this.disposeRequested) { + throw new Error("Agent session runtime disposal started before implementation prompt ownership."); + } + return this.startSessionPlanContinuation(activeContinuation, { + ...options, + preflightResult: reportPreflight, + }); + }), + ); + await prepared.completion; + } catch (error) { + reportPreflight(false); + throw error; + } + } + + /** Run the accepted first task on DEFAULT, then resolve this session's harness exactly once. */ + async prompt(text: string, options?: PromptOptions): Promise { + this.assertExclusiveRuntimeIdle(); + const entrySession = this.session; + const prepared = await this.enqueuePromptPreparation(() => { + try { + this.assertExclusiveRuntimeIdle(); + } catch (error) { + options?.preflightResult?.(false); + throw error; + } + if (this.session !== entrySession) { + options?.preflightResult?.(false); + throw new Error("Session changed while the prompt was waiting to be prepared."); + } + return this.preparePrompt(text, options); + }); + await prepared.completion; } dispose(): Promise { if (this.disposePromise) return this.disposePromise; + this.disposeRequested = true; let resolveDispose!: () => void; let rejectDispose!: (reason: unknown) => void; @@ -431,7 +773,10 @@ export class AgentSessionRuntime { rejectDispose = reject; }); this.disposePromise = disposePromise; - void this.teardownCurrent("quit").then(resolveDispose, rejectDispose); + const teardown = this.exclusiveRuntimeOperation + ? this.exclusiveRuntimeOperationSettled.then(() => this.teardownCurrent("quit")) + : this.teardownCurrent("quit"); + void teardown.then(resolveDispose, rejectDispose); return disposePromise; } } diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index e7fe98041..0db73b943 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -640,18 +640,19 @@ export class AgentSession { this.agent.afterToolCall = async ({ toolCall, args, result, isError, context }) => { const isPlanToolContext = this._collaborationMode === "plan" || this._isPlanToolContext(context.tools); const runner = this._extensionRunner; - const hookResult = !isPlanToolContext && runner.hasHandlers("tool_result") - ? await runner.emitToolResult({ - type: "tool_result", - toolName: toolCall.name, - toolCallId: toolCall.id, - input: args as Record, - content: result.content, - details: result.details, - isError, - usage: result.usage, - }) - : undefined; + const hookResult = + !isPlanToolContext && runner.hasHandlers("tool_result") + ? await runner.emitToolResult({ + type: "tool_result", + toolName: toolCall.name, + toolCallId: toolCall.id, + input: args as Record, + content: result.content, + details: result.details, + isError, + usage: result.usage, + }) + : undefined; const content = hookResult?.content ?? result.content ?? []; // Runs after the extension hook so images injected or replaced by extensions are normalized too. diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index 3830da205..0f4db72b6 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -1569,6 +1569,27 @@ export class SessionManager { this.leafId = null; } + /** + * Create an independent in-memory session containing the selected branch. + * The source manager is not mutated, so callers can prepare a replacement + * runtime without changing the live session before activation succeeds. + */ + createInMemoryBranch(leafId: string | null): SessionManager { + if (this.persist) { + throw new Error("In-memory branches can only be created from an in-memory session"); + } + if (leafId === null) { + return SessionManager.inMemory(this.cwd); + } + + const branch = SessionManager.inMemory(this.cwd); + branch.fileEntries = [...this.fileEntries]; + branch.sessionId = this.sessionId; + branch._buildIndex(); + branch.createBranchedSession(leafId); + return branch; + } + /** * Start a new branch with a summary of the abandoned path. * Same as branch(), but also appends a branch_summary entry that captures