diff --git a/actions/setup/js/codex_harness.cjs b/actions/setup/js/codex_harness.cjs index 54e737dfcf1..11aa5fb5c56 100644 --- a/actions/setup/js/codex_harness.cjs +++ b/actions/setup/js/codex_harness.cjs @@ -35,7 +35,7 @@ const { getErrorMessage } = require("./error_helpers.cjs"); const fs = require("fs"); -const { runProcess, formatDuration, sleep } = require("./process_runner.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 { AWF_API_PROXY_REFLECT_URL, AWF_REFLECT_OUTPUT_PATH, @@ -84,6 +84,86 @@ const MISSING_API_KEY_PATTERN = /Missing environment variable:\s*`?(?:CODEX_API_ // These are transient infrastructure failures that may resolve on retry. const SERVER_ERROR_PATTERN = /InternalServerError|ServiceUnavailableError|500 Internal Server Error|503 Service Unavailable/i; +// Post-result watchdog: once the agent writes a terminal safe-output the harness +// arms a watchdog timer and kills the Codex process if it is still running after +// POST_RESULT_WATCHDOG_IDLE_TIMEOUT_MS of inactivity. This prevents the step from +// hitting its hard timeout when Codex hangs on exit after completing its work. +// Constants and resolvePostResultWatchdogIdleTimeoutMs are imported from process_runner.cjs. +const POST_RESULT_WATCHDOG_IDLE_TIMEOUT_MS = resolvePostResultWatchdogIdleTimeoutMs(); + +// Types that are NOT terminal safe-outputs (infrastructure/diagnostic signals). +// A terminal safe-output is any entry whose type is NOT in this set, plus "noop". +const SAFE_OUTPUT_NON_TERMINAL_TYPES = new Set(["missing_tool", "report_incomplete"]); + +/** + * Return the current byte size of the safe-outputs file, or 0 if the file does not + * yet exist. Used as a per-attempt baseline so the watchdog only arms on output + * appended by the current attempt, not a record left by an earlier retry. + * @param {string} safeOutputsPath + * @returns {number} + */ +function getSafeOutputsByteOffset(safeOutputsPath) { + try { + return fs.statSync(safeOutputsPath).size; + } catch { + return 0; + } +} + +/** + * Read only the content of the safe-outputs JSONL file appended after byteOffset and + * return true if at least one terminal safe-output entry is present in that new content. + * A terminal safe-output is either a "noop" (nothing to do) or a non-diagnostic task + * result (e.g. add-labels, hide-comment). + * + * Using a per-attempt byte offset prevents the watchdog from arming on output produced + * by an earlier retry: if attempt N wrote a terminal record and exited non-zero before + * the watchdog polled, attempt N+1 would otherwise arm immediately and be killed even + * though it produced nothing useful. + * + * @param {string} safeOutputsPath + * @param {number} byteOffset - byte position in the file at the start of the current attempt + * @param {{ logger?: (msg: string) => void }=} options + * @returns {boolean} + */ +function hasTerminalSafeOutput(safeOutputsPath, byteOffset, options) { + const logger = options && options.logger ? options.logger : () => {}; + if (!safeOutputsPath) return false; + let content = ""; + try { + const fd = fs.openSync(safeOutputsPath, "r"); + try { + const stats = fs.fstatSync(fd); + const fileSize = stats.size; + if (fileSize <= byteOffset) return false; + const length = fileSize - byteOffset; + const buf = Buffer.allocUnsafe(length); + fs.readSync(fd, buf, 0, length, byteOffset); + content = buf.toString("utf8"); + } finally { + fs.closeSync(fd); + } + } catch { + return false; + } + for (const line of content.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + const parsed = JSON.parse(trimmed); + if (!parsed || typeof parsed.type !== "string") continue; + const type = parsed.type; + if (type === "noop" || !SAFE_OUTPUT_NON_TERMINAL_TYPES.has(type)) { + logger(`hasTerminalSafeOutput: terminal entry found in ${safeOutputsPath}: type=${type}`); + return true; + } + } catch { + // Ignore malformed lines. + } + } + return false; +} + /** * Emit a timestamped diagnostic log line to stderr. * All driver messages are prefixed with "[codex-harness]" so they are easy to @@ -509,7 +589,24 @@ async function main() { } } - const result = await runProcess({ command, args: resolvedArgs, attempt, log, logArgs: safeArgs, env: codexEnv }); + // Track the file size before this attempt so the watchdog only arms on output + // written by this attempt, not by a previous retry. + const safeOutputsByteOffset = safeOutputsPath ? getSafeOutputsByteOffset(safeOutputsPath) : 0; + + const result = await runProcess({ + command, + args: resolvedArgs, + attempt, + log, + logArgs: safeArgs, + env: codexEnv, + postResultWatchdog: safeOutputsPath + ? { + shouldArm: () => hasTerminalSafeOutput(safeOutputsPath, safeOutputsByteOffset, { logger: log }), + inactivityTimeoutMs: POST_RESULT_WATCHDOG_IDLE_TIMEOUT_MS, + } + : undefined, + }); lastExitCode = result.exitCode; // Success — stop retrying @@ -519,6 +616,17 @@ async function main() { break; } + // When the post-result watchdog fired (SIGTERM sent to a hanging Codex process) and the + // safe-outputs file contains a terminal result written during this attempt, treat the run + // as a success. The agent completed its work and wrote its output — the hang on exit is + // a cosmetic failure, not a task failure. Check this before logging "attempt failed" so + // the log stream does not contradict itself for what is ultimately a successful run. + if (result.watchdogFired && safeOutputsPath && hasTerminalSafeOutput(safeOutputsPath, safeOutputsByteOffset, { logger: log })) { + log(`attempt ${attempt + 1}: post-result watchdog fired after terminal safe-output was emitted — treating as success (late-activity exit suppressed)`); + lastExitCode = 0; + break; + } + const isRateLimit = isRateLimitError(result.output); const isTokenPerMinuteRateLimit = isTokenPerMinuteRateLimitError(result.output); const isAuthenticationFailed = isAuthenticationFailedError(result.output); @@ -530,6 +638,7 @@ async function main() { log( `attempt ${attempt + 1} failed:` + ` exitCode=${result.exitCode}` + + ` watchdogFired=${result.watchdogFired}` + ` isRateLimitError=${isRateLimit}` + ` isTokenPerMinuteRateLimitError=${isTokenPerMinuteRateLimit}` + ` isAuthenticationFailedError=${isAuthenticationFailed}` + @@ -662,6 +771,11 @@ if (typeof module !== "undefined" && module.exports) { applyModelFallback, injectModelFlagAfterExec, getCodexModelEnvVar, + resolvePostResultWatchdogIdleTimeoutMs, + POST_RESULT_WATCHDOG_IDLE_TIMEOUT_MS, + DEFAULT_POST_RESULT_WATCHDOG_IDLE_TIMEOUT_MS, + MIN_POST_RESULT_WATCHDOG_TIMEOUT_MS, + MAX_POST_RESULT_WATCHDOG_TIMEOUT_MS, }; } diff --git a/actions/setup/js/codex_harness.test.cjs b/actions/setup/js/codex_harness.test.cjs index 90763096ae5..3be0789cc44 100644 --- a/actions/setup/js/codex_harness.test.cjs +++ b/actions/setup/js/codex_harness.test.cjs @@ -28,6 +28,10 @@ const { configureCodexProviderFromReflect, hasNoopInSafeOutputs, resolveRetryConfig, + resolvePostResultWatchdogIdleTimeoutMs, + DEFAULT_POST_RESULT_WATCHDOG_IDLE_TIMEOUT_MS, + MIN_POST_RESULT_WATCHDOG_TIMEOUT_MS, + MAX_POST_RESULT_WATCHDOG_TIMEOUT_MS, } = require("./codex_harness.cjs"); const { detectNonRetryableHarnessGuard } = require("./harness_retry_guard.cjs"); @@ -673,6 +677,216 @@ process.exit(1);`, }); }); + describe("post-result watchdog suppression when terminal safe-output already produced", () => { + it("exits 0 without retrying when a terminal safe-output was produced and the process hangs on exit", () => { + const tempDir = makeHarnessTempDir("codex-watchdog-suppression-"); + 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 writes a terminal safe-output, then remains alive until SIGTERM. + // This exercises the watchdog-fired path end-to-end. + fs.writeFileSync( + stubPath, + `const fs = require("fs"); +const callsPath = process.env.CODEX_HARNESS_STUB_CALLS; +const safeOutputsPath = process.env.GH_AW_SAFE_OUTPUTS; +fs.appendFileSync(callsPath, JSON.stringify({args: process.argv.slice(2)}) + "\\n"); +fs.appendFileSync(safeOutputsPath, JSON.stringify({type:"add-labels",labels:["spam"]}) + "\\n"); +process.stdout.write("Label applied. Continuing cleanup...\\n"); +process.on("SIGTERM", () => process.exit(1)); +setInterval(() => {}, 1000);`, + "utf8" + ); + fs.writeFileSync(promptPath, "moderate the issue", "utf8"); + + const result = spawnSync(process.execPath, ["codex_harness.cjs", process.execPath, stubPath, "exec", "--prompt-file", promptPath], { + cwd: path.dirname(require.resolve("./codex_harness.cjs")), + env: { + ...process.env, + CODEX_HARNESS_STUB_CALLS: callsPath, + GH_AW_SAFE_OUTPUTS: safeOutputsPath, + CODEX_API_KEY: "fake-key-for-test", + GH_AW_HARNESS_WATCHDOG_TIMEOUT_MS: "100", + }, + encoding: "utf8", + timeout: 15000, + }); + const callCount = fs.readFileSync(callsPath, "utf8").trim().split("\n").filter(Boolean).length; + // Only one attempt — no retries when watchdog suppression applies + expect(callCount).toBe(1); + // Harness exits 0 because the terminal safe-output was already produced + expect(result.status).toBe(0); + expect(result.stderr).toContain("post-result watchdog fired after terminal safe-output was emitted"); + expect(result.stderr).toContain("late-activity exit suppressed"); + }); + + it("exits 0 without retrying when a noop safe-output was produced and the process hangs on exit", () => { + const tempDir = makeHarnessTempDir("codex-watchdog-noop-"); + 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 writes a noop safe-output, then remains alive until SIGTERM. + fs.writeFileSync( + stubPath, + `const fs = require("fs"); +const callsPath = process.env.CODEX_HARNESS_STUB_CALLS; +const safeOutputsPath = process.env.GH_AW_SAFE_OUTPUTS; +fs.appendFileSync(callsPath, JSON.stringify({args: process.argv.slice(2)}) + "\\n"); +fs.appendFileSync(safeOutputsPath, JSON.stringify({type:"noop",message:"nothing to do"}) + "\\n"); +process.stdout.write("Noop recorded. Waiting...\\n"); +process.on("SIGTERM", () => process.exit(1)); +setInterval(() => {}, 1000);`, + "utf8" + ); + fs.writeFileSync(promptPath, "moderate the issue", "utf8"); + + const result = spawnSync(process.execPath, ["codex_harness.cjs", process.execPath, stubPath, "exec", "--prompt-file", promptPath], { + cwd: path.dirname(require.resolve("./codex_harness.cjs")), + env: { + ...process.env, + CODEX_HARNESS_STUB_CALLS: callsPath, + GH_AW_SAFE_OUTPUTS: safeOutputsPath, + CODEX_API_KEY: "fake-key-for-test", + GH_AW_HARNESS_WATCHDOG_TIMEOUT_MS: "100", + }, + encoding: "utf8", + timeout: 15000, + }); + const callCount = fs.readFileSync(callsPath, "utf8").trim().split("\n").filter(Boolean).length; + expect(callCount).toBe(1); + expect(result.status).toBe(0); + expect(result.stderr).toContain("post-result watchdog fired after terminal safe-output was emitted"); + expect(result.stderr).toContain("late-activity exit suppressed"); + }); + + it("still retries partial_execution when no terminal safe-output was produced (watchdog not armed)", () => { + const tempDir = makeHarnessTempDir("codex-watchdog-no-output-"); + 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 produces output but exits 1 without writing any safe-output. + // The watchdog cannot fire (no terminal safe-output to arm it), so this + // should fall through to the normal partial-execution retry path. + fs.writeFileSync( + stubPath, + `const fs = require("fs"); +const callsPath = process.env.CODEX_HARNESS_STUB_CALLS; +fs.appendFileSync(callsPath, JSON.stringify({args: process.argv.slice(2)}) + "\\n"); +process.stdout.write("partial work done\\n"); +process.exit(1);`, + "utf8" + ); + fs.writeFileSync(promptPath, "moderate the issue", "utf8"); + + const result = spawnSync(process.execPath, ["codex_harness.cjs", process.execPath, stubPath, "exec", "--prompt-file", promptPath], { + cwd: path.dirname(require.resolve("./codex_harness.cjs")), + env: { + ...process.env, + CODEX_HARNESS_STUB_CALLS: callsPath, + GH_AW_SAFE_OUTPUTS: safeOutputsPath, + CODEX_API_KEY: "fake-key-for-test", + // Override retry config to keep the test fast. + GH_AW_HARNESS_MAX_RETRIES: "1", + GH_AW_HARNESS_INITIAL_DELAY_MS: "1", + }, + encoding: "utf8", + timeout: 15000, + }); + const callCount = fs.readFileSync(callsPath, "utf8").trim().split("\n").filter(Boolean).length; + // Should retry (2 attempts total with max_retries=1) + expect(callCount).toBeGreaterThan(1); + // Harness exits 1 because retries are exhausted with no output + expect(result.status).toBe(1); + expect(result.stderr).not.toContain("late-activity exit suppressed"); + }); + + it("does not arm watchdog on retry from terminal output left by a previous attempt", () => { + // Attempt 0 writes a terminal safe-output and exits non-zero quickly (no hang). + // Attempt 1 should NOT have its watchdog armed by attempt 0's output, and + // should NOT be suppressed as a success. + const tempDir = makeHarnessTempDir("codex-watchdog-baseline-"); + 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"); + // Attempt 0: writes a terminal safe-output, produces output, then exits 1 (no hang). + // Attempt 1+: produces output and exits 1 without writing anything new to safe-outputs. + // The watchdog on attempt 1 must NOT arm from attempt 0's output. + fs.writeFileSync( + stubPath, + `const fs = require("fs"); +const callsPath = process.env.CODEX_HARNESS_STUB_CALLS; +const safeOutputsPath = process.env.GH_AW_SAFE_OUTPUTS; +const calls = fs.existsSync(callsPath) ? fs.readFileSync(callsPath, "utf8").trim().split("\\n").filter(Boolean).length : 0; +fs.appendFileSync(callsPath, JSON.stringify({args: process.argv.slice(2)}) + "\\n"); +if (calls === 0) { + // First attempt: write terminal output and exit immediately + fs.appendFileSync(safeOutputsPath, JSON.stringify({type:"add-labels",labels:["spam"]}) + "\\n"); +} +// All attempts: produce output (so harness classifies as partial execution and retries) +process.stdout.write("partial work done\\n"); +process.exit(1);`, + "utf8" + ); + fs.writeFileSync(promptPath, "moderate the issue", "utf8"); + + const result = spawnSync(process.execPath, ["codex_harness.cjs", process.execPath, stubPath, "exec", "--prompt-file", promptPath], { + cwd: path.dirname(require.resolve("./codex_harness.cjs")), + env: { + ...process.env, + CODEX_HARNESS_STUB_CALLS: callsPath, + GH_AW_SAFE_OUTPUTS: safeOutputsPath, + CODEX_API_KEY: "fake-key-for-test", + GH_AW_HARNESS_MAX_RETRIES: "1", + GH_AW_HARNESS_INITIAL_DELAY_MS: "1", + GH_AW_HARNESS_WATCHDOG_TIMEOUT_MS: "100", + }, + encoding: "utf8", + timeout: 15000, + }); + const callCount = fs.readFileSync(callsPath, "utf8").trim().split("\n").filter(Boolean).length; + // Should retry: attempt 0's output does not suppress attempt 1 + expect(callCount).toBeGreaterThan(1); + // Harness exits 1: attempt 1 produced no new terminal safe-output + expect(result.status).toBe(1); + // The watchdog-suppression path must not have fired + expect(result.stderr).not.toContain("late-activity exit suppressed"); + }); + }); + + describe("resolvePostResultWatchdogIdleTimeoutMs", () => { + it("returns the default when no env var is set", () => { + expect(resolvePostResultWatchdogIdleTimeoutMs({})).toBe(DEFAULT_POST_RESULT_WATCHDOG_IDLE_TIMEOUT_MS); + }); + + it("returns the configured value when GH_AW_HARNESS_WATCHDOG_TIMEOUT_MS is a positive number", () => { + expect(resolvePostResultWatchdogIdleTimeoutMs({ GH_AW_HARNESS_WATCHDOG_TIMEOUT_MS: "5000" })).toBe(5000); + }); + + it("clamps to MIN_POST_RESULT_WATCHDOG_TIMEOUT_MS when configured value is too small", () => { + expect(resolvePostResultWatchdogIdleTimeoutMs({ GH_AW_HARNESS_WATCHDOG_TIMEOUT_MS: "1" })).toBe(MIN_POST_RESULT_WATCHDOG_TIMEOUT_MS); + }); + + it("clamps to MAX_POST_RESULT_WATCHDOG_TIMEOUT_MS when configured value is too large", () => { + expect(resolvePostResultWatchdogIdleTimeoutMs({ GH_AW_HARNESS_WATCHDOG_TIMEOUT_MS: String(MAX_POST_RESULT_WATCHDOG_TIMEOUT_MS + 1) })).toBe(MAX_POST_RESULT_WATCHDOG_TIMEOUT_MS); + }); + + it("returns the default for non-numeric input", () => { + expect(resolvePostResultWatchdogIdleTimeoutMs({ GH_AW_HARNESS_WATCHDOG_TIMEOUT_MS: "not-a-number" })).toBe(DEFAULT_POST_RESULT_WATCHDOG_IDLE_TIMEOUT_MS); + }); + + it("returns the default for zero", () => { + expect(resolvePostResultWatchdogIdleTimeoutMs({ GH_AW_HARNESS_WATCHDOG_TIMEOUT_MS: "0" })).toBe(DEFAULT_POST_RESULT_WATCHDOG_IDLE_TIMEOUT_MS); + }); + + it("returns the default for negative values", () => { + expect(resolvePostResultWatchdogIdleTimeoutMs({ GH_AW_HARNESS_WATCHDOG_TIMEOUT_MS: "-100" })).toBe(DEFAULT_POST_RESULT_WATCHDOG_IDLE_TIMEOUT_MS); + }); + }); + describe("retry configuration", () => { it("uses the default retry settings when env vars are unset", () => { expect(resolveRetryConfig({})).toEqual({ diff --git a/actions/setup/js/copilot_harness.cjs b/actions/setup/js/copilot_harness.cjs index 9c131b4ec07..a1d2a08d2ae 100644 --- a/actions/setup/js/copilot_harness.cjs +++ b/actions/setup/js/copilot_harness.cjs @@ -45,7 +45,16 @@ const { getErrorMessage } = require("./error_helpers.cjs"); const fs = require("fs"); const crypto = require("crypto"); const { getPromptPath, renderTemplateFromFile } = require("./messages_core.cjs"); -const { runProcess, formatDuration, sleep, isCopilotSDKEnabled, buildCopilotSDKEnv } = require("./process_runner.cjs"); +const { + runProcess, + formatDuration, + sleep, + isCopilotSDKEnabled, + buildCopilotSDKEnv, + MIN_POST_RESULT_WATCHDOG_TIMEOUT_MS, + DEFAULT_POST_RESULT_WATCHDOG_IDLE_TIMEOUT_MS, + resolvePostResultWatchdogIdleTimeoutMs, +} = require("./process_runner.cjs"); const { buildCopilotSDKServerArgs, getCopilotSDKServerPort, startCopilotSDKServer, stopCopilotSDKServer, waitForCopilotSDKServer } = require("./copilot_sdk_sidecar.cjs"); const { resolveRetryConfig: resolveSharedRetryConfig } = require("./harness_retry_config.cjs"); const { @@ -82,8 +91,6 @@ const PROMPT_FILE_INLINE_THRESHOLD_LABEL = "100KB"; const MAX_ENV_VAR_PREVIEW_LENGTH = 120; const OUTPUT_TAIL_MAX_CHARS = 600; const OUTPUT_TAIL_MAX_LINES = 12; -const MIN_POST_RESULT_WATCHDOG_TIMEOUT_MS = 50; -const DEFAULT_POST_RESULT_WATCHDOG_IDLE_TIMEOUT_MS = 20 * 1000; // Default token count threshold above which a 0-turn failure is classified as "long_run_exit" // rather than the generic "partial_execution". Corresponds to ~30+ minutes of Copilot // CLI work where the wrapper exits non-zero after the agent has completed substantial work. @@ -97,13 +104,6 @@ function resolveLongRunTokenThreshold(env = process.env) { return configured; } const LONG_RUN_TOKEN_THRESHOLD = resolveLongRunTokenThreshold(); -function resolvePostResultWatchdogIdleTimeoutMs(env = process.env) { - const configuredTimeoutMs = Number(env.GH_AW_HARNESS_WATCHDOG_TIMEOUT_MS); - if (!Number.isFinite(configuredTimeoutMs) || configuredTimeoutMs <= 0) { - return DEFAULT_POST_RESULT_WATCHDOG_IDLE_TIMEOUT_MS; - } - return Math.max(MIN_POST_RESULT_WATCHDOG_TIMEOUT_MS, configuredTimeoutMs); -} const POST_RESULT_WATCHDOG_IDLE_TIMEOUT_MS = resolvePostResultWatchdogIdleTimeoutMs(); const COPILOT_REQUESTS_PROXY_AUTH_403_TEMPLATE_NAME = "copilot_requests_proxy_auth_403.md"; // Pattern to detect transient CAPIError 400 in copilot output diff --git a/actions/setup/js/process_runner.cjs b/actions/setup/js/process_runner.cjs index 771f1942412..2cdcdfe191a 100644 --- a/actions/setup/js/process_runner.cjs +++ b/actions/setup/js/process_runner.cjs @@ -202,6 +202,29 @@ function runProcess({ command, args, attempt, log, logArgs, env, postResultWatch }); } +// Post-result watchdog: shared constants and timeout resolver used by all harnesses. +// These are kept here so both copilot_harness and codex_harness stay in sync. +const MIN_POST_RESULT_WATCHDOG_TIMEOUT_MS = 50; +const DEFAULT_POST_RESULT_WATCHDOG_IDLE_TIMEOUT_MS = 20 * 1000; +/** Maximum allowed value for GH_AW_HARNESS_WATCHDOG_TIMEOUT_MS to prevent the watchdog from being + * effectively disabled by an excessively large override (e.g. a stray zero). */ +const MAX_POST_RESULT_WATCHDOG_TIMEOUT_MS = 10 * 60 * 1000; + +/** + * Resolve the post-result watchdog inactivity timeout from the environment. + * Falls back to DEFAULT_POST_RESULT_WATCHDOG_IDLE_TIMEOUT_MS when unset or invalid. + * Clamps to [MIN_POST_RESULT_WATCHDOG_TIMEOUT_MS, MAX_POST_RESULT_WATCHDOG_TIMEOUT_MS]. + * @param {NodeJS.ProcessEnv} [env] + * @returns {number} + */ +function resolvePostResultWatchdogIdleTimeoutMs(env = process.env) { + const configuredTimeoutMs = Number(env.GH_AW_HARNESS_WATCHDOG_TIMEOUT_MS); + if (!Number.isFinite(configuredTimeoutMs) || configuredTimeoutMs <= 0) { + return DEFAULT_POST_RESULT_WATCHDOG_IDLE_TIMEOUT_MS; + } + return Math.min(MAX_POST_RESULT_WATCHDOG_TIMEOUT_MS, Math.max(MIN_POST_RESULT_WATCHDOG_TIMEOUT_MS, configuredTimeoutMs)); +} + /** * @param {NodeJS.ProcessEnv} [env] * @returns {boolean} @@ -251,5 +274,15 @@ function buildCopilotSDKEnv(env) { } if (typeof module !== "undefined" && module.exports) { - module.exports = { runProcess, formatDuration, sleep, isCopilotSDKEnabled, buildCopilotSDKEnv }; + module.exports = { + runProcess, + formatDuration, + sleep, + isCopilotSDKEnabled, + buildCopilotSDKEnv, + MIN_POST_RESULT_WATCHDOG_TIMEOUT_MS, + DEFAULT_POST_RESULT_WATCHDOG_IDLE_TIMEOUT_MS, + MAX_POST_RESULT_WATCHDOG_TIMEOUT_MS, + resolvePostResultWatchdogIdleTimeoutMs, + }; }