feat(discord): systemd user service with a supervised run; brakes exit 3 and are never retried (#1509)

QUEUE row 17, MVP iteration 2. scripts/discord-service.sh renders and
installs mosaic-discord@<binding> from packages/discord/systemd/. The
unit's main process is `run --supervised`, which applies the new recover
policy first: a lock whose owner is gone is cleared and only the STOP
written for that is removed; an operator STOP or a held binding refuses
with exit 3, which RestartPreventExitStatus never retries. `recover` is
also a CLI verb. First cut used ExecStartPre and looped live, since systemd
honours the never-retry status only from the main process; replaced and
re-verified before any message traffic. Suite 40/40, 95 node tests.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
2026-09-13 14:39:11 -05:00
co-authored by Claude Fable 5.1
parent dc5902aafd
commit 436ba6ed6b
15 changed files with 630 additions and 30 deletions
+37 -7
View File
@@ -1,9 +1,10 @@
#!/usr/bin/env node
// Usage:
// mosaic-discord check <binding> [--config PATH] [--repo PATH]
// mosaic-discord run <binding> [--config PATH] [--repo PATH]
// mosaic-discord run <binding> [--config PATH] [--repo PATH] [--supervised]
// mosaic-discord stop <binding> [--config PATH]
// mosaic-discord unlock <binding> [--config PATH]
// mosaic-discord recover <binding> [--config PATH]
//
// <binding> names <dataRoot>/discord/<binding>.json. The repository wrapper
// is scripts/discord.sh.
@@ -23,8 +24,18 @@
// with unverifiable identity, or recorded in a file it cannot read. `run` never reclaims on its own, and a
// claim that finds STOP after publishing releases itself, so unlock cannot
// race a start. Remove STOP to run again.
// recover: the supervised pre-start. Refuses while STOP is present or the
// binding is held by a live or unverifiable process; clears a lock whose
// owner is gone the way unlock does, then removes the STOP it wrote for
// that, so the run that follows can claim. Never removes a STOP an operator
// wrote. `run --supervised` does the same first thing itself; the service
// unit (scripts/discord-service.sh) uses that form, because systemd only
// honours a never-retry exit status from the main process, not from a
// pre-start command.
//
// Exit codes: 0 ok; 1 operation failed; 2 invalid data or configuration; 4 usage.
// Exit codes: 0 ok; 1 operation failed; 2 invalid data or configuration;
// 3 refused by a brake (STOP present or the binding held; a supervisor must
// not retry); 4 usage.
import { existsSync, mkdirSync, mkdtempSync, writeFileSync, statSync, readdirSync } from "node:fs";
import { join, resolve } from "node:path";
@@ -36,17 +47,18 @@ import { createGateway, CONNECTOR_INTENTS } from "./gateway.mjs";
import { createEngine, buildPiArgs } from "./engine-pi.mjs";
import { assembleContext } from "./context.mjs";
import { createConnector } from "./connector.mjs";
import { ensureJournal, requestStop, stopRequested, readPid, stopTarget, writePid, clearPid, unlock } from "./journal.mjs";
import { ensureJournal, requestStop, stopRequested, readPid, stopTarget, writePid, clearPid, unlock, recover, BRAKE_EXIT } from "./journal.mjs";
const USAGE = [
"usage: mosaic-discord check <binding> [--config PATH] [--repo PATH]",
" mosaic-discord run <binding> [--config PATH] [--repo PATH]",
" mosaic-discord run <binding> [--config PATH] [--repo PATH] [--supervised]",
" mosaic-discord stop <binding> [--config PATH]",
" mosaic-discord unlock <binding> [--config PATH]",
" mosaic-discord recover <binding> [--config PATH]",
].join("\n");
function parse(argv) {
const opts = { command: null, binding: null, config: defaultConfigPath(), repo: process.cwd() };
const opts = { command: null, binding: null, config: defaultConfigPath(), repo: process.cwd(), supervised: false };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === "--config" || a === "--repo") {
@@ -54,14 +66,17 @@ function parse(argv) {
opts[a.slice(2)] = resolve(argv[++i]);
} else if (a === "--help" || a === "-h") {
opts.command = "help";
} else if (a === "--supervised") {
opts.supervised = true;
} else if (a.startsWith("--")) throw new DiscordError(`unknown argument: ${a}\n${USAGE}`, 4);
else if (opts.command === null) opts.command = a;
else if (opts.binding === null) opts.binding = a;
else throw new DiscordError(`unexpected argument: ${a}\n${USAGE}`, 4);
}
if (opts.command === "help") return opts;
if (!["check", "run", "stop", "unlock"].includes(opts.command)) throw new DiscordError(USAGE, 4);
if (!["check", "run", "stop", "unlock", "recover"].includes(opts.command)) throw new DiscordError(USAGE, 4);
if (opts.binding === null) throw new DiscordError(`${opts.command} needs a binding name\n${USAGE}`, 4);
if (opts.supervised && opts.command !== "run") throw new DiscordError(`--supervised applies to run only\n${USAGE}`, 4);
return opts;
}
@@ -136,7 +151,11 @@ async function run(opts) {
const { binding, contextFiles, pi, journalDir, sessionDir } = prepare(opts);
const token = readToken(binding);
ensureJournal(journalDir);
if (stopRequested(journalDir)) throw new DiscordError(`STOP is present in ${journalDir}; remove it to run`, 1);
if (opts.supervised) {
const outcome = recover(journalDir);
if (outcome === "cleared") warn("supervised start: run.lock left by a process that is gone was removed");
}
if (stopRequested(journalDir)) throw new DiscordError(`STOP is present in ${journalDir}; remove it to run`, BRAKE_EXIT);
writePid(journalDir, process.pid);
const cleanupPid = () => clearPid(journalDir, process.pid);
try {
@@ -242,6 +261,16 @@ function unlockCommand(opts) {
say("remove STOP to run again");
}
function recoverCommand(opts) {
const dataRoot = loadDataRoot(opts.config);
const binding = loadBinding(bindingPath(dataRoot, opts.binding));
const journalDir = bindingDataDir(dataRoot, binding.name);
ensureJournal(journalDir);
const outcome = recover(journalDir);
if (outcome === "cleared") say("run.lock left by a process that is gone was removed; STOP is absent; ready to run");
else say("no lock and no STOP; ready to run");
}
async function main() {
const opts = parse(process.argv.slice(2));
if (opts.command === "help") {
@@ -251,6 +280,7 @@ async function main() {
if (opts.command === "check") await check(opts);
else if (opts.command === "run") await run(opts);
else if (opts.command === "unlock") unlockCommand(opts);
else if (opts.command === "recover") recoverCommand(opts);
else stop(opts);
return opts.command === "run" ? null : 0;
}