From c90dd7b147b227210d8621819d0154c8d2ecda3c Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Fri, 11 Sep 2026 16:45:47 +0700 Subject: [PATCH] fix(bootstrap): self-heal a systemd unit left on a retired plugin root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The residue an interrupted update leaves behind is invisible: the server runs, every doctor row reads OK, and the unit quietly still names a cache directory Claude Code deleted during the update. Nothing surfaces it until a reboot, when the dashboard does not come back and nothing recorded why. - fastPathOk now rejects a stale unit, so the next SessionStart repairs it instead of short-circuiting. `unitIsStale` is pure fs on purpose: that path runs on every session and may not spawn a process. - Repairing the unit is not restarting the server. A current, healthy server keeps running and the corrected unit takes effect at the next start (`pendingSystemdRestart = systemdOwned && needsServer`), and the detached-spawn fallback is guarded by needsServer so a stale unit alone cannot start a second server onto a bound port. - systemdOwned now also requires the unit to be ACTIVE. Installed-but- stopped owns nothing: skipping the stop there and asking systemd to start would bind an occupied port and crash-loop under Restart=always. - /agenthub-doctor gains a systemd row naming the consequence ("will NOT come back after a reboot") and the fix. WARN, never FAIL — the dashboard in front of you works, and having no unit at all is a normal setup. The fast-path test pins the regression against an otherwise fully healthy install, live server included; without a live server it would have passed against the unfixed code either way. --- CLAUDE.md | 2 +- docs/PLUGINS.md | 24 +++- plugins/agenthub/commands/agenthub-doctor.md | 7 +- scripts/plugin-bootstrap.js | 42 ++++-- scripts/plugin-doctor.js | 48 +++++++ scripts/systemd-service.js | 32 +++++ server/__tests__/plugin-bootstrap.test.js | 65 ++++++++++ .../__tests__/plugin-doctor-systemd.test.js | 121 ++++++++++++++++++ server/__tests__/systemd-service.test.js | 47 +++++++ 9 files changed, 377 insertions(+), 11 deletions(-) create mode 100644 server/__tests__/plugin-doctor-systemd.test.js diff --git a/CLAUDE.md b/CLAUDE.md index 6fc39851..2bdbf3b2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,7 +43,7 @@ A **lane** is a durable unit of parallel agent work — one working directory, m See `docs/LANES.md` for full guide: stage reporting, custom templates, lane actions, and the five node states. ## Non-negotiable engineering rules -- **When a `agenthub.service` unit of ours owns the server, nothing else may stop it.** The bootstrap that performs a self-update is spawned by the running server, so it lives in that server's systemd cgroup: SIGTERM-ing the server from there exits 0 (which `Restart=on-failure` read as "meant to stop") AND trips `KillMode=control-group`, reaping the bootstrap before it can repoint the unit or write `state.json` — a dashboard dead after every update until some later session bootstrapped again. So `scripts/systemd-service.js` splits the API: `writeUserService` repoints the unit and starts NOTHING, `restartUserService` uses `--no-block`, and `plugin-bootstrap.js` defers that restart past `writeState` and `releaseLock` (`pendingSystemdRestart`). The unit is `Restart=always`. Do not add a path that stops the dashboard from inside its own cgroup, and do not move the restart earlier. `agenthub service install` may keep using `installUserService` — it runs from a user's shell, outside the cgroup. +- **When a `agenthub.service` unit of ours owns the server, nothing else may stop it.** The bootstrap that performs a self-update is spawned by the running server, so it lives in that server's systemd cgroup: SIGTERM-ing the server from there exits 0 (which `Restart=on-failure` read as "meant to stop") AND trips `KillMode=control-group`, reaping the bootstrap before it can repoint the unit or write `state.json` — a dashboard dead after every update until some later session bootstrapped again. So `scripts/systemd-service.js` splits the API: `writeUserService` repoints the unit and starts NOTHING, `restartUserService` uses `--no-block`, and `plugin-bootstrap.js` defers that restart past `writeState` and `releaseLock` (`pendingSystemdRestart`). The unit is `Restart=always`. Do not add a path that stops the dashboard from inside its own cgroup, and do not move the restart earlier. `agenthub service install` may keep using `installUserService` — it runs from a user's shell, outside the cgroup. `systemdOwned` additionally requires the unit to be ACTIVE (an installed-but-stopped unit owns nothing; treating it as owner skips the stop and then binds an occupied port). A unit naming a RETIRED plugin root is invisible until a reboot, so `fastPathOk` rejects it via `unitIsStale` (pure fs — that path may never spawn a process) and the bootstrap rewrites it; repairing the unit is NOT restarting the server (`pendingSystemdRestart = systemdOwned && needsServer`), and the detached-spawn fallback is guarded by `needsServer` so a stale unit alone never starts a second server onto a bound port. - **A session reload is typed into a live pane, never spawned.** `/reload-plugins` and `/reload-skills` are scoped BY CLAUDE CODE to the session that runs them, so nothing can reload a session from the outside. `server/lib/cc-reload.js` therefore types them into the dashboard's existing tmux lane panes and spawns NO `claude` process — a one-shot `claude -p "/reload-plugins"` reloads only its own short-lived session, exits, and leaves a junk session in the dashboard's own history. Do not add a path that starts a session in order to reload one. - Preserve existing behavior unless explicitly asked to change it. - Prefer minimal, reversible diffs. diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index 41da8d1c..d2333fc4 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -210,11 +210,33 @@ the Settings panel do — still stops it. it runs from your own shell, outside the unit's cgroup, so it may block and start in one step. +**A unit pointing at a retired plugin root repairs itself on the next session.** +This is the residue an interrupted update leaves behind, and it is invisible +while the dashboard is up: the server runs fine, every doctor row reads OK, and +then a reboot starts a cache directory Claude Code deleted during the update — +so the dashboard never comes back, for a reason nothing recorded. The fast path +therefore checks it (`unitIsStale`, pure fs — it may not spawn a process on +every `SessionStart`) and refuses to short-circuit, and the bootstrap rewrites +the unit. + +Repairing the *unit* is not restarting the *server*: when the running server is +current and healthy, the corrected unit is simply left to take effect at the +next start (`pendingSystemdRestart = systemdOwned && needsServer`). Restarting +a working dashboard to fix a file that only matters at boot would be churn the +user feels for nothing. For the same reason the detached-spawn fallback is +guarded by `needsServer` — entering that branch for a stale unit alone would +start a second server onto a port the live one already holds. + +`systemdOwned` requires the unit to be **active**, not merely present: an +installed-but-stopped unit owns nothing, and treating it as the owner would +skip the stop and then ask systemd to bind an occupied port — a crash loop, +courtesy of `Restart=always`. + ### Commands | Command | Does | |---|---| -| `/agenthub-doctor` | Node version, bootstrap state, runtime deps, server liveness, duplicate hooks, CLI launcher + PATH, **tmux** (`WARN` when absent — only the run console, `agenthub lanes shell` and session reloads need it), **skill plugins** (see below), UI bundle | +| `/agenthub-doctor` | Node version, bootstrap state, runtime deps, server liveness, duplicate hooks, CLI launcher + PATH, **systemd** (`WARN` when the unit names a retired plugin root — the dashboard will not survive a reboot — or when it is installed but stopped; no unit at all is fine), **tmux** (`WARN` when absent — only the run console, `agenthub lanes shell` and session reloads need it), **skill plugins** (see below), UI bundle | | `/agenthub-update` | Pull the latest `agenthub` release via `claude plugin marketplace update` + `claude plugin update agenthub@ -y`, then reinstall dependencies and restart the server (`plugin-bootstrap.js --force`). The plugin pull needs a manual `/reload-plugins` afterward to take effect in the current session — not scriptable from a command. | | `/agenthub-open` | Rebuild the UI bundle and print the dashboard URL. Re-runs the bootstrap first when the server is down or still executing a plugin root that a mid-session `/plugin` update retired, so a fresh bundle never talks to an older server's routes | | `/ship-full ` | Put the lane on the 16-node `ship-feature` template, then run `/agenthub:ship-feature-lane` **in this session**. The in-session twin of `agenthub ship-full` (which spawns its own `claude` from a terminal) | diff --git a/plugins/agenthub/commands/agenthub-doctor.md b/plugins/agenthub/commands/agenthub-doctor.md index bf6ff1df..c5f02a2f 100644 --- a/plugins/agenthub/commands/agenthub-doctor.md +++ b/plugins/agenthub/commands/agenthub-doctor.md @@ -1,5 +1,5 @@ --- -description: Diagnose a plugin-installed AgentHub — Node, npm, disk, bootstrap, server, hooks, CLI, tmux, UI build +description: Diagnose a plugin-installed AgentHub — Node, npm, disk, bootstrap, server, hooks, CLI, systemd, tmux, UI build --- Run the diagnostic and report what it found. Do not fix anything unless the user @@ -31,6 +31,11 @@ Print the output verbatim, then add at most three lines of guidance based on the means that attempt has not run or did not succeed — say `/agenthub-update` retries it, and print the `claude plugin install …` line from the output as the manual route. +- `WARN systemd` → when it says the unit names a **previous plugin version**, + the dashboard is running now but will NOT come back after a reboot. Starting + a new Claude Code session repairs it; print the `--force` command from the + output as the immediate fix. "installed but not running" just needs + `systemctl --user start agenthub`. - `WARN tmux` → only the run console, `agenthub lanes shell` and the session-reload keystrokes are affected; monitoring, lanes and the CLI are not. Print the install command from the output. If the row says `ok` but Start Run diff --git a/scripts/plugin-bootstrap.js b/scripts/plugin-bootstrap.js index cd478692..4de5256b 100644 --- a/scripts/plugin-bootstrap.js +++ b/scripts/plugin-bootstrap.js @@ -107,6 +107,12 @@ function fastPathOk(rt = runtimeDir()) { // pipelines drive. Reading one recorded flag keeps this hot path cheap — the // actual check (manifest + every user SKILL.md) runs once, in the worker. if (!state.skillPluginsSettled) return false; + // A live server says nothing about whether it will come back. When our unit + // still names a previous plugin cache directory, everything looks healthy + // until the next reboot — and then the dashboard is simply gone, from a path + // Claude Code deleted long ago. Reading one file is cheap enough for a hot + // path; `unitIsStale` deliberately spawns nothing. + if (require("./systemd-service").unitIsStale("agenthub", PLUGIN_ROOT)) return false; return serverIsLive(); } @@ -518,7 +524,13 @@ function bootstrap(rt = runtimeDir(), { force = false } = {}) { // before it can write state or point the unit at the new cache dir. That // is the bug that left a dashboard dead after every self-update until the // next Claude Code session happened to bootstrap again. - const systemdOwned = svc.systemctlAvailable() && svc.unitIsOurs("agenthub"); + // ACTIVE matters, not merely present: a unit of ours that is installed but + // stopped owns nothing right now. If a detached server is holding the port + // in that state, treating systemd as the owner would skip the stop and then + // ask systemd to start a second server onto a bound port — a crash loop, + // courtesy of Restart=always. + const systemdOwned = + svc.systemctlAvailable() && svc.unitIsOurs("agenthub") && svc.unitIsActive("agenthub"); // After a plugin update the running server executes code from a cache // directory Claude Code has already discarded — retire it first. Only @@ -530,7 +542,13 @@ function bootstrap(rt = runtimeDir(), { force = false } = {}) { waitForPortsFree(); } - if (movedRoot || !serverIsLive()) { + // Two different needs, deliberately not collapsed. `needsServer` is "the + // running process must be replaced"; `unitStale` is "the unit names a cache + // directory that is no longer this one", which is invisible until a reboot + // and must be repaired even while a perfectly healthy server is up. + const needsServer = movedRoot || !serverIsLive(); + const unitStale = svc.unitIsStale("agenthub", PLUGIN_ROOT); + if (needsServer || unitStale) { const unit = { name: "agenthub", description: "AgentHub dashboard server (Claude Code plugin)", @@ -557,17 +575,25 @@ function bootstrap(rt = runtimeDir(), { force = false } = {}) { : "unavailable"; if (result === "written" || result === "unchanged") { if (result === "written") svc.enableLinger(); - pendingSystemdRestart = systemdOwned; + // Only when the RUNNING process has to change. A stale unit beside a + // healthy server is now corrected on disk and left to take effect at + // the next start — restarting a working dashboard to fix a file that + // only matters at boot would be churn the user feels for no reason. + pendingSystemdRestart = systemdOwned && needsServer; log( rt, - result === "written" - ? "installed a systemd --user service — the dashboard now survives reboots without Claude Code" - : "systemd --user service already up to date" + result === "unchanged" + ? "systemd --user service already up to date" + : unitStale && !needsServer + ? "repointed the systemd --user service at the current plugin version (it named a retired one)" + : "installed a systemd --user service — the dashboard now survives reboots without Claude Code" ); - } else { + } else if (needsServer) { // No systemd (macOS/Windows/WSL without it), or the unit exists but // wasn't ours to touch — same detached-spawn fallback as before, - // scoped to this session only. + // scoped to this session only. Guarded by `needsServer`: entering this + // block for a stale unit alone must never spawn a second server onto + // the port the live one already holds. const pid = startDashboard(rt); log( rt, diff --git a/scripts/plugin-doctor.js b/scripts/plugin-doctor.js index a45dd98a..c51a2a09 100644 --- a/scripts/plugin-doctor.js +++ b/scripts/plugin-doctor.js @@ -83,6 +83,7 @@ function diagnose() { ); add(...cliStatus()); + add(...systemdStatus()); add(...tmuxStatus()); add(...skillPluginStatus()); @@ -220,6 +221,53 @@ function skillPluginStatus() { ]; } +/** + * The failure this row exists for is silent by construction: a unit still + * naming a retired plugin cache directory keeps a running dashboard looking + * perfectly healthy, and only bites at the next reboot — when the server does + * not come back, from a path Claude Code deleted weeks earlier. Nothing else + * in this report would show it. + * + * WARN, never FAIL: the dashboard in front of you is working, and on macOS, + * Windows or a Linux box without systemd there is no unit to have an opinion + * about. + */ +function systemdStatus() { + const svc = require("./systemd-service"); + const unit = svc.unitPath("agenthub"); + + if (!svc.unitIsOurs("agenthub")) { + if (fs.existsSync(unit)) { + return ["warn", "systemd", `${unit} exists but was not written by AgentHub — left untouched`]; + } + return [ + "ok", + "systemd", + svc.systemctlAvailable() + ? "no unit — the server is started per session (install one with `agenthub service install`)" + : "not available on this platform — the server is started per session", + ]; + } + + if (svc.unitIsStale("agenthub", PLUGIN_ROOT)) { + return [ + "warn", + "systemd", + `${unit} still points at a previous plugin version — the dashboard will NOT come back after a reboot. ` + + `Starting a new Claude Code session repairs it; or run: node ${path.join(PLUGIN_ROOT, "scripts", "plugin-bootstrap.js")} --force`, + ]; + } + + if (!svc.unitIsActive("agenthub")) { + return [ + "warn", + "systemd", + "unit is installed and current but not running — start it with `systemctl --user start agenthub`", + ]; + } + return ["ok", "systemd", "unit is current and running — the dashboard survives reboots"]; +} + /** * WARN (never FAIL) when `tmux` is absent: every run the dashboard starts is a * tmux session (`server/lib/tmux.js`), so without the binary the run console, diff --git a/scripts/systemd-service.js b/scripts/systemd-service.js index 79dccee6..a7263cb3 100644 --- a/scripts/systemd-service.js +++ b/scripts/systemd-service.js @@ -63,6 +63,36 @@ function unitIsOurs(name) { * later Claude Code session happened to run the bootstrap. An explicit * `systemctl stop` (what uninstall does) still stops it for good. */ +/** + * Does our unit still point at `root`? A plugin update moves the install to a + * new cache directory, and a unit left naming the old one is a failure that + * shows NOTHING until the next reboot — the dashboard simply does not come + * back, from a path Claude Code garbage-collected weeks earlier. + * + * Pure fs, no `systemctl`: this is called from the bootstrap's fast path, which + * runs on every single SessionStart and may not spawn a process. + * + * A foreign unit is never "stale" — it is not ours to judge or rewrite. + */ +function unitIsStale(name, root) { + let body; + try { + body = fs.readFileSync(unitPath(name), "utf8"); + } catch { + return false; // no unit at all: nothing to be stale + } + if (!body.includes(MARKER)) return false; + return !body.includes(`\nWorkingDirectory=${root}\n`); +} + +/** Is the unit currently running? Costs a `systemctl` call — never call it + * from the fast path. */ +function unitIsActive(name) { + if (!systemctlAvailable()) return false; + const res = execImpl(["is-active", `${name}.service`], { encoding: "utf8", stdio: "pipe" }); + return String(res.stdout || "").trim() === "active"; +} + function unitContent({ description, workingDir, execStart, env }) { const envLines = Object.entries(env) .map(([k, v]) => `Environment=${k}=${v}`) @@ -178,6 +208,8 @@ function enableLinger() { module.exports = { systemctlAvailable, unitIsOurs, + unitIsStale, + unitIsActive, writeUserService, restartUserService, installUserService, diff --git a/server/__tests__/plugin-bootstrap.test.js b/server/__tests__/plugin-bootstrap.test.js index 42884f40..7b7d0434 100644 --- a/server/__tests__/plugin-bootstrap.test.js +++ b/server/__tests__/plugin-bootstrap.test.js @@ -129,6 +129,71 @@ describe("fast path", () => { ); assert.equal(boot.fastPathOk(RT), false); }); + + it("is false when the systemd unit still names a retired plugin root", () => { + // The one failure a live server hides completely: everything is healthy + // until the next reboot, and then the dashboard never comes back because + // its unit points into a cache directory Claude Code has deleted. The fast + // path has to reject it, or no SessionStart ever repairs it. + // + // Everything else here is deliberately HEALTHY — including a live server, + // without which `fastPathOk` is false anyway and this test would pass + // against the unfixed code. + const ROOT = path.resolve(__dirname, "..", ".."); + const pkg = require("../../package.json"); + const infoPath = path.join(TMP_HOME, ".agenthub.json"); + const prevHome = process.env.HOME; + // HOME first: `unitPath()` resolves against it, and computing the path + // before this line points every write in this test at the REAL + // ~/.config/systemd/user/agenthub.service — which the cleanup below then + // deletes. (Asked how I know.) + process.env.HOME = TMP_HOME; + const unit = require("../../scripts/systemd-service").unitPath("agenthub"); + assert.ok( + unit.startsWith(TMP_HOME + path.sep), + `refusing to touch a unit outside the temp home: ${unit}` + ); + + const writeUnit = (workingDir) => { + fs.mkdirSync(path.dirname(unit), { recursive: true }); + fs.writeFileSync( + unit, + "[Unit]\n# agenthub-managed-unit — regenerated on install, do not edit by hand\n" + + `[Service]\nWorkingDirectory=${workingDir}\n`, + "utf8" + ); + }; + + try { + fs.mkdirSync(path.join(RT, "node_modules"), { recursive: true }); + fs.writeFileSync( + infoPath, + JSON.stringify({ servers: [{ port: 4820, pid: process.pid, startedAt: "x" }] }) + ); + boot.writeState( + { + pluginVersion: pkg.version, + depsHash: boot.depsHash(), + pluginRoot: ROOT, + skillPluginsSettled: "nothing-missing", + }, + RT + ); + + // Control: a unit naming THIS root is the steady state — fast path taken. + writeUnit(ROOT); + assert.equal(boot.fastPathOk(RT), true, "a current unit must not block the fast path"); + + // The regression: same healthy install, unit left on a retired root. + writeUnit("/plugins/cache/agenthub/deadbeef"); + assert.equal(boot.fastPathOk(RT), false); + } finally { + fs.rmSync(infoPath, { force: true }); + fs.rmSync(unit, { force: true }); + if (prevHome === undefined) delete process.env.HOME; + else process.env.HOME = prevHome; + } + }); }); describe("skill-plugin auto-install gate", () => { diff --git a/server/__tests__/plugin-doctor-systemd.test.js b/server/__tests__/plugin-doctor-systemd.test.js new file mode 100644 index 00000000..032061e3 --- /dev/null +++ b/server/__tests__/plugin-doctor-systemd.test.js @@ -0,0 +1,121 @@ +/** + * @file Tests the `systemd` row of scripts/plugin-doctor.js. + * + * The row exists for a failure nothing else in the report can see: a unit still + * naming a retired plugin cache directory leaves a running, healthy-looking + * dashboard that simply does not come back after the next reboot — it starts a + * path Claude Code deleted during the update. Every other row would read OK. + * + * WARN and never FAIL: the dashboard in front of the user works, and plenty of + * machines legitimately have no unit at all. + * @author Nguyễn Ngọc Trí Vĩ + */ + +const { describe, it, beforeEach, after } = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +const TMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-doctor-systemd-")); +const prevHome = process.env.HOME; +// Before anything resolves a unit path: `unitPath()` reads HOME, and a path +// computed against the real one makes these tests rewrite the developer's own +// ~/.config/systemd/user/agenthub.service. +process.env.HOME = TMP_HOME; +process.env.CLAUDE_HOME = path.join(TMP_HOME, ".claude"); + +const svc = require("../../scripts/systemd-service"); +const { diagnose } = require("../../scripts/plugin-doctor"); + +const UNIT = svc.unitPath("agenthub"); +const PLUGIN_ROOT = path.resolve(__dirname, "..", ".."); + +assert.ok(UNIT.startsWith(TMP_HOME + path.sep), `refusing to touch ${UNIT}`); + +beforeEach(() => { + fs.rmSync(UNIT, { force: true }); + // Claim systemd exists and the unit is running, so each test varies exactly + // one thing: the unit file on disk. + svc.__setExecImpl((args) => ({ + status: 0, + stdout: args[0] === "is-active" ? "active\n" : "", + })); +}); + +after(() => { + svc.__reset(); + if (prevHome === undefined) delete process.env.HOME; + else process.env.HOME = prevHome; + delete process.env.CLAUDE_HOME; + fs.rmSync(TMP_HOME, { recursive: true, force: true }); +}); + +function row() { + const found = diagnose().find((r) => r.label === "systemd"); + assert.ok(found, "the doctor must always emit a systemd row"); + return found; +} + +function writeUnit(workingDir) { + fs.mkdirSync(path.dirname(UNIT), { recursive: true }); + fs.writeFileSync( + UNIT, + "[Unit]\n# agenthub-managed-unit — regenerated on install, do not edit by hand\n" + + `[Service]\nWorkingDirectory=${workingDir}\n`, + "utf8" + ); +} + +const linuxOnly = { skip: process.platform !== "linux" ? "systemd is Linux-only" : false }; + +describe("plugin-doctor: systemd row", linuxOnly, () => { + it("warns, never fails, when the unit names a retired plugin root", () => { + writeUnit("/plugins/cache/sg/agenthub/deadbeef"); + const r = row(); + + assert.equal(r.level, "warn", "a working dashboard must not fail the doctor run"); + assert.match(r.detail, /previous plugin version/i); + // The consequence is the whole point — a reader who does not know it will + // ignore the row. + assert.match(r.detail, /reboot/i); + assert.match(r.detail, /plugin-bootstrap\.js --force|new Claude Code session/); + }); + + it("passes when the unit is current and running", () => { + writeUnit(PLUGIN_ROOT); + const r = row(); + + assert.equal(r.level, "ok", r.detail); + assert.match(r.detail, /current and running/i); + }); + + it("warns when the unit is current but stopped", () => { + writeUnit(PLUGIN_ROOT); + svc.__setExecImpl((args) => ({ + status: 3, + stdout: args[0] === "is-active" ? "inactive\n" : "", + })); + + const r = row(); + assert.equal(r.level, "warn"); + assert.match(r.detail, /not running/i); + }); + + it("reports no unit as fine, not as a problem", () => { + // A plugin install without `agenthub service install` is a normal setup: + // the server is started per session. + const r = row(); + assert.equal(r.level, "ok", r.detail); + assert.match(r.detail, /no unit/i); + }); + + it("warns about a unit it did not write, and never claims it is stale", () => { + fs.mkdirSync(path.dirname(UNIT), { recursive: true }); + fs.writeFileSync(UNIT, "[Service]\nExecStart=/opt/someone-elses-agenthub\n", "utf8"); + + const r = row(); + assert.equal(r.level, "warn"); + assert.match(r.detail, /not written by AgentHub|left untouched/i); + }); +}); diff --git a/server/__tests__/systemd-service.test.js b/server/__tests__/systemd-service.test.js index f017db0a..94039a06 100644 --- a/server/__tests__/systemd-service.test.js +++ b/server/__tests__/systemd-service.test.js @@ -134,6 +134,53 @@ describe("systemd-service — restartUserService", linuxOnly, () => { }); }); +describe("systemd-service — unitIsStale", linuxOnly, () => { + it("is true when the unit names a plugin root that is no longer the current one", () => { + // Invisible while the server runs, fatal at the next reboot: the unit + // starts a path Claude Code garbage-collected after the update. + svc.writeUserService({ ...UNIT, workingDir: "/plugin/root/old" }); + assert.equal(svc.unitIsStale("agenthub", "/plugin/root/new"), true); + }); + + it("is false for a unit already naming the current root", () => { + svc.writeUserService(UNIT); + assert.equal(svc.unitIsStale("agenthub", "/plugin/root/new"), false); + }); + + it("is false when there is no unit, and for one we do not own", () => { + assert.equal(svc.unitIsStale("agenthub", "/plugin/root/new"), false); + + fs.mkdirSync(path.dirname(svc.unitPath("agenthub")), { recursive: true }); + fs.writeFileSync(svc.unitPath("agenthub"), "[Service]\nWorkingDirectory=/opt/mine\n", "utf8"); + assert.equal( + svc.unitIsStale("agenthub", "/plugin/root/new"), + false, + "a foreign unit is not ours to call stale, or to rewrite" + ); + }); + + it("spawns nothing — the bootstrap fast path runs it on every SessionStart", () => { + svc.writeUserService(UNIT); + calls = []; + + svc.unitIsStale("agenthub", "/plugin/root/new"); + assert.deepEqual(calls, []); + }); +}); + +describe("systemd-service — unitIsActive", linuxOnly, () => { + it("is true only for `active`", () => { + svc.__setExecImpl((args) => ({ status: 0, stdout: args[0] === "is-active" ? "active\n" : "" })); + assert.equal(svc.unitIsActive("agenthub"), true); + + svc.__setExecImpl((args) => ({ + status: 3, + stdout: args[0] === "is-active" ? "inactive\n" : "", + })); + assert.equal(svc.unitIsActive("agenthub"), false); + }); +}); + describe("systemd-service — unitIsOurs", linuxOnly, () => { it("is false when no unit exists at all", () => { fs.rmSync(svc.unitPath("agenthub"), { force: true });