From 88e67eb1deeb26bb1289386b030a6c75a89375c8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 28 Jul 2026 03:52:10 +0000 Subject: [PATCH 1/3] Initial plan From c3c583133bd1385ce56b4fb74d73723da939c1af Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 28 Jul 2026 04:07:50 +0000 Subject: [PATCH 2/3] fix: retry startup no-output watchdog failures in copilot harness Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/copilot_harness.cjs | 35 ++++++++++++++++------- actions/setup/js/copilot_harness.test.cjs | 31 ++++++++++++++++---- 2 files changed, 51 insertions(+), 15 deletions(-) diff --git a/actions/setup/js/copilot_harness.cjs b/actions/setup/js/copilot_harness.cjs index 9c131b4ec07..9e0d4e4e673 100644 --- a/actions/setup/js/copilot_harness.cjs +++ b/actions/setup/js/copilot_harness.cjs @@ -72,7 +72,8 @@ const { resolveConfiguredCopilotModel } = require("./resolve_model_alias.cjs"); const AWF_CONFIG_PATH = process.env.GH_AW_AWF_CONFIG_PATH || "/tmp/gh-aw/awf-config.json"; // Additional startup retry budget for scheduled and push-triggered runs when Copilot exits with -// code 2 before producing any output (typically transient API interruption at startup). +// code 2 before producing any output (typically transient API interruption at startup), or exits +// with code 1 after the post-result watchdog terminates an idle turns=0 run. // Push-triggered runs share the same transient startup-failure mode as scheduled runs; the // retry budget is equally useful there to avoid a deterministic red on push/main. const MAX_SCHEDULED_EXIT2_RETRIES = 1; @@ -294,6 +295,15 @@ function computeStartupRetryEligible(eventName) { return eventName === "schedule" || eventName === "push"; } +/** + * Returns true when a failed attempt qualifies for the startup no-output retry budget. + * @param {{exitCode: number, hasOutput: boolean, watchdogFired?: boolean}} result + * @returns {boolean} + */ +function isStartupNoOutputRetryCandidate(result) { + return !result.hasOutput && (result.exitCode === 2 || (result.exitCode === 1 && result.watchdogFired === true)); +} + /** * Read AWF config written by the compiler before the agent runs. * @returns {any|null} @@ -1042,8 +1052,9 @@ async function main() { let delay = initialDelayMs; let lastExitCode = 1; let lastHasOutput = false; + let lastWatchdogFired = false; const isScheduledRun = process.env.GITHUB_EVENT_NAME === "schedule"; - // Push-triggered runs are also eligible for the startup retry budget (exit code 2, no output). + // Push-triggered runs are also eligible for the startup retry budget (startup no-output failure). // The Tidy workflow was previously push-triggered and showed deterministic Turns=0 failures // at startup; extending the retry to push prevents the same pattern from recurring if a // push trigger is (re)added to any agentic workflow. @@ -1144,6 +1155,7 @@ async function main() { }); lastExitCode = result.exitCode; lastHasOutput = result.hasOutput; + lastWatchdogFired = result.watchdogFired === true; const attemptDetections = detectCopilotErrors(result.output); detectedCopilotErrors.inferenceAccessError ||= attemptDetections.inferenceAccessError; detectedCopilotErrors.mcpPolicyError ||= attemptDetections.mcpPolicyError; @@ -1376,18 +1388,20 @@ async function main() { } } - // Scheduled and push-triggered runs: retry once on exit code 2 even when no output was - // produced. This specifically targets transient Copilot API outages at startup where - // there is no partial session state to continue from (Turns=0 driver-handoff failure). - if (isStartupRetryEligible && result.exitCode === 2 && !result.hasOutput && scheduledExit2Retries < MAX_SCHEDULED_EXIT2_RETRIES && attempt < maxRetries) { + // Scheduled and push-triggered runs: retry once when startup fails before any output. + // This covers both exit code 2 interruptions and post-result watchdog idle exits + // (exit code 1 with watchdogFired=true), where there is no partial session state to + // continue from (Turns=0 driver-handoff failure). + if (isStartupRetryEligible && isStartupNoOutputRetryCandidate(result) && scheduledExit2Retries < MAX_SCHEDULED_EXIT2_RETRIES && attempt < maxRetries) { scheduledExit2Retries += 1; scheduledExit2RetryAttempted = true; useContinueOnRetry = false; const triggerLabel = isScheduledRun ? "scheduled" : "push"; - log(`attempt ${attempt + 1}: ${triggerLabel} startup interruption (exit code 2, no output — driver-handoff Turns=0)` + ` — retrying once as fresh run (startupRetry=${scheduledExit2Retries}/${MAX_SCHEDULED_EXIT2_RETRIES})`); + const startupSignal = result.exitCode === 2 ? "exit code 2" : "watchdog idle exit (exit code 1)"; + log(`attempt ${attempt + 1}: ${triggerLabel} startup interruption (${startupSignal}, no output — driver-handoff Turns=0)` + ` — retrying once as fresh run (startupRetry=${scheduledExit2Retries}/${MAX_SCHEDULED_EXIT2_RETRIES})`); continue; } - if (isStartupRetryEligible && result.exitCode === 2 && !result.hasOutput && scheduledExit2Retries < MAX_SCHEDULED_EXIT2_RETRIES && attempt >= maxRetries) { + if (isStartupRetryEligible && isStartupNoOutputRetryCandidate(result) && scheduledExit2Retries < MAX_SCHEDULED_EXIT2_RETRIES && attempt >= maxRetries) { log(`attempt ${attempt + 1}: startup interruption detected (driver-handoff Turns=0) but retry budget exhausted — no attempts remain`); } @@ -1416,10 +1430,11 @@ async function main() { break; } - if (isStartupRetryEligible && lastExitCode === 2 && scheduledExit2RetryAttempted && !lastHasOutput) { + if (isStartupRetryEligible && scheduledExit2RetryAttempted && isStartupNoOutputRetryCandidate({ exitCode: lastExitCode, hasOutput: lastHasOutput, watchdogFired: lastWatchdogFired })) { const triggerLabel = isScheduledRun ? "scheduled" : "push"; + const startupSignal = lastExitCode === 2 ? "exit code 2" : "watchdog idle exit (exit code 1)"; emitInfrastructureIncomplete( - `Copilot API interruption (exit code 2) persisted after automatic retry in ${triggerLabel} workflow run. ` + "This is the Turns=0 driver-handoff failure signature. Check the agent-stdio.log for startup diagnostics." + `Copilot startup interruption (${startupSignal}) persisted after automatic retry in ${triggerLabel} workflow run. ` + "This is the Turns=0 driver-handoff failure signature. Check the agent-stdio.log for startup diagnostics." ); } } diff --git a/actions/setup/js/copilot_harness.test.cjs b/actions/setup/js/copilot_harness.test.cjs index 412eea401b6..28809fc2542 100644 --- a/actions/setup/js/copilot_harness.test.cjs +++ b/actions/setup/js/copilot_harness.test.cjs @@ -390,12 +390,12 @@ describe("copilot_harness.cjs", () => { }); }); - describe("scheduled startup retry policy (exit code 2)", () => { + describe("scheduled startup retry policy (startup no-output)", () => { const MAX_RETRIES = 3; const MAX_SCHEDULED_EXIT2_RETRIES = 1; /** - * @param {{hasOutput: boolean, exitCode: number}} result + * @param {{hasOutput: boolean, exitCode: number, watchdogFired?: boolean}} result * @param {number} attempt * @param {boolean} isStartupRetryEligible * @param {number} scheduledExit2Retries @@ -404,8 +404,9 @@ describe("copilot_harness.cjs", () => { function shouldRetry(result, attempt, isStartupRetryEligible, scheduledExit2Retries) { if (result.exitCode === 0) return false; + const isStartupNoOutputRetryCandidate = !result.hasOutput && (result.exitCode === 2 || (result.exitCode === 1 && result.watchdogFired === true)); // Scheduled or push startup outage: retry once even when no output was produced. - if (isStartupRetryEligible && result.exitCode === 2 && !result.hasOutput && scheduledExit2Retries < MAX_SCHEDULED_EXIT2_RETRIES && attempt < MAX_RETRIES) { + if (isStartupRetryEligible && isStartupNoOutputRetryCandidate && scheduledExit2Retries < MAX_SCHEDULED_EXIT2_RETRIES && attempt < MAX_RETRIES) { return true; } @@ -428,6 +429,17 @@ describe("copilot_harness.cjs", () => { expect(shouldRetry(result, 1, isEligible, 1)).toBe(false); }); + it("retries once for scheduled watchdog idle exit with no output", () => { + const result = { exitCode: 1, hasOutput: false, watchdogFired: true }; + expect(shouldRetry(result, 0, true, 0)).toBe(true); + expect(shouldRetry(result, 1, true, 1)).toBe(false); + }); + + it("does not retry exit code 1 with no output when watchdog did not fire", () => { + const result = { exitCode: 1, hasOutput: false, watchdogFired: false }; + expect(shouldRetry(result, 0, true, 0)).toBe(false); + }); + it("does not retry on exit code 2 with no output for non-eligible triggers", () => { const result = { exitCode: 2, hasOutput: false }; // pull_request and other event types are not startup-retry-eligible @@ -464,14 +476,19 @@ describe("copilot_harness.cjs", () => { * Mirrors the emitInfrastructureIncomplete guard in the harness. * Returns true when the incomplete diagnostic should be emitted. */ - function shouldEmitIncomplete({ isStartupRetryEligible, lastExitCode, retryAttempted, lastHasOutput }) { - return isStartupRetryEligible && lastExitCode === 2 && retryAttempted && !lastHasOutput; + function shouldEmitIncomplete({ isStartupRetryEligible, lastExitCode, lastWatchdogFired, retryAttempted, lastHasOutput }) { + const isStartupNoOutputRetryCandidate = !lastHasOutput && (lastExitCode === 2 || (lastExitCode === 1 && lastWatchdogFired === true)); + return isStartupRetryEligible && isStartupNoOutputRetryCandidate && retryAttempted; } it("emits diagnostic when terminal attempt had no output", () => { expect(shouldEmitIncomplete({ isStartupRetryEligible: true, lastExitCode: 2, retryAttempted: true, lastHasOutput: false })).toBe(true); }); + it("emits diagnostic for watchdog idle exit with no output", () => { + expect(shouldEmitIncomplete({ isStartupRetryEligible: true, lastExitCode: 1, lastWatchdogFired: true, retryAttempted: true, lastHasOutput: false })).toBe(true); + }); + it("does not emit diagnostic when terminal attempt produced output", () => { // retry fired but the terminal attempt recovered and produced output — not a Turns=0 failure expect(shouldEmitIncomplete({ isStartupRetryEligible: true, lastExitCode: 2, retryAttempted: true, lastHasOutput: true })).toBe(false); @@ -481,6 +498,10 @@ describe("copilot_harness.cjs", () => { expect(shouldEmitIncomplete({ isStartupRetryEligible: true, lastExitCode: 2, retryAttempted: false, lastHasOutput: false })).toBe(false); }); + it("does not emit diagnostic for exit code 1 when watchdog did not fire", () => { + expect(shouldEmitIncomplete({ isStartupRetryEligible: true, lastExitCode: 1, lastWatchdogFired: false, retryAttempted: true, lastHasOutput: false })).toBe(false); + }); + it("does not emit diagnostic for non-eligible event", () => { expect(shouldEmitIncomplete({ isStartupRetryEligible: false, lastExitCode: 2, retryAttempted: true, lastHasOutput: false })).toBe(false); }); From 612ae737bee457f4fe3a31188474eb4e5586d8ed Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 28 Jul 2026 08:08:03 +0000 Subject: [PATCH 3/3] fix: rework watchdog handling - late-activity suppression instead of startup retry Per reviewer feedback: the postResultWatchdog is only armed after hasTerminalSafeOutput is true, so watchdogFired cannot indicate a startup failure. A run with watchdogFired=true and hasOutput=false means the agent completed its task (wrote safe-output to disk) but produced no stdio output before the watchdog terminated the idle process. - Remove watchdogFired from isStartupNoOutputRetryCandidate (exit code 2 only) - Extend the late-activity suppression to also handle no_output + watchdogFired + hasTerminalSafeOutput, treating it as success rather than a startup retry - Remove lastWatchdogFired tracking (no longer needed for startup predicate) - Update startup retry comment/log to mention exit code 2 only - Update tests: remove watchdog startup retry cases, add late-activity suppression test for no-stdio-output + watchdogFired + hasTerminalSafeOutput - Fix pre-existing smoke-copilot-auto.lock.yml drift from merge Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/smoke-copilot-auto.lock.yml | 1 - actions/setup/js/copilot_harness.cjs | 32 ++++----- actions/setup/js/copilot_harness.test.cjs | 71 ++++++++++++++----- 3 files changed, 67 insertions(+), 37 deletions(-) diff --git a/.github/workflows/smoke-copilot-auto.lock.yml b/.github/workflows/smoke-copilot-auto.lock.yml index 80f7930c2f1..9cac699d23d 100644 --- a/.github/workflows/smoke-copilot-auto.lock.yml +++ b/.github/workflows/smoke-copilot-auto.lock.yml @@ -149,7 +149,6 @@ jobs: GH_AW_INFO_FIREWALL_TYPE: "squid" GH_AW_INFO_FRONTMATTER_EMOJI: "🌸" GH_AW_COMPILED_STRICT: "true" - GH_AW_INFO_MODEL_COSTS: '{"providers":{"github-copilot":{"models":{"auto":{"cost":{"input":"8.5e-07","output":"1.55e-06"}}}}}}' GH_AW_INFO_FEATURES: '{"gh-aw-detection":false}' uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: diff --git a/actions/setup/js/copilot_harness.cjs b/actions/setup/js/copilot_harness.cjs index 9e0d4e4e673..02b58ca5ee1 100644 --- a/actions/setup/js/copilot_harness.cjs +++ b/actions/setup/js/copilot_harness.cjs @@ -72,8 +72,7 @@ const { resolveConfiguredCopilotModel } = require("./resolve_model_alias.cjs"); const AWF_CONFIG_PATH = process.env.GH_AW_AWF_CONFIG_PATH || "/tmp/gh-aw/awf-config.json"; // Additional startup retry budget for scheduled and push-triggered runs when Copilot exits with -// code 2 before producing any output (typically transient API interruption at startup), or exits -// with code 1 after the post-result watchdog terminates an idle turns=0 run. +// code 2 before producing any output (typically transient API interruption at startup). // Push-triggered runs share the same transient startup-failure mode as scheduled runs; the // retry budget is equally useful there to avoid a deterministic red on push/main. const MAX_SCHEDULED_EXIT2_RETRIES = 1; @@ -297,11 +296,11 @@ function computeStartupRetryEligible(eventName) { /** * Returns true when a failed attempt qualifies for the startup no-output retry budget. - * @param {{exitCode: number, hasOutput: boolean, watchdogFired?: boolean}} result + * @param {{exitCode: number, hasOutput: boolean}} result * @returns {boolean} */ function isStartupNoOutputRetryCandidate(result) { - return !result.hasOutput && (result.exitCode === 2 || (result.exitCode === 1 && result.watchdogFired === true)); + return !result.hasOutput && result.exitCode === 2; } /** @@ -1052,9 +1051,8 @@ async function main() { let delay = initialDelayMs; let lastExitCode = 1; let lastHasOutput = false; - let lastWatchdogFired = false; const isScheduledRun = process.env.GITHUB_EVENT_NAME === "schedule"; - // Push-triggered runs are also eligible for the startup retry budget (startup no-output failure). + // Push-triggered runs are also eligible for the startup retry budget (exit code 2, no output). // The Tidy workflow was previously push-triggered and showed deterministic Turns=0 failures // at startup; extending the retry to push prevents the same pattern from recurring if a // push trigger is (re)added to any agentic workflow. @@ -1155,7 +1153,6 @@ async function main() { }); lastExitCode = result.exitCode; lastHasOutput = result.hasOutput; - lastWatchdogFired = result.watchdogFired === true; const attemptDetections = detectCopilotErrors(result.output); detectedCopilotErrors.inferenceAccessError ||= attemptDetections.inferenceAccessError; detectedCopilotErrors.mcpPolicyError ||= attemptDetections.mcpPolicyError; @@ -1270,7 +1267,11 @@ async function main() { // (watchdogFired=true), as well as any other partial_execution failure that occurs // after the primary task output was already produced. Retrying would reproduce the // same pattern and exhaust the retry budget without ever posting a final safe-output. - if ((failureClass === "partial_execution" || failureClass === "long_run_exit") && safeOutputsPath && hasTerminalSafeOutput(safeOutputsPath)) { + // The no_output + watchdogFired case is also handled here: the post-result watchdog is + // only armed after hasTerminalSafeOutput is true, so watchdogFired on a no-stdio-output + // run means the agent completed its task (wrote safe-output) but produced no console + // output before the watchdog terminated the idle process. + if ((failureClass === "partial_execution" || failureClass === "long_run_exit" || (failureClass === "no_output" && result.watchdogFired)) && safeOutputsPath && hasTerminalSafeOutput(safeOutputsPath)) { const reason = result.watchdogFired ? "post-result watchdog fired after terminal safe-output was emitted" : "partial execution after terminal safe-output was already produced"; log(`attempt ${attempt + 1}: ${reason} — treating as success (late-activity exit suppressed)`); lastExitCode = 0; @@ -1388,17 +1389,15 @@ async function main() { } } - // Scheduled and push-triggered runs: retry once when startup fails before any output. - // This covers both exit code 2 interruptions and post-result watchdog idle exits - // (exit code 1 with watchdogFired=true), where there is no partial session state to - // continue from (Turns=0 driver-handoff failure). + // Scheduled and push-triggered runs: retry once on exit code 2 even when no output was + // produced. This specifically targets transient Copilot API outages at startup where + // there is no partial session state to continue from (Turns=0 driver-handoff failure). if (isStartupRetryEligible && isStartupNoOutputRetryCandidate(result) && scheduledExit2Retries < MAX_SCHEDULED_EXIT2_RETRIES && attempt < maxRetries) { scheduledExit2Retries += 1; scheduledExit2RetryAttempted = true; useContinueOnRetry = false; const triggerLabel = isScheduledRun ? "scheduled" : "push"; - const startupSignal = result.exitCode === 2 ? "exit code 2" : "watchdog idle exit (exit code 1)"; - log(`attempt ${attempt + 1}: ${triggerLabel} startup interruption (${startupSignal}, no output — driver-handoff Turns=0)` + ` — retrying once as fresh run (startupRetry=${scheduledExit2Retries}/${MAX_SCHEDULED_EXIT2_RETRIES})`); + log(`attempt ${attempt + 1}: ${triggerLabel} startup interruption (exit code 2, no output — driver-handoff Turns=0)` + ` — retrying once as fresh run (startupRetry=${scheduledExit2Retries}/${MAX_SCHEDULED_EXIT2_RETRIES})`); continue; } if (isStartupRetryEligible && isStartupNoOutputRetryCandidate(result) && scheduledExit2Retries < MAX_SCHEDULED_EXIT2_RETRIES && attempt >= maxRetries) { @@ -1430,11 +1429,10 @@ async function main() { break; } - if (isStartupRetryEligible && scheduledExit2RetryAttempted && isStartupNoOutputRetryCandidate({ exitCode: lastExitCode, hasOutput: lastHasOutput, watchdogFired: lastWatchdogFired })) { + if (isStartupRetryEligible && scheduledExit2RetryAttempted && isStartupNoOutputRetryCandidate({ exitCode: lastExitCode, hasOutput: lastHasOutput })) { const triggerLabel = isScheduledRun ? "scheduled" : "push"; - const startupSignal = lastExitCode === 2 ? "exit code 2" : "watchdog idle exit (exit code 1)"; emitInfrastructureIncomplete( - `Copilot startup interruption (${startupSignal}) persisted after automatic retry in ${triggerLabel} workflow run. ` + "This is the Turns=0 driver-handoff failure signature. Check the agent-stdio.log for startup diagnostics." + `Copilot API interruption (exit code 2) persisted after automatic retry in ${triggerLabel} workflow run. ` + "This is the Turns=0 driver-handoff failure signature. Check the agent-stdio.log for startup diagnostics." ); } } diff --git a/actions/setup/js/copilot_harness.test.cjs b/actions/setup/js/copilot_harness.test.cjs index 28809fc2542..80c1c4d65a0 100644 --- a/actions/setup/js/copilot_harness.test.cjs +++ b/actions/setup/js/copilot_harness.test.cjs @@ -390,12 +390,12 @@ describe("copilot_harness.cjs", () => { }); }); - describe("scheduled startup retry policy (startup no-output)", () => { + describe("scheduled startup retry policy (exit code 2)", () => { const MAX_RETRIES = 3; const MAX_SCHEDULED_EXIT2_RETRIES = 1; /** - * @param {{hasOutput: boolean, exitCode: number, watchdogFired?: boolean}} result + * @param {{hasOutput: boolean, exitCode: number}} result * @param {number} attempt * @param {boolean} isStartupRetryEligible * @param {number} scheduledExit2Retries @@ -404,7 +404,7 @@ describe("copilot_harness.cjs", () => { function shouldRetry(result, attempt, isStartupRetryEligible, scheduledExit2Retries) { if (result.exitCode === 0) return false; - const isStartupNoOutputRetryCandidate = !result.hasOutput && (result.exitCode === 2 || (result.exitCode === 1 && result.watchdogFired === true)); + const isStartupNoOutputRetryCandidate = !result.hasOutput && result.exitCode === 2; // Scheduled or push startup outage: retry once even when no output was produced. if (isStartupRetryEligible && isStartupNoOutputRetryCandidate && scheduledExit2Retries < MAX_SCHEDULED_EXIT2_RETRIES && attempt < MAX_RETRIES) { return true; @@ -429,14 +429,8 @@ describe("copilot_harness.cjs", () => { expect(shouldRetry(result, 1, isEligible, 1)).toBe(false); }); - it("retries once for scheduled watchdog idle exit with no output", () => { - const result = { exitCode: 1, hasOutput: false, watchdogFired: true }; - expect(shouldRetry(result, 0, true, 0)).toBe(true); - expect(shouldRetry(result, 1, true, 1)).toBe(false); - }); - - it("does not retry exit code 1 with no output when watchdog did not fire", () => { - const result = { exitCode: 1, hasOutput: false, watchdogFired: false }; + it("does not retry exit code 1 with no output (watchdog-fired exits are suppressed as late-activity, not retried)", () => { + const result = { exitCode: 1, hasOutput: false }; expect(shouldRetry(result, 0, true, 0)).toBe(false); }); @@ -476,8 +470,8 @@ describe("copilot_harness.cjs", () => { * Mirrors the emitInfrastructureIncomplete guard in the harness. * Returns true when the incomplete diagnostic should be emitted. */ - function shouldEmitIncomplete({ isStartupRetryEligible, lastExitCode, lastWatchdogFired, retryAttempted, lastHasOutput }) { - const isStartupNoOutputRetryCandidate = !lastHasOutput && (lastExitCode === 2 || (lastExitCode === 1 && lastWatchdogFired === true)); + function shouldEmitIncomplete({ isStartupRetryEligible, lastExitCode, retryAttempted, lastHasOutput }) { + const isStartupNoOutputRetryCandidate = !lastHasOutput && lastExitCode === 2; return isStartupRetryEligible && isStartupNoOutputRetryCandidate && retryAttempted; } @@ -485,10 +479,6 @@ describe("copilot_harness.cjs", () => { expect(shouldEmitIncomplete({ isStartupRetryEligible: true, lastExitCode: 2, retryAttempted: true, lastHasOutput: false })).toBe(true); }); - it("emits diagnostic for watchdog idle exit with no output", () => { - expect(shouldEmitIncomplete({ isStartupRetryEligible: true, lastExitCode: 1, lastWatchdogFired: true, retryAttempted: true, lastHasOutput: false })).toBe(true); - }); - it("does not emit diagnostic when terminal attempt produced output", () => { // retry fired but the terminal attempt recovered and produced output — not a Turns=0 failure expect(shouldEmitIncomplete({ isStartupRetryEligible: true, lastExitCode: 2, retryAttempted: true, lastHasOutput: true })).toBe(false); @@ -498,8 +488,8 @@ describe("copilot_harness.cjs", () => { expect(shouldEmitIncomplete({ isStartupRetryEligible: true, lastExitCode: 2, retryAttempted: false, lastHasOutput: false })).toBe(false); }); - it("does not emit diagnostic for exit code 1 when watchdog did not fire", () => { - expect(shouldEmitIncomplete({ isStartupRetryEligible: true, lastExitCode: 1, lastWatchdogFired: false, retryAttempted: true, lastHasOutput: false })).toBe(false); + it("does not emit diagnostic for exit code 1 (watchdog-fired exits are suppressed as late-activity, not emitted as incomplete)", () => { + expect(shouldEmitIncomplete({ isStartupRetryEligible: true, lastExitCode: 1, retryAttempted: true, lastHasOutput: false })).toBe(false); }); it("does not emit diagnostic for non-eligible event", () => { @@ -2587,6 +2577,49 @@ process.exit(1);`, expect(result.status).toBe(1); expect(result.stderr).not.toContain("late-activity exit suppressed"); }); + + it("exits 0 without retrying when watchdog fires after terminal safe-output was produced (no stdio output)", () => { + const tempDir = makeHarnessTempDir("copilot-watchdog-no-stdio-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 but produces NO stdio output. + // The watchdog arms (because hasTerminalSafeOutput is true) and fires after inactivity. + // This exercises the no_output + watchdogFired + hasTerminalSafeOutput suppression path. + fs.writeFileSync( + stubPath, + `const fs = require("fs"); +const callsPath = process.env.COPILOT_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_comment",body:"Report posted"}) + "\\n"); +// No stdout output — hasOutput remains false +process.on("SIGTERM", () => process.exit(1)); +setInterval(() => {}, 1000);`, + "utf8" + ); + fs.writeFileSync(promptPath, "generate the report", "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_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"); + }); }); describe("applyCopilotWireAPI", () => {