From 81425230f557591e6a7d2a1d12bcac1f4782c126 Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Fri, 15 May 2026 07:25:06 +0200 Subject: [PATCH] fix(headless): do not restart graceful child exits --- src/headless-events.ts | 17 +++++++++++++++++ src/headless.ts | 7 ++++--- src/tests/headless-cli-surface.test.ts | 14 ++++++++++++++ 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/src/headless-events.ts b/src/headless-events.ts index b2697fbdd..918657f0f 100644 --- a/src/headless-events.ts +++ b/src/headless-events.ts @@ -67,6 +67,23 @@ export interface HeadlessRestartDecisionInput { maxRestarts: number; } +/** + * Convert an unexpected child-process exit into the outer headless exit code. + * + * Purpose: keep crash recovery for real child failures without turning a + * graceful child exit or operator stop into an automatic restart loop. + * + * Consumer: headless.ts child process exit handler before restart policy runs. + */ +export function classifyUnexpectedChildExit( + code: number | null, + signal?: NodeJS.Signals | null, +): number { + if (code === EXIT_SUCCESS) return EXIT_SUCCESS; + if (signal === "SIGINT" || signal === "SIGTERM") return EXIT_CANCELLED; + return EXIT_ERROR; +} + /** * Decide whether the headless outer loop should restart a completed run. * diff --git a/src/headless.ts b/src/headless.ts index 83cb013db..2f484bd47 100644 --- a/src/headless.ts +++ b/src/headless.ts @@ -35,6 +35,7 @@ import { } from "./headless-context.js"; import { + classifyUnexpectedChildExit, EXIT_BLOCKED, EXIT_CANCELLED, EXIT_ERROR, @@ -1985,11 +1986,11 @@ async function runHeadlessOnce( // Detect child process crash (read-only exit event subscription — not stdin access) const internalProcess = (client as any).process as ChildProcess; if (internalProcess) { - internalProcess.on("exit", (code) => { + internalProcess.on("exit", (code, signal) => { if (!completed) { - const msg = `[headless] Child process exited unexpectedly with code ${code ?? "null"}\n`; + const msg = `[headless] Child process exited unexpectedly with code ${code ?? "null"}${signal ? ` signal ${signal}` : ""}\n`; process.stderr.write(msg); - exitCode = EXIT_ERROR; + exitCode = classifyUnexpectedChildExit(code, signal); resolveCompletion(); } }); diff --git a/src/tests/headless-cli-surface.test.ts b/src/tests/headless-cli-surface.test.ts index 3e6f90226..30fb6e368 100644 --- a/src/tests/headless-cli-surface.test.ts +++ b/src/tests/headless-cli-surface.test.ts @@ -12,6 +12,7 @@ import { test } from "vitest"; // ─── Import exit code constants & mapStatusToExitCode ────────────────────── import { + classifyUnexpectedChildExit, EXIT_BLOCKED, EXIT_CANCELLED, EXIT_ERROR, @@ -384,6 +385,19 @@ test("shouldRestartHeadlessRun still retries unexpected errors within budget", ( ); }); +test("classifyUnexpectedChildExit treats graceful child exit as terminal success", () => { + assert.equal(classifyUnexpectedChildExit(0, null), EXIT_SUCCESS); +}); + +test("classifyUnexpectedChildExit treats operator child signal as cancellation", () => { + assert.equal(classifyUnexpectedChildExit(null, "SIGTERM"), EXIT_CANCELLED); + assert.equal(classifyUnexpectedChildExit(null, "SIGINT"), EXIT_CANCELLED); +}); + +test("classifyUnexpectedChildExit keeps nonzero child exits restartable errors", () => { + assert.equal(classifyUnexpectedChildExit(1, null), EXIT_ERROR); +}); + // ─── HeadlessJsonResult type shape ───────────────────────────────────────── test("HeadlessJsonResult satisfies expected shape", () => {