omp-conductor 0.19.4 → 0.19.6

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.
package/src/upgrade.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
- import { join } from "node:path";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
3
4
  import { backupTimestamp, copyToUniqueBackup } from "./backups.ts";
4
5
  import { inspectBriefLayout, type BriefLayout } from "./brief-upgrade.ts";
5
6
  import { pauseInstance, setPaused, statusSnapshot, type AdmissionAckRecord } from "./daemon.ts";
@@ -24,6 +25,7 @@ import {
24
25
  upgradeJournalPath,
25
26
  type UpgradeCheck,
26
27
  type UpgradeJournalEntry,
28
+ type InstallSnapshotManagement,
27
29
  } from "./upgrade-journal.ts";
28
30
  import {
29
31
  enqueueUpgradeReport,
@@ -129,6 +131,8 @@ export interface UpgradeDeps {
129
131
  */
130
132
  pauseState(project?: string): { source: string; reason?: string; since: number } | undefined;
131
133
  setPaused(value: boolean, project?: string): void;
134
+ /** Host-global recovery fence for host-wide install surfaces. */
135
+ setGlobalRecoveryPaused(value: boolean): void;
132
136
  restartDaemon(): Promise<void>;
133
137
  sleep(ms: number): Promise<void>;
134
138
  /**
@@ -205,6 +209,14 @@ export const DEFAULT_DEPS: UpgradeDeps = {
205
209
  pauseState: (project) => pauseInstance(project),
206
210
  setPaused: (v, project) =>
207
211
  setPaused(v, { source: "upgrade", reason: "upgrade, draining" }, project),
212
+ setGlobalRecoveryPaused: (v) => {
213
+ const current = pauseInstance();
214
+ if (v && current === undefined) {
215
+ setPaused(true, { source: "upgrade-recovery", reason: "upgrade recovery verification required" });
216
+ } else if (!v && current?.source === "upgrade-recovery") {
217
+ setPaused(false);
218
+ }
219
+ },
208
220
  restartDaemon: async () => {
209
221
  await restartDaemon({});
210
222
  },
@@ -269,7 +281,7 @@ function parseRegistry(raw: string): { version: string; gitHead: string } {
269
281
  interface ReleaseIdentityPlan {
270
282
  version: string;
271
283
  gitHead: string;
272
- /** What `bun add -g` / `omp plugin install` are given for this identity. */
284
+ /** What the discovered CLI root / `omp plugin install` are given for this identity. */
273
285
  packageSpec: string;
274
286
  /** Named checks already run to earn this identity, journalled as evidence. */
275
287
  checks: UpgradeCheck[];
@@ -360,7 +372,12 @@ async function resolveIdentity(deps: UpgradeDeps, options: UpgradeOptions): Prom
360
372
  return { version, gitHead: sha, packageSpec: `github:${PACKAGE_SOURCE}#${sha}`, checks, bootstrap: true };
361
373
  }
362
374
 
363
- function ompPluginVersion(raw: string): string | undefined {
375
+ interface OmpPluginEntry {
376
+ version: string;
377
+ path?: string;
378
+ }
379
+
380
+ function ompPluginInventory(raw: string): unknown[] {
364
381
  let parsed: unknown;
365
382
  try {
366
383
  parsed = JSON.parse(raw);
@@ -372,15 +389,51 @@ function ompPluginVersion(raw: string): string | undefined {
372
389
  }
373
390
  const npm = Reflect.get(parsed, "npm");
374
391
  if (!Array.isArray(npm)) throw new Error("omp plugin list returned no npm inventory");
375
- const plugin = npm.find(
392
+ return npm;
393
+ }
394
+
395
+ function ompPluginEntry(raw: string): OmpPluginEntry | undefined {
396
+ const plugin = ompPluginInventory(raw).find(
376
397
  (entry) => entry !== null && typeof entry === "object" && Reflect.get(entry, "name") === PACKAGE,
377
398
  );
378
- if (plugin === undefined) return undefined;
399
+ if (plugin === undefined || plugin === null || typeof plugin !== "object") return undefined;
379
400
  const version = Reflect.get(plugin, "version");
380
401
  if (typeof version !== "string" || version.length === 0) {
381
402
  throw new Error("installed omp-conductor plugin has no version");
382
403
  }
383
- return version;
404
+ const path = Reflect.get(plugin, "path");
405
+ return {
406
+ version,
407
+ ...(typeof path === "string" && path.length > 0 ? { path } : {}),
408
+ };
409
+ }
410
+
411
+ function ompPluginVersion(raw: string): string | undefined {
412
+ return ompPluginEntry(raw)?.version;
413
+ }
414
+
415
+ function nodeModulesRoot(path: string): string | undefined {
416
+ const marker = "/node_modules/";
417
+ const at = path.lastIndexOf(marker);
418
+ return at <= 0 ? undefined : path.slice(0, at);
419
+ }
420
+
421
+ function defaultOmpPluginRoot(env: NodeJS.ProcessEnv): string {
422
+ const configured = env["OMP_CODING_AGENT_DIR"];
423
+ if (configured !== undefined && configured.length > 0) return join(configured, "plugins");
424
+ return join(env["HOME"] ?? homedir(), ".omp", "plugins");
425
+ }
426
+
427
+ function ompPluginInstallRoot(raw: string, env: NodeJS.ProcessEnv): string {
428
+ const inventory = ompPluginInventory(raw);
429
+ const ownPath = ompPluginEntry(raw)?.path;
430
+ const fallbackPath = inventory
431
+ .map((entry) =>
432
+ entry !== null && typeof entry === "object" ? Reflect.get(entry, "path") : undefined,
433
+ )
434
+ .find((path): path is string => typeof path === "string" && path.length > 0);
435
+ const root = nodeModulesRoot(ownPath ?? fallbackPath ?? "");
436
+ return root ?? defaultOmpPluginRoot(env);
384
437
  }
385
438
 
386
439
  function herdrPluginSource(raw: string): string | undefined {
@@ -393,12 +446,143 @@ function herdrPluginSource(raw: string): string | undefined {
393
446
  return source;
394
447
  }
395
448
 
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. */
449
+ function defaultHerdrInstallRoot(env: NodeJS.ProcessEnv): string {
450
+ const config =
451
+ env["HERDR_CONFIG_DIR"] ??
452
+ join(env["XDG_CONFIG_HOME"] ?? join(env["HOME"] ?? homedir(), ".config"), "herdr");
453
+ return join(config, "plugins", "github");
454
+ }
455
+
456
+ function herdrPluginManagement(
457
+ raw: string,
458
+ env: NodeJS.ProcessEnv,
459
+ ): InstallSnapshotManagement["herdr"] {
460
+ let parsed: unknown;
461
+ try {
462
+ parsed = JSON.parse(raw);
463
+ } catch {
464
+ throw new Error("herdr plugin list returned invalid JSON");
465
+ }
466
+ const result =
467
+ parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)
468
+ ? Reflect.get(parsed, "result")
469
+ : undefined;
470
+ const plugins =
471
+ result !== null && typeof result === "object" && !Array.isArray(result)
472
+ ? Reflect.get(result, "plugins")
473
+ : undefined;
474
+ if (!Array.isArray(plugins)) throw new Error("herdr plugin list returned no plugin inventory");
475
+ const plugin = plugins.find(
476
+ (entry) => entry !== null && typeof entry === "object" && Reflect.get(entry, "plugin_id") === HERDR_PLUGIN,
477
+ );
478
+ const root =
479
+ plugin !== undefined && typeof plugin === "object"
480
+ ? Reflect.get(plugin, "plugin_root")
481
+ : undefined;
482
+ const source =
483
+ plugin !== undefined && typeof plugin === "object"
484
+ ? Reflect.get(plugin, "source")
485
+ : undefined;
486
+ const managedPath =
487
+ source !== null && typeof source === "object" && !Array.isArray(source)
488
+ ? Reflect.get(source, "managed_path")
489
+ : undefined;
490
+ const marker = "/plugins/github/";
491
+ const markerAt = typeof root === "string" ? root.indexOf(marker) : -1;
492
+ const installRoot =
493
+ typeof managedPath === "string" && managedPath.length > 0
494
+ ? dirname(managedPath)
495
+ : markerAt >= 0 && typeof root === "string"
496
+ ? root.slice(0, markerAt + marker.length - 1)
497
+ : defaultHerdrInstallRoot(env);
498
+ return {
499
+ ...(typeof root === "string" && root.length > 0 ? { root } : {}),
500
+ installRoot,
501
+ };
502
+ }
503
+
504
+ function cliInstallRoot(target: string): string {
505
+ const marker = `/node_modules/${PACKAGE}/`;
506
+ const at = target.lastIndexOf(marker);
507
+ if (at <= 0) {
508
+ throw new Error(
509
+ `installed omp-conductor CLI target ${target} is not inside a discoverable node_modules/${PACKAGE} root`,
510
+ );
511
+ }
512
+ return target.slice(0, at);
513
+ }
514
+
515
+ /** Discover the exact managed roots the transaction will mutate (#1019). */
516
+ export async function discoverInstallManagement(
517
+ deps: SurfaceProbeDeps,
518
+ ): Promise<InstallSnapshotManagement> {
519
+ const session = resolveHerdrSession(deps.env);
520
+ const executable = (await mustRun(deps, "which", ["omp-conductor"])).stdout.trim();
521
+ if (executable.length === 0) throw new Error("which omp-conductor returned no executable");
522
+ const [resolved, omp, herdr] = await Promise.all([
523
+ mustRun(deps, "readlink", ["-f", executable]),
524
+ mustRun(deps, "omp", ["plugin", "list", "--json"]),
525
+ mustRun(deps, "herdr", ["--session", session, "plugin", "list", "--json"]),
526
+ ]);
527
+ const target = resolved.stdout.trim();
528
+ if (target.length === 0) throw new Error(`cannot resolve installed CLI ${executable}`);
529
+ const ompRoot = ompPluginInstallRoot(omp.stdout, deps.env);
530
+ return {
531
+ cli: { executable, target, root: cliInstallRoot(target) },
532
+ omp: { root: ompRoot, lock: join(ompRoot, "omp-plugins.lock.json") },
533
+ herdr: herdrPluginManagement(herdr.stdout, deps.env),
534
+ };
535
+ }
536
+
537
+ async function preflightInstall(
538
+ deps: UpgradeDeps,
539
+ management: InstallSnapshotManagement,
540
+ packageSpec: string,
541
+ ): Promise<void> {
542
+ const writable: { label: string; path: string }[] = [
543
+ { label: "CLI package root", path: management.cli.root },
544
+ { label: "CLI package manifest", path: join(management.cli.root, "package.json") },
545
+ { label: "CLI node_modules", path: join(management.cli.root, "node_modules") },
546
+ { label: "OMP plugin root", path: management.omp.root },
547
+ { label: "Herdr managed-plugin root", path: management.herdr.installRoot },
548
+ ];
549
+ if (existsSync(management.omp.lock)) {
550
+ writable.push({ label: "OMP plugin lock", path: management.omp.lock });
551
+ }
552
+ for (const target of writable) {
553
+ const checked = await deps.run("test", ["-w", target.path]);
554
+ if (checked.code !== 0) {
555
+ const detail = checked.stderr.trim() || checked.stdout.trim() || "missing or not writable";
556
+ throw new Error(
557
+ `upgrade preflight refused: ${target.label} ${target.path} is unusable (${detail}); ` +
558
+ "nothing has been installed or paused",
559
+ );
560
+ }
561
+ }
562
+ try {
563
+ await mustRun(deps, "bun", [
564
+ "add",
565
+ "--cwd",
566
+ management.cli.root,
567
+ "--exact",
568
+ "--dry-run",
569
+ packageSpec,
570
+ ]);
571
+ } catch (err) {
572
+ throw new Error(
573
+ `upgrade preflight refused: the CLI install command is unusable at ${management.cli.root} ` +
574
+ `(${err instanceof Error ? err.message : String(err)}); nothing has been installed or paused`,
575
+ );
576
+ }
577
+ }
578
+
579
+ /** The three installed identities one host carries: the discovered CLI package
580
+ * tree, the omp plugin, and the herdr recovery plugin's pin. */
398
581
  export interface InstalledSurfaces {
399
582
  cliVersion: string;
400
583
  ompVersion?: string;
401
584
  herdrSource?: string;
585
+ management?: InstallSnapshotManagement;
402
586
  }
403
587
 
404
588
  /** What reading the three surfaces needs — deliberately narrower than
@@ -417,6 +601,7 @@ export async function inspectSurfaces(
417
601
  deps: SurfaceProbeDeps,
418
602
  /** `readHerdr: false` skips the herdr session probe and plugin read
419
603
  * entirely, so a host with no herdr on PATH can still be asked what its
604
+
420
605
  * CLI and omp plugin are (#904). The upgrade transaction always reads all
421
606
  * three — a release pins every surface. */
422
607
  opts: { readHerdr?: boolean } = {},
@@ -445,6 +630,56 @@ export async function inspectSurfaces(
445
630
  };
446
631
  }
447
632
 
633
+ function packageVersionAt(deps: UpgradeDeps, manifest: string): string {
634
+ let parsed: unknown;
635
+ try {
636
+ parsed = JSON.parse(deps.readFile(manifest));
637
+ } catch (err) {
638
+ throw new Error(
639
+ `cannot read restored package manifest ${manifest}: ${err instanceof Error ? err.message : String(err)}`,
640
+ );
641
+ }
642
+ const version =
643
+ parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)
644
+ ? Reflect.get(parsed, "version")
645
+ : undefined;
646
+ if (typeof version !== "string" || version.length === 0) {
647
+ throw new Error(`restored package manifest ${manifest} has no version`);
648
+ }
649
+ return version;
650
+ }
651
+
652
+ /** Verify the snapshot's recorded destinations directly, before PATH-based probes. */
653
+ function verifyRestoredManagement(deps: UpgradeDeps, previous: InstalledSurfaces): void {
654
+ const management = previous.management;
655
+ if (management === undefined) return;
656
+ const cliManifest = join(management.cli.root, "node_modules", PACKAGE, "package.json");
657
+ const cliVersion = packageVersionAt(deps, cliManifest);
658
+ if (cliVersion !== previous.cliVersion) {
659
+ throw new Error(
660
+ `restored CLI root ${management.cli.root} has ${cliVersion}, expected ${previous.cliVersion}`,
661
+ );
662
+ }
663
+ if (previous.ompVersion !== undefined) {
664
+ const ompManifest = join(management.omp.root, "node_modules", PACKAGE, "package.json");
665
+ const ompVersion = packageVersionAt(deps, ompManifest);
666
+ if (ompVersion !== previous.ompVersion) {
667
+ throw new Error(
668
+ `restored OMP plugin root ${management.omp.root} has ${ompVersion}, expected ${previous.ompVersion}`,
669
+ );
670
+ }
671
+ }
672
+ if (previous.herdrSource !== undefined) {
673
+ if (management.herdr.root === undefined) {
674
+ throw new Error("restored Herdr plugin had no recorded root to verify");
675
+ }
676
+ const manifest = join(management.herdr.root, "herdr-plugin.toml");
677
+ if (deps.readFile(manifest).trim().length === 0) {
678
+ throw new Error(`restored Herdr plugin manifest ${manifest} is empty`);
679
+ }
680
+ }
681
+ }
682
+
448
683
  export function expectedHerdrSource(gitHead: string): string {
449
684
  return `github:${HERDR_SOURCE}@${gitHead}`;
450
685
  }
@@ -492,7 +727,7 @@ function previousHerdrInstall(source: string): readonly [string, readonly string
492
727
  * that represents them.
493
728
  *
494
729
  * Everything an upgrade or a draining restart replaces is host-wide: the
495
- * Bun-global CLI, the omp plugin, the Herdr plugin, and the one dispatch
730
+ * discovered CLI package tree, the omp plugin, the Herdr plugin, and the one
496
731
  * daemon. That daemon serves *every* configured project — the generated unit's
497
732
  * ExecStart carries no `--project` — so its restart lands on all of them at
498
733
  * once. The scope therefore names every project whose workers must drain and
@@ -1092,13 +1327,22 @@ export async function rollbackUpgrade(
1092
1327
  // Exact post-rollback identity verification decides whether absence is safe.
1093
1328
  }
1094
1329
  };
1095
-
1096
1330
  if (installTouched) {
1097
- await restore("Bun-global omp-conductor CLI", "bun", [
1098
- "add",
1099
- "-g",
1100
- `${PACKAGE}@${previous.cliVersion}`,
1101
- ]);
1331
+ if (previous.management === undefined) {
1332
+ await restore("legacy Bun-global omp-conductor CLI", "bun", [
1333
+ "add",
1334
+ "-g",
1335
+ `${PACKAGE}@${previous.cliVersion}`,
1336
+ ]);
1337
+ } else {
1338
+ await restore(`omp-conductor CLI at ${previous.management.cli.root}`, "bun", [
1339
+ "add",
1340
+ "--cwd",
1341
+ previous.management.cli.root,
1342
+ "--exact",
1343
+ `${PACKAGE}@${previous.cliVersion}`,
1344
+ ]);
1345
+ }
1102
1346
  if (previous.ompVersion === undefined) {
1103
1347
  await removeIfPresent("remove newly installed omp plugin", "omp", [
1104
1348
  "plugin",
@@ -1182,6 +1426,7 @@ export async function rollbackUpgrade(
1182
1426
 
1183
1427
  if (installTouched) {
1184
1428
  try {
1429
+ verifyRestoredManagement(deps, previous);
1185
1430
  const restored = await inspectSurfaces(deps);
1186
1431
  if (
1187
1432
  restored.cliVersion !== previous.cliVersion ||
@@ -1262,6 +1507,19 @@ export async function upgradeConductor(
1262
1507
  // already passed (#908). Everything below reads the same pair either way.
1263
1508
  const identity = await resolveIdentity(deps, options);
1264
1509
  const release = { version: identity.version, gitHead: identity.gitHead };
1510
+ const requestedProjects = (deps.env["OMP_CONDUCTOR_INSTALL_PROJECTS"] ?? "")
1511
+ .split(",")
1512
+ .filter((name) => name.length > 0);
1513
+ const requestedHolder = deps.env["OMP_CONDUCTOR_INSTALL_HOLDER"];
1514
+ const installHolder =
1515
+ requestedHolder === "human" || requestedHolder === "orchestrator"
1516
+ ? requestedHolder
1517
+ : undefined;
1518
+ const installProjects: readonly (string | undefined)[] =
1519
+ requestedProjects.length === 0 ? scope.selectors : requestedProjects;
1520
+ const installAttribution =
1521
+ `Host-global install holder: ${installHolder ?? "unknown"}; affected projects: ` +
1522
+ `${installProjects.map((selector) => selector ?? "(default)").join(", ")}.`;
1265
1523
  if (identity.bootstrap) {
1266
1524
  // The bootstrap's evidence lands in the journal before the first surface
1267
1525
  // moves, so an interrupted bootstrap leaves a record of what was verified
@@ -1287,15 +1545,24 @@ export async function upgradeConductor(
1287
1545
  version: release.version,
1288
1546
  gitHead: release.gitHead,
1289
1547
  unit: deps.env["OMP_CONDUCTOR_UNIT"],
1548
+ selectors: scope.selectors.map((selector) => selector ?? null),
1549
+ ...(requestedProjects.length === 0 ? {} : { installProjects: requestedProjects }),
1550
+ ...(installHolder === undefined ? {} : { installHolder }),
1290
1551
  });
1291
1552
  }
1292
1553
  const initial = deps.layers(scope.pauseKey);
1293
- const surfaces = await inspectSurfaces(deps);
1554
+ const observedSurfaces = await inspectSurfaces(deps);
1294
1555
  const briefs: ScopedBrief[] = scope.selectors.map((selector) => ({
1295
1556
  selector,
1296
1557
  ...deps.brief(selector),
1297
1558
  }));
1298
- const installNeeded = !surfacesCurrent(surfaces, release.version, release.gitHead);
1559
+ const installNeeded = !surfacesCurrent(observedSurfaces, release.version, release.gitHead);
1560
+ const management = installNeeded ? await discoverInstallManagement(deps) : undefined;
1561
+ if (management !== undefined) await preflightInstall(deps, management, identity.packageSpec);
1562
+ const surfaces: InstalledSurfaces = {
1563
+ ...observedSurfaces,
1564
+ ...(management === undefined ? {} : { management }),
1565
+ };
1299
1566
  // The host-unit baseline, read while the OLD package is still the one
1300
1567
  // rendering (#905). A destination already differing from the old render was
1301
1568
  // edited outside conductor, so the reconcile after the install must not
@@ -1394,6 +1661,7 @@ export async function upgradeConductor(
1394
1661
  cliVersion: surfaces.cliVersion,
1395
1662
  ompVersion: surfaces.ompVersion,
1396
1663
  herdrSource: surfaces.herdrSource,
1664
+ ...(surfaces.management === undefined ? {} : { management: surfaces.management }),
1397
1665
  },
1398
1666
  ...(preUpgradeBackup === undefined ? {} : { configBackup: preUpgradeBackup }),
1399
1667
  pauseKey: scope.pauseKey,
@@ -1413,7 +1681,14 @@ export async function upgradeConductor(
1413
1681
  deps.log("safety: pausing new issue claims");
1414
1682
  deps.setPaused(true, scope.pauseKey);
1415
1683
  }
1416
- journal({ kind: "phase", phase: "paused", surface: "dispatch", ok: true, version: release.version });
1684
+ journal({
1685
+ kind: "phase",
1686
+ phase: "paused",
1687
+ surface: "dispatch",
1688
+ ok: true,
1689
+ version: release.version,
1690
+ pauseSince: deps.pauseState(scope.pauseKey)?.since,
1691
+ });
1417
1692
  deps.log("drain: waiting for live omp worker sessions");
1418
1693
  try {
1419
1694
  await waitForDrain(deps, scope);
@@ -1436,9 +1711,18 @@ export async function upgradeConductor(
1436
1711
  const reloadStarted = Date.now();
1437
1712
  try {
1438
1713
  if (installNeeded) {
1714
+ if (management === undefined) {
1715
+ throw new Error("upgrade invariant failed: install needed without discovered surface management");
1716
+ }
1439
1717
  installTouched = true;
1440
- deps.log(`install 1/3: Bun-global omp-conductor CLI → ${release.version}`);
1441
- await mustRun(deps, "bun", ["add", "-g", identity.packageSpec]);
1718
+ deps.log(`install 1/3: omp-conductor CLI at ${management.cli.root} → ${release.version}`);
1719
+ await mustRun(deps, "bun", [
1720
+ "add",
1721
+ "--cwd",
1722
+ management.cli.root,
1723
+ "--exact",
1724
+ identity.packageSpec,
1725
+ ]);
1442
1726
  journal({ kind: "phase", phase: "install", surface: "cli", ok: true, version: release.version, gitHead: release.gitHead });
1443
1727
  deps.log(`install 2/3: omp plugin omp-conductor → ${release.version}`);
1444
1728
  await mustRun(deps, "omp", ["plugin", "install", identity.packageSpec]);
@@ -1595,6 +1879,7 @@ export async function upgradeConductor(
1595
1879
  } catch {
1596
1880
  deps.setPaused(true, scope.pauseKey);
1597
1881
  }
1882
+ deps.setGlobalRecoveryPaused(true);
1598
1883
  const failure = err instanceof Error ? err.message : String(err);
1599
1884
  journal({
1600
1885
  kind: "phase",
@@ -1623,10 +1908,11 @@ export async function upgradeConductor(
1623
1908
  journal({ kind: "outcome", phase: "rolled-back", ok: false, version: release.version, detail: failure });
1624
1909
  enqueueUpgradeReport(
1625
1910
  "tier2",
1626
- reportProject(scope.selectors),
1911
+ reportProject(installProjects),
1627
1912
  `fleet upgrade to omp-conductor@${release.version} failed and was rolled back`,
1628
1913
  [
1629
1914
  `Reason: ${failure}`,
1915
+ installAttribution,
1630
1916
  `Restored: cli=${surfaces.cliVersion}, omp=${surfaces.ompVersion ?? "missing"}, ` +
1631
1917
  `herdr=${surfaces.herdrSource ?? "missing"}`,
1632
1918
  ...(preUpgradeBackup === undefined ? [] : [`Pre-upgrade config snapshot: ${preUpgradeBackup}`]),
@@ -1639,10 +1925,11 @@ export async function upgradeConductor(
1639
1925
  journal({ kind: "outcome", phase: "rollback-failed", ok: false, version: release.version, detail: rollback });
1640
1926
  enqueueUpgradeReport(
1641
1927
  "tier2",
1642
- reportProject(scope.selectors),
1928
+ reportProject(installProjects),
1643
1929
  `upgrade to omp-conductor@${release.version} failed and ITS ROLLBACK ALSO FAILED`,
1644
1930
  [
1645
1931
  `Failure: ${failure}`,
1932
+ installAttribution,
1646
1933
  `Rollback failure: ${rollback}`,
1647
1934
  "The fleet may be at mixed versions; do not resume dispatch.",
1648
1935
  "Restore manually from the journal at " + upgradeJournalPath(stateDir()) + ".",
@@ -1748,6 +2035,9 @@ export async function rollbackFromJournal(
1748
2035
  cliVersion: snapshot.previous.cliVersion,
1749
2036
  ompVersion: snapshot.previous.ompVersion,
1750
2037
  herdrSource: snapshot.previous.herdrSource,
2038
+ ...(snapshot.previous.management === undefined
2039
+ ? {}
2040
+ : { management: snapshot.previous.management }),
1751
2041
  };
1752
2042
  let configBefore: string | undefined;
1753
2043
  if (request.configBackup !== undefined && existsSync(request.configBackup)) {
@@ -1779,6 +2069,10 @@ export async function rollbackFromJournal(
1779
2069
  configBefore,
1780
2070
  request.configBackup,
1781
2071
  );
2072
+ // The restored bytes remain host-wide unverified state until the returning
2073
+ // daemon reads all three surfaces. A project-scoped request cannot leave its
2074
+ // siblings admitting in that interval.
2075
+ deps.setGlobalRecoveryPaused(true);
1782
2076
 
1783
2077
  const restoredVersion = previous.cliVersion;
1784
2078
  appendJournal(root, {
@@ -1791,10 +2085,17 @@ export async function rollbackFromJournal(
1791
2085
  });
1792
2086
  enqueueUpgradeReport(
1793
2087
  "tier2",
1794
- reportProject(request.selectors),
2088
+ reportProject(request.installProjects ?? request.selectors),
1795
2089
  `fleet restored to omp-conductor@${restoredVersion} after the failed upgrade to ${request.version}`,
1796
2090
  [
1797
2091
  "The transient rollback unit restored every surface from the durable snapshot;",
2092
+ `Host-global install holder: ${request.installHolder ?? "unknown"}; affected projects: ${
2093
+ (request.installProjects ?? request.selectors).length === 0
2094
+ ? "all configured projects"
2095
+ : (request.installProjects ?? request.selectors)
2096
+ .map((selector) => selector ?? "(default)")
2097
+ .join(", ")
2098
+ }.`,
1798
2099
  "dispatch stays paused until the first tick after these restarts verifies the old version.",
1799
2100
  `Pre-upgrade config snapshot: ${request.configBackup ?? "none recorded"}`,
1800
2101
  ],
@@ -1008,7 +1008,12 @@ export function githubVerbActions(
1008
1008
  return { code: ran.ok ? 0 : 1, stdout: ran.stdout, stderr: ran.stderr };
1009
1009
  },
1010
1010
  env(),
1011
- { kind: "install", version: execution.version },
1011
+ {
1012
+ kind: "install",
1013
+ version: execution.version,
1014
+ holder: execution.holder,
1015
+ projects: execution.projects,
1016
+ },
1012
1017
  );
1013
1018
  if (!launched.ok) return { ok: false, stderr: launched.stderr };
1014
1019
  return { ok: true, detail: launched.unit };