Skip to content

Commit a373468

Browse files
keshavbotagentsteipete
authored andcommitted
fix: recover missing Codex bound threads
1 parent 761e668 commit a373468

3 files changed

Lines changed: 225 additions & 19 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,7 @@ Docs: https://docs.openclaw.ai
219219
- Google Meet: make Twilio setup status require an enabled `voice-call` plugin entry instead of treating a missing entry as ready. Thanks @vincentkoc.
220220
- Telegram: render shared interactive reply buttons in reply delivery so plugin approval messages show inline keyboards. (#76238) Thanks @keshavbotagent.
221221
- Cron/sessions: keep cron metadata rows without an on-disk transcript non-resumable until a transcript exists, so doctor and `sessions cleanup --fix-missing` no longer report or prune pre-transcript cron rows as broken sessions. Refs #77011.
222+
- OpenAI Codex: recreate missing bound app-server threads once when a stale `/codex bind` sidecar survives a restart, preserving the selected auth profile and turn overrides before retrying the inbound turn. (#76936) Thanks @keshavbotagent.
222223
- Agents/cli-runner: drop a saved `claude-cli` resume sessionId at preparation time when its on-disk transcript no longer exists in `~/.claude/projects/`, so a stale binding from a half-installed `update.run` cannot trap follow-up runs (auto-reply / Telegram direct) in a `claude --resume` timeout loop; the run starts fresh and the new sessionId is written back through the existing post-run flow. (#77030; refs #77011) Thanks @openperf.
223224
- Release validation: install the cross-OS TypeScript harness through Windows-safe Node/npm shims so native Windows package checks reach the OpenClaw smoke suites instead of exiting before artifact capture. Thanks @vincentkoc.
224225
- Release validation: let Windows packaged-upgrade checks continue after the shipped 2026.5.2 updater hits its native-module swap cleanup fallback, verifying the fallback-installed candidate through package metadata and downstream smoke instead of crashing on the immediate update-status probe. Thanks @vincentkoc.

extensions/codex/src/conversation-binding.test.ts

Lines changed: 143 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,15 +48,20 @@ describe("codex conversation binding", () => {
4848
});
4949

5050
beforeEach(() => {
51-
agentRuntimeMocks.ensureAuthProfileStore.mockReturnValue({ version: 1, profiles: {} });
51+
agentRuntimeMocks.ensureAuthProfileStore.mockReturnValue({
52+
version: 1,
53+
profiles: {},
54+
});
5255
agentRuntimeMocks.resolveAuthProfileOrder.mockReturnValue([]);
5356
agentRuntimeMocks.resolveOpenClawAgentDir.mockReturnValue("/agent");
5457
agentRuntimeMocks.resolveProviderIdForAuth.mockImplementation((provider: string) => provider);
5558
});
5659

5760
it("uses the default Codex auth profile and omits the public OpenAI provider for new binds", async () => {
5861
const sessionFile = path.join(tempDir, "session.jsonl");
59-
const config = { auth: { order: { "openai-codex": ["openai-codex:default"] } } };
62+
const config = {
63+
auth: { order: { "openai-codex": ["openai-codex:default"] } },
64+
};
6065
const requests: Array<{ method: string; params: Record<string, unknown> }> = [];
6166
agentRuntimeMocks.ensureAuthProfileStore.mockReturnValue({
6267
version: 1,
@@ -220,6 +225,142 @@ describe("codex conversation binding", () => {
220225
expect(result).toEqual({ handled: true });
221226
});
222227

228+
it("recreates a missing bound thread and preserves auth plus turn overrides", async () => {
229+
const sessionFile = path.join(tempDir, "session.jsonl");
230+
agentRuntimeMocks.ensureAuthProfileStore.mockReturnValue({
231+
version: 1,
232+
profiles: {
233+
work: {
234+
type: "oauth",
235+
provider: "openai-codex",
236+
access: "access-token",
237+
},
238+
},
239+
});
240+
await fs.writeFile(
241+
`${sessionFile}.codex-app-server.json`,
242+
JSON.stringify({
243+
schemaVersion: 1,
244+
threadId: "thread-old",
245+
cwd: tempDir,
246+
authProfileId: "work",
247+
model: "gpt-5.4-mini",
248+
modelProvider: "openai",
249+
approvalPolicy: "on-request",
250+
sandbox: "workspace-write",
251+
serviceTier: "fast",
252+
}),
253+
);
254+
const requests: Array<{ method: string; params: Record<string, unknown> }> = [];
255+
const notificationHandlers: Array<(notification: Record<string, unknown>) => void> = [];
256+
sharedClientMocks.getSharedCodexAppServerClient.mockResolvedValue({
257+
request: vi.fn(async (method: string, requestParams: Record<string, unknown>) => {
258+
requests.push({ method, params: requestParams });
259+
if (method === "turn/start" && requestParams.threadId === "thread-old") {
260+
throw new Error("thread not found: thread-old");
261+
}
262+
if (method === "thread/start") {
263+
return {
264+
thread: { id: "thread-new", cwd: tempDir },
265+
model: "gpt-5.4-mini",
266+
};
267+
}
268+
if (method === "turn/start" && requestParams.threadId === "thread-new") {
269+
setImmediate(() => {
270+
for (const handler of notificationHandlers) {
271+
handler({
272+
method: "turn/completed",
273+
params: {
274+
threadId: "thread-new",
275+
turn: {
276+
id: "turn-new",
277+
status: "completed",
278+
items: [
279+
{
280+
id: "assistant-1",
281+
type: "agentMessage",
282+
text: "Recovered",
283+
},
284+
],
285+
},
286+
},
287+
});
288+
}
289+
});
290+
return { turn: { id: "turn-new" } };
291+
}
292+
throw new Error(`unexpected method: ${method}`);
293+
}),
294+
addNotificationHandler: vi.fn((handler) => {
295+
notificationHandlers.push(handler);
296+
return () => undefined;
297+
}),
298+
addRequestHandler: vi.fn(() => () => undefined),
299+
});
300+
301+
const result = await handleCodexConversationInboundClaim(
302+
{
303+
content: "hi again",
304+
bodyForAgent: "hi again",
305+
channel: "telegram",
306+
isGroup: false,
307+
commandAuthorized: true,
308+
},
309+
{
310+
channelId: "telegram",
311+
pluginBinding: {
312+
bindingId: "binding-1",
313+
pluginId: "codex",
314+
pluginRoot: tempDir,
315+
channel: "telegram",
316+
accountId: "default",
317+
conversationId: "5185575566",
318+
boundAt: Date.now(),
319+
data: {
320+
kind: "codex-app-server-session",
321+
version: 1,
322+
sessionFile,
323+
workspaceDir: tempDir,
324+
},
325+
},
326+
},
327+
{ timeoutMs: 500 },
328+
);
329+
330+
expect(result).toEqual({ handled: true, reply: { text: "Recovered" } });
331+
expect(requests.map((request) => request.method)).toEqual([
332+
"turn/start",
333+
"thread/start",
334+
"turn/start",
335+
]);
336+
expect(sharedClientMocks.getSharedCodexAppServerClient).toHaveBeenCalledWith(
337+
expect.objectContaining({ authProfileId: "work" }),
338+
);
339+
expect(requests[1]?.params).toMatchObject({
340+
model: "gpt-5.4-mini",
341+
approvalPolicy: "on-request",
342+
sandbox: "workspace-write",
343+
serviceTier: "fast",
344+
});
345+
expect(requests[1]?.params).not.toHaveProperty("modelProvider");
346+
expect(requests[2]?.params).toMatchObject({
347+
threadId: "thread-new",
348+
approvalPolicy: "on-request",
349+
serviceTier: "fast",
350+
});
351+
const savedBinding = JSON.parse(
352+
await fs.readFile(`${sessionFile}.codex-app-server.json`, "utf8"),
353+
);
354+
expect(savedBinding).toMatchObject({
355+
threadId: "thread-new",
356+
authProfileId: "work",
357+
approvalPolicy: "on-request",
358+
sandbox: "workspace-write",
359+
serviceTier: "fast",
360+
});
361+
expect(savedBinding).not.toHaveProperty("modelProvider");
362+
});
363+
223364
it("returns a clean failure reply when app-server turn start rejects", async () => {
224365
const sessionFile = path.join(tempDir, "session.jsonl");
225366
await fs.writeFile(

extensions/codex/src/conversation-binding.ts

Lines changed: 81 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,11 @@ import { CODEX_CONTROL_METHODS } from "./app-server/capabilities.js";
1010
import {
1111
codexSandboxPolicyForTurn,
1212
resolveCodexAppServerRuntimeOptions,
13+
type CodexAppServerApprovalPolicy,
14+
type CodexAppServerSandboxMode,
1315
} from "./app-server/config.js";
1416
import {
17+
type CodexServiceTier,
1518
type CodexThreadResumeResponse,
1619
type CodexThreadStartResponse,
1720
type CodexTurnStartResponse,
@@ -59,6 +62,9 @@ type CodexConversationStartParams = {
5962
model?: string;
6063
modelProvider?: string;
6164
authProfileId?: string;
65+
approvalPolicy?: CodexAppServerApprovalPolicy;
66+
sandbox?: CodexAppServerSandboxMode;
67+
serviceTier?: CodexServiceTier;
6268
};
6369

6470
type BoundTurnResult = {
@@ -100,6 +106,9 @@ export async function startCodexConversationThread(
100106
model: params.model,
101107
modelProvider: params.modelProvider,
102108
authProfileId,
109+
approvalPolicy: params.approvalPolicy,
110+
sandbox: params.sandbox,
111+
serviceTier: params.serviceTier,
103112
config: params.config,
104113
});
105114
} else {
@@ -110,6 +119,9 @@ export async function startCodexConversationThread(
110119
model: params.model,
111120
modelProvider: params.modelProvider,
112121
authProfileId,
122+
approvalPolicy: params.approvalPolicy,
123+
sandbox: params.sandbox,
124+
serviceTier: params.serviceTier,
113125
config: params.config,
114126
});
115127
}
@@ -137,7 +149,7 @@ export async function handleCodexConversationInboundClaim(
137149
}
138150
try {
139151
const result = await enqueueBoundTurn(data.sessionFile, () =>
140-
runBoundTurn({
152+
runBoundTurnWithMissingThreadRecovery({
141153
data,
142154
prompt,
143155
event,
@@ -177,9 +189,14 @@ async function attachExistingThread(params: {
177189
model?: string;
178190
modelProvider?: string;
179191
authProfileId?: string;
192+
approvalPolicy?: CodexAppServerApprovalPolicy;
193+
sandbox?: CodexAppServerSandboxMode;
194+
serviceTier?: CodexServiceTier;
180195
config?: CodexAppServerAuthProfileLookup["config"];
181196
}): Promise<void> {
182-
const runtime = resolveCodexAppServerRuntimeOptions({ pluginConfig: params.pluginConfig });
197+
const runtime = resolveCodexAppServerRuntimeOptions({
198+
pluginConfig: params.pluginConfig,
199+
});
183200
const modelProvider = resolveThreadRequestModelProvider({
184201
authProfileId: params.authProfileId,
185202
modelProvider: params.modelProvider,
@@ -196,10 +213,12 @@ async function attachExistingThread(params: {
196213
threadId: params.threadId,
197214
...(params.model ? { model: params.model } : {}),
198215
...(modelProvider ? { modelProvider } : {}),
199-
approvalPolicy: runtime.approvalPolicy,
216+
approvalPolicy: params.approvalPolicy ?? runtime.approvalPolicy,
200217
approvalsReviewer: runtime.approvalsReviewer,
201-
sandbox: runtime.sandbox,
202-
...(runtime.serviceTier ? { serviceTier: runtime.serviceTier } : {}),
218+
sandbox: params.sandbox ?? runtime.sandbox,
219+
...((params.serviceTier ?? runtime.serviceTier)
220+
? { serviceTier: params.serviceTier ?? runtime.serviceTier }
221+
: {}),
203222
persistExtendedHistory: true,
204223
},
205224
{ timeoutMs: runtime.requestTimeoutMs },
@@ -217,9 +236,9 @@ async function attachExistingThread(params: {
217236
authProfileId: params.authProfileId,
218237
modelProvider: response.modelProvider ?? params.modelProvider,
219238
}),
220-
approvalPolicy: runtime.approvalPolicy,
221-
sandbox: runtime.sandbox,
222-
serviceTier: runtime.serviceTier,
239+
approvalPolicy: params.approvalPolicy ?? runtime.approvalPolicy,
240+
sandbox: params.sandbox ?? runtime.sandbox,
241+
serviceTier: params.serviceTier ?? runtime.serviceTier,
223242
},
224243
{
225244
config: params.config,
@@ -234,9 +253,14 @@ async function createThread(params: {
234253
model?: string;
235254
modelProvider?: string;
236255
authProfileId?: string;
256+
approvalPolicy?: CodexAppServerApprovalPolicy;
257+
sandbox?: CodexAppServerSandboxMode;
258+
serviceTier?: CodexServiceTier;
237259
config?: CodexAppServerAuthProfileLookup["config"];
238260
}): Promise<void> {
239-
const runtime = resolveCodexAppServerRuntimeOptions({ pluginConfig: params.pluginConfig });
261+
const runtime = resolveCodexAppServerRuntimeOptions({
262+
pluginConfig: params.pluginConfig,
263+
});
240264
const modelProvider = resolveThreadRequestModelProvider({
241265
authProfileId: params.authProfileId,
242266
modelProvider: params.modelProvider,
@@ -253,10 +277,12 @@ async function createThread(params: {
253277
cwd: params.workspaceDir,
254278
...(params.model ? { model: params.model } : {}),
255279
...(modelProvider ? { modelProvider } : {}),
256-
approvalPolicy: runtime.approvalPolicy,
280+
approvalPolicy: params.approvalPolicy ?? runtime.approvalPolicy,
257281
approvalsReviewer: runtime.approvalsReviewer,
258-
sandbox: runtime.sandbox,
259-
...(runtime.serviceTier ? { serviceTier: runtime.serviceTier } : {}),
282+
sandbox: params.sandbox ?? runtime.sandbox,
283+
...((params.serviceTier ?? runtime.serviceTier)
284+
? { serviceTier: params.serviceTier ?? runtime.serviceTier }
285+
: {}),
260286
developerInstructions:
261287
"This Codex thread is bound to an OpenClaw conversation. Answer normally; OpenClaw will deliver your final response back to the conversation.",
262288
experimentalRawEvents: true,
@@ -276,9 +302,9 @@ async function createThread(params: {
276302
authProfileId: params.authProfileId,
277303
modelProvider: response.modelProvider ?? params.modelProvider,
278304
}),
279-
approvalPolicy: runtime.approvalPolicy,
280-
sandbox: runtime.sandbox,
281-
serviceTier: runtime.serviceTier,
305+
approvalPolicy: params.approvalPolicy ?? runtime.approvalPolicy,
306+
sandbox: params.sandbox ?? runtime.sandbox,
307+
serviceTier: params.serviceTier ?? runtime.serviceTier,
282308
},
283309
{
284310
config: params.config,
@@ -293,7 +319,9 @@ async function runBoundTurn(params: {
293319
pluginConfig?: unknown;
294320
timeoutMs?: number;
295321
}): Promise<BoundTurnResult> {
296-
const runtime = resolveCodexAppServerRuntimeOptions({ pluginConfig: params.pluginConfig });
322+
const runtime = resolveCodexAppServerRuntimeOptions({
323+
pluginConfig: params.pluginConfig,
324+
});
297325
const binding = await readCodexAppServerBinding(params.data.sessionFile);
298326
const threadId = binding?.threadId;
299327
if (!threadId) {
@@ -350,7 +378,10 @@ async function runBoundTurn(params: {
350378
"turn/start",
351379
{
352380
threadId,
353-
input: buildCodexConversationTurnInput({ prompt: params.prompt, event: params.event }),
381+
input: buildCodexConversationTurnInput({
382+
prompt: params.prompt,
383+
event: params.event,
384+
}),
354385
cwd: binding.cwd || params.data.workspaceDir,
355386
approvalPolicy: binding.approvalPolicy ?? runtime.approvalPolicy,
356387
approvalsReviewer: runtime.approvalsReviewer,
@@ -389,6 +420,39 @@ async function runBoundTurn(params: {
389420
}
390421
}
391422

423+
async function runBoundTurnWithMissingThreadRecovery(params: {
424+
data: CodexConversationBindingData;
425+
prompt: string;
426+
event: PluginHookInboundClaimEvent;
427+
pluginConfig?: unknown;
428+
timeoutMs?: number;
429+
}): Promise<BoundTurnResult> {
430+
try {
431+
return await runBoundTurn(params);
432+
} catch (error) {
433+
if (!isCodexThreadNotFoundError(error)) {
434+
throw error;
435+
}
436+
const binding = await readCodexAppServerBinding(params.data.sessionFile);
437+
await startCodexConversationThread({
438+
pluginConfig: params.pluginConfig,
439+
sessionFile: params.data.sessionFile,
440+
workspaceDir: binding?.cwd || params.data.workspaceDir,
441+
model: binding?.model,
442+
modelProvider: binding?.modelProvider,
443+
authProfileId: binding?.authProfileId,
444+
approvalPolicy: binding?.approvalPolicy,
445+
sandbox: binding?.sandbox,
446+
serviceTier: binding?.serviceTier,
447+
});
448+
return await runBoundTurn(params);
449+
}
450+
}
451+
452+
function isCodexThreadNotFoundError(error: unknown): boolean {
453+
return /\bthread not found:/iu.test(formatErrorMessage(error));
454+
}
455+
392456
function enqueueBoundTurn<T>(key: string, run: () => Promise<T>): Promise<T> {
393457
const state = getGlobalState();
394458
const previous = state.queues.get(key) ?? Promise.resolve();

0 commit comments

Comments
 (0)