omp-conductor 0.15.10 → 0.15.12

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 (92) hide show
  1. package/README.md +273 -2544
  2. package/REFERENCE.md +2680 -0
  3. package/package.json +3 -2
  4. package/schema/config.schema.json +11 -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 +113 -34
  10. package/src/briefs/policy.md +33 -8
  11. package/src/briefs/worker.md +18 -9
  12. package/src/chain-check.ts +1 -1
  13. package/src/check-trailing-newlines.ts +82 -0
  14. package/src/cli.ts +225 -1406
  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 +150 -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 +100 -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 +155 -0
  30. package/src/commands/ledger.ts +69 -0
  31. package/src/commands/message.ts +96 -0
  32. package/src/commands/report.ts +206 -0
  33. package/src/commands/restart.ts +76 -0
  34. package/src/commands/resume.ts +58 -0
  35. package/src/commands/setup.ts +143 -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 +32 -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 +47 -1
  49. package/src/config.ts +45 -4
  50. package/src/daemon.ts +972 -101
  51. package/src/dashboard/app.js +459 -0
  52. package/src/dashboard/index.html +61 -0
  53. package/src/dashboard/server.ts +481 -0
  54. package/src/dashboard/style.css +348 -0
  55. package/src/decisions.ts +39 -14
  56. package/src/diff-flags.ts +131 -241
  57. package/src/doctor.ts +932 -0
  58. package/src/escalate.ts +2 -2
  59. package/src/failure-class.ts +66 -3
  60. package/src/fleet.ts +58 -1
  61. package/src/gitops.ts +157 -0
  62. package/src/graph-health.ts +1 -1
  63. package/src/label-projection.ts +1 -1
  64. package/src/lifecycle.ts +198 -2
  65. package/src/model-fallback.ts +177 -0
  66. package/src/notices.ts +9 -0
  67. package/src/omp.ts +93 -13
  68. package/src/orchestrator-tick.ts +414 -20
  69. package/src/orchestrator.ts +4 -4
  70. package/src/privileged.ts +10 -0
  71. package/src/release-policy.ts +342 -30
  72. package/src/reports.ts +19 -5
  73. package/src/session-host.ts +11 -5
  74. package/src/setup-host.ts +663 -25
  75. package/src/setup-install.ts +292 -28
  76. package/src/setup-wizard.ts +255 -74
  77. package/src/setup.ts +156 -35
  78. package/src/stats.ts +331 -0
  79. package/src/store.ts +219 -22
  80. package/src/tracker/github.ts +74 -4
  81. package/src/types.ts +215 -31
  82. package/src/unblock.ts +55 -11
  83. package/src/upgrade-journal.ts +220 -0
  84. package/src/upgrade-verify.ts +506 -0
  85. package/src/upgrade.ts +385 -58
  86. package/src/verbs/actions.ts +73 -1
  87. package/src/verbs/protocol.ts +29 -4
  88. package/src/verbs/server.ts +183 -20
  89. package/src/worker.ts +3 -3
  90. package/systemd/omp-conductor-recover.sh +433 -0
  91. package/systemd/omp-conductor.service.example +14 -3
  92. 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,
@@ -21,12 +27,41 @@ import {
21
27
  tickConfigMatchesProject,
22
28
  type TickConfig,
23
29
  } from "./orchestrator-tick.ts";
30
+ import { formatStep, type PrivilegedStep } from "./privileged.ts";
24
31
  import type { Caps, ConductorConfig, ProjectConfig } from "./types.ts";
25
32
 
26
33
  export const DEFAULT_TICK_INTERVAL_SECONDS = 900;
27
34
  export const STAGED_SERVICE_NAME = "omp-conductor.service";
28
35
  export const SYSTEMD_UNIT_DIR = "/etc/systemd/system";
29
36
 
37
+ /**
38
+ * The one recovery unit every fleet unit's `OnFailure=` points at (#485).
39
+ *
40
+ * A plain (non-template) unit on purpose: both fleet units name the same
41
+ * oneshot, and the playbook it runs determines which unit failed from live
42
+ * systemd state rather than trusting an instance name it cannot verify. The
43
+ * unit itself never carries `OnFailure=` — the one hard bound on
44
+ * recovery-triggering-recovery.
45
+ */
46
+ export const RECOVER_SERVICE_NAME = "omp-conductor-recover.service";
47
+
48
+ /** The playbook file shipped in the package's `systemd/` directory. */
49
+ const RECOVER_SCRIPT_FILE = "omp-conductor-recover.sh";
50
+
51
+ /**
52
+ * Where `setup host` installs the recovery playbook so the unit's ExecStart is
53
+ * a stable absolute path that survives package upgrades. /usr/local/sbin is
54
+ * root-executed operational tooling, on PATH for root on every target host.
55
+ */
56
+ export const RECOVER_SCRIPT_INSTALL_PATH = "/usr/local/sbin/omp-conductor-recover";
57
+
58
+ /**
59
+ * The symlink the session cwd loads as its brief. omp auto-loads `AGENTS.md`
60
+ * from the session cwd, so the fleet pane's cwd needs a link at this name
61
+ * resolving to the composed {@link ORCHESTRATOR_BRIEF_NAME}.
62
+ */
63
+ export const AGENTS_BRIEF_NAME = "AGENTS.md";
64
+
30
65
  export type PlannedWrite<T> = {
31
66
  path: string;
32
67
  action: "create" | "update" | "keep";
@@ -34,9 +69,103 @@ export type PlannedWrite<T> = {
34
69
  value: T;
35
70
  };
36
71
 
72
+ /**
73
+ * The `AGENTS.md` symlink setup places in a project's fleet cwd so the pane's
74
+ * session auto-loads the composed brief instead of staying brief-less.
75
+ *
76
+ * {@link action} is what the plan will *do*, compared against what sits at
77
+ * {@link path}:
78
+ * - `create` — no entry yet, link it;
79
+ * - `update` — a symlink occupies {@link path} but points elsewhere, replace it;
80
+ * - `keep` — an already-correct symlink, leave untouched (idempotent re-run);
81
+ * - `skip` — a regular file occupies {@link path}, never overwrite an
82
+ * operator's file (see {@link skippedReason}).
83
+ */
84
+ export interface BriefLinkPlan {
85
+ /** `AGENTS.md` inside the project's fleet cwd ({@link tickCwdForProject}). */
86
+ path: string;
87
+ /** Relative target inside the symlink — what the link resolves to. */
88
+ target: string;
89
+ action: "create" | "update" | "keep" | "skip";
90
+ /** Why {@link action} is `skip`; present only then. */
91
+ skippedReason?: string;
92
+ }
93
+
94
+ /**
95
+ * Why a host-global install (no `--project`) wrote no per-project tail, and
96
+ * what the operator does about it.
97
+ */
98
+ export interface HostRuntimeNoProject {
99
+ /** The project-scoped files the host-global install deliberately left unwritten. */
100
+ skipped: readonly string[];
101
+ /** The command that writes them: `setup host` naming one project. */
102
+ how: string;
103
+ }
104
+
37
105
  export interface HostRuntimePlan {
38
106
  service: PlannedWrite<string>;
107
+ /**
108
+ * The staged Herdr session-server unit. Absent when Herdr is not installed
109
+ * ({@link ServiceRuntime.herdr} is `undefined`) — a host driving Herdr some
110
+ * other way must stay supported. `upgrade` and `start` treat
111
+ * {@link DEFAULT_HERDR_UNIT} as a first-class dependency, and this is the
112
+ * rendering that provisions it (see {@link renderHerdrUnit}).
113
+ */
114
+ herdrUnit?: PlannedWrite<string>;
115
+ /**
116
+ * The fleet recovery oneshot (#485): staged always, because the daemon unit
117
+ * (which every fleet has) carries `OnFailure=` to it. Rendered by
118
+ * {@link renderRecoverUnit}.
119
+ */
120
+ recoverUnit: PlannedWrite<string>;
121
+ /**
122
+ * The recovery playbook body, staged from the shipped package file and
123
+ * installed to {@link RECOVER_SCRIPT_INSTALL_PATH} so the unit's ExecStart
124
+ * is a stable absolute path.
125
+ */
126
+ recoverScript: PlannedWrite<string>;
127
+ /**
128
+ * `[terminal] default_shell` staged into the session's herdr config, the
129
+ * belt-and-braces behind the unit's `SHELL=` (see {@link renderHerdrConfig}).
130
+ * Present exactly when {@link herdrUnit} is, unless the login shell is not a
131
+ * usable path ({@link usableHerdrShell}) or the rendered config would not
132
+ * parse — either one leaves {@link herdrConfigProblem} set and writes
133
+ * nothing.
134
+ *
135
+ * This is the **staged** copy, under the state directory like every other
136
+ * file the plan writes: herdr's live config is a file conductor does not own,
137
+ * so it is only replaced post-consent by the install step naming
138
+ * {@link herdrConfigTarget} (#513).
139
+ */
140
+ herdrConfig?: PlannedWrite<string>;
141
+ /** The live herdr config {@link herdrConfig} merges into, post-consent. */
142
+ herdrConfigTarget?: string;
143
+ /** Why no pane-shell key is planned (unusable login shell, …). */
144
+ herdrConfigProblem?: string;
39
145
  tick?: PlannedWrite<TickConfig>;
146
+ /** The `AGENTS.md` — composed-brief symlink — placed in the fleet cwd. */
147
+ briefLink?: BriefLinkPlan;
148
+ /**
149
+ * Set when an install ran with no project named (a host-global install):
150
+ * the per-project tail was deliberately not written. Names the files and
151
+ * how to write them, so the operator is told what is missing and how to get
152
+ * it rather than silently installing host-global units that leave a project
153
+ * brief-less and tick-less.
154
+ */
155
+ noProject?: HostRuntimeNoProject;
156
+ /**
157
+ * The exact privileged steps this plan will run, in order — recovery first,
158
+ * so the daemon unit that follows names a recovery unit systemd already has.
159
+ * This is the single list: {@link installCommands} and `runHostInstall` are
160
+ * both derived from it, so a step can never appear in the printed plan and
161
+ * be absent from the run (#509).
162
+ */
163
+ steps: readonly PrivilegedStep[];
164
+ /**
165
+ * The same steps as `sudo …` shell lines, for humans to read. A projection
166
+ * of {@link steps} rather than a parallel list, so nothing executes a step
167
+ * the printed plan does not show.
168
+ */
40
169
  installCommands: readonly string[];
41
170
  cliSource: "global" | "plugin";
42
171
  /** Absolute path of the unit systemd actually reads. */
@@ -54,6 +183,12 @@ export interface HostRuntimePlan {
54
183
  * the installed unit matched.
55
184
  */
56
185
  installedAction: PlannedWrite<string>["action"];
186
+ /**
187
+ * True when every file the privileged install steps would write is already
188
+ * at its destination with the current bytes. `runHostInstall` uses it to
189
+ * make a no-op re-run of `setup host` neither restage nor restart anything.
190
+ */
191
+ currentInstall: boolean;
57
192
  }
58
193
 
59
194
  export interface ServiceRuntime {
@@ -65,6 +200,21 @@ export interface ServiceRuntime {
65
200
  packageCli: string;
66
201
  conductorHome: string;
67
202
  telegramStateDir: string;
203
+ /**
204
+ * The account's login shell. Rendered into the Herdr session unit as
205
+ * `Environment=SHELL=` (and into the herdr config as `[terminal]
206
+ * default_shell`), so every pane in the fleet session runs this shell instead
207
+ * of the `/bin/sh` Herdr falls back to when `$SHELL` is unset (the fleet's
208
+ * dash-pane symptom, #456). A systemd service has no `$SHELL`, so this is
209
+ * load-bearing, not decoration.
210
+ */
211
+ shell: string;
212
+ /**
213
+ * Absolute path of the `herdr` binary on this host, or `undefined` when Herdr
214
+ * is not installed. When absent, setup stages no Herdr unit and no pane-shell
215
+ * config — a host driving Herdr some other way stays supported (#456).
216
+ */
217
+ herdr?: string;
68
218
  /**
69
219
  * Non-default Herdr session to pin into the unit (#394). Omitted when the
70
220
  * host uses {@link DEFAULT_HERDR_SESSION}: an always-present line would show
@@ -93,10 +243,6 @@ function systemdPath(value: string): string {
93
243
  return value.replaceAll("%", "%%");
94
244
  }
95
245
 
96
- function shellQuote(value: string): string {
97
- return `'${value.replaceAll("'", "'\\''")}'`;
98
- }
99
-
100
246
  function actionFor(path: string, content: string): PlannedWrite<string>["action"] {
101
247
  if (!existsSync(path)) return "create";
102
248
  try {
@@ -252,6 +398,8 @@ function defaultServiceRuntime(
252
398
  const home = homedir();
253
399
  const bun = process.execPath;
254
400
  const globalCli = Bun.which("omp-conductor");
401
+ const herdr = Bun.which("herdr");
402
+ const loginShell = userInfo().shell;
255
403
  const pathParts = [dirname(bun), ...(process.env["PATH"] ?? "").split(":")].filter(
256
404
  (value, index, all) => value.length > 0 && all.indexOf(value) === index,
257
405
  );
@@ -264,6 +412,10 @@ function defaultServiceRuntime(
264
412
  packageCli: join(import.meta.dir, "cli.ts"),
265
413
  conductorHome: dirname(configPath()),
266
414
  telegramStateDir,
415
+ shell: loginShell ?? "/bin/sh",
416
+ // Only when Herdr is actually installed: a host running Herdr some other
417
+ // way (or none yet) gets no Herdr unit rendered, ever.
418
+ ...(herdr === null ? {} : { herdr }),
267
419
  // Only pin a non-default session. Default hosts keep a unit with no
268
420
  // HERDR_SESSION line so setup/upgrade do not invent drift (#394).
269
421
  ...(herdrSession !== DEFAULT_HERDR_SESSION ? { herdrSession } : {}),
@@ -289,6 +441,10 @@ export function renderDaemonService(runtime: ServiceRuntime, totalWorkers: numbe
289
441
  "Documentation=https://github.com/TerrifiedBug/conductor",
290
442
  "After=network-online.target",
291
443
  "Wants=network-online.target",
444
+ // #485: a crash-looped daemon used to sit in `failed` indefinitely, in
445
+ // silence — no unit was watching it. The recovery oneshot runs outside
446
+ // this unit's cgroup, collects evidence durably, and pages tier-2.
447
+ `OnFailure=${RECOVER_SERVICE_NAME}`,
292
448
  "",
293
449
  "[Service]",
294
450
  "Type=simple",
@@ -304,7 +460,12 @@ export function renderDaemonService(runtime: ServiceRuntime, totalWorkers: numbe
304
460
  : [`Environment=${systemdQuote(`HERDR_SESSION=${runtime.herdrSession}`)}`]),
305
461
  `WorkingDirectory=${systemdPath(stateDir())}`,
306
462
  `ExecStart=${command.map(systemdQuote).join(" ")}`,
307
- "Restart=on-failure",
463
+ // #546: restart on any exit — clean, signalled or crashed — except an
464
+ // explicit `systemctl stop`, which systemd records as intentional and does
465
+ // not undo. A stray SIGTERM (a worker's `bun test`) then costs seconds of
466
+ // downtime instead of leaving the fleet down until a human. A crash loop
467
+ // still trips the start-limit burst and reaches `OnFailure=` above.
468
+ "Restart=always",
308
469
  "SuccessExitStatus=0 143",
309
470
  "MemoryAccounting=yes",
310
471
  `MemoryMax=${memoryMax}`,
@@ -315,6 +476,161 @@ export function renderDaemonService(runtime: ServiceRuntime, totalWorkers: numbe
315
476
  ].join("\n");
316
477
  }
317
478
 
479
+ /**
480
+ * The Herdr session-server unit, in the same shape as the daemon unit.
481
+ *
482
+ * The fleet's 24/7 guarantee (`upgrade`'s restart + pane-recovery leg, `start`,
483
+ * the recovery plugin) treats {@link DEFAULT_HERDR_UNIT} as a first-class
484
+ * dependency, yet nothing rendered it — every operator hand-wrote it from a bare
485
+ * `ExecStart=` line. This provisions it, and it is where the dash-pane bug dies:
486
+ * a systemd service has no `$SHELL`, so without `Environment=SHELL=` Herdr
487
+ * resolved every pane as `/bin/sh`→dash with no readline and no `.bashrc`. The
488
+ * account's login shell is pinned here, and again in the herdr config
489
+ * ({@link renderHerdrConfig}) as belt and braces.
490
+ */
491
+ export function renderHerdrUnit(runtime: ServiceRuntime): string {
492
+ if (runtime.herdr === undefined) throw new Error("cannot render a herdr unit without a herdr binary");
493
+ const session = runtime.herdrSession ?? DEFAULT_HERDR_SESSION;
494
+ const configPath = join(runtime.home, ".config", "herdr", "config.toml");
495
+ return [
496
+ "[Unit]",
497
+ "Description=herdr fleet session server",
498
+ "Documentation=https://github.com/TerrifiedBug/conductor",
499
+ "After=network-online.target",
500
+ "Wants=network-online.target",
501
+ // #485: the fleet's two units are each other's only plausible watcher and
502
+ // neither watched the other. The recovery oneshot is the watcher, and it
503
+ // must fire for the orchestrator host too — a dead herdr means no tick, so
504
+ // nobody else would ever report it.
505
+ `OnFailure=${RECOVER_SERVICE_NAME}`,
506
+ "",
507
+ "[Service]",
508
+ "Type=simple",
509
+ `User=${runtime.username}`,
510
+ `Environment=${systemdQuote(`HOME=${runtime.home}`)}`,
511
+ `Environment=${systemdQuote(`PATH=${runtime.path}`)}`,
512
+ `Environment=${systemdQuote(`HERDR_CONFIG_PATH=${configPath}`)}`,
513
+ `Environment=${systemdQuote(`SHELL=${runtime.shell}`)}`,
514
+ `WorkingDirectory=${systemdPath(runtime.conductorHome)}`,
515
+ `ExecStart=${systemdQuote(runtime.herdr)} --session ${systemdQuote(session)} server`,
516
+ // #546: same exposure as the daemon — restart on any exit except an
517
+ // explicit `systemctl stop`; a crash loop still trips the start limit.
518
+ "Restart=always",
519
+ "RestartSec=5",
520
+ "",
521
+ "[Install]",
522
+ "WantedBy=multi-user.target",
523
+ "",
524
+ ].join("\n");
525
+ }
526
+
527
+ /**
528
+ * The fleet recovery unit (#485): a oneshot every fleet unit's `OnFailure=`
529
+ * names.
530
+ *
531
+ * It runs as root, outside the fleet units' cgroups and process trees, so the
532
+ * restart it performs can never take it down, and it carries an explicit PATH —
533
+ * on a typical host the non-interactive ssh PATH lacks `omp`, `herdr` and
534
+ * `omp-conductor`, and the playbook needs all three.
535
+ *
536
+ * The bounds are part of the unit text so `doctor` drift sees them:
537
+ * `StartLimitIntervalSec`/`StartLimitBurst` cap failed recovery starts at the
538
+ * systemd layer (the playbook keeps its own durable consecutive-attempt
539
+ * counter, agreeing on the same window), and there is deliberately **no**
540
+ * `OnFailure=` of its own and no `[Install]` — nothing activates recovery
541
+ * except the two fleet units' failure, so recovery can never re-trigger
542
+ * recovery.
543
+ */
544
+ export function renderRecoverUnit(runtime: ServiceRuntime, projectName: string | undefined): string {
545
+ // `RECOVER_PROJECT` is how the playbook addresses the tier-2 escalation it
546
+ // enqueues. It is a host-global unit installed once for the whole box, so a
547
+ // static per-project value is only unambiguous on a single-project host — a
548
+ // multi-project host (or a no-project, host-global install) leaves it unset
549
+ // and reports without a `--project` rather than guessing one (#510/#530).
550
+ return [
551
+ "[Unit]",
552
+ "Description=omp-conductor fleet recovery (OnFailure handler)",
553
+ "Documentation=https://github.com/TerrifiedBug/conductor",
554
+ // The oneshot bound: a recovery that fails twice in the window stops
555
+ // trying, and the playbook's durable counter uses the same window.
556
+ "StartLimitIntervalSec=1800",
557
+ "StartLimitBurst=2",
558
+ "",
559
+ "[Service]",
560
+ "Type=oneshot",
561
+ `Environment=${systemdQuote(`PATH=${runtime.path}`)}`,
562
+ `Environment=${systemdQuote(`OMP_CONDUCTOR_HOME=${runtime.conductorHome}`)}`,
563
+ `Environment=${systemdQuote(`RECOVER_STATE_DIR=${stateDir()}`)}`,
564
+ ...(projectName === undefined ? [] : [`Environment=${systemdQuote(`RECOVER_PROJECT=${projectName}`)}`]),
565
+ `ExecStart=${RECOVER_SCRIPT_INSTALL_PATH}`,
566
+ "",
567
+ ].join("\n");
568
+ }
569
+
570
+ /**
571
+ * Whether a login-shell value may be pinned into herdr's config at all.
572
+ *
573
+ * `userInfo().shell` degrades to the literal `unknown` where the passwd entry
574
+ * cannot be read (the same root as #511's drift check), and `unknown` is not a
575
+ * path — a pane told to exec it dies on start, which is a stopped fleet. Only
576
+ * an absolute path that exists on this host qualifies; anything else must mean
577
+ * the caller writes **no** key and lets herdr's own `$SHELL → /bin/sh →
578
+ * passwd` fallback apply.
579
+ */
580
+ export function usableHerdrShell(shell: string): boolean {
581
+ if (!shell.startsWith("/")) return false;
582
+ try {
583
+ return existsSync(shell);
584
+ } catch {
585
+ return false;
586
+ }
587
+ }
588
+
589
+ /**
590
+ * Idempotently put `[terminal] default_shell` into a herdr config, preserving
591
+ * everything else verbatim.
592
+ *
593
+ * Belt-and-braces behind the unit's `Environment=SHELL=`: a pane comes up as the
594
+ * account's login shell even if an operator later edits the unit and drops the
595
+ * `SHELL` line. Herdr falls back `$SHELL → /bin/sh` and skips the passwd entry,
596
+ * so without this the only guarantee would be the unit's environment.
597
+ *
598
+ * A shell that is not a usable executable path ({@link usableHerdrShell})
599
+ * writes **no** key at all — the file passes through byte-identical, because
600
+ * "pin the shell" must never mean "write the word `unknown`".
601
+ */
602
+ export function renderHerdrConfig(shell: string, existing?: string): string {
603
+ const text = existing ?? "";
604
+ if (!usableHerdrShell(shell)) return text;
605
+
606
+ const quoted = `"${shell.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
607
+ const line = `default_shell = ${quoted}`;
608
+
609
+ // One anchored, comment-aware match, used for both the guard and the replace
610
+ // so they can never disagree: a *real* key starts its line (after whitespace),
611
+ // while `# default_shell = …` starts with a comment and is not a key. The
612
+ // old guard was unanchored, so it matched a commented line the anchored
613
+ // replace did not — a config whose only mention was a comment took the
614
+ // "replace" branch, replaced nothing, and silently wrote nothing at all.
615
+ // Any existing real key keeps its position and gets the new value in place —
616
+ // even when the value already matches, so a re-run stays idempotent instead
617
+ // of duplicating the key.
618
+ const keyLine = /^([ \t]*default_shell[ \t]*=\s*).*$/m;
619
+ if (keyLine.test(text)) {
620
+ return text.replace(keyLine, `$1${quoted}`);
621
+ }
622
+
623
+ const lines = text.split("\n");
624
+ const table = lines.findIndex((l) => /^\[terminal\]\s*$/.test(l));
625
+ if (table === -1) {
626
+ const body = text.replace(/\s+$/, "");
627
+ return `${body.length > 0 ? body + "\n" : ""}[terminal]\n${line}\n`;
628
+ }
629
+ // The table exists but has no default_shell; drop the key right under it.
630
+ lines.splice(table + 1, 0, line);
631
+ return lines.join("\n");
632
+ }
633
+
318
634
  function tickSearchRoots(project: ProjectConfig): string[] {
319
635
  const roots = [stateDir(), dirname(project.workspaceRoot), project.workspaceRoot];
320
636
  return roots.filter((root, index) => roots.indexOf(root) === index);
@@ -373,6 +689,47 @@ export function tickCwdForProject(project: ProjectConfig): string {
373
689
  }
374
690
  }
375
691
 
692
+ /**
693
+ * Where this project's `AGENTS.md` link should point and what setup will do
694
+ * about it. The link lives in the **fleet cwd** — the directory the pane runs
695
+ * in — pointing at the composed brief under `workspaceRoot`, by a relative
696
+ * target so the tree stays movable.
697
+ *
698
+ * A fleet whose tick config predates per-project roots keeps its cwd at the
699
+ * state dir, so the link is `<stateDir>/AGENTS.md -> worktrees/ORCHESTRATOR.md`;
700
+ * a brand-new project links `AGENTS.md -> ORCHESTRATOR.md` in its own cwd.
701
+ *
702
+ * Never a clobber: a symlink already at the brief is left alone, a stale
703
+ * symlink is replaceable, and a *regular file* is an operator's own brief —
704
+ * reported and left untouched.
705
+ */
706
+ function planBriefLink(project: ProjectConfig): BriefLinkPlan {
707
+ const path = join(tickCwdForProject(project), AGENTS_BRIEF_NAME);
708
+ const target = relative(dirname(path), join(project.workspaceRoot, ORCHESTRATOR_BRIEF_NAME));
709
+ let st;
710
+ try {
711
+ st = lstatSync(path);
712
+ } catch {
713
+ // ENOENT (no link yet) and any unreadable path (no permission on a parent)
714
+ // both collapse to a best-effort create; a refused filesystem surfaces as
715
+ // a warning at write time.
716
+ return { path, target, action: "create" };
717
+ }
718
+ if (st.isSymbolicLink()) {
719
+ return {
720
+ path,
721
+ target,
722
+ action: readlinkSync(path, "utf8") === target ? "keep" : "update",
723
+ };
724
+ }
725
+ return {
726
+ path,
727
+ target,
728
+ action: "skip",
729
+ skippedReason: `an existing file at ${path} is not ours to overwrite; left untouched`,
730
+ };
731
+ }
732
+
376
733
  function planTick(project: ProjectConfig, telegramStateDir: string): PlannedWrite<TickConfig> {
377
734
  const found = findProjectTick(project);
378
735
  const existing = found === undefined ? undefined : { path: found.path, config: found.config };
@@ -405,7 +762,14 @@ function planTick(project: ProjectConfig, telegramStateDir: string): PlannedWrit
405
762
  }
406
763
 
407
764
  export function planHostRuntime(
408
- project: ProjectConfig,
765
+ /**
766
+ * The project the per-project tail (tick config, brief link, the recovery
767
+ * unit's RECOVER_PROJECT) is planned for. `undefined` plans the host-global
768
+ * units only — the same staged paths and systemd destinations, one shared
769
+ * daemon — and a `noProject` note naming the per-project files it left for
770
+ * a named run (#530).
771
+ */
772
+ project: ProjectConfig | undefined,
409
773
  _caps: Caps,
410
774
  telegramStateDir: string,
411
775
  runtime: ServiceRuntime = defaultServiceRuntime(telegramStateDir),
@@ -413,6 +777,17 @@ export function planHostRuntime(
413
777
  // Defaulting through loadConfig() would throw in install tests that stage
414
778
  // files before a config exists, and would hide a missing total at the call site.
415
779
  totalWorkers: number = 1,
780
+ // The directory the privileged steps install units into. `runHostInstall`
781
+ // injects a test-hermetic directory so the executed argv is assertable; the
782
+ // real install always resolves to `/etc/systemd/system`.
783
+ unitDir: string = SYSTEMD_UNIT_DIR,
784
+ // Where the recovery playbook installs; injectable like {@link unitDir} so
785
+ // the idempotency gate is testable without touching the real `/usr/local/sbin`.
786
+ recoverScriptInstallPath: string = RECOVER_SCRIPT_INSTALL_PATH,
787
+ // True when the host configures more than one project. The recovery unit is
788
+ // host-global, so on a multi-project host it must not encode one project's
789
+ // name — and a no-project install never does (#510/#530).
790
+ multiProject: boolean = false,
416
791
  ): HostRuntimePlan {
417
792
  const servicePath = join(stateDir(), STAGED_SERVICE_NAME);
418
793
  const serviceContent = renderDaemonService(runtime, totalWorkers);
@@ -422,21 +797,201 @@ export function planHostRuntime(
422
797
  content: serviceContent,
423
798
  value: serviceContent,
424
799
  };
425
- const installedPath = join(SYSTEMD_UNIT_DIR, STAGED_SERVICE_NAME);
800
+ const herdrUnitPath = join(stateDir(), DEFAULT_HERDR_UNIT);
801
+ const herdrConfigPath = join(runtime.home, ".config", "herdr", "config.toml");
802
+ // The pane-shell config is staged like every other file; the live herdr
803
+ // config — a file conductor does not own — is only written by the install
804
+ // step, after consent (#513). A declined `setup host` must leave herdr's
805
+ // config byte-for-byte untouched, and the merge must land before the unit
806
+ // restart that accompanies it.
807
+ const herdrConfigStagedPath = join(stateDir(), "herdr-config.toml");
808
+ let herdrUnit: PlannedWrite<string> | undefined;
809
+ let herdrConfig: PlannedWrite<string> | undefined;
810
+ let herdrConfigTarget: string | undefined;
811
+ let herdrConfigProblem: string | undefined;
812
+ // The recovery playbook ships in this package's systemd/ dir; the same
813
+ // bytes go into the staged copy, so version control is the single source.
814
+ const recoverScriptPath = join(stateDir(), RECOVER_SCRIPT_FILE);
815
+ let recoverScriptContent: string;
816
+ try {
817
+ recoverScriptContent = readFileSync(join(import.meta.dir, "..", "systemd", RECOVER_SCRIPT_FILE), "utf8");
818
+ } catch (err) {
819
+ throw new Error(
820
+ `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)}`,
821
+ );
822
+ }
823
+ if (runtime.herdr !== undefined) {
824
+ const unitContent = renderHerdrUnit(runtime);
825
+ herdrUnit = {
826
+ path: herdrUnitPath,
827
+ action: actionFor(herdrUnitPath, unitContent),
828
+ content: unitContent,
829
+ value: unitContent,
830
+ };
831
+ // Belt and braces: read the session's herdr config so `[terminal]
832
+ // default_shell` merges into whatever the operator already has, preserving
833
+ // it verbatim. A missing or unreadable config starts from scratch.
834
+ let existing: string | undefined;
835
+ try {
836
+ existing = readFileSync(herdrConfigPath, "utf8");
837
+ } catch {
838
+ existing = undefined;
839
+ }
840
+ // An unresolvable login shell (userInfo() renders the literal "unknown")
841
+ // plans nothing and says why: never a placeholder, and never a value that
842
+ // is not an executable path — herdr's own `$SHELL → /bin/sh → passwd`
843
+ // fallback applies instead.
844
+ if (!usableHerdrShell(runtime.shell)) {
845
+ herdrConfigProblem =
846
+ `login shell ${JSON.stringify(runtime.shell)} is not an absolute path that exists on this host — ` +
847
+ "no [terminal] default_shell is written, so herdr's own $SHELL → /bin/sh → passwd fallback applies";
848
+ } else {
849
+ const configContent = renderHerdrConfig(runtime.shell, existing);
850
+ // The rendered file is parse-checked before anything may replace herdr's
851
+ // live config. An input that is already broken (a duplicated key, say)
852
+ // renders broken — the replace touches every real key, so two keys stay
853
+ // two keys — and writing that file is exactly the stopped-fleet outcome
854
+ // this must prevent. A parse failure aborts the whole plan here, before
855
+ // a single file has been staged.
856
+ if (actionFor(herdrConfigPath, configContent) !== "keep") {
857
+ try {
858
+ Bun.TOML.parse(configContent);
859
+ } catch (err) {
860
+ throw new Error(
861
+ `refusing to write herdr's config at ${herdrConfigPath}: it would not parse ` +
862
+ `(${err instanceof Error ? err.message : String(err)}). Nothing has been staged — ` +
863
+ "fix the file by hand (herdr's `config check` names the line), then re-run setup host.",
864
+ );
865
+ }
866
+ }
867
+ herdrConfig = {
868
+ path: herdrConfigStagedPath,
869
+ action: actionFor(herdrConfigStagedPath, configContent),
870
+ content: configContent,
871
+ value: configContent,
872
+ };
873
+ herdrConfigTarget = herdrConfigPath;
874
+ }
875
+ }
876
+ const installedPath = join(unitDir, STAGED_SERVICE_NAME);
877
+ const installedHerdr = join(unitDir, DEFAULT_HERDR_UNIT);
878
+ const recoverUnitPath = join(stateDir(), RECOVER_SERVICE_NAME);
879
+ // The recovery unit is host-global: one shared unit, installed once. A
880
+ // static RECOVER_PROJECT is only legitimate when there is exactly one
881
+ // project to be unambiguous about — a multi-project host (or a no-project
882
+ // install) leaves it unset so the escalation reports without attributing a
883
+ // sibling's crash to one project (#510/#530).
884
+ const recoverProject = project === undefined || multiProject ? undefined : project.name;
885
+ const recoverUnitContent = renderRecoverUnit(runtime, recoverProject);
886
+ const recoverUnit: PlannedWrite<string> = {
887
+ path: recoverUnitPath,
888
+ action: actionFor(recoverUnitPath, recoverUnitContent),
889
+ content: recoverUnitContent,
890
+ value: recoverUnitContent,
891
+ };
892
+ const recoverScript: PlannedWrite<string> = {
893
+ path: recoverScriptPath,
894
+ action: actionFor(recoverScriptPath, recoverScriptContent),
895
+ content: recoverScriptContent,
896
+ value: recoverScriptContent,
897
+ };
898
+ // Recovery first: the daemon unit that follows names it in OnFailure=, so
899
+ // the restart below must never point at a unit systemd cannot load. This is
900
+ // the single list `runHostInstall` executes and {@link installCommands}
901
+ // renders from, so no step can be in one and missing from the other (#509).
902
+ const installSteps: PrivilegedStep[] = [
903
+ {
904
+ title: "install the recovery playbook",
905
+ argv: ["install", "-m", "0755", recoverScriptPath, recoverScriptInstallPath],
906
+ },
907
+ {
908
+ title: `install ${RECOVER_SERVICE_NAME}`,
909
+ argv: ["install", "-m", "0644", recoverUnitPath, join(unitDir, RECOVER_SERVICE_NAME)],
910
+ },
911
+ { title: "reload systemd", argv: ["systemctl", "daemon-reload"] },
912
+ {
913
+ title: `install ${STAGED_SERVICE_NAME}`,
914
+ argv: ["install", "-m", "0644", servicePath, installedPath],
915
+ },
916
+ { title: "reload systemd", argv: ["systemctl", "daemon-reload"] },
917
+ { title: `enable ${STAGED_SERVICE_NAME}`, argv: ["systemctl", "enable", STAGED_SERVICE_NAME] },
918
+ { title: `restart ${STAGED_SERVICE_NAME}`, argv: ["systemctl", "restart", STAGED_SERVICE_NAME] },
919
+ // The herdr session server, provisioned alongside the daemon (#456). Only
920
+ // when herdr is installed and the plan therefore staged a unit.
921
+ ...(herdrUnit === undefined
922
+ ? []
923
+ : [
924
+ {
925
+ title: `install ${DEFAULT_HERDR_UNIT}`,
926
+ argv: ["install", "-m", "0644", herdrUnitPath, installedHerdr],
927
+ },
928
+ { title: "reload systemd", argv: ["systemctl", "daemon-reload"] },
929
+ { title: `enable ${DEFAULT_HERDR_UNIT}`, argv: ["systemctl", "enable", DEFAULT_HERDR_UNIT] },
930
+ // The pane-shell merge lands *before* the restart it accompanies, so
931
+ // the session server comes up with the pinned shell, not the stale
932
+ // config. It runs without sudo on purpose: the file is the fleet
933
+ // account's own (the escalation guard guarantees setup runs as that
934
+ // account), and a root-owned copy would break herdr's next rewrite.
935
+ // Nothing writes herdr's live config until this consent-gated step.
936
+ ...(herdrConfig === undefined || actionFor(herdrConfigPath, herdrConfig.content) === "keep"
937
+ ? []
938
+ : [
939
+ {
940
+ title: `write the pane shell into ${herdrConfigPath}`,
941
+ argv: ["install", "-m", "0644", herdrConfigStagedPath, herdrConfigPath],
942
+ unprivileged: true,
943
+ },
944
+ ]),
945
+ { title: `restart ${DEFAULT_HERDR_UNIT}`, argv: ["systemctl", "restart", DEFAULT_HERDR_UNIT] },
946
+ ]),
947
+ ];
948
+ // Everything the privileged steps install is already at its destination with
949
+ // the current bytes, so a re-run of `setup host` has nothing to install and
950
+ // nothing to restart. The herdr unit is absent on a host without herdr, and
951
+ // an absent unit that would not be provisioned is nothing to do.
952
+ const installedAction = actionFor(installedPath, serviceContent);
953
+ const currentInstall =
954
+ installedAction === "keep" &&
955
+ actionFor(join(unitDir, RECOVER_SERVICE_NAME), recoverUnitContent) === "keep" &&
956
+ actionFor(recoverScriptInstallPath, recoverScriptContent) === "keep" &&
957
+ (herdrUnit === undefined || actionFor(installedHerdr, herdrUnit.content) === "keep") &&
958
+ // The pane-shell file is a destination like the units: a re-run with all
959
+ // units current but the config merge still pending must not report
960
+ // "nothing to install" and skip the very write the plan exists to make.
961
+ (herdrConfig === undefined || actionFor(herdrConfigPath, herdrConfig.content) === "keep");
426
962
  return {
427
963
  service,
428
- ...(project.escalation.orchestrator === "external"
429
- ? { tick: planTick(project, telegramStateDir) }
430
- : {}),
431
- installCommands: [
432
- `sudo install -m 0644 ${shellQuote(servicePath)} ${shellQuote(installedPath)}`,
433
- "sudo systemctl daemon-reload",
434
- `sudo systemctl enable ${STAGED_SERVICE_NAME}`,
435
- `sudo systemctl restart ${STAGED_SERVICE_NAME}`,
436
- ],
964
+ // The herdr unit and pane-shell config stand and fall together: no herdr, no
965
+ // session to supervise, nothing for a pane shell to belong to. (The pane
966
+ // shell is omitted too when the login shell is unusable or the rendered
967
+ // config would not parse — those plans carry {@link herdrConfigProblem}.)
968
+ ...(herdrUnit === undefined ? {} : { herdrUnit }),
969
+ ...(herdrConfig === undefined ? {} : { herdrConfig, herdrConfigTarget }),
970
+ ...(herdrConfigProblem === undefined ? {} : { herdrConfigProblem }),
971
+ recoverUnit,
972
+ recoverScript,
973
+ ...(project === undefined
974
+ ? {
975
+ // Host-global install: the per-project tail entities are not written,
976
+ // and the plan says exactly which files and how to write them.
977
+ noProject: {
978
+ skipped: [TICK_CONFIG_FILE, AGENTS_BRIEF_NAME],
979
+ how: "re-run `omp-conductor setup host <NAME>` (or --project NAME) to write them for one project",
980
+ },
981
+ }
982
+ : {
983
+ briefLink: planBriefLink(project),
984
+ ...(project.escalation.orchestrator === "external"
985
+ ? { tick: planTick(project, telegramStateDir) }
986
+ : {}),
987
+ }),
988
+ steps: installSteps,
989
+ // For humans to read; the executed form is {@link steps}, in argv.
990
+ installCommands: installSteps.map((s) => formatStep(s)),
437
991
  cliSource: runtime.cli === undefined ? "plugin" : "global",
438
992
  installedPath,
439
- installedAction: actionFor(installedPath, serviceContent),
993
+ installedAction,
994
+ currentInstall,
440
995
  };
441
996
  }
442
997
 
@@ -445,6 +1000,16 @@ export function formatHostRuntimePlan(plan: HostRuntimePlan): string {
445
1000
  "host runtime",
446
1001
  ` service ${plan.service.action} ${plan.service.path}`,
447
1002
  ` daemon entry ${plan.cliSource === "global" ? "installed omp-conductor CLI" : "current installed plugin"}`,
1003
+ ` recovery ${plan.recoverUnit.action} ${plan.recoverUnit.path}`,
1004
+ ` recovery exec ${plan.recoverScript.action} ${plan.recoverScript.path} -> ${RECOVER_SCRIPT_INSTALL_PATH}`,
1005
+ ...(plan.herdrUnit === undefined
1006
+ ? [" herdr session skipped — herdr not installed on this host"]
1007
+ : [
1008
+ ` herdr session ${plan.herdrUnit.action} ${plan.herdrUnit.path}`,
1009
+ ...(plan.herdrConfig === undefined
1010
+ ? [` pane shell skipped — ${plan.herdrConfigProblem ?? "login shell unusable"}`]
1011
+ : [` pane shell ${plan.herdrConfig.action} ${plan.herdrConfig.path} ([terminal] default_shell)`]),
1012
+ ]),
448
1013
  ];
449
1014
  if (plan.tick !== undefined) {
450
1015
  lines.push(
@@ -456,6 +1021,19 @@ export function formatHostRuntimePlan(plan: HostRuntimePlan): string {
456
1021
  } else {
457
1022
  lines.push(" heartbeat embedded orchestrator — no external tick config");
458
1023
  }
1024
+ if (plan.noProject !== undefined) {
1025
+ lines.push(
1026
+ ` per-project skipped (no project named): ${plan.noProject.skipped.join(", ")}`,
1027
+ ` ${plan.noProject.how}`,
1028
+ );
1029
+ }
1030
+ if (plan.briefLink !== undefined) {
1031
+ lines.push(
1032
+ plan.briefLink.action === "skip"
1033
+ ? ` brief link ${plan.briefLink.path} — ${plan.briefLink.skippedReason}`
1034
+ : ` brief link ${plan.briefLink.action} ${plan.briefLink.path} -> ${plan.briefLink.target}`,
1035
+ );
1036
+ }
459
1037
  lines.push(" install staged only; the final result prints the systemd install commands");
460
1038
  return lines.join("\n");
461
1039
  }
@@ -474,17 +1052,77 @@ function atomicWrite(path: string, content: string, mode: number): void {
474
1052
  }
475
1053
  }
476
1054
 
477
- export function writeHostRuntime(plan: HostRuntimePlan): string[] {
478
- const written: string[] = [];
1055
+ /** What {@link writeHostRuntime} wrote, plus anything it could not. */
1056
+ export interface HostRuntimeWrite {
1057
+ /** Paths that were (re)written. */
1058
+ wrote: string[];
1059
+ /** Best-effort failures the caller should surface; setup continues regardless. */
1060
+ warnings: string[];
1061
+ }
1062
+
1063
+ export function writeHostRuntime(plan: HostRuntimePlan): HostRuntimeWrite {
1064
+ const wrote: string[] = [];
1065
+ const warnings: string[] = [];
479
1066
  if (plan.service.action !== "keep") {
480
1067
  atomicWrite(plan.service.path, plan.service.content, 0o644);
481
- written.push(plan.service.path);
1068
+ wrote.push(plan.service.path);
1069
+ }
1070
+ if (plan.herdrUnit !== undefined && plan.herdrUnit.action !== "keep") {
1071
+ atomicWrite(plan.herdrUnit.path, plan.herdrUnit.content, 0o644);
1072
+ wrote.push(plan.herdrUnit.path);
1073
+ }
1074
+ if (plan.herdrConfig !== undefined && plan.herdrConfig.action !== "keep") {
1075
+ atomicWrite(plan.herdrConfig.path, plan.herdrConfig.content, 0o644);
1076
+ wrote.push(plan.herdrConfig.path);
1077
+ }
1078
+ if (plan.recoverUnit.action !== "keep") {
1079
+ atomicWrite(plan.recoverUnit.path, plan.recoverUnit.content, 0o644);
1080
+ wrote.push(plan.recoverUnit.path);
1081
+ }
1082
+ if (plan.recoverScript.action !== "keep") {
1083
+ atomicWrite(plan.recoverScript.path, plan.recoverScript.content, 0o755);
1084
+ wrote.push(plan.recoverScript.path);
482
1085
  }
483
1086
  if (plan.tick !== undefined && plan.tick.action !== "keep") {
484
1087
  atomicWrite(plan.tick.path, plan.tick.content, 0o600);
485
- written.push(plan.tick.path);
1088
+ wrote.push(plan.tick.path);
1089
+ }
1090
+ // `skip` is a regular file the plan already told the operator would be left
1091
+ // alone; `keep` is an already-correct link. Only create/update touch disk —
1092
+ // and never on the plan's word: the entry can change across the consent gap
1093
+ // (an operator may drop a regular file where a stale symlink was planned for
1094
+ // replacement). Re-lstat at write time and act on *current* state, so a file
1095
+ // that appears mid-flight is preserved, never unlinked by a stale plan.
1096
+ // A host-global plan (no project) has no briefLink to write at all.
1097
+ if (plan.briefLink !== undefined && (plan.briefLink.action === "create" || plan.briefLink.action === "update")) {
1098
+ const { path, target } = plan.briefLink;
1099
+ try {
1100
+ mkdirSync(dirname(path), { recursive: true });
1101
+ let st: Stats | undefined;
1102
+ try {
1103
+ st = lstatSync(path);
1104
+ } catch {
1105
+ st = undefined; // ENOENT — nothing there yet; also best-effort if a parent refuses.
1106
+ }
1107
+ if (st !== undefined && !st.isSymbolicLink()) {
1108
+ // A regular file occupies the path (present at plan time, appeared, or
1109
+ // swapped in since). Never clobber an operator's file: warn and carry on.
1110
+ warnings.push(`${path} is a regular file, not ours to overwrite; AGENTS.md brief link skipped`);
1111
+ } else if (st !== undefined && readlinkSync(path, "utf8") === target) {
1112
+ // Already the correct link — leave it untouched, however the plan read it.
1113
+ } else {
1114
+ // Missing, or a stale symlink: (re)link it. Only a *current* symlink is
1115
+ // removed; an operator's file that appears mid-flight survives the write.
1116
+ if (st !== undefined) rmSync(path, { force: true });
1117
+ symlinkSync(target, path);
1118
+ wrote.push(path);
1119
+ }
1120
+ } catch (err) {
1121
+ // Best effort: a filesystem that refuses symlinks must not fail setup.
1122
+ warnings.push(`could not link ${path} -> ${target}: ${err instanceof Error ? err.message : String(err)}`);
1123
+ }
486
1124
  }
487
- return written;
1125
+ return { wrote, warnings };
488
1126
  }
489
1127
 
490
1128
  export interface SetupSmokeResult {