Intelligence

Artifacts

Browse the repository, read documents, and manage the governance folders. Source, runtime, and infrastructure are read-only.

Repository
README.md
CONSTITUTION_COMPLIANCE_AUDIT_V1.mdREADME.md
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