Intelligence
Artifacts
Browse the repository, read documents, and manage the governance folders. Source, runtime, and infrastructure are read-only.
Repository
repositories/aaf-holdings/hq01/lib/process/spawn.ts
2.9 KB
import { spawn } from "node:child_process";
import fs from "node:fs";
/**
* Detached process spawning for the HQ01 Session Manager.
*
* The whole point of the session manager is that HQ01 must never block: the CEO
* taps "Start", a Claude CLI process is launched on HQ01, and the request
* returns immediately while the process keeps running — even after the parent
* Next.js worker handling the request is gone.
*
* We achieve that with `detached: true` + `child.unref()`, and we capture the
* child's stdout/stderr by handing it raw file descriptors opened in append
* mode. Because the fds are duplicated into the child, the parent can (and must)
* close its own copies right after spawning — the child keeps writing to the
* files on its own.
*/
export interface SpawnDetachedOptions {
/** Executable to run, e.g. "claude". Resolved via PATH. */
command: string;
/** Arguments passed to the executable. */
args: string[];
/** Working directory the process is launched in (the target repository). */
cwd: string;
/** Absolute path of the file stdout is appended to. */
stdoutPath: string;
/** Absolute path of the file stderr is appended to. */
stderrPath: string;
/** Extra environment variables, merged over the parent environment. */
env?: Record<string, string>;
}
export interface SpawnDetachedResult {
pid: number;
}
/**
* Spawn a long-lived, detached child process whose output is streamed to log
* files. Returns the child's PID. Throws if the process could not be spawned
* (e.g. the command was not found, or the cwd does not exist).
*/
export function spawnDetached(opts: SpawnDetachedOptions): SpawnDetachedResult {
const { command, args, cwd, stdoutPath, stderrPath, env } = opts;
// Open the log files in append mode. "a" creates them if missing and never
// truncates an existing log, so restarting/observing a session preserves
// earlier output.
const outFd = fs.openSync(stdoutPath, "a");
const errFd = fs.openSync(stderrPath, "a");
try {
const child = spawn(command, args, {
cwd,
detached: true,
// stdin is closed; stdout/stderr go straight to the log file descriptors.
stdio: ["ignore", outFd, errFd],
env: { ...process.env, ...(env ?? {}) },
});
// Surface synchronous spawn failures (ENOENT, EACCES) as thrown errors so
// the manager can record the session as "failed" rather than "running".
let spawnError: Error | null = null;
child.on("error", (err) => {
spawnError = err;
});
if (!child.pid) {
throw spawnError ?? new Error(`Failed to spawn "${command}" (no pid)`);
}
// Detach: let the child outlive this Node worker and the HTTP request.
child.unref();
return { pid: child.pid };
} finally {
// The child holds its own duplicated descriptors; release ours so we don't
// leak fds across the many sessions a long-running HQ01 will start.
fs.closeSync(outFd);
fs.closeSync(errFd);
}
}
root · /srv/aaf