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
120 changes: 99 additions & 21 deletions actions/setup/js/codex_harness.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@

const { getErrorMessage } = require("./error_helpers.cjs");
const fs = require("fs");
const { DEFAULT_MCP_CALL_WATCHDOG_MS, MCP_CALL_TRANSPORT_GRACE_MS } = require("./constants.cjs");
const { loadCompiledConfig, mergeConfig } = require("./codex_config.cjs");
const { parseJsonPrefix } = require("./parse_json_prefix.cjs");
const { runProcess, formatDuration, sleep, MIN_POST_RESULT_WATCHDOG_TIMEOUT_MS, DEFAULT_POST_RESULT_WATCHDOG_IDLE_TIMEOUT_MS, MAX_POST_RESULT_WATCHDOG_TIMEOUT_MS, resolvePostResultWatchdogIdleTimeoutMs } = require("./process_runner.cjs");
const { runHarnessRetryLoop, shouldSkipForNoopSafeOutputs, shouldStopForNoopSafeOutputs } = require("./harness_retry_runner.cjs");
const {
Expand Down Expand Up @@ -92,6 +95,62 @@ const SERVER_ERROR_PATTERN = /InternalServerError|ServiceUnavailableError|500 In
// an identical rejection: retrying only re-bills the turns that succeeded before the failure point.
const INVALID_REQUEST_ERROR_PATTERN = /invalid_request_error/i;

function resolveMCPServerToolTimeouts(config, runtimeToolTimeoutSeconds) {
const configuredServers = config.defaults?.mcp_servers && typeof config.defaults.mcp_servers === "object" ? config.defaults.mcp_servers : {};
const defaults = {
...config.defaults,
mcp_servers: Object.fromEntries(
Object.entries(configuredServers).map(([name, value]) => [
name,
typeof value === "object" && value !== null && !Array.isArray(value)
? { ...value, ...(Number.isSafeInteger(runtimeToolTimeoutSeconds) && runtimeToolTimeoutSeconds > 0 ? { tool_timeout_sec: runtimeToolTimeoutSeconds } : {}) }
: value,
])
),
};
const effectiveServers = mergeConfig(defaults, config.overrides || {}).mcp_servers || {};
return Object.fromEntries(
Object.entries(effectiveServers).flatMap(([name, value]) => (typeof value?.tool_timeout_sec === "number" && Number.isSafeInteger(value.tool_timeout_sec) && value.tool_timeout_sec > 0 ? [[name, value.tool_timeout_sec]] : []))
);
}

function createMCPCallWatchdog(timeoutMs, now = Date.now) {
const pending = new Map();
function track(eventType, item) {
if (item?.type !== "mcp_tool_call" || typeof item.id !== "string") return;
if (eventType === "item.started") {
const callTimeoutMs = typeof timeoutMs === "function" ? timeoutMs(item) : timeoutMs;
pending.set(item.id, { startedAt: now(), timeoutMs: Number.isSafeInteger(callTimeoutMs) && callTimeoutMs > 0 ? callTimeoutMs : DEFAULT_MCP_CALL_WATCHDOG_MS });
}
if (eventType === "item.completed" || eventType === "item.failed") pending.delete(item.id);
}
return {
observe(line) {
let event;
try {
event = JSON.parse(line);
} catch {
return;
}
track(event?.type, event?.item);
},
observePrefix(prefix) {
const event = parseJsonPrefix(prefix);
track(event?.type, event?.item);
},
expiredTimeoutMs() {
const current = now();
for (const call of pending.values()) {
if (current - call.startedAt >= call.timeoutMs) return call.timeoutMs;
}
return null;
},
expired() {
return this.expiredTimeoutMs() !== null;
},
};
}

// Codex's `turn.failed` event nests the actual provider error as a JSON string inside
// `error.message` (sometimes doubly-nested, e.g. `error.message` -> `{"error": {...}}`).
// This is a specific, common form of "unsupported model" failure: the configured model does
Expand Down Expand Up @@ -785,6 +844,9 @@ async function main() {
// The deadline includes preflight time and is checked both between and during attempts.
const softTimeoutGuard = buildSoftTimeoutGuard(driverStartTime);
const contextRebuildCircuitBreaker = resolveContextRebuildCircuitBreakerConfig(process.env);
const configuredToolTimeout = Number(codexEnv.GH_AW_TOOL_TIMEOUT);
const fallbackToolTimeoutMs = Number.isSafeInteger(configuredToolTimeout) && configuredToolTimeout > 0 ? configuredToolTimeout * 1000 + MCP_CALL_TRANSPORT_GRACE_MS : DEFAULT_MCP_CALL_WATCHDOG_MS;
const serverToolTimeouts = resolveMCPServerToolTimeouts(loadCompiledConfig(), configuredToolTimeout);
/** @type {string[] | null} */
let resumeArgs = null;
let lastThreadId = "";
Expand All @@ -808,6 +870,10 @@ async function main() {
getRetryMode: () => (resumeArgs ? `resume ${lastThreadId}` : "fresh run"),
runAttempt: async attempt => {
const terminalErrors = [];
const mcpWatchdog = createMCPCallWatchdog(item => {
const server = item.server ?? item.server_name ?? item.serverName;
return serverToolTimeouts[server] ? serverToolTimeouts[server] * 1000 + MCP_CALL_TRANSPORT_GRACE_MS : fallbackToolTimeoutMs;
});
let nextContextCheckAt = Date.now() + contextRebuildCircuitBreaker.pollIntervalMs;
// Track the file size before this attempt so the watchdog only arms on output
// written by this attempt, not by a previous retry.
Expand All @@ -823,6 +889,7 @@ async function main() {
stdin: resumeArgs ? resumePrompt : promptInput.stdin,
maxCollectedOutputBytes: 4 * 1024 * 1024,
onStdoutLine: line => {
mcpWatchdog.observe(line);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added bounded stdout-prefix lifecycle metadata handling for oversized JSONL lines, so MCP completion clears the pending call without buffering its large result. Added an oversized-completion regression test. Fixed in 41f97a2.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/diagnosing-bugs] runProcess's lineObserver silently drops any pending line buffer exceeding 1MB (process_runner.cjs:224-231) before it reaches onStdoutLine. If an item.completed event for a large MCP tool result is dropped this way, the watchdog never clears the pending entry for that call and will eventually fire a false transport_wedge on an already-finished call.

💡 Suggested fix

Either (a) have the watchdog treat any dropped/oversized line as a heuristic "activity" signal that resets the deadline rather than silence, or (b) add a regression test simulating an oversized completion line and assert the watchdog does not misclassify a completed call as expired. Right now there's no test for this interaction between the line-size cap and the new expired() check.

@copilot please address this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The duplicated oversized-completion concern is covered by the bounded stdout-prefix lifecycle callback and regression test. Fixed in 41f97a2.

try {
const event = JSON.parse(line);
if (event.type === "thread.started" && typeof event.thread_id === "string") lastThreadId = event.thread_id;
Expand All @@ -832,27 +899,36 @@ async function main() {
}
} catch {}
},
runtimeGuard:
contextRebuildCircuitBreaker.enabled || softTimeoutGuard
? {
pollIntervalMs: Math.min(contextRebuildCircuitBreaker.pollIntervalMs, 1000),
termGraceMs: contextRebuildCircuitBreaker.termGraceMs,
shouldTerminate: async () => {
if (softTimeoutGuard && Date.now() >= softTimeoutGuard.softDeadlineMs) return { terminate: true, reason: "Codex reached the soft execution deadline; stopping to preserve structured output before the step timeout." };
if (!contextRebuildCircuitBreaker.enabled) return false;
if (Date.now() < nextContextCheckAt) return false;
nextContextCheckAt = Date.now() + contextRebuildCircuitBreaker.pollIntervalMs;
return evaluateContextRebuildCircuitBreakerForAttempt(
await readWorkingSetFromTokenUsage(tokenUsagePaths),
{
maxRebuildFactor: contextRebuildCircuitBreaker.maxRebuildFactor,
minCumulativeInputTokens: contextRebuildCircuitBreaker.minCumulativeInputTokens,
},
{ safeOutputsPath, safeOutputsByteOffset, logger: log }
);
},
}
: undefined,
onStdoutLinePrefix: prefix => mcpWatchdog.observePrefix(prefix),
runtimeGuard: {
pollIntervalMs: Math.min(contextRebuildCircuitBreaker.pollIntervalMs, 1000),
termGraceMs: contextRebuildCircuitBreaker.termGraceMs,
onTriggered: decision => {
if (decision.event) process.stdout.write(`${JSON.stringify(decision.event)}\n`);
},
shouldTerminate: async () => {
if (softTimeoutGuard && Date.now() >= softTimeoutGuard.softDeadlineMs) return { terminate: true, reason: "Codex reached the soft execution deadline; stopping to preserve structured output before the step timeout." };
const expiredMCPCallTimeoutMs = mcpWatchdog.expiredTimeoutMs();
if (expiredMCPCallTimeoutMs !== null) {
return {
terminate: true,
reason: `transport_wedge: MCP tool call timed out after ${Math.round(expiredMCPCallTimeoutMs / 1000)}s`,
event: { type: "agent.execution", data: { categories: ["transport_wedge"], errorCodes: [], errorTypes: [] } },
};
}
if (!contextRebuildCircuitBreaker.enabled) return false;
if (Date.now() < nextContextCheckAt) return false;
nextContextCheckAt = Date.now() + contextRebuildCircuitBreaker.pollIntervalMs;
return evaluateContextRebuildCircuitBreakerForAttempt(
await readWorkingSetFromTokenUsage(tokenUsagePaths),
{
maxRebuildFactor: contextRebuildCircuitBreaker.maxRebuildFactor,
minCumulativeInputTokens: contextRebuildCircuitBreaker.minCumulativeInputTokens,
},
{ safeOutputsPath, safeOutputsByteOffset, logger: log }
);
},
},
postResultWatchdog: safeOutputsPath
? {
shouldArm: () =>
Expand Down Expand Up @@ -1108,6 +1184,8 @@ if (typeof module !== "undefined" && module.exports) {
injectModelFlagAfterExec,
getCodexModelEnvVar,
resolvePostResultWatchdogIdleTimeoutMs,
createMCPCallWatchdog,
resolveMCPServerToolTimeouts,
POST_RESULT_WATCHDOG_IDLE_TIMEOUT_MS,
DEFAULT_POST_RESULT_WATCHDOG_IDLE_TIMEOUT_MS,
MIN_POST_RESULT_WATCHDOG_TIMEOUT_MS,
Expand Down
57 changes: 57 additions & 0 deletions actions/setup/js/codex_harness.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ const {
DEFAULT_CONTEXT_REBUILD_POLL_INTERVAL_MS,
DEFAULT_CONTEXT_REBUILD_TERM_GRACE_MS,
resolvePostResultWatchdogIdleTimeoutMs,
createMCPCallWatchdog,
resolveMCPServerToolTimeouts,
DEFAULT_POST_RESULT_WATCHDOG_IDLE_TIMEOUT_MS,
MIN_POST_RESULT_WATCHDOG_TIMEOUT_MS,
MAX_POST_RESULT_WATCHDOG_TIMEOUT_MS,
Expand Down Expand Up @@ -117,6 +119,61 @@ function runHarnessFixture(script, { prompt = "fix the bug", args = [], env = {}
}

describe("codex_harness.cjs", () => {
describe("MCP call watchdog", () => {
it("times out only an outstanding MCP call, not other output or completed calls", () => {
let time = 0;
const watchdog = createMCPCallWatchdog(120_000, () => time);
watchdog.observe(JSON.stringify({ type: "item.started", item: { type: "mcp_tool_call", id: "1", name: "search_repositories" } }));
time = 119_999;
watchdog.observe(JSON.stringify({ type: "item.completed", item: { type: "agent_message", id: "other" } }));
expect(watchdog.expired()).toBe(false);
time = 120_000;
expect(watchdog.expired()).toBe(true);
watchdog.observe(JSON.stringify({ type: "item.completed", item: { type: "mcp_tool_call", id: "1" } }));
expect(watchdog.expired()).toBe(false);
});

it("clears failed calls and ignores malformed events", () => {
let time = 0;
const watchdog = createMCPCallWatchdog(100, () => time);
watchdog.observe("{");
watchdog.observe(JSON.stringify({ type: "item.started", item: { type: "mcp_tool_call", id: "2" } }));
watchdog.observe(JSON.stringify({ type: "item.failed", item: { type: "mcp_tool_call", id: "2" } }));
time = 200;
expect(watchdog.expired()).toBe(false);
});

it("uses per-server timeouts after the global timeout override", () => {
const timeouts = resolveMCPServerToolTimeouts(
{
defaults: { mcp_servers: { github: { tool_timeout_sec: 60 }, search: { tool_timeout_sec: 30 } } },
overrides: { mcp_servers: { github: { tool_timeout_sec: 180 } } },
},
90
);
expect(timeouts).toEqual({ github: 180, search: 90 });

let time = 0;
const watchdog = createMCPCallWatchdog(
item => timeouts[item.server] * 1000 + 60_000,
() => time
);
watchdog.observe(JSON.stringify({ type: "item.started", item: { type: "mcp_tool_call", id: "1", server: "github" } }));
time = 239_999;
expect(watchdog.expired()).toBe(false);
time = 240_000;
expect(watchdog.expiredTimeoutMs()).toBe(240_000);
});

it("clears an oversized completion from its bounded lifecycle prefix", () => {
let time = 0;
const watchdog = createMCPCallWatchdog(100, () => time);
watchdog.observe(JSON.stringify({ type: "item.started", item: { type: "mcp_tool_call", id: "large" } }));
time = 100;
watchdog.observePrefix('{"type":"item.com\\u0070leted","item":{"\\u0069d":"large","type":"mcp_tool_call","server":"github","result":"');
expect(watchdog.expired()).toBe(false);
});
});
describe("native exec orchestration", () => {
it("preserves a native argument-parse exit without retrying a deterministic startup error", () => {
const { result, calls } = runHarnessFixture(`process.stderr.write("error: unexpected argument '--invalid' found\\n\\nUsage: codex exec [OPTIONS] [PROMPT]\\n");process.exit(2);`);
Expand Down
14 changes: 14 additions & 0 deletions actions/setup/js/constants.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,18 @@ const DETECTION_LOG_FILENAME = "detection.log";
*/
const DETECTION_RESULT_FILENAME = "detection_result.json";

/**
* Default timeout for a Codex MCP tool call when no server timeout is configured.
* @type {number}
*/
const DEFAULT_MCP_CALL_WATCHDOG_MS = 120_000;

/**
* Grace period added to the configured MCP tool timeout before terminating Codex.
* @type {number}
*/
const MCP_CALL_TRANSPORT_GRACE_MS = 60_000;

module.exports = {
AGENT_OUTPUT_FILENAME,
TMP_GH_AW_PATH,
Expand All @@ -175,4 +187,6 @@ module.exports = {
GITHUB_RATE_LIMITS_JSONL_PATH,
DETECTION_LOG_FILENAME,
DETECTION_RESULT_FILENAME,
DEFAULT_MCP_CALL_WATCHDOG_MS,
MCP_CALL_TRANSPORT_GRACE_MS,
};
11 changes: 11 additions & 0 deletions actions/setup/js/constants.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ const {
MANIFEST_FILE_PATH,
TEMPORARY_ID_MAP_FILE_PATH,
DETECTION_LOG_FILENAME,
DEFAULT_MCP_CALL_WATCHDOG_MS,
MCP_CALL_TRANSPORT_GRACE_MS,
} = require("./constants.cjs");

describe("constants", () => {
Expand Down Expand Up @@ -83,6 +85,13 @@ describe("constants", () => {
});
});

describe("Codex MCP watchdog timeouts", () => {
it("should export the default call timeout and transport grace period", () => {
expect(DEFAULT_MCP_CALL_WATCHDOG_MS).toBe(120_000);
expect(MCP_CALL_TRANSPORT_GRACE_MS).toBe(60_000);
});
});

describe("module exports", () => {
it("should export all expected constants", () => {
const exported = require("./constants.cjs");
Expand All @@ -99,6 +108,8 @@ describe("constants", () => {
"MANIFEST_FILE_PATH",
"TEMPORARY_ID_MAP_FILE_PATH",
"DETECTION_LOG_FILENAME",
"DEFAULT_MCP_CALL_WATCHDOG_MS",
"MCP_CALL_TRANSPORT_GRACE_MS",
];
for (const key of expectedKeys) {
expect(exported).toHaveProperty(key);
Expand Down
37 changes: 37 additions & 0 deletions actions/setup/js/handle_agent_failure.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const { extractShellCommandFromToolData } = require("./tool_call_details.cjs");
const { resolveFailureIssueRepo } = require("./repo_helpers.cjs");
const { GITHUB_API_VERSION } = require("./constants.cjs");
const { EMPTY_OUTPUT_CAUSES } = require("./empty_output_outcome.cjs");
const { isAgentExecutionEvent } = require("./agent_execution.cjs");
const fs = require("fs");
const https = require("https");
const os = require("os");
Expand Down Expand Up @@ -285,6 +286,7 @@ function parseHTMLCommentMetadata(body, markerKey) {
function buildFailureMatchCategories(options) {
const categories = [];

if (options.transportWedge) categories.push("transport_wedge");
if (options.isTimedOut) categories.push("timed_out");
if (options.hasAssignmentErrors) categories.push("assignment_errors");
if (options.hasAssignCopilotFailures) categories.push("assign_copilot_failures");
Expand Down Expand Up @@ -331,11 +333,32 @@ function buildFailureMatchCategories(options) {
return categories.sort();
}

function hasMCPTransportWedge(sessionContent) {
if (!sessionContent.includes('"transport_wedge"')) return false;
return sessionContent.split(/\r?\n/).some(line => {
try {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot add shortcut

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added an early return when the structured session has no "transport_wedge" marker, avoiding JSONL splitting/parsing on the common negative path; added a marker-free input regression. Commit: 0136b42.

const event = JSON.parse(line);
return isAgentExecutionEvent(event) && event.data.categories.includes("transport_wedge");
} catch {
return false;
}
});
}

function getAgentStdioLogPath(agentOutputFile = process.env.GH_AW_AGENT_OUTPUT) {
return agentOutputFile ? path.join(path.dirname(agentOutputFile), "agent-stdio.log") : "/tmp/gh-aw/agent-stdio.log";
}

function getAgentSessionPath(agentOutputFile = process.env.GH_AW_AGENT_OUTPUT) {
return agentOutputFile ? path.join(path.dirname(agentOutputFile), "agent-session.jsonl") : "/tmp/gh-aw/agent-session.jsonl";
}

/**
* Build a precise failure issue title for known failure classes.
* Falls back to the generic failure title when no specific class matches.
* @param {Object} options
* @param {string} options.workflowName
* @param {boolean} [options.transportWedge]
* @param {boolean} options.isTimedOut
* @param {boolean} options.hasMissingSafeOutputs
* @param {boolean} options.hasReportIncomplete
Expand Down Expand Up @@ -392,6 +415,7 @@ function buildFailureIssueTitle(options) {
const agentName = sanitizeContent(options.copilotAgentNotFound, COPILOT_AGENT_NOT_FOUND_AGENT_MAX_LENGTH).replace(/\s+/g, " ").trim();
return `[aw] ${workflowName} could not find configured Copilot agent "${agentName}"`;
}
if (options.transportWedge) return `[aw] ${workflowName} stalled on an MCP tool call`;
if (options.isTimedOut) return `[aw] ${workflowName} timed out`;
if (options.hasToolDenialsExceeded) return `[aw] ${workflowName} exceeded tool denial limit`;
if (options.hasCacheMissMisconfiguration) return `[aw] ${workflowName} has cache-memory miss misconfiguration`;
Expand Down Expand Up @@ -4276,10 +4300,19 @@ async function main() {

// Sanitize workflow name for title
const sanitizedWorkflowName = sanitizeContent(workflowName, { maxLength: 100 });
let transportWedge = false;
if (agentConclusion === "failure") {
try {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This reads a hardcoded /tmp/gh-aw/agent-stdio.log path, but every other stdio-log consumer in this file (e.g. buildEngineFailureContext at line 3072, hasOAuthTokenCheckFailed at 3021) derives the path from GH_AW_AGENT_OUTPUT's directory and only falls back to the hardcoded path:

const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT;
const stdioLogPath = agentOutputFile ? path.join(path.dirname(agentOutputFile), "agent-stdio.log") : "/tmp/gh-aw/agent-stdio.log";

If GH_AW_AGENT_OUTPUT points elsewhere (as it does for reusable/call-workflow runs, per apply_samples.test.cjs's call-workflow-agent-stdio.log), this new transportWedge check will silently read the wrong (or a stale/missing) file and never classify a real MCP watchdog termination as transport_wedge. Please reuse the same derivation pattern as the other call sites.

@copilot please address this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The watchdog classifier now reads agent-stdio.log beside GH_AW_AGENT_OUTPUT, with the existing default path as fallback; added a regression for a reusable-workflow output path. Fixed in fec4670.

transportWedge = hasMCPTransportWedge(fs.readFileSync(getAgentSessionPath(), "utf8"));
} catch {
core.debug("Unified agent session unavailable for MCP watchdog classification");
}
}
// Only the collector-written root metadata is trusted; report_incomplete.reason is agent-controlled.
const emptyOutputCause = agentOutputResult.success ? agentOutputResult.collectorEmptyOutputCause : undefined;
const issueTitle = buildFailureIssueTitle({
workflowName: sanitizedWorkflowName,
transportWedge,
emptyOutputCause,
isTimedOut,
hasMissingSafeOutputs,
Expand Down Expand Up @@ -4308,6 +4341,7 @@ async function main() {
});
const failureCategories = buildFailureMatchCategories({
agentConclusion,
transportWedge,
emptyOutputCause,
isTimedOut,
hasAssignmentErrors,
Expand Down Expand Up @@ -5019,6 +5053,9 @@ module.exports = {
CASCADE_ROLLUP_TITLE,
FAILURE_TITLE_PATTERN,
buildFailureMatchCategories,
hasMCPTransportWedge,
getAgentStdioLogPath,
getAgentSessionPath,
buildFailureIssueTitle,
FAILURE_CATEGORIES_PATH,
};
Loading
Loading