omp-conductor 0.15.11 → 0.15.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/REFERENCE.md +107 -60
  2. package/package.json +1 -1
  3. package/schema/config.schema.json +3 -0
  4. package/src/briefs/orchestrator.md +64 -11
  5. package/src/briefs/policy.md +19 -3
  6. package/src/briefs/worker.md +11 -8
  7. package/src/cli.ts +41 -21
  8. package/src/commands/context.ts +102 -1
  9. package/src/commands/doctor.ts +4 -2
  10. package/src/commands/intake.ts +26 -5
  11. package/src/commands/message.ts +80 -32
  12. package/src/commands/report.ts +38 -2
  13. package/src/commands/restart.ts +81 -54
  14. package/src/commands/setup.ts +61 -11
  15. package/src/commands/stop.ts +45 -22
  16. package/src/commands/upgrade-rollback.ts +9 -0
  17. package/src/config-schema.ts +9 -0
  18. package/src/config.ts +35 -1
  19. package/src/daemon.ts +588 -37
  20. package/src/dashboard/app.js +398 -59
  21. package/src/dashboard/index.html +27 -0
  22. package/src/dashboard/server.ts +219 -5
  23. package/src/dashboard/style.css +169 -1
  24. package/src/doctor.ts +419 -45
  25. package/src/escalate.ts +8 -0
  26. package/src/failure-class.ts +37 -0
  27. package/src/fleet.ts +49 -2
  28. package/src/gitops.ts +157 -0
  29. package/src/lifecycle.ts +113 -2
  30. package/src/model-fallback.ts +177 -0
  31. package/src/omp.ts +115 -13
  32. package/src/orchestrator-down.ts +231 -0
  33. package/src/orchestrator-tick.ts +108 -5
  34. package/src/orchestrator.ts +18 -4
  35. package/src/privileged.ts +10 -0
  36. package/src/release-policy.ts +373 -28
  37. package/src/session-host.ts +11 -5
  38. package/src/setup-host.ts +665 -70
  39. package/src/setup-install.ts +275 -28
  40. package/src/setup-wizard.ts +339 -126
  41. package/src/setup.ts +25 -0
  42. package/src/stop-provenance.ts +66 -0
  43. package/src/store.ts +194 -1
  44. package/src/tracker/github.ts +47 -0
  45. package/src/types.ts +182 -0
  46. package/src/upgrade.ts +110 -32
  47. package/src/verbs/protocol.ts +16 -3
  48. package/src/verbs/server.ts +27 -1
  49. package/src/wizard-ui.ts +261 -46
  50. package/src/worker.ts +24 -3
  51. package/systemd/omp-conductor.service.example +7 -3
@@ -222,6 +222,27 @@ export function dispatchInfra(
222
222
  // front of the thrown message, so a bare /^git / would never match a real row.
223
223
  const DISPATCH_GIT_ERROR = /^(?:Error: )?git .+ exited \d+/s;
224
224
 
225
+ /** The prefix the draining/restarting process writes to a run it killed. */
226
+ const ADMIN_RESTART_MARKER = "admin restart:";
227
+
228
+ /**
229
+ * The actor a draining restart recorded on a run it killed, or `undefined`
230
+ * when the row carries no such attribution. Read from `lastError` for the
231
+ * same reason {@link dispatchInfra} is read from it: it is the one surface a
232
+ * run's own row already persists, and the restarting process writes it the
233
+ * way `recordOperatorStop` writes "operator stopped: …". Recognised as an
234
+ * administrative kill, never a charging failure, because nothing the worker
235
+ * did ended it — #512's gap was that a killed session left no attribution.
236
+ */
237
+ export function adminRestartAttribution(lastError: string | undefined): string | undefined {
238
+ if (lastError === undefined) return undefined;
239
+ const marker = lastError.indexOf(ADMIN_RESTART_MARKER);
240
+ if (marker === -1) return undefined;
241
+ const firstLine = lastError.slice(marker + ADMIN_RESTART_MARKER.length).trim().split("\n")[0];
242
+ if (firstLine === undefined || firstLine.trim() === "") return undefined;
243
+ return firstLine.trim();
244
+ }
245
+
225
246
  export function classifyRun(
226
247
  run: RunRecord,
227
248
  facts: ClassifyFacts,
@@ -300,6 +321,22 @@ export function classifyRun(
300
321
  }
301
322
  }
302
323
 
324
+ // A run the draining/restarting process attributed before it restarted the
325
+ // session host: killed under its own ceilings by an administrative action,
326
+ // not by anything the worker did. Costs no budget and requeues, naming the
327
+ // actor (#512). A killed session used to leave no attribution and fell
328
+ // through to `unknown`, charging a failure for an operator's restart.
329
+ if (run.state === "failed" || run.state === "killed") {
330
+ const actor = adminRestartAttribution(run.lastError);
331
+ if (actor !== undefined) {
332
+ return {
333
+ cls: "admin-kill",
334
+ recovery: "requeue",
335
+ evidence: `killed by an administrative restart (${actor}) — an operator's restart, not the run's work`,
336
+ };
337
+ }
338
+ }
339
+
303
340
  if (run.state === "blocked") {
304
341
  return {
305
342
  cls: "question",
package/src/fleet.ts CHANGED
@@ -36,7 +36,7 @@ import { readApprovalSurface, readDaemonProfile } from "./approval-surface.ts";
36
36
  import { inspectBriefLayout } from "./brief-upgrade.ts";
37
37
  import { dbPath, openStore } from "./store.ts";
38
38
  import { renderBriefForProject } from "./setup.ts";
39
- import type { ProjectConfig, Store } from "./types.ts";
39
+ import type { DaemonStop, ProjectConfig, Store } from "./types.ts";
40
40
  import { settlementFlagSummary } from "./diff-flags.ts";
41
41
  import { probeCodeGraph, type CodeGraphHealth } from "./graph-health.ts";
42
42
  import { formatDigestBacklog, formatOpenReports } from "./reports.ts";
@@ -52,6 +52,7 @@ import {
52
52
  statusSnapshot,
53
53
  type StatusSnapshot,
54
54
  } from "./daemon.ts";
55
+ import { formatOrchestratorDown } from "./orchestrator-down.ts";
55
56
  import {
56
57
  healthCheck,
57
58
  isAlive,
@@ -1410,6 +1411,31 @@ function formatDaemonUnit(unit: UnitOwnership | undefined, recordPid: number): s
1410
1411
  }
1411
1412
  }
1412
1413
 
1414
+ /**
1415
+ * The newest daemon stop/restart provenance as status lines (#378).
1416
+ *
1417
+ * Rendered under the daemon block whether the daemon is down or has since
1418
+ * been restarted — the whole point is that the debrief survives the restart.
1419
+ * An unattributed record says so explicitly: the external-signal fallback
1420
+ * records what the receiving daemon knew (that it was unattributed, when,
1421
+ * which projects it served with live counts) rather than guessing a caller.
1422
+ */
1423
+ function formatLastStop(lastStop: DaemonStop | undefined): string[] {
1424
+ if (lastStop === undefined) return [];
1425
+ const caller = lastStop.unattributed
1426
+ ? "unattributed — no mediated request (external signal)"
1427
+ : `pid ${lastStop.callerPid ?? "?"}` +
1428
+ (lastStop.callerUid === undefined ? "" : ` uid ${lastStop.callerUid}`) +
1429
+ (lastStop.role === undefined ? "" : ` (${lastStop.role})`);
1430
+ return [
1431
+ ` last stop ${new Date(lastStop.at).toISOString()} ${lastStop.controlPath}`,
1432
+ ` caller ${caller}`,
1433
+ ` scope ${lastStop.scope}${lastStop.project === undefined ? "" : `: ${lastStop.project}`}`,
1434
+ ` affects ${lastStop.affected.map((a) => `${a.project} (${a.live} live)`).join(", ")}`,
1435
+ ` reason ${lastStop.reason}`,
1436
+ ];
1437
+ }
1438
+
1413
1439
  export function formatFleetStatus(
1414
1440
  s: StatusSnapshot,
1415
1441
  layers: FleetLayers,
@@ -1422,6 +1448,7 @@ export function formatFleetStatus(
1422
1448
  failureClasses: string | undefined = undefined,
1423
1449
  workerPhases: ReadonlyMap<number, WorkerPausePhase> = new Map(),
1424
1450
  intake: string | undefined = undefined,
1451
+ lastStop: DaemonStop | undefined = undefined,
1425
1452
  ): string {
1426
1453
  const tickLine =
1427
1454
  layers.ticksDetail === undefined
@@ -1505,8 +1532,9 @@ export function formatFleetStatus(
1505
1532
  ...(intake === undefined ? [] : [intake]),
1506
1533
  ...(graphBlock === undefined ? [] : [graphBlock]),
1507
1534
  daemonBlock,
1535
+ ...formatLastStop(lastStop),
1508
1536
  "",
1509
- formatProjectBody(s, workerPhases),
1537
+ formatProjectBody(s, workerPhases, now),
1510
1538
  ].join("\n");
1511
1539
  }
1512
1540
 
@@ -1540,12 +1568,18 @@ function formatDigestScheduleStatus(s: StatusSnapshot): string[] {
1540
1568
  function formatProjectBody(
1541
1569
  s: StatusSnapshot,
1542
1570
  workerPhases: ReadonlyMap<number, WorkerPausePhase> = new Map(),
1571
+ now = Date.now(),
1543
1572
  ): string {
1544
1573
  const lines = [
1545
1574
  `project ${s.project}${s.paused ? " (PAUSED)" : ""}`,
1546
1575
  ...(s.pauseReason === undefined ? [] : [`paused ${s.pauseReason}`]),
1547
1576
  `config ${s.configPath}`,
1548
1577
  `state ${s.stateDir}`,
1578
+ // The orchestrator-down degrade row: first-class in the body, present only
1579
+ // while the incident is open, so recovery drops it (#288).
1580
+ ...(s.orchestratorDown === undefined
1581
+ ? []
1582
+ : formatOrchestratorDown(s.orchestratorDown, now)),
1549
1583
  ...formatAvailabilityStatus(s),
1550
1584
  ...formatDigestScheduleStatus(s),
1551
1585
  "",
@@ -1675,6 +1709,18 @@ export async function renderStatus(projectName?: string): Promise<string> {
1675
1709
  const cached = codeGraphFromHealthz(healthBody, project.name);
1676
1710
  const codeGraph = cached ?? (await probeCodeGraph(project));
1677
1711
  const workerPhases = workerPhasesFromHealthz(healthBody, project.name);
1712
+ // The newest host-wide stop/restart provenance (#378). Read here — not in
1713
+ // `statusSnapshot`, which is synchronous and belongs to the daemon module —
1714
+ // and rendered identically from either project: the daemon_stops table is
1715
+ // deliberately not partitioned by project, because the daemon serves every
1716
+ // project and the uninvolved one must see who stopped it too.
1717
+ const store = openStore(dbPath());
1718
+ let lastStop: DaemonStop | undefined;
1719
+ try {
1720
+ lastStop = store.latestDaemonStop();
1721
+ } finally {
1722
+ store.close();
1723
+ }
1678
1724
  return formatFleetStatus(
1679
1725
  { ...s, planUsage, github },
1680
1726
  layers,
@@ -1687,6 +1733,7 @@ export async function renderStatus(projectName?: string): Promise<string> {
1687
1733
  failureClassBlock(project.name),
1688
1734
  workerPhases,
1689
1735
  intakeStatusLine(project.name),
1736
+ lastStop,
1690
1737
  );
1691
1738
  }
1692
1739
 
package/src/gitops.ts CHANGED
@@ -17,6 +17,7 @@
17
17
  * branch cannot disagree.
18
18
  */
19
19
 
20
+ import { existsSync } from "node:fs";
20
21
  import { join } from "node:path";
21
22
 
22
23
  import { parseChainSource, type ChainEntry } from "./chain-check.ts";
@@ -321,3 +322,159 @@ export async function readBaseChain(
321
322
  return { ok: false, stderr: err instanceof Error ? err.message : String(err) };
322
323
  }
323
324
  }
325
+
326
+ // ------------------------------------------- the shared-host critical-base guard
327
+
328
+ /**
329
+ * The stale-base admission verdict (#428): whether a preserved continuation
330
+ * branch may be reattached at all, once a project marks a base commit as
331
+ * critical. Fail closed — a branch that cannot be *proven* to contain every
332
+ * marker is refused.
333
+ */
334
+ export type CriticalBaseVerdict =
335
+ /** No preserved branch for this issue in the mirror — a clean first attempt. */
336
+ | { state: "no-branch" }
337
+ /** The reattach source contains every configured marker. */
338
+ | { state: "fresh" }
339
+ /** The reattach source predates a marker and cannot be safely advanced to it,
340
+ * so the dispatcher must not reattach it. `range` names the base commits
341
+ * the branch has not absorbed, for the hold's recovery wording. */
342
+ | { state: "stale"; marker: string; range: string[] }
343
+ /** The marker could not be verified at all (unresolvable, fetch failed). */
344
+ | { state: "unknown"; error: string };
345
+
346
+ /** The stubborn-session view of the probe, injectable for deterministic tests. */
347
+ export type CriticalBaseProbe = (
348
+ repo: RepoTarget,
349
+ markers: readonly string[],
350
+ branch: string,
351
+ ) => Promise<CriticalBaseVerdict>;
352
+
353
+ /** The bounded sample of base commits a held branch is missing. */
354
+ const CRITICAL_BASE_RANGE_MAX = 8;
355
+
356
+ /**
357
+ * Answer whether the preserved branch {@link addRunRepo} would reattach
358
+ * contains every configured critical-base marker (#428). A base safety fix
359
+ * protects only branches forked after it landed; a continuation forked before
360
+ * it still carries the dangerous code, and re-running the lifecycle suite it
361
+ * retains on a shared host is what SIGTERMed the production daemon.
362
+ *
363
+ * The reattach source is the mirror's `refs/heads/<branch>`. When it lacks a
364
+ * marker but the live remote branch now carries it and the reattach source is
365
+ * that live head's ancestor, the operator advanced the branch on GitHub (e.g.
366
+ * merged base into the PR branch): the fast-forward is folded into the mirror
367
+ * — safe and work-preserving, the same reconcile `pushRunBranch` performs —
368
+ * and the marker is accepted. Judged against the reattach source, never the
369
+ * live head alone, so a stale local copy cannot smuggle pre-fix code back in.
370
+ */
371
+ export async function probeCriticalBase(
372
+ project: Pick<ProjectConfig, "mirrorRoot">,
373
+ repo: RepoTarget,
374
+ branch: string,
375
+ markers: readonly string[],
376
+ exec: Exec = spawnCaptured,
377
+ ): Promise<CriticalBaseVerdict> {
378
+ const mirror = mirrorPath(project, repo);
379
+ // A mirror is where preserved branches live; without one there is nothing to
380
+ // reattach. Judged "no branch" rather than "unknown" so a fleet that has not
381
+ // yet created a mirror is not deadlocked at admission.
382
+ if (!existsSync(mirror)) return { state: "no-branch" };
383
+ const branchRef = `refs/heads/${branch}`;
384
+ const env = credentialedEnv();
385
+
386
+ const present = await exec(
387
+ ["git", "--git-dir", mirror, "show-ref", "--verify", "--quiet", branchRef],
388
+ { env },
389
+ );
390
+ if (present.code !== 0) return { state: "no-branch" };
391
+
392
+ // The configured marker is a commit that landed on base, so base has to be
393
+ // resolvable to prove anything about the branch against it. Fetch today's
394
+ // base tip into the mirror's tracking refs first, so the judgement is against
395
+ // the current base rather than whatever a run last left behind.
396
+ const baseRef = `refs/remotes/origin/${repo.defaultBranch}`;
397
+ const fetched = await exec(
398
+ ["git", "--git-dir", mirror, "fetch", "--no-tags", "origin", `+refs/heads/${repo.defaultBranch}:${baseRef}`],
399
+ { env },
400
+ );
401
+ if (fetched.code !== 0) {
402
+ return {
403
+ state: "unknown",
404
+ error: scrubUserinfo(fetched.stderr.trim() || fetched.stdout.trim() || `git fetch exited ${String(fetched.code)}`),
405
+ };
406
+ }
407
+
408
+ for (const marker of markers) {
409
+ const resolved = await exec(
410
+ ["git", "--git-dir", mirror, "rev-parse", "--verify", `${marker}^{commit}`],
411
+ { env },
412
+ );
413
+ if (resolved.code !== 0) {
414
+ return {
415
+ state: "unknown",
416
+ error: `critical-base marker "${marker}" is not resolvable in the ${repo.name} mirror`,
417
+ };
418
+ }
419
+ const markerSha = resolved.stdout.trim();
420
+ if (markerSha === "") {
421
+ return { state: "unknown", error: `critical-base marker "${marker}" resolved to no commit` };
422
+ }
423
+
424
+ const localHas = await exec(
425
+ ["git", "--git-dir", mirror, "merge-base", "--is-ancestor", markerSha, branchRef],
426
+ { env },
427
+ );
428
+ if (localHas.code === 0) continue;
429
+
430
+ // The reattach source lacks the marker. If the live branch now carries it
431
+ // and the reattach source is its ancestor, the operator advanced the branch
432
+ // on GitHub (e.g. merged base into the PR branch): fold that fast-forward
433
+ // into the reattach source — safe, work-preserving — and accept the marker.
434
+ const tracked = `refs/remotes/origin/${branch}`;
435
+ const liveFetched = await exec(
436
+ ["git", "--git-dir", mirror, "fetch", "--no-tags", "origin", `+refs/heads/${branch}:${tracked}`],
437
+ { env },
438
+ );
439
+ if (liveFetched.code !== 0) {
440
+ return {
441
+ state: "unknown",
442
+ error: scrubUserinfo(liveFetched.stderr.trim() || liveFetched.stdout.trim() || `git fetch ${branch} exited ${String(liveFetched.code)}`),
443
+ };
444
+ }
445
+ const liveHas = await exec(
446
+ ["git", "--git-dir", mirror, "merge-base", "--is-ancestor", markerSha, tracked],
447
+ { env },
448
+ );
449
+ if (liveHas.code === 0) {
450
+ const localIsAncestor = await exec(
451
+ ["git", "--git-dir", mirror, "merge-base", "--is-ancestor", branchRef, tracked],
452
+ { env },
453
+ );
454
+ if (localIsAncestor.code === 0) {
455
+ await exec(["git", "--git-dir", mirror, "fetch", "--no-tags", "origin", `+${branchRef}:${branchRef}`], { env });
456
+ continue;
457
+ }
458
+ }
459
+
460
+ // Predates the marker and cannot be safely advanced to it. Name the base
461
+ // commits the branch has not absorbed so the hold's recovery is concrete.
462
+ const range: string[] = [];
463
+ const missing = await exec(
464
+ ["git", "--git-dir", mirror, "log", "--oneline", "--format=%h", `${branchRef}..${baseRef}`],
465
+ { env },
466
+ );
467
+ if (missing.code === 0) {
468
+ range.push(
469
+ ...missing.stdout
470
+ .trim()
471
+ .split("\n")
472
+ .filter((line) => line.length > 0)
473
+ .slice(0, CRITICAL_BASE_RANGE_MAX),
474
+ );
475
+ }
476
+ return { state: "stale", marker: markerSha, range };
477
+ }
478
+
479
+ return { state: "fresh" };
480
+ }
package/src/lifecycle.ts CHANGED
@@ -25,6 +25,10 @@ import { spawn, spawnSync } from "node:child_process";
25
25
  import { chmodSync, closeSync, mkdirSync, openSync, readFileSync, readSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
26
26
  import { homedir } from "node:os";
27
27
  import { join } from "node:path";
28
+ // Type-only: erased at runtime, so the "free of every other module" property
29
+ // below survives — this module still opens no store, loads no config and runs
30
+ // no `gh`, and `stop`/`status` keep working when the config is broken.
31
+ import type { DaemonStopDraft } from "./types.ts";
28
32
 
29
33
  /**
30
34
  * The systemd unit name operators are expected to install for a supervised
@@ -480,6 +484,58 @@ export async function startDaemon(
480
484
  );
481
485
  }
482
486
 
487
+ /**
488
+ * The delivery facts a mediated stop/restart records at the exact chokepoint:
489
+ * the request's own provenance, how the stop is about to be delivered, and the
490
+ * daemon pid it targets (#378). The recorder is injected by the CLI callers
491
+ * (which own the store); this module stays free of it.
492
+ */
493
+ export interface StopDelivery {
494
+ /** The request facts built by the CLI (commands/stop.ts, commands/restart.ts). */
495
+ provenance: DaemonStopDraft;
496
+ /** Whether the stop is about to go through `systemctl` or a raw signal. */
497
+ via: "systemctl" | "signal";
498
+ /** The daemon pid being stopped, when one was known. */
499
+ pid?: number;
500
+ }
501
+
502
+ /** The injected recorder invoked immediately before a stop/restart is signalled. */
503
+ export type StopDeliveryFn = (stop: StopDelivery) => void;
504
+
505
+ /**
506
+ * The honest provenance for a daemon that received a stop with no mediated
507
+ * request anywhere: unattributed by construction — Linux exposes no sender
508
+ * identity for a signal, so the record must say "unattributed" rather than
509
+ * guess one — plus what the receiving process actually knows: its own pid,
510
+ * the moment (stamped by the store), the runtime directory, and the projects
511
+ * it serves with their live-run counts.
512
+ *
513
+ * Storage belongs at the caller: the daemon's SIGINT/SIGTERM path writes the
514
+ * returned draft through the store before/while draining. This builds the
515
+ * facts, store-free, so a raw `kill`/out-of-band `systemctl stop` — no
516
+ * conductor CLI in the delivery path at all — still leaves a durable row the
517
+ * next `omp-conductor status` can show.
518
+ */
519
+ export function externalStopProvenance(o: {
520
+ /** The daemon pid that received the signal, when known. */
521
+ daemonPid?: number;
522
+ /** The daemon's runtime directory (host paths only — never secrets). */
523
+ runtimeDir: string;
524
+ /** Every served project with its live-run count at signal time. */
525
+ affected: { project: string; live: number }[];
526
+ reason?: string;
527
+ }): DaemonStopDraft {
528
+ return {
529
+ controlPath: "external signal",
530
+ scope: "global",
531
+ ...(o.daemonPid === undefined ? {} : { daemonPid: o.daemonPid }),
532
+ runtimeDir: o.runtimeDir,
533
+ affected: o.affected,
534
+ reason: o.reason ?? "external signal — no mediated stop request was recorded",
535
+ unattributed: true,
536
+ };
537
+ }
538
+
483
539
  /**
484
540
  * How the last stop actually landed. Callers print this so an operator can
485
541
  * tell a supervised stop from a bare SIGTERM without reading the journal.
@@ -510,7 +566,35 @@ export interface RestartResult {
510
566
  * a stopped unit). The grace period is a deadline, not a clean drain: a tick
511
567
  * with a worker in flight can run for that worker's whole wall clock.
512
568
  */
513
- export async function stopDaemon(o: { timeoutMs?: number } = {}): Promise<StopResult> {
569
+ export async function stopDaemon(
570
+ o: {
571
+ timeoutMs?: number;
572
+ /**
573
+ * The request facts for this stop, when a mediated caller carries them.
574
+ * With `record`, the durable row is written immediately before the signal
575
+ * is sent — the exact ordering "provenance precedes signalling" refers to
576
+ * — and only when a stop actually lands: a request that finds no daemon
577
+ * stops nothing and records nothing. Drain-style callers whose restart may
578
+ * never execute additionally record the request at entry themselves.
579
+ */
580
+ provenance?: DaemonStopDraft;
581
+ /** Invoked just before the daemon is signalled, with the delivery method. */
582
+ record?: StopDeliveryFn;
583
+ } = {},
584
+ ): Promise<StopResult> {
585
+ const recordStop = (via: "systemctl" | "signal", pid: number | undefined): void => {
586
+ if (o.provenance === undefined || o.record === undefined) return;
587
+ o.record({
588
+ provenance: {
589
+ ...o.provenance,
590
+ controlPath: `${o.provenance.controlPath} via ${via}`,
591
+ ...(pid === undefined ? {} : { daemonPid: pid }),
592
+ },
593
+ via,
594
+ pid,
595
+ });
596
+ };
597
+
514
598
  const rec = livingDaemon();
515
599
  if (rec === undefined) {
516
600
  // `livingDaemon` already cleared a stale file; this covers the unparseable
@@ -521,6 +605,7 @@ export async function stopDaemon(o: { timeoutMs?: number } = {}): Promise<StopRe
521
605
  throw new Error(ownershipUnknown("stop", decision.reason));
522
606
  }
523
607
  if (decision.kind === "stop") {
608
+ recordStop("systemctl", decision.mainPid);
524
609
  await runSystemdStop(decision.mainPid, o.timeoutMs);
525
610
  clearRecord();
526
611
  return { kind: "stopped", pid: decision.mainPid, via: "systemctl" };
@@ -536,6 +621,7 @@ export async function stopDaemon(o: { timeoutMs?: number } = {}): Promise<StopRe
536
621
  throw new Error(ownershipUnknown("stop", decision.reason));
537
622
  }
538
623
  if (decision.kind === "stop") {
624
+ recordStop("systemctl", rec.pid);
539
625
  await runSystemdStop(rec.pid, o.timeoutMs);
540
626
  clearRecord();
541
627
  return { kind: "stopped", pid: rec.pid, via: "systemctl" };
@@ -544,6 +630,7 @@ export async function stopDaemon(o: { timeoutMs?: number } = {}): Promise<StopRe
544
630
  // decision.kind === "not-ours": confirmed no unit, inactive unit, no systemd
545
631
  // binary, or a unit whose MainPID is somebody else. Only a *confirmed*
546
632
  // negative is safe to signal.
633
+ recordStop("signal", rec.pid);
547
634
  const gone = await terminate(rec.pid, o.timeoutMs ?? STOP_TIMEOUT_MS);
548
635
  if (!gone) {
549
636
  // The record stays: something is still holding that pid, and forgetting
@@ -577,8 +664,29 @@ export async function stopDaemon(o: { timeoutMs?: number } = {}): Promise<StopRe
577
664
  * Returns the record of the process that is now answering `/healthz`.
578
665
  */
579
666
  export async function restartDaemon(
580
- o: { port?: number; project?: string; timeoutMs?: number } = {},
667
+ o: {
668
+ port?: number;
669
+ project?: string;
670
+ timeoutMs?: number;
671
+ /** Request facts and recorder, same semantics as {@link stopDaemon}: the
672
+ * durable row is written immediately before the restarting signal. */
673
+ provenance?: DaemonStopDraft;
674
+ record?: StopDeliveryFn;
675
+ } = {},
581
676
  ): Promise<RestartResult> {
677
+ const recordStop = (via: "systemctl" | "signal", pid: number | undefined): void => {
678
+ if (o.provenance === undefined || o.record === undefined) return;
679
+ o.record({
680
+ provenance: {
681
+ ...o.provenance,
682
+ controlPath: `${o.provenance.controlPath} via ${via}`,
683
+ ...(pid === undefined ? {} : { daemonPid: pid }),
684
+ },
685
+ via,
686
+ pid,
687
+ });
688
+ };
689
+
582
690
  const previous = livingDaemon();
583
691
  const ownership = probeUnit();
584
692
  if (ownership.kind === "unknown") {
@@ -591,6 +699,7 @@ export async function restartDaemon(
591
699
  // startDaemon() — that leaves the unit failed while handing the operator
592
700
  // a healthy-looking but unmanaged pid (the 12:17Z incident in #376).
593
701
  // Any manager refusal is terminal; so is unproven ownership afterwards.
702
+ recordStop("systemctl", previous?.pid);
594
703
  const record = await restoreFailedUnit(o.timeoutMs, o.project);
595
704
  return { previous, record, via: "systemctl" };
596
705
  }
@@ -602,6 +711,7 @@ export async function restartDaemon(
602
711
  // Ownership is proven. A refused/timed-out restart must not fall through
603
712
  // to stopDaemon's signal path — that is the exact bounce this module exists
604
713
  // to prevent (SIGTERM → exit 143 → Restart=on-failure → new MainPID).
714
+ recordStop("systemctl", ownership.pid);
605
715
  const ran = systemctl(["restart", SYSTEMD_UNIT]);
606
716
  if (!ran.ok) {
607
717
  throw new Error(systemctlFailure("restart", ran));
@@ -615,6 +725,7 @@ export async function restartDaemon(
615
725
 
616
726
  // Confirmed unmanaged: no unit, an inactive unit, or a unit whose MainPID
617
727
  // is somebody else. The detached CLI daemon is the only path left.
728
+ recordStop("signal", previous?.pid);
618
729
  await stopDaemon({ timeoutMs: o.timeoutMs });
619
730
  const record = await startDaemon({ port: o.port ?? previous?.port, project: o.project ?? previous?.project });
620
731
  return { previous, record, via: "cli" };