/** The binary composes machine-daemon custody with the stock package updater. */ import { existsSync } from "node:fs"; import { registry, type Command } from "@cotal-ai/core"; import { update, updateRuntime } from "@cotal-ai/cli/update"; import { withIdpSessionDir, withRemoteExchangeRetry } from "@cotal-ai/auth"; import { CommandFailure, homeCotalDir, readUpdateRecovery, claimUpdateClosureAccess, resolveControlTarget, shellPath } from "@cotal-ai/workspace"; import { askManager } from "@cotal-ai/workspace/control"; import { daemonPaths, EndpointRefusal } from "./endpoint.js"; import { managerViaDaemon, queryDaemon } from "./client.js"; import { installedDaemonIdentity, restartInstalled, unitReceiptFile } from "./install.js"; import { excludeLifetime, processBirth, readLifetimeRecord } from "./lifetime.js"; import { waitForInstalledDaemonGeneration } from "./update-generation.js"; const command = registry.resolve("command", "update"); registry.unregister("command", "update"); const composed: Command = { ...command, async run(args) { let hostedSpaces: string[] = []; let daemonObserved = false; let preparedRestart = false; let absentRecoveryHost = false; await update(args, { ...updateRuntime, preparedRestart: () => preparedRestart, async prepareReplacement() { if (process.env.COTAL_AUTO_UPDATE !== "1" || !daemonObserved) return async () => {}; const paths = daemonPaths(); try { await managerViaDaemon({ paths }, { op: "managers-prepare-restart" }); } catch (error) { throw new Error(`daemon unavailable or refused update preparation: ${error instanceof Error ? error.message : String(error)}`); } preparedRestart = true; return async () => { try { const current = await queryDaemon({ paths }); if (current.ok && current.result.version === updateRuntime.version()) await managerViaDaemon({ paths }, { op: "managers-abort-restart" }); } catch (error) { if (!(error instanceof EndpointRefusal) || !["NOT_RUNNING", "PEER_NOT_LOCK_OWNER"].includes(error.code)) throw error; } }; }, waitForInstalledGeneration: async version => { if (absentRecoveryHost) return; await waitForInstalledDaemonGeneration(version); }, reportRunningManager: (flags) => withRemoteExchangeRetry(async () => { const recovery = readUpdateRecovery(); if (recovery?.preparedRestart && process.env.COTAL_UPDATE_PARENT === String(process.ppid) && process.env.COTAL_UPDATE_TARGET_VERSION === updateRuntime.version() && recovery?.owner?.pid === process.ppid && recovery.owner.nonce === process.env.COTAL_UPDATE_CLOSURE_NONCE && recovery.owner.start === process.env.COTAL_UPDATE_CLOSURE_START) { const release = claimUpdateClosureAccess(); release(); const paths = daemonPaths(); const lifetime = readLifetimeRecord(paths.lock); let absent = !existsSync(paths.lock) || (lifetime?.start !== undefined && processBirth(lifetime.pid) !== lifetime.start); if (!lifetime && !absent) { const exclusion = excludeLifetime(paths.lock); exclusion.release(); absent = true; } if (absent) { absentRecoveryHost = recovery.phase === "restoring"; console.log("hosted Manager continuity: daemon lifetime proves no live host"); return "none"; } const observed = await queryDaemon({ paths }); const evidence = observed.ok ? observed.result.update?.restartEvidence : undefined; if (!evidence?.prepared || evidence.managers.some(row => !row.fenced || row.seats !== 0)) throw new Error("prepared update admission is no longer fenced and empty"); console.log("hosted Manager continuity: daemon verified prepared fences and zero seats"); return "none"; } let managers; try { const reply = await queryDaemon({ paths: daemonPaths() }); if (!reply.ok) throw new Error(`machine daemon custody is unreadable: ${reply.error.message}`); daemonObserved = true; managers = reply.result.managers ?? []; } catch (error) { if (error instanceof EndpointRefusal && ["NOT_RUNNING", "NO_RUNTIME_DIR"].includes(error.code)) return updateRuntime.reportRunningManager(flags); throw error; } // Updating this installation restarts the machine daemon, including spaces not selected by flags. for (const manager of managers) { if (manager.pid === undefined) console.log(`hosted Manager ${manager.space}: ${manager.state}; no live child, skipped`); } const active = managers.filter((manager) => manager.pid !== undefined); hostedSpaces = active.map((manager) => manager.space); if (!active.length) return updateRuntime.reportRunningManager(flags); for (const manager of active) { // A failed earlier observation is not proof that a retained child's endpoint cannot answer. // Keep the fresh, instance-pinned custody check below as the authority for update admission. if (!manager.instanceId) throw new CommandFailure("refused", "LIFECYCLE_BLOCKED", `hosted Manager for ${manager.space} is ${manager.state}; its custody must answer before update`, { remedy: `run \`cotal daemon service-disconnect --space ${manager.space}\`, correct its sign-in or connection, then run \`cotal update\` again` }); const reply = await withIdpSessionDir(homeCotalDir(), async () => { const target = await resolveControlTarget({ space: manager.space }, "control-caller-privileged", manager.instanceId, { onRefusal: "throw", readOnly: true }); return askManager({ versionSkew: (text: string) => text, roster: async () => [] }, manager.space, target.server, "managerStatus", undefined, target.auth, "owner", 3000, { instanceId: manager.instanceId }); }); if (!reply.ok) throw new Error(reply.error ?? "hosted Manager status did not answer"); const status = reply.data as { agentCount?: number; custody?: string; stopImpact?: { agents: { name: string }[] } }; if (status.custody === "legacy" && status.agentCount !== 0) { const names = status.stopImpact?.agents.map((agent) => agent.name); throw new CommandFailure("refused", "LIFECYCLE_BLOCKED", `hosted Manager ${manager.instanceId} cannot preserve running agents on this computer during update${names?.length ? ` (${names.join(", ")})` : ""}; restarting it would end them`, { remedy: `save their work; stop each with \`cotal stop --on ${manager.instanceId} --space ${shellPath(manager.space)}\`, then rerun the same update command. To list agents, use \`cotal ps --on ${manager.instanceId} --space ${shellPath(manager.space)}\` (requires admin scope)` }); } console.log(`hosted Manager ${manager.space}: instance ${manager.instanceId} answered; ${status.agentCount ?? "unknown"} seat(s)`); } // The stock resolver still inspects other locally hosted spaces when one is not selected. if (typeof flags.space !== "string") { const { readMeshRegistry } = await import("@cotal-ai/workspace"); for (const entry of readMeshRegistry().entries) { if (active.some((manager) => manager.space === entry.space)) continue; if (await updateRuntime.reportRunningManager({ ...flags, space: entry.space }) === "legacy") return "legacy"; } } return "none"; }) }); if (args.values.check === true || args.values.auto || args.values.notify || process.exitCode || !daemonObserved) return; // The daemon started this update and restarts itself once this process has exited. A restart // asked for from here would stop this process with the rest of the service. if (process.env.COTAL_AUTO_UPDATE === "1") return; const paths = daemonPaths(); const current = await queryDaemon({ paths }); if (!current.ok) throw new Error("the installed daemon did not answer after update"); if (!existsSync(unitReceiptFile(paths))) { console.log(`machine daemon: PID ${current.result.pid} runs Cotal ${current.result.version}; no recorded user service, left running`); return; } const installation = installedDaemonIdentity(paths); if (!installation.same) { console.log(`machine daemon: ${installation.installed} runs Cotal ${current.result.version}; another installation was left running`); return; } if (current.result.version !== updateRuntime.version()) { const hosted = (current.result.managers ?? []).some((manager) => manager.pid !== undefined); if (hosted) await managerViaDaemon({ paths }, { op: "managers-prepare-restart" }); try { await restartInstalled(paths, undefined, false, updateRuntime.version(), detail => console.log(`machine daemon: ${detail}`)); } catch (error) { if (hosted && process.platform !== "win32") { try { await managerViaDaemon({ paths }, { op: "managers-abort-restart" }); } catch { /* The native restart failure remains the primary outcome. */ } } throw error; } } const deadline = Date.now() + 120_000; for (;;) { try { const reply = await queryDaemon({ paths }); if (reply.ok && reply.result.version === updateRuntime.version() && hostedSpaces.every((space) => reply.result.managers?.some((manager) => manager.space === space && manager.state === "answering"))) return; } catch (error) { if (!(error instanceof EndpointRefusal) || !["NOT_RUNNING", "PEER_NOT_LOCK_OWNER"].includes(error.code)) throw error; } if (Date.now() >= deadline) throw new CommandFailure("refused", "LIFECYCLE_BLOCKED", "the updated daemon did not restore its hosted Managers before the deadline", { remedy: "read \`cotal daemon status\` and the recorded daemon user service" }); await new Promise((resolve) => setTimeout(resolve, 250)); } } }; registry.register(composed);