omp-conductor 0.19.5 → 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,
@@ -279,7 +281,7 @@ function parseRegistry(raw: string): { version: string; gitHead: string } {
279
281
  interface ReleaseIdentityPlan {
280
282
  version: string;
281
283
  gitHead: string;
282
- /** 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. */
283
285
  packageSpec: string;
284
286
  /** Named checks already run to earn this identity, journalled as evidence. */
285
287
  checks: UpgradeCheck[];
@@ -370,7 +372,12 @@ async function resolveIdentity(deps: UpgradeDeps, options: UpgradeOptions): Prom
370
372
  return { version, gitHead: sha, packageSpec: `github:${PACKAGE_SOURCE}#${sha}`, checks, bootstrap: true };
371
373
  }
372
374
 
373
- function ompPluginVersion(raw: string): string | undefined {
375
+ interface OmpPluginEntry {
376
+ version: string;
377
+ path?: string;
378
+ }
379
+
380
+ function ompPluginInventory(raw: string): unknown[] {
374
381
  let parsed: unknown;
375
382
  try {
376
383
  parsed = JSON.parse(raw);
@@ -382,15 +389,51 @@ function ompPluginVersion(raw: string): string | undefined {
382
389
  }
383
390
  const npm = Reflect.get(parsed, "npm");
384
391
  if (!Array.isArray(npm)) throw new Error("omp plugin list returned no npm inventory");
385
- const plugin = npm.find(
392
+ return npm;
393
+ }
394
+
395
+ function ompPluginEntry(raw: string): OmpPluginEntry | undefined {
396
+ const plugin = ompPluginInventory(raw).find(
386
397
  (entry) => entry !== null && typeof entry === "object" && Reflect.get(entry, "name") === PACKAGE,
387
398
  );
388
- if (plugin === undefined) return undefined;
399
+ if (plugin === undefined || plugin === null || typeof plugin !== "object") return undefined;
389
400
  const version = Reflect.get(plugin, "version");
390
401
  if (typeof version !== "string" || version.length === 0) {
391
402
  throw new Error("installed omp-conductor plugin has no version");
392
403
  }
393
- 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);
394
437
  }
395
438
 
396
439
  function herdrPluginSource(raw: string): string | undefined {
@@ -403,12 +446,143 @@ function herdrPluginSource(raw: string): string | undefined {
403
446
  return source;
404
447
  }
405
448
 
406
- /** The three installed identities one host carries: the Bun-global CLI/daemon
407
- * 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. */
408
581
  export interface InstalledSurfaces {
409
582
  cliVersion: string;
410
583
  ompVersion?: string;
411
584
  herdrSource?: string;
585
+ management?: InstallSnapshotManagement;
412
586
  }
413
587
 
414
588
  /** What reading the three surfaces needs — deliberately narrower than
@@ -427,6 +601,7 @@ export async function inspectSurfaces(
427
601
  deps: SurfaceProbeDeps,
428
602
  /** `readHerdr: false` skips the herdr session probe and plugin read
429
603
  * entirely, so a host with no herdr on PATH can still be asked what its
604
+
430
605
  * CLI and omp plugin are (#904). The upgrade transaction always reads all
431
606
  * three — a release pins every surface. */
432
607
  opts: { readHerdr?: boolean } = {},
@@ -455,6 +630,56 @@ export async function inspectSurfaces(
455
630
  };
456
631
  }
457
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
+
458
683
  export function expectedHerdrSource(gitHead: string): string {
459
684
  return `github:${HERDR_SOURCE}@${gitHead}`;
460
685
  }
@@ -502,7 +727,7 @@ function previousHerdrInstall(source: string): readonly [string, readonly string
502
727
  * that represents them.
503
728
  *
504
729
  * Everything an upgrade or a draining restart replaces is host-wide: the
505
- * 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
506
731
  * daemon. That daemon serves *every* configured project — the generated unit's
507
732
  * ExecStart carries no `--project` — so its restart lands on all of them at
508
733
  * once. The scope therefore names every project whose workers must drain and
@@ -1102,13 +1327,22 @@ export async function rollbackUpgrade(
1102
1327
  // Exact post-rollback identity verification decides whether absence is safe.
1103
1328
  }
1104
1329
  };
1105
-
1106
1330
  if (installTouched) {
1107
- await restore("Bun-global omp-conductor CLI", "bun", [
1108
- "add",
1109
- "-g",
1110
- `${PACKAGE}@${previous.cliVersion}`,
1111
- ]);
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
+ }
1112
1346
  if (previous.ompVersion === undefined) {
1113
1347
  await removeIfPresent("remove newly installed omp plugin", "omp", [
1114
1348
  "plugin",
@@ -1192,6 +1426,7 @@ export async function rollbackUpgrade(
1192
1426
 
1193
1427
  if (installTouched) {
1194
1428
  try {
1429
+ verifyRestoredManagement(deps, previous);
1195
1430
  const restored = await inspectSurfaces(deps);
1196
1431
  if (
1197
1432
  restored.cliVersion !== previous.cliVersion ||
@@ -1272,6 +1507,19 @@ export async function upgradeConductor(
1272
1507
  // already passed (#908). Everything below reads the same pair either way.
1273
1508
  const identity = await resolveIdentity(deps, options);
1274
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(", ")}.`;
1275
1523
  if (identity.bootstrap) {
1276
1524
  // The bootstrap's evidence lands in the journal before the first surface
1277
1525
  // moves, so an interrupted bootstrap leaves a record of what was verified
@@ -1297,15 +1545,24 @@ export async function upgradeConductor(
1297
1545
  version: release.version,
1298
1546
  gitHead: release.gitHead,
1299
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 }),
1300
1551
  });
1301
1552
  }
1302
1553
  const initial = deps.layers(scope.pauseKey);
1303
- const surfaces = await inspectSurfaces(deps);
1554
+ const observedSurfaces = await inspectSurfaces(deps);
1304
1555
  const briefs: ScopedBrief[] = scope.selectors.map((selector) => ({
1305
1556
  selector,
1306
1557
  ...deps.brief(selector),
1307
1558
  }));
1308
- 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
+ };
1309
1566
  // The host-unit baseline, read while the OLD package is still the one
1310
1567
  // rendering (#905). A destination already differing from the old render was
1311
1568
  // edited outside conductor, so the reconcile after the install must not
@@ -1404,6 +1661,7 @@ export async function upgradeConductor(
1404
1661
  cliVersion: surfaces.cliVersion,
1405
1662
  ompVersion: surfaces.ompVersion,
1406
1663
  herdrSource: surfaces.herdrSource,
1664
+ ...(surfaces.management === undefined ? {} : { management: surfaces.management }),
1407
1665
  },
1408
1666
  ...(preUpgradeBackup === undefined ? {} : { configBackup: preUpgradeBackup }),
1409
1667
  pauseKey: scope.pauseKey,
@@ -1453,9 +1711,18 @@ export async function upgradeConductor(
1453
1711
  const reloadStarted = Date.now();
1454
1712
  try {
1455
1713
  if (installNeeded) {
1714
+ if (management === undefined) {
1715
+ throw new Error("upgrade invariant failed: install needed without discovered surface management");
1716
+ }
1456
1717
  installTouched = true;
1457
- deps.log(`install 1/3: Bun-global omp-conductor CLI → ${release.version}`);
1458
- 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
+ ]);
1459
1726
  journal({ kind: "phase", phase: "install", surface: "cli", ok: true, version: release.version, gitHead: release.gitHead });
1460
1727
  deps.log(`install 2/3: omp plugin omp-conductor → ${release.version}`);
1461
1728
  await mustRun(deps, "omp", ["plugin", "install", identity.packageSpec]);
@@ -1641,10 +1908,11 @@ export async function upgradeConductor(
1641
1908
  journal({ kind: "outcome", phase: "rolled-back", ok: false, version: release.version, detail: failure });
1642
1909
  enqueueUpgradeReport(
1643
1910
  "tier2",
1644
- reportProject(scope.selectors),
1911
+ reportProject(installProjects),
1645
1912
  `fleet upgrade to omp-conductor@${release.version} failed and was rolled back`,
1646
1913
  [
1647
1914
  `Reason: ${failure}`,
1915
+ installAttribution,
1648
1916
  `Restored: cli=${surfaces.cliVersion}, omp=${surfaces.ompVersion ?? "missing"}, ` +
1649
1917
  `herdr=${surfaces.herdrSource ?? "missing"}`,
1650
1918
  ...(preUpgradeBackup === undefined ? [] : [`Pre-upgrade config snapshot: ${preUpgradeBackup}`]),
@@ -1657,10 +1925,11 @@ export async function upgradeConductor(
1657
1925
  journal({ kind: "outcome", phase: "rollback-failed", ok: false, version: release.version, detail: rollback });
1658
1926
  enqueueUpgradeReport(
1659
1927
  "tier2",
1660
- reportProject(scope.selectors),
1928
+ reportProject(installProjects),
1661
1929
  `upgrade to omp-conductor@${release.version} failed and ITS ROLLBACK ALSO FAILED`,
1662
1930
  [
1663
1931
  `Failure: ${failure}`,
1932
+ installAttribution,
1664
1933
  `Rollback failure: ${rollback}`,
1665
1934
  "The fleet may be at mixed versions; do not resume dispatch.",
1666
1935
  "Restore manually from the journal at " + upgradeJournalPath(stateDir()) + ".",
@@ -1766,6 +2035,9 @@ export async function rollbackFromJournal(
1766
2035
  cliVersion: snapshot.previous.cliVersion,
1767
2036
  ompVersion: snapshot.previous.ompVersion,
1768
2037
  herdrSource: snapshot.previous.herdrSource,
2038
+ ...(snapshot.previous.management === undefined
2039
+ ? {}
2040
+ : { management: snapshot.previous.management }),
1769
2041
  };
1770
2042
  let configBefore: string | undefined;
1771
2043
  if (request.configBackup !== undefined && existsSync(request.configBackup)) {
@@ -1813,10 +2085,17 @@ export async function rollbackFromJournal(
1813
2085
  });
1814
2086
  enqueueUpgradeReport(
1815
2087
  "tier2",
1816
- reportProject(request.selectors),
2088
+ reportProject(request.installProjects ?? request.selectors),
1817
2089
  `fleet restored to omp-conductor@${restoredVersion} after the failed upgrade to ${request.version}`,
1818
2090
  [
1819
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
+ }.`,
1820
2099
  "dispatch stays paused until the first tick after these restarts verifies the old version.",
1821
2100
  `Pre-upgrade config snapshot: ${request.configBackup ?? "none recorded"}`,
1822
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 };
@@ -42,7 +42,7 @@ import { randomUUID } from "node:crypto";
42
42
  import { createServer, type Server, type Socket } from "node:net";
43
43
  import { join } from "node:path";
44
44
 
45
- import { resolvePolicy, resolveReleaseGrants, resolveReview } from "../config.ts";
45
+ import { resolvePolicy, resolveReleaseGrants, resolveReview, resolveSharedInstallAuthority } from "../config.ts";
46
46
  import { wakeDispatch } from "../wake.ts";
47
47
  // The mediated release is the drain's closing gesture (#791): a successful
48
48
  // release ends this project's bounded release window, so the privileged half
@@ -60,6 +60,7 @@ import {
60
60
  import { releaseRefusal } from "../release-policy.ts";
61
61
  import { PR_LOOKUP_WINDOW_MS, REVISABLE_RUN_STATES, prReviewReadiness } from "../decisions.ts";
62
62
  import { LIVE_STATES } from "../store.ts";
63
+ import { DENIED_RELEASE_GRANTS } from "../types.ts";
63
64
  import type {
64
65
  FileLane,
65
66
  IssueComment,
@@ -71,6 +72,7 @@ import type {
71
72
  ReleaseRequirement,
72
73
  ReleaseShape,
73
74
  RepoTarget,
75
+ ResolvedGrants,
74
76
  ReviewReason,
75
77
  RunRecord,
76
78
  RunState,
@@ -172,6 +174,9 @@ export interface ReleaseExecution {
172
174
  export interface InstallExecution {
173
175
  /** The published semver to pin all three surfaces to, exactly as npm has it. */
174
176
  version: string;
177
+ /** Shared host authority and scope, carried into the detached report. */
178
+ holder: ResolvedGrants["install"];
179
+ projects: readonly string[];
175
180
  }
176
181
 
177
182
  /**
@@ -222,6 +227,11 @@ export interface VerbDeps {
222
227
  * unreadable, and cannot pick up an operator's edit either.
223
228
  */
224
229
  project: () => ProjectConfig;
230
+ /**
231
+ * Every project served by the shared daemon, re-read from the same config.
232
+ * Host-global mutations use this set rather than the calling socket's project.
233
+ */
234
+ projects: () => readonly ProjectConfig[];
225
235
  store: Store;
226
236
  tracker: Tracker;
227
237
  actions: VerbActions;
@@ -632,7 +642,7 @@ async function decide(deps: VerbDeps, channel: VerbChannel, raw: unknown): Promi
632
642
  case "conductor_release":
633
643
  return releaseVerb(deps, project, channel, args, refuse, allow);
634
644
  case "conductor_install":
635
- return installVerb(deps, project, channel, args, refuse, allow);
645
+ return installVerb(deps, channel, args, refuse, allow);
636
646
  case "conductor_pr_review":
637
647
  return prReviewVerb(deps, project, channel, args, refuse, allow);
638
648
  case "conductor_pr_review_clear":
@@ -1660,31 +1670,50 @@ async function releaseVerb(
1660
1670
 
1661
1671
 
1662
1672
  /**
1663
- * The fleet-installs-itself request (#486).
1664
- *
1665
- * Gated like a release act: the caller must be an orchestrator (spec), and the
1666
- * `install` shape must be granted to the orchestrator (release policy) — the
1667
- * package floor's "nobody patches the running conductor" is the deny default
1668
- * an operator deliberately moves, never something this verb works around.
1669
- *
1670
- * Everything past the gate is the privileged half's job: `actions.install`
1671
- * refussses a version npm does not expose and starts the detached transient
1672
- * unit. This verb records the request durably (the journal the post-restart
1673
- * tick verifies against) and tells the caller where the evidence will land.
1673
+ * The install is host-global: one package tree and daemon serve every project.
1674
+ * Authority is therefore the consensus holder across the configured project
1675
+ * set, never whichever project happened to invoke the verb (#1018). Mixed
1676
+ * holders refuse before version lookup or transient-unit creation.
1674
1677
  */
1675
1678
  async function installVerb(
1676
1679
  deps: VerbDeps,
1677
- project: ProjectConfig,
1678
1680
  channel: VerbChannel,
1679
1681
  args: Record<string, unknown>,
1680
1682
  refuse: Refuse,
1681
1683
  allow: Allow,
1682
1684
  ): Promise<Verdict> {
1683
- const grants = resolveReleaseGrants(project);
1684
- const granted = releaseRefusal(grants, channel.role, "install");
1685
+ let projects: readonly ProjectConfig[];
1686
+ try {
1687
+ projects = deps.projects();
1688
+ } catch (err) {
1689
+ const why = err instanceof Error ? err.message : String(err);
1690
+ return refuse("config-unreadable", `refused: shared install authority could not be read (${why}).`);
1691
+ }
1692
+ if (projects.length === 0) {
1693
+ return refuse("config-unreadable", "refused: shared install authority has no configured projects.");
1694
+ }
1695
+ const authority = resolveSharedInstallAuthority(projects);
1696
+ const holderDetail = authority.entries.map((entry) => `${entry.project}=${entry.holder}`).join(", ");
1697
+ if (authority.holder === undefined) {
1698
+ return refuse(
1699
+ "release-not-granted",
1700
+ `refused: conductor_install is host-global, but configured install holders conflict (${holderDetail}). ` +
1701
+ "No dispatch pause or transient install unit was created.",
1702
+ );
1703
+ }
1704
+ const globalHolder = authority.holder;
1705
+ const granted = releaseRefusal(
1706
+ { ...DENIED_RELEASE_GRANTS, install: globalHolder },
1707
+ channel.role,
1708
+ "install",
1709
+ );
1685
1710
  if (granted !== undefined) {
1686
- return refuse("release-not-granted", `refused: ${granted.reason}`);
1711
+ return refuse(
1712
+ "release-not-granted",
1713
+ `refused: ${granted.reason} Shared-host holders: ${holderDetail}.`,
1714
+ );
1687
1715
  }
1716
+ const affectedProjects = authority.entries.map((entry) => entry.project);
1688
1717
 
1689
1718
  const version = args["version"];
1690
1719
  if (typeof version !== "string" || version.trim().length === 0) {
@@ -1695,16 +1724,21 @@ async function installVerb(
1695
1724
  );
1696
1725
  }
1697
1726
 
1698
- const outcome = await deps.actions.install({ version: version.trim() });
1727
+ const outcome = await deps.actions.install({
1728
+ version: version.trim(),
1729
+ holder: globalHolder,
1730
+ projects: affectedProjects,
1731
+ });
1699
1732
  if (!outcome.ok) {
1700
1733
  return refuse("action-failed", `refused: the install request failed:\n${outcome.stderr}`);
1701
1734
  }
1702
1735
  const unit = outcome.detail;
1703
1736
  return allow(
1704
- `install requested: omp-conductor@${version} will be installed by a detached unit` +
1737
+ `install requested by host-global holder ${globalHolder}: omp-conductor@${version} will be installed ` +
1738
+ `for projects ${affectedProjects.join(", ")} by a detached unit` +
1705
1739
  `${unit === undefined ? "" : ` (${unit})`} that survives the restart. ` +
1706
1740
  "The first tick after the fleet restarts verifies version, /healthz, ticks, pane and doctor, " +
1707
- "and the outcome is reported through the durable outbox.",
1741
+ "and the outcome is reported through the durable outbox.",
1708
1742
  );
1709
1743
  }
1710
1744