diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ce3b2393fd..f066d3d623f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ Docs: https://docs.openclaw.ai - Config/gateway: recover configs accidentally prefixed with non-JSON output during gateway startup or `openclaw doctor --fix`, preserving the clobbered file as a backup while leaving normal config reads read-only. - Pi embedded runs: pass real built-in tools into Pi session creation and then narrow active tool names after custom tool registration, so the runner and compaction paths compile cleanly and keep OpenClaw-managed custom tool allowlists without feeding string arrays into `createAgentSession`. Thanks @vincentkoc. - Agents/OpenAI websocket: route native OpenAI websocket metadata and session-header decisions through the shared endpoint classifier so local mocks and custom `models.providers.openai.baseUrl` endpoints stay out of the native OpenAI path consistently across embedded-runner and websocket transport code. Thanks @vincentkoc. +- Cron/MCP: retire bundled MCP runtimes through one shared cleanup path for isolated cron run ends, persistent cron session rollover, and direct cron `deleteAfterRun` fallback cleanup. Fixes #69145, #68623, and #68827. - MCP/gateway: tear down stdio MCP process trees on transport close and dispose bundled MCP runtimes during session delete/reset, preventing orphaned wrapper/server processes from accumulating. Fixes #68809 and #69465. - Config: render validation warnings with real line breaks instead of a literal `\n` sequence in CLI/audit output. Fixes #70140. - Cron/doctor: repair malformed persisted cron job IDs through `openclaw doctor`, including legacy `jobId`, non-string `id`, and missing `id` rows, so `cron list` no longer needs display-layer coercion for corrupt store data. Fixes #70128. diff --git a/src/agents/pi-bundle-mcp-runtime.test.ts b/src/agents/pi-bundle-mcp-runtime.test.ts index 01f725e0e90..173259e73f7 100644 --- a/src/agents/pi-bundle-mcp-runtime.test.ts +++ b/src/agents/pi-bundle-mcp-runtime.test.ts @@ -1,6 +1,11 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { cleanupBundleMcpHarness } from "./pi-bundle-mcp-test-harness.js"; -import { __testing, materializeBundleMcpToolsForRun } from "./pi-bundle-mcp-tools.js"; +import { + __testing, + getOrCreateSessionMcpRuntime, + materializeBundleMcpToolsForRun, + retireSessionMcpRuntime, +} from "./pi-bundle-mcp-tools.js"; import type { SessionMcpRuntime } from "./pi-bundle-mcp-types.js"; vi.mock("./embedded-pi-mcp.js", () => ({ @@ -298,4 +303,20 @@ describe("session MCP runtime", () => { expect((result.error as Error).message).toMatch(/disposed/); expect(manager.listSessionIds()).not.toContain("session-d"); }); + + it("retires global session runtimes and ignores missing ids", async () => { + await getOrCreateSessionMcpRuntime({ + sessionId: "session-retire", + sessionKey: "agent:test:session-retire", + workspaceDir: "/workspace", + }); + expect(__testing.getCachedSessionIds()).toContain("session-retire"); + + await expect( + retireSessionMcpRuntime({ sessionId: " session-retire ", reason: "test" }), + ).resolves.toBe(true); + expect(__testing.getCachedSessionIds()).not.toContain("session-retire"); + + await expect(retireSessionMcpRuntime({ sessionId: " ", reason: "test" })).resolves.toBe(false); + }); }); diff --git a/src/agents/pi-bundle-mcp-runtime.ts b/src/agents/pi-bundle-mcp-runtime.ts index 7b488130f2a..84537f817cc 100644 --- a/src/agents/pi-bundle-mcp-runtime.ts +++ b/src/agents/pi-bundle-mcp-runtime.ts @@ -436,6 +436,24 @@ export async function disposeSessionMcpRuntime(sessionId: string): Promise await getSessionMcpRuntimeManager().disposeSession(sessionId); } +export async function retireSessionMcpRuntime(params: { + sessionId?: string | null; + reason: string; + onError?: (error: unknown, sessionId: string, reason: string) => void; +}): Promise { + const sessionId = normalizeOptionalString(params.sessionId); + if (!sessionId) { + return false; + } + try { + await disposeSessionMcpRuntime(sessionId); + return true; + } catch (error) { + params.onError?.(error, sessionId, params.reason); + return false; + } +} + export async function disposeAllSessionMcpRuntimes(): Promise { await getSessionMcpRuntimeManager().disposeAll(); } diff --git a/src/agents/pi-bundle-mcp-tools.ts b/src/agents/pi-bundle-mcp-tools.ts index e7b59d919e8..1f8330959a5 100644 --- a/src/agents/pi-bundle-mcp-tools.ts +++ b/src/agents/pi-bundle-mcp-tools.ts @@ -13,6 +13,7 @@ export { disposeSessionMcpRuntime, getOrCreateSessionMcpRuntime, getSessionMcpRuntimeManager, + retireSessionMcpRuntime, } from "./pi-bundle-mcp-runtime.js"; export { createBundleMcpToolRuntime, diff --git a/src/agents/pi-embedded-runner.bundle-mcp.e2e.test.ts b/src/agents/pi-embedded-runner.bundle-mcp.e2e.test.ts index 209d850a79c..a6d7d4e921f 100644 --- a/src/agents/pi-embedded-runner.bundle-mcp.e2e.test.ts +++ b/src/agents/pi-embedded-runner.bundle-mcp.e2e.test.ts @@ -33,6 +33,7 @@ let streamCallCount = 0; let observedContexts: Array> = []; vi.mock("./pi-bundle-mcp-tools.js", () => ({ + retireSessionMcpRuntime: vi.fn(async () => true), getOrCreateSessionMcpRuntime: async () => ({ sessionId: "bundle-mcp-runtime", sessionKey: "agent:test:bundle-mcp-e2e", diff --git a/src/agents/pi-embedded-runner.e2e.test.ts b/src/agents/pi-embedded-runner.e2e.test.ts index caa84e9778c..8a22f19c428 100644 --- a/src/agents/pi-embedded-runner.e2e.test.ts +++ b/src/agents/pi-embedded-runner.e2e.test.ts @@ -111,6 +111,8 @@ const installRunEmbeddedMocks = () => { })); vi.doMock("./pi-bundle-mcp-tools.js", () => ({ disposeSessionMcpRuntime: (sessionId: string) => disposeSessionMcpRuntimeMock(sessionId), + retireSessionMcpRuntime: ({ sessionId }: { sessionId?: string | null }) => + sessionId ? disposeSessionMcpRuntimeMock(sessionId) : Promise.resolve(false), })); vi.doMock("./pi-embedded-runner/model.js", async () => { const actual = await vi.importActual( diff --git a/src/agents/pi-embedded-runner/compact.hooks.harness.ts b/src/agents/pi-embedded-runner/compact.hooks.harness.ts index 3bbfbda04c6..70c8f19cc0d 100644 --- a/src/agents/pi-embedded-runner/compact.hooks.harness.ts +++ b/src/agents/pi-embedded-runner/compact.hooks.harness.ts @@ -336,6 +336,7 @@ export async function loadCompactHooksHarness(): Promise<{ })); vi.doMock("../pi-bundle-mcp-tools.js", () => ({ + retireSessionMcpRuntime: vi.fn(async () => true), createBundleMcpToolRuntime: vi.fn(async () => ({ tools: [], dispose: vi.fn(async () => {}), diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index a8ba9794062..1a169a5f6fc 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -50,7 +50,7 @@ import { } from "../model-auth.js"; import { normalizeProviderId } from "../model-selection.js"; import { ensureOpenClawModelsJson } from "../models-config.js"; -import { disposeSessionMcpRuntime } from "../pi-bundle-mcp-tools.js"; +import { retireSessionMcpRuntime } from "../pi-bundle-mcp-tools.js"; import { classifyFailoverReason, extractObservedOverflowTokenCount, @@ -2130,10 +2130,14 @@ export async function runEmbeddedPiAgent( await contextEngine.dispose?.(); stopRuntimeAuthRefreshTimer(); if (params.cleanupBundleMcpOnRunEnd === true) { - await disposeSessionMcpRuntime(params.sessionId).catch((error) => { - log.warn( - `bundle-mcp cleanup failed after run for ${params.sessionId}: ${formatErrorMessage(error)}`, - ); + await retireSessionMcpRuntime({ + sessionId: params.sessionId, + reason: "embedded-run-end", + onError: (error, sessionId) => { + log.warn( + `bundle-mcp cleanup failed after run for ${sessionId}: ${formatErrorMessage(error)}`, + ); + }, }); } } diff --git a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test-support.ts b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test-support.ts index b760e74ebd0..450053b99b3 100644 --- a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test-support.ts +++ b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test-support.ts @@ -424,6 +424,7 @@ vi.mock("../../pi-bundle-mcp-tools.js", () => ({ createBundleMcpToolRuntime: async () => undefined, getOrCreateSessionMcpRuntime: async () => undefined, materializeBundleMcpToolsForRun: async () => undefined, + retireSessionMcpRuntime: async () => true, })); vi.mock("../../pi-bundle-lsp-runtime.js", () => ({ diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index 6adf268b2af..48132583981 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -4,7 +4,7 @@ import { resolveSessionAgentId } from "../../agents/agent-scope.js"; import { clearBootstrapSnapshotOnSessionRollover } from "../../agents/bootstrap-cache.js"; import { getCliSessionBinding } from "../../agents/cli-session.js"; import { resetRegisteredAgentHarnessSessions } from "../../agents/harness/registry.js"; -import { disposeSessionMcpRuntime } from "../../agents/pi-bundle-mcp-tools.js"; +import { retireSessionMcpRuntime } from "../../agents/pi-bundle-mcp-tools.js"; import { normalizeChatType } from "../../channels/chat-type.js"; import { resolveGroupSessionKey } from "../../config/sessions/group.js"; import { canonicalizeMainSessionAlias } from "../../config/sessions/main-session.js"; @@ -815,13 +815,14 @@ export async function initSessionState(params: { agentId, archivedTranscripts, }); - await disposeSessionMcpRuntime(previousSessionEntry.sessionId).catch((error) => { - log.warn( - `failed to dispose bundle MCP runtime for session ${previousSessionEntry.sessionId}`, - { + await retireSessionMcpRuntime({ + sessionId: previousSessionEntry.sessionId, + reason: "reply-session-rollover", + onError: (error, sessionId) => { + log.warn(`failed to dispose bundle MCP runtime for session ${sessionId}`, { error: String(error), - }, - ); + }); + }, }); await resetRegisteredAgentHarnessSessions({ sessionId: previousSessionEntry.sessionId, diff --git a/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts b/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts index 3a2c08f7939..c7237e69c15 100644 --- a/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts +++ b/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts @@ -15,8 +15,9 @@ import { SILENT_REPLY_TOKEN } from "../../auto-reply/tokens.js"; // --- Module mocks (must be hoisted before imports) --- -const { countActiveDescendantRunsMock } = vi.hoisted(() => ({ +const { countActiveDescendantRunsMock, retireSessionMcpRuntimeMock } = vi.hoisted(() => ({ countActiveDescendantRunsMock: vi.fn().mockReturnValue(0), + retireSessionMcpRuntimeMock: vi.fn().mockResolvedValue(true), })); vi.mock("../../config/sessions/main-session.js", () => ({ @@ -28,6 +29,10 @@ vi.mock("../../agents/subagent-registry-read.js", () => ({ countActiveDescendantRuns: countActiveDescendantRunsMock, })); +vi.mock("../../agents/pi-bundle-mcp-tools.js", () => ({ + retireSessionMcpRuntime: retireSessionMcpRuntimeMock, +})); + vi.mock("./delivery-subagent-registry.runtime.js", () => ({ countActiveDescendantRuns: countActiveDescendantRunsMock, })); @@ -71,6 +76,7 @@ vi.mock("./subagent-followup.runtime.js", () => ({ waitForDescendantSubagentSummary: vi.fn().mockResolvedValue(undefined), })); +import { retireSessionMcpRuntime } from "../../agents/pi-bundle-mcp-tools.js"; // Import after mocks import { countActiveDescendantRuns } from "../../agents/subagent-registry-read.js"; import { callGateway } from "../../gateway/call.runtime.js"; @@ -138,6 +144,7 @@ function makeBaseParams(overrides: { } as never, agentId: "main", agentSessionKey: "agent:main", + sessionId: "test-session-id", runStartedAt, runEndedAt: runStartedAt, timeoutMs: 30_000, @@ -171,6 +178,7 @@ describe("dispatchCronDelivery — double-announce guard", () => { vi.mocked(isLikelyInterimCronMessage).mockReturnValue(false); vi.mocked(readDescendantSubagentFallbackReply).mockResolvedValue(undefined); vi.mocked(waitForDescendantSubagentSummary).mockResolvedValue(undefined); + vi.mocked(retireSessionMcpRuntime).mockResolvedValue(true); }); afterEach(() => { @@ -533,6 +541,28 @@ describe("dispatchCronDelivery — double-announce guard", () => { }); }); + it("retires the MCP runtime directly when deleteAfterRun gateway cleanup fails", async () => { + vi.mocked(countActiveDescendantRuns).mockReturnValue(0); + vi.mocked(isLikelyInterimCronMessage).mockReturnValue(false); + vi.mocked(callGateway).mockRejectedValueOnce(new Error("gateway down")); + + const params = makeBaseParams({ synthesizedText: SILENT_REPLY_TOKEN }); + (params.job as { deleteAfterRun?: boolean }).deleteAfterRun = true; + + const state = await dispatchCronDelivery(params); + + expect(state.result).toEqual( + expect.objectContaining({ + status: "ok", + delivered: false, + }), + ); + expect(retireSessionMcpRuntime).toHaveBeenCalledWith({ + sessionId: "test-session-id", + reason: "cron-delete-after-run-fallback", + }); + }); + it("text delivery fires exactly once (no double-deliver)", async () => { vi.mocked(countActiveDescendantRuns).mockReturnValue(0); vi.mocked(isLikelyInterimCronMessage).mockReturnValue(false); diff --git a/src/cron/isolated-agent/delivery-dispatch.ts b/src/cron/isolated-agent/delivery-dispatch.ts index 98b13310097..cbe0bc0fbf4 100644 --- a/src/cron/isolated-agent/delivery-dispatch.ts +++ b/src/cron/isolated-agent/delivery-dispatch.ts @@ -1,3 +1,4 @@ +import { retireSessionMcpRuntime } from "../../agents/pi-bundle-mcp-tools.js"; import type { ReplyPayload } from "../../auto-reply/reply-payload.js"; import { isSilentReplyText, @@ -103,6 +104,7 @@ type DispatchCronDeliveryParams = { job: CronJob; agentId: string; agentSessionKey: string; + sessionId: string; runStartedAt: number; runEndedAt: number; timeoutMs: number; @@ -477,6 +479,10 @@ export async function dispatchCronDelivery( }); directCronSessionDeleted = true; } catch { + await retireSessionMcpRuntime({ + sessionId: params.sessionId, + reason: "cron-delete-after-run-fallback", + }); // Best-effort; direct delivery result should still be returned. } }; diff --git a/src/cron/isolated-agent/run-executor.ts b/src/cron/isolated-agent/run-executor.ts index 3cb9c792ac8..2831d4d1602 100644 --- a/src/cron/isolated-agent/run-executor.ts +++ b/src/cron/isolated-agent/run-executor.ts @@ -160,6 +160,7 @@ export function createCronPromptExecutor(params: { sessionKey: params.agentSessionKey, agentId: params.agentId, trigger: "cron", + cleanupBundleMcpOnRunEnd: params.job.sessionTarget === "isolated", allowGatewaySubagentBinding: true, senderIsOwner: false, messageChannel: params.messageChannel, diff --git a/src/cron/isolated-agent/run.fast-mode.test.ts b/src/cron/isolated-agent/run.fast-mode.test.ts index f37376b9069..8782da113ce 100644 --- a/src/cron/isolated-agent/run.fast-mode.test.ts +++ b/src/cron/isolated-agent/run.fast-mode.test.ts @@ -7,6 +7,7 @@ import { import { loadRunCronIsolatedAgentTurn, makeCronSession, + retireSessionMcpRuntimeMock, resolveFastModeStateMock, resolveCronSessionMock, runEmbeddedPiAgentMock, @@ -36,19 +37,25 @@ function mockSuccessfulModelFallback() { async function runFastModeCase(params: { configFastMode: boolean; expectedFastMode: boolean; + expectedCleanupBundleMcpOnRunEnd?: boolean; + expectedRetiredSessionId?: string; message: string; + previousSessionId?: string; + sessionId?: string; sessionFastMode?: boolean; + sessionTarget?: string; }) { const baseSession = makeCronSession(); resolveCronSessionMock.mockReturnValue( - params.sessionFastMode === undefined - ? baseSession - : makeCronSession({ - sessionEntry: { - ...baseSession.sessionEntry, - fastMode: params.sessionFastMode, - }, - }), + makeCronSession({ + ...baseSession, + ...(params.previousSessionId ? { previousSessionId: params.previousSessionId } : {}), + sessionEntry: { + ...baseSession.sessionEntry, + ...(params.sessionId ? { sessionId: params.sessionId } : {}), + ...(params.sessionFastMode === undefined ? {} : { fastMode: params.sessionFastMode }), + }, + }), ); mockSuccessfulModelFallback(); resolveFastModeStateMock.mockImplementation(({ cfg, sessionEntry }) => { @@ -77,6 +84,7 @@ async function runFastModeCase(params: { }, }, job: makeIsolatedAgentTurnJob({ + sessionTarget: params.sessionTarget ?? "isolated", payload: { kind: "agentTurn", message: params.message, @@ -92,8 +100,19 @@ async function runFastModeCase(params: { provider: "openai", model: EXPECTED_OPENAI_MODEL, fastMode: params.expectedFastMode, + cleanupBundleMcpOnRunEnd: params.expectedCleanupBundleMcpOnRunEnd ?? true, allowGatewaySubagentBinding: true, }); + if (params.expectedRetiredSessionId) { + expect(retireSessionMcpRuntimeMock).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: params.expectedRetiredSessionId, + reason: "cron-session-rollover", + }), + ); + return; + } + expect(retireSessionMcpRuntimeMock).not.toHaveBeenCalled(); } describe("runCronIsolatedAgentTurn — fast mode", () => { @@ -124,4 +143,27 @@ describe("runCronIsolatedAgentTurn — fast mode", () => { sessionFastMode: true, }); }); + + it("preserves bundled MCP runtime state for persistent cron session targets", async () => { + await runFastModeCase({ + configFastMode: true, + expectedFastMode: true, + expectedCleanupBundleMcpOnRunEnd: false, + message: "test persistent cron session", + sessionTarget: "session:agent:main:main:thread:9999", + }); + }); + + it("retires the previous bundled MCP runtime when a persistent cron session rolls over", async () => { + await runFastModeCase({ + configFastMode: true, + expectedFastMode: true, + expectedCleanupBundleMcpOnRunEnd: false, + expectedRetiredSessionId: "stale-session-id", + message: "test persistent cron session rollover", + previousSessionId: "stale-session-id", + sessionId: "rotated-session-id", + sessionTarget: "session:agent:main:main:thread:9999", + }); + }); }); diff --git a/src/cron/isolated-agent/run.test-harness.ts b/src/cron/isolated-agent/run.test-harness.ts index 47a98a55d29..44cc62b5ece 100644 --- a/src/cron/isolated-agent/run.test-harness.ts +++ b/src/cron/isolated-agent/run.test-harness.ts @@ -69,6 +69,7 @@ export const resolveHeartbeatAckMaxCharsMock = createMock(); export const resolveSessionAuthProfileOverrideMock = createMock(); export const resolveFastModeStateMock = createMock(); export const getChannelPluginMock = createMock(); +export const retireSessionMcpRuntimeMock = createMock(); const resolveBootstrapWarningSignaturesSeenMock = createMock(); const resolveCronStyleNowMock = createMock(); @@ -196,6 +197,10 @@ vi.mock("../../agents/cli-runner.runtime.js", () => ({ setCliSessionId: vi.fn(), })); +vi.mock("../../agents/pi-bundle-mcp-tools.js", () => ({ + retireSessionMcpRuntime: retireSessionMcpRuntimeMock, +})); + vi.mock("../../config/sessions/store.runtime.js", () => ({ updateSessionStore: updateSessionStoreMock, })); @@ -433,6 +438,8 @@ function resetRunSessionMocks(): void { updateSessionStoreMock.mockResolvedValue(undefined); resolveCronSessionMock.mockReset(); resolveCronSessionMock.mockReturnValue(makeCronSession()); + retireSessionMcpRuntimeMock.mockReset(); + retireSessionMcpRuntimeMock.mockResolvedValue(true); } export function resetRunCronIsolatedAgentTurnHarness(): void { diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index 6a2904c24fc..4cb619d33ce 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -1,10 +1,12 @@ import { hasAnyAuthProfileStoreSource } from "../../agents/auth-profiles/source-check.js"; +import { retireSessionMcpRuntime } from "../../agents/pi-bundle-mcp-tools.js"; import type { MessagingToolSend } from "../../agents/pi-embedded-messaging.types.js"; import type { SkillSnapshot } from "../../agents/skills.js"; import type { ThinkLevel } from "../../auto-reply/thinking.js"; import type { CliDeps } from "../../cli/outbound-send-deps.js"; import type { AgentDefaultsConfig } from "../../config/types.agent-defaults.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { normalizeOptionalString } from "../../shared/string-coerce.js"; import { resolveCronDeliveryPlan, type CronDeliveryPlan } from "../delivery-plan.js"; import type { CronDeliveryTrace, @@ -120,6 +122,29 @@ function resolveNonNegativeNumber(value: number | undefined): number | undefined return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined; } +async function retireRolledCronSessionMcpRuntime(params: { + job: CronJob; + cronSession: MutableCronSession; +}) { + if (params.job.sessionTarget === "isolated") { + return; + } + const previousSessionId = normalizeOptionalString(params.cronSession.previousSessionId); + const currentSessionId = normalizeOptionalString(params.cronSession.sessionEntry.sessionId); + if (!previousSessionId || previousSessionId === currentSessionId) { + return; + } + await retireSessionMcpRuntime({ + sessionId: previousSessionId, + reason: "cron-session-rollover", + onError: (error, sessionId) => { + logWarn( + `[cron:${params.job.id}] Failed to dispose retired bundle MCP runtime for session ${sessionId}: ${String(error)}`, + ); + }, + }); +} + export type { RunCronAgentTurnResult } from "./run.types.js"; type CronExecutionRuntime = typeof import("./run-executor.runtime.js"); @@ -618,6 +643,10 @@ async function prepareCronRunContext(params: { } catch (err) { logWarn(`[cron:${input.job.id}] Failed to persist pre-run session entry: ${String(err)}`); } + await retireRolledCronSessionMcpRuntime({ + job: input.job, + cronSession, + }); const hasSessionAuthProfileOverride = Boolean( cronSession.sessionEntry.authProfileOverride?.trim(), ); @@ -839,6 +868,7 @@ async function finalizeCronRun(params: { job: prepared.input.job, agentId: prepared.agentId, agentSessionKey: prepared.agentSessionKey, + sessionId: prepared.runSessionId, runStartedAt: execution.runStartedAt, runEndedAt: execution.runEndedAt, timeoutMs: prepared.timeoutMs, diff --git a/src/cron/isolated-agent/session.test.ts b/src/cron/isolated-agent/session.test.ts index b7b7ca43aa3..eb4022698d0 100644 --- a/src/cron/isolated-agent/session.test.ts +++ b/src/cron/isolated-agent/session.test.ts @@ -120,6 +120,7 @@ describe("resolveCronSession", () => { expect(result.sessionEntry.sessionId).toBe("existing-session-id-123"); expect(result.isNewSession).toBe(false); + expect(result.previousSessionId).toBeUndefined(); expect(result.systemSent).toBe(true); expect(clearBootstrapSnapshot).not.toHaveBeenCalled(); }); @@ -139,6 +140,7 @@ describe("resolveCronSession", () => { expect(result.sessionEntry.sessionId).not.toBe("old-session-id"); expect(result.isNewSession).toBe(true); + expect(result.previousSessionId).toBe("old-session-id"); expect(result.systemSent).toBe(false); expect(result.sessionEntry.modelOverride).toBe("gpt-4.1-mini"); expect(result.sessionEntry.providerOverride).toBe("openai"); @@ -161,6 +163,7 @@ describe("resolveCronSession", () => { expect(result.sessionEntry.sessionId).not.toBe("existing-session-id-456"); expect(result.isNewSession).toBe(true); + expect(result.previousSessionId).toBe("existing-session-id-456"); expect(result.systemSent).toBe(false); expect(result.sessionEntry.modelOverride).toBe("sonnet-4"); expect(result.sessionEntry.providerOverride).toBe("anthropic"); diff --git a/src/cron/isolated-agent/session.ts b/src/cron/isolated-agent/session.ts index fbfb907c980..247e7263fe6 100644 --- a/src/cron/isolated-agent/session.ts +++ b/src/cron/isolated-agent/session.ts @@ -59,9 +59,10 @@ export function resolveCronSession(params: { systemSent = false; } + const previousSessionId = isNewSession ? entry?.sessionId : undefined; clearBootstrapSnapshotOnSessionRollover({ sessionKey: params.sessionKey, - previousSessionId: isNewSession ? entry?.sessionId : undefined, + previousSessionId, }); const sessionEntry: SessionEntry = { @@ -86,5 +87,5 @@ export function resolveCronSession(params: { sessionFile: undefined, }), }; - return { storePath, store, sessionEntry, systemSent, isNewSession }; + return { storePath, store, sessionEntry, systemSent, isNewSession, previousSessionId }; } diff --git a/src/gateway/server.sessions.gateway-server-sessions-a.test.ts b/src/gateway/server.sessions.gateway-server-sessions-a.test.ts index 0c2194fa949..1a0d1e20d16 100644 --- a/src/gateway/server.sessions.gateway-server-sessions-a.test.ts +++ b/src/gateway/server.sessions.gateway-server-sessions-a.test.ts @@ -207,6 +207,10 @@ vi.mock("../plugin-sdk/browser-maintenance.js", () => ({ vi.mock("../agents/pi-bundle-mcp-tools.js", () => ({ disposeSessionMcpRuntime: bundleMcpRuntimeMocks.disposeSessionMcpRuntime, + retireSessionMcpRuntime: ({ sessionId }: { sessionId?: string | null }) => + sessionId + ? bundleMcpRuntimeMocks.disposeSessionMcpRuntime(sessionId).then(() => true) + : Promise.resolve(false), })); installGatewayTestHooks({ scope: "suite" }); diff --git a/src/gateway/session-reset-service.ts b/src/gateway/session-reset-service.ts index 3d17bda0be9..d6e158c2586 100644 --- a/src/gateway/session-reset-service.ts +++ b/src/gateway/session-reset-service.ts @@ -7,7 +7,7 @@ import { getAcpRuntimeBackend } from "../acp/runtime/registry.js"; import { readAcpSessionEntry, upsertAcpSessionMeta } from "../acp/runtime/session-meta.js"; import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; import { clearBootstrapSnapshot } from "../agents/bootstrap-cache.js"; -import { disposeSessionMcpRuntime } from "../agents/pi-bundle-mcp-tools.js"; +import { retireSessionMcpRuntime } from "../agents/pi-bundle-mcp-tools.js"; import { abortEmbeddedPiRun, waitForEmbeddedPiRunEnd } from "../agents/pi-embedded.js"; import { stopSubagentsForRequester } from "../auto-reply/reply/abort.js"; import { clearSessionQueues } from "../auto-reply/reply/queue.js"; @@ -226,10 +226,14 @@ async function ensureSessionRuntimeCleanup(params: { const ended = await waitForEmbeddedPiRunEnd(params.sessionId, 15_000); clearBootstrapSnapshot(params.target.canonicalKey); if (ended) { - await disposeSessionMcpRuntime(params.sessionId).catch((error) => { - logVerbose( - `sessions cleanup: failed to dispose bundle MCP runtime for ${params.sessionId}: ${String(error)}`, - ); + await retireSessionMcpRuntime({ + sessionId: params.sessionId, + reason: "gateway-session-cleanup", + onError: (error, sessionId) => { + logVerbose( + `sessions cleanup: failed to dispose bundle MCP runtime for ${sessionId}: ${String(error)}`, + ); + }, }); await closeTrackedBrowserTabs(); return undefined;