omp-conductor 0.18.2 → 0.19.1

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 (60) hide show
  1. package/README.md +105 -40
  2. package/REFERENCE.md +865 -30
  3. package/package.json +1 -1
  4. package/schema/config.schema.json +26 -0
  5. package/src/admission.ts +212 -26
  6. package/src/ask.ts +288 -1
  7. package/src/briefs/orchestrator.md +6 -5
  8. package/src/cli.ts +5 -1
  9. package/src/command-help.ts +9 -1
  10. package/src/command-manifest.ts +36 -3
  11. package/src/commands/arm.ts +5 -1
  12. package/src/commands/context.ts +2 -0
  13. package/src/commands/message.ts +26 -2
  14. package/src/commands/reconcile-units.ts +104 -0
  15. package/src/commands/release-composition.ts +232 -0
  16. package/src/commands/resume.ts +2 -27
  17. package/src/commands/setup.ts +101 -16
  18. package/src/commands/stats.ts +11 -30
  19. package/src/commands/tail.ts +31 -1
  20. package/src/commands/upgrade.ts +20 -3
  21. package/src/commands/verb.ts +2 -1
  22. package/src/config-schema.ts +19 -0
  23. package/src/config.ts +80 -0
  24. package/src/credential-class.ts +366 -0
  25. package/src/daemon.ts +1218 -288
  26. package/src/dashboard/app.js +504 -2
  27. package/src/dashboard/controls.ts +336 -0
  28. package/src/dashboard/index.html +30 -0
  29. package/src/dashboard/server.ts +271 -30
  30. package/src/dashboard/style.css +116 -0
  31. package/src/dashboard/transcript.ts +173 -0
  32. package/src/doctor.ts +379 -22
  33. package/src/failure-class.ts +59 -0
  34. package/src/fleet.ts +511 -101
  35. package/src/host.ts +6 -130
  36. package/src/omp.ts +29 -0
  37. package/src/orchestrator-tick.ts +343 -88
  38. package/src/pause.ts +233 -0
  39. package/src/settlement.ts +159 -2
  40. package/src/setup-answers.ts +97 -0
  41. package/src/setup-host.ts +325 -1159
  42. package/src/setup-install.ts +204 -27
  43. package/src/setup-wizard.ts +111 -50
  44. package/src/setup.ts +33 -0
  45. package/src/spend-telemetry.ts +117 -0
  46. package/src/stats.ts +35 -0
  47. package/src/status-render.ts +348 -19
  48. package/src/store.ts +1229 -55
  49. package/src/telegram-freshness.ts +269 -0
  50. package/src/to-spec.ts +27 -0
  51. package/src/types.ts +697 -4
  52. package/src/unblock.ts +22 -0
  53. package/src/unit-reconcile.ts +303 -0
  54. package/src/upgrade-verify.ts +8 -1
  55. package/src/upgrade.ts +326 -47
  56. package/src/verbs/actions.ts +124 -10
  57. package/src/verbs/protocol.ts +70 -2
  58. package/src/verbs/server.ts +447 -8
  59. package/src/wake.ts +48 -0
  60. package/src/worker.ts +403 -3
package/src/upgrade.ts CHANGED
@@ -1,11 +1,10 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
2
3
  import { backupTimestamp, copyToUniqueBackup } from "./backups.ts";
3
4
  import { inspectBriefLayout, type BriefLayout } from "./brief-upgrade.ts";
4
5
  import { pauseInstance, setPaused, statusSnapshot, type AdmissionAckRecord } from "./daemon.ts";
5
6
  import {
6
- DEFAULT_HERDR_SESSION,
7
7
  fleetLayers,
8
- LEGACY_HERDR_SESSION_HINT,
9
8
  resolveHerdrSession,
10
9
  telegramStateDir,
11
10
  } from "./fleet.ts";
@@ -23,6 +22,7 @@ import {
23
22
  appendJournal,
24
23
  readUpgradeJournal,
25
24
  upgradeJournalPath,
25
+ type UpgradeCheck,
26
26
  type UpgradeJournalEntry,
27
27
  } from "./upgrade-journal.ts";
28
28
  import {
@@ -43,6 +43,13 @@ import {
43
43
  const PACKAGE = "omp-conductor";
44
44
  const HERDR_PLUGIN = "herdr-conductor";
45
45
  const HERDR_SOURCE = "TerrifiedBug/conductor/herdr";
46
+
47
+ /** The package root inside the repo, for a bootstrap install by git ref. */
48
+ const PACKAGE_SOURCE = "TerrifiedBug/conductor/omp";
49
+
50
+ /** A 40-hex commit: the only identity shape a bootstrap accepts. A branch or a
51
+ * tag is a moving target, and "the exact build I verified" is the whole point. */
52
+ const COMMIT_SHA = /^[0-9a-f]{40}$/i;
46
53
  const HERDR_UNIT = "herdr-fleet.service";
47
54
  const DRAIN_POLL_MS = 5_000;
48
55
  const RECOVERY_POLL_MS = 2_000;
@@ -50,6 +57,22 @@ const RECOVERY_ATTEMPTS = 30;
50
57
 
51
58
  export interface UpgradeOptions {
52
59
  version?: string;
60
+ /**
61
+ * Bootstrap from an exact source/build identity instead of a published semver
62
+ * (#908) — the path that exists because a reliability release cannot be cut
63
+ * when the *installed* conductor is the thing that is broken: publishing
64
+ * needs working workers, and the workers are what is broken.
65
+ *
66
+ * `sha` is the whole identity (a 40-hex commit, never a range), and `source`
67
+ * is a checkout of exactly that commit — its only job is to be the tree the
68
+ * package's own checks run against before anything is installed. The install
69
+ * itself is by git ref, so the identity is globally attestable rather than
70
+ * dependent on one machine's directory.
71
+ *
72
+ * Mutually exclusive with {@link version}: two identity kinds in one call is
73
+ * an ambiguity, not a merge.
74
+ */
75
+ bootstrap?: { sha: string; source: string };
53
76
  project?: string;
54
77
  /**
55
78
  * Run as the detached fleet installer (#486): journal every surface the
@@ -71,6 +94,15 @@ export interface UpgradeResult {
71
94
 
72
95
  export interface UpgradeDeps {
73
96
  run(command: string, args: readonly string[]): Promise<UpgradeCommandResult>;
97
+ /**
98
+ * A command run in a named working directory (#908): the bootstrap's checks
99
+ * run inside the source tree, and `run` is deliberately cwd-free everywhere
100
+ * else so no other step can depend on where it was invoked from.
101
+ */
102
+ runIn(cwd: string, command: string, args: readonly string[]): Promise<UpgradeCommandResult>;
103
+ /** Read one file as text. Injected so a bootstrap's manifest read is
104
+ * testable without a checkout on disk. */
105
+ readFile(path: string): string;
74
106
  snapshot(project?: string): { liveWorkers: number };
75
107
  layers(project?: string): FleetLayers;
76
108
  brief(project?: string): { kind: BriefLayout["kind"]; current: boolean };
@@ -99,6 +131,14 @@ export interface UpgradeDeps {
99
131
  setPaused(value: boolean, project?: string): void;
100
132
  restartDaemon(): Promise<void>;
101
133
  sleep(ms: number): Promise<void>;
134
+ /**
135
+ * The environment every surface of this transaction resolves from — the
136
+ * herdr session included, through {@link resolveHerdrSession}. One source,
137
+ * because the `herdr plugin list` read and the pane probe MUST agree on
138
+ * which session this fleet is: when they did not, `upgrade` installed
139
+ * against one session and failed verification against another, then rolled
140
+ * a good release back (#976).
141
+ */
102
142
  env: NodeJS.ProcessEnv;
103
143
  log(message: string): void;
104
144
  /**
@@ -108,11 +148,6 @@ export interface UpgradeDeps {
108
148
  * session process actually loaded.
109
149
  */
110
150
  health(port: number): Promise<{ ok: boolean; body?: string }>;
111
- /**
112
- * The herdr session the fleet pane lives in, for the external-orchestrator
113
- * pane probe (#832).
114
- */
115
- herdrSession(): string;
116
151
  /**
117
152
  * Every omp process the fleet pane currently claims by herdr, resolved to
118
153
  * its start time — the evidence an external (pane-owned) orchestrator
@@ -145,6 +180,8 @@ export interface UpgradeDeps {
145
180
  * post-restart verifier and the rollback unit run processes the same way. */
146
181
  export const DEFAULT_DEPS: UpgradeDeps = {
147
182
  run: runCommand,
183
+ runIn: (cwd, command, args) => runCommand(command, args, cwd),
184
+ readFile: (path) => readFileSync(path, "utf8"),
148
185
  snapshot: statusSnapshot,
149
186
  layers: fleetLayers,
150
187
  brief: (projectName) => {
@@ -172,7 +209,6 @@ export const DEFAULT_DEPS: UpgradeDeps = {
172
209
  await restartDaemon({});
173
210
  },
174
211
  health: async (port) => healthCheck(port),
175
- herdrSession: () => resolveHerdrSession(process.env),
176
212
  probePaneOmp: (session) => herdrPaneOmpStarts(runCommand, session, processStartTimeMs),
177
213
  // The bare host-global plan — the same render the advisory's `setup host`
178
214
  // command would install: no per-project tail, the recovery unit encoding no
@@ -191,7 +227,7 @@ function commandLine(command: string, args: readonly string[]): string {
191
227
  }
192
228
 
193
229
  async function mustRun(
194
- deps: UpgradeDeps,
230
+ deps: SurfaceProbeDeps,
195
231
  command: string,
196
232
  args: readonly string[],
197
233
  ): Promise<UpgradeCommandResult> {
@@ -222,6 +258,108 @@ function parseRegistry(raw: string): { version: string; gitHead: string } {
222
258
  return { version, gitHead };
223
259
  }
224
260
 
261
+ /**
262
+ * One release identity plus how to install it (#908).
263
+ *
264
+ * Two kinds, one shape downstream: everything after this — the currentness
265
+ * comparison, the journal, the install, the attestation and the rollback —
266
+ * reads a `{ version, gitHead }` pair and an install spec, so a bootstrap is a
267
+ * second accepted identity rather than a parallel installer.
268
+ */
269
+ interface ReleaseIdentityPlan {
270
+ version: string;
271
+ gitHead: string;
272
+ /** What `bun add -g` / `omp plugin install` are given for this identity. */
273
+ packageSpec: string;
274
+ /** Named checks already run to earn this identity, journalled as evidence. */
275
+ checks: UpgradeCheck[];
276
+ /** True for a source/build identity: npm does not have this version. */
277
+ bootstrap: boolean;
278
+ }
279
+
280
+ /** The version a source tree declares — the identity's other half, read from
281
+ * the tree itself because npm has never seen it. */
282
+ function sourceVersion(deps: UpgradeDeps, source: string): string {
283
+ const manifest = join(source, "omp", "package.json");
284
+ let raw: string;
285
+ try {
286
+ raw = deps.readFile(manifest);
287
+ } catch (err) {
288
+ throw new Error(
289
+ `bootstrap refused: cannot read ${manifest} (${err instanceof Error ? err.message : String(err)}) — ` +
290
+ "--source must be a checkout of this repository",
291
+ );
292
+ }
293
+ let parsed: unknown;
294
+ try {
295
+ parsed = JSON.parse(raw);
296
+ } catch {
297
+ throw new Error(`bootstrap refused: ${manifest} is not valid JSON`);
298
+ }
299
+ const version = parsed !== null && typeof parsed === "object" ? Reflect.get(parsed, "version") : undefined;
300
+ if (typeof version !== "string" || version.length === 0) {
301
+ throw new Error(`bootstrap refused: ${manifest} declares no version`);
302
+ }
303
+ return version;
304
+ }
305
+
306
+ /**
307
+ * Resolve which release this run installs, and prove a bootstrap identity
308
+ * before it can touch anything (#908).
309
+ *
310
+ * The three refusals are the deliverable, not the install: a sha that is not a
311
+ * sha, a source tree that is not at that sha, and a source tree whose own
312
+ * checks fail. Each names the identity and what failed, and each happens before
313
+ * the first surface is replaced — an unverified tree must never become a live
314
+ * install, which is the difference between this and the `bun add github:...`
315
+ * POLICY forbids.
316
+ */
317
+ async function resolveIdentity(deps: UpgradeDeps, options: UpgradeOptions): Promise<ReleaseIdentityPlan> {
318
+ const bootstrap = options.bootstrap;
319
+ if (bootstrap === undefined) {
320
+ const requested = options.version === undefined ? `${PACKAGE}@latest` : `${PACKAGE}@${options.version}`;
321
+ const release = parseRegistry(
322
+ (await mustRun(deps, "npm", ["view", requested, "version", "gitHead", "--json"])).stdout,
323
+ );
324
+ return { ...release, packageSpec: `${PACKAGE}@${release.version}`, checks: [], bootstrap: false };
325
+ }
326
+ if (options.version !== undefined) {
327
+ throw new Error("bootstrap refused: pass either --to VERSION or --bootstrap SHA, never both");
328
+ }
329
+ const sha = bootstrap.sha.toLowerCase();
330
+ if (!COMMIT_SHA.test(sha)) {
331
+ throw new Error(`bootstrap refused: "${bootstrap.sha}" is not a 40-character commit sha`);
332
+ }
333
+ const checks: UpgradeCheck[] = [];
334
+ // The tree must BE that commit. Without this the identity is a label the
335
+ // operator typed, and the checks below would attest a different build than
336
+ // the one installed.
337
+ const head = await deps.run("git", ["-C", bootstrap.source, "rev-parse", "HEAD"]);
338
+ const at = head.stdout.trim().toLowerCase();
339
+ if (head.code !== 0 || at !== sha) {
340
+ checks.push({ name: "source-at-sha", ok: false, detail: at.length === 0 ? head.stderr.trim() : at });
341
+ throw new Error(
342
+ `bootstrap refused: ${bootstrap.source} is at ${at.length === 0 ? "an unreadable HEAD" : at}, not ${sha} — ` +
343
+ "the checks would attest a different build than the one installed",
344
+ );
345
+ }
346
+ checks.push({ name: "source-at-sha", ok: true, detail: sha });
347
+ const version = sourceVersion(deps, bootstrap.source);
348
+ // The package's own declared gate, against the source, before anything is
349
+ // installed. A failure refuses: a partially verified tree must not go live.
350
+ deps.log(`bootstrap: running the package checks against ${bootstrap.source} (${sha.slice(0, 12)})`);
351
+ const gate = await deps.runIn(join(bootstrap.source, "omp"), "bun", ["run", "check"]);
352
+ if (gate.code !== 0) {
353
+ const detail = (gate.stderr.trim() || gate.stdout.trim()).split("\n").slice(-3).join("; ");
354
+ checks.push({ name: "package-checks", ok: false, detail });
355
+ throw new Error(
356
+ `bootstrap refused: \`bun run check\` failed against ${bootstrap.source} at ${sha} — ${detail}`,
357
+ );
358
+ }
359
+ checks.push({ name: "package-checks", ok: true });
360
+ return { version, gitHead: sha, packageSpec: `github:${PACKAGE_SOURCE}#${sha}`, checks, bootstrap: true };
361
+ }
362
+
225
363
  function ompPluginVersion(raw: string): string | undefined {
226
364
  let parsed: unknown;
227
365
  try {
@@ -255,39 +393,44 @@ function herdrPluginSource(raw: string): string | undefined {
255
393
  return source;
256
394
  }
257
395
 
258
- interface InstalledSurfaces {
396
+ /** The three installed identities one host carries: the Bun-global CLI/daemon
397
+ * tree, the omp plugin, and the herdr recovery plugin's pin. */
398
+ export interface InstalledSurfaces {
259
399
  cliVersion: string;
260
400
  ompVersion?: string;
261
401
  herdrSource?: string;
262
402
  }
263
403
 
264
- async function inspectSurfaces(deps: UpgradeDeps): Promise<InstalledSurfaces> {
265
- // HERDR_SESSION is authoritative. Otherwise prefer DEFAULT_HERDR_SESSION, with
266
- // #320's one-release bridge to a populated legacy "fleet" session.
267
- let session = resolveHerdrSession(deps.env);
268
- if (deps.env["HERDR_SESSION"] === undefined || deps.env["HERDR_SESSION"] === "") {
269
- // Prefer a session that answers plugin list; fall back to conductor.
270
- let conductorOk = false;
271
- let fleetOk = false;
272
- try {
273
- const r = await deps.run("herdr", ["--session", DEFAULT_HERDR_SESSION, "plugin", "list"]);
274
- conductorOk = r.code === 0;
275
- } catch {
276
- conductorOk = false;
277
- }
278
- try {
279
- const r = await deps.run("herdr", ["--session", "fleet", "plugin", "list"]);
280
- fleetOk = r.code === 0;
281
- } catch {
282
- fleetOk = false;
283
- }
284
- if (!conductorOk && fleetOk) {
285
- session = "fleet";
286
- deps.log(LEGACY_HERDR_SESSION_HINT);
287
- } else {
288
- session = DEFAULT_HERDR_SESSION;
289
- }
404
+ /** What reading the three surfaces needs — deliberately narrower than
405
+ * {@link UpgradeDeps} so a read-only consumer (`doctor`'s surface-parity
406
+ * finding, #904) can supply three fields instead of a whole transaction. */
407
+ export type SurfaceProbeDeps = Pick<UpgradeDeps, "run" | "log" | "env">;
408
+
409
+ /**
410
+ * Read the CLI, omp-plugin and herdr-plugin identities actually installed on
411
+ * this host. Exported because `upgrade` is not the only thing that needs to
412
+ * know whether the three agree: a host whose installs are manual diverges
413
+ * silently between transactions, and `doctor` reads them through this one
414
+ * seam rather than growing a second implementation (#904).
415
+ */
416
+ export async function inspectSurfaces(
417
+ deps: SurfaceProbeDeps,
418
+ /** `readHerdr: false` skips the herdr session probe and plugin read
419
+ * entirely, so a host with no herdr on PATH can still be asked what its
420
+ * CLI and omp plugin are (#904). The upgrade transaction always reads all
421
+ * three a release pins every surface. */
422
+ opts: { readHerdr?: boolean } = {},
423
+ ): Promise<InstalledSurfaces> {
424
+ if (opts.readHerdr === false) {
425
+ const [cliOnly, ompOnly] = await Promise.all([
426
+ mustRun(deps, "omp-conductor", ["--version"]),
427
+ mustRun(deps, "omp", ["plugin", "list", "--json"]),
428
+ ]);
429
+ const version = cliOnly.stdout.trim();
430
+ if (version.length === 0) throw new Error("installed omp-conductor CLI has no version");
431
+ return { cliVersion: version, ompVersion: ompPluginVersion(ompOnly.stdout) };
290
432
  }
433
+ const session = resolveHerdrSession(deps.env);
291
434
  const [cli, omp, herdr] = await Promise.all([
292
435
  mustRun(deps, "omp-conductor", ["--version"]),
293
436
  mustRun(deps, "omp", ["plugin", "list", "--json"]),
@@ -302,12 +445,25 @@ async function inspectSurfaces(deps: UpgradeDeps): Promise<InstalledSurfaces> {
302
445
  };
303
446
  }
304
447
 
305
- function expectedHerdrSource(gitHead: string): string {
448
+ export function expectedHerdrSource(gitHead: string): string {
306
449
  return `github:${HERDR_SOURCE}@${gitHead}`;
307
450
  }
308
451
 
452
+ /**
453
+ * The published identity of one release: its version and the exact commit it
454
+ * was cut from, straight from the registry metadata `upgrade` already trusts.
455
+ * Exported so a read-only consumer can answer "which commit should this
456
+ * host's herdr plugin be pinned to?" without re-deriving the mapping (#904).
457
+ */
458
+ export async function releaseIdentity(
459
+ deps: SurfaceProbeDeps,
460
+ version: string,
461
+ ): Promise<{ version: string; gitHead: string }> {
462
+ return parseRegistry((await mustRun(deps, "npm", ["view", `${PACKAGE}@${version}`, "version", "gitHead", "--json"])).stdout);
463
+ }
464
+
309
465
  function surfacesCurrent(
310
- surfaces: Awaited<ReturnType<typeof inspectSurfaces>>,
466
+ surfaces: InstalledSurfaces,
311
467
  version: string,
312
468
  gitHead: string,
313
469
  ): boolean {
@@ -727,7 +883,7 @@ async function sessionVerifyProblem(
727
883
  // No daemon to attest: only the pane could have reloaded anything, so the
728
884
  // pane probe decides alone, exactly as the external leg of the detached
729
885
  // verifier does.
730
- const pane = await deps.probePaneOmp(deps.herdrSession());
886
+ const pane = await deps.probePaneOmp(resolveHerdrSession(deps.env));
731
887
  if ("problem" in pane) return `the fleet pane could not be probed: ${pane.problem}`;
732
888
  return paneRestartProblem(pane.starts, reloadAfterMs) ?? undefined;
733
889
  }
@@ -753,7 +909,7 @@ async function sessionVerifyProblem(
753
909
  if (names.length === 0) {
754
910
  return `the restarted daemon's /healthz on :${port} named no project — the live session cannot be attested`;
755
911
  }
756
- const pane = await deps.probePaneOmp(deps.herdrSession());
912
+ const pane = await deps.probePaneOmp(resolveHerdrSession(deps.env));
757
913
  for (const project of names) {
758
914
  const problem = sessionReloadProblem({
759
915
  facts: orchestratorFactsFromHealth(health.body, project),
@@ -992,6 +1148,26 @@ export async function rollbackUpgrade(
992
1148
  }
993
1149
  }
994
1150
 
1151
+ // The units the forward transaction reconciled (#905). Restoring them is the
1152
+ // same operation in the other direction: the *restored* CLI renders the
1153
+ // previous version's templates, so running its own reconcile puts the
1154
+ // pre-upgrade bytes back at every destination — and leaves anything the
1155
+ // operator edited alone, because that drift is protected on both legs.
1156
+ //
1157
+ // Ordered before the restarts below, exactly as the forward leg is, and
1158
+ // best-effort: a rollback that restored three surfaces must not fail because
1159
+ // a unit file could not be rewritten. The failure is collected and reported
1160
+ // with the rest.
1161
+ if (installTouched) {
1162
+ const restoreUnits = await deps.run("omp-conductor", ["reconcile-units", "--yes"]);
1163
+ const summary = (restoreUnits.stdout.trim() || restoreUnits.stderr.trim()).split("\n").pop() ?? "";
1164
+ if (restoreUnits.code === 0) {
1165
+ deps.log(`rollback: ${summary.length === 0 ? "host units restored" : summary}`);
1166
+ } else {
1167
+ failures.push(`host units not restored: ${summary}`);
1168
+ }
1169
+ }
1170
+
995
1171
  if (herdrReloadStarted) {
996
1172
  await restore("restart herdr-fleet.service", "systemctl", ["restart", HERDR_UNIT]);
997
1173
  }
@@ -1053,7 +1229,16 @@ function logHostRuntimeDrift(deps: UpgradeDeps): void {
1053
1229
  if (plan.drift.length === 0) return;
1054
1230
  deps.log("host runtime:");
1055
1231
  for (const path of plan.drift) deps.log(` ${path} differs from this version's render`);
1056
- deps.log("fix: run `omp-conductor setup host` from the fleet account to re-install the host units");
1232
+ // Units are this transaction's own to reconcile (#905), and the reconcile
1233
+ // verb is bounded to them. Anything else the render owns — the herdr
1234
+ // pane-shell config, the herdr-conductor `config.env`, the worker identity —
1235
+ // still needs `setup host`, so the advisory names the right tool for each
1236
+ // rather than routing every drift into the flow that restarts (and kills)
1237
+ // the caller's own pane (#834).
1238
+ deps.log(
1239
+ "fix: `omp-conductor reconcile-units` re-installs the host units; anything else above needs " +
1240
+ "`omp-conductor setup host` from the fleet account",
1241
+ );
1057
1242
  }
1058
1243
 
1059
1244
  export async function upgradeConductor(
@@ -1069,14 +1254,29 @@ export async function upgradeConductor(
1069
1254
  throw new Error("run omp-conductor upgrade from a shell outside the target Herdr session");
1070
1255
  }
1071
1256
 
1072
- const requested = options.version === undefined ? `${PACKAGE}@latest` : `${PACKAGE}@${options.version}`;
1073
1257
  // Host-wide by default (#389): one daemon serves every configured project,
1074
1258
  // so an upgrade that restarts it drains them all and refreshes every brief.
1075
1259
  const scope = resolveScope(deps, "upgrade", options.project);
1076
1260
 
1077
- const release = parseRegistry(
1078
- (await mustRun(deps, "npm", ["view", requested, "version", "gitHead", "--json"])).stdout,
1079
- );
1261
+ // Published semver, or an exact source/build identity whose checks have
1262
+ // already passed (#908). Everything below reads the same pair either way.
1263
+ const identity = await resolveIdentity(deps, options);
1264
+ const release = { version: identity.version, gitHead: identity.gitHead };
1265
+ if (identity.bootstrap) {
1266
+ // The bootstrap's evidence lands in the journal before the first surface
1267
+ // moves, so an interrupted bootstrap leaves a record of what was verified
1268
+ // rather than an unexplained half-install.
1269
+ journal({
1270
+ kind: "request",
1271
+ ok: true,
1272
+ version: release.version,
1273
+ gitHead: release.gitHead,
1274
+ phase: "bootstrap",
1275
+ detail: `source identity from ${options.bootstrap?.source ?? "(source)"}`,
1276
+ checks: identity.checks,
1277
+ unit: deps.env["OMP_CONDUCTOR_UNIT"],
1278
+ });
1279
+ }
1080
1280
  if (detached) {
1081
1281
  // The durable request: written before anything can pause, so a unit that
1082
1282
  // dies after this line leaves a journal the returning process can act on
@@ -1096,6 +1296,18 @@ export async function upgradeConductor(
1096
1296
  ...deps.brief(selector),
1097
1297
  }));
1098
1298
  const installNeeded = !surfacesCurrent(surfaces, release.version, release.gitHead);
1299
+ // The host-unit baseline, read while the OLD package is still the one
1300
+ // rendering (#905). A destination already differing from the old render was
1301
+ // edited outside conductor, so the reconcile after the install must not
1302
+ // overwrite it; `undefined` means the baseline could not be established at
1303
+ // all, and the reconcile is skipped rather than guessing.
1304
+ const preInstallDrift = ((): string[] | undefined => {
1305
+ try {
1306
+ return [...deps.hostRuntime().drift];
1307
+ } catch {
1308
+ return undefined;
1309
+ }
1310
+ })();
1099
1311
  if (!installNeeded && briefs.every((b) => b.current)) {
1100
1312
  if (detached) {
1101
1313
  journal({
@@ -1151,6 +1363,23 @@ export async function upgradeConductor(
1151
1363
  }
1152
1364
  if (initial.herdr === "unknown") throw new Error("cannot determine whether herdr-fleet.service is active");
1153
1365
 
1366
+ // The one verification input that can be read before anything moves (#976).
1367
+ // Verification probes the fleet pane in `resolveHerdrSession(deps.env)`; a
1368
+ // host whose session answers to another name only discovers that after three
1369
+ // installs and two restarts, and a good release is then rolled back. Read it
1370
+ // first: the same probe, at zero cost, as a refusal.
1371
+ if (!detached && (initial.dispatch !== "stopped" || initial.herdr === "active")) {
1372
+ const session = resolveHerdrSession(deps.env);
1373
+ const pane = await deps.probePaneOmp(session);
1374
+ if ("problem" in pane) {
1375
+ throw new Error(
1376
+ `the fleet pane in herdr session "${session}" cannot be probed, so this upgrade could not be ` +
1377
+ `verified: ${pane.problem}. Set HERDR_SESSION to the session that owns the fleet pane and ` +
1378
+ `re-run, or bring that session up first — nothing has been installed.`,
1379
+ );
1380
+ }
1381
+ }
1382
+
1154
1383
  if (detached) {
1155
1384
  // The durable record the returning process rolls back or verifies against:
1156
1385
  // what was installed, what dispatch state the fleet began in, which pause
@@ -1209,10 +1438,10 @@ export async function upgradeConductor(
1209
1438
  if (installNeeded) {
1210
1439
  installTouched = true;
1211
1440
  deps.log(`install 1/3: Bun-global omp-conductor CLI → ${release.version}`);
1212
- await mustRun(deps, "bun", ["add", "-g", `${PACKAGE}@${release.version}`]);
1441
+ await mustRun(deps, "bun", ["add", "-g", identity.packageSpec]);
1213
1442
  journal({ kind: "phase", phase: "install", surface: "cli", ok: true, version: release.version, gitHead: release.gitHead });
1214
1443
  deps.log(`install 2/3: omp plugin omp-conductor → ${release.version}`);
1215
- await mustRun(deps, "omp", ["plugin", "install", `${PACKAGE}@${release.version}`]);
1444
+ await mustRun(deps, "omp", ["plugin", "install", identity.packageSpec]);
1216
1445
  journal({ kind: "phase", phase: "install", surface: "omp", ok: true, version: release.version, gitHead: release.gitHead });
1217
1446
  if (surfaces.herdrSource?.startsWith("local:")) {
1218
1447
  await mustRun(deps, "herdr", ["plugin", "unlink", HERDR_PLUGIN]);
@@ -1236,6 +1465,56 @@ export async function upgradeConductor(
1236
1465
  await upgradeBriefs(deps, briefs);
1237
1466
  journal({ kind: "phase", phase: "brief", surface: "brief", ok: true, version: release.version });
1238
1467
 
1468
+ // #905: the templates this release re-rendered. The render is CODE, so
1469
+ // this process — which loaded the previous version before replacing it —
1470
+ // cannot see the new one: the reconcile runs as a fresh child of the
1471
+ // just-installed CLI, which is also why it is spawned rather than called.
1472
+ // (Not an escalation of conductor itself: the child escalates only its own
1473
+ // bounded `install`/`daemon-reload` steps, which is exactly the boundary
1474
+ // `privileged.ts` draws.)
1475
+ //
1476
+ // It lands here, before the restarts below, so the daemon and the herdr
1477
+ // session come up reading the units this release actually ships — and
1478
+ // inside the same pause/drain window, so nothing is admitted against a
1479
+ // half-reconciled host.
1480
+ {
1481
+ // No baseline means no *refresh*: drift the operator made cannot be told
1482
+ // from drift the upgrade made, so nothing is overwritten. It does not
1483
+ // mean no *retirement* (#895) — a unit this release stopped shipping is
1484
+ // not a comparison against any render, and skipping the whole child
1485
+ // there left an affected host mounting an obsolete bind after every
1486
+ // upgrade, forever. So the child always runs; only its refresh half is
1487
+ // gated.
1488
+ const refreshable = preInstallDrift !== undefined;
1489
+ if (!refreshable) {
1490
+ deps.log(
1491
+ "host units: refresh skipped — this version's host state could not be read before the install, " +
1492
+ "so drift the operator made cannot be told from drift the upgrade made. Retirement still runs; " +
1493
+ "run `omp-conductor reconcile-units --dry-run` to see what differs.",
1494
+ );
1495
+ }
1496
+ const reconcile = await deps.run("omp-conductor", [
1497
+ "reconcile-units",
1498
+ "--yes",
1499
+ ...(refreshable ? [] : ["--no-refresh"]),
1500
+ // Destinations that already differed from the OLD render are the
1501
+ // operator's own edits, not this upgrade's doing: named here so the
1502
+ // child leaves them alone and reports them.
1503
+ ...(preInstallDrift ?? []).flatMap((path) => ["--protect", path]),
1504
+ ]);
1505
+ const summary = (reconcile.stdout.trim() || reconcile.stderr.trim()).split("\n").pop() ?? "";
1506
+ if (reconcile.code === 0) {
1507
+ deps.log(summary.length === 0 ? "host units: reconciled" : summary);
1508
+ journal({ kind: "phase", phase: "reconcile", surface: "units", ok: true, version: release.version, detail: summary });
1509
+ } else {
1510
+ // A failed reconcile is a drifted host, not a failed upgrade: the
1511
+ // packages are installed and verifiable, and the units are exactly as
1512
+ // they were. Say so precisely instead of rolling three surfaces back.
1513
+ deps.log(`host units: reconcile did not complete — ${summary}`);
1514
+ journal({ kind: "phase", phase: "reconcile", surface: "units", ok: false, version: release.version, detail: summary });
1515
+ }
1516
+ }
1517
+
1239
1518
  if (initial.herdr === "active") {
1240
1519
  herdrReloadStarted = true;
1241
1520
  deps.log("reload: restarting herdr-fleet.service and recovering the orchestrator pane");