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:
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user