From 5f1706bce6d29b8ff83dce2a273f2f398a443450 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Tue, 5 May 2026 14:12:13 +0200 Subject: [PATCH] restart stale daemons after agent-cdp upgrades --- .../src/__tests__/daemon-client.test.ts | 68 +++++++++++++++++++ packages/agent-cdp/src/cli.ts | 11 +-- packages/agent-cdp/src/daemon-client.ts | 68 ++++++++++++++++--- packages/agent-cdp/src/daemon.ts | 2 + packages/agent-cdp/src/types.ts | 1 + packages/agent-cdp/src/version.ts | 18 +++++ 6 files changed, 151 insertions(+), 17 deletions(-) create mode 100644 packages/agent-cdp/src/__tests__/daemon-client.test.ts create mode 100644 packages/agent-cdp/src/version.ts diff --git a/packages/agent-cdp/src/__tests__/daemon-client.test.ts b/packages/agent-cdp/src/__tests__/daemon-client.test.ts new file mode 100644 index 0000000..5d2b733 --- /dev/null +++ b/packages/agent-cdp/src/__tests__/daemon-client.test.ts @@ -0,0 +1,68 @@ +import { getRequiredDaemonAction } from "../daemon-client.js"; + +describe("getRequiredDaemonAction", () => { + const currentVersion = "1.2.0"; + + it("reuses a live daemon with the same version", () => { + expect( + getRequiredDaemonAction( + { + pid: 123, + socketPath: "/tmp/daemon.sock", + startedAt: Date.now(), + version: currentVersion, + }, + currentVersion, + true, + ), + ).toBe("reuse"); + }); + + it("restarts a live daemon with a different version", () => { + expect( + getRequiredDaemonAction( + { + pid: 123, + socketPath: "/tmp/daemon.sock", + startedAt: Date.now(), + version: "1.1.0", + }, + currentVersion, + true, + ), + ).toBe("restart"); + }); + + it("restarts a live daemon when version metadata is missing", () => { + expect( + getRequiredDaemonAction( + { + pid: 123, + socketPath: "/tmp/daemon.sock", + startedAt: Date.now(), + }, + currentVersion, + true, + ), + ).toBe("restart"); + }); + + it("starts fresh when the recorded daemon is not alive", () => { + expect( + getRequiredDaemonAction( + { + pid: 123, + socketPath: "/tmp/daemon.sock", + startedAt: Date.now(), + version: currentVersion, + }, + currentVersion, + false, + ), + ).toBe("start"); + }); + + it("starts fresh when no daemon info exists", () => { + expect(getRequiredDaemonAction(null, currentVersion, false)).toBe("start"); + }); +}); diff --git a/packages/agent-cdp/src/cli.ts b/packages/agent-cdp/src/cli.ts index 931ba80..753f86d 100644 --- a/packages/agent-cdp/src/cli.ts +++ b/packages/agent-cdp/src/cli.ts @@ -1,7 +1,7 @@ import { readdirSync, readFileSync } from "fs"; import { dirname, join } from "path"; import { fileURLToPath } from "url"; -import { ensureDaemon, readDaemonInfo, sendCommand, stopDaemon } from "./daemon-client.js"; +import { ensureDaemon, sendCommand, stopDaemon } from "./daemon-client.js"; import { formatConsoleList, formatConsoleMessage, @@ -273,17 +273,12 @@ export async function main(): Promise { } if (cmd === "stop") { - console.log(stopDaemon() ? "Daemon stopped" : "Daemon is not running"); + console.log((await stopDaemon()) ? "Daemon stopped" : "Daemon is not running"); return; } if (cmd === "status") { - const info = readDaemonInfo(); - if (!info) { - console.log("Daemon is not running"); - process.exit(1); - } - + await ensureDaemon(); const response = await sendCommand({ type: "status" }); if (!response.ok) { throw new Error(response.error || "Failed to load daemon status"); diff --git a/packages/agent-cdp/src/daemon-client.ts b/packages/agent-cdp/src/daemon-client.ts index 034a67e..269afe3 100644 --- a/packages/agent-cdp/src/daemon-client.ts +++ b/packages/agent-cdp/src/daemon-client.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { spawn } from "node:child_process"; import type { DaemonInfo, IpcCommand, IpcResponse } from "./types.js"; +import { getPackageVersion } from "./version.js"; const STATE_DIR = path.join(process.env.HOME || process.env.USERPROFILE || "/tmp", ".agent-cdp"); @@ -33,12 +34,23 @@ function isDaemonAlive(info: DaemonInfo): boolean { } } -export async function ensureDaemon(): Promise { - const info = readDaemonInfo(); - if (info && isDaemonAlive(info)) { - return; +export function getRequiredDaemonAction( + info: DaemonInfo | null, + currentVersion: string, + daemonAlive = info ? isDaemonAlive(info) : false, +): "reuse" | "restart" | "start" { + if (!info || !daemonAlive) { + return "start"; + } + + if (info.version !== currentVersion) { + return "restart"; } + return "reuse"; +} + +function cleanupDaemonState(): void { try { fs.unlinkSync(getDaemonInfoPath()); } catch {} @@ -46,6 +58,30 @@ export async function ensureDaemon(): Promise { try { fs.unlinkSync(getSocketPath()); } catch {} +} + +async function waitForDaemonExit(pid: number, timeoutMs = 5_000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + process.kill(pid, 0); + await new Promise((resolve) => setTimeout(resolve, 100)); + } catch { + return; + } + } + + throw new Error("Daemon failed to stop within 5 seconds"); +} + +async function stopDaemonProcess(info: DaemonInfo): Promise { + process.kill(info.pid, "SIGTERM"); + await waitForDaemonExit(info.pid); + cleanupDaemonState(); +} + +async function startDaemon(): Promise { + cleanupDaemonState(); const daemonScript = path.join(path.dirname(new URL(import.meta.url).pathname), "daemon.js"); const child = spawn(process.execPath, [daemonScript], { @@ -66,19 +102,33 @@ export async function ensureDaemon(): Promise { throw new Error("Daemon failed to start within 5 seconds"); } -export function stopDaemon(): boolean { +export async function ensureDaemon(): Promise { + const info = readDaemonInfo(); + const action = getRequiredDaemonAction(info, getPackageVersion()); + if (action === "reuse") { + return; + } + + if (action === "restart" && info) { + await stopDaemonProcess(info); + } else { + cleanupDaemonState(); + } + + await startDaemon(); +} + +export async function stopDaemon(): Promise { const info = readDaemonInfo(); if (!info) { return false; } try { - process.kill(info.pid, "SIGTERM"); - try { - fs.unlinkSync(getDaemonInfoPath()); - } catch {} + await stopDaemonProcess(info); return true; } catch { + cleanupDaemonState(); return false; } } diff --git a/packages/agent-cdp/src/daemon.ts b/packages/agent-cdp/src/daemon.ts index bfeee6a..b430a4d 100644 --- a/packages/agent-cdp/src/daemon.ts +++ b/packages/agent-cdp/src/daemon.ts @@ -13,6 +13,7 @@ import { createTargetProviders } from "./providers.js"; import { SessionManager } from "./session-manager.js"; import { TraceRecorder } from "./trace.js"; import type { DaemonInfo, IpcCommand, IpcResponse, StatusInfo } from "./types.js"; +import { getPackageVersion } from "./version.js"; const STATE_DIR = path.join(process.env.HOME || process.env.USERPROFILE || "/tmp", ".agent-cdp"); @@ -74,6 +75,7 @@ class Daemon { pid: process.pid, socketPath, startedAt: this.startedAt, + version: getPackageVersion(), buildMtime, }; diff --git a/packages/agent-cdp/src/types.ts b/packages/agent-cdp/src/types.ts index ae639a7..2501cd6 100644 --- a/packages/agent-cdp/src/types.ts +++ b/packages/agent-cdp/src/types.ts @@ -74,6 +74,7 @@ export interface DaemonInfo { pid: number; socketPath: string; startedAt: number; + version?: string; buildMtime?: number; } diff --git a/packages/agent-cdp/src/version.ts b/packages/agent-cdp/src/version.ts new file mode 100644 index 0000000..dad22a8 --- /dev/null +++ b/packages/agent-cdp/src/version.ts @@ -0,0 +1,18 @@ +import fs from "node:fs"; + +let cachedPackageVersion: string | null = null; + +export function getPackageVersion(): string { + if (cachedPackageVersion) { + return cachedPackageVersion; + } + + const raw = fs.readFileSync(new URL("../package.json", import.meta.url), "utf-8"); + const parsed = JSON.parse(raw) as { version?: string }; + if (!parsed.version) { + throw new Error("agent-cdp package version is missing"); + } + + cachedPackageVersion = parsed.version; + return cachedPackageVersion; +}