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:
@@ -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
@@ -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) |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
|
|||||||
Reference in New Issue
Block a user