diff --git a/src/acp/control-plane/manager.core.ts b/src/acp/control-plane/manager.core.ts index 58f74b72918..d92dd388f05 100644 --- a/src/acp/control-plane/manager.core.ts +++ b/src/acp/control-plane/manager.core.ts @@ -12,6 +12,7 @@ import { identityEquals, isSessionIdentityPending, mergeSessionIdentity, + resolveRuntimeResumeSessionId, resolveRuntimeHandleIdentifiersFromIdentity, resolveSessionIdentityFromMeta, } from "../runtime/session-identity.js"; @@ -972,20 +973,45 @@ export class AcpSessionManager { const backend = this.deps.requireRuntimeBackend(configuredBackend || undefined); const runtime = backend.runtime; - const ensured = await withAcpRuntimeErrorBoundary({ - run: async () => - await runtime.ensureSession({ - sessionKey: params.sessionKey, - agent, - mode, - cwd, - }), - fallbackCode: "ACP_SESSION_INIT_FAILED", - fallbackMessage: "Could not initialize ACP session runtime.", - }); - const previousMeta = params.meta; const previousIdentity = resolveSessionIdentityFromMeta(previousMeta); + const persistedResumeSessionId = + mode === "persistent" ? resolveRuntimeResumeSessionId(previousIdentity) : undefined; + const ensureSession = async (resumeSessionId?: string) => + await withAcpRuntimeErrorBoundary({ + run: async () => + await runtime.ensureSession({ + sessionKey: params.sessionKey, + agent, + mode, + ...(resumeSessionId ? { resumeSessionId } : {}), + cwd, + }), + fallbackCode: "ACP_SESSION_INIT_FAILED", + fallbackMessage: "Could not initialize ACP session runtime.", + }); + let ensured: AcpRuntimeHandle; + if (persistedResumeSessionId) { + try { + ensured = await ensureSession(persistedResumeSessionId); + } catch (error) { + const acpError = toAcpRuntimeError({ + error, + fallbackCode: "ACP_SESSION_INIT_FAILED", + fallbackMessage: "Could not initialize ACP session runtime.", + }); + if (acpError.code !== "ACP_SESSION_INIT_FAILED") { + throw acpError; + } + logVerbose( + `acp-manager: resume init failed for ${params.sessionKey}; retrying without persisted ACP session id: ${acpError.message}`, + ); + ensured = await ensureSession(); + } + } else { + ensured = await ensureSession(); + } + const now = Date.now(); const effectiveCwd = normalizeText(ensured.cwd) ?? cwd; const nextRuntimeOptions = normalizeRuntimeOptions({ diff --git a/src/acp/control-plane/manager.test.ts b/src/acp/control-plane/manager.test.ts index 7229e34914d..4f5d316c393 100644 --- a/src/acp/control-plane/manager.test.ts +++ b/src/acp/control-plane/manager.test.ts @@ -432,6 +432,186 @@ describe("AcpSessionManager", () => { expect(runtimeState.ensureSession).toHaveBeenCalledTimes(2); }); + it("passes persisted ACP backend session identity back into ensureSession for configured bindings after restart", async () => { + const runtimeState = createRuntime(); + hoisted.requireAcpRuntimeBackendMock.mockReturnValue({ + id: "acpx", + runtime: runtimeState.runtime, + }); + const sessionKey = "agent:codex:acp:binding:discord:default:deadbeef"; + hoisted.readAcpSessionEntryMock.mockImplementation((paramsUnknown: unknown) => { + const key = (paramsUnknown as { sessionKey?: string }).sessionKey ?? sessionKey; + return { + sessionKey: key, + storeSessionKey: key, + acp: { + ...readySessionMeta(), + runtimeSessionName: key, + identity: { + state: "resolved", + source: "status", + acpxSessionId: "acpx-sid-1", + lastUpdatedAt: Date.now(), + }, + }, + }; + }); + + const manager = new AcpSessionManager(); + await manager.runTurn({ + cfg: baseCfg, + sessionKey, + text: "after restart", + mode: "prompt", + requestId: "r-binding-restart", + }); + + expect(runtimeState.ensureSession).toHaveBeenCalledWith( + expect.objectContaining({ + sessionKey, + agent: "codex", + resumeSessionId: "acpx-sid-1", + }), + ); + }); + + it("does not resume persisted ACP identity for oneshot sessions after restart", async () => { + const runtimeState = createRuntime(); + hoisted.requireAcpRuntimeBackendMock.mockReturnValue({ + id: "acpx", + runtime: runtimeState.runtime, + }); + const sessionKey = "agent:codex:acp:binding:discord:default:oneshot"; + hoisted.readAcpSessionEntryMock.mockImplementation((paramsUnknown: unknown) => { + const key = (paramsUnknown as { sessionKey?: string }).sessionKey ?? sessionKey; + return { + sessionKey: key, + storeSessionKey: key, + acp: { + ...readySessionMeta(), + runtimeSessionName: key, + mode: "oneshot", + identity: { + state: "resolved", + source: "status", + acpxSessionId: "acpx-sid-oneshot", + lastUpdatedAt: Date.now(), + }, + }, + }; + }); + + const manager = new AcpSessionManager(); + await manager.runTurn({ + cfg: baseCfg, + sessionKey, + text: "after restart", + mode: "prompt", + requestId: "r-binding-oneshot", + }); + + expect(runtimeState.ensureSession).toHaveBeenCalledTimes(1); + const ensureInput = runtimeState.ensureSession.mock.calls[0]?.[0] as + | { resumeSessionId?: string; mode?: string } + | undefined; + expect(ensureInput).toMatchObject({ + sessionKey, + agent: "codex", + mode: "oneshot", + }); + expect(ensureInput?.resumeSessionId).toBeUndefined(); + }); + + it("falls back to a fresh ensure when reopening a persisted ACP backend session id fails", async () => { + const runtimeState = createRuntime(); + runtimeState.ensureSession.mockImplementation(async (inputUnknown: unknown) => { + const input = inputUnknown as { + sessionKey: string; + agent: string; + mode: "persistent" | "oneshot"; + resumeSessionId?: string; + }; + if (input.resumeSessionId) { + throw new AcpRuntimeError( + "ACP_SESSION_INIT_FAILED", + "failed to resume persisted ACP session", + ); + } + return { + sessionKey: input.sessionKey, + backend: "acpx", + runtimeSessionName: `${input.sessionKey}:${input.mode}:runtime`, + backendSessionId: "acpx-sid-fresh", + }; + }); + runtimeState.getStatus.mockResolvedValue({ + summary: "status=alive", + backendSessionId: "acpx-sid-fresh", + details: { status: "alive" }, + }); + hoisted.requireAcpRuntimeBackendMock.mockReturnValue({ + id: "acpx", + runtime: runtimeState.runtime, + }); + const sessionKey = "agent:codex:acp:binding:discord:default:retry-fresh"; + let currentMeta: SessionAcpMeta = { + ...readySessionMeta(), + runtimeSessionName: sessionKey, + identity: { + state: "resolved", + source: "status", + acpxSessionId: "acpx-sid-stale", + lastUpdatedAt: Date.now(), + }, + }; + hoisted.readAcpSessionEntryMock.mockImplementation((paramsUnknown: unknown) => { + const key = (paramsUnknown as { sessionKey?: string }).sessionKey ?? sessionKey; + return { + sessionKey: key, + storeSessionKey: key, + acp: currentMeta, + }; + }); + hoisted.upsertAcpSessionMetaMock.mockImplementation(async (paramsUnknown: unknown) => { + const params = paramsUnknown as { + mutate: ( + current: SessionAcpMeta | undefined, + entry: { acp?: SessionAcpMeta } | undefined, + ) => SessionAcpMeta | null | undefined; + }; + const next = params.mutate(currentMeta, { acp: currentMeta }); + if (next) { + currentMeta = next; + } + return { + sessionId: "session-1", + updatedAt: Date.now(), + acp: currentMeta, + }; + }); + + const manager = new AcpSessionManager(); + await manager.runTurn({ + cfg: baseCfg, + sessionKey, + text: "after restart", + mode: "prompt", + requestId: "r-binding-retry-fresh", + }); + + expect(runtimeState.ensureSession).toHaveBeenCalledTimes(2); + expect(runtimeState.ensureSession.mock.calls[0]?.[0]).toMatchObject({ + sessionKey, + agent: "codex", + resumeSessionId: "acpx-sid-stale", + }); + const retryInput = runtimeState.ensureSession.mock.calls[1]?.[0] as + | { resumeSessionId?: string } + | undefined; + expect(retryInput?.resumeSessionId).toBeUndefined(); + expect(currentMeta.identity?.acpxSessionId).toBe("acpx-sid-fresh"); + }); + it("enforces acp.maxConcurrentSessions when opening new runtime handles", async () => { const runtimeState = createRuntime(); hoisted.requireAcpRuntimeBackendMock.mockReturnValue({ diff --git a/src/acp/runtime/session-identity.ts b/src/acp/runtime/session-identity.ts index 066a3cb71e5..1ff808bd28c 100644 --- a/src/acp/runtime/session-identity.ts +++ b/src/acp/runtime/session-identity.ts @@ -71,6 +71,15 @@ export function identityHasStableSessionId(identity: SessionAcpIdentity | undefi return Boolean(identity?.acpxSessionId || identity?.agentSessionId); } +export function resolveRuntimeResumeSessionId( + identity: SessionAcpIdentity | undefined, +): string | undefined { + if (!identity) { + return undefined; + } + return normalizeText(identity.acpxSessionId) ?? normalizeText(identity.agentSessionId); +} + export function isSessionIdentityPending(identity: SessionAcpIdentity | undefined): boolean { if (!identity) { return true;