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
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,
@@ -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");
@@ -3778,12 +3778,6 @@ export class CodexAppServerAgentSession implements AgentSession {
options: { allowArchivedHistory?: boolean } = {},
): Promise<void> {
if (!this.client || !this.currentThreadId) return;
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<string, unknown> = { threadId: this.currentThreadId };
const developerInstructions = composeSystemPromptParts(
this.config.systemPrompt,
@@ -3796,6 +3790,12 @@ export class CodexAppServerAgentSession implements AgentSession {
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 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 });
}