Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 27 additions & 2 deletions plugins/codex/scripts/codex-companion.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,12 @@ const REVIEW_SCHEMA = path.join(ROOT_DIR, "schemas", "review-output.schema.json"
const DEFAULT_STATUS_WAIT_TIMEOUT_MS = 240000;
const DEFAULT_STATUS_POLL_INTERVAL_MS = 2000;
const TASK_WORKER_RECORD_WAIT_TIMEOUT_MS = 1000;
// Foreground runs are invoked by Claude Code's Bash tool, which SIGKILLs node
// at its own timeout (default 120000ms) and returns nothing. Set the runtime
// turn budget just below that so a stalled foreground turn fails fast with a
// structured error instead of being killed with an empty result.
// Ported from @russjhammond's openai/codex-plugin-cc#376.
const FOREGROUND_TURN_TIMEOUT_MS = 110000;
const REASONING_EFFORTS = ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"];
const VALID_REASONING_EFFORTS = new Set(REASONING_EFFORTS);
const MODEL_ALIASES = new Map([
Expand Down Expand Up @@ -688,6 +694,21 @@ async function executeTransfer(cwd, options = {}) {
};
}

// Set the per-turn budget for a FOREGROUND command (codex.mjs reads
// CODEX_TURN_TIMEOUT_MS at call time). Precedence: explicit --turn-timeout-ms
// flag > a pre-set CODEX_TURN_TIMEOUT_MS env > the foreground default just
// under the host Bash ceiling. Only call on foreground path: a detached
// background worker inherits the parent env, so capping here would shrink
// the background budget too. Ported from openai#376.
function applyForegroundTurnBudget(options) {
const explicit = Number(options["turn-timeout-ms"]);
if (Number.isFinite(explicit) && explicit > 0) {
process.env.CODEX_TURN_TIMEOUT_MS = String(explicit);
} else if (!process.env.CODEX_TURN_TIMEOUT_MS) {
process.env.CODEX_TURN_TIMEOUT_MS = String(FOREGROUND_TURN_TIMEOUT_MS);
}
}

function readTaskPrompt(cwd, options, positionals) {
if (options["prompt-file"]) {
return fs.readFileSync(path.resolve(cwd, options["prompt-file"]), "utf8");
Expand Down Expand Up @@ -759,7 +780,7 @@ function enqueueBackgroundTask(cwd, job, request) {

async function handleReviewCommand(argv, config) {
const { options, positionals } = parseCommandInput(argv, {
valueOptions: ["base", "scope", "model", "effort", "cwd"],
valueOptions: ["base", "scope", "model", "effort", "cwd", "turn-timeout-ms"],
booleanOptions: ["json", "background", "wait"],
aliasMap: {
m: "model"
Expand Down Expand Up @@ -787,6 +808,9 @@ async function handleReviewCommand(argv, config) {
jobClass: "review",
summary: metadata.summary
});
if (!options.background) {
applyForegroundTurnBudget(options);
}
await runForegroundCommand(
job,
(progress) =>
Expand All @@ -813,7 +837,7 @@ async function handleReview(argv) {

async function handleTask(argv) {
const { options, positionals } = parseCommandInput(argv, {
valueOptions: ["model", "effort", "cwd", "prompt-file"],
valueOptions: ["model", "effort", "cwd", "prompt-file", "turn-timeout-ms"],
booleanOptions: ["json", "write", "resume-last", "resume", "fresh", "background"],
aliasMap: {
m: "model"
Expand Down Expand Up @@ -858,6 +882,7 @@ async function handleTask(argv) {
}

const job = buildTaskJob(workspaceRoot, taskMetadata, write);
applyForegroundTurnBudget(options);
await runForegroundCommand(
job,
(progress) =>
Expand Down
56 changes: 55 additions & 1 deletion plugins/codex/scripts/lib/codex.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,30 @@ const SERVICE_NAME = "claude_code_codex_plugin";
const EXTERNAL_AGENT_IMPORT_COMPLETED = "externalAgentConfig/import/completed";
const EXTERNAL_AGENT_IMPORT_TIMEOUT_MS = 2 * 60 * 1000;

// Hard upper bound on a single Codex turn. Without this, the completion await
// at the end of captureTurn is unbounded: it is resolved ONLY by completeTurn()
// and is never rejected on a stalled/dead process (rejectCompletion was dead
// code). The foreground budget is set below the external Bash ceiling by the
// companion so timeouts surface as structured errors instead of a SIGKILL.
// Ported from @russjhammond's openai/codex-plugin-cc#376.
const DEFAULT_TURN_TIMEOUT_MS = 600000;

// Resolve the per-turn budget at CALL time, not import time. The companion sets
// CODEX_TURN_TIMEOUT_MS (e.g. the foreground budget, below the Bash ceiling)
// AFTER this module is imported; reading it at import froze the value at the
// default and made --turn-timeout-ms / the foreground budget inert.
function resolveTurnTimeoutMs(options = {}) {
const fromOptions = Number(options.turnTimeoutMs);
if (Number.isFinite(fromOptions) && fromOptions > 0) {
return fromOptions;
}
const fromEnv = Number(process.env.CODEX_TURN_TIMEOUT_MS);
if (Number.isFinite(fromEnv) && fromEnv > 0) {
return fromEnv;
}
return DEFAULT_TURN_TIMEOUT_MS;
}

function cleanCodexStderr(stderr) {
return stderr
.split(/\r?\n/)
Expand Down Expand Up @@ -600,7 +624,37 @@ async function captureTurn(client, threadId, startRequest, options = {}) {
completeTurn(state, response.turn);
}

return await state.completion;
// Bound the await so it can never outlast a dead process or a runaway turn.
// Ported from @russjhammond's openai/codex-plugin-cc#376.
// 1. state.completion — resolves on turn/completed (or inferred). Wire the
// previously-dead rejectCompletion to the client exit so an app-server
// death AFTER startRequest resolved rejects the await immediately.
// 2. deadline — hard per-turn budget (resolveTurnTimeoutMs).
let exitRaceSettled = false;
client.exitPromise.then(() => {
if (exitRaceSettled || state.completed) {
return;
}
exitRaceSettled = true;
state.rejectCompletion(
client.exitError ?? new Error("codex app-server exited before the turn completed.")
);
});
const turnTimeoutMs = resolveTurnTimeoutMs(options);
let deadlineTimer = null;
const deadline = new Promise((_resolve, reject) => {
deadlineTimer = setTimeout(() => {
reject(new Error(`codex turn exceeded the ${turnTimeoutMs}ms turn budget.`));
}, turnTimeoutMs);
deadlineTimer.unref?.();
});
try {
return await Promise.race([state.completion, deadline]);
} finally {
if (deadlineTimer) {
clearTimeout(deadlineTimer);
}
}
} finally {
clearCompletionTimer(state);
client.setNotificationHandler(previousHandler ?? null);
Expand Down