From ffe9006ed4499705f367c1d51fc0e715ea76d3c5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Jul 2026 00:04:34 +0000 Subject: [PATCH 1/8] Initial plan From 6ef43a896b2e100ac2b473b447e8bcdba7a5fe0a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Jul 2026 00:22:31 +0000 Subject: [PATCH 2/8] fix: guard copilot retries and safeoutputs tool schema Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/copilot_harness.cjs | 34 ++++++++++++ actions/setup/js/copilot_harness.test.cjs | 15 ++++++ actions/setup/js/mcp_cli_bridge.cjs | 31 ++++++++++- actions/setup/js/mcp_cli_bridge.test.cjs | 37 ++++++++++++- actions/setup/js/mount_mcp_as_cli.cjs | 60 +++++++++++++++++++++- actions/setup/js/mount_mcp_as_cli.test.cjs | 39 +++++++++++++- 6 files changed, 210 insertions(+), 6 deletions(-) diff --git a/actions/setup/js/copilot_harness.cjs b/actions/setup/js/copilot_harness.cjs index 0ead97601d6..b75a64d11a4 100644 --- a/actions/setup/js/copilot_harness.cjs +++ b/actions/setup/js/copilot_harness.cjs @@ -73,6 +73,9 @@ const INITIAL_DELAY_MS = 5000; const BACKOFF_MULTIPLIER = 2; // Maximum delay cap in milliseconds const MAX_DELAY_MS = 60000; +// Stop retrying this long before the step hard timeout so the harness can emit +// structured safe-output diagnostics instead of being terminated by Actions. +const SOFT_TIMEOUT_BUFFER_MS = 90 * 1000; // Additional startup retry budget for scheduled runs when Copilot exits with code 2 // before producing any output (typically transient API interruption at startup). const MAX_SCHEDULED_EXIT2_RETRIES = 1; @@ -698,6 +701,23 @@ function resolvePromptFileArgs(args) { return resolvedArgs; } +/** + * Compute a soft timeout deadline for the harness based on GH_AW_TIMEOUT_MINUTES. + * Returns null when timeout is unset/invalid. + * @param {number} driverStartTime + * @param {NodeJS.ProcessEnv} [env] + * @returns {{ timeoutMinutes: number, softDeadlineMs: number } | null} + */ +function buildSoftTimeoutGuard(driverStartTime, env = process.env) { + const timeoutMinutes = Number(env.GH_AW_TIMEOUT_MINUTES); + if (!Number.isFinite(timeoutMinutes) || timeoutMinutes <= 0) { + return null; + } + const hardTimeoutMs = Math.floor(timeoutMinutes * 60 * 1000); + const softDeadlineMs = driverStartTime + Math.max(hardTimeoutMs - SOFT_TIMEOUT_BUFFER_MS, 1000); + return { timeoutMinutes, softDeadlineMs }; +} + /** * Main entry point: run copilot with retry logic for partially-executed sessions. */ @@ -832,6 +852,7 @@ async function main() { // This prevents a broken --continue recovery from resurrecting --continue on the next attempt. let continueDisabledPermanently = false; const driverStartTime = Date.now(); + const softTimeoutGuard = buildSoftTimeoutGuard(driverStartTime); const detectedCopilotErrors = { inferenceAccessError: false, mcpPolicyError: false, @@ -873,6 +894,12 @@ async function main() { // Unified retry loop for CLI and driver modes. // --continue is a CLI concept; in SDK mode retries always restart the session fresh. for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + if (softTimeoutGuard && Date.now() >= softTimeoutGuard.softDeadlineMs) { + emitInfrastructureIncomplete(`Copilot harness reached soft retry budget before the ${softTimeoutGuard.timeoutMinutes}-minute step timeout. ` + "Stopping retries early to preserve structured failure output."); + log(`soft-timeout guard reached before attempt ${attempt + 1}: ` + `timeoutMinutes=${softTimeoutGuard.timeoutMinutes} bufferMs=${SOFT_TIMEOUT_BUFFER_MS}`); + lastExitCode = 1; + break; + } // Add --continue flag on CLI retries so the copilot session continues from where it left off const currentArgs = !copilotSDKMode && attempt > 0 && useContinueOnRetry ? [...resolvedArgs, "--continue"] : resolvedArgs; @@ -882,6 +909,12 @@ async function main() { await sleep(delay); delay = Math.min(delay * BACKOFF_MULTIPLIER, MAX_DELAY_MS); log(`retry ${attempt}/${MAX_RETRIES}: woke up, next delay cap will be ${Math.min(delay * BACKOFF_MULTIPLIER, MAX_DELAY_MS)}ms`); + if (softTimeoutGuard && Date.now() >= softTimeoutGuard.softDeadlineMs) { + emitInfrastructureIncomplete(`Copilot harness reached soft retry budget before the ${softTimeoutGuard.timeoutMinutes}-minute step timeout. ` + "Stopping retries early to preserve structured failure output."); + log(`soft-timeout guard reached after backoff sleep: ` + `timeoutMinutes=${softTimeoutGuard.timeoutMinutes} bufferMs=${SOFT_TIMEOUT_BUFFER_MS}`); + lastExitCode = 1; + break; + } } // Redact --prompt / -p value from logs to avoid leaking prompt content @@ -1195,6 +1228,7 @@ if (typeof module !== "undefined" && module.exports) { resolvePromptFileArgs, parseCopilotSDKServerArgsFromEnv, isCAPIQuotaExceededError, + buildSoftTimeoutGuard, }; } diff --git a/actions/setup/js/copilot_harness.test.cjs b/actions/setup/js/copilot_harness.test.cjs index 374da2764b2..1775b546ac1 100644 --- a/actions/setup/js/copilot_harness.test.cjs +++ b/actions/setup/js/copilot_harness.test.cjs @@ -51,6 +51,7 @@ const { resolvePromptFileArgs, writeCopilotOutputs, parseCopilotSDKServerArgsFromEnv, + buildSoftTimeoutGuard, } = require("./copilot_harness.cjs"); const agentTempDir = "/tmp/gh-aw/agent"; @@ -1575,6 +1576,20 @@ describe("copilot_harness.cjs", () => { expect(MAX_DELAY_MS).toBeGreaterThanOrEqual(INITIAL_DELAY_MS); }); + describe("soft timeout guard", () => { + it("returns null when GH_AW_TIMEOUT_MINUTES is missing", () => { + expect(buildSoftTimeoutGuard(1_000, {})).toBeNull(); + }); + + it("computes a deadline before hard timeout", () => { + const guard = buildSoftTimeoutGuard(10_000, { GH_AW_TIMEOUT_MINUTES: "15" }); + expect(guard).toEqual({ + timeoutMinutes: 15, + softDeadlineMs: 820000, + }); + }); + }); + it("exponential backoff does not exceed max delay", () => { const INITIAL_DELAY_MS = 5000; const BACKOFF_MULTIPLIER = 2; diff --git a/actions/setup/js/mcp_cli_bridge.cjs b/actions/setup/js/mcp_cli_bridge.cjs index bdd2547c19e..439f188fce3 100644 --- a/actions/setup/js/mcp_cli_bridge.cjs +++ b/actions/setup/js/mcp_cli_bridge.cjs @@ -59,6 +59,7 @@ const TOP_HELP_MAX_LINES = 20; const TOOL_HELP_MAX_LINES = 30; const TOOL_DESC_MAX_LEN = 90; const COMPACT_NAME_LINE_TARGET_WIDTH = 110; +const SAFEOUTPUTS_SERVER_NAME = "safeoutputs"; // --------------------------------------------------------------------------- // Audit logging @@ -823,7 +824,8 @@ function coerceToolArgValue(key, rawValue, schemaProperty, existingValue, allowN function loadTools(toolsFile) { try { if (fs.existsSync(toolsFile)) { - return JSON.parse(fs.readFileSync(toolsFile, "utf8")); + const parsed = JSON.parse(fs.readFileSync(toolsFile, "utf8")); + return Array.isArray(parsed) ? parsed : []; } } catch { // Fall through to empty array @@ -831,6 +833,30 @@ function loadTools(toolsFile) { return []; } +/** + * Ensure safeoutputs CLI always has a non-empty tool schema available. + * + * @param {Array<{name: string, description?: string, inputSchema?: {properties?: Record, required?: string[]}}>} tools + * @param {string} serverName + * @param {string} toolsFile + * @returns {Array<{name: string, description?: string, inputSchema?: {properties?: Record, required?: string[]}}>} + */ +function ensureSafeOutputsTools(tools, serverName, toolsFile) { + if (serverName !== SAFEOUTPUTS_SERVER_NAME || tools.length > 0) { + return tools; + } + const core = global.core; + const fallbackPath = process.env.GH_AW_SAFE_OUTPUTS_TOOLS_PATH || `${process.env.RUNNER_TEMP}/gh-aw/safeoutputs/tools.json`; + if (fallbackPath && fallbackPath !== toolsFile) { + const fallbackTools = loadTools(fallbackPath); + if (fallbackTools.length > 0) { + core.warning(`[${serverName}] tools cache ${toolsFile} is empty; recovered ${fallbackTools.length} tool(s) from ${fallbackPath}`); + return fallbackTools; + } + } + throw new Error(`[${serverName}] tool schema is empty (${toolsFile}). ` + "Failing fast to prevent runs where safe-output tools cannot be discovered."); +} + /** * Show top-level help: list all available commands for a server. * @@ -1176,7 +1202,7 @@ async function main() { }); // Load cached tools for help display - const tools = loadTools(toolsFile); + const tools = ensureSafeOutputsTools(loadTools(toolsFile), serverName, toolsFile); // Route: --help or no args → show top-level help if (userArgs.length === 0 || userArgs[0] === "--help" || userArgs[0] === "-h") { @@ -1276,5 +1302,6 @@ module.exports = { showToolHelp, hasStdinJsonPayload, readStdinSync, + ensureSafeOutputsTools, main, }; diff --git a/actions/setup/js/mcp_cli_bridge.test.cjs b/actions/setup/js/mcp_cli_bridge.test.cjs index d416d2e2662..275ddf8aef3 100644 --- a/actions/setup/js/mcp_cli_bridge.test.cjs +++ b/actions/setup/js/mcp_cli_bridge.test.cjs @@ -1,6 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import fs from "fs"; +import os from "os"; +import path from "path"; -import { formatResponse, hasStdinJsonPayload, parseToolArgs, readStdinSync, showHelp, showToolHelp, writeStdoutAndFlush } from "./mcp_cli_bridge.cjs"; +import { ensureSafeOutputsTools, formatResponse, hasStdinJsonPayload, parseToolArgs, readStdinSync, showHelp, showToolHelp, writeStdoutAndFlush } from "./mcp_cli_bridge.cjs"; describe("mcp_cli_bridge.cjs", () => { let originalCore; @@ -154,6 +157,38 @@ describe("mcp_cli_bridge.cjs", () => { }); }); + it("recovers empty safeoutputs schema from fallback tools path", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "bridge-safeoutputs-")); + const fallbackPath = path.join(tempDir, "tools.json"); + fs.writeFileSync(fallbackPath, JSON.stringify([{ name: "report_incomplete" }]), "utf8"); + const originalPath = process.env.GH_AW_SAFE_OUTPUTS_TOOLS_PATH; + process.env.GH_AW_SAFE_OUTPUTS_TOOLS_PATH = fallbackPath; + try { + const recovered = ensureSafeOutputsTools([], "safeoutputs", path.join(tempDir, "empty.json")); + expect(recovered).toHaveLength(1); + expect(recovered[0].name).toBe("report_incomplete"); + } finally { + if (originalPath === undefined) { + delete process.env.GH_AW_SAFE_OUTPUTS_TOOLS_PATH; + } else { + process.env.GH_AW_SAFE_OUTPUTS_TOOLS_PATH = originalPath; + } + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("fails fast when safeoutputs schema is empty", () => { + const originalPath = process.env.GH_AW_SAFE_OUTPUTS_TOOLS_PATH; + delete process.env.GH_AW_SAFE_OUTPUTS_TOOLS_PATH; + try { + expect(() => ensureSafeOutputsTools([], "safeoutputs", "/tmp/gh-aw/mcp-cli/tools/safeoutputs.json")).toThrow(/tool schema is empty/); + } finally { + if (originalPath !== undefined) { + process.env.GH_AW_SAFE_OUTPUTS_TOOLS_PATH = originalPath; + } + } + }); + it("coerces scientific notation when schema properties are unavailable", () => { const { args } = parseToolArgs(["--max_tokens", "1e3", "--threshold", "-2E-4"], {}); diff --git a/actions/setup/js/mount_mcp_as_cli.cjs b/actions/setup/js/mount_mcp_as_cli.cjs index 08f92dd9ec8..3cbb66c3f1e 100644 --- a/actions/setup/js/mount_mcp_as_cli.cjs +++ b/actions/setup/js/mount_mcp_as_cli.cjs @@ -33,6 +33,7 @@ const RUNNER_TEMP = process.env.RUNNER_TEMP || "/home/runner/work/_temp"; const CLI_BIN_DIR = `${RUNNER_TEMP}/gh-aw/mcp-cli/bin`; const TOOLS_DIR = `${RUNNER_TEMP}/gh-aw/mcp-cli/tools`; const AWF_GATEWAY_IP = "172.30.0.1"; +const SAFEOUTPUTS_SERVER_NAME = "safeoutputs"; /** MCP servers that are handled differently and should not be user-facing CLIs. * Note: safeoutputs and mcpscripts are NOT excluded — they are always CLI-mounted @@ -42,6 +43,47 @@ const INTERNAL_SERVERS = new Set(["github"]); /** Default timeout (ms) for HTTP calls to the local MCP gateway */ const DEFAULT_HTTP_TIMEOUT_MS = 15000; +/** + * Parse a tools JSON file and return a validated tools array. + * + * @param {string} toolsPath + * @param {typeof import("@actions/core")} core + * @returns {Array<{name: string, description?: string, inputSchema?: unknown}>} + */ +function loadToolsFromJSONFile(toolsPath, core) { + try { + if (!fs.existsSync(toolsPath)) { + return []; + } + const parsed = JSON.parse(fs.readFileSync(toolsPath, "utf8")); + return Array.isArray(parsed) ? parsed : []; + } catch (err) { + core.warning(` Failed to read tools file ${toolsPath}: ${err instanceof Error ? err.message : String(err)}`); + return []; + } +} + +/** + * Recover safeoutputs tools from the generated safe-outputs tools.json when MCP + * tools/list returned an empty result. + * + * @param {Array<{name: string, description?: string, inputSchema?: unknown}>} tools + * @param {typeof import("@actions/core")} core + * @returns {Array<{name: string, description?: string, inputSchema?: unknown}>} + */ +function recoverSafeOutputsToolsIfNeeded(tools, core) { + if (tools.length > 0) { + return tools; + } + const fallbackPath = process.env.GH_AW_SAFE_OUTPUTS_TOOLS_PATH || `${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json`; + const recovered = loadToolsFromJSONFile(fallbackPath, core); + if (recovered.length > 0) { + core.warning(` safeoutputs tools/list returned empty; recovered ${recovered.length} tool(s) from ${fallbackPath}`); + return recovered; + } + throw new Error(`safeoutputs tool schema is empty (tools/list returned 0 and fallback ${fallbackPath} is empty/missing). ` + `Failing fast to avoid agent runs without discoverable safe-output tools.`); +} + /** * Validate that a server name is safe to use as a filename and in shell scripts. * Prevents path traversal, shell metacharacter injection, and other abuse. @@ -404,7 +446,10 @@ async function main() { const toolsFile = path.join(TOOLS_DIR, `${name}.json`); // Query tools from the server using the host-accessible URL (mount step runs on host) - const tools = await fetchMCPTools(url, apiKey, core); + let tools = await fetchMCPTools(url, apiKey, core); + if (name === SAFEOUTPUTS_SERVER_NAME) { + tools = recoverSafeOutputsToolsIfNeeded(tools, core); + } core.info(` Found ${tools.length} tool(s)`); // Cache the tool list @@ -453,4 +498,15 @@ async function main() { core.setOutput("mounted-servers", mountedServers.join(",")); } -module.exports = { AWF_GATEWAY_IP, main, fetchMCPTools, generateCLIWrapperScript, isValidServerName, shellEscapeDoubleQuoted, parseMCPResponseBody, toContainerUrl }; +module.exports = { + AWF_GATEWAY_IP, + main, + fetchMCPTools, + generateCLIWrapperScript, + isValidServerName, + shellEscapeDoubleQuoted, + parseMCPResponseBody, + toContainerUrl, + loadToolsFromJSONFile, + recoverSafeOutputsToolsIfNeeded, +}; diff --git a/actions/setup/js/mount_mcp_as_cli.test.cjs b/actions/setup/js/mount_mcp_as_cli.test.cjs index 2c9a1d7414d..4acdf27b075 100644 --- a/actions/setup/js/mount_mcp_as_cli.test.cjs +++ b/actions/setup/js/mount_mcp_as_cli.test.cjs @@ -1,7 +1,10 @@ // @ts-check import { describe, expect, it } from "vitest"; +import fs from "fs"; +import os from "os"; +import path from "path"; -import { AWF_GATEWAY_IP, parseMCPResponseBody, toContainerUrl } from "./mount_mcp_as_cli.cjs"; +import { AWF_GATEWAY_IP, parseMCPResponseBody, recoverSafeOutputsToolsIfNeeded, toContainerUrl } from "./mount_mcp_as_cli.cjs"; describe("mount_mcp_as_cli.cjs", () => { it("parses JSON object responses unchanged", () => { @@ -60,4 +63,38 @@ describe("mount_mcp_as_cli.cjs", () => { } } }); + + it("recovers empty safeoutputs tools from fallback tools.json", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mount-safeoutputs-")); + const fallbackPath = path.join(tempDir, "tools.json"); + fs.writeFileSync(fallbackPath, JSON.stringify([{ name: "create_issue" }]), "utf8"); + const originalPath = process.env.GH_AW_SAFE_OUTPUTS_TOOLS_PATH; + process.env.GH_AW_SAFE_OUTPUTS_TOOLS_PATH = fallbackPath; + try { + const warnings = []; + const recovered = recoverSafeOutputsToolsIfNeeded([], { warning: message => warnings.push(message) }); + expect(recovered).toHaveLength(1); + expect(recovered[0].name).toBe("create_issue"); + expect(warnings.join("\n")).toContain("recovered 1 tool(s)"); + } finally { + if (originalPath === undefined) { + delete process.env.GH_AW_SAFE_OUTPUTS_TOOLS_PATH; + } else { + process.env.GH_AW_SAFE_OUTPUTS_TOOLS_PATH = originalPath; + } + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("throws when safeoutputs tools remain empty after fallback", () => { + const originalPath = process.env.GH_AW_SAFE_OUTPUTS_TOOLS_PATH; + delete process.env.GH_AW_SAFE_OUTPUTS_TOOLS_PATH; + try { + expect(() => recoverSafeOutputsToolsIfNeeded([], { warning: () => {} })).toThrow(/safeoutputs tool schema is empty/); + } finally { + if (originalPath !== undefined) { + process.env.GH_AW_SAFE_OUTPUTS_TOOLS_PATH = originalPath; + } + } + }); }); From c75f85cfbd6f128152c65029fde4aa7c2ac729a9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Jul 2026 01:18:50 +0000 Subject: [PATCH 3/8] fix: derive safeoutputs fallback path from toolsFile when RUNNER_TEMP is unset Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- actions/setup/js/mcp_cli_bridge.cjs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/actions/setup/js/mcp_cli_bridge.cjs b/actions/setup/js/mcp_cli_bridge.cjs index 439f188fce3..ff71f70fe85 100644 --- a/actions/setup/js/mcp_cli_bridge.cjs +++ b/actions/setup/js/mcp_cli_bridge.cjs @@ -846,8 +846,9 @@ function ensureSafeOutputsTools(tools, serverName, toolsFile) { return tools; } const core = global.core; - const fallbackPath = process.env.GH_AW_SAFE_OUTPUTS_TOOLS_PATH || `${process.env.RUNNER_TEMP}/gh-aw/safeoutputs/tools.json`; - if (fallbackPath && fallbackPath !== toolsFile) { + const runnerTemp = process.env.RUNNER_TEMP || path.resolve(path.dirname(toolsFile), "../../.."); + const fallbackPath = process.env.GH_AW_SAFE_OUTPUTS_TOOLS_PATH || path.join(runnerTemp, "gh-aw", "safeoutputs", "tools.json"); + if (fallbackPath !== toolsFile) { const fallbackTools = loadTools(fallbackPath); if (fallbackTools.length > 0) { core.warning(`[${serverName}] tools cache ${toolsFile} is empty; recovered ${fallbackTools.length} tool(s) from ${fallbackPath}`); From ce1a77df2ee93c43c9bd4ef69a9e105585177585 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Jul 2026 02:03:17 +0000 Subject: [PATCH 4/8] fix: add edge-case tests for buildSoftTimeoutGuard, guard global.core, deduplicate soft-timeout block Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- actions/setup/js/copilot_harness.cjs | 17 +++++++++++---- actions/setup/js/copilot_harness.test.cjs | 26 +++++++++++++++++++++++ actions/setup/js/mcp_cli_bridge.cjs | 3 +-- 3 files changed, 40 insertions(+), 6 deletions(-) diff --git a/actions/setup/js/copilot_harness.cjs b/actions/setup/js/copilot_harness.cjs index b75a64d11a4..6d8694209b6 100644 --- a/actions/setup/js/copilot_harness.cjs +++ b/actions/setup/js/copilot_harness.cjs @@ -718,6 +718,17 @@ function buildSoftTimeoutGuard(driverStartTime, env = process.env) { return { timeoutMinutes, softDeadlineMs }; } +/** + * Emit infrastructure incomplete signal and log when the soft timeout guard fires. + * Extracted to avoid duplicating the identical message/log pair at every call site. + * @param {{ timeoutMinutes: number, softDeadlineMs: number }} guard + * @param {string} context - Short label for where the check fired (e.g. "before attempt 2") + */ +function emitSoftTimeoutSignal(guard, context) { + emitInfrastructureIncomplete(`Copilot harness reached soft retry budget before the ${guard.timeoutMinutes}-minute step timeout. ` + "Stopping retries early to preserve structured failure output."); + log(`soft-timeout guard reached ${context}: timeoutMinutes=${guard.timeoutMinutes} bufferMs=${SOFT_TIMEOUT_BUFFER_MS}`); +} + /** * Main entry point: run copilot with retry logic for partially-executed sessions. */ @@ -895,8 +906,7 @@ async function main() { // --continue is a CLI concept; in SDK mode retries always restart the session fresh. for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { if (softTimeoutGuard && Date.now() >= softTimeoutGuard.softDeadlineMs) { - emitInfrastructureIncomplete(`Copilot harness reached soft retry budget before the ${softTimeoutGuard.timeoutMinutes}-minute step timeout. ` + "Stopping retries early to preserve structured failure output."); - log(`soft-timeout guard reached before attempt ${attempt + 1}: ` + `timeoutMinutes=${softTimeoutGuard.timeoutMinutes} bufferMs=${SOFT_TIMEOUT_BUFFER_MS}`); + emitSoftTimeoutSignal(softTimeoutGuard, `before attempt ${attempt + 1}`); lastExitCode = 1; break; } @@ -910,8 +920,7 @@ async function main() { delay = Math.min(delay * BACKOFF_MULTIPLIER, MAX_DELAY_MS); log(`retry ${attempt}/${MAX_RETRIES}: woke up, next delay cap will be ${Math.min(delay * BACKOFF_MULTIPLIER, MAX_DELAY_MS)}ms`); if (softTimeoutGuard && Date.now() >= softTimeoutGuard.softDeadlineMs) { - emitInfrastructureIncomplete(`Copilot harness reached soft retry budget before the ${softTimeoutGuard.timeoutMinutes}-minute step timeout. ` + "Stopping retries early to preserve structured failure output."); - log(`soft-timeout guard reached after backoff sleep: ` + `timeoutMinutes=${softTimeoutGuard.timeoutMinutes} bufferMs=${SOFT_TIMEOUT_BUFFER_MS}`); + emitSoftTimeoutSignal(softTimeoutGuard, "after backoff sleep"); lastExitCode = 1; break; } diff --git a/actions/setup/js/copilot_harness.test.cjs b/actions/setup/js/copilot_harness.test.cjs index 1775b546ac1..739b814b781 100644 --- a/actions/setup/js/copilot_harness.test.cjs +++ b/actions/setup/js/copilot_harness.test.cjs @@ -1581,6 +1581,22 @@ describe("copilot_harness.cjs", () => { expect(buildSoftTimeoutGuard(1_000, {})).toBeNull(); }); + it("returns null for zero timeout", () => { + expect(buildSoftTimeoutGuard(1_000, { GH_AW_TIMEOUT_MINUTES: "0" })).toBeNull(); + }); + + it("returns null for negative timeout", () => { + expect(buildSoftTimeoutGuard(1_000, { GH_AW_TIMEOUT_MINUTES: "-5" })).toBeNull(); + }); + + it("returns null for NaN timeout", () => { + expect(buildSoftTimeoutGuard(1_000, { GH_AW_TIMEOUT_MINUTES: "NaN" })).toBeNull(); + }); + + it("returns null for non-numeric timeout", () => { + expect(buildSoftTimeoutGuard(1_000, { GH_AW_TIMEOUT_MINUTES: "abc" })).toBeNull(); + }); + it("computes a deadline before hard timeout", () => { const guard = buildSoftTimeoutGuard(10_000, { GH_AW_TIMEOUT_MINUTES: "15" }); expect(guard).toEqual({ @@ -1588,6 +1604,16 @@ describe("copilot_harness.cjs", () => { softDeadlineMs: 820000, }); }); + + it("clamps deadline to start+1000ms when timeout is shorter than the buffer", () => { + // 1 minute (60_000ms) < SOFT_TIMEOUT_BUFFER_MS (90_000ms): deadline should be clamped to start + 1000ms + const guard = buildSoftTimeoutGuard(10_000, { GH_AW_TIMEOUT_MINUTES: "1" }); + expect(guard).not.toBeNull(); + expect(guard).toEqual({ + timeoutMinutes: 1, + softDeadlineMs: 11_000, + }); + }); }); it("exponential backoff does not exceed max delay", () => { diff --git a/actions/setup/js/mcp_cli_bridge.cjs b/actions/setup/js/mcp_cli_bridge.cjs index ff71f70fe85..269cd57e1f8 100644 --- a/actions/setup/js/mcp_cli_bridge.cjs +++ b/actions/setup/js/mcp_cli_bridge.cjs @@ -845,13 +845,12 @@ function ensureSafeOutputsTools(tools, serverName, toolsFile) { if (serverName !== SAFEOUTPUTS_SERVER_NAME || tools.length > 0) { return tools; } - const core = global.core; const runnerTemp = process.env.RUNNER_TEMP || path.resolve(path.dirname(toolsFile), "../../.."); const fallbackPath = process.env.GH_AW_SAFE_OUTPUTS_TOOLS_PATH || path.join(runnerTemp, "gh-aw", "safeoutputs", "tools.json"); if (fallbackPath !== toolsFile) { const fallbackTools = loadTools(fallbackPath); if (fallbackTools.length > 0) { - core.warning(`[${serverName}] tools cache ${toolsFile} is empty; recovered ${fallbackTools.length} tool(s) from ${fallbackPath}`); + global.core?.warning(`[${serverName}] tools cache ${toolsFile} is empty; recovered ${fallbackTools.length} tool(s) from ${fallbackPath}`); return fallbackTools; } } From aaac442d2aa48bcc96bc3cee440fe8494ab70481 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Jul 2026 03:48:58 +0000 Subject: [PATCH 5/8] refactor: move soft timeout guard to harness_retry_guard for shared use across harnesses Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/copilot_harness.cjs | 38 ++-------------- actions/setup/js/copilot_harness.test.cjs | 43 +------------------ actions/setup/js/harness_retry_guard.cjs | 38 ++++++++++++++++ actions/setup/js/harness_retry_guard.test.cjs | 42 +++++++++++++++++- 4 files changed, 84 insertions(+), 77 deletions(-) diff --git a/actions/setup/js/copilot_harness.cjs b/actions/setup/js/copilot_harness.cjs index 6d8694209b6..41f098335ec 100644 --- a/actions/setup/js/copilot_harness.cjs +++ b/actions/setup/js/copilot_harness.cjs @@ -61,7 +61,7 @@ const { } = require("./awf_reflect.cjs"); const { runSafeOutputsCLI, buildMissingToolAlternatives, emitMissingToolPermissionIssue, emitInfrastructureIncomplete, hasExpectedSafeOutputs, hasNoopInSafeOutputs } = require("./safeoutputs_cli.cjs"); const { countPermissionDeniedIssues, hasNumerousPermissionDeniedIssues, extractDeniedCommands, buildMissingToolPermissionIssuePayload } = require("./permission_denied_helpers.cjs"); -const { detectNonRetryableHarnessGuard } = require("./harness_retry_guard.cjs"); +const { detectNonRetryableHarnessGuard, buildSoftTimeoutGuard, emitSoftTimeoutSignal } = require("./harness_retry_guard.cjs"); const { isCAPIQuotaExceededError } = require("./detect_agent_errors.cjs"); const { loadModelsJson } = require("./model_costs.cjs"); @@ -73,9 +73,6 @@ const INITIAL_DELAY_MS = 5000; const BACKOFF_MULTIPLIER = 2; // Maximum delay cap in milliseconds const MAX_DELAY_MS = 60000; -// Stop retrying this long before the step hard timeout so the harness can emit -// structured safe-output diagnostics instead of being terminated by Actions. -const SOFT_TIMEOUT_BUFFER_MS = 90 * 1000; // Additional startup retry budget for scheduled runs when Copilot exits with code 2 // before producing any output (typically transient API interruption at startup). const MAX_SCHEDULED_EXIT2_RETRIES = 1; @@ -701,34 +698,6 @@ function resolvePromptFileArgs(args) { return resolvedArgs; } -/** - * Compute a soft timeout deadline for the harness based on GH_AW_TIMEOUT_MINUTES. - * Returns null when timeout is unset/invalid. - * @param {number} driverStartTime - * @param {NodeJS.ProcessEnv} [env] - * @returns {{ timeoutMinutes: number, softDeadlineMs: number } | null} - */ -function buildSoftTimeoutGuard(driverStartTime, env = process.env) { - const timeoutMinutes = Number(env.GH_AW_TIMEOUT_MINUTES); - if (!Number.isFinite(timeoutMinutes) || timeoutMinutes <= 0) { - return null; - } - const hardTimeoutMs = Math.floor(timeoutMinutes * 60 * 1000); - const softDeadlineMs = driverStartTime + Math.max(hardTimeoutMs - SOFT_TIMEOUT_BUFFER_MS, 1000); - return { timeoutMinutes, softDeadlineMs }; -} - -/** - * Emit infrastructure incomplete signal and log when the soft timeout guard fires. - * Extracted to avoid duplicating the identical message/log pair at every call site. - * @param {{ timeoutMinutes: number, softDeadlineMs: number }} guard - * @param {string} context - Short label for where the check fired (e.g. "before attempt 2") - */ -function emitSoftTimeoutSignal(guard, context) { - emitInfrastructureIncomplete(`Copilot harness reached soft retry budget before the ${guard.timeoutMinutes}-minute step timeout. ` + "Stopping retries early to preserve structured failure output."); - log(`soft-timeout guard reached ${context}: timeoutMinutes=${guard.timeoutMinutes} bufferMs=${SOFT_TIMEOUT_BUFFER_MS}`); -} - /** * Main entry point: run copilot with retry logic for partially-executed sessions. */ @@ -906,7 +875,7 @@ async function main() { // --continue is a CLI concept; in SDK mode retries always restart the session fresh. for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { if (softTimeoutGuard && Date.now() >= softTimeoutGuard.softDeadlineMs) { - emitSoftTimeoutSignal(softTimeoutGuard, `before attempt ${attempt + 1}`); + emitSoftTimeoutSignal(softTimeoutGuard, `before attempt ${attempt + 1}`, "Copilot harness", log); lastExitCode = 1; break; } @@ -920,7 +889,7 @@ async function main() { delay = Math.min(delay * BACKOFF_MULTIPLIER, MAX_DELAY_MS); log(`retry ${attempt}/${MAX_RETRIES}: woke up, next delay cap will be ${Math.min(delay * BACKOFF_MULTIPLIER, MAX_DELAY_MS)}ms`); if (softTimeoutGuard && Date.now() >= softTimeoutGuard.softDeadlineMs) { - emitSoftTimeoutSignal(softTimeoutGuard, "after backoff sleep"); + emitSoftTimeoutSignal(softTimeoutGuard, "after backoff sleep", "Copilot harness", log); lastExitCode = 1; break; } @@ -1237,7 +1206,6 @@ if (typeof module !== "undefined" && module.exports) { resolvePromptFileArgs, parseCopilotSDKServerArgsFromEnv, isCAPIQuotaExceededError, - buildSoftTimeoutGuard, }; } diff --git a/actions/setup/js/copilot_harness.test.cjs b/actions/setup/js/copilot_harness.test.cjs index 739b814b781..7756bc619aa 100644 --- a/actions/setup/js/copilot_harness.test.cjs +++ b/actions/setup/js/copilot_harness.test.cjs @@ -51,9 +51,10 @@ const { resolvePromptFileArgs, writeCopilotOutputs, parseCopilotSDKServerArgsFromEnv, - buildSoftTimeoutGuard, } = require("./copilot_harness.cjs"); +const { buildSoftTimeoutGuard } = require("./harness_retry_guard.cjs"); + const agentTempDir = "/tmp/gh-aw/agent"; function makeHarnessTempDir(name) { @@ -1576,46 +1577,6 @@ describe("copilot_harness.cjs", () => { expect(MAX_DELAY_MS).toBeGreaterThanOrEqual(INITIAL_DELAY_MS); }); - describe("soft timeout guard", () => { - it("returns null when GH_AW_TIMEOUT_MINUTES is missing", () => { - expect(buildSoftTimeoutGuard(1_000, {})).toBeNull(); - }); - - it("returns null for zero timeout", () => { - expect(buildSoftTimeoutGuard(1_000, { GH_AW_TIMEOUT_MINUTES: "0" })).toBeNull(); - }); - - it("returns null for negative timeout", () => { - expect(buildSoftTimeoutGuard(1_000, { GH_AW_TIMEOUT_MINUTES: "-5" })).toBeNull(); - }); - - it("returns null for NaN timeout", () => { - expect(buildSoftTimeoutGuard(1_000, { GH_AW_TIMEOUT_MINUTES: "NaN" })).toBeNull(); - }); - - it("returns null for non-numeric timeout", () => { - expect(buildSoftTimeoutGuard(1_000, { GH_AW_TIMEOUT_MINUTES: "abc" })).toBeNull(); - }); - - it("computes a deadline before hard timeout", () => { - const guard = buildSoftTimeoutGuard(10_000, { GH_AW_TIMEOUT_MINUTES: "15" }); - expect(guard).toEqual({ - timeoutMinutes: 15, - softDeadlineMs: 820000, - }); - }); - - it("clamps deadline to start+1000ms when timeout is shorter than the buffer", () => { - // 1 minute (60_000ms) < SOFT_TIMEOUT_BUFFER_MS (90_000ms): deadline should be clamped to start + 1000ms - const guard = buildSoftTimeoutGuard(10_000, { GH_AW_TIMEOUT_MINUTES: "1" }); - expect(guard).not.toBeNull(); - expect(guard).toEqual({ - timeoutMinutes: 1, - softDeadlineMs: 11_000, - }); - }); - }); - it("exponential backoff does not exceed max delay", () => { const INITIAL_DELAY_MS = 5000; const BACKOFF_MULTIPLIER = 2; diff --git a/actions/setup/js/harness_retry_guard.cjs b/actions/setup/js/harness_retry_guard.cjs index 6e4c7d3bbc2..8dfd36ac5cc 100644 --- a/actions/setup/js/harness_retry_guard.cjs +++ b/actions/setup/js/harness_retry_guard.cjs @@ -2,6 +2,12 @@ "use strict"; +const { emitInfrastructureIncomplete } = require("./safeoutputs_cli.cjs"); + +// Stop retrying this long before the step hard timeout so the harness can emit +// structured safe-output diagnostics instead of being terminated by Actions. +const SOFT_TIMEOUT_BUFFER_MS = 90 * 1000; + const AI_CREDITS_EXCEEDED_PATTERNS = [/\bmax[\s_-]*ai[\s_-]*credits[\s_-]*exceeded\b/i, /\bai[\s_-]*credits[\s_-]*rate[\s_-]*limit[\s_-]*error\b/i, /ai[\s_-]*credits?.*(?:rate[\s-]*limit|limit exceeded|budget exceeded|exceeded)/i]; const AWF_API_PROXY_BLOCKING_REQUESTS_PATTERNS = [/\bawf\b.*\bapi[\s_-]*proxy\b.*\bblocking requests\b/i, /\bapi[\s_-]*proxy\b.*\bblocking requests\b/i, /\bapi[\s_-]*proxy\b.*\bblocked requests?\b/i, /\bDIFC_FILTERED\b/]; @@ -30,6 +36,35 @@ function detectNonRetryableHarnessGuard(output) { }; } +/** + * Compute a soft timeout deadline for the harness based on GH_AW_TIMEOUT_MINUTES. + * Returns null when timeout is unset/invalid. + * @param {number} driverStartTime + * @param {NodeJS.ProcessEnv} [env] + * @returns {{ timeoutMinutes: number, softDeadlineMs: number } | null} + */ +function buildSoftTimeoutGuard(driverStartTime, env = process.env) { + const timeoutMinutes = Number(env.GH_AW_TIMEOUT_MINUTES); + if (!Number.isFinite(timeoutMinutes) || timeoutMinutes <= 0) { + return null; + } + const hardTimeoutMs = Math.floor(timeoutMinutes * 60 * 1000); + const softDeadlineMs = driverStartTime + Math.max(hardTimeoutMs - SOFT_TIMEOUT_BUFFER_MS, 1000); + return { timeoutMinutes, softDeadlineMs }; +} + +/** + * Emit infrastructure incomplete signal and log when the soft timeout guard fires. + * @param {{ timeoutMinutes: number, softDeadlineMs: number }} guard + * @param {string} context - Short label for where the check fired (e.g. "before attempt 2") + * @param {string} harnessName - Human-readable name of the harness (e.g. "Copilot harness") + * @param {(message: string) => void} logFn - Harness-specific log function + */ +function emitSoftTimeoutSignal(guard, context, harnessName, logFn) { + emitInfrastructureIncomplete(`${harnessName} reached soft retry budget before the ${guard.timeoutMinutes}-minute step timeout. ` + "Stopping retries early to preserve structured failure output."); + logFn(`soft-timeout guard reached ${context}: timeoutMinutes=${guard.timeoutMinutes} bufferMs=${SOFT_TIMEOUT_BUFFER_MS}`); +} + if (typeof module !== "undefined" && module.exports) { module.exports = { detectNonRetryableHarnessGuard, @@ -37,5 +72,8 @@ if (typeof module !== "undefined" && module.exports) { AWF_API_PROXY_BLOCKING_REQUESTS_PATTERNS, GOAL_ALREADY_ACTIVE_PATTERNS, MAX_RUNS_EXCEEDED_PATTERNS, + SOFT_TIMEOUT_BUFFER_MS, + buildSoftTimeoutGuard, + emitSoftTimeoutSignal, }; } diff --git a/actions/setup/js/harness_retry_guard.test.cjs b/actions/setup/js/harness_retry_guard.test.cjs index 0f345001302..91b837ae2a5 100644 --- a/actions/setup/js/harness_retry_guard.test.cjs +++ b/actions/setup/js/harness_retry_guard.test.cjs @@ -4,7 +4,7 @@ import { describe, expect, it } from "vitest"; import { createRequire } from "node:module"; const require = createRequire(import.meta.url); -const { detectNonRetryableHarnessGuard } = require("./harness_retry_guard.cjs"); +const { detectNonRetryableHarnessGuard, buildSoftTimeoutGuard } = require("./harness_retry_guard.cjs"); describe("harness_retry_guard.cjs", () => { it("detects AI credits exceeded markers", () => { @@ -118,3 +118,43 @@ describe("harness_retry_guard.cjs", () => { expect(result.maxRunsExceeded).toBe(false); }); }); + +describe("buildSoftTimeoutGuard", () => { + it("returns null when GH_AW_TIMEOUT_MINUTES is missing", () => { + expect(buildSoftTimeoutGuard(1_000, {})).toBeNull(); + }); + + it("returns null for zero timeout", () => { + expect(buildSoftTimeoutGuard(1_000, { GH_AW_TIMEOUT_MINUTES: "0" })).toBeNull(); + }); + + it("returns null for negative timeout", () => { + expect(buildSoftTimeoutGuard(1_000, { GH_AW_TIMEOUT_MINUTES: "-5" })).toBeNull(); + }); + + it("returns null for NaN timeout", () => { + expect(buildSoftTimeoutGuard(1_000, { GH_AW_TIMEOUT_MINUTES: "NaN" })).toBeNull(); + }); + + it("returns null for non-numeric timeout", () => { + expect(buildSoftTimeoutGuard(1_000, { GH_AW_TIMEOUT_MINUTES: "abc" })).toBeNull(); + }); + + it("computes a deadline before hard timeout", () => { + const guard = buildSoftTimeoutGuard(10_000, { GH_AW_TIMEOUT_MINUTES: "15" }); + expect(guard).toEqual({ + timeoutMinutes: 15, + softDeadlineMs: 820000, + }); + }); + + it("clamps deadline to start+1000ms when timeout is shorter than the buffer", () => { + // 1 minute (60_000ms) < SOFT_TIMEOUT_BUFFER_MS (90_000ms): deadline should be clamped to start + 1000ms + const guard = buildSoftTimeoutGuard(10_000, { GH_AW_TIMEOUT_MINUTES: "1" }); + expect(guard).not.toBeNull(); + expect(guard).toEqual({ + timeoutMinutes: 1, + softDeadlineMs: 11_000, + }); + }); +}); From 873ed65d27f4a37ad41dfc9cf40333f908be0335 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Jul 2026 00:04:34 +0000 Subject: [PATCH 6/8] Initial plan Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> From e7d15ad17c7ddb91404c74e421fa99a61c74d181 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Jul 2026 04:20:50 +0000 Subject: [PATCH 7/8] Apply soft timeout guard to claude and codex harnesses Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/claude_harness.cjs | 14 +++++++++++++- actions/setup/js/codex_harness.cjs | 14 +++++++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/actions/setup/js/claude_harness.cjs b/actions/setup/js/claude_harness.cjs index 37935475322..b76c5e15f18 100644 --- a/actions/setup/js/claude_harness.cjs +++ b/actions/setup/js/claude_harness.cjs @@ -47,7 +47,7 @@ const { } = require("./awf_reflect.cjs"); const { emitMissingToolPermissionIssue, hasExpectedSafeOutputs, hasNoopInSafeOutputs } = require("./safeoutputs_cli.cjs"); const { countPermissionDeniedIssues, hasNumerousPermissionDeniedIssues, extractDeniedCommands, buildMissingToolPermissionIssuePayload } = require("./permission_denied_helpers.cjs"); -const { detectNonRetryableHarnessGuard } = require("./harness_retry_guard.cjs"); +const { detectNonRetryableHarnessGuard, buildSoftTimeoutGuard, emitSoftTimeoutSignal } = require("./harness_retry_guard.cjs"); const { MODEL_NOT_SUPPORTED_PATTERN: INVALID_MODEL_ERROR_PATTERN } = require("./detect_agent_errors.cjs"); // Maximum number of retry attempts after the initial run @@ -344,8 +344,15 @@ async function main() { let useContinueOnRetry = false; let continueDisabledPermanently = false; const driverStartTime = Date.now(); + const softTimeoutGuard = buildSoftTimeoutGuard(driverStartTime); for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + if (softTimeoutGuard && Date.now() >= softTimeoutGuard.softDeadlineMs) { + emitSoftTimeoutSignal(softTimeoutGuard, `before attempt ${attempt + 1}`, "Claude harness", log); + lastExitCode = 1; + break; + } + // For --continue retries: omit the original prompt and add --continue. // Claude Code resumes the session from on-disk state; re-sending the original // instructions would re-execute the full task from scratch. @@ -365,6 +372,11 @@ async function main() { await sleep(delay); delay = Math.min(delay * BACKOFF_MULTIPLIER, MAX_DELAY_MS); log(`retry ${attempt}/${MAX_RETRIES}: woke up, next delay cap will be ${Math.min(delay * BACKOFF_MULTIPLIER, MAX_DELAY_MS)}ms`); + if (softTimeoutGuard && Date.now() >= softTimeoutGuard.softDeadlineMs) { + emitSoftTimeoutSignal(softTimeoutGuard, "after backoff sleep", "Claude harness", log); + lastExitCode = 1; + break; + } } const result = await runProcess({ command, args: currentArgs, attempt, log, logArgs }); diff --git a/actions/setup/js/codex_harness.cjs b/actions/setup/js/codex_harness.cjs index e5faf4551fd..44a9bce74a7 100644 --- a/actions/setup/js/codex_harness.cjs +++ b/actions/setup/js/codex_harness.cjs @@ -46,7 +46,7 @@ const { } = require("./awf_reflect.cjs"); const { emitMissingToolPermissionIssue, hasExpectedSafeOutputs, hasNoopInSafeOutputs } = require("./safeoutputs_cli.cjs"); const { countPermissionDeniedIssues, hasNumerousPermissionDeniedIssues, extractDeniedCommands, buildMissingToolPermissionIssuePayload } = require("./permission_denied_helpers.cjs"); -const { detectNonRetryableHarnessGuard } = require("./harness_retry_guard.cjs"); +const { detectNonRetryableHarnessGuard, buildSoftTimeoutGuard, emitSoftTimeoutSignal } = require("./harness_retry_guard.cjs"); const { MODEL_NOT_SUPPORTED_PATTERN: INVALID_MODEL_ERROR_PATTERN } = require("./detect_agent_errors.cjs"); // Maximum number of retry attempts after the initial run @@ -424,17 +424,29 @@ async function main() { let delay = INITIAL_DELAY_MS; let lastExitCode = 1; const driverStartTime = Date.now(); + const softTimeoutGuard = buildSoftTimeoutGuard(driverStartTime); for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { // Codex does not support --continue: every retry is a fresh run from scratch. // Context from the interrupted session is not recoverable, but transient API // failures (rate limits, server errors) may resolve on the next attempt. + if (softTimeoutGuard && Date.now() >= softTimeoutGuard.softDeadlineMs) { + emitSoftTimeoutSignal(softTimeoutGuard, `before attempt ${attempt + 1}`, "Codex harness", log); + lastExitCode = 1; + break; + } + if (attempt > 0) { log(`retry ${attempt}/${MAX_RETRIES}: sleeping ${delay}ms before next attempt (fresh run)`); await sleep(delay); delay = Math.min(delay * BACKOFF_MULTIPLIER, MAX_DELAY_MS); log(`retry ${attempt}/${MAX_RETRIES}: woke up, next delay cap will be ${Math.min(delay * BACKOFF_MULTIPLIER, MAX_DELAY_MS)}ms`); + if (softTimeoutGuard && Date.now() >= softTimeoutGuard.softDeadlineMs) { + emitSoftTimeoutSignal(softTimeoutGuard, "after backoff sleep", "Codex harness", log); + lastExitCode = 1; + break; + } } const result = await runProcess({ command, args: resolvedArgs, attempt, log, logArgs: safeArgs, env: codexChildEnv }); From 1bab2a690445fc73f89a7ee2b4225d6548c2d9a1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Jul 2026 04:50:28 +0000 Subject: [PATCH 8/8] Address review threads: polling guard docs, SERVER_VALIDATORS, warning assertion, soft-timeout test Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- actions/setup/js/claude_harness.cjs | 4 +++ actions/setup/js/codex_harness.cjs | 4 +++ actions/setup/js/copilot_harness.cjs | 4 +++ actions/setup/js/copilot_harness.test.cjs | 43 +++++++++++++++++++++++ actions/setup/js/mcp_cli_bridge.test.cjs | 1 + actions/setup/js/mount_mcp_as_cli.cjs | 18 ++++++++-- 6 files changed, 72 insertions(+), 2 deletions(-) diff --git a/actions/setup/js/claude_harness.cjs b/actions/setup/js/claude_harness.cjs index b76c5e15f18..b4891bdda27 100644 --- a/actions/setup/js/claude_harness.cjs +++ b/actions/setup/js/claude_harness.cjs @@ -344,6 +344,10 @@ async function main() { let useContinueOnRetry = false; let continueDisabledPermanently = false; const driverStartTime = Date.now(); + // Soft-timeout guard: polled at the top of the retry loop and after each backoff sleep. + // It does not preempt a running attempt — if a single invocation runs past the soft + // deadline the guard fires on the next iteration. Individual attempts are expected to + // complete within the SOFT_TIMEOUT_BUFFER_MS window. const softTimeoutGuard = buildSoftTimeoutGuard(driverStartTime); for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { diff --git a/actions/setup/js/codex_harness.cjs b/actions/setup/js/codex_harness.cjs index 44a9bce74a7..e4f8f040ea5 100644 --- a/actions/setup/js/codex_harness.cjs +++ b/actions/setup/js/codex_harness.cjs @@ -424,6 +424,10 @@ async function main() { let delay = INITIAL_DELAY_MS; let lastExitCode = 1; const driverStartTime = Date.now(); + // Soft-timeout guard: polled at the top of the retry loop and after each backoff sleep. + // It does not preempt a running attempt — if a single invocation runs past the soft + // deadline the guard fires on the next iteration. Individual attempts are expected to + // complete within the SOFT_TIMEOUT_BUFFER_MS window. const softTimeoutGuard = buildSoftTimeoutGuard(driverStartTime); for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { diff --git a/actions/setup/js/copilot_harness.cjs b/actions/setup/js/copilot_harness.cjs index 41f098335ec..f466e52a841 100644 --- a/actions/setup/js/copilot_harness.cjs +++ b/actions/setup/js/copilot_harness.cjs @@ -832,6 +832,10 @@ async function main() { // This prevents a broken --continue recovery from resurrecting --continue on the next attempt. let continueDisabledPermanently = false; const driverStartTime = Date.now(); + // Soft-timeout guard: polled at the top of the retry loop and after each backoff sleep. + // It does not preempt a running attempt — if a single invocation runs past the soft + // deadline the guard fires on the next iteration. Individual attempts are expected to + // complete within the SOFT_TIMEOUT_BUFFER_MS window. const softTimeoutGuard = buildSoftTimeoutGuard(driverStartTime); const detectedCopilotErrors = { inferenceAccessError: false, diff --git a/actions/setup/js/copilot_harness.test.cjs b/actions/setup/js/copilot_harness.test.cjs index 7756bc619aa..69598f8aabe 100644 --- a/actions/setup/js/copilot_harness.test.cjs +++ b/actions/setup/js/copilot_harness.test.cjs @@ -2023,6 +2023,49 @@ process.exit(1);`, expect(result.status).toBe(0); expect(result.stderr).toContain("noop message found in safe-outputs — not retrying"); }); + + it("exits 1 and emits soft-timeout signal when guard deadline is exceeded before next retry", () => { + const tempDir = makeHarnessTempDir("copilot-soft-timeout-"); + const safeOutputsPath = path.join(tempDir, "safe-outputs.jsonl"); + const stubPath = path.join(tempDir, "stub.cjs"); + const promptPath = path.join(tempDir, "prompt.txt"); + const callsPath = path.join(tempDir, "calls.jsonl"); + // Stub records the call, writes a line to stdout so hasOutput=true (enabling retry), + // then sleeps past the soft-timeout window before exiting 1. + fs.writeFileSync( + stubPath, + `const fs = require("fs"); +const callsPath = process.env.COPILOT_HARNESS_STUB_CALLS; +fs.appendFileSync(callsPath, JSON.stringify({args: process.argv.slice(2)}) + "\\n"); +process.stdout.write("running\\n"); +// Sleep 1.5 s so the soft deadline (clamped to start+1 s for 0.001-min timeout) is elapsed +const end = Date.now() + 1500; +while (Date.now() < end) {} +process.exit(1);`, + "utf8" + ); + fs.writeFileSync(promptPath, "fix the bug", "utf8"); + + const result = spawnSync(process.execPath, ["copilot_harness.cjs", process.execPath, stubPath, "--prompt-file", promptPath], { + cwd: path.dirname(require.resolve("./copilot_harness.cjs")), + env: { + ...process.env, + COPILOT_HARNESS_STUB_CALLS: callsPath, + GH_AW_SAFE_OUTPUTS: safeOutputsPath, + GH_AW_SAFEOUTPUTS_CLI: "true", + GH_AW_TIMEOUT_MINUTES: "0.001", + }, + encoding: "utf8", + timeout: 20000, + }); + const callCount = fs.readFileSync(callsPath, "utf8").trim().split("\n").filter(Boolean).length; + // Stub was called once; soft deadline fires before attempt 2 + expect(callCount).toBe(1); + // Harness exits 1 (soft-timeout signal) + expect(result.status).toBe(1); + // Guard log appears in stderr + expect(result.stderr).toContain("soft-timeout guard reached"); + }); }); describe("permission-denied suppression when expected safe-outputs already produced", () => { diff --git a/actions/setup/js/mcp_cli_bridge.test.cjs b/actions/setup/js/mcp_cli_bridge.test.cjs index 275ddf8aef3..739f2a470aa 100644 --- a/actions/setup/js/mcp_cli_bridge.test.cjs +++ b/actions/setup/js/mcp_cli_bridge.test.cjs @@ -167,6 +167,7 @@ describe("mcp_cli_bridge.cjs", () => { const recovered = ensureSafeOutputsTools([], "safeoutputs", path.join(tempDir, "empty.json")); expect(recovered).toHaveLength(1); expect(recovered[0].name).toBe("report_incomplete"); + expect(global.core.warning).toHaveBeenCalledWith(expect.stringContaining("recovered")); } finally { if (originalPath === undefined) { delete process.env.GH_AW_SAFE_OUTPUTS_TOOLS_PATH; diff --git a/actions/setup/js/mount_mcp_as_cli.cjs b/actions/setup/js/mount_mcp_as_cli.cjs index 3cbb66c3f1e..c058ea9f7d0 100644 --- a/actions/setup/js/mount_mcp_as_cli.cjs +++ b/actions/setup/js/mount_mcp_as_cli.cjs @@ -84,6 +84,18 @@ function recoverSafeOutputsToolsIfNeeded(tools, core) { throw new Error(`safeoutputs tool schema is empty (tools/list returned 0 and fallback ${fallbackPath} is empty/missing). ` + `Failing fast to avoid agent runs without discoverable safe-output tools.`); } +/** + * Per-server post-fetch validator registry. + * Each entry receives the fetched tool list and the @actions/core instance, and returns + * a (possibly replaced) tool list. Throwing here aborts the mount step for that server. + * Add an entry here when a server needs special validation after tools/list. + * + * @type {Record, core: typeof import("@actions/core")) => Array<{name: string, description?: string, inputSchema?: unknown}>>} + */ +const SERVER_VALIDATORS = { + [SAFEOUTPUTS_SERVER_NAME]: (tools, core) => recoverSafeOutputsToolsIfNeeded(tools, core), +}; + /** * Validate that a server name is safe to use as a filename and in shell scripts. * Prevents path traversal, shell metacharacter injection, and other abuse. @@ -447,8 +459,9 @@ async function main() { // Query tools from the server using the host-accessible URL (mount step runs on host) let tools = await fetchMCPTools(url, apiKey, core); - if (name === SAFEOUTPUTS_SERVER_NAME) { - tools = recoverSafeOutputsToolsIfNeeded(tools, core); + const validate = SERVER_VALIDATORS[name]; + if (validate) { + tools = validate(tools, core); } core.info(` Found ${tools.length} tool(s)`); @@ -509,4 +522,5 @@ module.exports = { toContainerUrl, loadToolsFromJSONFile, recoverSafeOutputsToolsIfNeeded, + SERVER_VALIDATORS, };