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