import { chmodSync, closeSync, existsSync, mkdirSync, openSync, rmSync, statSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import type { OrchestratorConfig } from "../config"; import type { ManagedSessionExitDiagnostics } from "../relay"; import { errMessage } from "agent-relay-sdk"; import { isPidAlive } from "agent-relay-sdk/process-utils"; import { shellEscape } from "agent-relay-sdk/shell-utils"; import { tmuxHasSession } from "agent-relay-sdk/tmux-utils"; import { sanitizeFsName } from "agent-relay-sdk/fs-name"; import { SESSION_DIR } from "./constants"; import { disableSystemdSupervisor, forceSystemdSupervisor } from "../config"; import { logLines, readLogTail } from "./log-utils"; import { captureSessionPaneBinding, clearRunnerTombstone, currentSessionPid, ensureSessionDir, findSessionRecord, isOwnerConfirmedDead, isSessionRecordAlive, loadState, logFilePath, readOwnerRunnerInfo, readRunnerInfo, removeSessionRecord, sessionSupervisor, writeRunnerTombstone, type OwnerDeathProbeDeps, type SessionPaneBinding } from "./runtime"; import { MANAGER_CMD_TIMEOUT_MS } from "../self-upgrade-guard"; import { systemdMainPidAsync, systemdUnitDiagnostics, systemdUnitName, type SystemdUnitDiagnostics } from "./systemd"; import type { SessionRecord, SessionSupervisor, SpawnedRunner } from "./types"; import { execProcess } from "../process"; const FILE_BACKED_ENV_PAYLOADS = new Map([ ["AGENT_RELAY_AGENT_PROFILE_JSON", "agent-profile.json"], ["AGENT_RELAY_WORKSPACE_JSON", "workspace.json"], ["AGENT_RELAY_INJECTION_EVENTS_JSON", "relay-injection-events.json"], ]); export async function spawnRunner(name: string, command: string[], cwd: string, env: Record, logFile: string): Promise { const launchScript = launchScriptPath(name); const launch = materializeLaunchPayload(launchScript, command, env); if (await shouldUseSystemdSupervisor()) { try { return await spawnSystemdRunner(name, launch.command, cwd, launch.env, logFile); } catch (error) { console.error(`[orchestrator] systemd runner supervisor unavailable for ${name}: ${errMessage(error)}`); console.error("[orchestrator] Falling back to process child; this agent will not survive orchestrator service restart."); } } ensureSessionDir(); writeFileSync(launchScript, buildLaunchScript(launch.command, cwd, launch.env), { mode: 0o700 }); chmodSync(launchScript, 0o700); const logFd = openSync(logFile, "a"); try { const proc = Bun.spawn([launchScript], { cwd, env: launch.env, stdin: "ignore", stdout: logFd, stderr: logFd, }); return { pid: proc.pid, supervisor: { type: "process", launchScript } }; } finally { closeSync(logFd); } } async function shouldUseSystemdSupervisor(): Promise { if (process.platform !== "linux") return false; if (disableSystemdSupervisor()) return false; if (forceSystemdSupervisor()) return true; // #1509 r12 (Finding 4): bounded — a hung systemctl must not hang every spawn at the supervisor // probe; a timeout reports ok:false ⇒ process-child fallback (the same degraded-but-live path a // systemd-less host takes). const result = await execProcess(["systemctl", "--user", "show-environment"], { stdout: "ignore", stderr: "ignore", timeoutMs: MANAGER_CMD_TIMEOUT_MS, timeoutLabel: "systemctl show-environment" }); return result.ok; } async function spawnSystemdRunner(name: string, command: string[], cwd: string, env: Record, logFile: string): Promise { const unit = systemdUnitName(name); const launchScript = launchScriptPath(name); ensureSessionDir(); writeFileSync(launchScript, buildLaunchScript(command, cwd, env), { mode: 0o700 }); chmodSync(launchScript, 0o700); // #1509 r12 (Finding 4): both manager calls bounded — an unbounded hung systemctl/systemd-run // wedged the spawn workflow indefinitely; a deadline-kill surfaces as a normal spawn failure. await execProcess(["systemctl", "--user", "stop", `${unit}.service`], { stdout: "ignore", stderr: "ignore", timeoutMs: MANAGER_CMD_TIMEOUT_MS, timeoutLabel: `systemctl stop ${unit}.service` }); const result = await execProcess([ "systemd-run", "--user", `--unit=${unit}`, "--collect", "--property=KillMode=control-group", `--property=StandardOutput=append:${logFile}`, `--property=StandardError=append:${logFile}`, launchScript, ], { timeoutMs: MANAGER_CMD_TIMEOUT_MS, timeoutLabel: `systemd-run ${unit}` }); if (!result.ok) { throw new Error(result.stderr || `systemd-run failed with exit code ${result.exitCode}`); } const pid = await waitForSystemdMainPid(unit, 2_000); if (!pid) throw new Error(`systemd unit ${unit}.service started without a MainPID`); return { pid, supervisor: { type: "systemd", unit, launchScript } }; } function launchScriptPath(session: string): string { const safe = sanitizeFsName(session, { replacement: "-", trimEdge: true, fallback: "agent" }); return join(SESSION_DIR, `${safe}.sh`); } function launchPayloadDirPath(launchScript: string): string { return `${launchScript}.d`; } function payloadFilePath(launchScript: string, filename: string): string { return join(launchPayloadDirPath(launchScript), filename); } function materializeEnvPayload(env: Record, launchScript: string): Record { const nextEnv = { ...env }; for (const [envKey, filename] of FILE_BACKED_ENV_PAYLOADS) { const value = nextEnv[envKey]; if (typeof value !== "string" || value.length === 0) continue; const filePath = payloadFilePath(launchScript, filename); mkdirSync(launchPayloadDirPath(launchScript), { recursive: true }); writeFileSync(filePath, value); delete nextEnv[envKey]; nextEnv[`${envKey}_FILE`] = filePath; } return nextEnv; } function materializeCommandArg(command: string[], env: Record, launchScript: string, flag: string, envKey: string, filename: string): void { const index = command.indexOf(flag); if (index < 0 || !command[index + 1]) return; const filePath = payloadFilePath(launchScript, filename); mkdirSync(launchPayloadDirPath(launchScript), { recursive: true }); writeFileSync(filePath, command[index + 1]!); command.splice(index, 2); env[`${envKey}_FILE`] = filePath; } export function materializeLaunchPayload(launchScript: string, command: string[], env: Record): { command: string[]; env: Record } { const nextCommand = [...command]; const nextEnv = materializeEnvPayload(env, launchScript); materializeCommandArg(nextCommand, nextEnv, launchScript, "--prompt", "AGENT_RELAY_PROMPT", "prompt.txt"); materializeCommandArg(nextCommand, nextEnv, launchScript, "--system-prompt-append", "AGENT_RELAY_SYSTEM_PROMPT_APPEND", "system-prompt-append.txt"); materializeCommandArg(nextCommand, nextEnv, launchScript, "--append-system-prompt", "AGENT_RELAY_SYSTEM_PROMPT_APPEND", "system-prompt-append.txt"); return { command: nextCommand, env: nextEnv }; } export function buildLaunchScript(command: string[], cwd: string, env: Record): string { const exports = Object.entries(env) .filter(([key, value]) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(key) && value !== undefined) .sort(([a], [b]) => a.localeCompare(b)) .map(([key, value]) => `export ${key}=${shellEscape(String(value))}`); return [ "#!/usr/bin/env bash", "set -euo pipefail", ...exports, `cd ${shellEscape(cwd)}`, `exec ${command.map(shellEscape).join(" ")}`, "", ].join("\n"); } async function waitForSystemdMainPid(unit: string, timeoutMs: number): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const pid = await systemdMainPidAsync(unit); if (pid > 0 && isPidAlive(pid)) return pid; await Bun.sleep(50); } return 0; } function logFileDiagnostics(logFile: string): Pick & { logUnavailable?: string } { try { const stat = statSync(logFile); if (stat.size === 0) return { logBytes: 0, logEmpty: true, logTail: [] }; const content = readLogTail(logFile); return { logBytes: stat.size, logEmpty: false, logTail: logLines(content).slice(-20), }; } catch (error) { return { logUnavailable: errMessage(error), }; } } /** Map systemd ExecMain* / Result + a best-effort journal OOM probe into signal/exitCode/oom (#636). */ function terminationDiagnostics(systemd: ManagedSessionExitDiagnostics["systemd"], pid?: number): Pick { const out: Pick = {}; if (systemd?.result === "oom-kill") { out.oom = { source: "systemd", detail: "Result=oom-kill" }; } if (systemd?.execMainCode && /killed|dumped/i.test(systemd.execMainCode) && systemd.execMainStatus) { out.signal = /^\d+$/.test(systemd.execMainStatus) ? `signal ${systemd.execMainStatus}` : `SIG${systemd.execMainStatus.replace(/^SIG/i, "")}`; } else if (systemd?.execMainCode === "exited" && systemd.execMainStatus && /^\d+$/.test(systemd.execMainStatus)) { out.exitCode = Number(systemd.execMainStatus); } // Best-effort kernel OOM-killer probe by pid — wrapped so a missing/permission-denied journal // never breaks diagnosis. Only consulted when systemd didn't already flag the OOM. if (!out.oom && pid && process.platform === "linux") { try { const result = Bun.spawnSync(["journalctl", "--user", "-k", "--no-pager", "-n", "200", "--grep", `Killed process ${pid}|Out of memory`], { stdin: "ignore", stdout: "pipe", stderr: "ignore", // #1509 r11 (Finding 6): bounded — a hung journalctl must not wedge the event loop. timeout: MANAGER_CMD_TIMEOUT_MS, killSignal: "SIGKILL", }); const text = result.exitCode === 0 ? result.stdout.toString() : ""; if (new RegExp(`Killed process ${pid}\\b|Out of memory: Killed process ${pid}\\b`).test(text)) { out.oom = { source: "journal", detail: `OOM-killer hit pid ${pid}` }; } } catch { // journal unavailable — leave OOM unset. } } return out; } export function describeSessionExit(record: SessionRecord, diagnostics: Omit): string { const seconds = Math.max(0, Math.round(diagnostics.runtimeMs / 1000)); const parts = [`managed ${record.provider} session ${record.name} exited after ${seconds}s`]; if (diagnostics.systemd?.unavailable) { parts.push(`systemd status unavailable: ${diagnostics.systemd.unavailable}`); } else if (diagnostics.systemd) { const state = [diagnostics.systemd.activeState, diagnostics.systemd.subState].filter(Boolean).join("/") || "unknown"; const result = diagnostics.systemd.result || "unknown"; const exit = [diagnostics.systemd.execMainCode, diagnostics.systemd.execMainStatus].filter(Boolean).join("/") || "unknown"; parts.push(`systemd ${diagnostics.systemd.unit}.service state=${state} result=${result} exit=${exit}`); } if (diagnostics.logEmpty) { parts.push("stdout/stderr log is empty"); } else if (diagnostics.logBytes === undefined) { parts.push("stdout/stderr log unavailable"); } if (!diagnostics.runnerInfoPresent) parts.push("runner info was not written"); return parts.join("; "); } export function diagnoseSessionExit(input: { agentId?: string; policyName?: string; spawnRequestId?: string; tmuxSession?: string; // Diagnostics already read by the liveness check that first detected this // session dead. When given, reused verbatim instead of querying `systemctl show` // again — a second, later query risks reading back a `--collect`ed transient // unit's defaulted (LoadState=not-found) properties instead of its real exit // status, which is exactly how #1317 laundered a status=1 death into a reported // "success exit=0/0". systemdOverride?: SystemdUnitDiagnostics; }): ManagedSessionExitDiagnostics | null { const record = findSessionRecord(input); if (!record) return null; const detectedAt = Date.now(); const supervisor = sessionSupervisor(record); const currentPid = currentSessionPid(record); const terminalAvailable = tmuxHasSession(record.name, readRunnerInfo(record)?.tmuxSocket); const log = logFileDiagnostics(record.logFile); const runnerInfoPresent = record.runnerInfoFile ? existsSync(record.runnerInfoFile) : false; const systemd = supervisor.type === "systemd" && supervisor.unit ? (input.systemdOverride ?? systemdUnitDiagnostics(supervisor.unit)) : undefined; const termination = terminationDiagnostics(systemd, record.pid ?? currentPid); const unavailable = [ ...(log.logUnavailable ? [`stdout/stderr log unavailable: ${log.logUnavailable}`] : []), ...(log.logEmpty ? ["stdout/stderr log empty"] : []), ...(!runnerInfoPresent ? ["runner info unavailable"] : []), ]; const base: Omit = { agentId: record.agentId, provider: record.provider as "claude" | "codex", workspaceMode: record.workspaceMode, workspace: record.workspace ?? (record.workspaceMode ? { mode: "shared", requestedMode: record.workspaceMode } : undefined), sessionName: record.name, tmuxSession: record.name, cwd: record.cwd, label: record.label, policyName: record.policyName, spawnRequestId: record.spawnRequestId, automationRunId: record.automationRunId, supervisor: supervisor.type, ...(supervisor.type === "systemd" && supervisor.unit ? { systemdUnit: supervisor.unit } : {}), terminalSession: record.name, terminalAvailable, pid: record.pid, currentPid, startedAt: record.startedAt, detectedAt, runtimeMs: Math.max(0, detectedAt - record.startedAt), logFile: record.logFile, logBytes: log.logBytes, logEmpty: log.logEmpty, logTail: log.logTail, runnerInfoFile: record.runnerInfoFile, runnerInfoPresent, ...(systemd ? { systemd } : {}), ...(termination.signal ? { signal: termination.signal } : {}), ...(termination.exitCode !== undefined ? { exitCode: termination.exitCode } : {}), ...(termination.oom ? { oom: termination.oom } : {}), ...(unavailable.length ? { unavailable } : {}), }; return { ...base, lastError: describeSessionExit(record, base), }; } function stopSystemdUnit(unit: string): void { // #1509 r11 (Finding 6): bounded — a hung systemctl must not wedge the event loop. Bun.spawnSync(["systemctl", "--user", "stop", `${unit}.service`], { stdin: "ignore", stdout: "ignore", stderr: "ignore", timeout: MANAGER_CMD_TIMEOUT_MS, killSignal: "SIGKILL", }); } function killSystemdUnit(unit: string): void { // #1509 r11 (Finding 6): bounded — a hung systemctl must not wedge the event loop. Bun.spawnSync(["systemctl", "--user", "kill", "--kill-whom=all", "--signal=SIGKILL", `${unit}.service`], { stdin: "ignore", stdout: "ignore", stderr: "ignore", timeout: MANAGER_CMD_TIMEOUT_MS, killSignal: "SIGKILL", }); } function cleanupSupervisor(supervisor: SessionSupervisor): void { if (supervisor.type === "systemd" && supervisor.unit) stopSystemdUnit(supervisor.unit); if (supervisor.launchScript) { rmSync(supervisor.launchScript, { force: true }); rmSync(launchPayloadDirPath(supervisor.launchScript), { recursive: true, force: true }); } } export function cleanupSessionRecord(record: SessionRecord, deps: OwnerDeathProbeDeps & { clearTombstone?: (name: string) => void; capturePaneBinding?: (name: string, tmuxSocket?: string) => SessionPaneBinding | null } = {}): void { // #1514 (r6, Defect 1/3) — the write-a-confirmed-dead-tombstone decision goes through the // SINGLE fail-closed, identity-bound gate (isOwnerConfirmedDead), NOT the two-state liveness // that reached this cleanup. A durable death tombstone is written ONLY on positive proof the // owning runner terminated. This is the exact r5 hole: an EPERM/ambiguous LIVE session used // to get a FALSE tombstone here (plus its runner-info deleted) → the reaper then killed it. // // Read the owner identity ONCE, before deletion, and reuse it for both the gate and the // tombstone stamp so the tombstone is identity-consistent with the runner-info it proves dead. const owner = (deps.readInfo ?? readOwnerRunnerInfo)(record.name); // #1514 (r7) — observe the (possibly still-live) tmux session's pane identity BEFORE any // teardown. This binding is stamped into the tombstone so it authorizes reaping ONLY the // exact session incarnation that was observable at the moment death was confirmed; a // tombstone written while no session was observable stays UNBOUND and can never authorize // reaping a session that appears later. The same observation feeds the gate's tombstone // path, keeping this call and the reaper on one decision surface. const pane = (deps.capturePaneBinding ?? captureSessionPaneBinding)(record.name, readRunnerInfo(record)?.tmuxSocket); const confirmedDead = isOwnerConfirmedDead( { name: record.name, panePid: pane?.panePid }, { classify: deps.classify, startMs: deps.startMs, readInfo: () => owner, readTombstone: deps.readTombstone }, ); if (confirmedDead) { // The runner is PROVEN dead but its owned tmux session can still linger untracked. Persist // durable proof-of-death (identity-bound to the dead runner AND to the observed lingering // session) in a SEPARATE dir this cleanup never touches, so the reaper can still reap the // orphan after runner-info is deleted below. writeRunnerTombstone({ name: record.name, pid: owner?.pid ?? record.pid, startedAt: owner?.startedAt ?? record.startedAt, runnerId: owner?.runnerId ?? readRunnerInfo(record)?.runnerId, confirmedDeadAt: Date.now(), panePid: pane?.panePid, paneStartMs: pane?.paneStartMs, }); } else { // NOT positively dead (EPERM / ambiguous / systemd-unavailable / still-live). We may still // clean runner-info here, so we MUST NOT leave any death-proof behind: clear a stale tombstone // so the reaper's fail-closed default (no proof ⇒ spare) protects a possibly-live session. (deps.clearTombstone ?? clearRunnerTombstone)(record.name); } cleanupSupervisor(sessionSupervisor(record)); if (record.runnerInfoFile) rmSync(record.runnerInfoFile, { force: true }); } export async function stopSession(name: string, config: OrchestratorConfig, reason: string, graceful = true, timeoutMs?: number): Promise<{ stopped: boolean; wasRunning: boolean }> { if (!name.startsWith(`${config.tmuxPrefix}-`)) throw new Error("session is not managed by this orchestrator"); const records = loadState(); const record = records.find((r) => r.name === name); if (!record || !isSessionRecordAlive(record)) { if (record) cleanupSessionRecord(record); removeSessionRecord(name); return { stopped: false, wasRunning: false }; } const pid = currentSessionPid(record); console.error(`[orchestrator] Stopping session ${name} (pid ${pid}): ${reason}`); const supervisor = sessionSupervisor(record); const gracefulTimeoutMs = sessionStopTimeoutMs(graceful, timeoutMs); if (supervisor.type === "systemd" && supervisor.unit) { stopSystemdUnit(supervisor.unit); const deadline = Date.now() + gracefulTimeoutMs; while (Date.now() < deadline && isSessionRecordAlive(record)) { await Bun.sleep(200); } if (isSessionRecordAlive(record)) { killSystemdUnit(supervisor.unit); const killDeadline = Date.now() + 2_000; while (Date.now() < killDeadline && isSessionRecordAlive(record)) { await Bun.sleep(100); } } if (isSessionRecordAlive(record)) return { stopped: false, wasRunning: true }; cleanupSessionRecord(record); removeSessionRecord(name); return { stopped: true, wasRunning: true }; } if (graceful) { try { process.kill(pid, "SIGTERM"); } catch {} const deadline = Date.now() + gracefulTimeoutMs; while (Date.now() < deadline && isPidAlive(pid)) { await Bun.sleep(200); } } if (isPidAlive(pid)) { try { process.kill(pid, "SIGKILL"); } catch {} const deadline = Date.now() + 2_000; while (Date.now() < deadline && isPidAlive(pid)) { await Bun.sleep(100); } } // Never report success while the process is still alive: deleting the session // record here would orphan a running process with no handle to stop it again. if (isPidAlive(pid)) { console.error(`[orchestrator] Session ${name} (pid ${pid}) survived SIGKILL; keeping record for retry`); return { stopped: false, wasRunning: true }; } cleanupSessionRecord(record); removeSessionRecord(name); return { stopped: true, wasRunning: true }; } function sessionStopTimeoutMs(graceful: boolean, timeoutMs?: number): number { if (!graceful) return 2_000; if (!Number.isSafeInteger(timeoutMs) || !timeoutMs || timeoutMs <= 0) return 10_000; return Math.min(timeoutMs, 60_000); }