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", () => {
|
||||
|
||||
Reference in New Issue
Block a user