diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 6f88e0db..6b1fed61 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "desktop", - "version": "0.15.101", + "version": "0.15.102", "description": "ClosedLoop Desktop", "author": "ClosedLoop AI ", "private": true, diff --git a/apps/desktop/scripts/build-agent-monitor.mjs b/apps/desktop/scripts/build-agent-monitor.mjs index 263ec1d4..d72ef113 100644 --- a/apps/desktop/scripts/build-agent-monitor.mjs +++ b/apps/desktop/scripts/build-agent-monitor.mjs @@ -6,13 +6,14 @@ // The generated runtime tree lives at `apps/desktop/.generated/agent-monitor` // and contains: // - server/ (copied from agent-dashboard, with ClosedLoop patches) +// - closedloop-runtime.js (Electron in-process runtime wrapper) // - scripts/ (copied from agent-dashboard, plus uninstall-hooks.js) // - client/dist/ (built from agent-dashboard-client with Vite) // - package.json / LICENSE // // Unlike the old vendored flow, this does not commit the upstream repo into // `/vendor`. The Electron app still ships the generated tree unpacked via -// extraResources so the sidecar server and hook scripts remain real files. +// extraResources so the in-process runtime and hook scripts remain real files. import { spawnSync } from "node:child_process"; import { createHash as hash } from "node:crypto"; @@ -53,6 +54,11 @@ const sourcePushLib = path.join(sourceRootDir, "server", "lib", "push.js"); const sourceClientIndex = path.join(sourceClientDir, "index.html"); const sourceClientDistDir = path.join(sourceClientDir, "dist"); const generatedServerEntry = path.join(generatedRootDir, "server", "index.js"); +const generatedRuntimeFile = path.join( + generatedRootDir, + "server", + "closedloop-runtime.js", +); const generatedSessionsRoute = path.join( generatedRootDir, "server", @@ -513,6 +519,835 @@ function buildClient() { } } +function renderClosedLoopRuntimeSource() { + return `"use strict"; + +// Generated by apps/desktop/scripts/build-agent-monitor.mjs. +// Hosts the generated Agent Monitor inside Electron main instead of spawning +// Electron-as-Node as a separate process. + +const childProcess = require("child_process"); +const Module = require("module"); +const path = require("path"); + +const CHILD_PROCESS_METHODS = [ + "spawn", + "exec", + "execFile", + "fork", + "spawnSync", + "execFileSync", + "execSync", +]; +const RUNTIME_ENV_KEYS = [ + "CCAM_RUNTIME_ROOT", + "DASHBOARD_PORT", + "CLAUDE_DASHBOARD_PORT", + "DASHBOARD_DB_PATH", + "CCAM_VAPID_KEYS_PATH", + "CCAM_ENABLE_RUN", + "CCAM_AUTO_INSTALL_HOOKS", + "SANDBOX_BASE_DIRECTORY", + "NODE_ENV", + "NODE_PATH", +]; + +let activeRuntime = null; +let activeRuntimeEnvContext = null; +let activeRuntimeStart = null; + +async function startClosedLoopAgentMonitorRuntime(options = {}) { + if (activeRuntime) return activeRuntime; + if (activeRuntimeStart) return activeRuntimeStart; + + activeRuntimeStart = startClosedLoopAgentMonitorRuntimeOnce(options).finally(() => { + activeRuntimeStart = null; + }); + return activeRuntimeStart; +} + +async function startClosedLoopAgentMonitorRuntimeOnce(options = {}) { + const signal = options.signal || null; + throwIfAborted(signal); + + const rootDir = path.resolve(options.rootDir || path.join(__dirname, "..")); + const port = parsePort( + options.port || (options.env && options.env.DASHBOARD_PORT) || process.env.DASHBOARD_PORT || "4820", + ); + const envState = installRuntimeContext(rootDir, { + ...(options.env || {}), + CCAM_RUNTIME_ROOT: rootDir, + DASHBOARD_PORT: String(port), + CLAUDE_DASHBOARD_PORT: String(port), + }); + const releaseChildProcessGuard = installChildProcessEnvGuard(envState); + clearRuntimeRequireCache(rootDir); + + let httpServer = null; + let services = null; + try { + throwIfAborted(signal); + const serverIndex = withRuntimeContext(() => require("./index")); + if ( + typeof serverIndex.createApp !== "function" || + typeof serverIndex.startServer !== "function" + ) { + throw new Error("generated server/index.js does not export createApp/startServer"); + } + + throwIfAborted(signal); + const app = withRuntimeContext(() => serverIndex.createApp()); + throwIfAborted(signal); + httpServer = await withRuntimeContext(() => serverIndex.startServer(app, port)); + throwIfAborted(signal); + services = withRuntimeContext(() => startRuntimeServices()); + throwIfAborted(signal); + + activeRuntime = { + port, + rootDir, + stop: once(async () => { + activeRuntime = null; + await stopRuntimeServices(services); + await closeWebSocket(); + await closeHttpServer(httpServer); + closeDatabase(); + releaseChildProcessGuard(); + envState.restore(); + clearRuntimeRequireCache(rootDir); + }), + }; + return activeRuntime; + } catch (error) { + try { + await stopRuntimeServices(services); + } catch { + /* ignore cleanup errors */ + } + try { + await closeWebSocket(); + } catch { + /* ignore cleanup errors */ + } + try { + await closeHttpServer(httpServer); + } catch { + /* ignore cleanup errors */ + } + try { + closeDatabase(); + } catch { + /* ignore cleanup errors */ + } + releaseChildProcessGuard(); + envState.restore(); + clearRuntimeRequireCache(rootDir); + activeRuntime = null; + throw error; + } +} + +function throwIfAborted(signal) { + if (!signal || !signal.aborted) return; + const reason = signal.reason; + if (reason instanceof Error) throw reason; + const error = new Error("Agent Monitor startup aborted"); + error.name = "AbortError"; + throw error; +} + +function startRuntimeServices() { + const handles = { + catalogFetchTimer: null, + catalogFetchPromise: null, + ingestAbort: new AbortController(), + ingestPromise: null, + maintenanceTimer: null, + stopped: false, + startupTimer: null, + stopWatchers: null, + updateScheduler: null, + }; + + const websocket = require("./websocket"); + const broadcast = websocket.broadcast; + const dbModule = require("./db"); + + try { + const { startUpdateScheduler } = require("./update-scheduler"); + handles.updateScheduler = startUpdateScheduler({ broadcast }); + } catch (err) { + console.warn("update scheduler failed to start:", err && err.message); + } + + handles.stopWatchers = startWatchers(broadcast); + reconcileDashboardRuns(); + maybeInstallHooks(); + handles.maintenanceTimer = startMaintenanceSweep(dbModule, broadcast); + runStartupBackfills(dbModule, handles); + handles.ingestPromise = startColdStartIngest(dbModule, handles.ingestAbort.signal); + + return handles; +} + +async function stopRuntimeServices(handles) { + if (!handles) { + stopTopLevelRuntimeSideEffects(); + return; + } + if (handles.stopped) { + stopTopLevelRuntimeSideEffects(); + return; + } + handles.stopped = true; + + if (handles.startupTimer) { + clearTimeout(handles.startupTimer); + handles.startupTimer = null; + } + if (handles.maintenanceTimer) { + clearInterval(handles.maintenanceTimer); + handles.maintenanceTimer = null; + } + if (handles.catalogFetchTimer) { + clearInterval(handles.catalogFetchTimer); + handles.catalogFetchTimer = null; + } + try { + handles.ingestAbort.abort(); + } catch { + /* ignore */ + } + try { + handles.stopWatchers && handles.stopWatchers(); + } catch { + /* ignore */ + } + try { + handles.updateScheduler && handles.updateScheduler.stop(); + } catch { + /* ignore */ + } + stopTopLevelRuntimeSideEffects(); + + await Promise.all([ + waitForPromise(handles.ingestPromise, 1000), + waitForPromise(handles.catalogFetchPromise, 1000), + ]); +} + +function stopTopLevelRuntimeSideEffects() { + try { + const hooksRouter = require("./routes/hooks"); + if (typeof hooksRouter.stopWatchdog === "function") hooksRouter.stopWatchdog(); + } catch { + /* ignore */ + } +} + +function startWatchers(broadcast) { + const specs = [ + ["cc", "./lib/cc-watcher", "startCcWatcher", "stopCcWatcher"], + ["codex", "./lib/codex-watcher", "startCodexWatcher", "stopCodexWatcher"], + ["cursor", "./lib/cursor-watcher", "startCursorWatcher", "stopCursorWatcher"], + ["copilot", "./lib/copilot-watcher", "startCopilotWatcher", "stopCopilotWatcher"], + ["opencode", "./lib/opencode-watcher", "startOpenCodeWatcher", "stopOpenCodeWatcher"], + ]; + + for (const [label, modPath, startFn] of specs) { + try { + const mod = require(modPath); + if (typeof mod[startFn] === "function") mod[startFn]({ broadcast }); + } catch (err) { + console.warn(label + "-watcher failed to start:", err && err.message); + } + } + + return () => { + for (const [, modPath, , stopFn] of specs) { + try { + const mod = require(modPath); + if (typeof mod[stopFn] === "function") mod[stopFn](); + } catch { + /* ignore */ + } + } + }; +} + +function reconcileDashboardRuns() { + try { + const { reconcileOrphans } = require("./lib/dashboard-runs"); + const reconciled = reconcileOrphans(); + if (reconciled > 0) { + console.log("[runs] reconciled " + reconciled + " orphan run(s) -> abandoned"); + } + } catch (err) { + console.warn("dashboard-runs reconciliation failed:", err && err.message); + } +} + +function maybeInstallHooks() { + if (process.env.CCAM_AUTO_INSTALL_HOOKS !== "1") return; + try { + const { installHooks } = require("../scripts/install-hooks"); + installHooks(true); + console.log("Claude Code hooks auto-configured."); + } catch { + /* non-fatal */ + } +} + +function startMaintenanceSweep(cleanupDb, broadcast) { + const staleMinutes = (() => { + const raw = parseInt(process.env.DASHBOARD_STALE_MINUTES, 10); + return Number.isFinite(raw) && raw > 0 ? raw : 180; + })(); + const sweepIntervalMs = Math.max( + 60000, + Math.min(300000, (staleMinutes * 60000) / 4), + ); + const { importCompactions } = require("../scripts/import-history"); + const { transcriptCache } = require("./routes/hooks"); + + const timer = setInterval(() => { + try { + const stale = cleanupDb.stmts.findStaleSessions.all("__periodic__", staleMinutes); + const now = new Date().toISOString(); + if (stale.length > 0) { + const staleIds = stale.map((s) => s.id); + const placeholders = staleIds.map(() => "?").join(","); + cleanupDb.db + .prepare( + "UPDATE agents SET status = 'completed', ended_at = COALESCE(ended_at, ?), updated_at = ? " + + "WHERE session_id IN (" + placeholders + ") AND status NOT IN ('completed', 'error')", + ) + .run(now, now, ...staleIds); + + for (const session of stale) { + cleanupDb.stmts.updateSession.run(null, "abandoned", now, null, session.id); + broadcast("session_updated", cleanupDb.stmts.getSession.get(session.id)); + const tpRow = cleanupDb.db + .prepare( + "SELECT json_extract(data, '$.transcript_path') as tp FROM events " + + "WHERE session_id = ? AND json_extract(data, '$.transcript_path') IS NOT NULL LIMIT 1", + ) + .get(session.id); + if (tpRow && tpRow.tp) transcriptCache.invalidate(tpRow.tp); + } + + for (const session of stale) { + const agents = cleanupDb.stmts.listAgentsBySession.all(session.id); + for (const agent of agents) { + if (agent.status === "completed") broadcast("agent_updated", agent); + } + } + } + + const active = cleanupDb.db + .prepare( + "SELECT DISTINCT e.session_id, json_extract(e.data, '$.transcript_path') as tp " + + "FROM events e JOIN sessions s ON s.id = e.session_id " + + "WHERE s.status = 'active' AND json_extract(e.data, '$.transcript_path') IS NOT NULL " + + "GROUP BY e.session_id ORDER BY MAX(e.id) DESC", + ) + .all(); + for (const row of active) { + if (!row.tp) continue; + try { + const compactions = transcriptCache.extractCompactions(row.tp); + if (compactions.length === 0) continue; + const mainAgentId = row.session_id + "-main"; + const created = importCompactions(cleanupDb, row.session_id, mainAgentId, compactions); + if (created > 0) { + broadcast( + "agent_created", + cleanupDb.stmts.getAgent.get( + row.session_id + "-compact-" + compactions[compactions.length - 1].uuid, + ), + ); + } + } catch (err) { + console.warn( + "[SWEEP] Compaction scan failed for session " + row.session_id + ":", + (err && err.message) || err, + ); + } + } + } catch (err) { + console.warn("[SWEEP] Maintenance sweep failed:", (err && err.message) || err); + } + }, sweepIntervalMs); + if (typeof timer.unref === "function") timer.unref(); + return timer; +} + +function runStartupBackfills(dbModule, handles) { + handles.startupTimer = setTimeout(() => { + withRuntimeContext(() => { + handles.startupTimer = null; + if (handles.stopped) return; + + try { + require("./lib/plan-backfill").runClaudePlanBackfill(dbModule.db); + } catch (err) { + console.warn("[plans] backfill failed:", err && err.message); + } + + try { + require("./lib/pr-backfill").runClaudePrBackfill(dbModule.db); + } catch (err) { + console.warn("[pull-requests] backfill failed:", err && err.message); + } + + try { + require("./lib/pack-scanner").runPackScanner(dbModule.db); + } catch (err) { + console.warn("[packs] scanner failed:", err && err.message); + } + + try { + const catalogSeed = require("./lib/catalog-seed.json"); + require("./lib/catalog-store").upsertCatalogSeed(dbModule.db, catalogSeed); + const catalogFetcher = require("./lib/catalog-fetcher"); + handles.catalogFetchPromise = catalogFetcher.runCatalogFetch(dbModule.db).catch(() => {}); + handles.catalogFetchTimer = catalogFetcher.scheduleCatalogFetch(dbModule.db); + } catch (err) { + console.warn("[catalog] startup failed:", err && err.message); + } + }); + }, 1000); + if (handles.startupTimer && typeof handles.startupTimer.unref === "function") { + handles.startupTimer.unref(); + } +} + +function startColdStartIngest(dbModule, signal) { + try { + const { ingestAllHarnesses } = require("./agent-monitor-shared/ingest-orchestrator"); + const { importAllSessions, backfillCompactions } = require("../scripts/import-history"); + const harnesses = [ + { key: "codex", importAll: require("./lib/codex-import").importAllCodexSessions }, + { key: "cursor", importAll: require("./lib/cursor-import").importAllCursorSessions }, + { key: "copilot", importAll: require("./lib/copilot-import").importAllCopilotSessions }, + { key: "opencode", importAll: require("./lib/opencode-import").importAllOpenCodeSessions }, + ]; + let existingCount = 0; + try { + existingCount = dbModule.db.prepare("SELECT COUNT(*) AS c FROM sessions").get().c; + } catch { + existingCount = 0; + } + if (existingCount === 0) { + try { + require("./agent-monitor-shared/ingest-paths").clearIngestState(); + } catch { + /* ignore */ + } + harnesses.unshift({ + key: "claude", + importAll: async (db, opts) => { + const result = await importAllSessions(db, opts); + try { + await backfillCompactions(db); + } catch { + /* ignore */ + } + return result; + }, + }); + } + return ingestAllHarnesses({ dbModule, harnesses, signal }).catch(() => {}); + } catch (err) { + console.warn("ingest orchestrator failed to start:", err && err.message); + return null; + } +} + +function installRuntimeContext(rootDir, env) { + const keys = new Set(RUNTIME_ENV_KEYS); + for (const key of Object.keys(env || {})) { + keys.add(key); + } + const runtimeEnv = {}; + const previous = new Map(); + for (const key of keys) { + previous.set(key, Object.prototype.hasOwnProperty.call(process.env, key) + ? process.env[key] + : undefined); + } + for (const [key, value] of Object.entries(env || {})) { + if (value !== undefined && value !== null) runtimeEnv[key] = String(value); + } + const hostEnv = process.env; + const hostCwd = process.cwd.bind(process); + const hostGlobalPaths = Module.globalPaths.slice(); + const hostResolveFilename = Module._resolveFilename; + const normalizedRoot = path.resolve(rootDir); + const runtimePrefix = normalizedRoot + path.sep; + const state = { + keys, + previous, + runtimeEnv, + depth: 0, + usesRuntimeContext() { + if (state.depth > 0) return true; + const stack = new Error().stack || ""; + return stack + .split("\\n") + .some((line) => !line.includes("closedloop-runtime.js") && line.includes(runtimePrefix)); + }, + restore() { + process.env = hostEnv; + process.cwd = hostCwd; + if (activeRuntimeEnvContext === state) { + activeRuntimeEnvContext = null; + } + Module._initPaths(); + Module.globalPaths.length = 0; + Module.globalPaths.push(...hostGlobalPaths); + Module._resolveFilename = hostResolveFilename; + }, + }; + function runtimeNodePaths() { + const runtimeNodePath = runtimeEnv.NODE_PATH || ""; + return String(runtimeNodePath) + .split(path.delimiter) + .filter((value) => value.length > 0) + .map((value) => path.resolve(value)); + } + function refreshRuntimeModulePaths() { + withRuntimeContext(() => Module._initPaths()); + const runtimePaths = runtimeNodePaths(); + const next = []; + const seen = new Set(); + for (const candidate of [...runtimePaths, ...hostGlobalPaths]) { + if (seen.has(candidate)) continue; + seen.add(candidate); + next.push(candidate); + } + Module.globalPaths.length = 0; + Module.globalPaths.push(...next); + } + Module._resolveFilename = function resolveFilename(request, parent, isMain, options) { + try { + return hostResolveFilename.call(this, request, parent, isMain, options); + } catch (error) { + const filename = parent && parent.filename ? path.resolve(parent.filename) : ""; + const fromRuntime = + filename === normalizedRoot || filename.startsWith(runtimePrefix); + if (!fromRuntime || !error || error.code !== "MODULE_NOT_FOUND") { + throw error; + } + const fallbackPaths = [ + ...runtimeNodePaths(), + ...((options && Array.isArray(options.paths)) ? options.paths : []), + ]; + return hostResolveFilename.call(this, request, parent, isMain, { + ...(options || {}), + paths: fallbackPaths, + }); + } + }; + const envProxy = new Proxy(hostEnv, { + get(target, prop, receiver) { + if ( + typeof prop === "string" && + state.usesRuntimeContext() && + Object.prototype.hasOwnProperty.call(runtimeEnv, prop) + ) { + return runtimeEnv[prop]; + } + return Reflect.get(target, prop, receiver); + }, + set(target, prop, value, receiver) { + if (typeof prop === "string" && state.usesRuntimeContext()) { + keys.add(prop); + if (!previous.has(prop)) { + previous.set(prop, Object.prototype.hasOwnProperty.call(target, prop) + ? target[prop] + : undefined); + } + if (value === undefined || value === null) delete runtimeEnv[prop]; + else runtimeEnv[prop] = String(value); + refreshRuntimeModulePaths(); + return true; + } + return Reflect.set(target, prop, value, receiver); + }, + deleteProperty(target, prop) { + if (typeof prop === "string" && state.usesRuntimeContext()) { + keys.add(prop); + if (!previous.has(prop)) { + previous.set(prop, Object.prototype.hasOwnProperty.call(target, prop) + ? target[prop] + : undefined); + } + delete runtimeEnv[prop]; + refreshRuntimeModulePaths(); + return true; + } + return Reflect.deleteProperty(target, prop); + }, + has(target, prop) { + if ( + typeof prop === "string" && + state.usesRuntimeContext() && + Object.prototype.hasOwnProperty.call(runtimeEnv, prop) + ) { + return true; + } + return Reflect.has(target, prop); + }, + ownKeys(target) { + const own = new Set(Reflect.ownKeys(target)); + if (state.usesRuntimeContext()) { + for (const key of Object.keys(runtimeEnv)) own.add(key); + } + return Array.from(own); + }, + getOwnPropertyDescriptor(target, prop) { + if ( + typeof prop === "string" && + state.usesRuntimeContext() && + Object.prototype.hasOwnProperty.call(runtimeEnv, prop) + ) { + return { + configurable: true, + enumerable: true, + value: runtimeEnv[prop], + writable: true, + }; + } + return Object.getOwnPropertyDescriptor(target, prop); + }, + }); + process.env = envProxy; + process.cwd = function cwd() { + return state.usesRuntimeContext() ? normalizedRoot : hostCwd(); + }; + activeRuntimeEnvContext = state; + refreshRuntimeModulePaths(); + return state; +} + +function withRuntimeContext(fn) { + const state = activeRuntimeEnvContext; + if (!state) return fn(); + state.depth += 1; + try { + return fn(); + } finally { + state.depth -= 1; + } +} + +function installChildProcessEnvGuard(envState) { + const previousMethods = {}; + + function sanitizeOptions(options) { + if (!envState.usesRuntimeContext()) { + return options; + } + const base = options && typeof options === "object" ? options : {}; + return { + ...base, + env: sanitizeChildEnv(base.env || process.env, envState), + }; + } + + for (const method of CHILD_PROCESS_METHODS) { + if (typeof childProcess[method] !== "function") continue; + previousMethods[method] = childProcess[method]; + } + + childProcess.spawn = function spawn(command, args, options) { + if (Array.isArray(args)) { + return previousMethods.spawn.call(this, command, args, sanitizeOptions(options)); + } + return previousMethods.spawn.call(this, command, sanitizeOptions(args)); + }; + childProcess.exec = function exec(command, options, callback) { + if (typeof options === "function") { + return previousMethods.exec.call(this, command, sanitizeOptions(undefined), options); + } + return previousMethods.exec.call(this, command, sanitizeOptions(options), callback); + }; + childProcess.execFile = function execFile(file, args, options, callback) { + if (typeof args === "function") { + return previousMethods.execFile.call(this, file, sanitizeOptions(undefined), args); + } + if (Array.isArray(args)) { + if (typeof options === "function") { + return previousMethods.execFile.call(this, file, args, sanitizeOptions(undefined), options); + } + return previousMethods.execFile.call(this, file, args, sanitizeOptions(options), callback); + } + return previousMethods.execFile.call(this, file, [], sanitizeOptions(args), options); + }; + childProcess.fork = function fork(modulePath, args, options) { + if (Array.isArray(args)) { + return previousMethods.fork.call(this, modulePath, args, sanitizeOptions(options)); + } + return previousMethods.fork.call(this, modulePath, [], sanitizeOptions(args)); + }; + childProcess.spawnSync = function spawnSync(command, args, options) { + if (Array.isArray(args)) { + return previousMethods.spawnSync.call(this, command, args, sanitizeOptions(options)); + } + return previousMethods.spawnSync.call(this, command, sanitizeOptions(args)); + }; + childProcess.execFileSync = function execFileSync(file, args, options) { + if (Array.isArray(args)) { + return previousMethods.execFileSync.call(this, file, args, sanitizeOptions(options)); + } + return previousMethods.execFileSync.call(this, file, [], sanitizeOptions(args)); + }; + childProcess.execSync = function execSync(command, options) { + return previousMethods.execSync.call(this, command, sanitizeOptions(options)); + }; + syncChildProcessBuiltinExports(); + + return () => { + for (const [method, original] of Object.entries(previousMethods)) { + childProcess[method] = original; + } + syncChildProcessBuiltinExports(); + }; +} + +function syncChildProcessBuiltinExports() { + try { + if (typeof Module.syncBuiltinESMExports === "function") { + Module.syncBuiltinESMExports(); + } + } catch { + /* ignore */ + } +} + +function sanitizeChildEnv(env, envState) { + const next = { ...(env || {}) }; + for (const key of envState.keys) { + if (!Object.prototype.hasOwnProperty.call(next, key)) continue; + if (envState.previous.has(key)) { + const previousValue = envState.previous.get(key); + if (previousValue === undefined) delete next[key]; + else next[key] = previousValue; + } else { + delete next[key]; + } + } + return next; +} + +function clearRuntimeRequireCache(rootDir) { + const normalizedRoot = path.resolve(rootDir); + const prefix = normalizedRoot + path.sep; + for (const id of Object.keys(require.cache)) { + if (id === __filename) continue; + const resolved = path.resolve(id); + if (resolved === normalizedRoot || resolved.startsWith(prefix)) { + delete require.cache[id]; + } + } +} + +async function closeWebSocket() { + try { + const websocket = require("./websocket"); + if (typeof websocket.closeWebSocket === "function") websocket.closeWebSocket(); + } catch { + /* ignore */ + } +} + +async function closeHttpServer(server) { + if (!server) return; + await new Promise((resolve) => { + let settled = false; + const done = () => { + if (settled) return; + settled = true; + resolve(); + }; + const timer = setTimeout(done, 1000); + if (typeof timer.unref === "function") timer.unref(); + try { + server.close(() => { + clearTimeout(timer); + done(); + }); + } catch { + clearTimeout(timer); + done(); + } + try { + if (typeof server.closeAllConnections === "function") server.closeAllConnections(); + } catch { + /* ignore */ + } + try { + if (typeof server.closeIdleConnections === "function") server.closeIdleConnections(); + } catch { + /* ignore */ + } + try { + if (typeof server.__closedloopDestroyConnections === "function") { + server.__closedloopDestroyConnections(); + } + } catch { + /* ignore */ + } + }); +} + +function closeDatabase() { + try { + require("./db").db.close(); + } catch { + /* already closed */ + } +} + +function waitForPromise(promise, timeoutMs) { + if (!promise || typeof promise.then !== "function") return Promise.resolve(); + return new Promise((resolve) => { + const timer = setTimeout(resolve, timeoutMs); + if (typeof timer.unref === "function") timer.unref(); + promise.then( + () => { + clearTimeout(timer); + resolve(); + }, + () => { + clearTimeout(timer); + resolve(); + }, + ); + }); +} + +function once(fn) { + let promise = null; + return () => { + if (!promise) promise = Promise.resolve().then(fn); + return promise; + }; +} + +function parsePort(value) { + const n = Number.parseInt(String(value), 10); + if (!Number.isInteger(n) || n <= 0 || n > 65535) { + throw new Error("invalid Agent Monitor port: " + value); + } + return n; +} + +module.exports = { startClosedLoopAgentMonitorRuntime }; +`; +} + function materializeRuntimeTree() { rmSync(generatedRootDir, { recursive: true, force: true }); mkdirSync(generatedRootDir, { recursive: true }); @@ -640,10 +1475,12 @@ function materializeRuntimeTree() { patchHooksTranscriptOutsideTx(generatedHooksRoute); patchHooksWriteQueueAndWatchdog(generatedHooksRoute); patchHooksSandboxFilter(generatedHooksRoute); + patchHooksWatchdogStop(generatedHooksRoute); patchImportRoute(generatedImportRoute); patchPushFile(generatedPushLib); patchWebSocketFile(generatedWebSocketFile); patchCcDiscovery(generatedCcDiscovery); + writeFileSync(generatedRuntimeFile, renderClosedLoopRuntimeSource(), "utf8"); writeFileSync(generatedUninstallHooks, UNINSTALL_HOOKS_SOURCE, "utf8"); } @@ -713,6 +1550,28 @@ function patchServerIndex(file) { ); } + if (!source.includes('server.once("error", onError);')) { + const promiseNeedle = [ + " return new Promise((resolve) => {", + ' server.listen(port, "127.0.0.1", () => {', + ].join("\n"); + if (!source.includes(promiseNeedle)) { + throw new Error( + `Unable to patch ${file}: expected the startServer listen promise for error propagation.`, + ); + } + source = source.replace( + promiseNeedle, + [ + " return new Promise((resolve, reject) => {", + " const onError = (error) => reject(error);", + ' server.once("error", onError);', + ' server.listen(port, "127.0.0.1", () => {', + ' server.off("error", onError);', + ].join("\n"), + ); + } + if (!source.includes("__closedloopDestroyConnections")) { const serverNeedle = [ "function startServer(app, port) {", @@ -747,6 +1606,39 @@ function patchServerIndex(file) { ); } + if (!source.includes('server.off("error", onError);\n initWebSocket(server);')) { + const startNeedle = [ + "function startServer(app, port) {", + " const server = http.createServer(app);", + " initWebSocket(server);", + " const sockets = new Set();", + ].join("\n"); + if (!source.includes(startNeedle)) { + throw new Error( + `Unable to patch ${file}: expected startServer socket tracking block for WebSocket listen-error hardening.`, + ); + } + source = source.replace( + startNeedle, + [ + "function startServer(app, port) {", + " const server = http.createServer(app);", + " const sockets = new Set();", + ].join("\n"), + ); + + const listenSuccessNeedle = ' server.off("error", onError);'; + if (!source.includes(listenSuccessNeedle)) { + throw new Error( + `Unable to patch ${file}: expected startServer success callback for WebSocket initialization.`, + ); + } + source = source.replace( + listenSuccessNeedle, + [listenSuccessNeedle, " initWebSocket(server);"].join("\n"), + ); + } + if (!source.includes('process.env.CCAM_ENABLE_RUN === "1"')) { const runNeedle = ' app.use("/api/run", runRouter);'; if (!source.includes(runNeedle)) { @@ -916,7 +1808,7 @@ function patchServerIndex(file) { ].join("\n"); if (!source.includes(shutdownNeedle)) { throw new Error( - `Unable to patch ${file}: expected the shutdown cleanup block for sidecar ownership hardening.`, + `Unable to patch ${file}: expected the shutdown cleanup block for runtime ownership hardening.`, ); } source = source.replace( @@ -1089,7 +1981,7 @@ function patchServerIndex(file) { // // Deferred via setImmediate so the first-run scan runs after the // current synchronous startup tick completes — boot is not blocked - // by it. Note: this is NOT tied to a sidecar "ready" signal; it + // by it. Note: this is NOT tied to a runtime "ready" signal; it // simply defers to the next event-loop tick, which is enough to // keep the boot critical path clean even at thousands of files. // The mtime cache in pr_backfill_seen makes subsequent boots @@ -2215,6 +3107,45 @@ function patchHooksSandboxFilter(file) { writeFileSync(file, source, "utf8"); } +function patchHooksWatchdogStop(file) { + let source = readFileSync(file, "utf8"); + if (source.includes("router.stopWatchdog = stopWatchdog;")) return; + + const timerNeedle = [ + "const watchdogTimer = setInterval(watchdogCheck, WATCHDOG_INTERVAL_MS);", + "// Don't keep the process alive just for the watchdog", + "if (watchdogTimer.unref) watchdogTimer.unref();", + "", + "router.transcriptCache = transcriptCache;", + "router.watchdogCheck = watchdogCheck;", + "module.exports = router;", + ].join("\n"); + if (!source.includes(timerNeedle)) { + throw new Error( + `Unable to patch ${file}: expected watchdog timer export block for in-process cleanup.`, + ); + } + source = source.replace( + timerNeedle, + [ + "const watchdogTimer = setInterval(watchdogCheck, WATCHDOG_INTERVAL_MS);", + "// Don't keep the process alive just for the watchdog", + "if (watchdogTimer.unref) watchdogTimer.unref();", + "", + "function stopWatchdog() {", + " clearInterval(watchdogTimer);", + "}", + "", + "router.transcriptCache = transcriptCache;", + "router.watchdogCheck = watchdogCheck;", + "router.stopWatchdog = stopWatchdog;", + "module.exports = router;", + ].join("\n"), + ); + + writeFileSync(file, source, "utf8"); +} + // CLOSEDLOOP FEA-1334: expose cold-start ingest progress so the desktop // renderer can show a floating "catching up on agent history" card on every // launch. The ingest orchestrator writes into the ingest-progress singleton; @@ -3089,6 +4020,7 @@ function assertGeneratedTree() { path.join(generatedRootDir, "package.json"), path.join(generatedRootDir, "LICENSE"), generatedServerEntry, + generatedRuntimeFile, generatedSessionsRoute, generatedDbFile, generatedPushLib, @@ -3108,6 +4040,16 @@ function assertGeneratedTree() { if (!serverIndex.includes('server.listen(port, "127.0.0.1", () => {')) { throw new Error("Generated server/index.js is missing the loopback-only bind."); } + if (!serverIndex.includes('server.once("error", onError);')) { + throw new Error( + "Generated server/index.js is missing startServer listen error propagation.", + ); + } + if (!serverIndex.includes('server.off("error", onError);\n initWebSocket(server);')) { + throw new Error( + "Generated server/index.js must initialize WebSocket only after listen succeeds.", + ); + } if (!serverIndex.includes('process.env.CCAM_AUTO_INSTALL_HOOKS === "1"')) { throw new Error( "Generated server/index.js is missing the CCAM_AUTO_INSTALL_HOOKS guard.", @@ -3224,6 +4166,30 @@ function assertGeneratedTree() { } const hooksRouteSource = readFileSync(generatedHooksRoute, "utf8"); + if (!hooksRouteSource.includes("router.stopWatchdog = stopWatchdog;")) { + throw new Error( + "Generated server/routes/hooks.js is missing the watchdog cleanup export.", + ); + } + + const runtimeSource = readFileSync(generatedRuntimeFile, "utf8"); + for (const requiredRuntimeToken of [ + "startClosedLoopAgentMonitorRuntime", + "clearRuntimeRequireCache", + "installChildProcessEnvGuard", + "sanitizeChildEnv", + "startMaintenanceSweep", + "startColdStartIngest", + "closeWebSocket", + "stopWatchdog", + "Module._initPaths()", + ]) { + if (!runtimeSource.includes(requiredRuntimeToken)) { + throw new Error( + `Generated closedloop-runtime.js is missing ${requiredRuntimeToken}.`, + ); + } + } // CLOSEDLOOP plan-extraction hard-gates (FEA-1189): a future upstream bump // that breaks an anchor must fail the build, not silently drop plan capture. @@ -3798,6 +4764,7 @@ const stamp = currentStamp(); if ( !force && existsSync(generatedServerEntry) && + existsSync(generatedRuntimeFile) && existsSync(generatedClientIndex) && existsSync(generatedUninstallHooks) && existsSync(stampFile) && diff --git a/apps/desktop/src/main/agent-monitor-hooks.ts b/apps/desktop/src/main/agent-monitor-hooks.ts index 576a62e1..331434d0 100644 --- a/apps/desktop/src/main/agent-monitor-hooks.ts +++ b/apps/desktop/src/main/agent-monitor-hooks.ts @@ -12,6 +12,7 @@ import path from "node:path"; import Store from "electron-store"; +import { resolveAgentMonitorPort } from "../shared/contracts.js"; import { gatewayLog } from "./gateway-logger.js"; import { resolveAgentMonitorPaths } from "./agent-monitor-path.js"; @@ -102,10 +103,9 @@ function refreshHandlerCopy(): string { function makeHookCommand(handler: string, hookType: string): string { // Executed by Claude Code via the shell. Use the Electron binary as Node - // (ELECTRON_RUN_AS_NODE) so no system `node` is required. Port defaults to - // 4820 inside hook-handler.js, which matches our fixed sidecar port — so no - // per-hook env is needed (avoids depending on Claude Code honoring it). - return `ELECTRON_RUN_AS_NODE=1 "${process.execPath}" "${handler}" ${JSON.stringify(hookType)}`; + // (ELECTRON_RUN_AS_NODE) so no system `node` is required. The dashboard port + // is baked into the command so dev overrides stay aligned with the runtime. + return `CLAUDE_DASHBOARD_PORT=${resolveAgentMonitorPort()} ELECTRON_RUN_AS_NODE=1 "${process.execPath}" "${handler}" ${JSON.stringify(hookType)}`; } function makeHookEntry( diff --git a/apps/desktop/src/main/agent-monitor-path.ts b/apps/desktop/src/main/agent-monitor-path.ts index 1008b6ab..9e194318 100644 --- a/apps/desktop/src/main/agent-monitor-path.ts +++ b/apps/desktop/src/main/agent-monitor-path.ts @@ -11,8 +11,10 @@ const TAG = "agent-monitor-path"; export interface AgentMonitorPaths { // Directory containing server/, client/dist/, scripts/, package.json. rootDir: string; - // The Node CLI entry to spawn (the Express server). + // The generated Express server entry. entryFile: string; + // Electron in-process runtime wrapper generated next to server/index.js. + runtimeFile: string; // Directory holding install-hooks.js / hook-handler.js / uninstall-hooks.js. scriptsDir: string; } @@ -25,6 +27,7 @@ export function resolveAgentMonitorPaths(): AgentMonitorPaths { return { rootDir, entryFile: path.join(rootDir, "server", "index.js"), + runtimeFile: path.join(rootDir, "server", "closedloop-runtime.js"), scriptsDir: path.join(rootDir, "scripts"), }; } diff --git a/apps/desktop/src/main/agent-monitor-sidecar.ts b/apps/desktop/src/main/agent-monitor-sidecar.ts index cd3621cd..1c9ecae9 100644 --- a/apps/desktop/src/main/agent-monitor-sidecar.ts +++ b/apps/desktop/src/main/agent-monitor-sidecar.ts @@ -1,13 +1,15 @@ import { app } from "electron"; -import { spawn, execFile, type ChildProcess } from "node:child_process"; -import { randomUUID } from "node:crypto"; +import { execFile } from "node:child_process"; import { existsSync } from "node:fs"; import fs from "node:fs/promises"; import { createRequire } from "node:module"; import path from "node:path"; import { promisify } from "node:util"; -import { AGENT_MONITOR_PORT } from "../shared/contracts.js"; +import { + AGENT_MONITOR_PORT, + resolveAgentMonitorPort, +} from "../shared/contracts.js"; import { gatewayLog } from "./gateway-logger.js"; import { resolveAgentMonitorPaths } from "./agent-monitor-path.js"; @@ -15,46 +17,31 @@ const TAG = "agent-monitor"; const HOST = "127.0.0.1"; const HEALTH_TIMEOUT_MS = 2_000; const READY_POLL_INTERVAL_MS = 500; -// Cold start = Electron-as-Node spawn + Express init + ~20 SQLite -// migrations/index builds on first DB open + a possibly-large first-run -// legacy import competing for the event loop. Ready != import-complete; the -// iframe shows a loading state and populates progressively over the socket. const READY_TIMEOUT_MS = 60_000; -// EADDRINUSE crashes do not surface until the child has finished its SQLite -// init / migrations / Express boot and reached listen(). Live testing on a -// dev build observed up to ~2.5s between spawn and the EADDRINUSE error; -// production machines under load could be slower. Hold the readiness verdict -// long enough that an exit during init reliably arrives before we reset the -// restart counter, otherwise a foreign process on the same port answers -// /api/health while our child is still initializing and the supervisor loops -// forever at attempt 1/N. 5s leaves enough margin without making a real -// successful boot feel sluggish (cold start is already 60s budget). -const READY_STABILITY_WINDOW_MS = 5_000; -const MAX_RESTART_ATTEMPTS = 5; -const RESTART_BASE_DELAY_MS = 1_000; -const RESTART_MAX_DELAY_MS = 30_000; -// Upstream's SIGINT/SIGTERM handler closes the HTTP server + DB synchronously, -// then a non-unref'd maintenance setInterval keeps the loop alive until a 5s -// forced process.exit(0). DB integrity is already flushed by then, so a short -// grace + process-group SIGKILL keeps app shutdown within budget. -const STOP_GRACE_MS = 2_000; -// Bounds how long reclaimOrphan waits for a SIGKILLed orphan to actually exit -// (and release the fixed port) before launch() respawns. Kept short — same order -// as STOP_GRACE_MS — so a lingering pid can never stall the fire-and-forget boot; -// handleExit()'s exponential-backoff restart loop is the fallback if it times out. -const RECLAIM_WAIT_TIMEOUT_MS = 2_000; +const LEGACY_RECLAIM_WAIT_TIMEOUT_MS = 2_000; const requireFromHere = createRequire(import.meta.url); const execFileAsync = promisify(execFile); -// Runs the generated Claude-Code-Agent-Monitor runtime tree as a managed -// localhost child process. The Electron binary is reused as the Node runtime -// via ELECTRON_RUN_AS_NODE (a packaged app ships no standalone `node`). Unlike -// the gateway, the port is FIXED (see AGENT_MONITOR_PORT) because Claude Code -// hooks bake a port at install time. +interface AgentMonitorRuntimeHandle { + stop: () => Promise | void; +} + +interface AgentMonitorRuntimeModule { + startClosedLoopAgentMonitorRuntime: (options: { + rootDir: string; + port: number; + env: NodeJS.ProcessEnv; + signal?: AbortSignal; + }) => Promise; +} + +// Runs the generated Claude-Code-Agent-Monitor runtime tree inside Electron +// main. The localhost port remains fixed by default because Claude Code hooks +// bake it at install time, but CL_AGENT_MONITOR_PORT can temporarily move dev +// builds away from another running Electron session. export class AgentMonitorSidecar { - private child: ChildProcess | null = null; - private readonly port = AGENT_MONITOR_PORT; - private readonly sessionToken = randomUUID(); + private runtime: AgentMonitorRuntimeHandle | null = null; + private readonly port = resolveAgentMonitorPort(); private readonly dataDir = path.join( app.getPath("userData"), "agent-monitor", @@ -62,9 +49,9 @@ export class AgentMonitorSidecar { private started = false; private stopping = false; private ready = false; - private restartAttempts = 0; + private starting: Promise | null = null; + private startAbort: AbortController | null = null; private readyResolvers: Array<(ok: boolean) => void> = []; - private lastExitWasPortConflict = false; private onTerminalFailure?: (reason: string) => void; private sandboxBaseDirectory = ""; @@ -87,15 +74,32 @@ export class AgentMonitorSidecar { // Fire-and-forget safe: never rejects, never blocks app boot. async start(): Promise { if (this.started || this.stopping) { - return; + return this.starting ?? undefined; } this.started = true; - try { - await this.launch(); - } catch (error) { - this.started = false; - gatewayLog.error(TAG, `start failed: ${describe(error)}`); - } + const controller = new AbortController(); + this.startAbort = controller; + this.starting = this.launch(controller.signal) + .catch((error) => { + if (controller.signal.aborted || this.stopping || !this.started) { + this.runtime = null; + this.flushReady(false); + return; + } + this.started = false; + this.runtime = null; + this.flushReady(false); + const reason = this.describeStartupFailure(error); + gatewayLog.error(TAG, reason); + this.onTerminalFailure?.(reason); + }) + .finally(() => { + if (this.startAbort === controller) { + this.startAbort = null; + this.starting = null; + } + }); + return this.starting; } // Resolves true once the monitor answers health checks, false on timeout. @@ -120,26 +124,19 @@ export class AgentMonitorSidecar { async stop(): Promise { this.started = false; this.stopping = true; - const child = this.child; - this.child = null; - this.ready = false; - this.flushReady(false); - if (!child?.pid) { - this.restartAttempts = 0; - this.stopping = false; - return; - } - const { pid } = child; + this.startAbort?.abort(); + const starting = this.starting; try { - killGroup(pid, "SIGTERM"); - await delay(STOP_GRACE_MS); - if (isRunning(pid)) { - killGroup(pid, "SIGKILL"); + const runtime = this.runtime; + this.runtime = null; + this.flushReady(false); + if (runtime) { + await runtime.stop(); + gatewayLog.info(TAG, "agent monitor stopped"); + } else if (starting) { + await starting.catch(() => {}); } - gatewayLog.info(TAG, "agent monitor stopped"); } finally { - await this.deletePidFile(); - this.restartAttempts = 0; this.stopping = false; } } @@ -153,28 +150,111 @@ export class AgentMonitorSidecar { } } - private async deletePidFile(): Promise { + private async launch(signal: AbortSignal): Promise { + if (signal.aborted || !this.started || this.stopping) { + return; + } + + const { rootDir, runtimeFile, entryFile } = resolveAgentMonitorPaths(); + if (!existsSync(runtimeFile)) { + throw new Error( + `agent monitor runtime not found at ${runtimeFile} - run \`pnpm -C apps/desktop build:agent-monitor\``, + ); + } + + await fs.mkdir(this.dataDir, { recursive: true }); + await this.reclaimLegacySidecarOrphan(entryFile); + if (signal.aborted || !this.started || this.stopping) { + return; + } + const runtimeModule = requireFromHere(runtimeFile) as AgentMonitorRuntimeModule; + if ( + !runtimeModule || + typeof runtimeModule.startClosedLoopAgentMonitorRuntime !== "function" + ) { + throw new Error( + `agent monitor runtime at ${runtimeFile} does not export startClosedLoopAgentMonitorRuntime`, + ); + } + + gatewayLog.info(TAG, `starting in-process agent monitor port=${this.port}`); + const runtime = await runtimeModule.startClosedLoopAgentMonitorRuntime({ + rootDir, + port: this.port, + env: this.buildRuntimeEnv(rootDir), + signal, + }); + if (signal.aborted || !this.started || this.stopping) { + await runtime.stop(); + return; + } + this.runtime = runtime; + + if (await this.waitForHealth(signal)) { + gatewayLog.info(TAG, `agent monitor ready at http://${HOST}:${this.port}`); + this.flushReady(true); + return; + } + + const currentRuntime = this.runtime; + this.runtime = null; + await currentRuntime?.stop(); + if (signal.aborted || !this.started || this.stopping) { + return; + } + throw new Error(`agent monitor did not become healthy on port ${this.port}`); + } + + private buildRuntimeEnv(rootDir: string): NodeJS.ProcessEnv { + const dbPath = path.join(this.dataDir, "dashboard.db"); + const pushKeysPath = path.join(this.dataDir, "data", "vapid-keys.json"); + const runtimeNodePath = buildRuntimeNodePath(); + return { + CCAM_RUNTIME_ROOT: rootDir, + // The embedded dashboard always serves the generated client/dist tree. + // Even in desktop-dev we are not running the upstream Vite dev server. + NODE_ENV: "production", + ...(runtimeNodePath ? { NODE_PATH: runtimeNodePath } : {}), + DASHBOARD_PORT: String(this.port), + CLAUDE_DASHBOARD_PORT: String(this.port), + DASHBOARD_DB_PATH: dbPath, + CCAM_VAPID_KEYS_PATH: pushKeysPath, + CCAM_ENABLE_RUN: "0", + // Hooks are host-managed via explicit opt-in (agent-monitor-hooks.ts). + // Never let the generated server silently auto-install them. + CCAM_AUTO_INSTALL_HOOKS: "0", + ...(this.sandboxBaseDirectory + ? { SANDBOX_BASE_DIRECTORY: this.sandboxBaseDirectory } + : {}), + }; + } + + private async deleteLegacyPidFile(): Promise { try { await fs.unlink(path.join(this.dataDir, "sidecar.pid")); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") { - gatewayLog.warn(TAG, `failed to delete PID file: ${describe(error)}`); + gatewayLog.warn(TAG, `failed to delete legacy PID file: ${describe(error)}`); } } } - private async reclaimOrphan(): Promise { + private async reclaimLegacySidecarOrphan(entryFile: string): Promise { + if (this.port !== AGENT_MONITOR_PORT) { + return; + } + const pidFile = path.join(this.dataDir, "sidecar.pid"); let raw: string; try { raw = await fs.readFile(pidFile, "utf-8"); } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - return; + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + gatewayLog.warn(TAG, `failed to read legacy PID file: ${describe(error)}`); } - gatewayLog.warn(TAG, `failed to read PID file: ${describe(error)}`); return; } + let pid: number; let sessionToken: string | undefined; let recordedStartTime: string | null; @@ -188,259 +268,54 @@ export class AgentMonitorSidecar { sessionToken = parsed.sessionToken; recordedStartTime = parsed.startTime ?? null; } catch (error) { - gatewayLog.warn(TAG, `failed to parse PID file: ${describe(error)}`); - await this.deletePidFile(); + gatewayLog.warn(TAG, `failed to parse legacy PID file: ${describe(error)}`); + await this.deleteLegacyPidFile(); return; } + if (!Number.isInteger(pid) || pid <= 0) { - gatewayLog.warn( - TAG, - `PID file contains invalid pid=${pid} — deleting without kill`, - ); - await this.deletePidFile(); + gatewayLog.warn(TAG, `legacy PID file contains invalid pid=${pid}`); + await this.deleteLegacyPidFile(); return; } if (!sessionToken) { - gatewayLog.warn( - TAG, - `PID file missing sessionToken — potential foreign process on pid=${pid}, skipping kill`, - ); - await this.deletePidFile(); + gatewayLog.warn(TAG, `legacy PID file missing sessionToken for pid=${pid}`); + await this.deleteLegacyPidFile(); return; } + if (isRunning(pid)) { - // sessionToken presence only proves THIS app authored the PID file — it - // cannot prove the pid still belongs to our sidecar (the token is written - // and read by the same record, so it has no independent witness). PIDs are - // recycled, and our port is fixed, so a stale pid may now belong to an - // unrelated process. Before SIGKILL we verify ownership against the live - // process itself: its command line must still be running our sidecar - // entry, and its OS start-time must match what we recorded at spawn. Both - // are independent of the PID file, so a recycled/foreign pid fails the - // check and is never killed. - const { entryFile } = resolveAgentMonitorPaths(); const [command, liveStartTime] = await Promise.all([ getProcessCommand(pid), getProcessStartTime(pid), ]); const runsOurEntry = command !== null && command.includes(entryFile); - // If we could not record a start-time at spawn (ps unavailable), fall back - // to the command-line identity alone rather than refusing to ever reclaim. const startTimeMatches = recordedStartTime === null || (liveStartTime !== null && liveStartTime === recordedStartTime); + if (runsOurEntry && startTimeMatches) { - gatewayLog.info(TAG, `reclaiming orphan sidecar pid=${pid}`); + gatewayLog.info(TAG, `reclaiming legacy agent monitor process pid=${pid}`); killGroup(pid, "SIGKILL"); - // SIGKILL delivery is not synchronous with the OS releasing the orphan's - // listening socket on our fixed port. Wait (bounded) for the pid to - // actually exit so the imminent respawn in launch() binds on the first - // attempt instead of racing a not-yet-released port and hitting - // EADDRINUSE. The deadline guarantees this never stalls the - // fire-and-forget boot; if the pid lingers past it, handleExit()'s - // exponential-backoff restart loop recovers on a later attempt. - const deadline = Date.now() + RECLAIM_WAIT_TIMEOUT_MS; + const deadline = Date.now() + LEGACY_RECLAIM_WAIT_TIMEOUT_MS; while (isRunning(pid) && Date.now() < deadline) { await delay(READY_POLL_INTERVAL_MS); } } else { gatewayLog.warn( TAG, - `pid=${pid} does not match our sidecar identity (recycled or foreign process) — skipping kill`, + `legacy pid=${pid} does not match the agent monitor entry; skipping kill`, ); } } - await this.deletePidFile(); - } - private async writePidFile(pid: number): Promise { - const pidFile = path.join(this.dataDir, "sidecar.pid"); - const tmpFile = `${pidFile}.tmp`; - const payload = JSON.stringify({ - pid, - sessionToken: this.sessionToken, - startTime: await getProcessStartTime(pid), - recordedAt: new Date().toISOString(), - }); - try { - await fs.mkdir(this.dataDir, { recursive: true }); - await fs.writeFile(tmpFile, payload, "utf-8"); - await fs.rename(tmpFile, pidFile); - } catch (error) { - gatewayLog.warn(TAG, `failed to write PID file: ${describe(error)}`); - } + await this.deleteLegacyPidFile(); } - private async launch(): Promise { - if (!this.started || this.stopping) { - return; - } - - this.lastExitWasPortConflict = false; - await this.reclaimOrphan(); - - const { rootDir, entryFile } = resolveAgentMonitorPaths(); - if (!existsSync(entryFile)) { - gatewayLog.error( - TAG, - `agent monitor entry not found at ${entryFile} — run \`pnpm -C apps/desktop build:agent-monitor\``, - ); - this.started = false; - this.flushReady(false); - return; - } - - const dbPath = path.join(this.dataDir, "dashboard.db"); - const pushKeysPath = path.join(this.dataDir, "data", "vapid-keys.json"); - const runtimeNodePath = buildRuntimeNodePath(); - - const child = spawn(process.execPath, [entryFile], { - cwd: rootDir, - env: { - ...process.env, - ELECTRON_RUN_AS_NODE: "1", - // The embedded sidecar always serves the generated client/dist tree. - // Even in desktop-dev we are not running the upstream Vite dev server. - NODE_ENV: "production", - ...(runtimeNodePath ? { NODE_PATH: runtimeNodePath } : {}), - DASHBOARD_PORT: String(this.port), - DASHBOARD_DB_PATH: dbPath, - CCAM_VAPID_KEYS_PATH: pushKeysPath, - CCAM_ENABLE_RUN: "0", - // Hooks are host-managed via explicit opt-in (agent-monitor-hooks.ts). - // Never let the generated server silently auto-install them. - CCAM_AUTO_INSTALL_HOOKS: "0", - ...(this.sandboxBaseDirectory - ? { SANDBOX_BASE_DIRECTORY: this.sandboxBaseDirectory } - : {}), - }, - detached: true, - stdio: ["ignore", "pipe", "pipe"], - }); - this.child = child; - - if (!child.pid) { - this.started = false; - gatewayLog.error(TAG, "failed to spawn agent monitor process"); - this.flushReady(false); - return; - } - gatewayLog.info( - TAG, - `starting agent monitor pid=${child.pid} port=${this.port}`, - ); - await this.writePidFile(child.pid); - - pipeLines(child.stdout, (line) => gatewayLog.debug(TAG, line)); - pipeLines(child.stderr, (line) => { - if (line.includes("EADDRINUSE")) { - this.lastExitWasPortConflict = true; - } - gatewayLog.warn(TAG, line); - }); - child.on("error", (error) => - gatewayLog.error(TAG, `process error: ${describe(error)}`), - ); - child.on("exit", (code, signal) => this.handleExit(code, signal)); - - const healthy = await this.waitForHealth(child); - // Don't trust a bare /api/health 200 — a foreign process on the same port - // (orphaned dev sidecar, prior app instance, deliberate standalone build) - // will answer while OUR child is mid-EADDRINUSE crash. Re-verify our child - // is still the active one and still alive, then hold for a short stability - // window and re-verify once more. Only then is readiness real and the - // restart counter is safe to clear. - if (healthy && this.isChildAliveAndCurrent(child)) { - await delay(READY_STABILITY_WINDOW_MS); - if (this.isChildAliveAndCurrent(child)) { - this.restartAttempts = 0; - gatewayLog.info( - TAG, - `agent monitor ready at http://${HOST}:${this.port}`, - ); - this.flushReady(true); - return; - } - } - // A newer launch() call has already replaced this.child — the current - // launch has been superseded, so suppress the stale warn and skip - // flushReady(false) to avoid overwriting the newer launch's outcome. - if (this.child !== child) { - return; - } - gatewayLog.warn( - TAG, - `agent monitor did not become healthy on port ${this.port}`, - ); - this.flushReady(false); - } - - // Single source of truth for "the child we just spawned is still our - // active reference AND still running". Keeping this in one place means a - // future change cannot quietly skip half the guard at one of the three - // call sites (waitForHealth poll, post-health gate, post-stability gate) - // and reintroduce the false-positive-ready race fixed in FEA-1403. - private isChildAliveAndCurrent(child: ChildProcess): boolean { - return this.child === child && child.exitCode === null; - } - - private handleExit( - code: number | null, - signal: NodeJS.Signals | null, - ): void { - void this.deletePidFile(); - const shouldRestart = this.started && !this.stopping; - this.child = null; - this.ready = false; - if (!shouldRestart) { - this.restartAttempts = 0; - return; - } - gatewayLog.warn(TAG, `agent monitor exited code=${code} signal=${signal}`); - this.flushReady(false); - - // A fixed port can lose to another local process (EADDRINUSE). The backoff - // + hard cap degrades to "no monitor" — it never blocks boot, and Claude - // Code is unaffected (the hook handler fails silently in <=3s). - if (this.restartAttempts >= MAX_RESTART_ATTEMPTS) { - this.started = false; - gatewayLog.error( - TAG, - `giving up after ${this.restartAttempts} restart attempts`, - ); - const reason = this.lastExitWasPortConflict - ? `Agent monitor failed: port ${this.port} is in use by another process. Close the conflicting process and restart.` - : `Agent monitor failed after ${this.restartAttempts} restart attempts.`; - this.onTerminalFailure?.(reason); - return; - } - const attempt = ++this.restartAttempts; - const backoff = Math.min( - RESTART_BASE_DELAY_MS * 2 ** (attempt - 1), - RESTART_MAX_DELAY_MS, - ); - gatewayLog.info( - TAG, - `restarting agent monitor in ${backoff}ms (attempt ${attempt}/${MAX_RESTART_ATTEMPTS})`, - ); - setTimeout(() => { - if (!this.started || this.stopping) { - return; - } - this.launch().catch((error) => - gatewayLog.error(TAG, `restart failed: ${describe(error)}`), - ); - }, backoff); - } - - private async waitForHealth(child: ChildProcess): Promise { + private async waitForHealth(signal: AbortSignal): Promise { const deadline = Date.now() + READY_TIMEOUT_MS; while (Date.now() < deadline) { - // Identity-scoped: a 200 from /api/health is only meaningful if it's - // OUR child still serving it. If `this.child` has been replaced by a - // newer launch, or the spawned child has already exited, abandon the - // poll so the caller does not credit the success to this process. - if (this.stopping || !this.isChildAliveAndCurrent(child)) { + if (signal.aborted || this.stopping || !this.runtime) { return false; } if (await healthOk(this.port)) { @@ -450,6 +325,14 @@ export class AgentMonitorSidecar { } return false; } + + private describeStartupFailure(error: unknown): string { + const description = describe(error); + if (description.includes("EADDRINUSE")) { + return `Agent monitor failed: port ${this.port} is in use by another process. Close the conflicting process and retry.`; + } + return `Agent monitor failed: ${description}`; + } } function buildRuntimeNodePath(): string | undefined { @@ -498,37 +381,12 @@ async function healthOk(port: number): Promise { } } -function pipeLines( - stream: NodeJS.ReadableStream | null, - onLine: (line: string) => void, -): void { - if (!stream) { - return; - } - stream.setEncoding("utf-8"); - let buffer = ""; - stream.on("data", (chunk: string) => { - buffer += chunk; - const lines = buffer.split("\n"); - buffer = lines.pop() ?? ""; - for (const raw of lines) { - const line = raw.trim(); - if (line) { - onLine(line); - } - } - }); -} - function killGroup(pid: number, signal: NodeJS.Signals): void { - // Defense-in-depth: a non-positive pid would make process.kill(-pid, ...) - // signal the current process group (pid=0 → -0 → 0), killing the app itself. if (!Number.isInteger(pid) || pid <= 0) { gatewayLog.warn(TAG, `killGroup ignoring invalid pid=${pid}`); return; } try { - // Negative pid targets the detached process group. process.kill(-pid, signal); } catch (error) { const err = error as NodeJS.ErrnoException; @@ -547,21 +405,10 @@ function isRunning(pid: number): boolean { } } -// OS-observed process identity used to confirm a recorded pid still belongs to -// our sidecar before SIGKILL (see reclaimOrphan). `ps` is available on both -// macOS (the packaged target) and Linux; on macOS another process's env is not -// readable and there is no /proc, so the command line + start-time are the only -// portable, independent ownership signals. Both helpers return null on any -// failure so the caller fails safe (skip kill) rather than throwing. - -// Full argv of `pid` (-ww disables width truncation so a long entry path is -// never cut off). Used to check the live process is still running our sidecar. async function getProcessCommand(pid: number): Promise { return queryProcess(pid, "command="); } -// OS start-time of `pid`. Stable for the life of a process, so a recycled pid -// (now a different process) reports a different value than what we recorded. async function getProcessStartTime(pid: number): Promise { return queryProcess(pid, "lstart="); } diff --git a/apps/desktop/src/main/app.ts b/apps/desktop/src/main/app.ts index 98d31e4e..7bcea480 100644 --- a/apps/desktop/src/main/app.ts +++ b/apps/desktop/src/main/app.ts @@ -218,11 +218,11 @@ export class DesktopApplication { private dangerousAutoApprove = false; private cloudStatus: CloudSocketStatus = { state: "idle" }; private cloudCommandsPaused: boolean; - // In-memory supervisor verdict: set once the agent-monitor sidecar gives up - // permanently (after MAX_RESTART_ATTEMPTS). refreshTrayState() consults this so + // In-memory monitor verdict: set when the in-process Agent Monitor cannot + // start. refreshTrayState() consults this so // the degraded indicator sticks across later refreshes instead of being reset // to ready by the next cloud heartbeat or gateway recheck. Not persisted — a - // fresh boot re-attempts the sidecar, so the verdict is per-process. + // fresh boot re-attempts the monitor, so the verdict is per-process. private agentMonitorFailed = false; private agentMonitorFailureReason: string | null = null; private cloudConnectionEnabled: boolean; @@ -659,8 +659,8 @@ export class DesktopApplication { loopSleepRecovery.init(); // Independent of the gateway, but fully feature-gated. When enabled, start - // the sidecar fire-and-forget BEFORE the gateway try-block so a - // gateway-start failure never prevents it from running, and a sidecar + // the Agent Monitor runtime fire-and-forget BEFORE the gateway try-block so a + // gateway-start failure never prevents it from running, and a runtime // failure never blocks or fails app boot. The relay sync service follows // the same flag, so disabling Agent Monitor leaves only dormant wiring in // the desktop shell and no background sync loop. Hook repair remains @@ -1270,6 +1270,9 @@ export class DesktopApplication { this.tray.setAgentMonitorEnabled(enabled); if (enabled) { + this.agentMonitorFailed = false; + this.agentMonitorFailureReason = null; + this.refreshTrayState(); void this.agentMonitor.start(); syncAgentMonitorHooksOnBoot(); this.agentSessionSync.start(); @@ -2459,9 +2462,9 @@ export class DesktopApplication { enabled: this.isAgentMonitorEnabled(), planExtractionEnabled: this.isPlanExtractionEnabled(), })); - // FEA-1334: proxy the sidecar's cold-start ingest progress so the renderer + // FEA-1334: proxy the Agent Monitor cold-start ingest progress so the renderer // can drive the floating progress card without a cross-origin fetch. - // Returns null whenever the sidecar is not reachable — the renderer treats + // Returns null whenever the Agent Monitor runtime is not reachable — the renderer treats // that as "keep polling, nothing to show yet". ipcMain.handle("desktop:get-agent-monitor-ingest-progress", async () => { const baseUrl = this.agentMonitor.getUrl(); @@ -2480,8 +2483,8 @@ export class DesktopApplication { return null; } }); - // FEA-1334: clear the dashboard DB and restart the sidecar so it - // re-imports every agent session from scratch. The sidecar's empty-DB + // FEA-1334: clear the dashboard DB and restart the monitor so it + // re-imports every agent session from scratch. The runtime's empty-DB // boot path clears the persisted ingest caches and re-runs the // orchestrator, which the progress banner tracks. ipcMain.handle("desktop:reprocess-agent-logs", async () => { @@ -2505,6 +2508,9 @@ export class DesktopApplication { /* file may not exist — fine */ } } + this.agentMonitorFailed = false; + this.agentMonitorFailureReason = null; + this.refreshTrayState(); void this.agentMonitor.start(); return { ok: true }; } catch (error) { @@ -2680,6 +2686,9 @@ export class DesktopApplication { this.agentMonitor.setSandboxBaseDirectory(selectedSandbox); if (this.settingsStore.getAgentMonitorEnabled()) { await this.agentMonitor.stop(); + this.agentMonitorFailed = false; + this.agentMonitorFailureReason = null; + this.refreshTrayState(); void this.agentMonitor.start(); } } diff --git a/apps/desktop/src/main/preload.ts b/apps/desktop/src/main/preload.ts index 865c954a..260acbbb 100644 --- a/apps/desktop/src/main/preload.ts +++ b/apps/desktop/src/main/preload.ts @@ -142,7 +142,7 @@ const desktopApi = { ipcRenderer.on("desktop:flags-changed", callback); }, // FEA-1334: cold-start ingest progress for the floating progress card. - // Resolves null when the sidecar is unreachable or has no progress yet. + // Resolves null when the Agent Monitor runtime is unreachable or has no progress yet. getAgentMonitorIngestProgress: () => ipcRenderer.invoke( "desktop:get-agent-monitor-ingest-progress", @@ -159,7 +159,7 @@ const desktopApi = { { total: number; parsed: number; imported: number; complete: boolean } >; } | null>, - // FEA-1334: clear the dashboard DB and restart the sidecar so it re-imports + // FEA-1334: clear the dashboard DB and restart the Agent Monitor runtime so it re-imports // every agent session from scratch. The progress banner tracks the re-import. reprocessAgentLogs: () => ipcRenderer.invoke("desktop:reprocess-agent-logs") as Promise<{ diff --git a/apps/desktop/src/renderer/index.html b/apps/desktop/src/renderer/index.html index 664befc3..dd2cd322 100644 --- a/apps/desktop/src/renderer/index.html +++ b/apps/desktop/src/renderer/index.html @@ -2911,7 +2911,7 @@ justify-content: center; padding: 6px; } - /* Agent nav hidden when the Agent Dashboard sidecar is disabled */ + /* Agent nav hidden when the Agent Dashboard runtime is disabled */ .app.agent-disabled .sb-item[data-kind="agent"] { display: none; } @@ -3943,7 +3943,7 @@

Labs

const item = NAV_ITEMS.find((n) => n.id === id && n.type !== "group"); if (!item) return; if (item.kind === "agent" && !cachedAgentMonitorEnabled) { - // Agent views need the sidecar enabled — fall back to Settings. + // Agent views need the Agent Dashboard runtime enabled — fall back to Settings. activateNav("settings"); activateSettingsTab("relay-gateway"); return; @@ -4014,16 +4014,16 @@

Labs

} }, 1500); - // --- Claude Dashboard (generated Agent Monitor sidecar) -------------- - // Embed the already-running localhost sidecar in an iframe once it is - // healthy. When the feature is enabled, the sidecar starts at app boot + // --- Claude Dashboard (generated Agent Monitor runtime) -------------- + // Embed the already-running localhost runtime in an iframe once it is + // healthy. When the feature is enabled, the runtime starts at app boot // (fire-and-forget); here we just poll readiness and lazily set src once. let claudeDashTimer = null; let claudeDashLoaded = false; function syncAgentMonitorTabVisibility(enabled) { cachedAgentMonitorEnabled = enabled === true; - // Agent nav items are hidden in the sidebar when the sidecar is off. + // Agent nav items are hidden in the sidebar when the runtime is off. appShell.classList.toggle("agent-disabled", !cachedAgentMonitorEnabled); const frame = document.getElementById("claudeDashFrame"); @@ -4174,7 +4174,7 @@

Labs

} const frameUrl = new URL(r.url); agentMonitorFrameOrigin = frameUrl.origin; - // Embed mode hides the sidecar's own sidebar/chrome; the + // Embed mode hides the Agent Dashboard's own sidebar/chrome; the // initial route is baked in so the first paint lands right. frameUrl.pathname = pendingAgentRoute || "/"; frameUrl.searchParams.set("embed", "1"); @@ -4216,11 +4216,11 @@

Labs

} // ── Ingest progress banner (FEA-1334) ────────────────────────── - // Polls the agent-monitor sidecar's cold-start ingest progress and + // Polls the agent-monitor runtime's cold-start ingest progress and // drives the full-width top banner. Runs on every launch, independent // of which view is active. The banner stays up until the user // dismisses it; a fresh run (e.g. toggling Agent Dashboard in - // Settings, which restarts the sidecar) re-shows it. + // Settings, which restarts the runtime) re-shows it. (function setupIngestProgressBanner() { const banner = document.getElementById("ingestBanner"); if (!banner) return; @@ -4366,7 +4366,7 @@

Labs

return; } - // A fresh run (new startedAt) — e.g. the sidecar was restarted via + // A fresh run (new startedAt) — e.g. the runtime was restarted via // the Settings toggle — clears a prior dismissal and re-primes the // fill animation so the banner shows (and fills) again. if (runId !== currentRunId) { @@ -4391,7 +4391,7 @@

Labs

show(); renderDone(snap); } - // Keep polling slowly so a later sidecar restart re-shows the bar. + // Keep polling slowly so a later runtime restart re-shows the bar. schedule(POLL_IDLE_MS); } @@ -4402,12 +4402,12 @@

Labs

schedule(POLL_IDLE_MS); }); - // Kick off polling; the sidecar boots in parallel with the renderer. + // Kick off polling; the runtime boots in parallel with the renderer. schedule(POLL_WAIT_MS); })(); // ── Reprocess all logs button (FEA-1334) ─────────────────────── - // Clears the dashboard DB and restarts the sidecar, which triggers a + // Clears the dashboard DB and restarts the runtime, which triggers a // full re-import — the top progress banner tracks it. (function setupReprocessLogsButton() { const btn = document.getElementById("reprocessLogsBtn"); diff --git a/apps/desktop/src/shared/contracts.ts b/apps/desktop/src/shared/contracts.ts index cd22bfbe..b38f6831 100644 --- a/apps/desktop/src/shared/contracts.ts +++ b/apps/desktop/src/shared/contracts.ts @@ -4,7 +4,7 @@ export const PORT_PROBE_ORDER = [DEFAULT_GATEWAY_PORT, ...FALLBACK_GATEWAY_PORTS export const GATEWAY_PROTOCOL_VERSION = "0.1.0"; /** - * Fixed loopback port for the generated Agent Monitor sidecar. It MUST be fixed + * Fixed loopback port for the generated Agent Monitor runtime. It MUST be fixed * (not an ephemeral free port like the gateway) because Claude Code hooks bake * a port at install time and the hook handler POSTs to * `127.0.0.1:${CLAUDE_DASHBOARD_PORT || 4820}` — 4820 is upstream's own default, @@ -13,6 +13,20 @@ export const GATEWAY_PROTOCOL_VERSION = "0.1.0"; */ export const AGENT_MONITOR_PORT = 4820; +export function resolveAgentMonitorPort( + env: Partial> = process.env, +): number { + const raw = env.CL_AGENT_MONITOR_PORT; + if (!raw) { + return AGENT_MONITOR_PORT; + } + const port = Number.parseInt(raw, 10); + if (Number.isInteger(port) && port > 0 && port <= 65535) { + return port; + } + return AGENT_MONITOR_PORT; +} + export const COMMAND_SIGNING_REJECTION_REASONS = { noKeysAuthorized: "unauthorized: no keys authorized", unsignedCommand: "unauthorized: unsigned command", @@ -152,7 +166,7 @@ export interface DesktopSettings { dashboardWelcomeSeen: boolean; cloudCommandsPaused: boolean; cloudConnectionEnabled: boolean; - /** Enables the Claude Dashboard sidecar/tab. Off by default. */ + /** Enables the Claude Dashboard runtime/tab. Off by default. */ agentMonitorEnabled: boolean; /** Host-owned opt-in for Plans / plan extraction UI in the embedded Agent Dashboard. */ planExtractionEnabled: boolean; diff --git a/apps/desktop/src/shared/feature-flags.ts b/apps/desktop/src/shared/feature-flags.ts index 81118183..1d2d9041 100644 --- a/apps/desktop/src/shared/feature-flags.ts +++ b/apps/desktop/src/shared/feature-flags.ts @@ -25,7 +25,7 @@ const FEATURE_FLAGS_INTERNAL = [ default: true, label: "Agent Dashboard", description: - "Runs the local Agent Dashboard sidecar that powers the Dashboard and agent views in the sidebar.", + "Runs the in-process Agent Dashboard runtime that powers the Dashboard and agent views in the sidebar.", category: "Diagnostics" as const, requiresRestart: true, }, diff --git a/apps/desktop/test/agent-monitor-runtime-env.test.ts b/apps/desktop/test/agent-monitor-runtime-env.test.ts new file mode 100644 index 00000000..e87e496f --- /dev/null +++ b/apps/desktop/test/agent-monitor-runtime-env.test.ts @@ -0,0 +1,215 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { createServer } from "node:net"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { createRequire } from "node:module"; +import { test } from "node:test"; + +const requireFromHere = createRequire(import.meta.url); +const generatedRoot = path.resolve( + new URL("../.generated/agent-monitor", import.meta.url).pathname, +); +const runtimeFile = path.join(generatedRoot, "server", "closedloop-runtime.js"); + +test("in-process runtime sanitizes dashboard env for ESM child_process named imports", async (t) => { + if (!existsSync(runtimeFile)) { + t.skip("generated Agent Monitor runtime is not built"); + return; + } + + const runtime = requireFromHere(runtimeFile) as { + startClosedLoopAgentMonitorRuntime: (options: { + rootDir: string; + port: number; + env: NodeJS.ProcessEnv; + }) => Promise<{ stop: () => Promise | void }>; + }; + + const tmp = mkdtempSync(path.join(tmpdir(), "closedloop-agent-monitor-env-")); + const port = await getFreePort(); + const previousEnv = snapshotEnv([ + "DASHBOARD_DB_PATH", + "DASHBOARD_PORT", + "CCAM_ENABLE_RUN", + ]); + process.env.DASHBOARD_DB_PATH = "host-dashboard.db"; + process.env.DASHBOARD_PORT = "49999"; + process.env.CCAM_ENABLE_RUN = "host-run"; + const hostCwd = process.cwd(); + + let handle: { stop: () => Promise | void } | null = null; + try { + handle = await runtime.startClosedLoopAgentMonitorRuntime({ + rootDir: generatedRoot, + port, + env: { + NODE_ENV: "production", + DASHBOARD_DB_PATH: path.join(tmp, "dashboard.db"), + CCAM_VAPID_KEYS_PATH: path.join(tmp, "vapid-keys.json"), + CCAM_ENABLE_RUN: "0", + CCAM_AUTO_INSTALL_HOOKS: "0", + SANDBOX_BASE_DIRECTORY: tmp, + }, + }); + + assert.equal(process.env.DASHBOARD_DB_PATH, "host-dashboard.db"); + assert.equal(process.env.DASHBOARD_PORT, "49999"); + assert.equal(process.cwd(), hostCwd); + + const overviewResponse = await fetch( + `http://127.0.0.1:${port}/api/cc-config/overview`, + ); + assert.equal(overviewResponse.status, 200); + const overview = await overviewResponse.json() as { + roots: { projectRoot: string }; + }; + assert.equal(overview.roots.projectRoot, generatedRoot); + + const inheritedResult = spawnSync( + process.execPath, + [ + "-e", + [ + "process.stdout.write(JSON.stringify({", + "db: process.env.DASHBOARD_DB_PATH ?? null,", + "port: process.env.DASHBOARD_PORT ?? null,", + "run: process.env.CCAM_ENABLE_RUN ?? null", + "}));", + ].join(""), + ], + { + encoding: "utf8", + env: { ...process.env }, + }, + ); + assert.equal(inheritedResult.status, 0, inheritedResult.stderr); + assert.deepEqual(JSON.parse(inheritedResult.stdout), { + db: "host-dashboard.db", + port: "49999", + run: "host-run", + }); + + const explicitResult = spawnSync( + process.execPath, + [ + "-e", + [ + "process.stdout.write(JSON.stringify({", + "db: process.env.DASHBOARD_DB_PATH ?? null,", + "port: process.env.DASHBOARD_PORT ?? null,", + "nodeEnv: process.env.NODE_ENV ?? null", + "}));", + ].join(""), + ], + { + encoding: "utf8", + env: { + DASHBOARD_DB_PATH: "explicit-child-dashboard.db", + DASHBOARD_PORT: "12345", + NODE_ENV: "child-mode", + }, + }, + ); + assert.equal(explicitResult.status, 0, explicitResult.stderr); + assert.deepEqual(JSON.parse(explicitResult.stdout), { + db: "explicit-child-dashboard.db", + port: "12345", + nodeEnv: "child-mode", + }); + } finally { + if (handle) { + await handle.stop(); + } + restoreEnv(previousEnv); + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("in-process runtime rejects an already-aborted startup without installing global state", async (t) => { + if (!existsSync(runtimeFile)) { + t.skip("generated Agent Monitor runtime is not built"); + return; + } + + const runtime = requireFromHere(runtimeFile) as { + startClosedLoopAgentMonitorRuntime: (options: { + rootDir: string; + port: number; + env: NodeJS.ProcessEnv; + signal?: AbortSignal; + }) => Promise<{ stop: () => Promise | void }>; + }; + + const tmp = mkdtempSync(path.join(tmpdir(), "closedloop-agent-monitor-abort-")); + const port = await getFreePort(); + const hostCwd = process.cwd(); + const previousEnv = snapshotEnv(["DASHBOARD_DB_PATH", "DASHBOARD_PORT", "NODE_ENV"]); + const controller = new AbortController(); + controller.abort(); + + try { + await assert.rejects( + runtime.startClosedLoopAgentMonitorRuntime({ + rootDir: generatedRoot, + port, + env: { + NODE_ENV: "production", + DASHBOARD_DB_PATH: path.join(tmp, "dashboard.db"), + DASHBOARD_PORT: String(port), + }, + signal: controller.signal, + }), + /aborted|Abort/i, + ); + assert.equal(process.cwd(), hostCwd); + for (const [key, value] of previousEnv) { + assert.equal(process.env[key], value); + } + } finally { + restoreEnv(previousEnv); + rmSync(tmp, { recursive: true, force: true }); + } +}); + +function snapshotEnv(keys: string[]): Map { + const values = new Map(); + for (const key of keys) { + values.set( + key, + Object.prototype.hasOwnProperty.call(process.env, key) + ? process.env[key] + : undefined, + ); + } + return values; +} + +function restoreEnv(values: Map): void { + for (const [key, value] of values) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } +} + +function getFreePort(): Promise { + return new Promise((resolve, reject) => { + const server = createServer(); + server.unref(); + server.on("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + server.close(() => { + if (address && typeof address === "object") { + resolve(address.port); + } else { + reject(new Error("failed to allocate a free local port")); + } + }); + }); + }); +} diff --git a/apps/desktop/test/agent-monitor-sidecar.test.ts b/apps/desktop/test/agent-monitor-sidecar.test.ts index eeba19e4..0524d728 100644 --- a/apps/desktop/test/agent-monitor-sidecar.test.ts +++ b/apps/desktop/test/agent-monitor-sidecar.test.ts @@ -1,592 +1,192 @@ -/** - * Tests for agent-monitor-sidecar.ts PID persistence, orphan reclamation, - * foreign process safety, and stale log suppression. - * - * AC-011: foreign process holds port 4820 — counter advances 1-5, no PID - * killed, no false-positive ready log, terminal "giving up" log fires. - * AC-012: orphan recovery — spawn, persist PID, force-kill, restart, orphan - * SIGKILLed, new spawn binds port 4820 successfully and reaches ready. - * AC-013: stale log suppression — prev-launch resolves after new-launch race; - * misleading "did not become healthy" log does not fire. - * - * Because agent-monitor-sidecar.ts imports `app` from "electron" directly, - * the class cannot be imported under the Node.js test runner (tsx --test). - * These tests follow the same structural-verification approach used in - * agent-monitor-wiring-static.test.ts: they read the source as text and assert - * the implementation invariants that make each AC hold at runtime. - * - */ - import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { describe, test } from "node:test"; -// --------------------------------------------------------------------------- -// Source text fixture (read once at module evaluation time) -// --------------------------------------------------------------------------- - const sidecarSource = readFileSync( new URL("../src/main/agent-monitor-sidecar.ts", import.meta.url), "utf-8", ); +const pathSource = readFileSync( + new URL("../src/main/agent-monitor-path.ts", import.meta.url), + "utf-8", +); +const contractsSource = readFileSync( + new URL("../src/shared/contracts.ts", import.meta.url), + "utf-8", +); +const buildScriptSource = readFileSync( + new URL("../scripts/build-agent-monitor.mjs", import.meta.url), + "utf-8", +); +const appSource = readFileSync( + new URL("../src/main/app.ts", import.meta.url), + "utf-8", +); -// --------------------------------------------------------------------------- -// Pre-computed method body slices (avoids repeating indexOf + slice in each test) -// --------------------------------------------------------------------------- - -/** - * Extract a method body from the sidecar source by its signature prefix. - * Returns the slice starting at the method signature up to `windowChars` chars. - * Throws if the signature is not found (fail-fast for stale tests). - */ function methodBody(signature: string, windowChars: number): string { const idx = sidecarSource.indexOf(signature); assert.ok(idx >= 0, `${signature} not found in sidecar source`); return sidecarSource.slice(idx, idx + windowChars); } -// Windows are sized to comfortably contain the full method body so a -// boundary-straddling assertion target (e.g. a string near the method's end) -// is never silently truncated out of the slice. Pad generously; the cost is a -// few extra chars of unrelated source, the failure mode of being too small is a -// misleading "not found" that blames production code for a test-window bug. -const reclaimOrphanBody = methodBody("private async reclaimOrphan()", 4000); -const handleExitBody = methodBody("private handleExit(", 2000); -const launchBody = methodBody("private async launch()", 4000); - -// --------------------------------------------------------------------------- -// Static verification tests (AC-006 through AC-010 source-level invariants) -// --------------------------------------------------------------------------- - -describe("agent-monitor-sidecar.ts source-level invariants", () => { - // ------------------------------------------------------------------------- - // AC-006: PID file lifecycle — write after spawn, delete on stop() - // ------------------------------------------------------------------------- - - test("AC-006a: writePidFile uses atomic rename (write .tmp then rename)", () => { - assert.match( - sidecarSource, - /await fs\.writeFile\(tmpFile, payload, "utf-8"\);\s*await fs\.rename\(tmpFile, pidFile\)/, - ); - }); - - test("AC-006b: writePidFile persists { pid, sessionToken, startTime, recordedAt } JSON", () => { - // startTime (OS process start-time captured at spawn) is part of the - // ownership identity reclaimOrphan re-verifies against the live process. - assert.match( - sidecarSource, - /pid,\s*sessionToken: this\.sessionToken,\s*startTime: await getProcessStartTime\(pid\),\s*recordedAt:/, - ); - }); - - test("AC-006c: writePidFile ensures agent-monitor directory exists with mkdir recursive", () => { - assert.match( - sidecarSource, - /await fs\.mkdir\(this\.dataDir, \{ recursive: true \}\);[\s\S]{0,100}await fs\.writeFile\(tmpFile/, - ); - }); - - test("AC-006d: deletePidFile is called in stop() after killing the child", () => { - // The finally block in stop() must contain deletePidFile() - assert.match( - sidecarSource, - /async stop\(\): Promise[\s\S]{0,600}await this\.deletePidFile\(\)/, - ); - }); - - test("AC-006e: deletePidFile suppresses ENOENT (file absent on first run)", () => { - // The deletePidFile method body catches errors and only logs when code is - // NOT ENOENT — meaning ENOENT (file absent on first run) is silently swallowed. - assert.match( - sidecarSource, - /deletePidFile[\s\S]{0,400}code !== "ENOENT"/, - ); - }); +const launchBody = methodBody("private async launch(signal: AbortSignal)", 3200); +const stopBody = methodBody("async stop(): Promise", 1200); - test("AC-006f: writePidFile is called after spawn before health waits", () => { - // The PID file must be written as soon as a child pid exists, before - // waitForHealth() and the stability window, so a force-quit during startup - // leaves enough metadata for the next launch to reclaim the orphan. - const pidGuardPos = launchBody.indexOf("if (!child.pid)"); - const writePidPos = launchBody.indexOf("await this.writePidFile(child.pid)"); - const waitForHealthPos = launchBody.indexOf("const healthy = await this.waitForHealth(child)"); - assert.ok(pidGuardPos >= 0, "child.pid guard not found in launch()"); - assert.ok(writePidPos >= 0, "writePidFile(child.pid) not found in launch()"); - assert.ok(waitForHealthPos >= 0, "waitForHealth(child) not found in launch()"); - assert.ok( - pidGuardPos < writePidPos && writePidPos < waitForHealthPos, - "writePidFile(child.pid) must run after the pid guard and before waitForHealth(child)", - ); +describe("agent-monitor in-process runtime wiring", () => { + test("does not spawn or supervise an Electron-as-Node child process", () => { + assert.doesNotMatch(sidecarSource, /spawn\(process\.execPath/); + assert.doesNotMatch(sidecarSource, /ELECTRON_RUN_AS_NODE/); + assert.doesNotMatch(sidecarSource, /ChildProcess/); + assert.doesNotMatch(sidecarSource, /restartAttempts/); }); - // ------------------------------------------------------------------------- - // AC-007: Pre-bind orphan reclamation - // ------------------------------------------------------------------------- - - test("AC-007a: reclaimOrphan is called before spawn in launch()", () => { - const reclaimPos = launchBody.indexOf("await this.reclaimOrphan()"); - const spawnPos = launchBody.indexOf("const child = spawn("); - assert.ok(reclaimPos >= 0, "reclaimOrphan() call not found in launch()"); - assert.ok(spawnPos >= 0, "spawn() call not found in launch()"); - assert.ok( - reclaimPos < spawnPos, - "reclaimOrphan() must be called before spawn()", - ); - }); - - test("AC-007b: reclaimOrphan SIGKILLs a running orphan before the final deletePidFile call", () => { - assert.match(reclaimOrphanBody, /isRunning\(pid\)/); - assert.match(reclaimOrphanBody, /killGroup\(pid, "SIGKILL"\)/); - // Verify the unconditional deletePidFile at the end of reclaimOrphan comes - // after the SIGKILL inside the isRunning guard. - const sigkillPos = reclaimOrphanBody.indexOf('killGroup(pid, "SIGKILL")'); - assert.ok(sigkillPos >= 0, 'killGroup(pid, "SIGKILL") not found in reclaimOrphan body'); - // The last deletePidFile() call in the body is the unconditional one that - // runs after the kill (all other deletePidFile calls are in early-return paths). - const lastDeletePos = reclaimOrphanBody.lastIndexOf("await this.deletePidFile()"); - assert.ok(lastDeletePos >= 0, "await this.deletePidFile() not found in reclaimOrphan body"); - assert.ok( - sigkillPos < lastDeletePos, - `Expected SIGKILL (pos ${sigkillPos}) to precede final deletePidFile (pos ${lastDeletePos})`, - ); - }); - - test("AC-007c: reclaimOrphan reads sidecar.pid from the dataDir directory", () => { - assert.match( - reclaimOrphanBody, - /path\.join\(this\.dataDir, "sidecar\.pid"\)/, - ); - }); - - // ------------------------------------------------------------------------- - // AC-008: Foreign process safety - // ------------------------------------------------------------------------- - - test("AC-008a: reclaimOrphan skips kill when PID file is absent (ENOENT returns early)", () => { - assert.match(reclaimOrphanBody, /code === "ENOENT"[\s\S]{0,60}return;/); - }); - - test("AC-008b: reclaimOrphan skips kill when sessionToken is missing", () => { - assert.match( - sidecarSource, - /!sessionToken[\s\S]{0,200}skipping kill[\s\S]{0,200}await this\.deletePidFile/, - ); - }); - - test("AC-008c: reclaimOrphan only kills via SIGKILL — no SIGTERM path", () => { - assert.match(reclaimOrphanBody, /SIGKILL/); - assert.doesNotMatch(reclaimOrphanBody, /SIGTERM/); - }); - - test("AC-008d: reclaimOrphan verifies live-process ownership (command + start-time) before SIGKILL", () => { - // A live pid is only SIGKILLed when BOTH independent, PID-file-independent - // signals confirm it is still our sidecar: its command line runs our entry - // file, and its OS start-time matches the value recorded at spawn. This is - // the guard that prevents killing a recycled/foreign process holding the - // fixed port — sessionToken presence alone is insufficient (it has no - // independent witness). - assert.match(reclaimOrphanBody, /const runsOurEntry =\s*command !== null && command\.includes\(entryFile\)/); - assert.match(reclaimOrphanBody, /liveStartTime !== null && liveStartTime === recordedStartTime/); - - // The ownership check must precede the SIGKILL — the kill is gated on it. - const ownershipPos = reclaimOrphanBody.indexOf("runsOurEntry && startTimeMatches"); - const killGroupPos = reclaimOrphanBody.indexOf('killGroup(pid, "SIGKILL")'); - assert.ok(ownershipPos >= 0, "ownership check (runsOurEntry && startTimeMatches) not found in reclaimOrphan"); - assert.ok(killGroupPos >= 0, 'killGroup(pid, "SIGKILL") not found in reclaimOrphan'); - assert.ok( - ownershipPos < killGroupPos, - `ownership check (pos ${ownershipPos}) must gate SIGKILL (pos ${killGroupPos})`, - ); - }); - - test("AC-008e: reclaimOrphan logs and skips kill when the live process is not our sidecar", () => { - // The else-branch of the ownership check must warn and fall through to the - // unconditional deletePidFile WITHOUT calling killGroup, so a recycled or - // foreign pid is never signalled. - assert.match( - reclaimOrphanBody, - /recycled or foreign process[\s\S]{0,80}skipping kill/, - ); - }); - - test("AC-008f: reclaimOrphan waits (bounded) for the SIGKILLed orphan to exit before returning", () => { - // SIGKILL is not synchronous with the orphan releasing the fixed port, so - // reclaimOrphan must poll isRunning(pid) on a bounded deadline after the kill - // before launch() respawns — otherwise the first respawn can race a - // not-yet-released socket and hit EADDRINUSE. Assert the exact bounded-wait - // invariant: a deadline built from the named timeout constant, gating a - // delay()-spaced isRunning(pid) poll loop, placed AFTER the SIGKILL. - assert.match( - reclaimOrphanBody, - /killGroup\(pid, "SIGKILL"\);[\s\S]{0,600}const deadline = Date\.now\(\) \+ RECLAIM_WAIT_TIMEOUT_MS;\s*while \(isRunning\(pid\) && Date\.now\(\) < deadline\) \{\s*await delay\(READY_POLL_INTERVAL_MS\);\s*\}/, - ); - // The timeout constant must be defined so the wait is genuinely bounded. - assert.match(sidecarSource, /const RECLAIM_WAIT_TIMEOUT_MS = [\d_]+;/); - }); - - // ------------------------------------------------------------------------- - // AC-009: Terminal failure callback - // ------------------------------------------------------------------------- - - test("AC-009a: onTerminalFailure callback is accepted in constructor options", () => { - assert.match( - sidecarSource, - /constructor\(options\?: \{ onTerminalFailure\?: \(reason: string\) => void \}\)/, - ); - }); - - test("AC-009b: onTerminalFailure is invoked when restartAttempts >= MAX_RESTART_ATTEMPTS", () => { - assert.match( - sidecarSource, - /this\.restartAttempts >= MAX_RESTART_ATTEMPTS[\s\S]{0,500}this\.onTerminalFailure\?\.\(reason\)/, - ); - }); - - test("AC-009c: EADDRINUSE stderr sets lastExitWasPortConflict flag", () => { - assert.match(sidecarSource, /EADDRINUSE[\s\S]{0,60}lastExitWasPortConflict = true/); - }); - - test("AC-009d: terminal failure message includes port-in-use detail when lastExitWasPortConflict", () => { - assert.match( - sidecarSource, - /lastExitWasPortConflict[\s\S]{0,300}port.*is in use by another process/, - ); - }); - - // ------------------------------------------------------------------------- - // AC-010: Stale log suppression - // ------------------------------------------------------------------------- - - test("AC-010: stale waitForHealth log is gated by this.child === child check", () => { - // The warn log must be inside a guard that checks whether the child is - // still the active one. The guard must appear BEFORE the warn log. - assert.match( - sidecarSource, - /this\.child !== child[\s\S]{0,200}return;[\s\S]{0,400}agent monitor did not become healthy/, - ); - }); -}); - -// --------------------------------------------------------------------------- -// T-3.2: Foreign-process guard scenario (AC-011) — source-level invariants -// -// These tests verify the behavioral invariants that make the foreign-process -// scenario correct at runtime by reading the source as text and asserting -// the presence and ordering of critical logic patterns. -// -// AC-011: foreign process holds port 4820 — counter advances 1-5, no PID -// killed, no false-positive ready log, terminal "giving up" log fires. -// --------------------------------------------------------------------------- - -describe("T-3.2: foreign-process guard scenario source-level invariants (AC-011)", () => { - // ------------------------------------------------------------------------- - // Invariant 1: restartAttempts increments up to MAX_RESTART_ATTEMPTS - // ------------------------------------------------------------------------- - - test("restart counter increments on each exit before reaching the cap", () => { - // handleExit() must increment restartAttempts (++this.restartAttempts) when - // the attempt count is below the cap. - assert.match( - sidecarSource, - /const attempt = \+\+this\.restartAttempts/, - ); - }); - - test("restart counter is bounded by MAX_RESTART_ATTEMPTS check before increment", () => { - // The guard `this.restartAttempts >= MAX_RESTART_ATTEMPTS` must appear in - // handleExit() before the increment, so the cap is enforced correctly. - assert.match(handleExitBody, /this\.restartAttempts >= MAX_RESTART_ATTEMPTS/); - const capCheckPos = handleExitBody.indexOf("this.restartAttempts >= MAX_RESTART_ATTEMPTS"); - const incrementPos = handleExitBody.indexOf("const attempt = ++this.restartAttempts"); - assert.ok(capCheckPos >= 0, "cap check not found in handleExit"); - assert.ok(incrementPos >= 0, "restart counter increment (const attempt = ++this.restartAttempts) not found in handleExit"); - assert.ok( - capCheckPos < incrementPos, - `cap check (pos ${capCheckPos}) must precede increment (pos ${incrementPos})`, - ); - }); - - test("restart attempt number is logged with MAX_RESTART_ATTEMPTS denominator", () => { - // The log line `attempt N/MAX_RESTART_ATTEMPTS` must appear so the user can - // see progress toward the cap (attempt 1/5 through 5/5). - assert.match( - sidecarSource, - /attempt \$\{attempt\}\/\$\{MAX_RESTART_ATTEMPTS\}/, - ); - }); - - // ------------------------------------------------------------------------- - // Invariant 2: "giving up" log fires when restartAttempts >= MAX_RESTART_ATTEMPTS - // ------------------------------------------------------------------------- - - test('"giving up" log message fires inside the MAX_RESTART_ATTEMPTS guard', () => { - // The "giving up" error log must be inside the restartAttempts >= cap guard - // so it fires exactly when the supervisor exhausts all attempts. - assert.match( - sidecarSource, - /this\.restartAttempts >= MAX_RESTART_ATTEMPTS[\s\S]{0,300}giving up after \$\{this\.restartAttempts\} restart attempts/, - ); - }); - - test('"giving up" log uses gatewayLog.error (not warn or info)', () => { - // Giving up is a fatal event — it must be logged at error level. - const giveUpIdx = sidecarSource.indexOf("giving up after"); - assert.ok(giveUpIdx >= 0, '"giving up after" string not found in source'); - // Look back up to 50 chars for the log method name. - const context = sidecarSource.slice(Math.max(0, giveUpIdx - 50), giveUpIdx); - assert.match(context, /gatewayLog\.error/); - }); - - // ------------------------------------------------------------------------- - // Invariant 3: No process.kill/killGroup when sessionToken is missing from PID file - // ------------------------------------------------------------------------- - - test("reclaimOrphan returns without calling killGroup when sessionToken is missing", () => { - // When the PID file exists but has no sessionToken, the code must log a - // warning and return early (via deletePidFile then return) WITHOUT calling - // killGroup. This is the foreign-process safety guard. - assert.match(reclaimOrphanBody, /!sessionToken/); - - // After the !sessionToken check there must be a return; before any killGroup. - const noTokenIdx = reclaimOrphanBody.indexOf("!sessionToken"); - const returnAfterNoToken = reclaimOrphanBody.indexOf("return;", noTokenIdx); - const killGroupIdx = reclaimOrphanBody.indexOf("killGroup("); - assert.ok(noTokenIdx >= 0, "!sessionToken guard not found"); - assert.ok(returnAfterNoToken >= 0, "return after !sessionToken not found"); - assert.ok(killGroupIdx >= 0, "killGroup call not found in reclaimOrphan"); - assert.ok( - returnAfterNoToken < killGroupIdx, - `return; after !sessionToken (pos ${returnAfterNoToken}) must precede killGroup (pos ${killGroupIdx}) so missing sessionToken exits before kill`, - ); - }); - - test("reclaimOrphan logs a warning when sessionToken is missing (not silently skipped)", () => { - // The foreign-process safety warning must be explicit so operators can - // diagnose why a port-holding process was not reclaimed. - assert.match( - sidecarSource, - /PID file missing sessionToken[\s\S]{0,100}skipping kill/, - ); - }); - - test("killGroup is only called inside the isRunning(pid) guard in reclaimOrphan", () => { - // SIGKILL must only be sent if the recorded PID is alive. This prevents - // killing a reused PID that belongs to a different process. - const isRunningPos = reclaimOrphanBody.indexOf("isRunning(pid)"); - const killGroupPos = reclaimOrphanBody.indexOf("killGroup("); - assert.ok(isRunningPos >= 0, "isRunning(pid) guard not found in reclaimOrphan"); - assert.ok(killGroupPos >= 0, "killGroup call not found in reclaimOrphan"); - assert.ok( - isRunningPos < killGroupPos, - `isRunning guard (pos ${isRunningPos}) must precede killGroup call (pos ${killGroupPos})`, - ); - }); - - // ------------------------------------------------------------------------- - // Invariant 4: onTerminalFailure callback is invoked when giving up - // ------------------------------------------------------------------------- - - test("onTerminalFailure callback is invoked inside the giving-up branch", () => { - // The callback must be called with an actionable reason string when the - // supervisor exhausts all restart attempts. - assert.match( - sidecarSource, - /this\.restartAttempts >= MAX_RESTART_ATTEMPTS[\s\S]{0,500}this\.onTerminalFailure\?\.\(reason\)/, - ); - }); - - test("onTerminalFailure receives reason string built from lastExitWasPortConflict", () => { - // The reason passed to the callback must differ based on whether the exit - // was caused by EADDRINUSE, providing an actionable message in both cases. - assert.match( - sidecarSource, - /lastExitWasPortConflict[\s\S]{0,100}port.*is in use by another process/, - ); - // Fallback reason for non-port-conflict terminal failures. - assert.match( - sidecarSource, - /Agent monitor failed after \$\{this\.restartAttempts\} restart attempts/, - ); - }); - - test("onTerminalFailure is called with the built reason, not a hardcoded string", () => { - // The `reason` variable must be constructed and then passed directly to - // the callback — not an inline string literal. - assert.match( - sidecarSource, - /const reason = [\s\S]{0,300}this\.onTerminalFailure\?\.\(reason\)/, - ); - }); - - // ------------------------------------------------------------------------- - // Invariant 5: "did not become healthy" log is present with the port number - // ------------------------------------------------------------------------- - - test('"did not become healthy" log includes the port number', () => { - // The warn log must include `this.port` so the operator knows which port - // failed, especially when running non-default configurations. - assert.match( - sidecarSource, - /agent monitor did not become healthy on port \$\{this\.port\}/, - ); - }); - - test('"did not become healthy" log uses gatewayLog.warn', () => { - // This is a recoverable failure (supervisor will retry), so warn is correct. - const didNotIdx = sidecarSource.indexOf("agent monitor did not become healthy on port"); - assert.ok(didNotIdx >= 0, '"did not become healthy" log not found'); - const context = sidecarSource.slice(Math.max(0, didNotIdx - 60), didNotIdx); - assert.match(context, /gatewayLog\.warn/); - }); - - test('"did not become healthy" log is only reached when this.child === child (stale-guard)', () => { - // The stale-guard check `this.child !== child` with an early return must - // precede the warn log so a superseded launch cannot emit this message. - // (Shared with AC-010 but validated here as part of the foreign-process - // behavioral invariant set.) - assert.match( - sidecarSource, - /this\.child !== child[\s\S]{0,200}return;[\s\S]{0,400}agent monitor did not become healthy/, - ); - }); -}); - -// --------------------------------------------------------------------------- -// T-3.4: Stale log suppression scenario (AC-013) — source-level invariants -// -// These tests verify the behavioral invariants that prevent a previous launch's -// stale waitForHealth resolution from emitting misleading "did not become -// healthy" logs after a new launch has already started. -// -// AC-013: stale log suppression — prev-launch resolves after new-launch race; -// misleading "did not become healthy" log does not fire for the stale -// context. -// -// The race condition: when launch() is called twice in rapid succession (e.g. -// because handleExit fires a restart while a prior waitForHealth is still -// polling), the first launch's waitForHealth eventually resolves false after -// the new child has already been set on this.child. Without the stale guard, -// the first launch would emit a misleading warn log and call flushReady(false), -// potentially overwriting the second launch's ready state. -// --------------------------------------------------------------------------- - -describe("T-3.4: stale log suppression scenario source-level invariants (AC-013)", () => { - // ------------------------------------------------------------------------- - // Invariant 1: this.child !== child early-return guard is present in launch() - // ------------------------------------------------------------------------- - - test("launch() contains the this.child !== child stale guard before the warn log", () => { - // The stale guard must be present so that when a second launch() has already - // replaced this.child, the first launch's continuation returns immediately - // without logging the misleading "did not become healthy" message. + test("resolves and loads the generated closedloop-runtime.js wrapper", () => { + assert.match(pathSource, /runtimeFile: string/); + assert.match(pathSource, /"server", "closedloop-runtime\.js"/); assert.match( launchBody, - /this\.child !== child/, - "launch() must contain the this.child !== child stale guard", - ); - }); - - // ------------------------------------------------------------------------- - // Invariant 2: the stale guard must result in an early return - // ------------------------------------------------------------------------- - - test("the this.child !== child guard has an early return that precedes the warn log", () => { - // The return statement must immediately follow the stale guard check so - // the warn log and flushReady(false) are completely skipped for stale launches. - assert.match( - sidecarSource, - /this\.child !== child[\s\S]{0,50}return;/, - "this.child !== child guard must be followed by a return statement", - ); + /const \{ rootDir, runtimeFile, entryFile \} = resolveAgentMonitorPaths\(\)/, + ); + assert.match(launchBody, /existsSync\(runtimeFile\)/); + assert.match(launchBody, /requireFromHere\(runtimeFile\) as AgentMonitorRuntimeModule/); + assert.match(launchBody, /startClosedLoopAgentMonitorRuntime/); + }); + + test("preserves the localhost URL and fixed-port default with a dev override", () => { + assert.match(contractsSource, /export const AGENT_MONITOR_PORT = 4820/); + assert.match(contractsSource, /resolveAgentMonitorPort/); + assert.match(contractsSource, /CL_AGENT_MONITOR_PORT/); + assert.match(sidecarSource, /private readonly port = resolveAgentMonitorPort\(\)/); + assert.match(sidecarSource, /return this\.ready \? `http:\/\/\$\{HOST\}:\$\{this\.port\}` : null/); + assert.doesNotMatch(sidecarSource, /pickPort|freePort|PORT_PROBE_ORDER/); + }); + + test("reclaims only verified legacy Electron-as-Node orphans on the default port", () => { + assert.match(sidecarSource, /reclaimLegacySidecarOrphan\(entryFile\)/); + assert.match(sidecarSource, /this\.port !== AGENT_MONITOR_PORT/); + assert.match(sidecarSource, /"sidecar\.pid"/); + assert.match(sidecarSource, /sessionToken/); + assert.match(sidecarSource, /getProcessCommand\(pid\)/); + assert.match(sidecarSource, /command\.includes\(entryFile\)/); + assert.match(sidecarSource, /getProcessStartTime\(pid\)/); + assert.match(sidecarSource, /killGroup\(pid, "SIGKILL"\)/); + assert.match(sidecarSource, /deleteLegacyPidFile/); + }); + + test("passes the existing runtime environment contract into the wrapper", () => { + const envBody = methodBody("private buildRuntimeEnv", 1900); + for (const token of [ + "CCAM_RUNTIME_ROOT", + "NODE_ENV", + "NODE_PATH", + "DASHBOARD_PORT", + "CLAUDE_DASHBOARD_PORT", + "DASHBOARD_DB_PATH", + "CCAM_VAPID_KEYS_PATH", + "CCAM_ENABLE_RUN", + "CCAM_AUTO_INSTALL_HOOKS", + "SANDBOX_BASE_DIRECTORY", + "resolveRuntimeSupportNodePaths(\"agent-dashboard\")", + ]) { + const source = token === "resolveRuntimeSupportNodePaths(\"agent-dashboard\")" + ? sidecarSource + : envBody; + assert.match(source, new RegExp(token.replace(/[()]/g, "\\$&"))); + } + }); + + test("waits for health before marking ready and flushes waiters on failure", () => { + assert.match(launchBody, /if \(await this\.waitForHealth\(signal\)\)/); + assert.match(launchBody, /this\.flushReady\(true\)/); + assert.match(sidecarSource, /this\.flushReady\(false\)/); + assert.match(sidecarSource, /fetch\(`http:\/\/\$\{HOST\}:\$\{port\}\/api\/health`/); + }); + + test("stop aborts and deterministically waits for in-flight startup cleanup", () => { + assert.match(sidecarSource, /private startAbort: AbortController \| null = null/); + assert.match(stopBody, /this\.startAbort\?\.abort\(\)/); + assert.match(stopBody, /const starting = this\.starting/); + assert.match(stopBody, /await starting\.catch\(\(\) => \{\}\)/); + assert.doesNotMatch(stopBody, /Promise\.race/); + assert.match(stopBody, /const runtime = this\.runtime/); + assert.match(stopBody, /await runtime\.stop\(\)/); + assert.match(stopBody, /this\.flushReady\(false\)/); + assert.match(launchBody, /signal\.aborted/); + assert.match(launchBody, /await runtime\.stop\(\)/); + }); + + test("startup failures reset started and surface terminal reasons", () => { + assert.match(sidecarSource, /this\.started = false/); + assert.match(sidecarSource, /this\.onTerminalFailure\?\.\(reason\)/); + assert.match(sidecarSource, /description\.includes\("EADDRINUSE"\)/); + assert.match(sidecarSource, /port \$\{this\.port\} is in use by another process/); + }); + + test("explicit retry paths clear the terminal-failure tray latch", () => { + assert.match(appSource, /this\.agentMonitorFailed = false/); + assert.match(appSource, /this\.agentMonitorFailureReason = null/); + assert.match(appSource, /desktop:reprocess-agent-logs/); }); +}); - // ------------------------------------------------------------------------- - // Invariant 3: the stale guard early return precedes the warn log in source - // ------------------------------------------------------------------------- - - test("the stale guard early return appears before the warn log in launch() body", () => { - // Position-based assertion: the early return in the stale guard must come - // before the warn log so the warn is unreachable for superseded launches. - const staleGuardPos = launchBody.indexOf("this.child !== child"); - const warnLogPos = launchBody.indexOf( - "agent monitor did not become healthy on port", - ); - assert.ok(staleGuardPos >= 0, "this.child !== child not found in launch()"); - assert.ok( - warnLogPos >= 0, - "\"agent monitor did not become healthy\" log not found in launch()", - ); - assert.ok( - staleGuardPos < warnLogPos, - `stale guard (pos ${staleGuardPos}) must precede the warn log (pos ${warnLogPos})`, - ); - }); - - // ------------------------------------------------------------------------- - // Invariant 4: flushReady(false) is skipped when the launch is stale - // ------------------------------------------------------------------------- - - test("flushReady(false) is only reachable after the stale guard in launch()", () => { - // When this.child !== child, the code returns before the flushReady(false) - // call, ensuring the newer launch's ready state is not overwritten. - // We verify this by asserting the stale guard return precedes flushReady(false). - const staleGuardPos = launchBody.indexOf("this.child !== child"); - // Find the flushReady(false) call that follows the warn log (there may be - // earlier flushReady(false) calls in the early-exit paths at the top of launch()). - const warnLogPos = launchBody.indexOf("agent monitor did not become healthy"); - const flushReadyAfterWarn = launchBody.indexOf("this.flushReady(false)", warnLogPos); - assert.ok(staleGuardPos >= 0, "stale guard not found in launch() body"); - assert.ok(warnLogPos >= 0, "warn log not found in launch() body"); - assert.ok( - flushReadyAfterWarn >= 0, - "flushReady(false) after warn log not found in launch() body", - ); - // The stale guard must come before flushReady(false), confirming that when the - // guard fires and returns early, flushReady(false) is bypassed. - assert.ok( - staleGuardPos < flushReadyAfterWarn, - `stale guard (pos ${staleGuardPos}) must precede flushReady(false) (pos ${flushReadyAfterWarn}) so the call is skipped for stale launches`, - ); - }); - - // ------------------------------------------------------------------------- - // Invariant 5: the guard compares local child against this.child (not this.child against this.child) - // ------------------------------------------------------------------------- - - test("the stale guard compares the local child variable against this.child", () => { - // The guard must reference the closure-captured local `child` variable from - // the spawn call — not a stale snapshot of `this.child`. This ensures that - // the comparison correctly detects when a newer launch has replaced this.child - // after the current launch captured its local reference. - - // The guard must be expressed as `this.child !== child` (this.child on the - // left, local child on the right) — not `child !== child` or any other form. - assert.match( - launchBody, - /if \(this\.child !== child\)/, - "stale guard must use the exact form `if (this.child !== child)`", - ); - - // The local `child` variable must be defined in launch() via the spawn() call. - assert.match( - launchBody, - /const child = spawn\(/, - "local `child` must be set via spawn() in launch()", - ); - }); - - // ------------------------------------------------------------------------- - // Invariant 6: the stale guard comment explains the race condition - // ------------------------------------------------------------------------- - - test("the stale guard has an explanatory comment about the superseded launch", () => { - // A comment documenting the race condition makes the invariant auditable - // and prevents future maintainers from inadvertently removing the guard. - // The comment must appear near the stale guard. - assert.match( - launchBody, - /superseded[\s\S]{0,200}this\.child !== child/, - "a comment mentioning \"superseded\" must appear before the stale guard in launch()", - ); +describe("generated runtime wrapper hardening", () => { + test("build script emits a runtime wrapper with lifecycle cleanup", () => { + assert.match(buildScriptSource, /closedloop-runtime\.js/); + assert.match(buildScriptSource, /renderClosedLoopRuntimeSource/); + assert.match(buildScriptSource, /startClosedLoopAgentMonitorRuntime/); + assert.match(buildScriptSource, /closeHttpServer/); + assert.match(buildScriptSource, /closeWebSocket/); + assert.match(buildScriptSource, /clearRuntimeRequireCache/); + assert.match(buildScriptSource, /Module\._initPaths\(\)/); + }); + + test("generated wrapper owns timers, watchers, and startup ingest that a child process used to own", () => { + for (const token of [ + "startMaintenanceSweep", + "startColdStartIngest", + "startWatchers", + "stopWatchdog", + "startUpdateScheduler", + "runClaudePlanBackfill", + "runClaudePrBackfill", + "runPackScanner", + "scheduleCatalogFetch", + "ingestAllHarnesses", + ]) { + assert.match(buildScriptSource, new RegExp(token)); + } + }); + + test("generated server listen errors reject the runtime startup promise", () => { + assert.match(buildScriptSource, /server\.once\("error", onError\)/); + assert.match(buildScriptSource, /server\.off\("error", onError\)/); + assert.match(buildScriptSource, /server\.off\("error", onError\);\\n initWebSocket\(server\);/); + assert.match(buildScriptSource, /return new Promise\(\(resolve, reject\)/); + }); + + test("runtime wrapper sanitizes generated dashboard env from child processes", () => { + assert.match(buildScriptSource, /let activeRuntimeStart = null/); + assert.match(buildScriptSource, /throwIfAborted\(signal\)/); + assert.match(buildScriptSource, /installRuntimeContext/); + assert.match(buildScriptSource, /withRuntimeContext/); + assert.match(buildScriptSource, /process\.env = envProxy/); + assert.match(buildScriptSource, /process\.cwd = function cwd/); + assert.match(buildScriptSource, /Module\._resolveFilename = function resolveFilename/); + assert.match(buildScriptSource, /installChildProcessEnvGuard/); + assert.match(buildScriptSource, /sanitizeChildEnv/); + assert.match(buildScriptSource, /if \(!envState\.usesRuntimeContext\(\)\)/); + assert.match(buildScriptSource, /syncChildProcessBuiltinExports/); + assert.match(buildScriptSource, /Module\.syncBuiltinESMExports/); + assert.match(buildScriptSource, /DASHBOARD_DB_PATH/); + assert.match(buildScriptSource, /CCAM_VAPID_KEYS_PATH/); + }); + + test("startup failure cleanup also stops top-level runtime side effects", () => { + assert.match(buildScriptSource, /function stopTopLevelRuntimeSideEffects/); + assert.match( + buildScriptSource, + /if \(!handles\) \{\n stopTopLevelRuntimeSideEffects\(\);\n return;\n \}/, + ); + assert.match(buildScriptSource, /stopWatchdog/); }); }); diff --git a/apps/desktop/test/agent-monitor-wiring-static.test.ts b/apps/desktop/test/agent-monitor-wiring-static.test.ts index 7d6a5824..d54102fd 100644 --- a/apps/desktop/test/agent-monitor-wiring-static.test.ts +++ b/apps/desktop/test/agent-monitor-wiring-static.test.ts @@ -238,18 +238,28 @@ test("electron-builder ships the generated agent-monitor runtime tree unpacked", ); }); -test("runtime resolves the generated tree and sidecar wiring still uses the fixed port", () => { +test("runtime resolves the generated tree and loads the in-process monitor wrapper", () => { assert.match(agentMonitorPathSource, /\.generated", "agent-monitor"/); assert.doesNotMatch(agentMonitorPathSource, /vendor\/agent-monitor/); assert.match(agentMonitorPathSource, /gatewayLog\.warn/); assert.match(contractsSource, /export const AGENT_MONITOR_PORT = 4820/); - assert.match(sidecarSource, /AGENT_MONITOR_PORT/); + assert.match(contractsSource, /resolveAgentMonitorPort/); + assert.match(contractsSource, /CL_AGENT_MONITOR_PORT/); + assert.match(agentMonitorPathSource, /runtimeFile/); + assert.match(agentMonitorPathSource, /"server", "closedloop-runtime\.js"/); + assert.match(sidecarSource, /resolveAgentMonitorPort\(\)/); // Fixed port: must NOT pick a free port like the gateway sidecar did. assert.doesNotMatch(sidecarSource, /pickPort|freePort/); - // Spawn the server entry with no CLI port/host flags (server reads env). - assert.match(sidecarSource, /spawn\(process\.execPath,\s*\[entryFile\]/); - assert.match(sidecarSource, /ELECTRON_RUN_AS_NODE:\s*"1"/); + assert.doesNotMatch(sidecarSource, /spawn\(process\.execPath/); + assert.doesNotMatch(sidecarSource, /ELECTRON_RUN_AS_NODE/); + assert.doesNotMatch(sidecarSource, /ChildProcess|restartAttempts/); + assert.match(sidecarSource, /requireFromHere\(runtimeFile\) as AgentMonitorRuntimeModule/); + assert.match(sidecarSource, /startClosedLoopAgentMonitorRuntime/); + assert.match(sidecarSource, /reclaimLegacySidecarOrphan\(entryFile\)/); + assert.match(sidecarSource, /this\.port !== AGENT_MONITOR_PORT/); + assert.match(sidecarSource, /command\.includes\(entryFile\)/); assert.match(sidecarSource, /DASHBOARD_PORT:\s*String\(this\.port\)/); + assert.match(sidecarSource, /CLAUDE_DASHBOARD_PORT:\s*String\(this\.port\)/); assert.match(sidecarSource, /DASHBOARD_DB_PATH/); assert.match(sidecarSource, /CCAM_VAPID_KEYS_PATH/); assert.match(sidecarSource, /CCAM_ENABLE_RUN:\s*"0"/); @@ -260,16 +270,34 @@ test("runtime resolves the generated tree and sidecar wiring still uses the fixe assert.match(sidecarSource, /resolveRuntimeSupportNodePaths\("agent-dashboard"\)/); assert.match(sidecarSource, /path\.dirname\(packageRoot\)/); assert.match(sidecarSource, /process\.resourcesPath,\s*"app\.asar",\s*"app",\s*"node_modules"/); - assert.match(sidecarSource, /const healthy = await this\.waitForHealth\(child\);/); + assert.match(sidecarSource, /if \(await this\.waitForHealth\(signal\)\)/); + assert.match(sidecarSource, /this\.startAbort\?\.abort\(\)/); + assert.match(sidecarSource, /await starting\.catch\(\(\) => \{\}\)/); + assert.doesNotMatch(sidecarSource, /Promise\.race\(\[starting\.catch/); assert.match(sidecarSource, /\/api\/health/); assert.doesNotMatch(sidecarSource, /spawnSync\(\s*"lsof"/); assert.doesNotMatch(sidecarSource, /spawnSync\(\s*"ps"/); assert.match( sidecarSource, - /async stop\(\): Promise \{[\s\S]*this\.started = false;[\s\S]*this\.stopping = true;[\s\S]*this\.restartAttempts = 0;[\s\S]*this\.stopping = false;/, + /async stop\(\): Promise \{[\s\S]*this\.started = false;[\s\S]*this\.stopping = true;[\s\S]*await runtime\.stop\(\)[\s\S]*this\.stopping = false;/, ); - assert.match(sidecarSource, /const shouldRestart = this\.started && !this\.stopping;/); + assert.match(buildScriptSource, /function renderClosedLoopRuntimeSource/); + assert.match(buildScriptSource, /startClosedLoopAgentMonitorRuntime/); + assert.match(buildScriptSource, /let activeRuntimeStart = null/); + assert.match(buildScriptSource, /throwIfAborted\(signal\)/); assert.match(buildScriptSource, /function patchWebSocketFile/); + assert.match(buildScriptSource, /router\.stopWatchdog = stopWatchdog/); + assert.match(buildScriptSource, /function stopTopLevelRuntimeSideEffects/); + assert.match(buildScriptSource, /function installRuntimeContext/); + assert.match(buildScriptSource, /function withRuntimeContext/); + assert.match(buildScriptSource, /process\.env = envProxy/); + assert.match(buildScriptSource, /process\.cwd = function cwd/); + assert.match(buildScriptSource, /Module\._resolveFilename = function resolveFilename/); + assert.match(buildScriptSource, /if \(!envState\.usesRuntimeContext\(\)\)/); + assert.match(buildScriptSource, /syncChildProcessBuiltinExports/); + assert.match(buildScriptSource, /Module\.syncBuiltinESMExports/); + assert.match(buildScriptSource, /server\.once\("error", onError\)/); + assert.match(buildScriptSource, /server\.off\("error", onError\);\\n initWebSocket\(server\);/); assert.match(buildScriptSource, /updateScheduler = startUpdateScheduler\(\{ broadcast \}\);/); assert.match(buildScriptSource, /catalogFetchTimer = require\("\.\/lib\/catalog-fetcher"\)\.scheduleCatalogFetch\(dbModule\.db\);/); assert.match(buildScriptSource, /require\("\.\/websocket"\)\.closeWebSocket\(\);/); @@ -277,72 +305,17 @@ test("runtime resolves the generated tree and sidecar wiring still uses the fixe assert.match(buildScriptSource, /httpServer\.__closedloopDestroyConnections\(\)/); }); -// FEA-1403: when port 4820 is held by a foreign process (orphaned dev sidecar, -// stale standalone build, etc.), /api/health answers 200 OK before OUR -// just-spawned child has even hit listen(). Readiness must be scoped to the -// child we spawned — not to "anyone on the port" — otherwise the supervisor's -// restartAttempts=0 reset fires every cycle and the documented 5-attempt cap -// is never reached. The supervisor loops forever at "attempt 1/5". -test("FEA-1403: agent monitor readiness is scoped to the spawned child, not to any process on the port", () => { - // The stability window must outlast the observed EADDRINUSE crash latency. - // Live testing on a dev build with port 4820 held by a foreign process - // showed the child reaching listen() (and crashing) up to ~2.5s after - // spawn — slower than the original ~300ms estimate, because SQLite init + - // migrations + Express boot run before listen(). Parse the constant - // numerically so a future change shortening it below the safety margin - // fails this test. - const stabilityMatch = sidecarSource.match( - /const READY_STABILITY_WINDOW_MS = ([\d_]+)/, - ); - assert.ok( - stabilityMatch, - "READY_STABILITY_WINDOW_MS constant must be defined in agent-monitor-sidecar.ts", - ); - const stabilityMs = Number(stabilityMatch[1].replaceAll("_", "")); - assert.ok( - stabilityMs >= 3_000, - `READY_STABILITY_WINDOW_MS must be >= 3000ms to outlast the observed ~2500ms EADDRINUSE crash window, got ${stabilityMs}ms`, - ); - - // waitForHealth takes the spawned child as a parameter so it can verify - // identity, not just the port answering. - assert.match( - sidecarSource, - /private async waitForHealth\(child: ChildProcess\): Promise/, - ); - - // Single source of truth for the identity-and-alive predicate. Three - // call sites share this guard (waitForHealth poll, post-health gate, - // post-stability gate); keeping them in one method means a future change - // cannot quietly drop half the check at one site. - assert.match( - sidecarSource, - /private isChildAliveAndCurrent\(child: ChildProcess\): boolean \{\s*return this\.child === child && child\.exitCode === null;\s*\}/, - ); - - // waitForHealth bails when our child is no longer the active one or has - // already exited — a 200 OK from a foreign process must NOT be credited. - assert.match( - sidecarSource, - /this\.stopping[\s\S]{0,100}!this\.isChildAliveAndCurrent\(child\)/, - ); - - // The "agent monitor ready" log + restartAttempts = 0 reset only fire - // after the stability window AND after re-verifying our child is still - // the active live one via the shared predicate. The reset is GUARDED — - // not unconditional. - assert.match( - sidecarSource, - /await delay\(READY_STABILITY_WINDOW_MS\);[\s\S]{0,400}this\.isChildAliveAndCurrent\(child\)[\s\S]{0,400}this\.restartAttempts = 0;/, - ); - - // Guard: there must NOT be an ungated `restartAttempts = 0` immediately - // following `await this.waitForHealth(...)` — that was the original bug. - // The post-waitForHealth success path must check child identity first. - assert.doesNotMatch( - sidecarSource, - /const healthy = await this\.waitForHealth\(child\);\s*if \(healthy\) \{\s*this\.restartAttempts = 0;/, - ); +test("PRD-407: in-process runtime surfaces port conflicts without restart loops", () => { + assert.match(buildScriptSource, /return new Promise\(\(resolve, reject\)/); + assert.match(buildScriptSource, /server\.once\("error", onError\)/); + assert.match(buildScriptSource, /server\.off\("error", onError\)/); + assert.match(buildScriptSource, /initWebSocket\(server\)/); + assert.match(buildScriptSource, /stopTopLevelRuntimeSideEffects\(\)/); + assert.match(sidecarSource, /description\.includes\("EADDRINUSE"\)/); + assert.match(sidecarSource, /port \$\{this\.port\} is in use by another process/); + assert.match(sidecarSource, /this\.onTerminalFailure\?\.\(reason\)/); + assert.doesNotMatch(sidecarSource, /MAX_RESTART_ATTEMPTS|READY_STABILITY_WINDOW_MS/); + assert.doesNotMatch(sidecarSource, /setTimeout\(\(\) => \{[\s\S]*this\.launch\(\)/); }); test("docs and ignores describe generated pnpm-managed inputs, not vendor source", () => { @@ -443,6 +416,7 @@ test("hooks are opt-in: default off, silent server auto-install never enabled", // The host never sets CCAM_AUTO_INSTALL_HOOKS=1; it manages hooks directly. assert.doesNotMatch(sidecarSource, /CCAM_AUTO_INSTALL_HOOKS:\s*"1"/); assert.match(hooksSource, /store\(\)\.get\("enabled", false\)/); + assert.match(hooksSource, /CLAUDE_DASHBOARD_PORT=\$\{resolveAgentMonitorPort\(\)\}/); assert.match(hooksSource, /ELECTRON_RUN_AS_NODE=1/); assert.match(hooksSource, /JSON\.stringify\(hookType\)/); assert.match(hooksSource, /renameSync/); diff --git a/docs/plans/prd-407-in-process-only.md b/docs/plans/prd-407-in-process-only.md new file mode 100644 index 00000000..71a848c1 --- /dev/null +++ b/docs/plans/prd-407-in-process-only.md @@ -0,0 +1,38 @@ +# PRD-407 In-Process Agent Monitor Only + +## Scope + +Build only PRD-407 from a clean `origin/main` worktree. Do not cherry-pick or copy the broader PRD-428 work. + +## Interpretation + +Eliminate the spawned Electron-as-Node Agent Monitor sidecar process. Keep the generated Agent Monitor web runtime on a fixed loopback port so the existing iframe, hook handler, and `/api/health` contracts continue to work. + +## Plan + +1. Generate a `server/closedloop-runtime.js` wrapper from `build-agent-monitor.mjs`. Done. +2. Replace `AgentMonitorSidecar` internals so it loads that wrapper with `createRequire` and starts/stops the runtime in Electron main. Done. +3. Preserve current host contracts: fixed port, health polling, runtime env, sandbox env, hook opt-in, shutdown ordering, reprocess flow. Done. +4. Remove child-process supervision expectations from tests and add guards for the in-process runtime. Done. +5. Verify with `build:agent-monitor`, focused tests, `typecheck`, full desktop tests, runtime health smoke, and port-conflict smoke. Done. + +## Verification + +- `pnpm -C apps/desktop exec node --check scripts/build-agent-monitor.mjs` +- `pnpm -C apps/desktop exec tsc -p tsconfig.json --noEmit` +- `pnpm -C apps/desktop build:agent-monitor -- --force` +- `pnpm -C apps/desktop exec tsx --test test/agent-monitor-sidecar.test.ts test/agent-monitor-runtime-env.test.ts test/agent-monitor-wiring-static.test.ts` +- `pnpm -C apps/desktop build` +- `pnpm -C apps/desktop test` +- Runtime smoke on `127.0.0.1:54821` returned `/api/health` 200 and confirmed host `process.env`/`process.cwd()` stayed isolated. +- Port-conflict smoke on `127.0.0.1:54822` rejected with `EADDRINUSE` and restored host env. +- Independent PR review found no material findings after fixes. +- `git diff --check` + +## Non-Goals + +- No PRD-428 strategy items. +- No single-player nav changes. +- No Codex hook work beyond what already exists on `origin/main`. +- No pricing/audit/catalog feature changes beyond what is necessary for PRD-407 runtime lifecycle. +- No commits or pushes.