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:
@@ -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", () => {
|
||||
|
||||
@@ -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, () => {
|
||||
it("is false when no unit exists at all", () => {
|
||||
fs.rmSync(svc.unitPath("agenthub"), { force: true });
|
||||
|
||||
Reference in New Issue
Block a user