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