omp-conductor 0.15.9 → 0.15.11

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 (86) hide show
  1. package/README.md +273 -2543
  2. package/REFERENCE.md +2638 -0
  3. package/package.json +3 -2
  4. package/schema/config.schema.json +8 -23
  5. package/src/arm-challenge.ts +112 -0
  6. package/src/ask.ts +434 -0
  7. package/src/board.ts +81 -15
  8. package/src/brief-upgrade.ts +114 -8
  9. package/src/briefs/orchestrator.md +55 -29
  10. package/src/briefs/policy.md +14 -5
  11. package/src/briefs/worker.md +7 -1
  12. package/src/chain-check.ts +1 -1
  13. package/src/check-trailing-newlines.ts +82 -0
  14. package/src/cli.ts +190 -1391
  15. package/src/commands/arm.ts +21 -0
  16. package/src/commands/board.ts +23 -0
  17. package/src/commands/brief-upgrade.ts +186 -0
  18. package/src/commands/context.ts +49 -0
  19. package/src/commands/daemon.ts +71 -0
  20. package/src/commands/dashboard.ts +74 -0
  21. package/src/commands/decision.ts +103 -0
  22. package/src/commands/disarm.ts +21 -0
  23. package/src/commands/doctor.ts +98 -0
  24. package/src/commands/event.ts +62 -0
  25. package/src/commands/extend.ts +64 -0
  26. package/src/commands/friction.ts +56 -0
  27. package/src/commands/help.ts +9 -0
  28. package/src/commands/hold.ts +26 -0
  29. package/src/commands/intake.ts +134 -0
  30. package/src/commands/ledger.ts +69 -0
  31. package/src/commands/message.ts +48 -0
  32. package/src/commands/report.ts +170 -0
  33. package/src/commands/restart.ts +76 -0
  34. package/src/commands/resume.ts +58 -0
  35. package/src/commands/setup.ts +93 -0
  36. package/src/commands/start.ts +23 -0
  37. package/src/commands/stats.ts +131 -0
  38. package/src/commands/status.ts +48 -0
  39. package/src/commands/stop.ts +51 -0
  40. package/src/commands/tail.ts +109 -0
  41. package/src/commands/unblock.ts +39 -0
  42. package/src/commands/upgrade-install.ts +31 -0
  43. package/src/commands/upgrade-rollback.ts +23 -0
  44. package/src/commands/upgrade.ts +25 -0
  45. package/src/commands/verb.ts +83 -0
  46. package/src/commands/version.ts +30 -0
  47. package/src/commands/worker.ts +100 -0
  48. package/src/config-schema.ts +38 -1
  49. package/src/config.ts +10 -3
  50. package/src/daemon.ts +613 -94
  51. package/src/dashboard/app.js +120 -0
  52. package/src/dashboard/index.html +34 -0
  53. package/src/dashboard/server.ts +267 -0
  54. package/src/dashboard/style.css +180 -0
  55. package/src/decisions.ts +39 -14
  56. package/src/diff-flags.ts +131 -241
  57. package/src/doctor.ts +795 -0
  58. package/src/escalate.ts +60 -19
  59. package/src/failure-class.ts +29 -3
  60. package/src/fleet.ts +58 -1
  61. package/src/graph-health.ts +1 -1
  62. package/src/label-projection.ts +1 -1
  63. package/src/lifecycle.ts +198 -2
  64. package/src/notices.ts +9 -0
  65. package/src/omp.ts +2 -0
  66. package/src/orchestrator-tick.ts +315 -17
  67. package/src/release-policy.ts +135 -23
  68. package/src/reports.ts +19 -5
  69. package/src/setup-host.ts +420 -8
  70. package/src/setup-install.ts +69 -14
  71. package/src/setup-wizard.ts +199 -61
  72. package/src/setup.ts +131 -35
  73. package/src/stats.ts +331 -0
  74. package/src/store.ts +206 -21
  75. package/src/tracker/github.ts +27 -4
  76. package/src/types.ts +144 -31
  77. package/src/unblock.ts +55 -11
  78. package/src/upgrade-journal.ts +220 -0
  79. package/src/upgrade-verify.ts +506 -0
  80. package/src/upgrade.ts +295 -26
  81. package/src/verbs/actions.ts +73 -1
  82. package/src/verbs/protocol.ts +29 -4
  83. package/src/verbs/server.ts +183 -20
  84. package/systemd/omp-conductor-recover.sh +433 -0
  85. package/systemd/omp-conductor.service.example +7 -0
  86. package/systemd/recover-unit-test.sh +428 -0
package/src/setup-host.ts CHANGED
@@ -1,10 +1,16 @@
1
- import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
1
+ import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync, type Stats } from "node:fs";
2
2
  import { spawnSync } from "node:child_process";
3
3
  import { homedir, userInfo } from "node:os";
4
- import { dirname, join } from "node:path";
4
+ import { dirname, join, relative } from "node:path";
5
5
  import { configPath, loadConfig, resolveCaps, stateDir } from "./config.ts";
6
6
  import { isPaused, runDaemon, statusSnapshot, type StatusSnapshot } from "./daemon.ts";
7
- import { DEFAULT_FLEET_AGENT_NAME, DEFAULT_HERDR_SESSION, resolveHerdrSessionWithBridge } from "./fleet.ts";
7
+ import { ORCHESTRATOR_BRIEF_NAME } from "./brief-upgrade.ts";
8
+ import {
9
+ DEFAULT_FLEET_AGENT_NAME,
10
+ DEFAULT_HERDR_SESSION,
11
+ DEFAULT_HERDR_UNIT,
12
+ resolveHerdrSessionWithBridge,
13
+ } from "./fleet.ts";
8
14
  import {
9
15
  DEFAULT_PORT,
10
16
  healthCheck,
@@ -27,6 +33,34 @@ export const DEFAULT_TICK_INTERVAL_SECONDS = 900;
27
33
  export const STAGED_SERVICE_NAME = "omp-conductor.service";
28
34
  export const SYSTEMD_UNIT_DIR = "/etc/systemd/system";
29
35
 
36
+ /**
37
+ * The one recovery unit every fleet unit's `OnFailure=` points at (#485).
38
+ *
39
+ * A plain (non-template) unit on purpose: both fleet units name the same
40
+ * oneshot, and the playbook it runs determines which unit failed from live
41
+ * systemd state rather than trusting an instance name it cannot verify. The
42
+ * unit itself never carries `OnFailure=` — the one hard bound on
43
+ * recovery-triggering-recovery.
44
+ */
45
+ export const RECOVER_SERVICE_NAME = "omp-conductor-recover.service";
46
+
47
+ /** The playbook file shipped in the package's `systemd/` directory. */
48
+ const RECOVER_SCRIPT_FILE = "omp-conductor-recover.sh";
49
+
50
+ /**
51
+ * Where `setup host` installs the recovery playbook so the unit's ExecStart is
52
+ * a stable absolute path that survives package upgrades. /usr/local/sbin is
53
+ * root-executed operational tooling, on PATH for root on every target host.
54
+ */
55
+ export const RECOVER_SCRIPT_INSTALL_PATH = "/usr/local/sbin/omp-conductor-recover";
56
+
57
+ /**
58
+ * The symlink the session cwd loads as its brief. omp auto-loads `AGENTS.md`
59
+ * from the session cwd, so the fleet pane's cwd needs a link at this name
60
+ * resolving to the composed {@link ORCHESTRATOR_BRIEF_NAME}.
61
+ */
62
+ export const AGENTS_BRIEF_NAME = "AGENTS.md";
63
+
30
64
  export type PlannedWrite<T> = {
31
65
  path: string;
32
66
  action: "create" | "update" | "keep";
@@ -34,9 +68,59 @@ export type PlannedWrite<T> = {
34
68
  value: T;
35
69
  };
36
70
 
71
+ /**
72
+ * The `AGENTS.md` symlink setup places in a project's fleet cwd so the pane's
73
+ * session auto-loads the composed brief instead of staying brief-less.
74
+ *
75
+ * {@link action} is what the plan will *do*, compared against what sits at
76
+ * {@link path}:
77
+ * - `create` — no entry yet, link it;
78
+ * - `update` — a symlink occupies {@link path} but points elsewhere, replace it;
79
+ * - `keep` — an already-correct symlink, leave untouched (idempotent re-run);
80
+ * - `skip` — a regular file occupies {@link path}, never overwrite an
81
+ * operator's file (see {@link skippedReason}).
82
+ */
83
+ export interface BriefLinkPlan {
84
+ /** `AGENTS.md` inside the project's fleet cwd ({@link tickCwdForProject}). */
85
+ path: string;
86
+ /** Relative target inside the symlink — what the link resolves to. */
87
+ target: string;
88
+ action: "create" | "update" | "keep" | "skip";
89
+ /** Why {@link action} is `skip`; present only then. */
90
+ skippedReason?: string;
91
+ }
92
+
37
93
  export interface HostRuntimePlan {
38
94
  service: PlannedWrite<string>;
95
+ /**
96
+ * The staged Herdr session-server unit. Absent when Herdr is not installed
97
+ * ({@link ServiceRuntime.herdr} is `undefined`) — a host driving Herdr some
98
+ * other way must stay supported. `upgrade` and `start` treat
99
+ * {@link DEFAULT_HERDR_UNIT} as a first-class dependency, and this is the
100
+ * rendering that provisions it (see {@link renderHerdrUnit}).
101
+ */
102
+ herdrUnit?: PlannedWrite<string>;
103
+ /**
104
+ * The fleet recovery oneshot (#485): staged always, because the daemon unit
105
+ * (which every fleet has) carries `OnFailure=` to it. Rendered by
106
+ * {@link renderRecoverUnit}.
107
+ */
108
+ recoverUnit: PlannedWrite<string>;
109
+ /**
110
+ * The recovery playbook body, staged from the shipped package file and
111
+ * installed to {@link RECOVER_SCRIPT_INSTALL_PATH} so the unit's ExecStart
112
+ * is a stable absolute path.
113
+ */
114
+ recoverScript: PlannedWrite<string>;
115
+ /**
116
+ * `[terminal] default_shell` staged into the session's herdr config, the
117
+ * belt-and-braces behind the unit's `SHELL=` (see {@link renderHerdrConfig}).
118
+ * Present exactly when {@link herdrUnit} is.
119
+ */
120
+ herdrConfig?: PlannedWrite<string>;
39
121
  tick?: PlannedWrite<TickConfig>;
122
+ /** The `AGENTS.md` — composed-brief symlink — placed in the fleet cwd. */
123
+ briefLink: BriefLinkPlan;
40
124
  installCommands: readonly string[];
41
125
  cliSource: "global" | "plugin";
42
126
  /** Absolute path of the unit systemd actually reads. */
@@ -65,6 +149,21 @@ export interface ServiceRuntime {
65
149
  packageCli: string;
66
150
  conductorHome: string;
67
151
  telegramStateDir: string;
152
+ /**
153
+ * The account's login shell. Rendered into the Herdr session unit as
154
+ * `Environment=SHELL=` (and into the herdr config as `[terminal]
155
+ * default_shell`), so every pane in the fleet session runs this shell instead
156
+ * of the `/bin/sh` Herdr falls back to when `$SHELL` is unset (the fleet's
157
+ * dash-pane symptom, #456). A systemd service has no `$SHELL`, so this is
158
+ * load-bearing, not decoration.
159
+ */
160
+ shell: string;
161
+ /**
162
+ * Absolute path of the `herdr` binary on this host, or `undefined` when Herdr
163
+ * is not installed. When absent, setup stages no Herdr unit and no pane-shell
164
+ * config — a host driving Herdr some other way stays supported (#456).
165
+ */
166
+ herdr?: string;
68
167
  /**
69
168
  * Non-default Herdr session to pin into the unit (#394). Omitted when the
70
169
  * host uses {@link DEFAULT_HERDR_SESSION}: an always-present line would show
@@ -252,6 +351,8 @@ function defaultServiceRuntime(
252
351
  const home = homedir();
253
352
  const bun = process.execPath;
254
353
  const globalCli = Bun.which("omp-conductor");
354
+ const herdr = Bun.which("herdr");
355
+ const loginShell = userInfo().shell;
255
356
  const pathParts = [dirname(bun), ...(process.env["PATH"] ?? "").split(":")].filter(
256
357
  (value, index, all) => value.length > 0 && all.indexOf(value) === index,
257
358
  );
@@ -264,6 +365,10 @@ function defaultServiceRuntime(
264
365
  packageCli: join(import.meta.dir, "cli.ts"),
265
366
  conductorHome: dirname(configPath()),
266
367
  telegramStateDir,
368
+ shell: loginShell ?? "/bin/sh",
369
+ // Only when Herdr is actually installed: a host running Herdr some other
370
+ // way (or none yet) gets no Herdr unit rendered, ever.
371
+ ...(herdr === null ? {} : { herdr }),
267
372
  // Only pin a non-default session. Default hosts keep a unit with no
268
373
  // HERDR_SESSION line so setup/upgrade do not invent drift (#394).
269
374
  ...(herdrSession !== DEFAULT_HERDR_SESSION ? { herdrSession } : {}),
@@ -289,6 +394,10 @@ export function renderDaemonService(runtime: ServiceRuntime, totalWorkers: numbe
289
394
  "Documentation=https://github.com/TerrifiedBug/conductor",
290
395
  "After=network-online.target",
291
396
  "Wants=network-online.target",
397
+ // #485: a crash-looped daemon used to sit in `failed` indefinitely, in
398
+ // silence — no unit was watching it. The recovery oneshot runs outside
399
+ // this unit's cgroup, collects evidence durably, and pages tier-2.
400
+ `OnFailure=${RECOVER_SERVICE_NAME}`,
292
401
  "",
293
402
  "[Service]",
294
403
  "Type=simple",
@@ -315,6 +424,122 @@ export function renderDaemonService(runtime: ServiceRuntime, totalWorkers: numbe
315
424
  ].join("\n");
316
425
  }
317
426
 
427
+ /**
428
+ * The Herdr session-server unit, in the same shape as the daemon unit.
429
+ *
430
+ * The fleet's 24/7 guarantee (`upgrade`'s restart + pane-recovery leg, `start`,
431
+ * the recovery plugin) treats {@link DEFAULT_HERDR_UNIT} as a first-class
432
+ * dependency, yet nothing rendered it — every operator hand-wrote it from a bare
433
+ * `ExecStart=` line. This provisions it, and it is where the dash-pane bug dies:
434
+ * a systemd service has no `$SHELL`, so without `Environment=SHELL=` Herdr
435
+ * resolved every pane as `/bin/sh`→dash with no readline and no `.bashrc`. The
436
+ * account's login shell is pinned here, and again in the herdr config
437
+ * ({@link renderHerdrConfig}) as belt and braces.
438
+ */
439
+ export function renderHerdrUnit(runtime: ServiceRuntime): string {
440
+ if (runtime.herdr === undefined) throw new Error("cannot render a herdr unit without a herdr binary");
441
+ const session = runtime.herdrSession ?? DEFAULT_HERDR_SESSION;
442
+ const configPath = join(runtime.home, ".config", "herdr", "config.toml");
443
+ return [
444
+ "[Unit]",
445
+ "Description=herdr fleet session server",
446
+ "Documentation=https://github.com/TerrifiedBug/conductor",
447
+ "After=network-online.target",
448
+ "Wants=network-online.target",
449
+ // #485: the fleet's two units are each other's only plausible watcher and
450
+ // neither watched the other. The recovery oneshot is the watcher, and it
451
+ // must fire for the orchestrator host too — a dead herdr means no tick, so
452
+ // nobody else would ever report it.
453
+ `OnFailure=${RECOVER_SERVICE_NAME}`,
454
+ "",
455
+ "[Service]",
456
+ "Type=simple",
457
+ `User=${runtime.username}`,
458
+ `Environment=${systemdQuote(`HOME=${runtime.home}`)}`,
459
+ `Environment=${systemdQuote(`PATH=${runtime.path}`)}`,
460
+ `Environment=${systemdQuote(`HERDR_CONFIG_PATH=${configPath}`)}`,
461
+ `Environment=${systemdQuote(`SHELL=${runtime.shell}`)}`,
462
+ `WorkingDirectory=${systemdPath(runtime.conductorHome)}`,
463
+ `ExecStart=${systemdQuote(runtime.herdr)} --session ${systemdQuote(session)} server`,
464
+ "Restart=on-failure",
465
+ "RestartSec=5",
466
+ "",
467
+ "[Install]",
468
+ "WantedBy=multi-user.target",
469
+ "",
470
+ ].join("\n");
471
+ }
472
+
473
+ /**
474
+ * The fleet recovery unit (#485): a oneshot every fleet unit's `OnFailure=`
475
+ * names.
476
+ *
477
+ * It runs as root, outside the fleet units' cgroups and process trees, so the
478
+ * restart it performs can never take it down, and it carries an explicit PATH —
479
+ * on a typical host the non-interactive ssh PATH lacks `omp`, `herdr` and
480
+ * `omp-conductor`, and the playbook needs all three.
481
+ *
482
+ * The bounds are part of the unit text so `doctor` drift sees them:
483
+ * `StartLimitIntervalSec`/`StartLimitBurst` cap failed recovery starts at the
484
+ * systemd layer (the playbook keeps its own durable consecutive-attempt
485
+ * counter, agreeing on the same window), and there is deliberately **no**
486
+ * `OnFailure=` of its own and no `[Install]` — nothing activates recovery
487
+ * except the two fleet units' failure, so recovery can never re-trigger
488
+ * recovery.
489
+ */
490
+ export function renderRecoverUnit(runtime: ServiceRuntime, projectName: string): string {
491
+ return [
492
+ "[Unit]",
493
+ "Description=omp-conductor fleet recovery (OnFailure handler)",
494
+ "Documentation=https://github.com/TerrifiedBug/conductor",
495
+ // The oneshot bound: a recovery that fails twice in the window stops
496
+ // trying, and the playbook's durable counter uses the same window.
497
+ "StartLimitIntervalSec=1800",
498
+ "StartLimitBurst=2",
499
+ "",
500
+ "[Service]",
501
+ "Type=oneshot",
502
+ `Environment=${systemdQuote(`PATH=${runtime.path}`)}`,
503
+ `Environment=${systemdQuote(`OMP_CONDUCTOR_HOME=${runtime.conductorHome}`)}`,
504
+ `Environment=${systemdQuote(`RECOVER_STATE_DIR=${stateDir()}`)}`,
505
+ `Environment=${systemdQuote(`RECOVER_PROJECT=${projectName}`)}`,
506
+ `ExecStart=${RECOVER_SCRIPT_INSTALL_PATH}`,
507
+ "",
508
+ ].join("\n");
509
+ }
510
+
511
+ /**
512
+ * Idempotently put `[terminal] default_shell` into a herdr config, preserving
513
+ * everything else verbatim.
514
+ *
515
+ * Belt-and-braces behind the unit's `Environment=SHELL=`: a pane comes up as the
516
+ * account's login shell even if an operator later edits the unit and drops the
517
+ * `SHELL` line. Herdr falls back `$SHELL → /bin/sh` and skips the passwd entry,
518
+ * so without this the only guarantee would be the unit's environment.
519
+ */
520
+ export function renderHerdrConfig(shell: string, existing?: string): string {
521
+ const quoted = `"${shell.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
522
+ const text = existing ?? "";
523
+ const line = `default_shell = ${quoted}`;
524
+
525
+ // Any existing `default_shell = …` gets the new value in place — even when the
526
+ // value already matches, so a re-run stays idempotent instead of duplicating
527
+ // the key.
528
+ if (/\s*default_shell\s*=/.test(text)) {
529
+ return text.replace(/^(\s*default_shell\s*=\s*).*$/m, `$1${quoted}`);
530
+ }
531
+
532
+ const lines = text.split("\n");
533
+ const table = lines.findIndex((l) => /^\[terminal\]\s*$/.test(l));
534
+ if (table === -1) {
535
+ const body = text.replace(/\s+$/, "");
536
+ return `${body.length > 0 ? body + "\n" : ""}[terminal]\n${line}\n`;
537
+ }
538
+ // The table exists but has no default_shell; drop the key right under it.
539
+ lines.splice(table + 1, 0, line);
540
+ return lines.join("\n");
541
+ }
542
+
318
543
  function tickSearchRoots(project: ProjectConfig): string[] {
319
544
  const roots = [stateDir(), dirname(project.workspaceRoot), project.workspaceRoot];
320
545
  return roots.filter((root, index) => roots.indexOf(root) === index);
@@ -373,6 +598,47 @@ export function tickCwdForProject(project: ProjectConfig): string {
373
598
  }
374
599
  }
375
600
 
601
+ /**
602
+ * Where this project's `AGENTS.md` link should point and what setup will do
603
+ * about it. The link lives in the **fleet cwd** — the directory the pane runs
604
+ * in — pointing at the composed brief under `workspaceRoot`, by a relative
605
+ * target so the tree stays movable.
606
+ *
607
+ * A fleet whose tick config predates per-project roots keeps its cwd at the
608
+ * state dir, so the link is `<stateDir>/AGENTS.md -> worktrees/ORCHESTRATOR.md`;
609
+ * a brand-new project links `AGENTS.md -> ORCHESTRATOR.md` in its own cwd.
610
+ *
611
+ * Never a clobber: a symlink already at the brief is left alone, a stale
612
+ * symlink is replaceable, and a *regular file* is an operator's own brief —
613
+ * reported and left untouched.
614
+ */
615
+ function planBriefLink(project: ProjectConfig): BriefLinkPlan {
616
+ const path = join(tickCwdForProject(project), AGENTS_BRIEF_NAME);
617
+ const target = relative(dirname(path), join(project.workspaceRoot, ORCHESTRATOR_BRIEF_NAME));
618
+ let st;
619
+ try {
620
+ st = lstatSync(path);
621
+ } catch {
622
+ // ENOENT (no link yet) and any unreadable path (no permission on a parent)
623
+ // both collapse to a best-effort create; a refused filesystem surfaces as
624
+ // a warning at write time.
625
+ return { path, target, action: "create" };
626
+ }
627
+ if (st.isSymbolicLink()) {
628
+ return {
629
+ path,
630
+ target,
631
+ action: readlinkSync(path, "utf8") === target ? "keep" : "update",
632
+ };
633
+ }
634
+ return {
635
+ path,
636
+ target,
637
+ action: "skip",
638
+ skippedReason: `an existing file at ${path} is not ours to overwrite; left untouched`,
639
+ };
640
+ }
641
+
376
642
  function planTick(project: ProjectConfig, telegramStateDir: string): PlannedWrite<TickConfig> {
377
643
  const found = findProjectTick(project);
378
644
  const existing = found === undefined ? undefined : { path: found.path, config: found.config };
@@ -422,17 +688,91 @@ export function planHostRuntime(
422
688
  content: serviceContent,
423
689
  value: serviceContent,
424
690
  };
691
+ const herdrUnitPath = join(stateDir(), DEFAULT_HERDR_UNIT);
692
+ const herdrConfigPath = join(runtime.home, ".config", "herdr", "config.toml");
693
+ let herdrUnit: PlannedWrite<string> | undefined;
694
+ let herdrConfig: PlannedWrite<string> | undefined;
695
+ // The recovery playbook ships in this package's systemd/ dir; the same
696
+ // bytes go into the staged copy, so version control is the single source.
697
+ const recoverScriptPath = join(stateDir(), RECOVER_SCRIPT_FILE);
698
+ let recoverScriptContent: string;
699
+ try {
700
+ recoverScriptContent = readFileSync(join(import.meta.dir, "..", "systemd", RECOVER_SCRIPT_FILE), "utf8");
701
+ } catch (err) {
702
+ throw new Error(
703
+ `cannot read the recovery playbook at ${join(import.meta.dir, "..", "systemd", RECOVER_SCRIPT_FILE)} — it ships with this package, and setup cannot stage it without it: ${err instanceof Error ? err.message : String(err)}`,
704
+ );
705
+ }
706
+ if (runtime.herdr !== undefined) {
707
+ const unitContent = renderHerdrUnit(runtime);
708
+ herdrUnit = {
709
+ path: herdrUnitPath,
710
+ action: actionFor(herdrUnitPath, unitContent),
711
+ content: unitContent,
712
+ value: unitContent,
713
+ };
714
+ // Belt and braces: read the session's herdr config so `[terminal]
715
+ // default_shell` merges into whatever the operator already has, preserving
716
+ // it verbatim. A missing or unreadable config starts from scratch.
717
+ let existing: string | undefined;
718
+ try {
719
+ existing = readFileSync(herdrConfigPath, "utf8");
720
+ } catch {
721
+ existing = undefined;
722
+ }
723
+ const configContent = renderHerdrConfig(runtime.shell, existing);
724
+ herdrConfig = {
725
+ path: herdrConfigPath,
726
+ action: actionFor(herdrConfigPath, configContent),
727
+ content: configContent,
728
+ value: configContent,
729
+ };
730
+ }
425
731
  const installedPath = join(SYSTEMD_UNIT_DIR, STAGED_SERVICE_NAME);
732
+ const installedHerdr = join(SYSTEMD_UNIT_DIR, DEFAULT_HERDR_UNIT);
733
+ const recoverUnitPath = join(stateDir(), RECOVER_SERVICE_NAME);
734
+ const recoverUnitContent = renderRecoverUnit(runtime, project.name);
735
+ const recoverUnit: PlannedWrite<string> = {
736
+ path: recoverUnitPath,
737
+ action: actionFor(recoverUnitPath, recoverUnitContent),
738
+ content: recoverUnitContent,
739
+ value: recoverUnitContent,
740
+ };
741
+ const recoverScript: PlannedWrite<string> = {
742
+ path: recoverScriptPath,
743
+ action: actionFor(recoverScriptPath, recoverScriptContent),
744
+ content: recoverScriptContent,
745
+ value: recoverScriptContent,
746
+ };
426
747
  return {
427
748
  service,
749
+ // The herdr unit and pane-shell config stand and fall together: no herdr, no
750
+ // session to supervise, nothing for a pane shell to belong to.
751
+ ...(herdrUnit === undefined ? {} : { herdrUnit, herdrConfig }),
752
+ recoverUnit,
753
+ recoverScript,
754
+ briefLink: planBriefLink(project),
428
755
  ...(project.escalation.orchestrator === "external"
429
756
  ? { tick: planTick(project, telegramStateDir) }
430
757
  : {}),
431
758
  installCommands: [
759
+ // Recovery first: the daemon unit that follows names it in OnFailure=,
760
+ // so the restart below must never point at a unit systemd cannot load.
761
+ `sudo install -m 0755 ${shellQuote(recoverScriptPath)} ${shellQuote(RECOVER_SCRIPT_INSTALL_PATH)}`,
762
+ `sudo install -m 0644 ${shellQuote(recoverUnitPath)} ${shellQuote(join(SYSTEMD_UNIT_DIR, RECOVER_SERVICE_NAME))}`,
763
+ "sudo systemctl daemon-reload",
432
764
  `sudo install -m 0644 ${shellQuote(servicePath)} ${shellQuote(installedPath)}`,
433
765
  "sudo systemctl daemon-reload",
434
766
  `sudo systemctl enable ${STAGED_SERVICE_NAME}`,
435
767
  `sudo systemctl restart ${STAGED_SERVICE_NAME}`,
768
+ ...(herdrUnit === undefined
769
+ ? []
770
+ : [
771
+ `sudo install -m 0644 ${shellQuote(herdrUnitPath)} ${shellQuote(installedHerdr)}`,
772
+ "sudo systemctl daemon-reload",
773
+ `sudo systemctl enable ${DEFAULT_HERDR_UNIT}`,
774
+ `sudo systemctl restart ${DEFAULT_HERDR_UNIT}`,
775
+ ]),
436
776
  ],
437
777
  cliSource: runtime.cli === undefined ? "plugin" : "global",
438
778
  installedPath,
@@ -445,6 +785,14 @@ export function formatHostRuntimePlan(plan: HostRuntimePlan): string {
445
785
  "host runtime",
446
786
  ` service ${plan.service.action} ${plan.service.path}`,
447
787
  ` daemon entry ${plan.cliSource === "global" ? "installed omp-conductor CLI" : "current installed plugin"}`,
788
+ ` recovery ${plan.recoverUnit.action} ${plan.recoverUnit.path}`,
789
+ ` recovery exec ${plan.recoverScript.action} ${plan.recoverScript.path} -> ${RECOVER_SCRIPT_INSTALL_PATH}`,
790
+ ...(plan.herdrUnit === undefined
791
+ ? [" herdr session skipped — herdr not installed on this host"]
792
+ : [
793
+ ` herdr session ${plan.herdrUnit.action} ${plan.herdrUnit.path}`,
794
+ ` pane shell ${plan.herdrConfig!.action} ${plan.herdrConfig!.path} ([terminal] default_shell)`,
795
+ ]),
448
796
  ];
449
797
  if (plan.tick !== undefined) {
450
798
  lines.push(
@@ -456,6 +804,11 @@ export function formatHostRuntimePlan(plan: HostRuntimePlan): string {
456
804
  } else {
457
805
  lines.push(" heartbeat embedded orchestrator — no external tick config");
458
806
  }
807
+ lines.push(
808
+ plan.briefLink.action === "skip"
809
+ ? ` brief link ${plan.briefLink.path} — ${plan.briefLink.skippedReason}`
810
+ : ` brief link ${plan.briefLink.action} ${plan.briefLink.path} -> ${plan.briefLink.target}`,
811
+ );
459
812
  lines.push(" install staged only; the final result prints the systemd install commands");
460
813
  return lines.join("\n");
461
814
  }
@@ -474,17 +827,76 @@ function atomicWrite(path: string, content: string, mode: number): void {
474
827
  }
475
828
  }
476
829
 
477
- export function writeHostRuntime(plan: HostRuntimePlan): string[] {
478
- const written: string[] = [];
830
+ /** What {@link writeHostRuntime} wrote, plus anything it could not. */
831
+ export interface HostRuntimeWrite {
832
+ /** Paths that were (re)written. */
833
+ wrote: string[];
834
+ /** Best-effort failures the caller should surface; setup continues regardless. */
835
+ warnings: string[];
836
+ }
837
+
838
+ export function writeHostRuntime(plan: HostRuntimePlan): HostRuntimeWrite {
839
+ const wrote: string[] = [];
840
+ const warnings: string[] = [];
479
841
  if (plan.service.action !== "keep") {
480
842
  atomicWrite(plan.service.path, plan.service.content, 0o644);
481
- written.push(plan.service.path);
843
+ wrote.push(plan.service.path);
844
+ }
845
+ if (plan.herdrUnit !== undefined && plan.herdrUnit.action !== "keep") {
846
+ atomicWrite(plan.herdrUnit.path, plan.herdrUnit.content, 0o644);
847
+ wrote.push(plan.herdrUnit.path);
848
+ }
849
+ if (plan.herdrConfig !== undefined && plan.herdrConfig.action !== "keep") {
850
+ atomicWrite(plan.herdrConfig.path, plan.herdrConfig.content, 0o644);
851
+ wrote.push(plan.herdrConfig.path);
852
+ }
853
+ if (plan.recoverUnit.action !== "keep") {
854
+ atomicWrite(plan.recoverUnit.path, plan.recoverUnit.content, 0o644);
855
+ wrote.push(plan.recoverUnit.path);
856
+ }
857
+ if (plan.recoverScript.action !== "keep") {
858
+ atomicWrite(plan.recoverScript.path, plan.recoverScript.content, 0o755);
859
+ wrote.push(plan.recoverScript.path);
482
860
  }
483
861
  if (plan.tick !== undefined && plan.tick.action !== "keep") {
484
862
  atomicWrite(plan.tick.path, plan.tick.content, 0o600);
485
- written.push(plan.tick.path);
863
+ wrote.push(plan.tick.path);
864
+ }
865
+ // `skip` is a regular file the plan already told the operator would be left
866
+ // alone; `keep` is an already-correct link. Only create/update touch disk —
867
+ // and never on the plan's word: the entry can change across the consent gap
868
+ // (an operator may drop a regular file where a stale symlink was planned for
869
+ // replacement). Re-lstat at write time and act on *current* state, so a file
870
+ // that appears mid-flight is preserved, never unlinked by a stale plan.
871
+ if (plan.briefLink.action === "create" || plan.briefLink.action === "update") {
872
+ const { path, target } = plan.briefLink;
873
+ try {
874
+ mkdirSync(dirname(path), { recursive: true });
875
+ let st: Stats | undefined;
876
+ try {
877
+ st = lstatSync(path);
878
+ } catch {
879
+ st = undefined; // ENOENT — nothing there yet; also best-effort if a parent refuses.
880
+ }
881
+ if (st !== undefined && !st.isSymbolicLink()) {
882
+ // A regular file occupies the path (present at plan time, appeared, or
883
+ // swapped in since). Never clobber an operator's file: warn and carry on.
884
+ warnings.push(`${path} is a regular file, not ours to overwrite; AGENTS.md brief link skipped`);
885
+ } else if (st !== undefined && readlinkSync(path, "utf8") === target) {
886
+ // Already the correct link — leave it untouched, however the plan read it.
887
+ } else {
888
+ // Missing, or a stale symlink: (re)link it. Only a *current* symlink is
889
+ // removed; an operator's file that appears mid-flight survives the write.
890
+ if (st !== undefined) rmSync(path, { force: true });
891
+ symlinkSync(target, path);
892
+ wrote.push(path);
893
+ }
894
+ } catch (err) {
895
+ // Best effort: a filesystem that refuses symlinks must not fail setup.
896
+ warnings.push(`could not link ${path} -> ${target}: ${err instanceof Error ? err.message : String(err)}`);
897
+ }
486
898
  }
487
- return written;
899
+ return { wrote, warnings };
488
900
  }
489
901
 
490
902
  export interface SetupSmokeResult {