From 3f7188bc1c6e691bdd79f6d91234e3ce1d957566 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 20 Aug 2026 22:39:26 +0200 Subject: [PATCH] fix(codex): restore externally archived active threads (#3334) Codex threads can be archived outside Paseo while their Paseo agent remains active. Repair that provider-state drift during interactive resume while preserving the existing read-only history path for Paseo-archived agents. --- docs/agent-lifecycle.md | 4 +- .../providers/codex-app-server-agent.test.ts | 45 +++++++++++++++++-- .../agent/providers/codex-app-server-agent.ts | 37 ++++++++++----- 3 files changed, 70 insertions(+), 16 deletions(-) diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index db9762cbe..7e3d0913c 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -103,7 +103,9 @@ recoverable from History. Opening one pins its tab and renders the archived-agen timeline catch-up may load provider history with a runtime-only `history` resume purpose, which must leave both Paseo's `archivedAt` and the provider's native archive state unchanged. **Unarchive** remains the only transition back to an interactive runtime: it runs the provider's native unarchive hook -(including Codex `thread/unarchive`) before the normal agent resume and timeline hydration flow. +(including Codex `thread/unarchive`) before the normal agent resume and timeline hydration flow. A +provider session can be archived outside Paseo while its Paseo agent remains active. Interactive +resume repairs that drift through the provider's native unarchive hook; history resume does not. Provider session connection owns every process it spawns until the session is registered with `AgentManager`. If initialization, persisted-session resume, or initial history hydration fails, diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index d9093a885..2f8c75413 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -1394,16 +1394,55 @@ describe("Codex app-server provider", () => { appServer.assertNoErrors(); }); + test("unarchives Codex when an active Paseo agent resumes an archived thread", async () => { + const threadRequests: string[] = []; + let resumeAttempts = 0; + const appServer = createFakeCodexAppServer({ + "thread/loaded/list": () => { + threadRequests.push("thread/loaded/list"); + return { data: [] }; + }, + "thread/resume": () => { + threadRequests.push("thread/resume"); + resumeAttempts += 1; + if (resumeAttempts === 1) { + return Promise.reject(new Error(archivedThreadErrorMessage("archived-thread-id"))); + } + return { thread: { id: "archived-thread-id" } }; + }, + "thread/unarchive": () => { + threadRequests.push("thread/unarchive"); + return { thread: { id: "archived-thread-id" } }; + }, + "thread/read": () => { + threadRequests.push("thread/read"); + return { thread: { turns: [] } }; + }, + }); + const provider = createProviderWithFakeAppServer(appServer); + + const session = await provider.resumeSession(archivedThreadHandle()); + + expect(threadRequests).toEqual([ + "thread/loaded/list", + "thread/resume", + "thread/unarchive", + "thread/resume", + "thread/read", + ]); + await session.close(); + appServer.assertNoErrors(); + }); + test("closes Codex app-server when an interactive resume fails", async () => { const appServer = createFakeCodexAppServer({ - "thread/resume": () => - Promise.reject(new Error(archivedThreadErrorMessage("archived-thread-id"))), + "thread/resume": () => Promise.reject(new Error("thread resume failed")), }); const killSpy = vi.spyOn(appServer.child, "kill"); const provider = createProviderWithFakeAppServer(appServer); await expect(provider.resumeSession(archivedThreadHandle())).rejects.toThrow( - archivedThreadErrorMessage("archived-thread-id"), + "thread resume failed", ); expect(killSpy).toHaveBeenCalledWith("SIGTERM"); diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index 1019cde52..859318ffe 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -3778,24 +3778,24 @@ export class CodexAppServerAgentSession implements AgentSession { options: { allowArchivedHistory?: boolean } = {}, ): Promise { if (!this.client || !this.currentThreadId) return; + const params: Record = { threadId: this.currentThreadId }; + const developerInstructions = composeSystemPromptParts( + this.config.systemPrompt, + this.config.daemonAppendSystemPrompt, + ); + if (developerInstructions) { + params.developerInstructions = developerInstructions; + } + const codexConfig = this.buildCodexInnerConfig(); + if (codexConfig) { + params.config = codexConfig; + } try { const loaded = toObjectRecord(await this.client.request("thread/loaded/list", {})); const ids = Array.isArray(loaded?.data) ? loaded.data : []; if (ids.includes(this.currentThreadId)) { return; } - const params: Record = { threadId: this.currentThreadId }; - const developerInstructions = composeSystemPromptParts( - this.config.systemPrompt, - this.config.daemonAppendSystemPrompt, - ); - if (developerInstructions) { - params.developerInstructions = developerInstructions; - } - const codexConfig = this.buildCodexInnerConfig(); - if (codexConfig) { - params.config = codexConfig; - } const response = await this.client.request("thread/resume", params); this.rememberResolvedSandboxPolicy(response); } catch (error) { @@ -3811,6 +3811,19 @@ export class CodexAppServerAgentSession implements AgentSession { ); return; } + if (isArchivedCodexThreadResumeError(error, threadId)) { + try { + await this.client.request("thread/unarchive", { threadId }); + } catch (unarchiveError) { + if (!isCodexAlreadyUnarchivedError(unarchiveError, threadId)) { + throw unarchiveError; + } + } + const response = await this.client.request("thread/resume", params); + this.rememberResolvedSandboxPolicy(response); + this.logger.info({ threadId }, "Unarchived Codex thread to restore active Paseo agent"); + return; + } this.logger.warn({ error, threadId }, "Failed to resume persisted Codex thread"); throw new Error(`Failed to resume Codex thread ${threadId}: ${message}`, { cause: error }); }