diff --git a/app/api/sessions/[id]/route.ts b/app/api/sessions/[id]/route.ts index 1062991cac..90efe41e8a 100644 --- a/app/api/sessions/[id]/route.ts +++ b/app/api/sessions/[id]/route.ts @@ -343,10 +343,12 @@ export async function DELETE( for (const deletedId of [...deletedSessionIds].reverse()) { if (deletedId === id) continue; try { await abortSubagent(deletedId); } catch { /* idle or completed */ } - await getRpcSession(deletedId)?.shutdown(); + const child = getRpcSession(deletedId); + if (!child?.isRunning()) await child?.shutdown(); } try { await abortSubagent(id); } catch { /* ordinary session */ } - await getRpcSession(id)?.shutdown(); + const target = getRpcSession(id); + if (!target?.isRunning()) await target?.shutdown(); for (const [deletedId, deletedPath] of deletedPaths) { try { unlinkSync(deletedPath); diff --git a/lib/rpc-manager.test.mjs b/lib/rpc-manager.test.mjs index 6380eeb54b..1bc9962559 100644 --- a/lib/rpc-manager.test.mjs +++ b/lib/rpc-manager.test.mjs @@ -229,7 +229,8 @@ test("normal session teardown paths use graceful extension shutdown", async () = assert.match(replacementShutdownSource, /await this\.shutdown\(\)/); assert.match(forkSource, /shutdownAfterSessionReplacement\("fork"\)/); assert.match(cloneSource, /shutdownAfterSessionReplacement\("clone"\)/); - assert.match(deleteRouteSource, /await getRpcSession\(id\)\?\.shutdown\(\)/); + assert.match(deleteRouteSource, /const target = getRpcSession\(id\);\s*if \(!target\?\.isRunning\(\)\) await target\?\.shutdown\(\)/); + assert.match(deleteRouteSource, /const child = getRpcSession\(deletedId\);\s*if \(!child\?\.isRunning\(\)\) await child\?\.shutdown\(\)/); assert.match(trustRouteSource, /await destroyRpcSessionsForCwd\(result\.cwd\)/); }); diff --git a/lib/rpc-manager.ts b/lib/rpc-manager.ts index cc1cfed0c6..01f478f17c 100644 --- a/lib/rpc-manager.ts +++ b/lib/rpc-manager.ts @@ -1210,6 +1210,9 @@ export class AgentSessionWrapper { } case "reload": { + if (this.isSessionRunningForReplacement()) { + throw new Error("Cannot reload while the session is running"); + } if (this.extensionUiAbortController.signal.aborted) { this.extensionUiAbortController = new AbortController(); } @@ -1959,6 +1962,9 @@ export class AgentSessionWrapper { this.navigateTreeKeepingToolSelection(targetId, { summarize: options?.summarize }), switchSession: async () => ({ cancelled: true }), reload: async () => { + if (this.isSessionRunningForReplacement()) { + throw new Error("Cannot reload (extension ctx) while the session is running"); + } this.extensionStatuses.clear(); this.resetExtensionWidgetsForReload(); this.syncProjectTrust(); @@ -2295,7 +2301,7 @@ export function hasBusyRpcSessionForCwd(cwd: string): boolean { export async function destroyRpcSessionsForCwd(cwd: string): Promise { const targetCwd = normalizeRpcCwd(cwd); const sessions = Array.from(getRegistry().values()).filter( - (session) => normalizeRpcCwd(session.cwd) === targetCwd, + (session) => normalizeRpcCwd(session.cwd) === targetCwd && !session.isRunning(), ); await Promise.all(sessions.map((session) => session.shutdown())); return sessions.length; diff --git a/lib/subagent-runtime.ts b/lib/subagent-runtime.ts index 8ce263d822..88b9f18230 100644 --- a/lib/subagent-runtime.ts +++ b/lib/subagent-runtime.ts @@ -1,3 +1,4 @@ +import { registerSessionLivenessProvider } from "./session-liveness"; import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import { createAgentSessionFromServices, @@ -540,9 +541,17 @@ export function createSubagentController( finishQueuedAbort, ); stored.cancelQueued = queued.cancel; + // A background subagent outlives its parent's turn; keep the parent session from + // being automatically idle-evicted while the run is still active. Registered here, + // after the last call that can throw, so the release below cannot be skipped. + const releaseParentLiveness = registerSessionLivenessProvider({ + name: "pi-web-subagent-run", + sessionId: parentSessionId, + isActive: () => getSubagentRuns().get(initialRun.sessionId) === stored, + }); void queued.promise.then(resolveCompletion, (error) => { resolveCompletion({ ...initialRun, status: "failed", completedAt: new Date().toISOString(), error: error instanceof Error ? error.message : String(error) }); - }); + }).finally(releaseParentLiveness); return { run: stored.run, completion: stored.completion }; } catch (error) { @@ -663,7 +672,13 @@ export function createSubagentController( dependencies.invalidateSessionList(); }, finishQueuedAbort); stored.cancelQueued = queued.cancel; - void queued.promise.then(resolveCompletion, (error) => resolveCompletion({ ...initialRun, status: "failed", completedAt: new Date().toISOString(), error: error instanceof Error ? error.message : String(error) })); + // Keep the resumed run's parent alive past its own turn (see start()). + const releaseParentLiveness = registerSessionLivenessProvider({ + name: "pi-web-subagent-run", + sessionId: parentSessionId, + isActive: () => getSubagentRuns().get(request.sessionId) === stored, + }); + void queued.promise.then(resolveCompletion, (error) => resolveCompletion({ ...initialRun, status: "failed", completedAt: new Date().toISOString(), error: error instanceof Error ? error.message : String(error) })).finally(releaseParentLiveness); return { run: stored.run, completion }; }