omp-conductor 0.18.1 → 0.19.0

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 (69) hide show
  1. package/README.md +106 -41
  2. package/REFERENCE.md +866 -31
  3. package/agents/to-spec.md +6 -2
  4. package/package.json +1 -1
  5. package/schema/config.schema.json +32 -1
  6. package/src/admission.ts +212 -26
  7. package/src/arm-challenge.ts +250 -57
  8. package/src/ask.ts +288 -1
  9. package/src/briefs/orchestrator.md +27 -13
  10. package/src/briefs/to-spec.md +6 -2
  11. package/src/cli.ts +127 -2
  12. package/src/command-help.ts +9 -1
  13. package/src/command-manifest.ts +52 -8
  14. package/src/commands/arm.ts +6 -2
  15. package/src/commands/context.ts +2 -0
  16. package/src/commands/intake.ts +4 -19
  17. package/src/commands/message.ts +26 -2
  18. package/src/commands/reconcile-units.ts +104 -0
  19. package/src/commands/release-composition.ts +232 -0
  20. package/src/commands/resume.ts +2 -27
  21. package/src/commands/setup.ts +101 -16
  22. package/src/commands/stats.ts +11 -30
  23. package/src/commands/tail.ts +31 -1
  24. package/src/commands/upgrade.ts +20 -3
  25. package/src/commands/verb.ts +2 -1
  26. package/src/commands/watch.ts +4 -17
  27. package/src/config-schema.ts +38 -6
  28. package/src/config.ts +103 -8
  29. package/src/credential-class.ts +366 -0
  30. package/src/daemon.ts +1368 -529
  31. package/src/dashboard/app.js +504 -2
  32. package/src/dashboard/controls.ts +336 -0
  33. package/src/dashboard/index.html +30 -0
  34. package/src/dashboard/server.ts +271 -30
  35. package/src/dashboard/style.css +116 -0
  36. package/src/dashboard/transcript.ts +173 -0
  37. package/src/decisions.ts +19 -11
  38. package/src/doctor.ts +431 -148
  39. package/src/escalate.ts +22 -11
  40. package/src/failure-class.ts +59 -0
  41. package/src/fleet.ts +587 -230
  42. package/src/host.ts +6 -455
  43. package/src/omp-settings.ts +19 -0
  44. package/src/omp.ts +40 -56
  45. package/src/orchestrator-tick.ts +564 -121
  46. package/src/pause.ts +233 -0
  47. package/src/session-host.ts +6 -41
  48. package/src/settlement.ts +159 -2
  49. package/src/setup-answers.ts +97 -0
  50. package/src/setup-host.ts +343 -1160
  51. package/src/setup-install.ts +204 -27
  52. package/src/setup-wizard.ts +252 -51
  53. package/src/setup.ts +87 -4
  54. package/src/spend-telemetry.ts +117 -0
  55. package/src/stats.ts +35 -0
  56. package/src/status-render.ts +485 -19
  57. package/src/store.ts +1229 -55
  58. package/src/telegram-freshness.ts +269 -0
  59. package/src/to-spec.ts +50 -2
  60. package/src/types.ts +759 -10
  61. package/src/unblock.ts +22 -0
  62. package/src/unit-reconcile.ts +303 -0
  63. package/src/upgrade-verify.ts +8 -1
  64. package/src/upgrade.ts +299 -12
  65. package/src/verbs/actions.ts +124 -10
  66. package/src/verbs/protocol.ts +70 -2
  67. package/src/verbs/server.ts +485 -11
  68. package/src/wake.ts +48 -0
  69. package/src/worker.ts +401 -14
@@ -20,7 +20,8 @@ import { platform } from "node:os";
20
20
  import { join } from "node:path";
21
21
  import { loadConfig, stateDir } from "./config.ts";
22
22
  import { pauseInstance, pauseSourceToken, setPaused, statusSnapshot } from "./daemon.ts";
23
- import { fleetLayers, DEFAULT_HERDR_UNIT } from "./fleet.ts";
23
+ import { fleetLayers, DEFAULT_HERDR_SESSION, DEFAULT_HERDR_UNIT } from "./fleet.ts";
24
+ import { spawnSync } from "node:child_process";
24
25
  import { livingDaemon } from "./lifecycle.ts";
25
26
  import { dbPath, openStore } from "./store.ts";
26
27
  import {
@@ -51,8 +52,9 @@ import { runPrivileged, type PrivilegedDeps, type PrivilegedStep } from "./privi
51
52
  import {
52
53
  agentRenameVerdict,
53
54
  checkEscalation,
55
+ checkSessionRestartContext,
56
+ herdrOwnership,
54
57
  DEFAULT_AGENT_RENAME_DEPS,
55
- defaultIdentityProbes,
56
58
  defaultServiceRuntime,
57
59
  planHostRuntime,
58
60
  totalConfiguredWorkers,
@@ -62,8 +64,9 @@ import {
62
64
  RECOVER_SCRIPT_INSTALL_PATH,
63
65
  type AgentRenameDeps,
64
66
  type EscalationDeps,
67
+ type HerdrOwnership,
68
+ type HerdrOwnershipFacts,
65
69
  type ServiceRuntime,
66
- type WorkerIdentityProbes,
67
70
  } from "./setup-host.ts";
68
71
  import type { WizardUi } from "./wizard-ui.ts";
69
72
  import type { Caps, ProjectConfig, Store } from "./types.ts";
@@ -84,14 +87,6 @@ export interface InstallDeps {
84
87
  escalation?: EscalationDeps;
85
88
  /** `"linux"` gates the systemd half. Injectable so the refusal is testable. */
86
89
  platform?: () => string;
87
- /**
88
- * The worker identity plan's probes (#798). `undefined` means the real
89
- * host's read-only facts — production: every `setup host` installs and
90
- * grants the dedicated worker account. `null` disables the identity plan
91
- * entirely (the historical step list, for tests pinning it), and an object
92
- * is used as given (tests pinning a specific host state).
93
- */
94
- workerIdentityProbes?: WorkerIdentityProbes | null;
95
90
  unitDir?: string;
96
91
  /**
97
92
  * Where the recovery playbook installs. Injectable so the idempotency gate
@@ -133,6 +128,31 @@ export interface InstallDeps {
133
128
  * default is the same `herdr --session` CLI recover.sh and the tick use.
134
129
  */
135
130
  agentRename?: AgentRenameDeps;
131
+ /**
132
+ * The environment the caller's context is read from (#834): `$HERDR_ENV` says
133
+ * this shell is a pane inside the unit the install restarts. Injectable so
134
+ * the refusal is testable without a live Herdr session.
135
+ */
136
+ env?: Record<string, string | undefined>;
137
+ /**
138
+ * The live supervision facts and the one remedy for them (#893). Injectable
139
+ * so the ownership transfer and its readiness gate are testable without
140
+ * touching real systemd or a real herdr server — production reads
141
+ * `herdr session list --json` and `systemctl show`.
142
+ */
143
+ supervision?: SupervisionDeps;
144
+ }
145
+
146
+ /** The reads and the one write the #893 ownership transfer needs. */
147
+ export interface SupervisionDeps {
148
+ /** Who is serving the fleet session right now. */
149
+ facts: (session: string) => HerdrOwnershipFacts;
150
+ /** Bounded readiness polling between reads. */
151
+ sleep: (ms: number) => Promise<void>;
152
+ /** How many times the post-restart gate re-reads before giving up. */
153
+ attempts?: number;
154
+ /** Milliseconds between those reads. */
155
+ intervalMs?: number;
136
156
  }
137
157
 
138
158
  /**
@@ -199,13 +219,6 @@ export async function runHostInstall(
199
219
  // happens only after consent (#510), so the prompt's "Nothing has been run
200
220
  // yet" is true of the staged tree when it is printed, and a declined run
201
221
  // leaves every staged file byte-identical.
202
- // The worker identity plan rides the same consent (#798): production probes
203
- // the real host (account present? grants current?); a test may pass its own
204
- // probes, or `null` to plan the historical step list only.
205
- const identityProbes: WorkerIdentityProbes | null =
206
- deps.workerIdentityProbes === null
207
- ? null
208
- : deps.workerIdentityProbes ?? defaultIdentityProbes(deps.runtime ?? defaultServiceRuntime(telegramStateDir));
209
222
  const plan = planHostRuntime(
210
223
  project,
211
224
  caps,
@@ -215,7 +228,6 @@ export async function runHostInstall(
215
228
  unitDir,
216
229
  deps.recoverScriptInstallPath ?? RECOVER_SCRIPT_INSTALL_PATH,
217
230
  deps.multiProject ?? hostMultiProject(),
218
- identityProbes,
219
231
  );
220
232
  // No pane-shell key is planned (unusable login shell, unparseable config):
221
233
  // the operator hears why before the consent prompt, not after an install
@@ -257,6 +269,17 @@ export async function runHostInstall(
257
269
  return { kind: "installed", wrote: [] };
258
270
  }
259
271
 
272
+ // The caller must survive the transaction it started (#834). Placed here on
273
+ // purpose: after the plan and the no-op gate — so a host with no herdr unit,
274
+ // and a re-run with nothing to install, are never refused for a restart that
275
+ // is not going to happen — and before the drain fence and the post-consent
276
+ // staging, so a refusal has mutated nothing at all.
277
+ const context = checkSessionRestartContext("setup host", plan.steps, deps.env);
278
+ if (context.kind === "refuse") {
279
+ ui.notify(context.message, "error");
280
+ return { kind: "refused", reason: context.message };
281
+ }
282
+
260
283
  // argv, not shell: `installCommands` renders `sudo …` strings for humans to
261
284
  // read, and re-parsing those into an argv is how a path with a space becomes
262
285
  // two arguments. The plan carries the exact argv once, in
@@ -298,7 +321,47 @@ export async function runHostInstall(
298
321
  attributeRestartKills(fence.scope, project?.name ?? "", deps.drain?.drainStore);
299
322
  };
300
323
 
301
- const outcome = await runPrivileged(plan.steps, ui, {
324
+ // Who is serving the fleet session, before anything is staged or run (#893).
325
+ // A `systemctl restart` that returns 0 does not prove supervision moved: an
326
+ // unmanaged `herdr --session <name> server` holding the session makes every
327
+ // start of the unit exit 1, and `Type=simple` returns before the child finds
328
+ // out. So the collision is resolved by stopping the unmanaged session inside
329
+ // this transaction, or the transaction refuses before it mutates anything.
330
+ const supervision = deps.supervision ?? defaultSupervisionDeps();
331
+ const session = plan.herdrSession ?? DEFAULT_HERDR_SESSION;
332
+ let transferSteps: PrivilegedStep[] = [];
333
+ if (plan.herdrUnit !== undefined) {
334
+ const ownership = herdrOwnership(supervision.facts(session));
335
+ if (ownership.kind === "unknown") {
336
+ // The remedy here stops somebody's live session, so an unreadable fact is
337
+ // a refusal, never an assumption. Nothing has been staged or installed.
338
+ const reason =
339
+ `refused: ${ownership.detail}, so this install cannot tell whether ${DEFAULT_HERDR_UNIT} owns the ` +
340
+ `fleet session or an unmanaged server does. Stopping the wrong one kills a live fleet, so nothing ` +
341
+ `was staged or installed. Check \`systemctl status ${DEFAULT_HERDR_UNIT}\` and ` +
342
+ `\`herdr session list\`, then re-run.`;
343
+ ui.notify(reason, "error");
344
+ return { kind: "refused", reason };
345
+ }
346
+ if (ownership.kind === "unmanaged") {
347
+ ui.notify(
348
+ `The fleet session is served outside systemd: ${ownership.detail}. This install stops that server ` +
349
+ `first so ${DEFAULT_HERDR_UNIT} can take the session over — every pane in it restarts under the unit.`,
350
+ "warning",
351
+ );
352
+ transferSteps = [
353
+ {
354
+ // The fleet account's own session, so no escalation: `setup host`
355
+ // already runs as that account (the escalation guard proves it).
356
+ title: `stop the unmanaged herdr session "${session}" so ${DEFAULT_HERDR_UNIT} can own it`,
357
+ argv: ["herdr", "session", "stop", session],
358
+ unprivileged: true,
359
+ },
360
+ ];
361
+ }
362
+ }
363
+
364
+ const outcome = await runPrivileged([...transferSteps, ...plan.steps], ui, {
302
365
  ...(deps.privileged === undefined ? {} : { deps: deps.privileged }),
303
366
  title: "Install and start the supervised session?",
304
367
  answerKey: "install-host",
@@ -317,14 +380,22 @@ export async function runHostInstall(
317
380
  ]),
318
381
  ]),
319
382
  "The unit runs as the account that staged it; nothing here changes that.",
320
- ...(plan.workerIdentity === undefined
383
+ ...(transferSteps.length === 0
384
+ ? []
385
+ : [
386
+ `Stops the unmanaged herdr session "${session}" first — systemd cannot adopt a running process, so the ` +
387
+ "session has to be handed over. Every pane in it comes back under the unit.",
388
+ ]),
389
+ // Retirement is named before the confirm that authorises it (#895): an
390
+ // operator approving this batch is approving an unmount, and a removal
391
+ // nobody announced is not consented to.
392
+ ...(plan.retire === undefined
321
393
  ? []
322
394
  : [
323
- `Establishes the dedicated worker identity ${plan.workerIdentity.account}: every worker session runs under it, ` +
324
- "never under the daemon's account, and it is granted search/read access to exactly the fleet paths a worker needs.",
325
- ...(plan.workerIdentity.current
326
- ? ["The worker identity is already in place nothing to change."]
327
- : plan.workerIdentity.pending),
395
+ `Retires host state an earlier release installed and this one no longer ships: ` +
396
+ `${[...plan.retire.units, ...plan.retire.staged].join(", ")}.`,
397
+ "Worker sessions run as the fleet account again, so the read-only bind that served the separate " +
398
+ "worker account is unmounted and its unit removed. The account itself is left alone.",
328
399
  ]),
329
400
  "",
330
401
  liveWorkers === 0
@@ -343,6 +414,27 @@ export async function runHostInstall(
343
414
  if (fence !== undefined && !fence.initialPaused) drainDeps.setPaused(false, fence.scope.pauseKey);
344
415
  return { kind: "failed", reason: `${outcome.step.title} exited ${outcome.exitCode}` };
345
416
  }
417
+ // Success is not "the steps exited 0" (#893). `Type=simple` returns before the
418
+ // session server can discover a collision and die, so the transaction proves
419
+ // the canonical unit is still up AND owns the session — bounded, because an
420
+ // auto-restart loop keeps looking momentarily alive.
421
+ if (plan.herdrUnit !== undefined) {
422
+ const settled = await proveSupervision(supervision, session);
423
+ if (settled.kind !== "unit") {
424
+ // Dispatch stays paused, deliberately and unlike a failed step: the fleet
425
+ // is being served by something nobody supervises, and admitting workers
426
+ // into that is the outcome this gate exists to prevent. The operator is
427
+ // told what was observed rather than a generic failure.
428
+ const reason =
429
+ `${DEFAULT_HERDR_UNIT} did not take over the fleet session: ${settled.detail}. ` +
430
+ `The units are installed, but supervision did not transfer, so dispatch stays paused. ` +
431
+ `Check \`systemctl status ${DEFAULT_HERDR_UNIT}\` and \`herdr session list\`, then re-run \`setup host\` ` +
432
+ `from a shell outside the session.`;
433
+ ui.notify(reason, "error");
434
+ return { kind: "failed", reason };
435
+ }
436
+ ui.notify(`${DEFAULT_HERDR_UNIT} owns the fleet session — ${settled.detail}.`, "info");
437
+ }
346
438
  if (fence !== undefined && !fence.initialPaused) drainDeps.setPaused(false, fence.scope.pauseKey);
347
439
  ui.notify(`Installed and started ${STAGED_SERVICE_NAME}.`, "info");
348
440
  // #541: the tick plan may have restamped agentName away from the shared
@@ -376,6 +468,84 @@ export async function runHostInstall(
376
468
  return { kind: "installed", wrote };
377
469
  }
378
470
 
471
+ /**
472
+ * Poll until the canonical unit owns the fleet session, or the bound runs out
473
+ * (#893). Returns the last verdict either way, so the caller reports what was
474
+ * actually observed rather than "timed out".
475
+ *
476
+ * `absent` and `unmanaged` are both failures here, for different reasons: the
477
+ * first is a unit that started and died (the collision's own signature under
478
+ * `Type=simple`), the second is a session still held outside it.
479
+ */
480
+ async function proveSupervision(deps: SupervisionDeps, session: string): Promise<HerdrOwnership> {
481
+ const attempts = deps.attempts ?? SUPERVISION_PROBE_ATTEMPTS;
482
+ const intervalMs = deps.intervalMs ?? SUPERVISION_PROBE_INTERVAL_MS;
483
+ let verdict = herdrOwnership(deps.facts(session));
484
+ for (let attempt = 1; attempt < attempts && verdict.kind !== "unit"; attempt += 1) {
485
+ await deps.sleep(intervalMs);
486
+ verdict = herdrOwnership(deps.facts(session));
487
+ }
488
+ return verdict;
489
+ }
490
+
491
+ /** How many reads the readiness gate takes, and how far apart. Twelve seconds
492
+ * total: long enough for a session server to bind its socket, short enough
493
+ * that a restart loop is reported rather than waited out. */
494
+ const SUPERVISION_PROBE_ATTEMPTS = 12;
495
+ const SUPERVISION_PROBE_INTERVAL_MS = 1_000;
496
+
497
+ /** The real reads: herdr's own session list, and systemd's view of the unit. */
498
+ function defaultSupervisionDeps(): SupervisionDeps {
499
+ return {
500
+ facts: (session) => ({
501
+ sessionRunning: herdrSessionRunning(session),
502
+ unit: herdrUnitState(),
503
+ }),
504
+ sleep: Bun.sleep,
505
+ };
506
+ }
507
+
508
+ /** Whether herdr reports a live server for one session, or `undefined` when it
509
+ * could not be asked — never `false` on a failed read (#893). */
510
+ function herdrSessionRunning(session: string): boolean | undefined {
511
+ const ran = spawnSync("herdr", ["session", "list", "--json"], { encoding: "utf8", timeout: 10_000 });
512
+ if (ran.status !== 0 || typeof ran.stdout !== "string") return undefined;
513
+ try {
514
+ const parsed: unknown = JSON.parse(ran.stdout);
515
+ const sessions = parsed !== null && typeof parsed === "object" ? Reflect.get(parsed, "sessions") : undefined;
516
+ if (!Array.isArray(sessions)) return undefined;
517
+ const row = sessions.find(
518
+ (entry) => entry !== null && typeof entry === "object" && Reflect.get(entry, "name") === session,
519
+ );
520
+ if (row === undefined) return false;
521
+ return Reflect.get(row as object, "running") === true;
522
+ } catch {
523
+ return undefined;
524
+ }
525
+ }
526
+
527
+ /** The canonical unit's live state, or `undefined` when systemctl could not
528
+ * answer. A property systemd omits reads as unreadable rather than as zero. */
529
+ function herdrUnitState(): HerdrOwnershipFacts["unit"] {
530
+ const ran = spawnSync(
531
+ "systemctl",
532
+ ["show", DEFAULT_HERDR_UNIT, "-p", "ActiveState", "-p", "SubState", "-p", "MainPID", "-p", "NRestarts"],
533
+ { encoding: "utf8", timeout: 10_000 },
534
+ );
535
+ if (ran.status !== 0 || typeof ran.stdout !== "string") return undefined;
536
+ const values = new Map<string, string>();
537
+ for (const line of ran.stdout.split("\n")) {
538
+ const eq = line.indexOf("=");
539
+ if (eq > 0) values.set(line.slice(0, eq), line.slice(eq + 1));
540
+ }
541
+ const activeState = values.get("ActiveState");
542
+ const subState = values.get("SubState");
543
+ const mainPid = Number(values.get("MainPID"));
544
+ const nRestarts = Number(values.get("NRestarts"));
545
+ if (activeState === undefined || subState === undefined || !Number.isFinite(mainPid)) return undefined;
546
+ return { activeState, subState, mainPid, nRestarts: Number.isFinite(nRestarts) ? nRestarts : 0 };
547
+ }
548
+
379
549
  /**
380
550
  * The pause/drain fence's default accessors, overridable per test through
381
551
  * {@link InstallDeps.drain.deps}. Mirrors `upgrade.ts`'s {@link DrainDeps}
@@ -395,7 +565,14 @@ function hostInstallDrainDeps(ui: WizardUi, deps: InstallDeps): DrainDeps {
395
565
  : { running: true, project: daemon.project, generation: `${daemon.pid}@${daemon.startedAt}` };
396
566
  },
397
567
  pauseState: (project) => pauseInstance(project),
398
- setPaused: (v, project) => setPaused(v, { source: pauseSourceToken("setup host"), reason: "setup host, draining" }, project),
568
+ setPaused: (v, project) =>
569
+ // `owner` is this process (#938): the fence lives exactly as long as this
570
+ // transaction, so a kill leaves it behind with nothing to explain it.
571
+ setPaused(
572
+ v,
573
+ { source: pauseSourceToken("setup host"), reason: "setup host, draining", owner: process.pid },
574
+ project,
575
+ ),
399
576
  sleep: Bun.sleep,
400
577
  log: (message) => ui.notify(message, "info"),
401
578
  };