diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8e486f6 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +* text=auto eol=lf +*.cmd text eol=crlf +*.bat text eol=crlf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 54d6a88..f08e344 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,11 @@ on: jobs: build-and-test: - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v6 @@ -16,7 +20,6 @@ jobs: node-version: 24 cache: npm - run: npm ci - - run: npm run build - run: npm run lint - run: npm run format:check - run: npm test diff --git a/README.md b/README.md index d38bf9a..8f3de35 100644 --- a/README.md +++ b/README.md @@ -18,10 +18,10 @@ src="https://img.shields.io/github/actions/workflow/status/kunchenguid/gnhf/release-please.yml?style=flat-square&label=release" /> Platform ` | Agent to use (`claude`, `codex`, `rovodev`, or `opencode`) | config file (`claude`) | -| `--max-iterations ` | Abort after `n` total iterations | unlimited | -| `--max-tokens ` | Abort after `n` total input+output tokens | unlimited | -| `--version` | Show version | | +| Flag | Description | Default | +| ------------------------ | ------------------------------------------------------------------ | ---------------------- | +| `--agent ` | Agent to use (`claude`, `codex`, `rovodev`, or `opencode`) | config file (`claude`) | +| `--max-iterations ` | Abort after `n` total iterations | unlimited | +| `--max-tokens ` | Abort after `n` total input+output tokens | unlimited | +| `--prevent-sleep ` | Prevent system sleep during the run (`on`/`off` or `true`/`false`) | config file (`on`) | +| `--version` | Show version | | ## Configuration @@ -160,11 +162,24 @@ agent: claude # Abort after this many consecutive failures maxConsecutiveFailures: 3 + +# Prevent the machine from sleeping during a run +preventSleep: true ``` If the file does not exist yet, `gnhf` creates it on first run using the resolved defaults. -CLI flags override config file values. The iteration and token caps are runtime-only flags and are not persisted in `config.yml`. +CLI flags override config file values. `--prevent-sleep` accepts `on`/`off` as well as `true`/`false`; the config file always uses a boolean. +The iteration and token caps are runtime-only flags and are not persisted in `config.yml`. +When sleep prevention is enabled, `gnhf` uses the native mechanism for your OS: `caffeinate` on macOS, `systemd-inhibit` on Linux, and a small PowerShell helper backed by `SetThreadExecutionState` on Windows. + +## Debug Logs + +Set `GNHF_DEBUG_LOG_PATH` to capture lifecycle events as JSONL while debugging a run: + +```sh +GNHF_DEBUG_LOG_PATH=/tmp/gnhf-debug.jsonl gnhf "ship it" +``` ## Agents @@ -182,7 +197,8 @@ CLI flags override config file values. The iteration and token caps are runtime- ```sh npm run build # Build with tsdown npm run dev # Watch mode -npm test # Run tests (vitest) +npm test # Build, then run unit tests (vitest) +npm run test:e2e # Build, then run end-to-end tests against the mock opencode executable npm run lint # ESLint npm run format # Prettier ``` diff --git a/package.json b/package.json index 7bbdad3..68d920a 100644 --- a/package.json +++ b/package.json @@ -9,12 +9,13 @@ "scripts": { "build": "tsdown", "dev": "tsdown --watch", - "start": "node dist/cli.js", + "start": "node dist/cli.mjs", "lint": "eslint src", "format": "prettier --write src", "format:check": "prettier --check src", - "test": "vitest run", - "test:coverage": "vitest run --coverage" + "test": "npm run build && vitest run", + "test:e2e": "npm run build && vitest run test/e2e.test.ts", + "test:coverage": "vitest run --coverage --exclude test/e2e.test.ts" }, "dependencies": { "commander": "^14.0.3", diff --git a/src/cli.test.ts b/src/cli.test.ts index 3c57ea5..39fc8cf 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -1,4 +1,12 @@ -import { readFileSync } from "node:fs"; +import { + existsSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; import { describe, expect, it, vi } from "vitest"; import type { Config } from "./core/config.js"; import type { RunInfo } from "./core/run.js"; @@ -18,9 +26,15 @@ const stubRunInfo: RunInfo = { }; interface CliMockOverrides { + appendDebugLog?: ReturnType; + createAgent?: ReturnType; + env?: Record; orchestratorStart?: ReturnType; + readStdinText?: ReturnType; rendererWaitUntilExit?: ReturnType; rendererStop?: ReturnType; + startSleepPrevention?: ReturnType; + stdinIsTTY?: boolean; } async function runCliWithMocks( @@ -41,7 +55,14 @@ async function runCliWithMocks( }) as typeof process.exit); const loadConfig = vi.fn(() => config); - const createAgent = vi.fn(() => ({ name: config.agent })); + const createAgent = + overrides.createAgent ?? vi.fn(() => ({ name: config.agent })); + const appendDebugLog = overrides.appendDebugLog ?? vi.fn(); + const readStdinText = + overrides.readStdinText ?? vi.fn(() => Promise.resolve("")); + const startSleepPrevention = + overrides.startSleepPrevention ?? + vi.fn(() => Promise.resolve({ type: "skipped", reason: "unsupported" })); const orchestratorStart = overrides.orchestratorStart ?? vi.fn(() => Promise.resolve()); @@ -70,6 +91,7 @@ async function runCliWithMocks( vi.resetModules(); vi.doMock("./core/config.js", () => ({ loadConfig })); + vi.doMock("./core/debug-log.js", () => ({ appendDebugLog })); vi.doMock("./core/git.js", () => ({ ensureCleanWorkingTree: vi.fn(), createBranch: vi.fn(), @@ -81,7 +103,11 @@ async function runCliWithMocks( resumeRun: vi.fn(), getLastIterationNumber: vi.fn(() => 0), })); + vi.doMock("./core/stdin.js", () => ({ readStdinText })); vi.doMock("./core/agents/factory.js", () => ({ createAgent })); + vi.doMock("./core/sleep.js", () => ({ + startSleepPrevention, + })); vi.doMock("./core/orchestrator.js", () => ({ Orchestrator: class MockOrchestrator { constructor(...args: unknown[]) { @@ -102,16 +128,49 @@ async function runCliWithMocks( })); process.argv = ["node", "gnhf", ...args]; + const originalIsTTY = Object.getOwnPropertyDescriptor(process.stdin, "isTTY"); + Object.defineProperty(process.stdin, "isTTY", { + configurable: true, + value: overrides.stdinIsTTY ?? true, + }); + const envEntries = Object.entries(overrides.env ?? {}); + const originalEnv = new Map( + envEntries.map(([key]) => [key, process.env[key]]), + ); + for (const [key, value] of envEntries) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } try { await import("./cli.js"); } finally { process.argv = originalArgv; + if (originalIsTTY) { + Object.defineProperty(process.stdin, "isTTY", originalIsTTY); + } + for (const [key, value] of originalEnv) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } stdoutWrite.mockRestore(); exitSpy.mockRestore(); } - return { loadConfig, createAgent, orchestratorCtor }; + return { + appendDebugLog, + loadConfig, + createAgent, + orchestratorCtor, + readStdinText, + startSleepPrevention, + }; } describe("cli", () => { @@ -120,21 +179,32 @@ describe("cli", () => { const stdoutWrite = vi .spyOn(process.stdout, "write") .mockImplementation(() => true); - const exitSpy = vi - .spyOn(process, "exit") - .mockImplementation((() => undefined) as typeof process.exit); + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + const exitSpy = vi.spyOn(process, "exit").mockImplementation((( + code?: string | number | null, + ) => { + throw new Error( + `process.exit unexpectedly called with ${JSON.stringify(code)}`, + ); + }) as typeof process.exit); process.argv = ["node", "gnhf", "-V"]; try { vi.resetModules(); - await import("./cli.js"); + await expect(import("./cli.js")).rejects.toThrow( + /process\.exit unexpectedly called with 1/, + ); expect(stdoutWrite).toHaveBeenCalledWith(`${packageVersion}\n`); - expect(exitSpy).toHaveBeenCalledWith(0); + expect(exitSpy).toHaveBeenNthCalledWith(1, 0); + expect(exitSpy).toHaveBeenNthCalledWith(2, 1); } finally { process.argv = originalArgv; stdoutWrite.mockRestore(); + consoleError.mockRestore(); exitSpy.mockRestore(); } }); @@ -143,9 +213,10 @@ describe("cli", () => { const { loadConfig, createAgent } = await runCliWithMocks(["ship it"], { agent: "codex", maxConsecutiveFailures: 3, + preventSleep: false, }); - expect(loadConfig).toHaveBeenCalledWith(undefined); + expect(loadConfig).toHaveBeenCalledWith({}); expect(createAgent).toHaveBeenCalledWith("codex", stubRunInfo); }); @@ -155,6 +226,7 @@ describe("cli", () => { { agent: "claude", maxConsecutiveFailures: 3, + preventSleep: false, }, ); @@ -168,6 +240,7 @@ describe("cli", () => { { agent: "rovodev", maxConsecutiveFailures: 3, + preventSleep: false, }, ); @@ -181,6 +254,7 @@ describe("cli", () => { { agent: "opencode", maxConsecutiveFailures: 3, + preventSleep: false, }, ); @@ -194,6 +268,7 @@ describe("cli", () => { { agent: "claude", maxConsecutiveFailures: 3, + preventSleep: false, }, ); @@ -204,6 +279,428 @@ describe("cli", () => { }); }); + it("treats --prevent-sleep as a runtime override without passing it to config bootstrap", async () => { + const { loadConfig, orchestratorCtor, startSleepPrevention } = + await runCliWithMocks(["ship it", "--prevent-sleep", "off"], { + agent: "claude", + maxConsecutiveFailures: 3, + preventSleep: false, + }); + + expect(loadConfig).toHaveBeenCalledWith({}); + expect(startSleepPrevention).not.toHaveBeenCalled(); + expect(orchestratorCtor).toHaveBeenCalledTimes(1); + expect(orchestratorCtor.mock.calls[0]?.[0]).toEqual({ + agent: "claude", + maxConsecutiveFailures: 3, + preventSleep: false, + }); + }); + + it("does not emit run:start from the Linux sleep-prevention wrapper process", async () => { + const appendDebugLog = vi.fn(); + const startSleepPrevention = vi.fn(() => + Promise.resolve({ type: "reexeced" as const, exitCode: 0 }), + ); + + await expect( + runCliWithMocks( + ["ship it"], + { + agent: "claude", + maxConsecutiveFailures: 3, + preventSleep: true, + }, + { appendDebugLog, startSleepPrevention }, + ), + ).rejects.toThrow(/process\.exit unexpectedly called/); + + expect(startSleepPrevention).toHaveBeenCalledTimes(1); + expect(appendDebugLog).not.toHaveBeenCalledWith( + "run:start", + expect.anything(), + ); + }); + + it("passes the stdin prompt to Linux sleep-prevention re-exec via a temp file", async () => { + let promptFilePath: string | undefined; + const readStdinText = vi.fn(() => Promise.resolve("objective from stdin")); + const startSleepPrevention = vi.fn(async (_argv, deps) => { + promptFilePath = deps?.reexecEnv?.GNHF_REEXEC_STDIN_PROMPT_FILE; + expect(promptFilePath).toEqual(expect.any(String)); + expect(deps?.reexecEnv?.GNHF_REEXEC_STDIN_PROMPT).toBeUndefined(); + expect(readFileSync(promptFilePath!, "utf-8")).toBe( + "objective from stdin", + ); + return { type: "skipped" as const, reason: "unsupported" }; + }); + + await runCliWithMocks( + [], + { + agent: "claude", + maxConsecutiveFailures: 3, + preventSleep: true, + }, + { + readStdinText, + startSleepPrevention, + stdinIsTTY: false, + }, + ); + + expect(readStdinText).toHaveBeenCalledTimes(1); + expect(startSleepPrevention).toHaveBeenCalledTimes(1); + expect(promptFilePath).toBeDefined(); + expect(existsSync(promptFilePath!)).toBe(false); + }); + + it("uses the serialized stdin prompt file after Linux sleep-prevention re-exec", async () => { + const readStdinText = vi.fn(() => Promise.resolve("should not be read")); + const startSleepPrevention = vi.fn(() => + Promise.resolve({ + type: "skipped" as const, + reason: "already-inhibited", + }), + ); + const promptDir = mkdtempSync(join(tmpdir(), "gnhf-stdin-")); + const promptPath = join(promptDir, "prompt.txt"); + writeFileSync(promptPath, "objective from stdin", "utf-8"); + + try { + const { orchestratorCtor } = await runCliWithMocks( + [], + { + agent: "claude", + maxConsecutiveFailures: 3, + preventSleep: true, + }, + { + env: { + GNHF_REEXEC_STDIN_PROMPT_FILE: promptPath, + GNHF_SLEEP_INHIBITED: "1", + }, + readStdinText, + startSleepPrevention, + stdinIsTTY: false, + }, + ); + + expect(readStdinText).not.toHaveBeenCalled(); + expect(startSleepPrevention).toHaveBeenCalledTimes(1); + expect(orchestratorCtor).toHaveBeenCalledTimes(1); + expect(orchestratorCtor.mock.calls[0]?.[3]).toBe("objective from stdin"); + expect(existsSync(promptPath)).toBe(false); + expect(existsSync(dirname(promptPath))).toBe(false); + } finally { + rmSync(promptDir, { recursive: true, force: true }); + } + }); + + it("falls back to stdin when Linux sleep inhibition is inherited without a serialized prompt", async () => { + const readStdinText = vi.fn(() => Promise.resolve("objective from stdin")); + const startSleepPrevention = vi.fn(() => + Promise.resolve({ + type: "skipped" as const, + reason: "already-inhibited", + }), + ); + + const { orchestratorCtor } = await runCliWithMocks( + [], + { + agent: "claude", + maxConsecutiveFailures: 3, + preventSleep: true, + }, + { + env: { + GNHF_SLEEP_INHIBITED: "1", + }, + readStdinText, + startSleepPrevention, + stdinIsTTY: false, + }, + ); + + expect(readStdinText).toHaveBeenCalledTimes(1); + expect(startSleepPrevention).toHaveBeenCalledTimes(1); + expect(orchestratorCtor).toHaveBeenCalledTimes(1); + expect(orchestratorCtor.mock.calls[0]?.[3]).toBe("objective from stdin"); + }); + + it("clears the serialized stdin prompt file path from process.env after reading it", async () => { + let inheritedPromptPath: string | undefined; + const createAgent = vi.fn(() => { + inheritedPromptPath = process.env.GNHF_REEXEC_STDIN_PROMPT_FILE; + return { name: "claude" }; + }); + const startSleepPrevention = vi.fn(() => + Promise.resolve({ + type: "skipped" as const, + reason: "already-inhibited", + }), + ); + const promptDir = mkdtempSync(join(tmpdir(), "gnhf-stdin-")); + const promptPath = join(promptDir, "prompt.txt"); + writeFileSync(promptPath, "sensitive prompt", "utf-8"); + + try { + await runCliWithMocks( + [], + { + agent: "claude", + maxConsecutiveFailures: 3, + preventSleep: true, + }, + { + createAgent, + env: { + GNHF_REEXEC_STDIN_PROMPT_FILE: promptPath, + GNHF_SLEEP_INHIBITED: "1", + }, + startSleepPrevention, + }, + ); + + expect(startSleepPrevention).toHaveBeenCalledTimes(1); + expect(createAgent).toHaveBeenCalledTimes(1); + expect(inheritedPromptPath).toBeUndefined(); + expect(existsSync(promptPath)).toBe(false); + } finally { + rmSync(promptDir, { recursive: true, force: true }); + } + }); + + it("does not recursively delete an untrusted prompt file parent directory", async () => { + const promptDir = mkdtempSync(join(tmpdir(), "gnhf-cli-test-")); + const promptPath = join(promptDir, "prompt-from-env.txt"); + const siblingPath = join(promptDir, "keep.txt"); + writeFileSync(promptPath, "prompt from env", "utf-8"); + writeFileSync(siblingPath, "keep me", "utf-8"); + + try { + const { orchestratorCtor } = await runCliWithMocks( + [], + { + agent: "claude", + maxConsecutiveFailures: 3, + preventSleep: true, + }, + { + env: { + GNHF_REEXEC_STDIN_PROMPT_FILE: promptPath, + GNHF_SLEEP_INHIBITED: "1", + }, + startSleepPrevention: vi.fn(() => + Promise.resolve({ + type: "skipped" as const, + reason: "already-inhibited", + }), + ), + }, + ); + + expect(orchestratorCtor).toHaveBeenCalledTimes(1); + expect(orchestratorCtor.mock.calls[0]?.[3]).toBe("prompt from env"); + expect(existsSync(promptDir)).toBe(true); + expect(existsSync(siblingPath)).toBe(true); + } finally { + rmSync(promptDir, { recursive: true, force: true }); + } + }); + + it("signals Linux sleep-prevention re-exec readiness before loading config", async () => { + const loadConfig = vi.fn(() => ({ + agent: "claude" as const, + maxConsecutiveFailures: 3, + preventSleep: true, + })); + const startSleepPrevention = vi.fn(() => + Promise.resolve({ + type: "skipped" as const, + reason: "already-inhibited", + }), + ); + + vi.resetModules(); + vi.doMock("./core/config.js", () => ({ loadConfig })); + vi.doMock("./core/debug-log.js", () => ({ appendDebugLog: vi.fn() })); + vi.doMock("./core/git.js", () => ({ + ensureCleanWorkingTree: vi.fn(), + createBranch: vi.fn(), + getHeadCommit: vi.fn(() => "abc123"), + getCurrentBranch: vi.fn(() => "main"), + })); + vi.doMock("./core/run.js", () => ({ + setupRun: vi.fn(() => stubRunInfo), + resumeRun: vi.fn(), + getLastIterationNumber: vi.fn(() => 0), + })); + vi.doMock("./core/stdin.js", () => ({ + readStdinText: vi.fn(() => Promise.resolve("")), + })); + vi.doMock("./core/agents/factory.js", () => ({ + createAgent: vi.fn(() => ({ name: "claude" })), + })); + vi.doMock("./core/sleep.js", () => ({ + startSleepPrevention, + })); + vi.doMock("./core/orchestrator.js", () => ({ + Orchestrator: class MockOrchestrator { + start = vi.fn(() => Promise.resolve()); + stop = vi.fn(); + on = vi.fn(); + getState = vi.fn(() => ({ + status: "running" as const, + currentIteration: 0, + totalInputTokens: 0, + totalOutputTokens: 0, + commitCount: 0, + iterations: [], + successCount: 0, + failCount: 0, + consecutiveFailures: 0, + startTime: new Date("2026-01-01T00:00:00Z"), + waitingUntil: null, + lastMessage: null, + })); + }, + })); + vi.doMock("./renderer.js", () => ({ + Renderer: class MockRenderer { + start = vi.fn(); + stop = vi.fn(); + waitUntilExit = vi.fn(() => Promise.resolve()); + }, + })); + + const originalArgv = [...process.argv]; + const stdoutWrite = vi + .spyOn(process.stdout, "write") + .mockImplementation(() => true); + const exitSpy = vi + .spyOn(process, "exit") + .mockImplementation((() => undefined) as typeof process.exit); + + process.argv = ["node", "gnhf", "ship it"]; + const originalSleepInhibited = process.env.GNHF_SLEEP_INHIBITED; + process.env.GNHF_SLEEP_INHIBITED = "1"; + + try { + await import("./cli.js"); + + expect(startSleepPrevention).toHaveBeenCalledTimes(1); + expect(loadConfig).toHaveBeenCalledTimes(1); + expect(startSleepPrevention.mock.invocationCallOrder[0]).toBeLessThan( + loadConfig.mock.invocationCallOrder[0] ?? Infinity, + ); + } finally { + process.argv = originalArgv; + if (originalSleepInhibited === undefined) { + delete process.env.GNHF_SLEEP_INHIBITED; + } else { + process.env.GNHF_SLEEP_INHIBITED = originalSleepInhibited; + } + stdoutWrite.mockRestore(); + exitSpy.mockRestore(); + } + }); + + it("does not start sleep prevention when quitting from the overwrite prompt", async () => { + const originalArgv = [...process.argv]; + const stdoutWrite = vi + .spyOn(process.stdout, "write") + .mockImplementation(() => true); + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + const exitSpy = vi.spyOn(process, "exit").mockImplementation((( + code?: string | number | null, + ) => { + throw new Error( + `process.exit unexpectedly called with ${JSON.stringify(code)}`, + ); + }) as typeof process.exit); + const startSleepPrevention = vi.fn(() => + Promise.resolve({ type: "skipped" as const, reason: "unsupported" }), + ); + const tempDir = mkdtempSync(join(tmpdir(), "gnhf-cli-test-")); + const promptPath = join(tempDir, "PROMPT.md"); + writeFileSync(promptPath, "existing prompt", "utf-8"); + + vi.resetModules(); + vi.doMock("node:readline", () => ({ + createInterface: vi.fn(() => ({ + question: (_question: string, callback: (answer: string) => void) => { + callback("q"); + }, + close: vi.fn(), + })), + })); + vi.doMock("./core/config.js", () => ({ + loadConfig: vi.fn(() => ({ + agent: "claude", + maxConsecutiveFailures: 3, + preventSleep: true, + })), + })); + vi.doMock("./core/git.js", () => ({ + ensureCleanWorkingTree: vi.fn(), + createBranch: vi.fn(), + getHeadCommit: vi.fn(() => "abc123"), + getCurrentBranch: vi.fn(() => "gnhf/existing-run"), + })); + vi.doMock("./core/run.js", () => ({ + setupRun: vi.fn(() => stubRunInfo), + resumeRun: vi.fn(() => ({ + ...stubRunInfo, + runId: "existing-run", + promptPath, + })), + getLastIterationNumber: vi.fn(() => 3), + })); + vi.doMock("./core/agents/factory.js", () => ({ + createAgent: vi.fn(() => ({ name: "claude" })), + })); + vi.doMock("./core/sleep.js", () => ({ + startSleepPrevention, + })); + vi.doMock("./core/orchestrator.js", () => ({ + Orchestrator: class MockOrchestrator { + start = vi.fn(() => Promise.resolve()); + stop = vi.fn(); + on = vi.fn(); + getState = vi.fn(); + }, + })); + vi.doMock("./renderer.js", () => ({ + Renderer: class MockRenderer { + start = vi.fn(); + stop = vi.fn(); + waitUntilExit = vi.fn(() => Promise.resolve()); + }, + })); + + process.argv = ["node", "gnhf", "new prompt"]; + + try { + await expect(import("./cli.js")).rejects.toThrow( + /process\.exit unexpectedly called with 1/, + ); + + expect(startSleepPrevention).not.toHaveBeenCalled(); + expect(exitSpy).toHaveBeenNthCalledWith(1, 0); + expect(exitSpy).toHaveBeenNthCalledWith(2, 1); + } finally { + process.argv = originalArgv; + stdoutWrite.mockRestore(); + consoleError.mockRestore(); + exitSpy.mockRestore(); + rmSync(tempDir, { recursive: true, force: true }); + } + }); + it("waits for orchestrator shutdown after the renderer exits", async () => { let resolveStart!: () => void; const orchestratorStart = vi.fn( @@ -219,6 +716,7 @@ describe("cli", () => { { agent: "claude", maxConsecutiveFailures: 3, + preventSleep: false, }, { orchestratorStart, rendererWaitUntilExit }, ); @@ -253,6 +751,7 @@ describe("cli", () => { { agent: "opencode", maxConsecutiveFailures: 3, + preventSleep: false, }, { orchestratorStart: vi.fn(() => Promise.resolve()), @@ -289,6 +788,7 @@ describe("cli", () => { loadConfig: vi.fn(() => ({ agent: "claude", maxConsecutiveFailures: 3, + preventSleep: false, })), })); vi.doMock("./core/git.js", () => ({ @@ -324,4 +824,194 @@ describe("cli", () => { exitSpy.mockRestore(); } }); + + it("uses the SIGTERM exit code when shutdown times out after SIGTERM", async () => { + vi.useFakeTimers(); + + const originalArgv = [...process.argv]; + const stdoutWrite = vi + .spyOn(process.stdout, "write") + .mockImplementation(() => true); + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + const exitSpy = vi + .spyOn(process, "exit") + .mockImplementation((() => undefined) as typeof process.exit); + const processOn = vi.spyOn(process, "on"); + const processOff = vi.spyOn(process, "off"); + const signalHandlers = new Map void>(); + processOn.mockImplementation(((event: string, listener: () => void) => { + if (event === "SIGINT" || event === "SIGTERM") { + signalHandlers.set(event, listener); + } + return process; + }) as typeof process.on); + processOff.mockImplementation((() => process) as typeof process.off); + + let resolveRendererExit!: () => void; + const rendererWaitUntilExit = vi.fn( + () => + new Promise((resolve) => { + resolveRendererExit = resolve; + }), + ); + + vi.resetModules(); + vi.doMock("./core/config.js", () => ({ + loadConfig: vi.fn(() => ({ + agent: "claude", + maxConsecutiveFailures: 3, + preventSleep: false, + })), + })); + vi.doMock("./core/git.js", () => ({ + ensureCleanWorkingTree: vi.fn(), + createBranch: vi.fn(), + getHeadCommit: vi.fn(() => "abc123"), + getCurrentBranch: vi.fn(() => "main"), + })); + vi.doMock("./core/run.js", () => ({ + setupRun: vi.fn(() => stubRunInfo), + resumeRun: vi.fn(), + getLastIterationNumber: vi.fn(() => 0), + })); + vi.doMock("./core/agents/factory.js", () => ({ + createAgent: vi.fn(() => ({ name: "claude" })), + })); + vi.doMock("./core/orchestrator.js", () => ({ + Orchestrator: class MockOrchestrator { + start = vi.fn(() => new Promise(() => {})); + stop = vi.fn(); + on = vi.fn(); + getState = vi.fn(() => ({ + status: "running" as const, + currentIteration: 0, + totalInputTokens: 0, + totalOutputTokens: 0, + commitCount: 0, + iterations: [], + successCount: 0, + failCount: 0, + consecutiveFailures: 0, + startTime: new Date("2026-01-01T00:00:00Z"), + waitingUntil: null, + lastMessage: null, + })); + }, + })); + vi.doMock("./renderer.js", () => ({ + Renderer: class MockRenderer { + start = vi.fn(); + stop = vi.fn(() => { + resolveRendererExit(); + }); + waitUntilExit = rendererWaitUntilExit; + }, + })); + + process.argv = ["node", "gnhf", "ship it"]; + + try { + const cliPromise = import("./cli.js"); + + await vi.waitFor(() => { + expect(signalHandlers.has("SIGTERM")).toBe(true); + }); + + signalHandlers.get("SIGTERM")?.(); + await vi.advanceTimersByTimeAsync(5_000); + + await cliPromise; + + expect(exitSpy).toHaveBeenCalledWith(143); + expect(exitSpy).not.toHaveBeenCalledWith(130); + } finally { + process.argv = originalArgv; + stdoutWrite.mockRestore(); + consoleError.mockRestore(); + exitSpy.mockRestore(); + processOn.mockRestore(); + processOff.mockRestore(); + vi.useRealTimers(); + } + }); + + it("uses the SIGINT exit code when the renderer reports an interactive interrupt", async () => { + const originalArgv = [...process.argv]; + const stdoutWrite = vi + .spyOn(process.stdout, "write") + .mockImplementation(() => true); + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + const exitSpy = vi + .spyOn(process, "exit") + .mockImplementation((() => undefined) as typeof process.exit); + + vi.resetModules(); + vi.doMock("./core/config.js", () => ({ + loadConfig: vi.fn(() => ({ + agent: "claude", + maxConsecutiveFailures: 3, + preventSleep: false, + })), + })); + vi.doMock("./core/git.js", () => ({ + ensureCleanWorkingTree: vi.fn(), + createBranch: vi.fn(), + getHeadCommit: vi.fn(() => "abc123"), + getCurrentBranch: vi.fn(() => "main"), + })); + vi.doMock("./core/run.js", () => ({ + setupRun: vi.fn(() => stubRunInfo), + resumeRun: vi.fn(), + getLastIterationNumber: vi.fn(() => 0), + })); + vi.doMock("./core/agents/factory.js", () => ({ + createAgent: vi.fn(() => ({ name: "claude" })), + })); + vi.doMock("./core/orchestrator.js", () => ({ + Orchestrator: class MockOrchestrator { + start = vi.fn(() => Promise.resolve()); + stop = vi.fn(); + on = vi.fn(); + getState = vi.fn(() => ({ + status: "running" as const, + currentIteration: 0, + totalInputTokens: 0, + totalOutputTokens: 0, + commitCount: 0, + iterations: [], + successCount: 0, + failCount: 0, + consecutiveFailures: 0, + startTime: new Date("2026-01-01T00:00:00Z"), + waitingUntil: null, + lastMessage: null, + })); + }, + })); + vi.doMock("./renderer.js", () => ({ + Renderer: class MockRenderer { + start = vi.fn(); + stop = vi.fn(); + waitUntilExit = vi.fn(() => Promise.resolve("interrupted")); + }, + })); + + process.argv = ["node", "gnhf", "ship it"]; + + try { + await import("./cli.js"); + + expect(exitSpy).toHaveBeenCalledWith(130); + expect(exitSpy).not.toHaveBeenCalledWith(0); + } finally { + process.argv = originalArgv; + stdoutWrite.mockRestore(); + consoleError.mockRestore(); + exitSpy.mockRestore(); + } + }); }); diff --git a/src/cli.ts b/src/cli.ts index f3f2e10..f8cdf16 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,8 +1,17 @@ -import { readFileSync } from "node:fs"; +import { + mkdtempSync, + readFileSync, + rmSync, + rmdirSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, dirname, join, resolve } from "node:path"; import process from "node:process"; import { createInterface } from "node:readline"; import { Command, InvalidArgumentError } from "commander"; import { loadConfig } from "./core/config.js"; +import { appendDebugLog } from "./core/debug-log.js"; import { ensureCleanWorkingTree, createBranch, @@ -15,6 +24,8 @@ import { resumeRun, getLastIterationNumber, } from "./core/run.js"; +import { readStdinText } from "./core/stdin.js"; +import { startSleepPrevention } from "./core/sleep.js"; import { createAgent } from "./core/agents/factory.js"; import { Orchestrator } from "./core/orchestrator.js"; import { MockOrchestrator } from "./mock-orchestrator.js"; @@ -25,6 +36,10 @@ const packageVersion = JSON.parse( readFileSync(new URL("../package.json", import.meta.url), "utf-8"), ).version as string; const FORCE_EXIT_TIMEOUT_MS = 5_000; +const GNHF_REEXEC_STDIN_PROMPT = "GNHF_REEXEC_STDIN_PROMPT"; +const GNHF_REEXEC_STDIN_PROMPT_FILE = "GNHF_REEXEC_STDIN_PROMPT_FILE"; +const GNHF_REEXEC_STDIN_PROMPT_DIR_PREFIX = "gnhf-stdin-"; +const GNHF_REEXEC_STDIN_PROMPT_FILENAME = "prompt.txt"; function parseNonNegativeInteger(value: string): number { if (!/^\d+$/.test(value)) { @@ -39,6 +54,14 @@ function parseNonNegativeInteger(value: string): number { return parsed; } +function parseOnOffBoolean(value: string): boolean { + if (value === "on" || value === "true") return true; + if (value === "off" || value === "false") return false; + throw new InvalidArgumentError( + 'must be one of: "on", "off", "true", "false"', + ); +} + function humanizeErrorMessage(message: string): string { if (message.includes("not a git repository")) { return 'This command must be run inside a Git repository. Change into a repo or run "git init" first.'; @@ -69,6 +92,71 @@ function ask(question: string): Promise { }); } +function getSignalExitCode(signal: NodeJS.Signals): number { + return signal === "SIGINT" ? 130 : 143; +} + +function persistStdinPromptForReexec(prompt: string): { + path: string; + cleanup: () => void; +} { + const promptDir = mkdtempSync( + join(tmpdir(), GNHF_REEXEC_STDIN_PROMPT_DIR_PREFIX), + ); + const promptPath = join(promptDir, GNHF_REEXEC_STDIN_PROMPT_FILENAME); + writeFileSync(promptPath, prompt, { encoding: "utf-8", mode: 0o600 }); + return { + path: promptPath, + cleanup: () => { + rmSync(promptDir, { recursive: true, force: true }); + }, + }; +} + +function isTrustedReexecPromptPath(promptPath: string): boolean { + const resolvedPromptPath = resolve(promptPath); + const promptDir = dirname(resolvedPromptPath); + return ( + basename(resolvedPromptPath) === GNHF_REEXEC_STDIN_PROMPT_FILENAME && + dirname(promptDir) === resolve(tmpdir()) && + basename(promptDir).startsWith(GNHF_REEXEC_STDIN_PROMPT_DIR_PREFIX) + ); +} + +function cleanupTrustedReexecPromptPath(promptPath: string): void { + if (!isTrustedReexecPromptPath(promptPath)) { + return; + } + + const resolvedPromptPath = resolve(promptPath); + rmSync(resolvedPromptPath, { force: true }); + try { + rmdirSync(dirname(resolvedPromptPath)); + } catch { + // Leave the directory in place if anything unexpected remains. + } +} + +function readReexecStdinPrompt(env: NodeJS.ProcessEnv): string | undefined { + const promptPath = env[GNHF_REEXEC_STDIN_PROMPT_FILE]; + if (promptPath !== undefined) { + delete env[GNHF_REEXEC_STDIN_PROMPT_FILE]; + try { + return readFileSync(promptPath, "utf-8"); + } finally { + cleanupTrustedReexecPromptPath(promptPath); + } + } + + const prompt = env[GNHF_REEXEC_STDIN_PROMPT]; + if (prompt !== undefined) { + delete env[GNHF_REEXEC_STDIN_PROMPT]; + return prompt; + } + + return undefined; +} + const program = new Command(); program @@ -90,6 +178,11 @@ program "Abort after N total input+output tokens", parseNonNegativeInteger, ) + .option( + "--prevent-sleep ", + 'Prevent system sleep during the run ("on" or "off")', + parseOnOffBoolean, + ) .option("--mock", "", false) .action( async ( @@ -98,6 +191,7 @@ program agent?: string; maxIterations?: number; maxTokens?: number; + preventSleep?: boolean; mock: boolean; }, ) => { @@ -115,11 +209,16 @@ program exitAltScreen(); return; } - let prompt = promptArg; - - if (!prompt && !process.stdin.isTTY) { - prompt = readFileSync("/dev/stdin", "utf-8").trim(); + let initialSleepPrevention: Awaited< + ReturnType + > | null = null; + if (process.env.GNHF_SLEEP_INHIBITED === "1") { + initialSleepPrevention = await startSleepPrevention( + process.argv.slice(2), + ); } + let prompt = promptArg; + let promptFromStdin = false; const agentName = options.agent; if ( @@ -135,13 +234,19 @@ program process.exit(1); } - const config = loadConfig( + const loadedConfig = loadConfig( agentName ? { agent: agentName as "claude" | "codex" | "rovodev" | "opencode", } - : undefined, + : {}, ); + const config = { + ...loadedConfig, + ...(options.preventSleep === undefined + ? {} + : { preventSleep: options.preventSleep }), + }; if ( config.agent !== "claude" && config.agent !== "codex" && @@ -153,6 +258,15 @@ program ); process.exit(1); } + + if (!prompt && process.env.GNHF_SLEEP_INHIBITED === "1") { + prompt = readReexecStdinPrompt(process.env); + } + if (!prompt && !process.stdin.isTTY) { + prompt = await readStdinText(process.stdin); + promptFromStdin = true; + } + const cwd = process.cwd(); const currentBranch = getCurrentBranch(cwd); @@ -197,6 +311,41 @@ program runInfo = initializeNewBranch(prompt, cwd); } + let sleepPreventionCleanup: (() => Promise) | null = null; + if (config.preventSleep) { + const persistedPrompt = + promptFromStdin && prompt !== undefined + ? persistStdinPromptForReexec(prompt) + : null; + let reexeced = false; + try { + const sleepPrevention = + initialSleepPrevention ?? + (await startSleepPrevention(process.argv.slice(2), { + reexecEnv: persistedPrompt + ? { + [GNHF_REEXEC_STDIN_PROMPT_FILE]: persistedPrompt.path, + } + : undefined, + })); + if (sleepPrevention.type === "reexeced") { + reexeced = true; + process.exit(sleepPrevention.exitCode); + } + if (sleepPrevention.type === "active") { + sleepPreventionCleanup = sleepPrevention.cleanup; + } + } finally { + if (!reexeced) { + persistedPrompt?.cleanup(); + } + } + } + + appendDebugLog("run:start", { + args: process.argv.slice(2), + }); + const agent = createAgent(config.agent, runInfo); const orchestrator = new Orchestrator( config, @@ -210,11 +359,24 @@ program maxTokens: options.maxTokens, }, ); + let shutdownSignal: NodeJS.Signals | null = null; enterAltScreen(); const renderer = new Renderer(orchestrator, prompt, config.agent); renderer.start(); + const requestShutdown = (signal: NodeJS.Signals) => { + if (shutdownSignal) return; + shutdownSignal = signal; + appendDebugLog(`signal:${signal}`); + renderer.stop(); + orchestrator.stop(); + }; + const handleSigInt = () => requestShutdown("SIGINT"); + const handleSigTerm = () => requestShutdown("SIGTERM"); + process.on("SIGINT", handleSigInt); + process.on("SIGTERM", handleSigTerm); + const orchestratorPromise = orchestrator .start() .finally(() => { @@ -225,20 +387,41 @@ program die(err instanceof Error ? err.message : String(err)); }); - await renderer.waitUntilExit(); - exitAltScreen(); - const shutdownResult = await Promise.race([ - orchestratorPromise.then(() => "done" as const), - new Promise<"timeout">((resolve) => { - setTimeout(() => resolve("timeout"), FORCE_EXIT_TIMEOUT_MS).unref(); - }), - ]); + try { + const rendererExitReason = await renderer.waitUntilExit(); + if (rendererExitReason === "interrupted" && !shutdownSignal) { + shutdownSignal = "SIGINT"; + appendDebugLog("signal:SIGINT"); + } + exitAltScreen(); + const shutdownResult = await Promise.race([ + orchestratorPromise.then(() => "done" as const), + new Promise<"timeout">((resolve) => { + setTimeout(() => resolve("timeout"), FORCE_EXIT_TIMEOUT_MS).unref(); + }), + ]); - if (shutdownResult === "timeout") { - console.error( - `\n gnhf: shutdown timed out after ${FORCE_EXIT_TIMEOUT_MS / 1000}s, forcing exit\n`, - ); - process.exit(130); + if (shutdownResult === "timeout") { + appendDebugLog("run:shutdown-timeout", { + timeoutMs: FORCE_EXIT_TIMEOUT_MS, + }); + console.error( + `\n gnhf: shutdown timed out after ${FORCE_EXIT_TIMEOUT_MS / 1000}s, forcing exit\n`, + ); + process.exit(getSignalExitCode(shutdownSignal ?? "SIGINT")); + } + } finally { + process.off("SIGINT", handleSigInt); + process.off("SIGTERM", handleSigTerm); + await sleepPreventionCleanup?.(); + } + + appendDebugLog("run:complete", { + signal: shutdownSignal, + }); + + if (shutdownSignal) { + process.exit(getSignalExitCode(shutdownSignal)); } }, ); diff --git a/src/core/agents/managed-process.test.ts b/src/core/agents/managed-process.test.ts new file mode 100644 index 0000000..1bc2ac5 --- /dev/null +++ b/src/core/agents/managed-process.test.ts @@ -0,0 +1,163 @@ +import { EventEmitter } from "node:events"; +import type { ChildProcess } from "node:child_process"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { shutdownChildProcess, signalChildProcess } from "./managed-process.js"; + +function createChildProcess(pid = 1234): ChildProcess { + return Object.assign(new EventEmitter(), { + exitCode: null, + pid, + kill: vi.fn(() => true), + signalCode: null, + }) as unknown as ChildProcess; +} + +describe("signalChildProcess", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("signals the process group for detached children", () => { + const child = createChildProcess(); + const killProcess = vi.fn(); + + signalChildProcess(child, { + detached: true, + killProcess, + signal: "SIGTERM", + }); + + expect(killProcess).toHaveBeenCalledWith(-1234, "SIGTERM"); + expect(child.kill).not.toHaveBeenCalled(); + }); + + it("falls back to killing the direct child when process-group signaling fails", () => { + const child = createChildProcess(); + const killProcess = vi.fn(() => { + throw new Error("group kill failed"); + }); + + signalChildProcess(child, { + detached: true, + killProcess, + signal: "SIGTERM", + }); + + expect(child.kill).toHaveBeenCalledWith("SIGTERM"); + }); +}); + +describe("shutdownChildProcess", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + }); + + it("force kills the child when graceful shutdown times out", async () => { + const child = createChildProcess(); + vi.mocked(child.kill).mockImplementation((signal?: NodeJS.Signals) => { + if (signal === "SIGKILL") { + queueMicrotask(() => { + child.emit("close", 0, null); + }); + } + return true; + }); + + const closePromise = shutdownChildProcess(child, { + detached: false, + timeoutMs: 3_000, + }); + + expect(child.kill).toHaveBeenCalledWith("SIGTERM"); + + await vi.advanceTimersByTimeAsync(3_000); + expect(child.kill).toHaveBeenCalledWith("SIGKILL"); + + await closePromise; + vi.useRealTimers(); + }); + + it("waits for close after sending SIGKILL", async () => { + const child = createChildProcess(); + let resolved = false; + + const closePromise = shutdownChildProcess(child, { + detached: false, + timeoutMs: 3_000, + }).then(() => { + resolved = true; + }); + + expect(child.kill).toHaveBeenCalledWith("SIGTERM"); + + await vi.advanceTimersByTimeAsync(3_000); + expect(child.kill).toHaveBeenCalledWith("SIGKILL"); + await Promise.resolve(); + expect(resolved).toBe(false); + + child.emit("close", 0, null); + await closePromise; + expect(resolved).toBe(true); + + vi.useRealTimers(); + }); + + it("resolves after a hard deadline if the child never closes", async () => { + const child = createChildProcess(); + let resolved = false; + + const closePromise = shutdownChildProcess(child, { + detached: false, + timeoutMs: 3_000, + }).then(() => { + resolved = true; + }); + + expect(child.kill).toHaveBeenCalledWith("SIGTERM"); + + await vi.advanceTimersByTimeAsync(3_000); + expect(child.kill).toHaveBeenCalledWith("SIGKILL"); + expect(resolved).toBe(false); + + await vi.advanceTimersByTimeAsync(100); + await closePromise; + expect(resolved).toBe(true); + }); + + it("clears the force-kill timer when the child closes first", async () => { + const child = createChildProcess(); + + const closePromise = shutdownChildProcess(child, { + detached: false, + timeoutMs: 3_000, + }); + + expect(child.kill).toHaveBeenCalledWith("SIGTERM"); + + child.emit("close", 0, null); + await closePromise; + + await vi.advanceTimersByTimeAsync(3_000); + expect(child.kill).not.toHaveBeenCalledWith("SIGKILL"); + + vi.useRealTimers(); + }); + + it("resolves immediately when the child has already exited", async () => { + const child = Object.assign(createChildProcess(), { + exitCode: 0, + }); + + await shutdownChildProcess(child, { + detached: false, + timeoutMs: 3_000, + }); + + expect(child.kill).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(3_000); + expect(child.kill).not.toHaveBeenCalledWith("SIGKILL"); + + vi.useRealTimers(); + }); +}); diff --git a/src/core/agents/managed-process.ts b/src/core/agents/managed-process.ts new file mode 100644 index 0000000..9380254 --- /dev/null +++ b/src/core/agents/managed-process.ts @@ -0,0 +1,90 @@ +import type { ChildProcess } from "node:child_process"; + +interface SignalChildProcessOptions { + detached: boolean; + killProcess?: typeof process.kill; + signal: NodeJS.Signals; +} + +interface ShutdownChildProcessOptions { + detached: boolean; + killProcess?: typeof process.kill; + timeoutMs?: number; +} + +const POST_SIGKILL_GRACE_MS = 100; + +export function signalChildProcess( + child: ChildProcess, + options: SignalChildProcessOptions, +): void { + const killProcess = options.killProcess ?? process.kill.bind(process); + + if (options.detached && child.pid) { + try { + killProcess(-child.pid, options.signal); + return; + } catch { + // Fall back to the direct child below. + } + } + + child.kill(options.signal); +} + +export async function shutdownChildProcess( + child: ChildProcess, + options: ShutdownChildProcessOptions, +): Promise { + if (child.exitCode != null || child.signalCode != null) { + return; + } + + const timeoutMs = options.timeoutMs ?? 3_000; + await new Promise((resolve) => { + let forceKillTimer: ReturnType | null = null; + let hardDeadlineTimer: ReturnType | null = null; + let settled = false; + + const settle = () => { + if (settled) return; + settled = true; + if (forceKillTimer) { + clearTimeout(forceKillTimer); + forceKillTimer = null; + } + if (hardDeadlineTimer) { + clearTimeout(hardDeadlineTimer); + hardDeadlineTimer = null; + } + child.off("close", handleClose); + resolve(); + }; + + const handleClose = () => { + settle(); + }; + + child.on("close", handleClose); + + try { + signalChildProcess(child, { ...options, signal: "SIGTERM" }); + } catch { + // Best-effort cleanup only. + } + + forceKillTimer = setTimeout(() => { + try { + signalChildProcess(child, { ...options, signal: "SIGKILL" }); + } catch { + // Best-effort cleanup only. + } + + hardDeadlineTimer = setTimeout(() => { + settle(); + }, POST_SIGKILL_GRACE_MS); + hardDeadlineTimer.unref?.(); + }, timeoutMs); + forceKillTimer.unref?.(); + }); +} diff --git a/src/core/agents/opencode.test.ts b/src/core/agents/opencode.test.ts index 18f4fad..5b740b7 100644 --- a/src/core/agents/opencode.test.ts +++ b/src/core/agents/opencode.test.ts @@ -7,10 +7,11 @@ import type { Mock } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("node:child_process", () => ({ + execFileSync: vi.fn(), spawn: vi.fn(), })); -import { spawn } from "node:child_process"; +import { execFileSync, spawn } from "node:child_process"; import { OpenCodeAgent } from "./opencode.js"; import { AGENT_OUTPUT_SCHEMA } from "./types.js"; @@ -266,7 +267,9 @@ describe("OpenCodeAgent", () => { cacheReadTokens: 3, cacheCreationTokens: 2, }); - expect(readFileSync(logPath, "utf-8")).toContain("message.part.delta"); + await vi.waitFor(() => { + expect(readFileSync(logPath, "utf-8")).toContain("message.part.delta"); + }); resolveMessageResponse( finalMessageResponse("done", { input: 10, output: 4, read: 3, write: 2 }), @@ -457,6 +460,99 @@ describe("OpenCodeAgent", () => { ); }); + it("uses a shell on Windows so PATH-resolved .cmd shims can launch", async () => { + const proc = createMockProcess(); + mockSpawn.mockReturnValue(proc); + + const windowsAgent = new OpenCodeAgent({ + fetch: fetchMock as typeof fetch, + getPort, + platform: "win32", + }); + + fetchMock + .mockResolvedValueOnce(jsonResponse({ healthy: true, version: "1.3.13" })) + .mockResolvedValueOnce( + jsonResponse({ + id: "session-123", + directory: "/repo", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }), + ) + .mockResolvedValueOnce( + sseResponse( + finalAnswerEvents("done", { + input: 1, + output: 1, + read: 0, + write: 0, + }), + ), + ) + .mockResolvedValueOnce( + finalMessageResponse("done", { + input: 1, + output: 1, + read: 0, + write: 0, + }), + ) + .mockResolvedValueOnce(jsonResponse(true)); + + await windowsAgent.run("test prompt", "/repo"); + + expect(mockSpawn).toHaveBeenCalledWith( + "opencode", + ["serve", "--hostname", "127.0.0.1", "--port", "8765", "--print-logs"], + expect.objectContaining({ + cwd: "/repo", + detached: false, + shell: true, + stdio: ["ignore", "pipe", "pipe"], + }), + ); + }); + + it("kills the full process tree on Windows so the opencode server does not survive shutdown", async () => { + const proc = createMockProcess(); + Object.defineProperty(proc, "pid", { value: 5678 }); + mockSpawn.mockReturnValue(proc); + + const windowsAgent = new OpenCodeAgent({ + fetch: fetchMock as typeof fetch, + getPort, + platform: "win32", + }); + + fetchMock + .mockResolvedValueOnce(jsonResponse({ healthy: true, version: "1.3.13" })) + .mockResolvedValueOnce(jsonResponse({ id: "session-123" })) + .mockResolvedValueOnce( + sseResponse( + finalAnswerEvents("done", { input: 1, output: 1, read: 0, write: 0 }), + ), + ) + .mockResolvedValueOnce( + finalMessageResponse("done", { + input: 1, + output: 1, + read: 0, + write: 0, + }), + ) + .mockResolvedValueOnce(jsonResponse(true)); + + await windowsAgent.run("test", "/repo"); + await windowsAgent.close(); + + expect(vi.mocked(execFileSync)).toHaveBeenCalledWith( + "taskkill", + ["/T", "/F", "/PID", "5678"], + { stdio: "ignore" }, + ); + expect(proc.kill).not.toHaveBeenCalled(); + }); + it("reuses the existing server process across runs in the same cwd", async () => { const proc = createMockProcess(); mockSpawn.mockReturnValue(proc); @@ -665,6 +761,14 @@ describe("OpenCodeAgent", () => { it("force terminates opencode if shutdown exceeds the timeout", async () => { vi.useFakeTimers(); const proc = createMockProcess(); + vi.mocked(proc.kill).mockImplementation((signal?: NodeJS.Signals) => { + if (signal === "SIGKILL") { + queueMicrotask(() => { + proc.emit("close", 0, null); + }); + } + return true; + }); mockSpawn.mockReturnValue(proc); fetchMock diff --git a/src/core/agents/opencode.ts b/src/core/agents/opencode.ts index ef16163..fd1aab3 100644 --- a/src/core/agents/opencode.ts +++ b/src/core/agents/opencode.ts @@ -1,4 +1,8 @@ -import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { + execFileSync, + spawn, + type ChildProcessWithoutNullStreams, +} from "node:child_process"; import { createWriteStream, type WriteStream } from "node:fs"; import { createServer } from "node:net"; import { @@ -9,6 +13,8 @@ import { type AgentRunOptions, type TokenUsage, } from "./types.js"; +import { appendDebugLog } from "../debug-log.js"; +import { shutdownChildProcess } from "./managed-process.js"; interface OpenCodeMessagePart { type?: string; @@ -77,6 +83,7 @@ interface OpenCodeDeps { fetch?: typeof fetch; getPort?: () => Promise; killProcess?: typeof process.kill; + platform?: NodeJS.Platform; spawn?: typeof spawn; } @@ -137,6 +144,21 @@ function buildPrompt(prompt: string): string { ].join("\n"); } +/** + * On Windows with `shell: true`, `child.pid` is the `cmd.exe` wrapper, not + * the actual server process. `taskkill /T` terminates the entire process + * tree rooted at that PID so the real server doesn't survive shutdown. + */ +async function killWindowsProcessTree(pid: number): Promise { + try { + execFileSync("taskkill", ["/T", "/F", "/PID", String(pid)], { + stdio: "ignore", + }); + } catch { + // Best-effort: the process may have already exited. + } +} + function createAbortError(): Error { return new Error("Agent was aborted"); } @@ -225,6 +247,7 @@ export class OpenCodeAgent implements Agent { private fetchFn: typeof fetch; private getPortFn: () => Promise; private killProcessFn: typeof process.kill; + private platform: NodeJS.Platform; private spawnFn: typeof spawn; private server: OpenCodeServer | null = null; private closingPromise: Promise | null = null; @@ -233,6 +256,7 @@ export class OpenCodeAgent implements Agent { this.fetchFn = deps.fetch ?? fetch; this.getPortFn = deps.getPort ?? getAvailablePort; this.killProcessFn = deps.killProcess ?? process.kill.bind(process); + this.platform = deps.platform ?? process.platform; this.spawnFn = deps.spawn ?? spawn; } @@ -309,7 +333,8 @@ export class OpenCodeAgent implements Agent { } const port = await this.getPortFn(); - const detached = process.platform !== "win32"; + const isWindows = this.platform === "win32"; + const detached = !isWindows; const child = this.spawnFn( "opencode", [ @@ -323,6 +348,7 @@ export class OpenCodeAgent implements Agent { { cwd, detached, + shell: isWindows, stdio: ["ignore", "pipe", "pipe"], env: buildOpencodeChildEnv(), }, @@ -363,6 +389,7 @@ export class OpenCodeAgent implements Agent { }); this.server = server; + appendDebugLog("opencode:spawn", { cwd, port, detached }); server.readyPromise = this.waitForHealthy(server, signal).catch( async (error) => { await this.shutdownServer(); @@ -789,59 +816,26 @@ export class OpenCodeAgent implements Agent { } const server = this.server; - const waitForClose = new Promise((resolve) => { - if (server.closed) { - resolve(); - return; + appendDebugLog("opencode:shutdown", { cwd: server.cwd, port: server.port }); + + this.closingPromise = ( + this.platform === "win32" && server.child.pid + ? killWindowsProcessTree(server.child.pid) + : shutdownChildProcess(server.child, { + detached: server.detached, + killProcess: this.killProcessFn, + timeoutMs: 3_000, + }) + ).finally(() => { + if (this.server === server) { + this.server = null; } - server.child.once("close", () => resolve()); - }); - - try { - this.signalServer(server, "SIGTERM"); - } catch { - // Best effort only. - } - - const forceKill = new Promise((resolve) => { - const timer = setTimeout(() => { - if (!server.closed) { - try { - this.signalServer(server, "SIGKILL"); - } catch { - // Best effort only. - } - } - resolve(); - }, 3_000); - timer.unref?.(); + this.closingPromise = null; }); - this.closingPromise = Promise.race([waitForClose, forceKill]).finally( - () => { - if (this.server === server) { - this.server = null; - } - this.closingPromise = null; - }, - ); - await this.closingPromise; } - private signalServer(server: OpenCodeServer, signal: NodeJS.Signals): void { - if (server.detached && server.child.pid) { - try { - this.killProcessFn(-server.child.pid, signal); - return; - } catch { - // Fall back to killing the direct child below. - } - } - - server.child.kill(signal); - } - private async requestJSON( server: OpenCodeServer, path: string, diff --git a/src/core/agents/rovodev.test.ts b/src/core/agents/rovodev.test.ts index 892179b..18980a6 100644 --- a/src/core/agents/rovodev.test.ts +++ b/src/core/agents/rovodev.test.ts @@ -333,6 +333,14 @@ describe("RovoDevAgent", () => { it("force terminates rovodev if shutdown exceeds the timeout", async () => { vi.useFakeTimers(); const proc = createMockProcess(); + vi.mocked(proc.kill).mockImplementation((signal?: NodeJS.Signals) => { + if (signal === "SIGKILL") { + queueMicrotask(() => { + proc.emit("close", 0, null); + }); + } + return true; + }); mockSpawn.mockReturnValue(proc); fetchMock diff --git a/src/core/agents/rovodev.ts b/src/core/agents/rovodev.ts index 1fa3288..a6f7924 100644 --- a/src/core/agents/rovodev.ts +++ b/src/core/agents/rovodev.ts @@ -8,6 +8,8 @@ import type { AgentRunOptions, TokenUsage, } from "./types.js"; +import { appendDebugLog } from "../debug-log.js"; +import { shutdownChildProcess } from "./managed-process.js"; interface RovoDevRequestUsageEvent { input_tokens?: number; @@ -213,7 +215,7 @@ export class RovoDevAgent implements Agent { stdio: ["ignore", "pipe", "pipe"], env: process.env, }, - ); + ) as unknown as ChildProcessWithoutNullStreams; const server: RovoDevServer = { baseUrl: `http://127.0.0.1:${port}`, @@ -250,6 +252,7 @@ export class RovoDevAgent implements Agent { }); this.server = server; + appendDebugLog("rovodev:spawn", { cwd, port, detached }); server.readyPromise = this.waitForHealthy(server, signal).catch( async (error) => { await this.shutdownServer(); @@ -266,10 +269,10 @@ export class RovoDevAgent implements Agent { signal?: AbortSignal, ): Promise { const deadline = Date.now() + 30_000; - let spawnError: Error | null = null; + let spawnErrorMessage: string | null = null; server.child.once("error", (error) => { - spawnError = error; + spawnErrorMessage = error.message; }); while (Date.now() < deadline) { @@ -277,8 +280,8 @@ export class RovoDevAgent implements Agent { throw createAbortError(); } - if (spawnError) { - throw new Error(`Failed to spawn rovodev: ${spawnError.message}`); + if (spawnErrorMessage) { + throw new Error(`Failed to spawn rovodev: ${spawnErrorMessage}`); } if (server.closed) { @@ -605,59 +608,22 @@ export class RovoDevAgent implements Agent { } const server = this.server; - const waitForClose = new Promise((resolve) => { - if (server.closed) { - resolve(); - return; - } - server.child.once("close", () => resolve()); - }); - - try { - this.signalServer(server, "SIGTERM"); - } catch { - // Best effort only. - } + appendDebugLog("rovodev:shutdown", { cwd: server.cwd, port: server.port }); - const forceKill = new Promise((resolve) => { - const timer = setTimeout(() => { - if (!server.closed) { - try { - this.signalServer(server, "SIGKILL"); - } catch { - // Best effort only. - } - } - resolve(); - }, 3_000); - timer.unref?.(); + this.closingPromise = shutdownChildProcess(server.child, { + detached: server.detached, + killProcess: this.killProcessFn, + timeoutMs: 3_000, + }).finally(() => { + if (this.server === server) { + this.server = null; + } + this.closingPromise = null; }); - this.closingPromise = Promise.race([waitForClose, forceKill]).finally( - () => { - if (this.server === server) { - this.server = null; - } - this.closingPromise = null; - }, - ); - await this.closingPromise; } - private signalServer(server: RovoDevServer, signal: NodeJS.Signals): void { - if (server.detached && server.child.pid) { - try { - this.killProcessFn(-server.child.pid, signal); - return; - } catch { - // Fall back to killing the direct child below. - } - } - - server.child.kill(signal); - } - private async requestJSON( server: RovoDevServer, path: string, diff --git a/src/core/config.test.ts b/src/core/config.test.ts index f67278d..738fd99 100644 --- a/src/core/config.test.ts +++ b/src/core/config.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; +import { join } from "node:path"; vi.mock("node:fs", () => ({ readFileSync: vi.fn(), @@ -17,6 +18,10 @@ const mockMkdirSync = vi.mocked(mkdirSync); const mockReadFileSync = vi.mocked(readFileSync); const mockWriteFileSync = vi.mocked(writeFileSync); +const HOME = "/mock-home"; +const CONFIG_DIR = join(HOME, ".gnhf"); +const CONFIG_PATH = join(CONFIG_DIR, "config.yml"); + describe("loadConfig", () => { beforeEach(() => { vi.clearAllMocks(); @@ -29,17 +34,18 @@ describe("loadConfig", () => { const config = loadConfig(); - expect(mockMkdirSync).toHaveBeenCalledWith("/mock-home/.gnhf", { + expect(mockMkdirSync).toHaveBeenCalledWith(CONFIG_DIR, { recursive: true, }); expect(mockWriteFileSync).toHaveBeenCalledWith( - "/mock-home/.gnhf/config.yml", - "# Agent to use by default\nagent: claude\n\n# Abort after this many consecutive failures\nmaxConsecutiveFailures: 3\n", + CONFIG_PATH, + "# Agent to use by default\nagent: claude\n\n# Abort after this many consecutive failures\nmaxConsecutiveFailures: 3\n\n# Prevent the machine from sleeping during a run\npreventSleep: true\n", "utf-8", ); expect(config).toEqual({ agent: "claude", maxConsecutiveFailures: 3, + preventSleep: true, }); }); @@ -58,6 +64,7 @@ describe("loadConfig", () => { expect(config).toEqual({ agent: "claude", maxConsecutiveFailures: 3, + preventSleep: true, }); }); @@ -71,13 +78,14 @@ describe("loadConfig", () => { const config = loadConfig({ agent: "codex" }); expect(mockWriteFileSync).toHaveBeenCalledWith( - "/mock-home/.gnhf/config.yml", - "# Agent to use by default\nagent: codex\n\n# Abort after this many consecutive failures\nmaxConsecutiveFailures: 3\n", + CONFIG_PATH, + "# Agent to use by default\nagent: codex\n\n# Abort after this many consecutive failures\nmaxConsecutiveFailures: 3\n\n# Prevent the machine from sleeping during a run\npreventSleep: true\n", "utf-8", ); expect(config).toEqual({ agent: "codex", maxConsecutiveFailures: 3, + preventSleep: true, }); }); @@ -91,13 +99,14 @@ describe("loadConfig", () => { const config = loadConfig({ agent: "rovodev" }); expect(mockWriteFileSync).toHaveBeenCalledWith( - "/mock-home/.gnhf/config.yml", - "# Agent to use by default\nagent: rovodev\n\n# Abort after this many consecutive failures\nmaxConsecutiveFailures: 3\n", + CONFIG_PATH, + "# Agent to use by default\nagent: rovodev\n\n# Abort after this many consecutive failures\nmaxConsecutiveFailures: 3\n\n# Prevent the machine from sleeping during a run\npreventSleep: true\n", "utf-8", ); expect(config).toEqual({ agent: "rovodev", maxConsecutiveFailures: 3, + preventSleep: true, }); }); @@ -111,13 +120,14 @@ describe("loadConfig", () => { const config = loadConfig({ agent: "opencode" }); expect(mockWriteFileSync).toHaveBeenCalledWith( - "/mock-home/.gnhf/config.yml", - "# Agent to use by default\nagent: opencode\n\n# Abort after this many consecutive failures\nmaxConsecutiveFailures: 3\n", + CONFIG_PATH, + "# Agent to use by default\nagent: opencode\n\n# Abort after this many consecutive failures\nmaxConsecutiveFailures: 3\n\n# Prevent the machine from sleeping during a run\npreventSleep: true\n", "utf-8", ); expect(config).toEqual({ agent: "opencode", maxConsecutiveFailures: 3, + preventSleep: true, }); }); @@ -126,10 +136,7 @@ describe("loadConfig", () => { const config = loadConfig(); - expect(mockReadFileSync).toHaveBeenCalledWith( - "/mock-home/.gnhf/config.yml", - "utf-8", - ); + expect(mockReadFileSync).toHaveBeenCalledWith(CONFIG_PATH, "utf-8"); expect(config.agent).toBe("codex"); }); @@ -140,18 +147,54 @@ describe("loadConfig", () => { expect(config).toEqual({ agent: "claude", maxConsecutiveFailures: 10, + preventSleep: true, + }); + }); + + it('coerces quoted "false" for preventSleep to a boolean false', () => { + mockReadFileSync.mockReturnValue('preventSleep: "false"\n'); + + const config = loadConfig(); + + expect(config).toEqual({ + agent: "claude", + maxConsecutiveFailures: 3, + preventSleep: false, }); }); + it('coerces "off" for preventSleep to a boolean false', () => { + mockReadFileSync.mockReturnValue("preventSleep: off\n"); + + const config = loadConfig(); + + expect(config).toEqual({ + agent: "claude", + maxConsecutiveFailures: 3, + preventSleep: false, + }); + }); + + it("throws when preventSleep has an unrecognized value", () => { + mockReadFileSync.mockReturnValue("preventSleep: flase\n"); + + expect(() => loadConfig()).toThrow(/Invalid config value for preventSleep/); + }); + it("overrides take precedence over file config and defaults", () => { mockReadFileSync.mockReturnValue( - "agent: codex\nmaxConsecutiveFailures: 10\n", + "agent: codex\nmaxConsecutiveFailures: 10\npreventSleep: false\n", ); - const config = loadConfig({ agent: "claude", maxConsecutiveFailures: 3 }); + const config = loadConfig({ + agent: "claude", + maxConsecutiveFailures: 3, + preventSleep: true, + }); expect(config).toEqual({ agent: "claude", maxConsecutiveFailures: 3, + preventSleep: true, }); }); @@ -162,6 +205,7 @@ describe("loadConfig", () => { expect(config).toEqual({ agent: "claude", maxConsecutiveFailures: 3, + preventSleep: true, }); }); @@ -174,6 +218,7 @@ describe("loadConfig", () => { expect(config).toEqual({ agent: "claude", maxConsecutiveFailures: 3, + preventSleep: true, }); }); }); diff --git a/src/core/config.ts b/src/core/config.ts index 94a7718..47062db 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -6,13 +6,50 @@ import yaml from "js-yaml"; export interface Config { agent: "claude" | "codex" | "rovodev" | "opencode"; maxConsecutiveFailures: number; + preventSleep: boolean; } const DEFAULT_CONFIG: Config = { agent: "claude", maxConsecutiveFailures: 3, + preventSleep: true, }; +class InvalidConfigError extends Error {} + +function normalizePreventSleep(value: unknown): boolean | undefined { + if (typeof value === "boolean") return value; + if (typeof value !== "string") return undefined; + + if (value === "true") return true; + if (value === "false") return false; + if (value === "on") return true; + if (value === "off") return false; + return undefined; +} + +function normalizeConfig(config: Partial): Partial { + const normalized: Partial = { ...config }; + const hasPreventSleep = Object.prototype.hasOwnProperty.call( + config, + "preventSleep", + ); + const preventSleep = normalizePreventSleep(config.preventSleep); + + if (preventSleep === undefined) { + if (hasPreventSleep && config.preventSleep !== undefined) { + throw new InvalidConfigError( + `Invalid config value for preventSleep: ${String(config.preventSleep)}`, + ); + } + delete normalized.preventSleep; + } else { + normalized.preventSleep = preventSleep; + } + + return normalized; +} + function isMissingConfigError(error: unknown): boolean { if (!(error instanceof Error)) return false; return "code" in error @@ -26,6 +63,9 @@ agent: ${config.agent} # Abort after this many consecutive failures maxConsecutiveFailures: ${config.maxConsecutiveFailures} + +# Prevent the machine from sleeping during a run +preventSleep: ${config.preventSleep} `; } @@ -37,8 +77,11 @@ export function loadConfig(overrides?: Partial): Config { try { const raw = readFileSync(configPath, "utf-8"); - fileConfig = (yaml.load(raw) as Partial) ?? {}; + fileConfig = normalizeConfig((yaml.load(raw) as Partial) ?? {}); } catch (error) { + if (error instanceof InvalidConfigError) { + throw error; + } if (isMissingConfigError(error)) { shouldBootstrapConfig = true; } @@ -46,7 +89,11 @@ export function loadConfig(overrides?: Partial): Config { // Config file doesn't exist or is invalid -- use defaults } - const resolvedConfig = { ...DEFAULT_CONFIG, ...fileConfig, ...overrides }; + const resolvedConfig = { + ...DEFAULT_CONFIG, + ...fileConfig, + ...normalizeConfig(overrides ?? {}), + }; if (shouldBootstrapConfig) { try { diff --git a/src/core/debug-log.test.ts b/src/core/debug-log.test.ts new file mode 100644 index 0000000..fb6eeb2 --- /dev/null +++ b/src/core/debug-log.test.ts @@ -0,0 +1,42 @@ +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { appendDebugLog } from "./debug-log.js"; + +describe("appendDebugLog", () => { + const originalLogPath = process.env.GNHF_DEBUG_LOG_PATH; + + afterEach(() => { + if (originalLogPath === undefined) { + delete process.env.GNHF_DEBUG_LOG_PATH; + } else { + process.env.GNHF_DEBUG_LOG_PATH = originalLogPath; + } + }); + + it("writes JSON lines when GNHF_DEBUG_LOG_PATH is set", () => { + const tempDir = mkdtempSync(join(tmpdir(), "gnhf-debug-log-test-")); + const logPath = join(tempDir, "debug.jsonl"); + process.env.GNHF_DEBUG_LOG_PATH = logPath; + + appendDebugLog("run:start", { prompt: "ship it" }); + + const [line] = readFileSync(logPath, "utf-8").trim().split("\n"); + expect(JSON.parse(line!)).toMatchObject({ + event: "run:start", + prompt: "ship it", + pid: process.pid, + }); + + rmSync(tempDir, { recursive: true, force: true }); + }); + + it("does nothing when the env var is unset", () => { + delete process.env.GNHF_DEBUG_LOG_PATH; + + expect(() => + appendDebugLog("run:start", { prompt: "ship it" }), + ).not.toThrow(); + }); +}); diff --git a/src/core/debug-log.ts b/src/core/debug-log.ts new file mode 100644 index 0000000..0efe8bf --- /dev/null +++ b/src/core/debug-log.ts @@ -0,0 +1,24 @@ +import { appendFileSync } from "node:fs"; + +export function appendDebugLog( + event: string, + details: Record = {}, +): void { + const logPath = process.env.GNHF_DEBUG_LOG_PATH; + if (!logPath) return; + + try { + appendFileSync( + logPath, + `${JSON.stringify({ + timestamp: new Date().toISOString(), + pid: process.pid, + event, + ...details, + })}\n`, + "utf-8", + ); + } catch { + // Debug logging is best-effort only. + } +} diff --git a/src/core/orchestrator.test.ts b/src/core/orchestrator.test.ts index 028438b..a468884 100644 --- a/src/core/orchestrator.test.ts +++ b/src/core/orchestrator.test.ts @@ -59,6 +59,7 @@ function createSuccessResult(summary = "done"): AgentResult { describe("Orchestrator stop limits", () => { beforeEach(() => { vi.clearAllMocks(); + vi.useRealTimers(); }); it("aborts before starting when the max iteration cap is already reached", async () => { @@ -117,7 +118,7 @@ describe("Orchestrator stop limits", () => { name: "claude", run: vi.fn( (_prompt, _cwd, options) => - new Promise((_resolve, reject) => { + new Promise((_resolve, reject) => { options?.signal?.addEventListener("abort", () => { reject(new Error("Agent was aborted")); }); @@ -156,7 +157,7 @@ describe("Orchestrator stop limits", () => { }); }); - it("closes the agent when stop is requested", () => { + it("closes the agent when stop is requested", async () => { const close = vi.fn(); const agent: Agent = { name: "claude", @@ -172,6 +173,7 @@ describe("Orchestrator stop limits", () => { ); orchestrator.stop(); + await Promise.resolve(); expect(close).toHaveBeenCalledTimes(1); }); @@ -201,6 +203,7 @@ describe("Orchestrator stop limits", () => { orchestrator.on("stopped", stopped); orchestrator.stop(); + await Promise.resolve(); expect(close).toHaveBeenCalledTimes(1); expect(stopped).not.toHaveBeenCalled(); @@ -211,4 +214,139 @@ describe("Orchestrator stop limits", () => { expect(stopped).toHaveBeenCalledTimes(1); }); + + it("waits for the active iteration to unwind before closing the agent", async () => { + let rejectRun!: (error: Error) => void; + const close = vi.fn(() => Promise.resolve()); + const agent: Agent = { + name: "claude", + run: vi.fn( + (_prompt, _cwd, options) => + new Promise((_resolve, reject) => { + rejectRun = reject; + options?.signal?.addEventListener("abort", () => { + queueMicrotask(() => { + reject(new Error("Agent was aborted")); + }); + }); + }), + ), + close, + }; + const orchestrator = new Orchestrator( + config, + agent, + runInfo, + "ship it", + "/repo", + ); + + const startPromise = orchestrator.start(); + + await vi.waitFor(() => { + expect(agent.run).toHaveBeenCalledTimes(1); + }); + + orchestrator.stop(); + + expect(close).not.toHaveBeenCalled(); + + rejectRun(new Error("Agent was aborted")); + await startPromise; + + expect(close).toHaveBeenCalledTimes(1); + }); + + it("starts agent cleanup if a stopped iteration remains stuck after a grace period", async () => { + vi.useFakeTimers(); + + let rejectRun!: (error: Error) => void; + const close = vi.fn(() => { + rejectRun(new Error("Agent was aborted")); + return Promise.resolve(); + }); + const agent: Agent = { + name: "claude", + run: vi.fn( + (_prompt, _cwd, options) => + new Promise((_resolve, reject) => { + rejectRun = reject; + options?.signal?.addEventListener("abort", () => { + // Simulate an agent that stays hung until close() tears down + // its backing process. + }); + }), + ), + close, + }; + const orchestrator = new Orchestrator( + config, + agent, + runInfo, + "ship it", + "/repo", + ); + + const startPromise = orchestrator.start(); + + await vi.waitFor(() => { + expect(agent.run).toHaveBeenCalledTimes(1); + }); + + orchestrator.stop(); + + expect(close).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(250); + + expect(close).toHaveBeenCalledTimes(1); + + await startPromise; + }); + + it("does not record or commit a late successful result after stop is requested", async () => { + vi.useFakeTimers(); + + let resolveRun!: (result: AgentResult) => void; + const close = vi.fn(() => Promise.resolve()); + const agent: Agent = { + name: "claude", + run: vi.fn( + (_prompt, _cwd, options) => + new Promise((resolve) => { + resolveRun = resolve; + options?.signal?.addEventListener("abort", () => { + setTimeout(() => { + resolve(createSuccessResult("late success")); + }, 10); + }); + }), + ), + close, + }; + const orchestrator = new Orchestrator( + { ...config, preventSleep: false }, + agent, + runInfo, + "ship it", + "/repo", + ); + + const startPromise = orchestrator.start(); + + await vi.waitFor(() => { + expect(agent.run).toHaveBeenCalledTimes(1); + }); + + orchestrator.stop(); + await vi.advanceTimersByTimeAsync(10); + await startPromise; + + expect(resolveRun).toBeTypeOf("function"); + expect(mockAppendNotes).not.toHaveBeenCalled(); + expect(mockCommitAll).not.toHaveBeenCalled(); + expect(orchestrator.getState().iterations).toEqual([]); + expect(orchestrator.getState().status).toBe("stopped"); + expect(close).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/core/orchestrator.ts b/src/core/orchestrator.ts index 5c59bef..7c733c0 100644 --- a/src/core/orchestrator.ts +++ b/src/core/orchestrator.ts @@ -44,8 +44,11 @@ export interface RunLimits { maxTokens?: number; } +const STOP_CLOSE_AGENT_GRACE_MS = 250; + type RunIterationResult = | { type: "completed"; record: IterationRecord } + | { type: "stopped" } | { type: "aborted"; reason: string }; export class Orchestrator extends EventEmitter { @@ -57,6 +60,7 @@ export class Orchestrator extends EventEmitter { private limits: RunLimits; private stopRequested = false; private stopPromise: Promise | null = null; + private activeIterationPromise: Promise | null = null; private activeAbortController: AbortController | null = null; private pendingAbortReason: string | null = null; @@ -109,7 +113,27 @@ export class Orchestrator extends EventEmitter { if (this.stopPromise) return; this.stopPromise = (async () => { - await this.closeAgent(); + if (this.activeIterationPromise) { + const iterationPromise = this.activeIterationPromise.catch( + () => undefined, + ); + await new Promise((resolve) => { + let settled = false; + const settle = () => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(); + }; + const timer = setTimeout(settle, STOP_CLOSE_AGENT_GRACE_MS); + timer.unref?.(); + void iterationPromise.finally(settle); + }); + await this.closeAgent(); + await iterationPromise; + } else { + await this.closeAgent(); + } resetHard(this.cwd); this.state.status = "stopped"; this.emit("state", this.getState()); @@ -141,7 +165,12 @@ export class Orchestrator extends EventEmitter { prompt: this.prompt, }); - const result = await this.runIteration(iterationPrompt); + this.activeIterationPromise = this.runIteration(iterationPrompt); + const result = await this.activeIterationPromise; + this.activeIterationPromise = null; + if (result.type === "stopped") { + break; + } if (result.type === "aborted") { this.abort(result.reason); break; @@ -184,6 +213,7 @@ export class Orchestrator extends EventEmitter { } } } finally { + this.activeIterationPromise = null; if (this.stopPromise) { await this.stopPromise; } else { @@ -233,6 +263,10 @@ export class Orchestrator extends EventEmitter { logPath, }); + if (this.stopRequested) { + return { type: "stopped" }; + } + if (result.output.success) { return { type: "completed", record: this.recordSuccess(result.output) }; } @@ -254,6 +288,10 @@ export class Orchestrator extends EventEmitter { return { type: "aborted", reason: this.pendingAbortReason }; } + if (this.stopRequested) { + return { type: "stopped" }; + } + const summary = err instanceof Error ? err.message : String(err); return { type: "completed", diff --git a/src/core/run.test.ts b/src/core/run.test.ts index 961495a..504f2fe 100644 --- a/src/core/run.test.ts +++ b/src/core/run.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; +import { join } from "node:path"; vi.mock("node:fs", () => ({ mkdirSync: vi.fn(), @@ -29,6 +30,8 @@ import { import { findLegacyRunBaseCommit, getHeadCommit } from "./git.js"; import { setupRun, appendNotes, resumeRun } from "./run.js"; +const P = "/project"; + const mockMkdirSync = vi.mocked(mkdirSync); const mockWriteFileSync = vi.mocked(writeFileSync); const mockAppendFileSync = vi.mocked(appendFileSync); @@ -45,37 +48,37 @@ describe("setupRun", () => { }); it("creates the run directory recursively", () => { - setupRun("test-run-1", "fix bugs", "abc123", "/project"); - expect(mockMkdirSync).toHaveBeenCalledWith("/project/.git/info", { + setupRun("test-run-1", "fix bugs", "abc123", P); + expect(mockMkdirSync).toHaveBeenCalledWith(join(P, ".git", "info"), { recursive: true, }); expect(mockMkdirSync).toHaveBeenCalledWith( - "/project/.gnhf/runs/test-run-1", + join(P, ".gnhf", "runs", "test-run-1"), { recursive: true }, ); }); it("writes the ignore rule to .git/info/exclude", () => { - setupRun("run-abc", "test", "abc123", "/project"); + setupRun("run-abc", "test", "abc123", P); expect(mockWriteFileSync).toHaveBeenCalledWith( - "/project/.git/info/exclude", + join(P, ".git", "info", "exclude"), ".gnhf/runs/\n", "utf-8", ); }); it("writes PROMPT.md with the prompt text", () => { - setupRun("run-abc", "improve coverage", "abc123", "/project"); + setupRun("run-abc", "improve coverage", "abc123", P); expect(mockWriteFileSync).toHaveBeenCalledWith( - "/project/.gnhf/runs/run-abc/prompt.md", + join(P, ".gnhf", "runs", "run-abc", "prompt.md"), "improve coverage", "utf-8", ); }); it("writes notes.md with header and objective", () => { - setupRun("run-abc", "improve coverage", "abc123", "/project"); + setupRun("run-abc", "improve coverage", "abc123", P); const notesCall = mockWriteFileSync.mock.calls.find( (call) => typeof call[0] === "string" && call[0].endsWith("notes.md"), ); @@ -87,7 +90,7 @@ describe("setupRun", () => { }); it("writes output-schema.json with valid JSON schema", () => { - setupRun("run-abc", "test", "abc123", "/project"); + setupRun("run-abc", "test", "abc123", P); const schemaCall = mockWriteFileSync.mock.calls.find( (call) => typeof call[0] === "string" && call[0].endsWith("output-schema.json"), @@ -103,42 +106,42 @@ describe("setupRun", () => { }); it("writes the branch base commit for new runs", () => { - setupRun("run-abc", "test", "abc123", "/project"); + setupRun("run-abc", "test", "abc123", P); expect(mockWriteFileSync).toHaveBeenCalledWith( - "/project/.gnhf/runs/run-abc/base-commit", + join(P, ".gnhf", "runs", "run-abc", "base-commit"), "abc123\n", "utf-8", ); }); it("preserves the existing branch base commit on overwrite", () => { - mockExistsSync.mockImplementation( - (path) => path === "/project/.gnhf/runs/run-abc/base-commit", - ); + const baseCommitPath = join(P, ".gnhf", "runs", "run-abc", "base-commit"); + mockExistsSync.mockImplementation((path) => path === baseCommitPath); mockReadFileSync.mockImplementation((path) => - path === "/project/.gnhf/runs/run-abc/base-commit" ? "old123\n" : "", + path === baseCommitPath ? "old123\n" : "", ); - setupRun("run-abc", "test", "new456", "/project"); + setupRun("run-abc", "test", "new456", P); expect(mockWriteFileSync).not.toHaveBeenCalledWith( - "/project/.gnhf/runs/run-abc/base-commit", + baseCommitPath, "new456\n", "utf-8", ); }); it("returns correct RunInfo paths", () => { - const info = setupRun("my-run", "prompt text", "abc123", "/project"); + const runDir = join(P, ".gnhf", "runs", "my-run"); + const info = setupRun("my-run", "prompt text", "abc123", P); expect(info).toEqual({ runId: "my-run", - runDir: "/project/.gnhf/runs/my-run", - promptPath: "/project/.gnhf/runs/my-run/prompt.md", - notesPath: "/project/.gnhf/runs/my-run/notes.md", - schemaPath: "/project/.gnhf/runs/my-run/output-schema.json", + runDir, + promptPath: join(runDir, "prompt.md"), + notesPath: join(runDir, "notes.md"), + schemaPath: join(runDir, "output-schema.json"), baseCommit: "abc123", - baseCommitPath: "/project/.gnhf/runs/my-run/base-commit", + baseCommitPath: join(runDir, "base-commit"), }); }); }); @@ -149,14 +152,13 @@ describe("resumeRun", () => { }); it("refreshes output-schema.json to the current JSON schema", () => { - mockExistsSync.mockImplementation( - (path) => path === "/project/.gnhf/runs/run-abc", - ); + const runDir = join(P, ".gnhf", "runs", "run-abc"); + mockExistsSync.mockImplementation((path) => path === runDir); - resumeRun("run-abc", "/project"); + resumeRun("run-abc", P); expect(mockWriteFileSync).toHaveBeenCalledWith( - "/project/.gnhf/runs/run-abc/output-schema.json", + join(runDir, "output-schema.json"), expect.any(String), "utf-8", ); @@ -169,34 +171,30 @@ describe("resumeRun", () => { }); it("reads the stored base commit when present", () => { + const runDir = join(P, ".gnhf", "runs", "run-abc"); + const baseCommitPath = join(runDir, "base-commit"); mockExistsSync.mockImplementation( - (path) => - path === "/project/.gnhf/runs/run-abc" || - path === "/project/.gnhf/runs/run-abc/base-commit", + (path) => path === runDir || path === baseCommitPath, ); mockReadFileSync.mockImplementation((path) => - path === "/project/.gnhf/runs/run-abc/base-commit" ? "abc123\n" : "", + path === baseCommitPath ? "abc123\n" : "", ); - const info = resumeRun("run-abc", "/project"); + const info = resumeRun("run-abc", P); expect(info.baseCommit).toBe("abc123"); }); it("backfills missing base-commit for legacy runs", () => { - mockExistsSync.mockImplementation( - (path) => path === "/project/.gnhf/runs/run-abc", - ); + const runDir = join(P, ".gnhf", "runs", "run-abc"); + mockExistsSync.mockImplementation((path) => path === runDir); mockFindLegacyRunBaseCommit.mockReturnValue("legacy123"); - const info = resumeRun("run-abc", "/project"); + const info = resumeRun("run-abc", P); - expect(mockFindLegacyRunBaseCommit).toHaveBeenCalledWith( - "run-abc", - "/project", - ); + expect(mockFindLegacyRunBaseCommit).toHaveBeenCalledWith("run-abc", P); expect(mockWriteFileSync).toHaveBeenCalledWith( - "/project/.gnhf/runs/run-abc/base-commit", + join(runDir, "base-commit"), "legacy123\n", "utf-8", ); @@ -204,15 +202,14 @@ describe("resumeRun", () => { }); it("falls back to HEAD when a legacy run has no recoverable base commit", () => { - mockExistsSync.mockImplementation( - (path) => path === "/project/.gnhf/runs/run-abc", - ); + const runDir = join(P, ".gnhf", "runs", "run-abc"); + mockExistsSync.mockImplementation((path) => path === runDir); mockFindLegacyRunBaseCommit.mockReturnValue(null); mockGetHeadCommit.mockReturnValue("head456"); - const info = resumeRun("run-abc", "/project"); + const info = resumeRun("run-abc", P); - expect(mockGetHeadCommit).toHaveBeenCalledWith("/project"); + expect(mockGetHeadCommit).toHaveBeenCalledWith(P); expect(info.baseCommit).toBe("head456"); }); }); diff --git a/src/core/sleep.test.ts b/src/core/sleep.test.ts new file mode 100644 index 0000000..13fc8d1 --- /dev/null +++ b/src/core/sleep.test.ts @@ -0,0 +1,568 @@ +import { EventEmitter } from "node:events"; +import type { ChildProcess } from "node:child_process"; +import { + existsSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("node:child_process", () => ({ + spawn: vi.fn(), +})); + +import { spawn } from "node:child_process"; +import { startSleepPrevention } from "./sleep.js"; + +const mockSpawn = vi.mocked(spawn); + +function createChildProcess(pid = 1234): ChildProcess { + const child = Object.assign(new EventEmitter(), { + exitCode: null, + pid, + kill: vi.fn((signal?: NodeJS.Signals) => { + child.emit("close", signal === "SIGKILL" ? 1 : 0, null); + return true; + }), + signalCode: null, + }); + return child as unknown as ChildProcess; +} + +describe("startSleepPrevention", () => { + afterEach(() => { + vi.clearAllMocks(); + vi.useRealTimers(); + }); + + it("starts caffeinate on macOS and returns a cleanup handle", async () => { + const child = createChildProcess(); + mockSpawn.mockImplementation(() => { + queueMicrotask(() => child.emit("spawn")); + return child as never; + }); + + const result = await startSleepPrevention(["ship it"], { + pid: 42, + platform: "darwin", + }); + + expect(mockSpawn).toHaveBeenCalledWith( + "caffeinate", + ["-i", "-w", "42"], + expect.objectContaining({ stdio: "ignore" }), + ); + expect(result.type).toBe("active"); + if (result.type === "active") { + await result.cleanup(); + } + expect(child.kill).toHaveBeenCalledWith("SIGTERM"); + }); + + it("falls back when a helper exits immediately after spawn", async () => { + vi.useFakeTimers(); + + const child = createChildProcess(); + mockSpawn.mockImplementation(() => { + queueMicrotask(() => { + child.emit("spawn"); + setTimeout(() => { + child.emit("exit", 1, null); + }, 50); + }); + return child as never; + }); + + const resultPromise = startSleepPrevention(["ship it"], { + pid: 42, + platform: "darwin", + }); + + await vi.advanceTimersByTimeAsync(50); + + await expect(resultPromise).resolves.toEqual({ + type: "skipped", + reason: "unavailable", + }); + }); + + it("falls back when a helper has already exited by the time stability checking starts", async () => { + vi.useFakeTimers(); + + const child = createChildProcess(); + mockSpawn.mockImplementation(() => { + queueMicrotask(() => { + child.emit("spawn"); + child.exitCode = 1; + }); + return child as never; + }); + + const resultPromise = startSleepPrevention(["ship it"], { + pid: 42, + platform: "darwin", + }); + + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(100); + + await expect(resultPromise).resolves.toEqual({ + type: "skipped", + reason: "unavailable", + }); + }); + + it("re-execs under systemd-inhibit on Linux", async () => { + const child = createChildProcess(); + mockSpawn.mockImplementation(() => { + queueMicrotask(() => { + child.emit("spawn"); + child.emit("exit", 0, null); + }); + return child as never; + }); + + const result = await startSleepPrevention( + ["ship it", "--agent", "opencode"], + { + env: {}, + platform: "linux", + processArgv1: "/dist/cli.mjs", + processExecPath: "/node", + }, + ); + + expect(mockSpawn).toHaveBeenCalledWith( + "systemd-inhibit", + expect.arrayContaining([ + "--what=idle:sleep", + "--mode=block", + "/node", + "/dist/cli.mjs", + "ship it", + "--agent", + "opencode", + ]), + expect.objectContaining({ + detached: true, + stdio: "inherit", + env: expect.objectContaining({ GNHF_SLEEP_INHIBITED: "1" }), + }), + ); + expect(result).toEqual({ type: "reexeced", exitCode: 0 }); + }); + + it("preserves process.execArgv when re-execing under systemd-inhibit on Linux", async () => { + const child = createChildProcess(); + mockSpawn.mockImplementation(() => { + queueMicrotask(() => { + child.emit("spawn"); + child.emit("exit", 0, null); + }); + return child as never; + }); + + const result = await startSleepPrevention(["ship it"], { + env: {}, + platform: "linux", + processArgv1: "/dist/cli.mjs", + processExecPath: "/node", + processExecArgv: ["--inspect", "--loader", "tsx"], + }); + + expect(mockSpawn).toHaveBeenCalledWith( + "systemd-inhibit", + expect.arrayContaining([ + "--what=idle:sleep", + "--mode=block", + "/node", + "--inspect", + "--loader", + "tsx", + "/dist/cli.mjs", + "ship it", + ]), + expect.any(Object), + ); + expect(result).toEqual({ type: "reexeced", exitCode: 0 }); + }); + + it("preserves re-exec environment overrides when re-execing under systemd-inhibit on Linux", async () => { + const child = createChildProcess(); + mockSpawn.mockImplementation(() => { + queueMicrotask(() => { + child.emit("spawn"); + child.emit("exit", 0, null); + }); + return child as never; + }); + + const result = await startSleepPrevention(["--agent", "opencode"], { + env: {}, + platform: "linux", + processArgv1: "/dist/cli.mjs", + processExecPath: "/node", + reexecEnv: { + GNHF_REEXEC_STDIN_PROMPT: "objective from stdin", + }, + }); + + expect(mockSpawn).toHaveBeenCalledWith( + "systemd-inhibit", + expect.any(Array), + expect.objectContaining({ + env: expect.objectContaining({ + GNHF_REEXEC_STDIN_PROMPT: "objective from stdin", + GNHF_SLEEP_INHIBITED: "1", + }), + }), + ); + expect(result).toEqual({ type: "reexeced", exitCode: 0 }); + }); + + it("falls back when systemd-inhibit exits immediately with an error", async () => { + const child = createChildProcess(); + mockSpawn.mockImplementation(() => { + queueMicrotask(() => { + child.emit("spawn"); + child.emit("exit", 1, null); + }); + return child as never; + }); + + const result = await startSleepPrevention(["ship it"], { + env: {}, + platform: "linux", + processArgv1: "/dist/cli.mjs", + processExecPath: "/node", + }); + + expect(result).toEqual({ type: "skipped", reason: "unavailable" }); + }); + + it("signals readiness when running inside the re-execed Linux process", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "gnhf-sleep-")); + const readyPath = join(tempDir, "reexec-ready"); + + try { + const result = await startSleepPrevention(["ship it"], { + env: { + GNHF_SLEEP_INHIBITED: "1", + GNHF_SLEEP_REEXEC_READY_PATH: readyPath, + }, + platform: "linux", + }); + + expect(result).toEqual({ type: "skipped", reason: "already-inhibited" }); + expect(existsSync(readyPath)).toBe(true); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("does not overwrite an untrusted readiness path from the environment", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "gnhf-sleep-test-")); + const victimPath = join(tempDir, "victim.txt"); + writeFileSync(victimPath, "do not touch", "utf-8"); + + try { + const result = await startSleepPrevention(["ship it"], { + env: { + GNHF_SLEEP_INHIBITED: "1", + GNHF_SLEEP_REEXEC_READY_PATH: victimPath, + }, + platform: "linux", + }); + + expect(result).toEqual({ type: "skipped", reason: "already-inhibited" }); + expect(readFileSync(victimPath, "utf-8")).toBe("do not touch"); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("falls back when systemd-inhibit exits before the re-execed child signals readiness", async () => { + vi.useFakeTimers(); + + const child = createChildProcess(); + mockSpawn.mockImplementation(() => { + queueMicrotask(() => { + child.emit("spawn"); + setTimeout(() => { + child.emit("exit", 1, null); + }, 500); + }); + return child as never; + }); + + const resultPromise = startSleepPrevention(["ship it"], { + env: {}, + platform: "linux", + processArgv1: "/dist/cli.mjs", + processExecPath: "/node", + }); + + await vi.advanceTimersByTimeAsync(500); + + await expect(resultPromise).resolves.toEqual({ + type: "skipped", + reason: "unavailable", + }); + }); + + it("treats the Linux re-exec as authoritative after the child signals readiness", async () => { + vi.useFakeTimers(); + + const child = createChildProcess(); + mockSpawn.mockImplementation((_, __, options) => { + const readyPath = options?.env?.GNHF_SLEEP_REEXEC_READY_PATH; + + expect(readyPath).toEqual(expect.any(String)); + + queueMicrotask(() => { + child.emit("spawn"); + setTimeout(() => { + if (typeof readyPath === "string") { + writeFileSync(readyPath, "ready\n", "utf-8"); + } + }, 300); + setTimeout(() => { + child.emit("exit", 1, null); + }, 600); + }); + return child as never; + }); + + const resultPromise = startSleepPrevention(["ship it"], { + env: {}, + platform: "linux", + processArgv1: "/dist/cli.mjs", + processExecPath: "/node", + }); + + await vi.advanceTimersByTimeAsync(600); + + await expect(resultPromise).resolves.toEqual({ + type: "reexeced", + exitCode: 1, + }); + }); + + it("treats the Linux re-exec as authoritative when the ready file appears just before a nonzero exit", async () => { + vi.useFakeTimers(); + + const child = createChildProcess(); + mockSpawn.mockImplementation((_, __, options) => { + const readyPath = options?.env?.GNHF_SLEEP_REEXEC_READY_PATH; + + expect(readyPath).toEqual(expect.any(String)); + + queueMicrotask(() => { + child.emit("spawn"); + setTimeout(() => { + if (typeof readyPath === "string") { + writeFileSync(readyPath, "ready\n", "utf-8"); + } + }, 301); + setTimeout(() => { + child.emit("exit", 1, null); + }, 302); + }); + return child as never; + }); + + const resultPromise = startSleepPrevention(["ship it"], { + env: {}, + platform: "linux", + processArgv1: "/dist/cli.mjs", + processExecPath: "/node", + }); + + await vi.advanceTimersByTimeAsync(302); + + await expect(resultPromise).resolves.toEqual({ + type: "reexeced", + exitCode: 1, + }); + }); + + it("forwards SIGTERM to systemd-inhibit while waiting for the re-execed child to exit", async () => { + vi.useFakeTimers(); + + const child = createChildProcess(); + const killProcess = vi.fn(() => true); + let handleSigTerm: (() => void) | undefined; + const processOn = vi.fn((event: string, listener: () => void) => { + if (event === "SIGTERM") handleSigTerm = listener; + return process; + }); + const processOff = vi.fn(() => process); + mockSpawn.mockImplementation((_, __, options) => { + const readyPath = options?.env?.GNHF_SLEEP_REEXEC_READY_PATH; + + queueMicrotask(() => { + child.emit("spawn"); + setTimeout(() => { + if (typeof readyPath === "string") { + writeFileSync(readyPath, "ready\n", "utf-8"); + } + }, 100); + }); + return child as never; + }); + + const resultPromise = startSleepPrevention(["ship it"], { + env: {}, + platform: "linux", + processArgv1: "/dist/cli.mjs", + processExecPath: "/node", + killProcess, + processOn, + processOff, + }); + + await vi.advanceTimersByTimeAsync(100); + handleSigTerm?.(); + child.emit("exit", null, "SIGTERM"); + + await expect(resultPromise).resolves.toEqual({ + type: "reexeced", + exitCode: 143, + }); + expect(killProcess).toHaveBeenCalledWith(-1234, "SIGTERM"); + expect(processOff).toHaveBeenCalledWith("SIGTERM", expect.any(Function)); + }); + + it("forwards signals received before waitForSpawn resolves", async () => { + const child = createChildProcess(); + const killProcess = vi.fn(() => true); + let handleSigInt: (() => void) | undefined; + const processOn = vi.fn((event: string, listener: () => void) => { + if (event === "SIGINT") handleSigInt = listener; + return process; + }); + const processOff = vi.fn(() => process); + mockSpawn.mockImplementation(() => { + // Simulate SIGINT arriving *before* the spawn event fires. + queueMicrotask(() => { + handleSigInt?.(); + child.emit("spawn"); + child.emit("exit", null, "SIGINT"); + }); + return child as never; + }); + + const result = await startSleepPrevention(["ship it"], { + env: {}, + platform: "linux", + processArgv1: "/dist/cli.mjs", + processExecPath: "/node", + killProcess, + processOn, + processOff, + }); + + expect(result).toEqual({ type: "reexeced", exitCode: 130 }); + expect(killProcess).toHaveBeenCalledWith(-1234, "SIGINT"); + expect(processOff).toHaveBeenCalledWith("SIGINT", expect.any(Function)); + }); + + it("tears down signal forwarding when spawn fails", async () => { + const child = createChildProcess(); + const processOn = vi.fn(() => process); + const processOff = vi.fn(() => process); + mockSpawn.mockImplementation(() => { + queueMicrotask(() => { + child.emit("error", new Error("spawn failed")); + }); + return child as never; + }); + + const result = await startSleepPrevention(["ship it"], { + env: {}, + platform: "linux", + processArgv1: "/dist/cli.mjs", + processExecPath: "/node", + processOn, + processOff, + }); + + expect(result).toEqual({ type: "skipped", reason: "unavailable" }); + expect(processOn).toHaveBeenCalledWith("SIGINT", expect.any(Function)); + expect(processOn).toHaveBeenCalledWith("SIGTERM", expect.any(Function)); + expect(processOff).toHaveBeenCalledWith("SIGINT", expect.any(Function)); + expect(processOff).toHaveBeenCalledWith("SIGTERM", expect.any(Function)); + }); + + it("shuts down the Linux re-exec process group before falling back after a readiness timeout", async () => { + vi.useFakeTimers(); + + const child = createChildProcess(); + const killProcess = vi.fn((pid: number, signal: NodeJS.Signals) => { + if (pid === -1234 && signal === "SIGTERM") { + queueMicrotask(() => { + child.emit("close", 0, null); + }); + } + return true; + }); + mockSpawn.mockImplementation(() => { + queueMicrotask(() => { + child.emit("spawn"); + }); + return child as never; + }); + + const resultPromise = startSleepPrevention(["ship it"], { + env: {}, + killProcess, + platform: "linux", + processArgv1: "/dist/cli.mjs", + processExecPath: "/node", + }); + + await vi.advanceTimersByTimeAsync(5_000); + + await expect(resultPromise).resolves.toEqual({ + type: "skipped", + reason: "unavailable", + }); + expect(killProcess).toHaveBeenCalledWith(-1234, "SIGTERM"); + }); + + it("starts a PowerShell helper on Windows", async () => { + const child = createChildProcess(); + mockSpawn.mockImplementation(() => { + queueMicrotask(() => child.emit("spawn")); + return child as never; + }); + + const result = await startSleepPrevention(["ship it"], { + pid: 42, + platform: "win32", + }); + + expect(mockSpawn.mock.calls[0]?.[0]).toBe("powershell.exe"); + expect(mockSpawn.mock.calls[0]?.[1]).toEqual( + expect.arrayContaining([ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + ]), + ); + expect(String(mockSpawn.mock.calls[0]?.[1]?.at(-1))).toContain( + "SetThreadExecutionState", + ); + expect(String(mockSpawn.mock.calls[0]?.[1]?.at(-1))).toContain( + "Add-Type @'\n", + ); + expect(String(mockSpawn.mock.calls[0]?.[1]?.at(-1))).toContain("\n'@;"); + expect(String(mockSpawn.mock.calls[0]?.[1]?.at(-1))).toContain("42"); + expect(result.type).toBe("active"); + }); +}); diff --git a/src/core/sleep.ts b/src/core/sleep.ts new file mode 100644 index 0000000..fd55c7a --- /dev/null +++ b/src/core/sleep.ts @@ -0,0 +1,456 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, dirname, join, resolve } from "node:path"; +import { + shutdownChildProcess, + signalChildProcess, +} from "./agents/managed-process.js"; +import { appendDebugLog } from "./debug-log.js"; + +export type SleepPreventionResult = + | { + type: "active"; + cleanup: () => Promise; + } + | { + type: "reexeced"; + exitCode: number; + } + | { + type: "skipped"; + reason: "already-inhibited" | "unavailable" | "unsupported"; + }; + +interface SleepPreventionDeps { + env?: NodeJS.ProcessEnv; + killProcess?: typeof process.kill; + pid?: number; + platform?: NodeJS.Platform; + processExecArgv?: string[]; + processArgv1?: string; + processExecPath?: string; + processOff?: typeof process.off; + processOn?: typeof process.on; + reexecEnv?: NodeJS.ProcessEnv; + spawn?: typeof spawn; +} + +const SYSTEMD_INHIBIT_READY_TIMEOUT_MS = 5_000; +const SYSTEMD_INHIBIT_READY_POLL_MS = 25; +const GNHF_SLEEP_REEXEC_READY_PATH = "GNHF_SLEEP_REEXEC_READY_PATH"; +const GNHF_SLEEP_REEXEC_READY_DIR_PREFIX = "gnhf-sleep-"; +const GNHF_SLEEP_REEXEC_READY_FILENAME = "reexec-ready"; +const HELPER_STARTUP_GRACE_MS = 100; + +function getSignalExitCode(signal: NodeJS.Signals | null): number { + if (signal === "SIGINT") return 130; + if (signal === "SIGTERM") return 143; + return 1; +} + +async function waitForSpawn(child: ChildProcess): Promise { + return await new Promise((resolve) => { + child.once("spawn", () => resolve(true)); + child.once("error", () => resolve(false)); + }); +} + +async function waitForHelperStability( + child: ChildProcess, + timeoutMs: number, +): Promise { + return await new Promise((resolve) => { + let settled = false; + let timer: ReturnType | null = null; + const settle = (value: boolean) => { + if (settled) return; + settled = true; + if (timer) { + clearTimeout(timer); + timer = null; + } + resolve(value); + }; + + child.once("exit", () => { + settle(false); + }); + child.once("error", () => { + settle(false); + }); + + if (child.exitCode != null || child.signalCode != null) { + settle(false); + return; + } + + timer = setTimeout(() => { + settle(true); + }, timeoutMs); + timer.unref?.(); + }); +} + +function isTrustedLinuxReexecReadyPath(readyPath: string): boolean { + const resolvedReadyPath = resolve(readyPath); + const readyDir = dirname(resolvedReadyPath); + return ( + basename(resolvedReadyPath) === GNHF_SLEEP_REEXEC_READY_FILENAME && + dirname(readyDir) === resolve(tmpdir()) && + basename(readyDir).startsWith(GNHF_SLEEP_REEXEC_READY_DIR_PREFIX) + ); +} + +function signalLinuxReexecReady(env: NodeJS.ProcessEnv): void { + const readyPath = env[GNHF_SLEEP_REEXEC_READY_PATH]; + if (!readyPath) return; + if (!isTrustedLinuxReexecReadyPath(readyPath)) { + appendDebugLog("sleep:ready-signal-failed", { + command: "systemd-inhibit", + error: "untrusted ready path", + }); + return; + } + + try { + writeFileSync(readyPath, "ready\n", { encoding: "utf-8", flag: "wx" }); + } catch (error) { + appendDebugLog("sleep:ready-signal-failed", { + command: "systemd-inhibit", + error: error instanceof Error ? error.message : String(error), + }); + } +} + +async function waitForLinuxReexecReady( + readyPath: string, + exitStatePromise: Promise<{ + exitCode: number; + signal: NodeJS.Signals | null; + }>, + timeoutMs: number, +): Promise< + | { type: "ready" } + | { type: "exit"; exitCode: number; signal: NodeJS.Signals | null } + | { type: "timeout" } +> { + if (existsSync(readyPath)) { + return { type: "ready" }; + } + + return await new Promise((resolve) => { + let settled = false; + const settle = ( + result: + | { type: "ready" } + | { type: "exit"; exitCode: number } + | { type: "timeout" }, + ) => { + if (settled) return; + settled = true; + clearInterval(poller); + clearTimeout(timeout); + resolve(result); + }; + + const poller = setInterval(() => { + if (existsSync(readyPath)) { + settle({ type: "ready" }); + } + }, SYSTEMD_INHIBIT_READY_POLL_MS); + poller.unref?.(); + + const timeout = setTimeout(() => { + settle({ type: "timeout" }); + }, timeoutMs); + timeout.unref?.(); + + void exitStatePromise.then(({ exitCode, signal }) => { + settle({ type: "exit", exitCode, signal }); + }); + }); +} + +function forwardTerminationSignalsToChild( + child: ChildProcess, + detached: boolean, + killProcess: typeof process.kill, + processOn: typeof process.on, + processOff: typeof process.off, +): () => void { + const listeners: Array<[NodeJS.Signals, () => void]> = []; + + for (const signal of ["SIGINT", "SIGTERM"] as const) { + const listener = () => { + try { + signalChildProcess(child, { + detached, + killProcess, + signal, + }); + } catch { + // Best-effort only. + } + }; + processOn(signal, listener); + listeners.push([signal, listener]); + } + + return () => { + for (const [signal, listener] of listeners) { + processOff(signal, listener); + } + }; +} + +function buildPowerShellCommand(parentPid: number): string { + return [ + "Add-Type @'", + "using System;", + "using System.Runtime.InteropServices;", + "public static class SleepBlock {", + ' [DllImport("kernel32.dll")]', + " public static extern uint SetThreadExecutionState(uint flags);", + "}", + "'@;", + "$ES_CONTINUOUS = 0x80000000;", + "$ES_SYSTEM_REQUIRED = 0x00000001;", + "[SleepBlock]::SetThreadExecutionState($ES_CONTINUOUS -bor $ES_SYSTEM_REQUIRED) | Out-Null;", + `try { Wait-Process -Id ${parentPid} } catch { } finally { [SleepBlock]::SetThreadExecutionState($ES_CONTINUOUS) | Out-Null }`, + ].join("\n"); +} + +async function startHelperProcess( + command: string, + args: string[], + spawnFn: typeof spawn, + env: NodeJS.ProcessEnv, +): Promise { + const child = spawnFn(command, args, { + env, + stdio: "ignore", + }); + + const spawned = await waitForSpawn(child); + if (!spawned) { + appendDebugLog("sleep:unavailable", { command }); + return null; + } + + const stable = await waitForHelperStability(child, HELPER_STARTUP_GRACE_MS); + if (!stable) { + appendDebugLog("sleep:unavailable", { + command, + reason: "early-exit", + }); + return null; + } + + return child; +} + +export async function startSleepPrevention( + argv: string[], + deps: SleepPreventionDeps = {}, +): Promise { + const env = deps.env ?? process.env; + const killProcess = deps.killProcess ?? process.kill.bind(process); + const pid = deps.pid ?? process.pid; + const platform = deps.platform ?? process.platform; + const processExecArgv = deps.processExecArgv ?? process.execArgv; + const processArgv1 = deps.processArgv1 ?? process.argv[1]; + const processExecPath = deps.processExecPath ?? process.execPath; + const processOn = deps.processOn ?? process.on.bind(process); + const processOff = deps.processOff ?? process.off.bind(process); + const reexecEnv = deps.reexecEnv ?? {}; + const spawnFn = deps.spawn ?? spawn; + + if (platform === "linux") { + if (env.GNHF_SLEEP_INHIBITED === "1") { + signalLinuxReexecReady(env); + return { type: "skipped", reason: "already-inhibited" }; + } + + const readyDir = mkdtempSync( + join(tmpdir(), GNHF_SLEEP_REEXEC_READY_DIR_PREFIX), + ); + const readyPath = join(readyDir, GNHF_SLEEP_REEXEC_READY_FILENAME); + const child = spawnFn( + "systemd-inhibit", + [ + "--what=idle:sleep", + "--mode=block", + "--who=gnhf", + "--why=Prevent sleep while gnhf is running", + processExecPath, + ...processExecArgv, + processArgv1, + ...argv, + ], + { + detached: true, + env: { + ...env, + ...reexecEnv, + GNHF_SLEEP_INHIBITED: "1", + [GNHF_SLEEP_REEXEC_READY_PATH]: readyPath, + }, + stdio: "inherit", + }, + ); + const exitStatePromise = new Promise<{ + exitCode: number; + signal: NodeJS.Signals | null; + }>((resolve) => { + child.once("exit", (code, signal) => { + resolve({ + exitCode: signal ? getSignalExitCode(signal) : (code ?? 1), + signal, + }); + }); + }); + + // Register signal forwarding immediately so SIGINT/SIGTERM received + // between spawn and the readiness check are forwarded to the child. + const stopForwardingSignals = forwardTerminationSignalsToChild( + child, + true, + killProcess, + processOn, + processOff, + ); + + const spawned = await waitForSpawn(child); + if (!spawned) { + stopForwardingSignals(); + rmSync(readyDir, { recursive: true, force: true }); + appendDebugLog("sleep:unavailable", { command: "systemd-inhibit" }); + return { type: "skipped", reason: "unavailable" }; + } + + try { + const readyState = await waitForLinuxReexecReady( + readyPath, + exitStatePromise, + SYSTEMD_INHIBIT_READY_TIMEOUT_MS, + ); + try { + if (readyState.type === "ready") { + appendDebugLog("sleep:reexec", { command: "systemd-inhibit" }); + const { exitCode } = await exitStatePromise; + return { + type: "reexeced", + exitCode, + }; + } + + if (readyState.type === "exit") { + if ( + readyState.signal === "SIGINT" || + readyState.signal === "SIGTERM" + ) { + appendDebugLog("sleep:reexec", { + command: "systemd-inhibit", + signal: readyState.signal, + }); + return { type: "reexeced", exitCode: readyState.exitCode }; + } + + if (readyState.exitCode !== 0) { + if (existsSync(readyPath)) { + appendDebugLog("sleep:reexec", { + command: "systemd-inhibit", + exitCode: readyState.exitCode, + readySignal: "late", + }); + return { type: "reexeced", exitCode: readyState.exitCode }; + } + + appendDebugLog("sleep:unavailable", { + command: "systemd-inhibit", + exitCode: readyState.exitCode, + }); + return { type: "skipped", reason: "unavailable" }; + } + + appendDebugLog("sleep:reexec", { + command: "systemd-inhibit", + readySignal: false, + }); + return { type: "reexeced", exitCode: readyState.exitCode }; + } + + appendDebugLog("sleep:unavailable", { + command: "systemd-inhibit", + reason: "timeout", + timeoutMs: SYSTEMD_INHIBIT_READY_TIMEOUT_MS, + }); + await shutdownChildProcess(child, { + detached: true, + killProcess, + timeoutMs: 1_000, + }); + return { type: "skipped", reason: "unavailable" }; + } finally { + stopForwardingSignals(); + } + } finally { + rmSync(readyDir, { recursive: true, force: true }); + } + } + + if (platform === "darwin") { + const child = await startHelperProcess( + "caffeinate", + ["-i", "-w", String(pid)], + spawnFn, + env, + ); + if (!child) return { type: "skipped", reason: "unavailable" }; + + appendDebugLog("sleep:active", { command: "caffeinate" }); + return { + type: "active", + cleanup: async () => { + appendDebugLog("sleep:cleanup", { command: "caffeinate" }); + await shutdownChildProcess(child, { + detached: false, + timeoutMs: 1_000, + }); + }, + }; + } + + if (platform === "win32") { + const child = await startHelperProcess( + "powershell.exe", + [ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-Command", + buildPowerShellCommand(pid), + ], + spawnFn, + env, + ); + if (!child) return { type: "skipped", reason: "unavailable" }; + + appendDebugLog("sleep:active", { command: "powershell.exe" }); + return { + type: "active", + cleanup: async () => { + appendDebugLog("sleep:cleanup", { command: "powershell.exe" }); + await shutdownChildProcess(child, { + detached: false, + timeoutMs: 1_000, + }); + }, + }; + } + + return { type: "skipped", reason: "unsupported" }; +} diff --git a/src/core/stdin.test.ts b/src/core/stdin.test.ts new file mode 100644 index 0000000..11ba648 --- /dev/null +++ b/src/core/stdin.test.ts @@ -0,0 +1,20 @@ +import { Readable } from "node:stream"; +import { describe, expect, it } from "vitest"; +import { readStdinText } from "./stdin.js"; + +describe("readStdinText", () => { + it("reads and trims text from a stream", async () => { + const input = Readable.from(["ship it\n"]); + + await expect(readStdinText(input)).resolves.toBe("ship it"); + }); + + it("supports Buffer chunks", async () => { + const input = Readable.from([ + Buffer.from("multi"), + Buffer.from(" line\nobjective\n"), + ]); + + await expect(readStdinText(input)).resolves.toBe("multi line\nobjective"); + }); +}); diff --git a/src/core/stdin.ts b/src/core/stdin.ts new file mode 100644 index 0000000..3664749 --- /dev/null +++ b/src/core/stdin.ts @@ -0,0 +1,11 @@ +export async function readStdinText( + input: AsyncIterable, +): Promise { + const chunks: Buffer[] = []; + + for await (const chunk of input) { + chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk); + } + + return Buffer.concat(chunks).toString("utf-8").trim(); +} diff --git a/src/renderer.test.ts b/src/renderer.test.ts index 2c23062..b819f1b 100644 --- a/src/renderer.test.ts +++ b/src/renderer.test.ts @@ -283,7 +283,7 @@ describe("Renderer ctrl+c", () => { expect(dataHandler).not.toBeNull(); dataHandler?.(Buffer.from([3])); - await renderer.waitUntilExit(); + await expect(renderer.waitUntilExit()).resolves.toBe("interrupted"); expect( orchestrator.stop as ReturnType, diff --git a/src/renderer.ts b/src/renderer.ts index e55edbe..7aab26b 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -28,6 +28,8 @@ const MAX_MSG_LINES = 3; const MAX_MSG_LINE_LEN = 64; const RESUME_HINT = "[ctrl+c to stop, gnhf again to resume]"; +export type RendererExitReason = "interrupted" | "stopped"; + // ── ANSI helpers ───────────────────────────────────────────── export function stripAnsi(s: string): string { @@ -422,8 +424,8 @@ export class Renderer { private agentName: string; private state: OrchestratorState; private interval: ReturnType | null = null; - private exitResolve!: () => void; - private exitPromise: Promise; + private exitResolve!: (reason: RendererExitReason) => void; + private exitPromise: Promise; private topStars: Star[] = []; private bottomStars: Star[] = []; private sideStars: Star[] = []; @@ -448,7 +450,7 @@ export class Renderer { }); this.orchestrator.on("stopped", () => { - this.stop(); + this.stop("stopped"); }); if (process.stdin.isTTY) { @@ -456,7 +458,7 @@ export class Renderer { process.stdin.resume(); process.stdin.on("data", (data) => { if (data[0] === 3) { - this.stop(); + this.stop("interrupted"); this.orchestrator.stop(); } }); @@ -466,7 +468,7 @@ export class Renderer { this.render(); } - stop(): void { + stop(reason: RendererExitReason = "stopped"): void { if (this.interval) { clearInterval(this.interval); this.interval = null; @@ -476,10 +478,10 @@ export class Renderer { process.stdin.pause(); process.stdin.removeAllListeners("data"); } - this.exitResolve(); + this.exitResolve(reason); } - waitUntilExit(): Promise { + waitUntilExit(): Promise { return this.exitPromise; } diff --git a/test/e2e.test.ts b/test/e2e.test.ts new file mode 100644 index 0000000..57ca123 --- /dev/null +++ b/test/e2e.test.ts @@ -0,0 +1,267 @@ +import { execFileSync, spawn } from "node:child_process"; +import { + existsSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, describe, expect, it } from "vitest"; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const distCliPath = join(repoRoot, "dist", "cli.mjs"); +const fixtureBinDir = join(repoRoot, "test", "fixtures"); + +interface RunResult { + code: number | null; + signal: NodeJS.Signals | null; + stdout: string; + stderr: string; +} + +function git(args: string[], cwd: string): string { + return execFileSync("git", args, { cwd, encoding: "utf-8" }).trim(); +} + +function createRepo(): string { + const cwd = mkdtempSync(join(tmpdir(), "gnhf-e2e-")); + git(["init", "-b", "main"], cwd); + git(["config", "user.name", "gnhf tests"], cwd); + git(["config", "user.email", "tests@example.com"], cwd); + writeFileSync(join(cwd, "README.md"), "# fixture\n", "utf-8"); + git(["add", "README.md"], cwd); + git(["commit", "-m", "init"], cwd); + return cwd; +} + +function readJsonLines(filePath: string): Record[] { + if (!existsSync(filePath)) return []; + return readFileSync(filePath, "utf-8") + .trim() + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line) as Record); +} + +async function waitForLogEvent( + filePath: string, + event: string, + timeoutMs = 15_000, +): Promise> { + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + const match = readJsonLines(filePath).find( + (entry) => entry.event === event, + ); + if (match) return match; + await new Promise((resolveDelay) => setTimeout(resolveDelay, 50)); + } + + throw new Error(`Timed out waiting for log event ${event} in ${filePath}`); +} + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +function runCli( + cwd: string, + args: string[], + options: { stdin?: string; env?: NodeJS.ProcessEnv } = {}, +): Promise { + return new Promise((resolveResult, reject) => { + const child = spawn(process.execPath, [distCliPath, ...args], { + cwd, + env: options.env, + stdio: ["pipe", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { + stdout += chunk.toString(); + }); + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + child.on("error", reject); + child.on("close", (code, signal) => { + resolveResult({ code, signal, stdout, stderr }); + }); + + if (options.stdin !== undefined) { + child.stdin.end(options.stdin); + } else { + child.stdin.end(); + } + }); +} + +describe("gnhf e2e", () => { + const tempDirs: string[] = []; + + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + try { + rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 }); + } catch { + // Windows: child processes may still hold file locks briefly after exit + } + } + }); + + it("runs one iteration from an argv prompt and cleans up the mock opencode server", async () => { + const cwd = createRepo(); + tempDirs.push(cwd); + const logDir = mkdtempSync(join(tmpdir(), "gnhf-e2e-logs-")); + tempDirs.push(logDir); + const mockLogPath = join(logDir, "mock-opencode.jsonl"); + const debugLogPath = join(logDir, "gnhf-debug.jsonl"); + + const result = await runCli( + cwd, + ["ship it", "--agent", "opencode", "--max-iterations", "1"], + { + env: { + ...process.env, + PATH: `${fixtureBinDir}${process.platform === "win32" ? ";" : ":"}${process.env.PATH ?? ""}`, + GNHF_MOCK_OPENCODE_LOG_PATH: mockLogPath, + GNHF_DEBUG_LOG_PATH: debugLogPath, + }, + }, + ); + + expect(result.code).toBe(0); + expect(git(["rev-list", "--count", "HEAD"], cwd)).toBe("2"); + expect(git(["log", "-1", "--format=%s"], cwd)).toContain("gnhf #1:"); + + const startEvent = await waitForLogEvent(mockLogPath, "server:start"); + expect(startEvent.command).toBe("serve"); + expect(isProcessAlive(Number(startEvent.pid))).toBe(false); + + const debugEvents = readJsonLines(debugLogPath).map((entry) => entry.event); + expect(debugEvents).toContain("run:start"); + expect(debugEvents).toContain("run:complete"); + }, 30_000); + + it("reads the objective from stdin", async () => { + const cwd = createRepo(); + tempDirs.push(cwd); + const logDir = mkdtempSync(join(tmpdir(), "gnhf-e2e-logs-")); + tempDirs.push(logDir); + const mockLogPath = join(logDir, "mock-opencode.jsonl"); + + const result = await runCli( + cwd, + ["--agent", "opencode", "--max-iterations", "1"], + { + stdin: "ship it from stdin\n", + env: { + ...process.env, + PATH: `${fixtureBinDir}${process.platform === "win32" ? ";" : ":"}${process.env.PATH ?? ""}`, + GNHF_MOCK_OPENCODE_LOG_PATH: mockLogPath, + }, + }, + ); + + expect(result.code).toBe(0); + + const messageEvent = await waitForLogEvent(mockLogPath, "message:start"); + expect(String(messageEvent.prompt)).toContain("ship it from stdin"); + }, 30_000); + + it("resumes an existing gnhf branch without requiring the prompt again", async () => { + const cwd = createRepo(); + tempDirs.push(cwd); + const logDir = mkdtempSync(join(tmpdir(), "gnhf-e2e-logs-")); + tempDirs.push(logDir); + const mockLogPath = join(logDir, "mock-opencode.jsonl"); + + const env = { + ...process.env, + PATH: `${fixtureBinDir}${process.platform === "win32" ? ";" : ":"}${process.env.PATH ?? ""}`, + GNHF_MOCK_OPENCODE_LOG_PATH: mockLogPath, + }; + + const firstRun = await runCli( + cwd, + ["first prompt", "--agent", "opencode", "--max-iterations", "1"], + { env }, + ); + expect(firstRun.code).toBe(0); + + const secondRun = await runCli( + cwd, + ["--agent", "opencode", "--max-iterations", "2"], + { env }, + ); + expect(secondRun.code).toBe(0); + expect(git(["rev-list", "--count", "HEAD"], cwd)).toBe("3"); + }, 30_000); + + // Windows has no POSIX signals; child.kill("SIGINT") force-terminates the + // process tree without triggering the graceful shutdown path this test covers. + it.skipIf(process.platform === "win32")("shuts down the agent server when gnhf receives SIGINT", async () => { + const cwd = createRepo(); + tempDirs.push(cwd); + const logDir = mkdtempSync(join(tmpdir(), "gnhf-e2e-logs-")); + tempDirs.push(logDir); + const mockLogPath = join(logDir, "mock-opencode.jsonl"); + const debugLogPath = join(logDir, "gnhf-debug.jsonl"); + + const child = spawn( + process.execPath, + [distCliPath, "slow cleanup", "--agent", "opencode"], + { + cwd, + env: { + ...process.env, + PATH: `${fixtureBinDir}${process.platform === "win32" ? ";" : ":"}${process.env.PATH ?? ""}`, + GNHF_MOCK_OPENCODE_LOG_PATH: mockLogPath, + GNHF_DEBUG_LOG_PATH: debugLogPath, + }, + stdio: ["pipe", "pipe", "pipe"], + }, + ); + child.stdin.end(); + + const exitPromise = new Promise((resolveResult, reject) => { + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { + stdout += chunk.toString(); + }); + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + child.on("error", reject); + child.on("close", (code, signal) => { + resolveResult({ code, signal, stdout, stderr }); + }); + }); + + const startEvent = await waitForLogEvent(mockLogPath, "server:start"); + await waitForLogEvent(mockLogPath, "message:start"); + child.kill("SIGINT"); + + const result = await exitPromise; + expect(result.code).toBe(130); + expect(isProcessAlive(Number(startEvent.pid))).toBe(false); + + const mockEvents = readJsonLines(mockLogPath).map((entry) => entry.event); + expect(mockEvents).toContain("session:abort"); + expect(mockEvents).toContain("session:delete"); + + const debugEvents = readJsonLines(debugLogPath).map((entry) => entry.event); + expect(debugEvents).toContain("signal:SIGINT"); + }, 30_000); +}); diff --git a/test/fixtures/mock-opencode-server.mjs b/test/fixtures/mock-opencode-server.mjs new file mode 100755 index 0000000..86c1b20 --- /dev/null +++ b/test/fixtures/mock-opencode-server.mjs @@ -0,0 +1,266 @@ +#!/usr/bin/env node + +import console from "node:console"; +import { Buffer } from "node:buffer"; +import { appendFileSync } from "node:fs"; +import { join } from "node:path"; +import { createServer } from "node:http"; +import process from "node:process"; +import { setTimeout } from "node:timers"; + +function appendLog(event, details = {}) { + const logPath = process.env.GNHF_MOCK_OPENCODE_LOG_PATH; + if (!logPath) return; + appendFileSync( + logPath, + `${JSON.stringify({ timestamp: new Date().toISOString(), pid: process.pid, event, ...details })}\n`, + "utf-8", + ); +} + +function readJson(req) { + return new Promise((resolve, reject) => { + let body = ""; + req.on("data", (chunk) => { + body += chunk.toString(); + }); + req.on("end", () => { + if (!body) { + resolve({}); + return; + } + + try { + resolve(JSON.parse(body)); + } catch (error) { + reject(error); + } + }); + req.on("error", reject); + }); +} + +function sendJson(res, body, statusCode = 200) { + const text = JSON.stringify(body); + res.writeHead(statusCode, { + "content-type": "application/json", + "content-length": Buffer.byteLength(text), + }); + res.end(text); +} + +const args = process.argv.slice(2); +if (args[0] !== "serve") { + console.error("mock-opencode only supports 'serve'"); + process.exit(1); +} + +const host = args[args.indexOf("--hostname") + 1] ?? "127.0.0.1"; +const port = Number(args[args.indexOf("--port") + 1] ?? "0"); + +let sessionCounter = 0; +const sessions = new Map(); +const eventStreams = new Set(); + +function broadcast(event) { + const line = `data: ${JSON.stringify(event)}\n\n`; + for (const stream of eventStreams) { + stream.write(line); + } +} + +function buildStructuredResponse(summary) { + return { + success: true, + summary, + key_changes_made: ["README.md"], + key_learnings: ["mock opencode completed successfully"], + }; +} + +function emitCompletedEvents(sessionId, summary) { + const output = buildStructuredResponse(summary); + broadcast({ + directory: "/repo", + payload: { + type: "message.part.updated", + properties: { + sessionID: sessionId, + part: { + id: "part-commentary", + type: "text", + text: "Mock agent is working.", + metadata: { openai: { phase: "commentary" } }, + }, + }, + }, + }); + broadcast({ + directory: "/repo", + payload: { + type: "message.part.updated", + properties: { + sessionID: sessionId, + part: { + id: "part-final", + type: "text", + text: JSON.stringify(output), + metadata: { openai: { phase: "final_answer" } }, + }, + }, + }, + }); + broadcast({ + directory: "/repo", + payload: { + type: "message.part.updated", + properties: { + sessionID: sessionId, + part: { + id: "finish-1", + messageID: "msg-1", + type: "step-finish", + tokens: { + input: 10, + output: 5, + cache: { read: 1, write: 0 }, + }, + }, + }, + }, + }); + broadcast({ + directory: "/repo", + payload: { + type: "session.idle", + properties: { sessionID: sessionId }, + }, + }); + return output; +} + +function applyWorkspaceChange(sessionId) { + const session = sessions.get(sessionId); + if (!session?.directory) return; + + const marker = `- mock change ${Date.now()}\n`; + appendFileSync(join(session.directory, "README.md"), marker, "utf-8"); + appendLog("workspace:changed", { sessionId, marker: marker.trim() }); +} + +const server = createServer(async (req, res) => { + appendLog("http:request", { method: req.method, url: req.url }); + + if (req.method === "GET" && req.url === "/global/health") { + sendJson(res, { healthy: true, version: "mock" }); + return; + } + + if (req.method === "GET" && req.url === "/global/event") { + res.writeHead(200, { + "content-type": "text/event-stream", + "cache-control": "no-cache", + connection: "keep-alive", + }); + res.flushHeaders(); + eventStreams.add(res); + req.on("close", () => { + eventStreams.delete(res); + }); + return; + } + + if (req.method === "POST" && req.url === "/session") { + const body = await readJson(req); + const sessionId = `session-${++sessionCounter}`; + sessions.set(sessionId, { directory: body.directory, aborted: false }); + appendLog("session:create", { sessionId, directory: body.directory }); + sendJson(res, { id: sessionId }); + return; + } + + const match = req.url?.match(/^\/session\/([^/]+)(?:\/(message|abort))?$/); + if (match?.[2] === "message" && req.method === "POST") { + const sessionId = match[1]; + const body = await readJson(req); + const prompt = body.parts?.[0]?.text ?? ""; + appendLog("message:start", { sessionId, prompt }); + + if (String(prompt).includes("slow cleanup")) { + req.on("close", () => { + appendLog("message:closed", { sessionId }); + }); + return; + } + + applyWorkspaceChange(sessionId); + const output = emitCompletedEvents(sessionId, "mocked objective complete"); + sendJson(res, { + info: { + id: "msg-1", + role: "assistant", + structured: output, + tokens: { + input: 10, + output: 5, + cache: { read: 1, write: 0 }, + }, + }, + parts: [ + { + id: "part-final", + type: "text", + text: JSON.stringify(output), + metadata: { openai: { phase: "final_answer" } }, + }, + ], + }); + return; + } + + if (match?.[2] === "abort" && req.method === "POST") { + const sessionId = match[1]; + const session = sessions.get(sessionId); + if (session) session.aborted = true; + appendLog("session:abort", { sessionId }); + sendJson(res, { ok: true }); + return; + } + + if (match && !match[2] && req.method === "DELETE") { + const sessionId = match[1]; + sessions.delete(sessionId); + appendLog("session:delete", { sessionId }); + sendJson(res, { ok: true }); + return; + } + + sendJson(res, { error: "not found" }, 404); +}); + +let shuttingDown = false; + +function shutdown(reason) { + if (shuttingDown) return; + shuttingDown = true; + appendLog("server:shutdown", { reason }); + for (const stream of eventStreams) { + stream.end(); + } + server.close(() => { + process.exit(0); + }); + setTimeout(() => process.exit(0), 1_000).unref(); +} + +process.on("SIGINT", () => shutdown("SIGINT")); +process.on("SIGTERM", () => shutdown("SIGTERM")); +process.on("SIGBREAK", () => shutdown("SIGBREAK")); + +server.listen(port, host, () => { + appendLog("server:start", { + command: "serve", + host, + port, + }); +}); diff --git a/test/fixtures/opencode b/test/fixtures/opencode new file mode 100755 index 0000000..28993ed --- /dev/null +++ b/test/fixtures/opencode @@ -0,0 +1,4 @@ +#!/usr/bin/env sh + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +exec node "$SCRIPT_DIR/mock-opencode-server.mjs" "$@" diff --git a/test/fixtures/opencode.cmd b/test/fixtures/opencode.cmd new file mode 100644 index 0000000..7a97fe2 --- /dev/null +++ b/test/fixtures/opencode.cmd @@ -0,0 +1,2 @@ +@echo off +node "%~dp0\mock-opencode-server.mjs" %*