/** * `stop` — stop the conductor: pause claiming, disarm ticks, then stop the dispatch daemon. Pane stays up unless --pane is passed. * * Moved out of cli.ts's switch by the per-verb module split (#462); * only the case wrapper, the injected `ctx` lookups and the imports * changed from the original bodies. */ import type { CommandContext } from "./context.ts"; import { buildStopProvenance, liveWorkload, refuseBusyDaemon } from "../stop-provenance.ts"; import { hold, pinPaneHalt, stopConductorPane } from "../fleet.ts"; import { stopDaemon } from "../lifecycle.ts"; import { openStore, dbPath } from "../store.ts"; export async function stopCommand(ctx: CommandContext): Promise { const withPane = ctx.argv.includes("--pane"); const forced = ctx.argv.includes("--force"); const store = openStore(dbPath()); try { // Refusal (#545): the shared daemon serves every configured project, so a // stop orphans any project's live workers — not just the one named. Refuse // while anything is live, naming project + issues, before any hold/disarm/ // pin side-effect, so a refused stop leaves the fleet untouched. `--force` // skips the whole gate (so a wedged daemon stays stoppable even when the // store is unreadable) and the override is recorded in the provenance. if (!forced) refuseBusyDaemon(liveWorkload(store), { override: false, verb: "stop" }); const targets = ctx.targetProjects().map((project) => { // `stop` takes the fleet down, so it always disarms — `--keep-ticks` is a // `hold` affordance. Assert the invariant instead of printing a path that // might not exist. const held = hold(project.name, "halt"); if (held.disarmed === undefined) throw new Error("stop must disarm ticks"); return { project, hold: { ...held, disarmed: held.disarmed }, pin: withPane ? pinPaneHalt(project.name).path : undefined, }; }); // Provenance (#378): the shared daemon serves every configured project, so // the record names the request's own project (or the global scope) AND every // sibling with its live-run count — no silent cross-project stop. A forced // stop keeps the override visible in the control path, never silent. const provenance = buildStopProvenance({ controlPath: forced ? "cli stop --force" : "cli stop", reason: "operator stop", scope: targets.length > 1 ? "global" : "project", project: targets.length === 1 ? targets[0]!.project.name : undefined, }); const stop = await stopDaemon({ provenance, // The durable row is written by the lifecycle at the exact chokepoint — // immediately before systemctl/SIGTERM, with the delivery method that is // about to be used — which is what makes "provenance precedes signalling" // provable rather than claimed. record: (signal) => store.recordDaemonStop(signal.provenance), }); const stopLine = stop.kind === "not-running" ? "daemon was not running" : `daemon stopped — pid ${stop.pid}${stop.via === "systemctl" ? " (via systemctl)" : ""}`; for (const target of targets) { if (target.pin !== undefined) { const pane = await stopConductorPane(target.project.name); process.stdout.write( `stopped — claiming paused; ticks disarmed at ${target.hold.disarmed.path}\n` + `${stopLine}\n` + `pane recovery pinned at ${target.pin}\n` + `pane stop: ${pane.stopped} — ${pane.detail}\n` + ` (conductor agent "${pane.agentName}" only — herdr-fleet.service was NOT stopped;\n` + ` resume clears the pin when you want recovery again)\n`, ); } else { process.stdout.write( `stopped — claiming paused; ticks disarmed at ${target.hold.disarmed.path}\n` + `${stopLine}\n` + `pane left running (pass --pane to stop the conductor agent and pin recovery)\n`, ); } } } finally { store.close(); } }