import { closeSync, existsSync, openSync, rmSync } from "node:fs"; import type { OrchestratorConfig } from "../config"; import { checkProviderAuth } from "../provider-probe"; import { resolveSpawnWorkspace, workspacesRoot } from "../workspace-probe"; import type { ManagedAgentReport } from "../relay"; import { applyProjectAcquisitionManifest } from "./acquisition"; import { buildEnv, buildRunnerCommand, defaultSpawnLabel, isWithinBaseDir, sessionLabelForSpawn, sessionName } from "./command"; import { addSessionRecord, clearRunnerTombstone, currentSessionPid, findSessionRecord, ensureLogDir, ensureRunnerInfoDir, logFilePath, runnerInfoPath, sessionRecordLiveness, sessionReportFields } from "./runtime"; import { managedAgentId } from "./sessions"; import { spawnRunner } from "./supervisor"; import type { SpawnOptions } from "./types"; interface SpawnAgentDeps { resolveSpawnWorkspace: typeof resolveSpawnWorkspace; applyProjectAcquisitionManifest: typeof applyProjectAcquisitionManifest; spawnRunner: typeof spawnRunner; addSessionRecord: typeof addSessionRecord; findSessionRecord: typeof findSessionRecord; sessionRecordLiveness: typeof sessionRecordLiveness; currentSessionPid: typeof currentSessionPid; sessionReportFields: typeof sessionReportFields; ensureLogDir: typeof ensureLogDir; ensureRunnerInfoDir: typeof ensureRunnerInfoDir; logFilePath: typeof logFilePath; runnerInfoPath: typeof runnerInfoPath; clearRunnerTombstone: typeof clearRunnerTombstone; checkProviderAuth: typeof checkProviderAuth; } const defaultSpawnAgentDeps: SpawnAgentDeps = { resolveSpawnWorkspace, applyProjectAcquisitionManifest, spawnRunner, addSessionRecord, findSessionRecord, sessionRecordLiveness, currentSessionPid, sessionReportFields, ensureLogDir, ensureRunnerInfoDir, logFilePath, runnerInfoPath, clearRunnerTombstone, checkProviderAuth, }; export async function spawnAgent( opts: SpawnOptions, config: OrchestratorConfig, deps: Partial = {}, ): Promise { const d = { ...defaultSpawnAgentDeps, ...deps }; const label = opts.label || defaultSpawnLabel(); const agentId = opts.agentId || managedAgentId(config, opts.provider, label); const name = sessionName(config, opts.provider, sessionLabelForSpawn({ ...opts, label }), opts.spawnRequestId ?? agentId); if (!isWithinBaseDir(opts.cwd, config.baseDir)) { throw new Error(`cwd must be within base directory: ${config.baseDir}`); } const acquisition = await d.applyProjectAcquisitionManifest(opts.acquisition, config.baseDir); if (!existsSync(opts.cwd)) { throw new Error(`cwd does not exist: ${opts.cwd}`); } const existing = existingSpawnSession(opts, d); if (existing) return existing; // #1254 — fail the spawn LOUDLY when this host's provider login is invalid/expired, instead of // launching a runner process that will register and then sit idle forever (unable to make any // provider call). Thrown here, this propagates straight through the command-poller's try/catch // to markSpawnCommandFailed → agent.spawn_failed, with a reason naming the actual cause. const authStatus = await d.checkProviderAuth(opts.provider); if (!authStatus.ok) { throw new Error(authStatus.reason ?? `${opts.provider} provider login appears invalid or expired on this host — re-authenticate before spawning`); } const resolvedWorkspace = await d.resolveSpawnWorkspace({ ...opts, label, workspaceSymlinks: opts.workspaceSymlinks, ...(acquisition?.baseRef && acquisition.baseSha ? { baseRef: acquisition.baseRef, baseSha: acquisition.baseSha } : {}), workspaceRoot: workspacesRoot(config.baseDir), }); if (resolvedWorkspace.reusedExisting) { const existingAfterPrep = existingSpawnSession(opts, d); if (existingAfterPrep) return existingAfterPrep; } const spawnOpts = { ...opts, label, agentId, cwd: resolvedWorkspace.cwd, workspace: resolvedWorkspace.workspace }; const command = buildRunnerCommand(spawnOpts, config); d.ensureLogDir(); d.ensureRunnerInfoDir(); const logFile = d.logFilePath(name); const runnerInfoFile = d.runnerInfoPath(name); rmSync(runnerInfoFile, { force: true }); // #1514 (Defect 3) — a spawn reusing this session name supersedes any prior incarnation: // drop a stale confirmed-dead tombstone so the freshly-spawned (live) runner can never be // reaped on the strength of its dead predecessor's death-proof. d.clearRunnerTombstone(name); const env = buildEnv({ ...spawnOpts, env: { ...(spawnOpts.env ?? {}), AGENT_RELAY_RUNNER_INFO_FILE: runnerInfoFile } }, config, logFile, name); const logFd = openSync(logFile, "w"); console.error(`[orchestrator] Spawning ${opts.provider} agent: ${name}`); console.error(`[orchestrator] cwd: ${opts.cwd}`); console.error(`[orchestrator] command: ${command.join(" ")}`); console.error(`[orchestrator] log: ${logFile}`); closeSync(logFd); const runner = await d.spawnRunner(name, command, spawnOpts.cwd, env, logFile); d.addSessionRecord({ name, pid: runner.pid, supervisor: runner.supervisor, provider: spawnOpts.provider, model: spawnOpts.model, effort: spawnOpts.effort, profile: spawnOpts.profile, workspaceMode: spawnOpts.workspaceMode, lifecycle: spawnOpts.lifecycle ?? "persistent", workspace: spawnOpts.workspace, label, cwd: spawnOpts.cwd, logFile, runnerInfoFile, agentId, approvalMode: spawnOpts.approvalMode, policyName: spawnOpts.policyName, spawnRequestId: spawnOpts.spawnRequestId, automationId: spawnOpts.automationId, automationRunId: spawnOpts.automationRunId, startedAt: Date.now(), }); return { agentId, provider: spawnOpts.provider, model: spawnOpts.model, effort: spawnOpts.effort, profile: spawnOpts.profile, workspaceMode: spawnOpts.workspaceMode, lifecycle: spawnOpts.lifecycle ?? "persistent", workspace: spawnOpts.workspace, ...d.sessionReportFields({ name, supervisor: runner.supervisor, runnerInfoFile, agentId, provider: spawnOpts.provider }), cwd: spawnOpts.cwd, label, approvalMode: spawnOpts.approvalMode || "guarded", policyName: spawnOpts.policyName, spawnRequestId: spawnOpts.spawnRequestId, automationRunId: spawnOpts.automationRunId, pid: runner.pid, startedAt: Date.now(), }; } function existingSpawnSession(opts: SpawnOptions, deps: Pick): ManagedAgentReport | undefined { if (!opts.spawnRequestId) return undefined; // #1746 fwd — this is an EXACT-spawnRequestId idempotency probe ("is THIS request already running?"), // not a target resolution. Since #1746 fwd made selectSessionRecord "try every key" (a MISS on one key // falls through to the next, for the kill-switch), passing policyName here would let a FRESH // spawnRequestId that misses fall through to the policyName branch and wrongly dedupe a genuinely-new // spawn onto an existing policy session. spawnRequestId is unique, so match it and it ALONE. const record = deps.findSessionRecord({ spawnRequestId: opts.spawnRequestId }); if (!record) return undefined; const liveness = deps.sessionRecordLiveness(record); if (liveness === "dead") return undefined; const pid = deps.currentSessionPid(record); return { agentId: record.agentId, provider: record.provider as ManagedAgentReport["provider"], model: record.model, effort: record.effort, profile: record.profile, workspaceMode: record.workspaceMode, lifecycle: record.lifecycle ?? "persistent", workspace: record.workspace, ...deps.sessionReportFields(record), cwd: record.cwd, label: record.label, approvalMode: record.approvalMode || "guarded", policyName: record.policyName, spawnRequestId: record.spawnRequestId, automationRunId: record.automationRunId, pid, startedAt: record.startedAt, }; } export type { SpawnAgentDeps };