fix(bootstrap): self-heal a systemd unit left on a retired plugin root

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.
This commit is contained in:
2026-09-11 16:45:47 +07:00
parent 49a041b1dc
commit c90dd7b147
9 changed files with 377 additions and 11 deletions
+1 -1
View File
@@ -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. See `docs/LANES.md` for full guide: stage reporting, custom templates, lane actions, and the five node states.
## Non-negotiable engineering rules ## 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. - **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. - Preserve existing behavior unless explicitly asked to change it.
- Prefer minimal, reversible diffs. - Prefer minimal, reversible diffs.
+23 -1
View File
@@ -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 it runs from your own shell, outside the unit's cgroup, so it may block and
start in one step. 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 ### Commands
| Command | Does | | 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@<marketplace> -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-update` | Pull the latest `agenthub` release via `claude plugin marketplace update` + `claude plugin update agenthub@<marketplace> -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 | | `/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 <requirement>` | 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) | | `/ship-full <requirement>` | 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) |
+6 -1
View File
@@ -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 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 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 it, and print the `claude plugin install …` line from the output as the manual
route. 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 - `WARN tmux` → only the run console, `agenthub lanes shell` and the
session-reload keystrokes are affected; monitoring, lanes and the CLI are not. 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 Print the install command from the output. If the row says `ok` but Start Run
+34 -8
View File
@@ -107,6 +107,12 @@ function fastPathOk(rt = runtimeDir()) {
// pipelines drive. Reading one recorded flag keeps this hot path cheap — the // pipelines drive. Reading one recorded flag keeps this hot path cheap — the
// actual check (manifest + every user SKILL.md) runs once, in the worker. // actual check (manifest + every user SKILL.md) runs once, in the worker.
if (!state.skillPluginsSettled) return false; 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(); 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 // 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 // is the bug that left a dashboard dead after every self-update until the
// next Claude Code session happened to bootstrap again. // 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 // After a plugin update the running server executes code from a cache
// directory Claude Code has already discarded — retire it first. Only // directory Claude Code has already discarded — retire it first. Only
@@ -530,7 +542,13 @@ function bootstrap(rt = runtimeDir(), { force = false } = {}) {
waitForPortsFree(); 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 = { const unit = {
name: "agenthub", name: "agenthub",
description: "AgentHub dashboard server (Claude Code plugin)", description: "AgentHub dashboard server (Claude Code plugin)",
@@ -557,17 +575,25 @@ function bootstrap(rt = runtimeDir(), { force = false } = {}) {
: "unavailable"; : "unavailable";
if (result === "written" || result === "unchanged") { if (result === "written" || result === "unchanged") {
if (result === "written") svc.enableLinger(); 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( log(
rt, rt,
result === "written" result === "unchanged"
? "installed a systemd --user service — the dashboard now survives reboots without Claude Code" ? "systemd --user service already up to date"
: "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 // No systemd (macOS/Windows/WSL without it), or the unit exists but
// wasn't ours to touch — same detached-spawn fallback as before, // 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); const pid = startDashboard(rt);
log( log(
rt, rt,
+48
View File
@@ -83,6 +83,7 @@ function diagnose() {
); );
add(...cliStatus()); add(...cliStatus());
add(...systemdStatus());
add(...tmuxStatus()); add(...tmuxStatus());
add(...skillPluginStatus()); 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 * 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, * tmux session (`server/lib/tmux.js`), so without the binary the run console,
+32
View File
@@ -63,6 +63,36 @@ function unitIsOurs(name) {
* later Claude Code session happened to run the bootstrap. An explicit * later Claude Code session happened to run the bootstrap. An explicit
* `systemctl stop` (what uninstall does) still stops it for good. * `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 }) { function unitContent({ description, workingDir, execStart, env }) {
const envLines = Object.entries(env) const envLines = Object.entries(env)
.map(([k, v]) => `Environment=${k}=${v}`) .map(([k, v]) => `Environment=${k}=${v}`)
@@ -178,6 +208,8 @@ function enableLinger() {
module.exports = { module.exports = {
systemctlAvailable, systemctlAvailable,
unitIsOurs, unitIsOurs,
unitIsStale,
unitIsActive,
writeUserService, writeUserService,
restartUserService, restartUserService,
installUserService, installUserService,
+65
View File
@@ -129,6 +129,71 @@ describe("fast path", () => {
); );
assert.equal(boot.fastPathOk(RT), false); 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", () => { describe("skill-plugin auto-install gate", () => {
@@ -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ĩ <vinnt@smartgift.vn>
*/
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);
});
});
+47
View File
@@ -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, () => { describe("systemd-service — unitIsOurs", linuxOnly, () => {
it("is false when no unit exists at all", () => { it("is false when no unit exists at all", () => {
fs.rmSync(svc.unitPath("agenthub"), { force: true }); fs.rmSync(svc.unitPath("agenthub"), { force: true });