import { randomUUID } from "node:crypto"; import type { SpawnOptions } from "../execution/deps.js"; import type { HarnessEvent } from "../execution/events.js"; import { nodeRunnerDeps } from "../execution/node-deps.js"; import { streamTurn } from "../execution/stream-turn.js"; import { KILL_GRACE_MS } from "../execution/supervisor.js"; import { buildTurnEnv } from "../interpretation/argv.js"; import { ArgvRefusalError } from "../interpretation/refusal.js"; import { markJsonCrashStream } from "./crash.js"; import { EXIT_FAILURE, exitCodeForCause } from "./exit-codes.js"; import { createRunObserver } from "./observer.js"; import { ownOutputErrors } from "./output-errors.js"; import { planTurn, writePlanDiagnostics } from "./plan-turn.js"; import { refusalOf, refuse } from "./refuse.js"; import { createRenderState, renderEvent, writeEventNdjsonAsync } from "./render.js"; import { resolveHarness } from "./resolve-harness.js"; import { effectiveGuardEnv, resumeStore } from "./resume-guard.js"; import { resumeLastNoticesFor } from "./resume-last-guard.js"; import { createHarnessSignalTarget } from "./signal-target.js"; export const run = async (harnessName: string, rawArgs: string[]): Promise => { const h = resolveHarness(harnessName); if (rawArgs.includes("--help") || rawArgs.includes("-h")) { const { RUN_HELP } = await import("./help.js"); process.stdout.write(RUN_HELP); return; } // One owner turns the arguments into the plan the runner spawns from // (RFC-02 change 10); every refusal it raises is structured. const outcome = await planTurn(h, rawArgs, { command: "run" }); if (outcome.kind === "refusal") { refuse(outcome.refusal, outcome.wantJson); return; } const { plan } = outcome; const { wantJson } = plan; markJsonCrashStream(wantJson); writePlanDiagnostics(h, plan, "spawn"); // A harness that creates a session when the id is unknown would turn a // stale --resume into a silent blank session. Refuse when the session // store path does not exist; where the path cannot be computed, the // runner's pre-spawn warning is the only guard. The check runs against // the child's effective environment (--env merged over the process env // with the spawn's delete rule, plus the descriptor turn env), so a // --env store relocation moves the guard with the child. // RFC-06: the id-less most-recent path intentionally skips the store // check below - there is no id to look up. The guards on that path are // the CLI-computed pre-spawn notices (fixed warning, resolved-root // diagnostic, absent-directory warn) passed to the runner as plain data // plus the identity resumeLast signal, never a refusal: the absent case // is also the legitimate first run in a new cwd. const resume = plan.options.resume; let resumeVerified: true | undefined; if (resume !== undefined && h.resume.onMissing === "create") { // The guard reads the child's effective environment once: --env merged // over the process env with the spawn's delete rule, plus the // descriptor turn env. Home comes from that same env, so --env HOME // moves the guard with the child. const guardEnv = effectiveGuardEnv( process.env, plan.options.env ?? {}, buildTurnEnv(h, plan.options, "resume"), ); const { path, exists } = resumeStore(h, { home: guardEnv.HOME ?? guardEnv.USERPROFILE ?? "", cwd: plan.options.cwd ?? process.cwd(), sessionId: resume, env: guardEnv, }); if (path !== null && !exists) { refuse( { message: `no ${h.name} session ${resume} found at ${path}`, issue: "invalid-option-value", supported: [`a session id that exists in ${h.name}'s store`], }, wantJson, ); return; } if (path !== null && exists) resumeVerified = true; } // D-025: a child harness must not inherit Herdr's environment. delete (process.env as Record).HERDR_ENV; // D11: opt-in wall-clock budget (no profile entry by ratification); // 0 = explicit disable. const timeoutSeconds = plan.behavior.timeoutSeconds.value; const deps = timeoutSeconds !== undefined && timeoutSeconds > 0 ? nodeRunnerDeps({ turnTimeoutMs: timeoutSeconds * 1000 }) : nodeRunnerDeps(); // Signal handling: the runner owns the process handle, so the deps are // wrapped to forward SIGINT/SIGTERM at the harness child explicitly (L6). // The approval observer spawns its helper after the harness child; that // later spawn must not retarget signals, so the first spawn wins. // Spread-first object shape (ES2023-safe emit): every spread precedes // every method property, so tsc emits no mid-literal spread node - that // ES2025-only shape is what bun parses but node 24 rejects at parse // time (0.6.10 dist/cli/run.js died with `Unexpected token '('`). The // conditional spread stays first (an empty spread is a no-op) so the // ...deps override order is unchanged. const signalTarget = createHarnessSignalTarget(deps.signal); // The run lifecycle observer (ADR 0011): inert unless HCN_OBSERVER // enables it. The launch steps below run on the FIRST spawn only; the // approval helper's later spawn carries none of this. const observer = createRunObserver(h.name, plan.options.cwd); let observerLaunched = false; const wrappedDeps = { ...(plan.options.nativeApprovals ? { approvalInput: { chunks: process.stdin, close: () => process.stdin.destroy(), newId: randomUUID, }, } : {}), ...deps, spawn: (argv: readonly string[], opts: SpawnOptions) => { if (!observerLaunched) { observerLaunched = true; // Launch order: mint the invocation id, take the timestamp, add // HCN_INVOCATION_ID over the caller's env, spawn the harness, // then - in a finally, so a spawn that throws is also covered - // spawn the observer and write the started record. const invocationId = randomUUID(); const at = new Date().toISOString(); let proc: ReturnType; try { proc = deps.spawn(argv, observer.harnessEnv(opts, invocationId)); signalTarget.noteSpawn(proc); } finally { observer.launched(invocationId, at); } return proc; } const proc = deps.spawn(argv, opts); signalTarget.noteSpawn(proc); return proc; }, }; const abortController = new AbortController(); let outputError: Error | undefined; const releaseOutputErrors = plan.options.nativeApprovals ? ownOutputErrors((error) => { outputError = error; abortController.abort(); }) : () => {}; let interrupted = false; let escalationTimer: ReturnType | null = null; const onSig = async () => { if (interrupted) return; interrupted = true; abortController.abort(); try { signalTarget.forward("SIGTERM"); escalationTimer = setTimeout(() => { try { signalTarget.forward("SIGKILL"); } catch {} escalationTimer = null; }, KILL_GRACE_MS); escalationTimer.unref?.(); } catch {} }; process.on("SIGINT", onSig); process.on("SIGTERM", onSig); const state = createRenderState(); let lastEvent: HarnessEvent | null = null; let exitCode = 0; try { // RFC-06: the resume-last notices resolve through the child's // effective environment here in the CLI layer (a --env store // relocation moves them with the child) and travel to the runner as // plain data; execution only emits them. const resumeLastNotices = plan.options.resumeLast === true ? resumeLastNoticesFor(h, { env: effectiveGuardEnv( process.env, plan.options.env ?? {}, buildTurnEnv(h, plan.options, "resume"), ), cwd: plan.options.cwd ?? process.cwd(), }).messages : undefined; const events = streamTurn( h, { ...plan.options, ...(resumeVerified === true ? { resumeVerified } : {}), ...(resumeLastNotices !== undefined ? { resumeLastNotices } : {}), signal: abortController.signal, }, wrappedDeps, ); for await (const event of events) { if (outputError) throw outputError; lastEvent = event; observer.event(event); if (wantJson) { // Await the write: a consumer that stops reading must stall the // harness, not be absorbed into this process's memory. await writeEventNdjsonAsync(event); } else { renderEvent(event, state, h); } // A refusal before spawn exits 2 above; every done maps through the // one exit-code rule. if (event.kind === "done") exitCode = exitCodeForCause(event.cause); } } catch (err) { if (err instanceof ArgvRefusalError) { refuse(refusalOf(err), wantJson); return; } // Transport / spawn failure process.stderr.write(`run failed: ${err instanceof Error ? err.message : String(err)}\n`); // The synthetic failure and done go to the observer whatever --json // is: they are the run's terminal outcome even when stdout is gone. const transportFailure = { kind: "failure" as const, class: "transport" as const, retryable: true, message: String(err), }; const transportDone = { kind: "done" as const, exitCode: null, cause: "failed" as const, failure: transportFailure, }; if (lastEvent?.kind !== "done") { // The cast mirrors the stdout bytes: the synthetic done carries no // escalation record, which docs/observer.md documents. observer.event(transportFailure); observer.event(transportDone as unknown as HarnessEvent); } if (wantJson && lastEvent?.kind !== "done" && !outputError && !process.stdout.destroyed) { process.stdout.write(`${JSON.stringify(transportFailure)}\n`); process.stdout.write(`${JSON.stringify(transportDone)}\n`); } process.exitCode = EXIT_FAILURE; return; } finally { markJsonCrashStream(false); releaseOutputErrors(); observer.end(); if (escalationTimer !== null) { clearTimeout(escalationTimer); escalationTimer = null; } process.off("SIGINT", onSig); process.off("SIGTERM", onSig); } // If no done event was seen (should not happen), exit 1 if (lastEvent === null) { process.exitCode = EXIT_FAILURE; return; } if (lastEvent.kind === "done") { process.exitCode = exitCode; } else { // Stream ended without done (e.g., consumer break via head) process.exitCode = interrupted ? EXIT_FAILURE : 0; } if (interrupted && process.exitCode === 0) process.exitCode = EXIT_FAILURE; };