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.
This commit is contained in:
Mohamed Boudra
2026-08-21 04:39:26 +08:00
committed by GitHub
parent b54c8e493a
commit 3f7188bc1c
3 changed files with 70 additions and 16 deletions
+3 -1
View File
@@ -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 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 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 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 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, `AgentManager`. If initialization, persisted-session resume, or initial history hydration fails,
@@ -1394,16 +1394,55 @@ describe("Codex app-server provider", () => {
appServer.assertNoErrors(); 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 () => { test("closes Codex app-server when an interactive resume fails", async () => {
const appServer = createFakeCodexAppServer({ const appServer = createFakeCodexAppServer({
"thread/resume": () => "thread/resume": () => Promise.reject(new Error("thread resume failed")),
Promise.reject(new Error(archivedThreadErrorMessage("archived-thread-id"))),
}); });
const killSpy = vi.spyOn(appServer.child, "kill"); const killSpy = vi.spyOn(appServer.child, "kill");
const provider = createProviderWithFakeAppServer(appServer); const provider = createProviderWithFakeAppServer(appServer);
await expect(provider.resumeSession(archivedThreadHandle())).rejects.toThrow( await expect(provider.resumeSession(archivedThreadHandle())).rejects.toThrow(
archivedThreadErrorMessage("archived-thread-id"), "thread resume failed",
); );
expect(killSpy).toHaveBeenCalledWith("SIGTERM"); expect(killSpy).toHaveBeenCalledWith("SIGTERM");
@@ -3778,24 +3778,24 @@ export class CodexAppServerAgentSession implements AgentSession {
options: { allowArchivedHistory?: boolean } = {}, options: { allowArchivedHistory?: boolean } = {},
): Promise<void> { ): Promise<void> {
if (!this.client || !this.currentThreadId) return; if (!this.client || !this.currentThreadId) return;
const params: Record<string, unknown> = { 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 { try {
const loaded = toObjectRecord(await this.client.request("thread/loaded/list", {})); const loaded = toObjectRecord(await this.client.request("thread/loaded/list", {}));
const ids = Array.isArray(loaded?.data) ? loaded.data : []; const ids = Array.isArray(loaded?.data) ? loaded.data : [];
if (ids.includes(this.currentThreadId)) { if (ids.includes(this.currentThreadId)) {
return; return;
} }
const params: Record<string, unknown> = { 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); const response = await this.client.request("thread/resume", params);
this.rememberResolvedSandboxPolicy(response); this.rememberResolvedSandboxPolicy(response);
} catch (error) { } catch (error) {
@@ -3811,6 +3811,19 @@ export class CodexAppServerAgentSession implements AgentSession {
); );
return; 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"); this.logger.warn({ error, threadId }, "Failed to resume persisted Codex thread");
throw new Error(`Failed to resume Codex thread ${threadId}: ${message}`, { cause: error }); throw new Error(`Failed to resume Codex thread ${threadId}: ${message}`, { cause: error });
} }