Skip to content

Commit bd9c2bd

Browse files
xuiocodex
andcommitted
Prevent foreground Codex task client timeouts
Co-Authored-By: OpenAI Codex <noreply@openai.com>
1 parent 3366afc commit bd9c2bd

3 files changed

Lines changed: 238 additions & 53 deletions

File tree

dist/index.js

Lines changed: 99 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -26470,6 +26470,7 @@ var usageGuide = [
2647026470
"- Keep sandbox read-only unless the user explicitly asks for a different sandbox.",
2647126471
"- If the user explicitly asks for non-sandbox/full local capabilities, set full_access true. This maps to Codex's --dangerously-bypass-approvals-and-sandbox flag and allows DNS/network plus unrestricted file and git writes.",
2647226472
"- Approvals are non-interactive; do not expect Codex to ask permission.",
26473+
`- Foreground codex_task calls are also capped to ${defaultBlockingWaitTimeoutMs}ms by default before they hand back a live session_id. If completed is false, the Codex task is still running; use codex_followup mode wait, steer, or cancel.`,
2647326474
'- If codex_followup mode wait returns completed false with timeoutReason "wait_timeout", the session is still running unless its status says otherwise.',
2647426475
`- Blocking wait tools are capped to ${defaultBlockingWaitTimeoutMs}ms by default so Claude stays responsive. If a wait returns completed false, call codex_followup mode wait or codex_wait_any again, or read codex://sessions/{session_id}.`,
2647526476
"- Use codex_wait_any after launching several background Codex tasks to harvest whichever one finishes first without busy-polling.",
@@ -27297,6 +27298,28 @@ function codexLiveProgressMessage(sessionId, fallback) {
2729727298
const activeStatus = session?.activeTurn?.status ?? session?.status;
2729827299
return activeStatus ? `Codex session ${sessionId} ${activeStatus}` : fallback;
2729927300
}
27301+
function foregroundTaskStillRunningPayload(args, session, turn, waitTimeout, timeoutReason) {
27302+
const progressPayload = sessionProgressPayload(session);
27303+
const partial2 = sessionPartialMessage(session);
27304+
return {
27305+
ok: true,
27306+
completed: false,
27307+
status: "running",
27308+
summary: "Codex task is still running.",
27309+
result: partial2 || `Codex task "${args.description}" is still running.`,
27310+
session_id: session.id,
27311+
turn,
27312+
last_milestone_seq: session.lastMilestoneSeq,
27313+
elapsed_ms: progressPayload.elapsed_ms,
27314+
...waitTimeoutFields(waitTimeout),
27315+
timeoutReason,
27316+
hint: "Use codex_followup mode wait to collect the result, mode steer to redirect the running task, or mode cancel to stop it.",
27317+
diagnostics: args.advanced?.include_diagnostics ? {
27318+
session,
27319+
...progressPayload
27320+
} : void 0
27321+
};
27322+
}
2730027323
function toRunOptions(args) {
2730127324
return {
2730227325
prompt: args.prompt,
@@ -27882,49 +27905,96 @@ registerTool(
2788227905
await progress.send(`Starting Codex task: ${args.description}`);
2788327906
if (args.background || args.advanced?.wait_for_completion === false) {
2788427907
throwIfRequestAborted(extra);
27885-
const { session: session2, turn } = sessionManager.startAsync(runOptions, { sessionName: args.session_name });
27908+
const { session, turn: turn2 } = sessionManager.startAsync(runOptions, { sessionName: args.session_name });
2788627909
await progress.flush();
27887-
const compactSession2 = compactSessionSnapshotForMcp(session2);
27910+
const compactSession = compactSessionSnapshotForMcp(session);
2788827911
const payload = {
2788927912
ok: true,
2789027913
status: "running",
2789127914
summary: `Started Codex task: ${args.description}`,
27892-
result: `Codex task started in the background. Session: ${session2.id}`,
27893-
session_id: session2.id,
27894-
turn,
27915+
result: `Codex task started in the background. Session: ${session.id}`,
27916+
session_id: session.id,
27917+
turn: turn2,
2789527918
hint: "Use codex_wait_any for parallel background tasks, or codex_followup mode wait, steer, or cancel with this session_id."
2789627919
};
2789727920
if (args.advanced?.include_diagnostics) {
2789827921
payload.diagnostics = {
27899-
session: compactSession2,
27900-
...sessionProgressPayload(compactSession2)
27922+
session: compactSession,
27923+
...sessionProgressPayload(compactSession)
2790127924
};
2790227925
}
2790327926
return nativeTextResult(payload);
2790427927
}
27905-
const { session, result } = await withProgressHeartbeat(
27906-
progress,
27907-
`Still running Codex task: ${args.description}`,
27908-
() => sessionManager.start(withRequestAbort(runOptions, extra), {
27909-
sessionName: args.session_name,
27910-
onMilestone: (milestone) => {
27911-
const message = formatMilestoneProgress(milestone);
27912-
if (message) void progress.send(message);
27913-
}
27914-
})
27915-
);
27916-
await reportAgentResult(progress, result);
27917-
await progress.flush();
27918-
const compactSession = compactSessionSnapshotForMcp(session);
27919-
return nativeAgentResponse(result, {
27920-
description: args.description,
27921-
prompt: args.prompt,
27922-
tool: "codex_task",
27923-
session: compactSession,
27924-
turn: compactSession.recentTurns?.at(-1),
27925-
includeDiagnostics: Boolean(args.advanced?.include_diagnostics),
27926-
includeSessionId: Boolean(args.keep_session)
27928+
const waitTimeout = capBlockingWaitTimeout(void 0);
27929+
const { session: startedSession, turn } = sessionManager.startAsync(runOptions, {
27930+
sessionName: args.session_name
27931+
});
27932+
const abortHandler = () => {
27933+
logger.warn("codex_task.foreground_request_cancelled", {
27934+
sessionId: startedSession.id,
27935+
turnId: turn.id
27936+
});
27937+
sessionManager.cancel(startedSession.id, "MCP request was cancelled by the client.");
27938+
};
27939+
extra?.signal?.addEventListener("abort", abortHandler, { once: true });
27940+
const unsubscribeMilestones = sessionManager.subscribeMilestones(startedSession.id, (milestone) => {
27941+
const message = formatMilestoneProgress(milestone);
27942+
if (message) void progress.send(message);
2792727943
});
27944+
try {
27945+
if (extra?.signal?.aborted) abortHandler();
27946+
const waited = await withProgressHeartbeat(
27947+
progress,
27948+
() => codexLiveProgressMessage(startedSession.id, `Still running Codex task: ${args.description}`),
27949+
() => sessionManager.wait(startedSession.id, waitTimeout.effectiveMs, turn.id, extra?.signal)
27950+
);
27951+
await progress.flush();
27952+
if (waited.error || !waited.session) {
27953+
return nativeErrorResult(new Error(waited.error ?? "Codex session was not found."), "codex_task");
27954+
}
27955+
const compactSession = compactSessionSnapshotForMcp(waited.session);
27956+
if (waited.completed && !waited.result) {
27957+
const turnError = waited.turn?.error ?? compactSession.error ?? `Codex session turn did not produce a result: ${turn.id}`;
27958+
return nativeErrorResult(new Error(turnError), "codex_task");
27959+
}
27960+
if (!waited.completed) {
27961+
logger.warn("codex_task.foreground_wait_timeout", {
27962+
sessionId: startedSession.id,
27963+
turnId: turn.id,
27964+
timeoutReason: waited.timeoutReason,
27965+
requestedMs: waitTimeout.requestedMs,
27966+
effectiveMs: waitTimeout.effectiveMs
27967+
});
27968+
return nativeTextResult(
27969+
foregroundTaskStillRunningPayload(
27970+
args,
27971+
compactSession,
27972+
waited.turn ?? turn,
27973+
waitTimeout,
27974+
waited.timeoutReason ?? "wait_timeout"
27975+
),
27976+
waited.timeoutReason === "wait_cancelled"
27977+
);
27978+
}
27979+
const result = waited.result;
27980+
if (!result) {
27981+
return nativeErrorResult(new Error(`Codex session turn did not produce a result: ${turn.id}`), "codex_task");
27982+
}
27983+
await reportAgentResult(progress, result);
27984+
await progress.flush();
27985+
return nativeAgentResponse(result, {
27986+
description: args.description,
27987+
prompt: args.prompt,
27988+
tool: "codex_task",
27989+
session: compactSession,
27990+
turn: waited.turn ?? compactSession.recentTurns?.at(-1),
27991+
includeDiagnostics: Boolean(args.advanced?.include_diagnostics),
27992+
includeSessionId: Boolean(args.keep_session)
27993+
});
27994+
} finally {
27995+
unsubscribeMilestones();
27996+
extra?.signal?.removeEventListener("abort", abortHandler);
27997+
}
2792827998
} catch (error2) {
2792927999
await progress.flush();
2793028000
logger.error("codex_task.failed", { error: errorForLog(error2) });

src/index.ts

Lines changed: 105 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ const usageGuide = [
9292
"- Keep sandbox read-only unless the user explicitly asks for a different sandbox.",
9393
"- If the user explicitly asks for non-sandbox/full local capabilities, set full_access true. This maps to Codex's --dangerously-bypass-approvals-and-sandbox flag and allows DNS/network plus unrestricted file and git writes.",
9494
"- Approvals are non-interactive; do not expect Codex to ask permission.",
95+
`- Foreground codex_task calls are also capped to ${defaultBlockingWaitTimeoutMs}ms by default before they hand back a live session_id. If completed is false, the Codex task is still running; use codex_followup mode wait, steer, or cancel.`,
9596
"- If codex_followup mode wait returns completed false with timeoutReason \"wait_timeout\", the session is still running unless its status says otherwise.",
9697
`- Blocking wait tools are capped to ${defaultBlockingWaitTimeoutMs}ms by default so Claude stays responsive. If a wait returns completed false, call codex_followup mode wait or codex_wait_any again, or read codex://sessions/{session_id}.`,
9798
"- Use codex_wait_any after launching several background Codex tasks to harvest whichever one finishes first without busy-polling.",
@@ -1326,6 +1327,38 @@ function codexLiveProgressMessage(sessionId: string, fallback: string): string {
13261327
return activeStatus ? `Codex session ${sessionId} ${activeStatus}` : fallback;
13271328
}
13281329

1330+
function foregroundTaskStillRunningPayload(
1331+
args: NativeTaskV3Input,
1332+
session: ReturnType<typeof compactSessionSnapshotForMcp>,
1333+
turn: unknown,
1334+
waitTimeout: BlockingWaitTimeout,
1335+
timeoutReason: string,
1336+
): Record<string, unknown> {
1337+
const progressPayload = sessionProgressPayload(session);
1338+
const partial = sessionPartialMessage(session);
1339+
return {
1340+
ok: true,
1341+
completed: false,
1342+
status: "running",
1343+
summary: "Codex task is still running.",
1344+
result: partial || `Codex task "${args.description}" is still running.`,
1345+
session_id: (session as { id?: string }).id,
1346+
turn,
1347+
last_milestone_seq: (session as { lastMilestoneSeq?: number }).lastMilestoneSeq,
1348+
elapsed_ms: progressPayload.elapsed_ms,
1349+
...waitTimeoutFields(waitTimeout),
1350+
timeoutReason,
1351+
hint:
1352+
"Use codex_followup mode wait to collect the result, mode steer to redirect the running task, or mode cancel to stop it.",
1353+
diagnostics: args.advanced?.include_diagnostics
1354+
? {
1355+
session,
1356+
...progressPayload,
1357+
}
1358+
: undefined,
1359+
};
1360+
}
1361+
13291362
function toRunOptions(args: {
13301363
prompt: string;
13311364
name?: string;
@@ -2182,30 +2215,79 @@ registerTool(
21822215
}
21832216
return nativeTextResult(payload);
21842217
}
2185-
const { session, result } = await withProgressHeartbeat(
2186-
progress,
2187-
`Still running Codex task: ${args.description}`,
2188-
() =>
2189-
sessionManager.start(withRequestAbort(runOptions, extra), {
2190-
sessionName: args.session_name,
2191-
onMilestone: (milestone) => {
2192-
const message = formatMilestoneProgress(milestone);
2193-
if (message) void progress.send(message);
2194-
},
2195-
}),
2196-
);
2197-
await reportAgentResult(progress, result);
2198-
await progress.flush();
2199-
const compactSession = compactSessionSnapshotForMcp(session);
2200-
return nativeAgentResponse(result, {
2201-
description: args.description,
2202-
prompt: args.prompt,
2203-
tool: "codex_task",
2204-
session: compactSession,
2205-
turn: compactSession.recentTurns?.at(-1),
2206-
includeDiagnostics: Boolean(args.advanced?.include_diagnostics),
2207-
includeSessionId: Boolean(args.keep_session),
2218+
const waitTimeout = capBlockingWaitTimeout(undefined);
2219+
const { session: startedSession, turn } = sessionManager.startAsync(runOptions, {
2220+
sessionName: args.session_name,
2221+
});
2222+
const abortHandler = () => {
2223+
logger.warn("codex_task.foreground_request_cancelled", {
2224+
sessionId: startedSession.id,
2225+
turnId: turn.id,
2226+
});
2227+
sessionManager.cancel(startedSession.id, "MCP request was cancelled by the client.");
2228+
};
2229+
extra?.signal?.addEventListener("abort", abortHandler, { once: true });
2230+
const unsubscribeMilestones = sessionManager.subscribeMilestones(startedSession.id, (milestone) => {
2231+
const message = formatMilestoneProgress(milestone);
2232+
if (message) void progress.send(message);
22082233
});
2234+
try {
2235+
if (extra?.signal?.aborted) abortHandler();
2236+
const waited = await withProgressHeartbeat(
2237+
progress,
2238+
() => codexLiveProgressMessage(startedSession.id, `Still running Codex task: ${args.description}`),
2239+
() => sessionManager.wait(startedSession.id, waitTimeout.effectiveMs, turn.id, extra?.signal),
2240+
);
2241+
await progress.flush();
2242+
if (waited.error || !waited.session) {
2243+
return nativeErrorResult(new Error(waited.error ?? "Codex session was not found."), "codex_task");
2244+
}
2245+
const compactSession = compactSessionSnapshotForMcp(waited.session);
2246+
if (waited.completed && !waited.result) {
2247+
const turnError =
2248+
waited.turn?.error ??
2249+
(compactSession as { error?: string }).error ??
2250+
`Codex session turn did not produce a result: ${turn.id}`;
2251+
return nativeErrorResult(new Error(turnError), "codex_task");
2252+
}
2253+
if (!waited.completed) {
2254+
logger.warn("codex_task.foreground_wait_timeout", {
2255+
sessionId: startedSession.id,
2256+
turnId: turn.id,
2257+
timeoutReason: waited.timeoutReason,
2258+
requestedMs: waitTimeout.requestedMs,
2259+
effectiveMs: waitTimeout.effectiveMs,
2260+
});
2261+
return nativeTextResult(
2262+
foregroundTaskStillRunningPayload(
2263+
args,
2264+
compactSession,
2265+
waited.turn ?? turn,
2266+
waitTimeout,
2267+
waited.timeoutReason ?? "wait_timeout",
2268+
),
2269+
waited.timeoutReason === "wait_cancelled",
2270+
);
2271+
}
2272+
const result = waited.result;
2273+
if (!result) {
2274+
return nativeErrorResult(new Error(`Codex session turn did not produce a result: ${turn.id}`), "codex_task");
2275+
}
2276+
await reportAgentResult(progress, result);
2277+
await progress.flush();
2278+
return nativeAgentResponse(result, {
2279+
description: args.description,
2280+
prompt: args.prompt,
2281+
tool: "codex_task",
2282+
session: compactSession,
2283+
turn: waited.turn ?? compactSession.recentTurns?.at(-1),
2284+
includeDiagnostics: Boolean(args.advanced?.include_diagnostics),
2285+
includeSessionId: Boolean(args.keep_session),
2286+
});
2287+
} finally {
2288+
unsubscribeMilestones();
2289+
extra?.signal?.removeEventListener("abort", abortHandler);
2290+
}
22092291
} catch (error) {
22102292
await progress.flush();
22112293
logger.error("codex_task.failed", { error: errorForLog(error) });

test/smoke-mcp.mjs

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -235,12 +235,45 @@ try {
235235
"capped wait should return before Claude Desktop's inactivity watchdog could fire",
236236
cappedWait.structuredContent,
237237
);
238+
const slowForeground = await callToolOn(cappedClient, "codex_task", {
239+
description: "Foreground handoff smoke",
240+
prompt: "foreground handoff smoke DELAY_MS=500",
241+
project_dir: projectDir,
242+
});
243+
assert(
244+
!slowForeground.isError &&
245+
slowForeground.structuredContent?.completed === false &&
246+
slowForeground.structuredContent?.status === "running" &&
247+
slowForeground.structuredContent?.session_id &&
248+
slowForeground.structuredContent?.effective_wait_timeout_ms === 50,
249+
"slow foreground codex_task should hand back a running session instead of hitting the client timeout",
250+
slowForeground.structuredContent,
251+
);
252+
let foregroundCompleted;
253+
for (let attempt = 0; attempt < 20; attempt += 1) {
254+
foregroundCompleted = await callToolOn(cappedClient, "codex_followup", {
255+
session_id: slowForeground.structuredContent.session_id,
256+
mode: "wait",
257+
wait_timeout_ms: 2_000,
258+
});
259+
if (foregroundCompleted.structuredContent?.completed) break;
260+
}
261+
assert(
262+
foregroundCompleted.structuredContent?.completed === true &&
263+
foregroundCompleted.structuredContent?.status === "completed",
264+
"foreground handoff session should be collectable with codex_followup wait",
265+
foregroundCompleted.structuredContent,
266+
);
238267
const cancelled = await callToolOn(cappedClient, "codex_followup", {
239268
session_id: slow.structuredContent.session_id,
240269
mode: "cancel",
241270
reason: "capped wait smoke cleanup",
242271
});
243-
assert(cancelled.structuredContent?.status === "cancelled", "capped wait smoke cleanup should cancel the session", cancelled);
272+
assert(
273+
["cancelled", "already_completed"].includes(cancelled.structuredContent?.status),
274+
"capped wait smoke cleanup should leave the session terminal",
275+
cancelled,
276+
);
244277
},
245278
);
246279

0 commit comments

Comments
 (0)