omp-conductor 0.15.11 → 0.15.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/REFERENCE.md +107 -60
  2. package/package.json +1 -1
  3. package/schema/config.schema.json +3 -0
  4. package/src/briefs/orchestrator.md +64 -11
  5. package/src/briefs/policy.md +19 -3
  6. package/src/briefs/worker.md +11 -8
  7. package/src/cli.ts +41 -21
  8. package/src/commands/context.ts +102 -1
  9. package/src/commands/doctor.ts +4 -2
  10. package/src/commands/intake.ts +26 -5
  11. package/src/commands/message.ts +80 -32
  12. package/src/commands/report.ts +38 -2
  13. package/src/commands/restart.ts +81 -54
  14. package/src/commands/setup.ts +61 -11
  15. package/src/commands/stop.ts +45 -22
  16. package/src/commands/upgrade-rollback.ts +9 -0
  17. package/src/config-schema.ts +9 -0
  18. package/src/config.ts +35 -1
  19. package/src/daemon.ts +588 -37
  20. package/src/dashboard/app.js +398 -59
  21. package/src/dashboard/index.html +27 -0
  22. package/src/dashboard/server.ts +219 -5
  23. package/src/dashboard/style.css +169 -1
  24. package/src/doctor.ts +419 -45
  25. package/src/escalate.ts +8 -0
  26. package/src/failure-class.ts +37 -0
  27. package/src/fleet.ts +49 -2
  28. package/src/gitops.ts +157 -0
  29. package/src/lifecycle.ts +113 -2
  30. package/src/model-fallback.ts +177 -0
  31. package/src/omp.ts +115 -13
  32. package/src/orchestrator-down.ts +231 -0
  33. package/src/orchestrator-tick.ts +108 -5
  34. package/src/orchestrator.ts +18 -4
  35. package/src/privileged.ts +10 -0
  36. package/src/release-policy.ts +373 -28
  37. package/src/session-host.ts +11 -5
  38. package/src/setup-host.ts +665 -70
  39. package/src/setup-install.ts +275 -28
  40. package/src/setup-wizard.ts +339 -126
  41. package/src/setup.ts +25 -0
  42. package/src/stop-provenance.ts +66 -0
  43. package/src/store.ts +194 -1
  44. package/src/tracker/github.ts +47 -0
  45. package/src/types.ts +182 -0
  46. package/src/upgrade.ts +110 -32
  47. package/src/verbs/protocol.ts +16 -3
  48. package/src/verbs/server.ts +27 -1
  49. package/src/wizard-ui.ts +261 -46
  50. package/src/worker.ts +24 -3
  51. package/systemd/omp-conductor.service.example +7 -3
@@ -18,7 +18,17 @@
18
18
  import { existsSync, readFileSync } from "node:fs";
19
19
  import { platform } from "node:os";
20
20
  import { join } from "node:path";
21
- import { stateDir } from "./config.ts";
21
+ import { loadConfig, stateDir } from "./config.ts";
22
+ import { pauseInstance, pauseSourceToken, setPaused, statusSnapshot } from "./daemon.ts";
23
+ import { fleetLayers, DEFAULT_HERDR_UNIT } from "./fleet.ts";
24
+ import { livingDaemon } from "./lifecycle.ts";
25
+ import { dbPath, openStore } from "./store.ts";
26
+ import {
27
+ resolveScope,
28
+ pauseAndDrain,
29
+ type DrainDeps,
30
+ type UpgradeScope,
31
+ } from "./upgrade.ts";
22
32
  import {
23
33
  graphRepos,
24
34
  formatGraphSetup,
@@ -34,18 +44,21 @@ import {
34
44
  import { probeCodeGraph, type CodeGraphHealth } from "./graph-health.ts";
35
45
  import { runPrivileged, type PrivilegedDeps, type PrivilegedStep } from "./privileged.ts";
36
46
  import {
47
+ agentRenameVerdict,
37
48
  checkEscalation,
49
+ DEFAULT_AGENT_RENAME_DEPS,
38
50
  planHostRuntime,
39
51
  totalConfiguredWorkers,
40
52
  writeHostRuntime,
41
53
  STAGED_SERVICE_NAME,
42
54
  SYSTEMD_UNIT_DIR,
55
+ RECOVER_SCRIPT_INSTALL_PATH,
56
+ type AgentRenameDeps,
43
57
  type EscalationDeps,
44
58
  type ServiceRuntime,
45
59
  } from "./setup-host.ts";
46
- import { DEFAULT_HERDR_UNIT } from "./fleet.ts";
47
60
  import type { WizardUi } from "./wizard-ui.ts";
48
- import type { Caps, ProjectConfig } from "./types.ts";
61
+ import type { Caps, ProjectConfig, Store } from "./types.ts";
49
62
 
50
63
  /**
51
64
  * Every outcome a caller has to tell apart. `staged` is the non-Linux answer:
@@ -64,12 +77,46 @@ export interface InstallDeps {
64
77
  /** `"linux"` gates the systemd half. Injectable so the refusal is testable. */
65
78
  platform?: () => string;
66
79
  unitDir?: string;
80
+ /**
81
+ * Where the recovery playbook installs. Injectable so the idempotency gate
82
+ * is testable hermetically; the real install uses
83
+ * {@link RECOVER_SCRIPT_INSTALL_PATH}.
84
+ */
85
+ recoverScriptInstallPath?: string;
67
86
  /**
68
87
  * The runtime the plan derives from. Injectable so tests pin herdr
69
88
  * present/absent deterministically; the default ({@link defaultServiceRuntime})
70
89
  * detects herdr from the host's own PATH.
71
90
  */
72
91
  runtime?: ServiceRuntime;
92
+ /**
93
+ * Whether the host configures more than one project, which scopes the
94
+ * recovery unit's RECOVER_PROJECT (host-global: never one project's name on
95
+ * a multi-project host, #510/#530). Injectable so tests pin the shape
96
+ * hermetically; the default ({@link hostMultiProject}) reads the config.
97
+ */
98
+ multiProject?: boolean;
99
+ /**
100
+ * The pause/drain fence around the restart (#512): which projects to drain,
101
+ * how long to wait, and the store used to attribute a run the restart kills.
102
+ * Injectable so tests pin the drain hermetically; the defaults read the real
103
+ * daemon, sentinel and store.
104
+ */
105
+ drain?: {
106
+ deps?: DrainDeps;
107
+ /** How long the drain waits for live workers before giving up and staying
108
+ * paused; absent waits indefinitely, the `upgrade` posture. */
109
+ timeoutMs?: number;
110
+ /** The store consulted for runs still live at restart time, so they are
111
+ * attributed before the session host restarts under them. */
112
+ drainStore?: Pick<Store, "liveRuns" | "updateRun">;
113
+ };
114
+ /**
115
+ * The live herdr CLI seam for the post-install agent rename (#541).
116
+ * Injectable so tests pin the verdict without touching the host's herdr; the
117
+ * default is the same `herdr --session` CLI recover.sh and the tick use.
118
+ */
119
+ agentRename?: AgentRenameDeps;
73
120
  }
74
121
 
75
122
  /**
@@ -100,11 +147,21 @@ function sameFile(a: string, b: string): boolean {
100
147
  }
101
148
 
102
149
  /**
103
- * Install the supervised daemon unit: re-render, stage, then run the four steps
104
- * `planHostRuntime` used to only print.
150
+ * Install the supervised daemon unit: re-render, stage, then run the plan's
151
+ * steps in order. The steps are the plan's own {@link HostRuntimePlan.steps} —
152
+ * the same single list its {@link installCommands} display renders from — so
153
+ * what is printed and what is executed can never diverge.
105
154
  */
106
155
  export async function runHostInstall(
107
- project: ProjectConfig,
156
+ /**
157
+ * The project whose per-project tail to write, or `undefined` for a
158
+ * host-global install: the units are host-global (same staged paths, same
159
+ * systemd destinations, one shared daemon), so no project still installs
160
+ * and starts them. The per-project tail (tick config, brief link) is the
161
+ * only part that needs a name — with none, it is skipped with a printed
162
+ * note (#530).
163
+ */
164
+ project: ProjectConfig | undefined,
108
165
  caps: Caps,
109
166
  telegramStateDir: string,
110
167
  ui: WizardUi,
@@ -116,40 +173,106 @@ export async function runHostInstall(
116
173
  return { kind: "refused", reason: verdict.message };
117
174
  }
118
175
 
119
- const plan = planHostRuntime(project, caps, telegramStateDir, deps.runtime, hostInstallWorkers(caps));
120
- const { wrote, warnings } = writeHostRuntime(plan);
121
- for (const warning of warnings) ui.notify(warning, "warning");
122
176
  const unitDir = deps.unitDir ?? SYSTEMD_UNIT_DIR;
177
+ // The plan is computed eagerly — `planHostRuntime` renders every byte and
178
+ // compares it against disk, so the prompt can print exactly what would be
179
+ // written and the currentInstall gate below can decide a re-run restarts
180
+ // nothing. But rendering is read-only: no file is written here. Staging
181
+ // happens only after consent (#510), so the prompt's "Nothing has been run
182
+ // yet" is true of the staged tree when it is printed, and a declined run
183
+ // leaves every staged file byte-identical.
184
+ const plan = planHostRuntime(
185
+ project,
186
+ caps,
187
+ telegramStateDir,
188
+ deps.runtime,
189
+ hostInstallWorkers(caps),
190
+ unitDir,
191
+ deps.recoverScriptInstallPath ?? RECOVER_SCRIPT_INSTALL_PATH,
192
+ deps.multiProject ?? hostMultiProject(),
193
+ );
194
+ // No pane-shell key is planned (unusable login shell, unparseable config):
195
+ // the operator hears why before the consent prompt, not after an install
196
+ // that quietly had nothing to say about the shell.
197
+ if (plan.herdrConfigWarning !== undefined) ui.notify(plan.herdrConfigWarning, "warning");
198
+ if (plan.herdrConfigProblem !== undefined) ui.notify(plan.herdrConfigProblem, "warning");
199
+ // A host-global install (no --project): the per-project tail was
200
+ // deliberately not written, and the operator must be told which files and
201
+ // how to get them — never a silent skip.
202
+ if (plan.noProject !== undefined) {
203
+ ui.notify(
204
+ `No project named: ${plan.noProject.skipped.join(", ")} were not written. ${plan.noProject.how}.`,
205
+ "info",
206
+ );
207
+ }
123
208
  const installed = join(unitDir, STAGED_SERVICE_NAME);
124
209
  const installedHerdr = join(unitDir, DEFAULT_HERDR_UNIT);
125
210
 
126
211
  const blocked = linuxOnly(deps);
127
212
  if (blocked !== undefined) {
213
+ // A non-Linux host cannot run systemd, so staging IS the deliverable: the
214
+ // rendered files are written for the operator to copy to the box that will
215
+ // run them, and only the systemctl half is refused. There is no consent
216
+ // prompt on this path — nothing privileged to approve — so the write lands
217
+ // here, not in the (never reached) post-consent hook.
218
+ const { wrote, warnings } = writeHostRuntime(plan);
219
+ for (const warning of warnings) ui.notify(warning, "warning");
128
220
  ui.notify(`${blocked} ${plan.service.path}`, "warning");
129
221
  return { kind: "staged", wrote, reason: `${blocked} ${plan.service.path}` };
130
222
  }
131
223
 
224
+ // A no-op re-run: every unit the steps would install is already current on
225
+ // disk, so installing them again would only touch mtimes and restart the
226
+ // units for nothing. Report success without running a single privileged step.
227
+ // Nothing is staged here either: a re-run with nothing to install has no
228
+ // reason to touch the staged tree.
229
+ if (plan.currentInstall) {
230
+ ui.notify("The supervised daemon and recovery unit are already current — nothing to install, nothing restarted.", "info");
231
+ return { kind: "installed", wrote: [] };
232
+ }
233
+
132
234
  // argv, not shell: `installCommands` renders `sudo …` strings for humans to
133
235
  // read, and re-parsing those into an argv is how a path with a space becomes
134
- // two arguments. The steps are built from the same values instead.
135
- const steps: PrivilegedStep[] = [
136
- { title: `install ${STAGED_SERVICE_NAME}`, argv: ["install", "-m", "0644", plan.service.path, installed] },
137
- { title: "reload systemd", argv: ["systemctl", "daemon-reload"] },
138
- { title: `enable ${STAGED_SERVICE_NAME}`, argv: ["systemctl", "enable", STAGED_SERVICE_NAME] },
139
- { title: `restart ${STAGED_SERVICE_NAME}`, argv: ["systemctl", "restart", STAGED_SERVICE_NAME] },
140
- // The herdr session server, provisioned alongside the daemon (#456). Only
141
- // when herdr is installed and the plan therefore staged a unit.
142
- ...(plan.herdrUnit === undefined
143
- ? []
144
- : [
145
- { title: `install ${DEFAULT_HERDR_UNIT}`, argv: ["install", "-m", "0644", plan.herdrUnit.path, installedHerdr] },
146
- { title: "reload systemd", argv: ["systemctl", "daemon-reload"] },
147
- { title: `enable ${DEFAULT_HERDR_UNIT}`, argv: ["systemctl", "enable", DEFAULT_HERDR_UNIT] },
148
- { title: `restart ${DEFAULT_HERDR_UNIT}`, argv: ["systemctl", "restart", DEFAULT_HERDR_UNIT] },
149
- ]),
150
- ];
151
-
152
- const outcome = await runPrivileged(steps, ui, {
236
+ // two arguments. The plan carries the exact argv once, in
237
+ // {@link HostRuntimePlan.steps}; we execute that same list, so the printed
238
+ // plan and the run are the same steps (#509).
239
+ //
240
+ // The fence `upgrade` already has (#512): pause new issue claims, drain live
241
+ // workers to idle, restart the units, then restore the dispatch state this
242
+ // began in. The consent prompt below names how many workers will be drained
243
+ // so the operator can choose the moment; it authorises the whole batch,
244
+ // drain included, in one confirm (the `beforeRun` hook).
245
+ const drainDeps = hostInstallDrainDeps(ui, deps);
246
+ // Host-wide by default (#389): the daemon this restarts serves every
247
+ // configured project, so the drain covers all of them — narrowing to the
248
+ // named project would restart the shared session host under the other
249
+ // projects' live workers without ever counting them.
250
+ const fenceScope = resolveScope(drainDeps, "setup host");
251
+ const liveWorkers = fenceScope.selectors.reduce((n, s) => n + drainDeps.snapshot(s).liveWorkers, 0);
252
+ let fence: { scope: UpgradeScope; initialPaused: boolean } | undefined;
253
+ // What the post-consent staging actually wrote, surfaced by the outcomes that
254
+ // report it. Empty on a decline — the hook never ran.
255
+ let wrote: readonly string[] = [];
256
+ const beforeRun = async (): Promise<void> => {
257
+ // Consent has been given; the plan's files may now land. Staging here —
258
+ // post-confirm, pre-step, under the same confirm that authorised the batch
259
+ // — is what makes the prompt's "Nothing has been run yet" true of the
260
+ // staged tree when it is printed: a declined prompt never reaches this
261
+ // hook, so nothing is staged and a re-run still plans an install (#510).
262
+ const staged = writeHostRuntime(plan);
263
+ wrote = staged.wrote;
264
+ for (const warning of staged.warnings) ui.notify(warning, "warning");
265
+ fence = await pauseAndDrain(drainDeps, "setup host", {
266
+ ...(deps.drain?.timeoutMs === undefined ? {} : { timeoutMs: deps.drain.timeoutMs }),
267
+ });
268
+ // Any run still live when the session host is about to restart is killed by
269
+ // it; record the actor on its row so the classifier reads an administrative
270
+ // kill rather than charging a failure for an operator's restart (#512). The
271
+ // fence drained to idle, so in the healthy path this records nothing.
272
+ attributeRestartKills(fence.scope, project?.name ?? "", deps.drain?.drainStore);
273
+ };
274
+
275
+ const outcome = await runPrivileged(plan.steps, ui, {
153
276
  ...(deps.privileged === undefined ? {} : { deps: deps.privileged }),
154
277
  title: "Install and start the supervised session?",
155
278
  preamble: [
@@ -159,18 +282,126 @@ export async function runHostInstall(
159
282
  : [
160
283
  `Also installs the fleet session unit as ${installedHerdr} with the account's login shell so panes`,
161
284
  "run it instead of dash, then enables and restarts it.",
285
+ ...(plan.herdrConfigTarget === undefined
286
+ ? []
287
+ : [
288
+ `And merges [terminal] default_shell into ${plan.herdrConfigTarget} as you, before that restart,`,
289
+ "so the session server reads the pinned shell the moment it comes up.",
290
+ ]),
162
291
  ]),
163
292
  "The unit runs as the account that staged it; nothing here changes that.",
293
+ "",
294
+ liveWorkers === 0
295
+ ? "No workers are live — nothing to drain before the restart."
296
+ : `Pauses new claims and drains ${liveWorkers} live worker(s) to idle first — this can take minutes per worker — then restarts the units and resumes dispatch.`,
164
297
  ],
298
+ beforeRun,
165
299
  });
166
300
  if (outcome.kind === "declined") return { kind: "declined", wrote };
167
301
  if (outcome.kind === "failed") {
302
+ // Restore the preceding dispatch state: a restart that failed is still a
303
+ // finished operator action whose units may or may not be up, so the fleet
304
+ // must not sit paused for it. A throw from the drain itself (a timeout or
305
+ // a superseded fence) never reaches here — it propagates and leaves
306
+ // dispatch paused, fail-closed, exactly as upgrade does.
307
+ if (fence !== undefined && !fence.initialPaused) drainDeps.setPaused(false, fence.scope.pauseKey);
168
308
  return { kind: "failed", reason: `${outcome.step.title} exited ${outcome.exitCode}` };
169
309
  }
310
+ if (fence !== undefined && !fence.initialPaused) drainDeps.setPaused(false, fence.scope.pauseKey);
170
311
  ui.notify(`Installed and started ${STAGED_SERVICE_NAME}.`, "info");
312
+ // #541: the tick plan may have restamped agentName away from the shared
313
+ // default — the exact moment the live pane is stuck under the old name and
314
+ // every tick declines with the rename hint. Perform the rename now that the
315
+ // herdr session has (re)started, safely: exactly one pane carrying the old
316
+ // identity is renamed; zero, several, or an unreadable agent list prints the
317
+ // exact command and says why it was not run. Never guesses a pane.
318
+ if (plan.agentRename !== undefined) {
319
+ const renameDeps = deps.agentRename ?? DEFAULT_AGENT_RENAME_DEPS;
320
+ const verdict = agentRenameVerdict(
321
+ renameDeps.list(plan.agentRename.session),
322
+ plan.agentRename.from,
323
+ plan.agentRename.to,
324
+ plan.agentRename.session,
325
+ );
326
+ if (verdict.kind === "rename") {
327
+ const ran = renameDeps.rename(plan.agentRename.session, verdict.pane, plan.agentRename.to);
328
+ if (ran.ok) {
329
+ ui.notify(`Renamed the herdr agent in pane ${verdict.pane} to ${plan.agentRename.to}.`, "info");
330
+ } else {
331
+ ui.notify(
332
+ `The herdr agent rename did not go through (${ran.problem ?? "unknown"}): ${verdict.command} — the pane still carries the old name.`,
333
+ "warning",
334
+ );
335
+ }
336
+ } else {
337
+ ui.notify(`${verdict.reason}; run it by hand: ${verdict.command}`, "warning");
338
+ }
339
+ }
171
340
  return { kind: "installed", wrote };
172
341
  }
173
342
 
343
+ /**
344
+ * The pause/drain fence's default accessors, overridable per test through
345
+ * {@link InstallDeps.drain.deps}. Mirrors `upgrade.ts`'s {@link DrainDeps}
346
+ * wiring, except the pause names `setup host` as the actor and the drain
347
+ * reports progress through the same UI surface.
348
+ */
349
+ function hostInstallDrainDeps(ui: WizardUi, deps: InstallDeps): DrainDeps {
350
+ if (deps.drain?.deps !== undefined) return deps.drain.deps;
351
+ return {
352
+ snapshot: statusSnapshot,
353
+ layers: fleetLayers,
354
+ projectNames: () => loadConfig().projects.map((p) => p.name),
355
+ daemonIdentity: () => {
356
+ const daemon = livingDaemon();
357
+ return daemon === undefined
358
+ ? { running: false }
359
+ : { running: true, project: daemon.project, generation: `${daemon.pid}@${daemon.startedAt}` };
360
+ },
361
+ pauseState: (project) => pauseInstance(project),
362
+ setPaused: (v, project) => setPaused(v, { source: pauseSourceToken("setup host"), reason: "setup host, draining" }, project),
363
+ sleep: Bun.sleep,
364
+ log: (message) => ui.notify(message, "info"),
365
+ };
366
+ }
367
+
368
+ /**
369
+ * Attribute the runs the restart is about to kill: any run still live when the
370
+ * session host restarts underneath it is that restart's casualty, not a failed
371
+ * attempt, so its row records the actor. The classifier reads this as an
372
+ * administrative kill that costs no budget and requeues (#512). The fence
373
+ * drains to idle first, so in the healthy path this records nothing — it is
374
+ * the safety net for a claim that raced the drain or a worker the drain could
375
+ * not bring to idle.
376
+ */
377
+ function attributeRestartKills(
378
+ scope: UpgradeScope,
379
+ project: string,
380
+ store: Pick<Store, "liveRuns" | "updateRun"> | undefined,
381
+ ): void {
382
+ const attribute = (owner: Pick<Store, "liveRuns" | "updateRun">): void => {
383
+ for (const selector of scope.selectors) {
384
+ for (const run of owner.liveRuns(selector ?? project)) {
385
+ owner.updateRun(run.id, { lastError: "admin restart: setup host (draining)" });
386
+ }
387
+ }
388
+ };
389
+ if (store !== undefined) {
390
+ attribute(store);
391
+ return;
392
+ }
393
+ const path = dbPath();
394
+ // No store yet — a host that has never dispatched — means nothing to attribute
395
+ // and nothing a fresh open would do but create an empty database.
396
+ if (!existsSync(path)) return;
397
+ const owner = openStore(path);
398
+ try {
399
+ attribute(owner);
400
+ } finally {
401
+ owner.close();
402
+ }
403
+ }
404
+
174
405
  /** Fleet-wide worker sum when config is loadable; otherwise this project's caps. */
175
406
  function hostInstallWorkers(caps: Caps): number {
176
407
  try {
@@ -180,6 +411,22 @@ function hostInstallWorkers(caps: Caps): number {
180
411
  }
181
412
  }
182
413
 
414
+ /**
415
+ * Whether this host configures more than one project. The recovery unit is
416
+ * host-global and must not encode one project's name when several are
417
+ * configured, so the plan scopes RECOVER_PROJECT by this (#510/#530). A
418
+ * loadable config is the truth; when it cannot be read (a not-yet-configured
419
+ * box), there is no evidence of several projects and the named case stays
420
+ * unambiguous — the same fallback shape as {@link hostInstallWorkers}.
421
+ */
422
+ function hostMultiProject(): boolean {
423
+ try {
424
+ return loadConfig().projects.length > 1;
425
+ } catch {
426
+ return false;
427
+ }
428
+ }
429
+
183
430
  export interface GraphInstallOptions extends InstallDeps {
184
431
  /**
185
432
  * Skip the seeding step. The timer is still installed and enabled, and the