diff --git a/src/main/services/workspace-command.ts b/src/main/services/workspace-command.ts index c9a3cc56..3f3094fb 100644 --- a/src/main/services/workspace-command.ts +++ b/src/main/services/workspace-command.ts @@ -75,12 +75,18 @@ export function runCommand(workspaceRoot: string, command: string, options: { ti child.stdout.on("data", collect); child.stderr.on("data", collect); child.on("error", (error) => finish(`ERROR: ${error.message}`)); - child.on("close", (code) => { - finish(`exit code ${code ?? 0}\n${output.text().trim() || "(no output)"}`); - }); + child.on("close", (code, signal) => finish(formatCommandResult(code, signal, output.text()))); }); } +/** The tool result for a finished command; no exit code means it was killed, which is never success. */ +export function formatCommandResult(code: number | null, signal: NodeJS.Signals | null, text: string): string { + const body = text.trim() || "(no output)"; + if (code !== null) return `exit code ${code}\n${body}`; + const how = signal ? `was killed by ${signal} before it exited` : "ended without an exit code"; + return `ERROR: the command ${how}; treat it as failed.\n${body}`; +} + /** Kills a process and every process it started; returns why that failed, or null. */ function killProcessTree(pid: number | undefined): string | null { if (pid === undefined) return null; diff --git a/tests/unit/workspace-command.test.ts b/tests/unit/workspace-command.test.ts index 31e97af9..25e01cc1 100644 --- a/tests/unit/workspace-command.test.ts +++ b/tests/unit/workspace-command.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, test, vi } from "vitest"; -import { BoundedOutput, runCommand } from "../../src/main/services/workspace-command"; +import { BoundedOutput, formatCommandResult, runCommand } from "../../src/main/services/workspace-command"; import { stopRun, withRunControl } from "../../src/main/services/run-control"; describe("bounded command output", () => { @@ -66,3 +66,12 @@ describe("run_command and run stops", () => { warn.mockRestore(); }); }); + +describe("command results", () => { + test("a signal-terminated command is reported as killed, never as exit code 0", () => { + expect(formatCommandResult(0, null, "built")).toBe("exit code 0\nbuilt"); + expect(formatCommandResult(2, null, "")).toBe("exit code 2\n(no output)"); + expect(formatCommandResult(null, "SIGKILL", "partial")).toBe("ERROR: the command was killed by SIGKILL before it exited; treat it as failed.\npartial"); + expect(formatCommandResult(null, null, "")).toBe("ERROR: the command ended without an exit code; treat it as failed.\n(no output)"); + }); +});