bin/daemon/update-generation.ts
Rama main · réplica de lectura del repositorio original.
/** A replacement updater observes activation. The daemon remains the sole automatic restart owner. */
import { existsSync } from "node:fs";
import { CommandFailure, homeCotalDir } from "@cotal-ai/workspace";
import { queryDaemon } from "./client.js";
import { daemonPaths, EndpointRefusal } from "./endpoint.js";
import { installedDaemonIdentity, unitReceiptFile } from "./install.js";
import { daemonManagerIntentFile } from "./manager.js";
/** Test hooks: the installed-service check, the daemon query and the wait bound. */
export interface GenerationWaitProbe {
readonly installed?: () => boolean;
readonly query?: typeof queryDaemon;
readonly timeoutMs?: number;
}
export async function waitForInstalledDaemonGeneration(version: string, probe: GenerationWaitProbe = {}): Promise<void> {
let paths;
try { paths = daemonPaths(); }
catch (error) {
if (error instanceof EndpointRefusal && error.code === "NO_RUNTIME_DIR") return;
throw error;
}
// Foreground daemons and another installation keep their existing update behavior.
if (!(probe.installed ? probe.installed() : existsSync(unitReceiptFile(paths)) && installedDaemonIdentity(paths).same)) return;
const query = probe.query ?? queryDaemon;
const bound = probe.timeoutMs ?? 120_000;
console.log(`machine daemon: waiting up to ${Math.round(bound / 1000)}s for Cotal ${version} and its hosted Managers before the continuity check`);
const deadline = Date.now() + bound;
let waiting = "the recorded daemon has not answered";
for (;;) {
const remaining = deadline - Date.now();
if (remaining <= 0) throw new CommandFailure("refused", "LIFECYCLE_BLOCKED", `installed Cotal ${version} did not become ready within ${Math.round(bound / 1000)}s: ${waiting}; connector reconciliation was not started by this update. Read \`cotal daemon status\` and the recorded daemon user service, restore the daemon and its hosted Managers, then run \`cotal update\` again`, {
remedy: "read \`cotal daemon status\` and the recorded daemon user service, restore the daemon and its hosted Managers, then run \`cotal update\` again",
});
try {
const reply = await query({ paths, timeoutMs: Math.min(20_000, Math.max(1, remaining)) });
// A daemon that cannot complete status while it stops or starts its hosted Managers is still
// restarting. Its reason is kept for the deadline, and the wait goes on.
if (!reply.ok) waiting = `the daemon could not answer status: ${reply.error.code} ${reply.error.message}`;
else if (reply.result.version !== version) {
waiting = `daemon PID ${reply.result.pid} still runs Cotal ${reply.result.version}${reply.result.generation ? ` (${reply.result.generation.waiting})` : ""}`;
} else {
const pending = (reply.result.managers ?? []).filter(manager =>
(manager.pid !== undefined || existsSync(daemonManagerIntentFile(manager.space, homeCotalDir()))) && manager.state !== "answering");
if (!pending.length) return;
waiting = pending.map(manager => `hosted Manager ${manager.space}: ${manager.state}${manager.reason ? ` (${manager.reason})` : ""}`).join("; ");
}
} catch (error) {
// Authentication and ownership failures are refusals, not evidence of a restarting service.
if (error instanceof EndpointRefusal) {
if (error.code !== "NOT_RUNNING" && error.code !== "PEER_NOT_LOCK_OWNER") throw error;
} else {
const transient = error instanceof Error && ([
"daemon closed the connection without a reply", "timed out waiting for the daemon's reply",
].includes(error.message) || ["ECONNRESET", "EPIPE"].includes((error as NodeJS.ErrnoException).code ?? "") || error.message.startsWith("timed out connecting to "));
if (!transient) throw error;
}
// Retain the last generation/Manager observation so an endpoint gap does not erase it.
}
await new Promise(resolve => setTimeout(resolve, Math.min(250, Math.max(0, deadline - Date.now()))));
}
}