diff --git a/CHANGELOG.md b/CHANGELOG.md index 000b502cf..8695cdbad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,11 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename - Drop unused `@opentui/keymap`, `@opentui/solid`, and `solid-js`. The interactive TUI is imperative `@opentui/core` only. +### Fixed + +- Headless `corbits exec` now registers the active run so SIGINT/SIGTERM/SIGHUP finalize `run.json`. + + ## [0.3.15] - 2026-09-04 ### Added diff --git a/src/exec/runner.ts b/src/exec/runner.ts index 3a7ca6d92..4224be0f3 100644 --- a/src/exec/runner.ts +++ b/src/exec/runner.ts @@ -44,7 +44,8 @@ import { sessionContextDir, sessionDir, } from "../session/index.js"; -import { saveState, type ConnectedMcpServer } from "../session/state.js"; +import { setActiveRun } from "../session/active-run.js"; +import { finalizeRunState, saveState, type ConnectedMcpServer } from "../session/state.js"; import { resolveExecRunStatus, type RunSink } from "../session/run-sink.js"; import { createRunSummary } from "../session/hooks.js"; import { @@ -292,7 +293,7 @@ export async function runExec(config: Config): Promise { ): Promise => { if (finalized && status === "running") return; if (status !== "running") finalized = true; - await saveState(config.cwd, sessionId, { + const snapshot = { status, turnsUsed: runSink?.getTurnCount() ?? turnsUsed, task, @@ -301,7 +302,22 @@ export async function runExec(config: Config): Promise { mcpServers: connectedMcp, ...(status !== "running" ? { finishedAt: Date.now() } : {}), ...(extra?.error !== undefined ? { error: extra.error } : {}), - }).catch((err: unknown) => { + }; + const write = + status === "running" + ? saveState(config.cwd, sessionId, snapshot).then(() => { + if (!finalized) { + setActiveRun({ + sessionId, + cwd: config.cwd, + task, + startedAt, + model: `${config.providerName}:${config.model}`, + }); + } + }) + : finalizeRunState(config.cwd, sessionId, snapshot); + await write.catch((err: unknown) => { // Persistence failure must not fail the run, but dropping it silently // hides disk/permission problems that leave run.json stale. logger.warn("saveState failed for session {sessionId} status={status}: {error}", { diff --git a/src/index.ts b/src/index.ts index ce6dd5914..17ad08c76 100644 --- a/src/index.ts +++ b/src/index.ts @@ -164,7 +164,8 @@ export async function handleFatal(kind: CrashKind, error: unknown): Promise = { // exists for the signal actually reaching the process: external // orchestration (kill, systemd, docker stop), or a terminal that never // entered raw mode at all (exec mode has no TUI host and no raw stdin, so -// its Ctrl+C is a real SIGINT today with no listener at all — Bun's default -// disposition kills it immediately without a chance to close out run.json). +// its Ctrl+C is a real SIGINT today). Listeners are already installed at +// process entry; they finalize the registered handle, and no-op only when +// none is registered. // // Terminal restore is done directly here, the same way handleFatal does it, // rather than left to OpenTUI's own same-signal listener (registered later, diff --git a/src/session/active-run.ts b/src/session/active-run.ts index 2e88c4004..7bff94ed1 100644 --- a/src/session/active-run.ts +++ b/src/session/active-run.ts @@ -1,7 +1,8 @@ // A module-level slot the top-level uncaughtException/unhandledRejection -// handler (src/index.ts) can reach even though persistRunSnapshot is a -// closure local to runTUI. Only ever consulted from the crash path: a run -// that never crashes never has this read. +// handler (src/index.ts) can reach even though persist is a closure local to +// the in-flight runner (TUI persistRunSnapshot or exec persist). Only ever +// consulted from the crash path and from signal handlers: a run that never +// crashes and is never signaled never has this read. // // Carries enough of the live run state (task, startedAt, model) that the // crash handler can build a full RunState record itself. It must not read diff --git a/tests/fixtures/crash-run/simulate-exec-signal.ts b/tests/fixtures/crash-run/simulate-exec-signal.ts new file mode 100644 index 000000000..e9de73793 --- /dev/null +++ b/tests/fixtures/crash-run/simulate-exec-signal.ts @@ -0,0 +1,66 @@ +// Spawned as a subprocess by tests/integration/exec-signal-finalize.test.ts. +// Installs process-level signal handlers the way import.meta.main does, then +// calls production runExec. Does not register the active-run handle itself — +// that is the product path under test. +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +import type { Config } from "../../../src/config/index.js"; +import { sessionDir } from "../../../src/session/index.js"; +import { withMockedModuleDuring } from "../../helpers/mock-module.js"; + +const cwd = process.cwd(); +const sessionId = process.env["SIGNAL_TEST_SESSION_ID"]; +if (sessionId === undefined) { + throw new Error("SIGNAL_TEST_SESSION_ID must be set"); +} + +const task = "headless exec signal task"; +const runDir = sessionDir(cwd, sessionId); +const runJsonPath = join(runDir, "run.json"); + +async function waitForRunningRunJson(): Promise { + for (;;) { + if (existsSync(runJsonPath)) { + try { + const state = JSON.parse(readFileSync(runJsonPath, "utf8")) as { status?: string }; + if (state.status === "running") return; + } catch { + // rename/parse race on the first persist + } + } + await new Promise((resolve) => setTimeout(resolve, 20)); + } +} + +await withMockedModuleDuring( + import.meta.resolve("../../../src/session/assemble-runtime.js"), + (real: typeof import("../../../src/session/assemble-runtime.js")) => ({ + ...real, + // Stall the first await after persist("running") so bootstrap catch cannot + // persist("failed") before the parent sends a signal. + assembleInferenceBase: () => new Promise(() => undefined), + }), + async () => { + const { installSignalHandlers } = await import("../../../src/index.js"); + const { runExec } = await import("../../../src/exec/runner.js"); + installSignalHandlers(); + const config = { + command: "exec", + task, + cwd, + configured: true, + providerName: "test-provider", + model: "test-model", + providers: {}, + force: false, + dangerouslySkipPermissions: true, + autoMode: false, + sessionId, + } as unknown as Config; + void runExec(config); + await waitForRunningRunJson(); + process.stdout.write(`${runDir}\n`); + await new Promise(() => undefined); + }, +); diff --git a/tests/integration/exec-signal-finalize.test.ts b/tests/integration/exec-signal-finalize.test.ts new file mode 100644 index 000000000..99896cb9b --- /dev/null +++ b/tests/integration/exec-signal-finalize.test.ts @@ -0,0 +1,75 @@ +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { describe, expect, test } from "bun:test"; + +import { generateSessionId } from "../../src/session/index.js"; +import type { RunState } from "../../src/session/state.js"; + +const FIXTURE = join(import.meta.dirname, "../fixtures/crash-run/simulate-exec-signal.ts"); + +async function readLine(stream: ReadableStream): Promise { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + while (!buffer.includes("\n")) { + const { value, done } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + } + reader.releaseLock(); + return buffer; +} + +describe("integration — signaled exec process finalizes run.json", () => { + test.each([ + ["SIGINT", 130], + ["SIGTERM", 143], + ["SIGHUP", 129], + ] as const)( + "%s writes status: failed and exits with %i", + async (signal, expectedExitCode) => { + const cwd = mkdtempSync(join(tmpdir(), "corbits-exec-signal-cwd-")); + const home = mkdtempSync(join(tmpdir(), "corbits-exec-signal-home-")); + const sessionId = generateSessionId(); + + try { + const proc = Bun.spawn(["bun", "run", FIXTURE], { + cwd, + env: { ...process.env, HOME: home, SIGNAL_TEST_SESSION_ID: sessionId }, + stdout: "pipe", + stderr: "pipe", + }); + + const output = await readLine(proc.stdout); + const [runDir] = output.split("\n"); + if (runDir === undefined || runDir.length === 0) { + const errText = await new Response(proc.stderr).text(); + throw new Error( + `fixture did not report a run directory: ${JSON.stringify(output)} stderr=${errText}`, + ); + } + + proc.kill(signal); + const exitCode = await proc.exited; + + expect(exitCode).toBe(expectedExitCode); + + const runJsonPath = join(runDir, "run.json"); + const raw = readFileSync(runJsonPath, "utf8"); + const state = JSON.parse(raw) as RunState; + + expect(state.status).toBe("failed"); + expect(state.status).not.toBe("running"); + expect(state.finishedAt).toBeGreaterThan(0); + expect(state.error).toBe(`terminated by ${signal}`); + expect(state.task).toBe("headless exec signal task"); + } finally { + rmSync(cwd, { recursive: true, force: true }); + rmSync(home, { recursive: true, force: true }); + } + }, + 15_000, + ); +}); diff --git a/tests/unit/exec/runner.test.ts b/tests/unit/exec/runner.test.ts index 4685c4cc0..a856fbdcc 100644 --- a/tests/unit/exec/runner.test.ts +++ b/tests/unit/exec/runner.test.ts @@ -1,4 +1,9 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + import { describe, expect, test } from "bun:test"; +import type { InferenceSource } from "@intx/types/runtime"; import type { Config } from "../../../src/config/index.js"; import { disposeExecRuntime, @@ -9,7 +14,11 @@ import { runExec, } from "../../../src/exec/runner.js"; import { BUILD_TOOLS, SKYWALKER_TOOLS } from "../../../src/agent/directors/tool-sets.js"; +import { clearActiveRun, getActiveRun, setActiveRun } from "../../../src/session/active-run.js"; +import { loadState, type RunState } from "../../../src/session/state.js"; +import type { AgentToolset } from "../../../src/agent/tools.js"; import { createSubAgentSessionStore } from "../../../src/subagent/session-store.js"; +import { withMockedModuleDuring } from "../../helpers/mock-module.js"; function bareConfig(task: string): Config { // Minimal unconfigured-shaped object is not enough — runExec only needs @@ -101,6 +110,8 @@ describe("selected provider refresh failures", () => { describe("runExec", () => { test("empty prompt exits 2 with stderr message without bootstrapping", async () => { + const previous = getActiveRun(); + clearActiveRun(); const stderrChunks: string[] = []; const origWrite = process.stderr.write.bind(process.stderr); process.stderr.write = ((chunk: string | Uint8Array, ...rest: unknown[]) => { @@ -114,8 +125,167 @@ describe("runExec", () => { expect(result.status).toBe("failed"); expect(result.error).toMatch(/missing prompt|empty prompt/i); expect(stderrChunks.join("")).toMatch(/missing prompt|empty prompt|Usage: corbits exec/i); + expect(getActiveRun()).toBeNull(); } finally { process.stderr.write = origWrite; + if (previous !== null) setActiveRun(previous); + else clearActiveRun(); + } + }); + + test("bootstrap throw after running write leaves terminal run.json and no active run", async () => { + const previous = getActiveRun(); + clearActiveRun(); + const cwd = mkdtempSync(join(tmpdir(), "corbits-exec-boot-cwd-")); + const home = mkdtempSync(join(tmpdir(), "corbits-exec-boot-home-")); + const sessionId = "exec-bootstrap-fail"; + try { + await withMockedModuleDuring( + import.meta.resolve("node:os"), + (real: typeof import("node:os")) => ({ ...real, homedir: () => home }), + async () => { + await withMockedModuleDuring( + import.meta.resolve("../../../src/session/assemble-runtime.js"), + (real: typeof import("../../../src/session/assemble-runtime.js")) => ({ + ...real, + assembleInferenceBase: () => Promise.reject(new Error("bootstrap failed")), + }), + async () => { + const { runExec: runExecUnderMock } = await import("../../../src/exec/runner.js"); + const result = await runExecUnderMock({ + ...bareConfig("do the thing"), + cwd, + sessionId, + }); + expect(result.exitCode).toBe(1); + expect(result.status).toBe("failed"); + const persisted = await loadState(cwd, sessionId, home); + expect(persisted.kind).toBe("ok"); + if (persisted.kind !== "ok") return; + expect(persisted.state.status).toBe("failed"); + expect(persisted.state.status).not.toBe("running"); + expect(persisted.state.finishedAt).toBeGreaterThan(0); + expect(persisted.state.task).toBe("do the thing"); + expect(persisted.state.error).toBe("bootstrap failed"); + expect(getActiveRun()).toBeNull(); + }, + ); + }, + ); + } finally { + if (previous !== null) setActiveRun(previous); + else clearActiveRun(); + rmSync(cwd, { recursive: true, force: true }); + rmSync(home, { recursive: true, force: true }); + } + }); + + test("an in-flight persist(running) overlapping a terminal persist does not resurrect the handle", async () => { + const previous = getActiveRun(); + clearActiveRun(); + const cwd = mkdtempSync(join(tmpdir(), "corbits-exec-resurrect-cwd-")); + const home = mkdtempSync(join(tmpdir(), "corbits-exec-resurrect-home-")); + const sessionId = "exec-running-overlap"; + const held = Promise.withResolvers(); + let runningSaves = 0; + let heldRunningSave: Promise | undefined; + const dummySource = { id: "test", provider: "test", model: "test" } as InferenceSource; + try { + await withMockedModuleDuring( + import.meta.resolve("node:os"), + (real: typeof import("node:os")) => ({ ...real, homedir: () => home }), + async () => { + await withMockedModuleDuring( + import.meta.resolve("../../../src/session/state.js"), + (real: typeof import("../../../src/session/state.js")) => ({ + ...real, + saveState: ( + saveCwd: string, + saveSessionId: string, + snapshot: RunState, + saveHome?: string, + ) => { + if (snapshot.status === "running") { + runningSaves += 1; + if (runningSaves === 1) { + return real.saveState(saveCwd, saveSessionId, snapshot, saveHome); + } + const issued = real.saveState(saveCwd, saveSessionId, snapshot, saveHome); + heldRunningSave = issued.then(() => held.promise); + return heldRunningSave; + } + return real.saveState(saveCwd, saveSessionId, snapshot, saveHome); + }, + }), + async () => { + await withMockedModuleDuring( + import.meta.resolve("../../../src/agent/tools.js"), + (real: typeof import("../../../src/agent/tools.js")) => ({ + ...real, + createAgentToolset: async (): Promise => + ({ + dispose: () => Promise.resolve(), + }) as AgentToolset, + }), + async () => { + await withMockedModuleDuring( + import.meta.resolve("../../../src/session/assemble-runtime.js"), + (real: typeof import("../../../src/session/assemble-runtime.js")) => ({ + ...real, + resolveLiveSessionSources: () => ({ + sources: [dummySource], + defaultSource: dummySource.id, + selected: dummySource, + }), + assembleChatAgent: () => ({ + directorHolder: {}, + buildAgent: async () => { + throw new Error("buildAgent should not run"); + }, + }), + assembleSessionLifecycle: async (wiring: { + onTurnBoundarySnapshot: () => void; + }) => { + wiring.onTurnBoundarySnapshot(); + throw new Error("overlap-terminal"); + }, + }), + async () => { + const { runExec: runExecUnderMock } = + await import("../../../src/exec/runner.js"); + const result = await runExecUnderMock({ + ...bareConfig("do the thing"), + cwd, + sessionId, + director: "builder", + globalSettingsPath: join(home, "settings.json"), + providers: [], + }); + expect(result.status).toBe("failed"); + expect(heldRunningSave).toBeDefined(); + held.resolve(undefined); + await heldRunningSave; + await Promise.resolve(); + await Promise.resolve(); + expect(getActiveRun()).toBeNull(); + const persisted = await loadState(cwd, sessionId, home); + expect(persisted.kind).toBe("ok"); + if (persisted.kind !== "ok") return; + expect(persisted.state.status).toBe("failed"); + expect(persisted.state.status).not.toBe("running"); + }, + ); + }, + ); + }, + ); + }, + ); + } finally { + if (previous !== null) setActiveRun(previous); + else clearActiveRun(); + rmSync(cwd, { recursive: true, force: true }); + rmSync(home, { recursive: true, force: true }); } }); });