omp-conductor 0.15.4 → 0.15.5

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 (3) hide show
  1. package/README.md +15 -5
  2. package/package.json +1 -1
  3. package/src/upgrade.ts +174 -54
package/README.md CHANGED
@@ -251,10 +251,20 @@ command exits without changing anything when all three surfaces already use that
251
251
  release, the Herdr plugin is pinned to its exact `gitHead`, and the brief is
252
252
  current.
253
253
 
254
- When a daemon is live, the command drains and restarts its recorded project.
255
- An explicit `--project` that names a different project is rejected before pause
256
- or installation, so an operator cannot drain one queue and kill another queue's
257
- workers.
254
+ The upgrade is host-wide, because everything it replaces is: one daemon serves
255
+ every configured project, so its restart lands on all of them at once. A bare
256
+ `omp-conductor upgrade` therefore drains **every** project's workers and
257
+ refreshes **every** project's brief, and pauses them with the fleet-wide
258
+ sentinel — which leaves any per-project `hold` you set standing when it
259
+ restores dispatch.
260
+
261
+ `--project` narrows that only when it is truthful to do so. When a live daemon
262
+ recorded a single project, the command drains and restarts that project, and an
263
+ explicit `--project` naming a different one is rejected before pause or
264
+ installation. When the daemon serves every configured project and there is more
265
+ than one, `--project` is rejected too: draining one queue and then restarting
266
+ the shared daemon would kill another queue's workers without ever counting
267
+ them. Re-run without the flag.
258
268
 
259
269
  Ticks remain in their existing armed or disarmed state, so an ordinary update
260
270
  does not halt the exact pane or require another Telegram arm challenge. Progress
@@ -2334,7 +2344,7 @@ omp-conductor help
2334
2344
  | `start` | Start `herdr-fleet.service` when that optional unit is installed, clearing a previous pane-recovery pin, then spawn the dispatch loop in the background and wait until it answers `GET /healthz` on `:8787`. Without systemd or that unit it keeps the standalone daemon behaviour. It never clears pause or arms ticks. Refuses if a daemon is already live, naming its pid; if the process dies or never serves, it cleans up and quotes the tail of `daemon.log`. |
2335
2345
  | `stop` | Prefer `systemctl stop omp-conductor.service` when that unit's MainPID is the live daemon — systemd then owns the stop and will not schedule a restart for the exit it just requested. Otherwise `SIGTERM`, then `SIGKILL` after a 10-second grace period. Prints `not running` when there is nothing to stop, and tags the confirmation with `(via systemctl)` when the unit path was used. |
2336
2346
  | `restart [--now] [--timeout SECONDS] [--port N] [--project NAME]` | Drains the fleet by default: pause new claims, wait until live workers reach `0 / N` (bounded by `--timeout SECONDS`, default 1800 = 30 min), restart, then restore the prior dispatch state. Prefer `systemctl restart` when the unit owns the live pid so the replacement stays supervised; otherwise `stop` then `start`, inheriting the running daemon's port and project unless a flag overrides them. `--now` skips the drain and restarts immediately, orphaning any live runs (old behaviour). A drain that hits `--timeout` restarts nothing and leaves dispatch paused — `omp-conductor resume` lifts it, or re-run `restart` to keep waiting. The new process **salvages dirty live worktrees before orphaning** those rows — see [Deploying a new package onto a busy fleet](#deploying-a-new-package-onto-a-busy-fleet). |
2337
- | `upgrade [--to VERSION] [--project NAME]` | Deterministically update the Bun-global CLI, omp plugin, Herdr recovery plugin, and managed brief as one release. Resolves the npm version and exact `gitHead`, pauses only new claims, drains active workers, installs all surfaces, reloads Herdr and the daemon, waits for pane recovery, verifies identities and fleet health twice, then restores the original dispatch state. A no-op when already current. Failure leaves dispatch paused. Must run outside a Herdr-managed session. |
2347
+ | `upgrade [--to VERSION] [--project NAME]` | Deterministically update the Bun-global CLI, omp plugin, Herdr recovery plugin, and managed brief as one release. Resolves the npm version and exact `gitHead`, pauses only new claims, drains active workers, installs all surfaces, reloads Herdr and the daemon, waits for pane recovery, verifies identities and fleet health twice, then restores the original dispatch state. Host-wide by default: one daemon serves every configured project, so a bare run drains all of them and refreshes every brief. `--project` is rejected when the live daemon serves several projects — draining one queue and restarting the shared daemon would kill another's workers. A no-op when already current. Failure leaves dispatch paused. Must run outside a Herdr-managed session. |
2338
2348
  | `status [--project NAME]` | Layered fleet report first: `dispatch` / `ticks` / next scheduled tick / `pane` / `recovery` / `herdr` / `telegram` / `brief` / `decisions` / optional `failure classes` and `code graph` / `daemon`, then the project body. The project body includes the latest completed dispatch timestamp, ready/routed/admitted counts, bounded hold groups, and the GitHub API budget (`graphql` / `core` remaining and reset, in the caps block); API failures are marked `DEGRADED` so queue starvation cannot look idle. Active-run lines overlay cooperative worker `paused`/`pausing` from `/healthz` without changing SQLite `running` state or the live worker count. The next tick comes from the live heartbeat process, not a guess from log timestamps. Telegram health uses `getMe` to prove API authentication without sending a message and reports inbound bridge configuration separately. Configured graphs report prerequisites, indexed repos, timer state, and refresh freshness without blocking dispatch. A `reports` block lists everything the outbox has not delivered, with its age, and prints `pending` (nobody has it) differently from `SENDING` (outcome unknown, it may already have arrived) — see [Report delivery](#report-delivery-the-outbox). The daemon block includes `rss` from `/healthz`; live workers add a busy-deploy warning. A `.conductor-stalled` marker adds an `orchestrator STALLED since …` line. |
2339
2349
  | `ledger [--issue N] [--limit N]` | The action audit: every [mediated-verb](#the-mediated-verbs-126) mutation and every next-attempt turn budget. Verb entries include the arguments, decision, named refusal, and resulting SHA. Turn-budget entries remain after an override is replaced or consumed. Reads (`conductor_pr_status`) are absent so polling cannot bury the signal. `--issue` narrows both histories; `--limit` defaults to 50. Recent verb refusals and pending turn overrides also appear in `status`. |
2340
2350
  | `board [--project NAME]` | Live keyboard-driven kanban over the same SQLite and `/healthz` truth as `status`, plus the tracker's current labels: Queue, Claimed, Running, Green, Blocked, Failed, Orphaned, the last 24 hours of Merged and Settled, and Parked (an issue the tracker has not confirmed closed — still open, or a label read that failed — so nothing dispatches it until a human labels it). Columns are mutually exclusive and describe current state, not the newest run row, so a requeued issue is queued rather than failed and a closed issue is neither. Refreshes run/spend/turn values every second, and health plus the label read every ten seconds. `Enter` follows the selected transcript in place; `u` invokes the existing unblock workflow on a Blocked, Failed, or Orphaned card; `i` / `p` open the issue / PR; `r` refreshes health; `?` shows all keys. Requires an interactive terminal of at least 50×20. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omp-conductor",
3
- "version": "0.15.4",
3
+ "version": "0.15.5",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "A 24/7 dispatcher that takes ready GitHub issues to green, mergeable PRs using omp coding sessions, with tiered escalation first to an orchestrator session and then to a human.",
package/src/upgrade.ts CHANGED
@@ -52,6 +52,13 @@ export interface UpgradeDeps {
52
52
  snapshot(project?: string): { liveWorkers: number };
53
53
  layers(project?: string): FleetLayers;
54
54
  brief(project?: string): { kind: BriefLayout["kind"]; current: boolean };
55
+ /**
56
+ * Every configured project name, in config order. One daemon serves all of
57
+ * them (the generated unit's ExecStart carries no `--project`), so a
58
+ * transaction that restarts that daemon has to know the whole set rather
59
+ * than the one project a caller happened to name (#389).
60
+ */
61
+ projectNames(): readonly string[];
55
62
  /**
56
63
  * The daemon the transaction targets, when one is running. `generation`
57
64
  * identifies the exact instance (pid + start time) so a later restart cannot
@@ -103,6 +110,7 @@ export const DEFAULT_DEPS: UpgradeDeps = {
103
110
  const live = readFileSync(layout.orchestratorPath, "utf8");
104
111
  return { kind: layout.kind, current: live === renderBriefForProject(project, policy) };
105
112
  },
113
+ projectNames: () => loadConfig().projects.map((p) => p.name),
106
114
  daemonIdentity: () => {
107
115
  const daemon = livingDaemon();
108
116
  if (daemon === undefined) return { running: false };
@@ -268,9 +276,95 @@ function previousHerdrInstall(source: string): readonly [string, readonly string
268
276
  ];
269
277
  }
270
278
 
279
+ /**
280
+ * The projects one lifecycle transaction covers, and the single pause sentinel
281
+ * that represents them.
282
+ *
283
+ * Everything an upgrade or a draining restart replaces is host-wide: the
284
+ * Bun-global CLI, the omp plugin, the Herdr plugin, and the one dispatch
285
+ * daemon. That daemon serves *every* configured project — the generated unit's
286
+ * ExecStart carries no `--project` — so its restart lands on all of them at
287
+ * once. The scope therefore names every project whose workers must drain and
288
+ * whose brief must be refreshed, instead of the single project a caller
289
+ * happened to name (#389).
290
+ *
291
+ * `selectors` are what the rest of the module passes back to the project-aware
292
+ * deps. A selector of `undefined` means "the one configured project", which is
293
+ * how a single-project host has always addressed itself: keeping it verbatim
294
+ * means the single-project path still emits exactly the same `brief-upgrade`
295
+ * command and the same one `snapshot` call it did before.
296
+ *
297
+ * `pauseKey` is the sentinel this transaction owns. It is `undefined` for a
298
+ * host-wide scope, which selects the legacy *global* sentinel — and that is
299
+ * precisely the right instrument: `isPaused(name)` consults the global file
300
+ * for every project, so one write pauses the whole host, and removing it lifts
301
+ * exactly this transaction's pause while leaving any per-project operator hold
302
+ * standing.
303
+ */
304
+ interface UpgradeScope {
305
+ /** Project selectors to address the project-aware deps with, never empty. */
306
+ selectors: readonly (string | undefined)[];
307
+ /** The sentinel this transaction pauses and resumes. */
308
+ pauseKey: string | undefined;
309
+ }
310
+
311
+ /**
312
+ * Decides which projects a transaction covers, and refuses the requests that
313
+ * cannot be honoured truthfully.
314
+ *
315
+ * Two refusals, both about a caller naming a project the daemon does not agree
316
+ * with:
317
+ *
318
+ * - the daemon records a *different* project — it serves that one, so a
319
+ * request aimed at another is simply about a different fleet;
320
+ * - the daemon records *no* project while several are configured. That is not
321
+ * "unknown", it is the multi-project daemon: it serves them all. Narrowing
322
+ * is refused rather than obeyed, because draining one project and then
323
+ * restarting the shared daemon would kill the other projects' live workers
324
+ * without ever counting them (#389, and the shared-daemon rule in #378).
325
+ *
326
+ * A single-configured-project host keeps the historical bare selector, so its
327
+ * behaviour is unchanged whether or not the daemon recorded its name.
328
+ */
329
+ function resolveScope(deps: UpgradeDeps, verb: "upgrade" | "restart", project?: string): UpgradeScope {
330
+ let configured: readonly string[] = [];
331
+ try {
332
+ configured = deps.projectNames();
333
+ } catch {
334
+ // No readable config is not this function's error to raise: the caller
335
+ // reaches a project-aware dep moments later and fails with the real
336
+ // problem. Treat it as the historical bare case.
337
+ }
338
+ const daemon = deps.daemonIdentity();
339
+
340
+ if (project !== undefined) {
341
+ if (daemon.running && daemon.project !== undefined && daemon.project !== project) {
342
+ throw new Error(
343
+ `active daemon serves ${daemon.project}; refusing to ${verb} --project ${project} while it is running`,
344
+ );
345
+ }
346
+ if (daemon.running && daemon.project === undefined && configured.length > 1) {
347
+ throw new Error(
348
+ `the running daemon serves all ${String(configured.length)} configured projects ` +
349
+ `(${configured.join(", ")}); ${verb} replaces host-wide packages and restarts that one daemon, ` +
350
+ `so it cannot be narrowed to --project ${project} — re-run \`omp-conductor ${verb}\` without --project`,
351
+ );
352
+ }
353
+ return { selectors: [project], pauseKey: project };
354
+ }
355
+
356
+ // A daemon that recorded its project serves that project alone; the rest of
357
+ // the config is somebody else's fleet as far as this restart is concerned.
358
+ if (daemon.running && daemon.project !== undefined) {
359
+ return { selectors: [daemon.project], pauseKey: daemon.project };
360
+ }
361
+ if (configured.length > 1) return { selectors: configured, pauseKey: undefined };
362
+ return { selectors: [undefined], pauseKey: undefined };
363
+ }
364
+
271
365
  async function waitForDrain(
272
366
  deps: UpgradeDeps,
273
- project?: string,
367
+ scope: UpgradeScope,
274
368
  deadlineAt?: number,
275
369
  stale?: () => string | undefined,
276
370
  ): Promise<void> {
@@ -282,7 +376,10 @@ async function waitForDrain(
282
376
  // and nothing it may stop (#377).
283
377
  const cancelled = stale?.();
284
378
  if (cancelled !== undefined) throw new Error(cancelled);
285
- const workers = deps.snapshot(project).liveWorkers;
379
+ // Every project the scope covers: a host-wide drain that counted only one
380
+ // would report zero while another project's worker was still writing to a
381
+ // worktree the restart is about to orphan (#389).
382
+ const workers = scope.selectors.reduce((n, s) => n + deps.snapshot(s).liveWorkers, 0);
286
383
  if (workers === 0) return;
287
384
  if (deadlineAt !== undefined && Date.now() >= deadlineAt) {
288
385
  throw new Error(
@@ -321,13 +418,13 @@ interface RestartBegun {
321
418
  */
322
419
  function restartFenceProblem(
323
420
  deps: UpgradeDeps,
324
- project: string | undefined,
421
+ scope: UpgradeScope,
325
422
  begun: RestartBegun,
326
423
  ): string | undefined {
327
- if (!deps.layers(project).paused) {
424
+ if (!deps.layers(scope.pauseKey).paused) {
328
425
  return "restart cancelled: dispatch was resumed while the drain was in progress — nothing was restarted";
329
426
  }
330
- const owned = deps.pauseState(project);
427
+ const owned = deps.pauseState(scope.pauseKey);
331
428
  if (
332
429
  owned === undefined ||
333
430
  owned.since !== begun.pauseToken.since ||
@@ -370,13 +467,17 @@ export async function drainAndRestart(
370
467
  deps: UpgradeDeps,
371
468
  o: { project?: string; timeoutMs: number },
372
469
  ): Promise<void> {
373
- const initial = deps.layers(o.project);
374
- if (!initial.paused) deps.setPaused(true, o.project);
470
+ // Host-wide by default (#389): the daemon this restarts serves every
471
+ // configured project, so the drain counts every project's workers and the
472
+ // pause covers all of them.
473
+ const scope = resolveScope(deps, "restart", o.project);
474
+ const initial = deps.layers(scope.pauseKey);
475
+ if (!initial.paused) deps.setPaused(true, scope.pauseKey);
375
476
  // The lock must be provable NOW. A pause that cannot be read as an instance
376
477
  // — sentinel malformed or unreadable — would make the whole fence fail open
377
478
  // (nothing to compare against), so the transaction refuses before waiting
378
479
  // on anything it cannot act upon either way.
379
- const pauseToken = deps.pauseState(o.project);
480
+ const pauseToken = deps.pauseState(scope.pauseKey);
380
481
  if (pauseToken === undefined) {
381
482
  throw new Error(
382
483
  "restart cancelled: cannot prove the restart-owned pause — the active pause sentinel is unreadable or malformed; nothing was restarted",
@@ -386,14 +487,14 @@ export async function drainAndRestart(
386
487
  pauseToken,
387
488
  daemon: deps.daemonIdentity(),
388
489
  };
389
- const stale = () => restartFenceProblem(deps, o.project, begun);
390
- await waitForDrain(deps, o.project, Date.now() + o.timeoutMs, stale);
490
+ const stale = () => restartFenceProblem(deps, scope, begun);
491
+ await waitForDrain(deps, scope, Date.now() + o.timeoutMs, stale);
391
492
  // The drain completed; the world may have moved on while it did. Re-prove
392
493
  // the pause and the generation an instant before the destructive call.
393
494
  const cancelled = stale();
394
495
  if (cancelled !== undefined) throw new Error(cancelled);
395
496
  await deps.restartDaemon();
396
- if (!initial.paused) deps.setPaused(false, o.project);
497
+ if (!initial.paused) deps.setPaused(false, scope.pauseKey);
397
498
  }
398
499
 
399
500
  function recoveryProblem(
@@ -419,19 +520,32 @@ function recoveryProblem(
419
520
  async function waitForRecovery(
420
521
  deps: UpgradeDeps,
421
522
  initial: FleetLayers,
422
- project?: string,
523
+ scope: UpgradeScope,
423
524
  ): Promise<void> {
424
525
  let problem = "recovery did not settle";
425
526
  for (let attempt = 0; attempt < RECOVERY_ATTEMPTS; attempt += 1) {
426
- problem = recoveryProblem(deps.layers(project), initial, deps.snapshot(project).liveWorkers) ?? "";
527
+ const live = scope.selectors.reduce((n, s) => n + deps.snapshot(s).liveWorkers, 0);
528
+ problem = recoveryProblem(deps.layers(scope.pauseKey), initial, live) ?? "";
427
529
  if (problem === "") return;
428
530
  await deps.sleep(RECOVERY_POLL_MS);
429
531
  }
430
532
  throw new Error(`upgrade verification failed: ${problem}`);
431
533
  }
432
534
 
535
+ /** One project's brief, as the transaction found it. */
536
+ interface ScopedBrief {
537
+ /** The selector to address the project-aware deps and the CLI with. */
538
+ selector: string | undefined;
539
+ kind: BriefLayout["kind"];
540
+ current: boolean;
541
+ }
542
+
433
543
  /**
434
- * Brings the brief up with the freshly installed package.
544
+ * Brings every in-scope brief up with the freshly installed package.
545
+ *
546
+ * Each project owns its own ORCHESTRATOR.md, so a host-wide upgrade migrates
547
+ * them one at a time rather than leaving every project but one on a floor the
548
+ * installed package no longer emits (#389).
435
549
  *
436
550
  * `brief-upgrade --migrate --apply` is the cross-version ABI: this code runs
437
551
  * from the *old* CLI while the new one is already installed, so the verb has to
@@ -445,29 +559,27 @@ async function waitForRecovery(
445
559
  * fleet on a single-file brief whose floor no longer tracks the package —
446
560
  * the exact drift this verb exists to end — so it fails loudly and rolls back.
447
561
  */
448
- async function upgradeBrief(
449
- deps: UpgradeDeps,
450
- kind: BriefLayout["kind"],
451
- project?: string,
452
- ): Promise<void> {
453
- const selected = project === undefined ? [] : ["--project", project];
454
- if (kind === "legacy-handwritten") {
455
- // Ungated on purpose: a hand-written brief cannot be migrated without it.
456
- await mustRun(deps, "omp-conductor", ["brief-upgrade", "--retrofit", "--apply", ...selected]);
457
- }
458
- try {
459
- await mustRun(deps, "omp-conductor", ["brief-upgrade", "--migrate", "--apply", ...selected]);
460
- } catch (err) {
461
- if (kind !== "overlay") throw err;
462
- const msg = err instanceof Error ? err.message : String(err);
463
- deps.log(`brief: brief-upgrade unavailable in the target CLI (${msg}) — overlay recomposes each tick, continuing`);
562
+ async function upgradeBriefs(deps: UpgradeDeps, briefs: readonly ScopedBrief[]): Promise<void> {
563
+ for (const brief of briefs) {
564
+ const selected = brief.selector === undefined ? [] : ["--project", brief.selector];
565
+ if (brief.kind === "legacy-handwritten") {
566
+ // Ungated on purpose: a hand-written brief cannot be migrated without it.
567
+ await mustRun(deps, "omp-conductor", ["brief-upgrade", "--retrofit", "--apply", ...selected]);
568
+ }
569
+ try {
570
+ await mustRun(deps, "omp-conductor", ["brief-upgrade", "--migrate", "--apply", ...selected]);
571
+ } catch (err) {
572
+ if (brief.kind !== "overlay") throw err;
573
+ const msg = err instanceof Error ? err.message : String(err);
574
+ deps.log(`brief: brief-upgrade unavailable in the target CLI (${msg}) — overlay recomposes each tick, continuing`);
575
+ }
464
576
  }
465
577
  }
466
578
 
467
579
  async function rollbackUpgrade(
468
580
  deps: UpgradeDeps,
469
581
  previous: InstalledSurfaces,
470
- project: string | undefined,
582
+ scope: UpgradeScope,
471
583
  installTouched: boolean,
472
584
  briefChanged: boolean,
473
585
  herdrReloadStarted: boolean,
@@ -577,7 +689,10 @@ async function rollbackUpgrade(
577
689
  if (briefChanged) {
578
690
  try {
579
691
  deps.log("rollback: ORCHESTRATOR.md package floor");
580
- await upgradeBrief(deps, "overlay", project);
692
+ await upgradeBriefs(
693
+ deps,
694
+ scope.selectors.map((selector) => ({ selector, kind: "overlay" as const, current: false })),
695
+ );
581
696
  } catch (err) {
582
697
  failures.push(err instanceof Error ? err.message : String(err));
583
698
  }
@@ -626,23 +741,21 @@ export async function upgradeConductor(
626
741
  }
627
742
 
628
743
  const requested = options.version === undefined ? `${PACKAGE}@latest` : `${PACKAGE}@${options.version}`;
629
- const daemon = deps.daemonIdentity();
630
- if (daemon.running && options.project !== undefined && daemon.project !== options.project) {
631
- const active = daemon.project === undefined ? "an unrecorded project" : daemon.project;
632
- throw new Error(
633
- `active daemon serves ${active}; refusing to upgrade --project ${options.project} while it is running`,
634
- );
635
- }
636
- const project = options.project ?? daemon.project;
744
+ // Host-wide by default (#389): one daemon serves every configured project,
745
+ // so an upgrade that restarts it drains them all and refreshes every brief.
746
+ const scope = resolveScope(deps, "upgrade", options.project);
637
747
 
638
748
  const release = parseRegistry(
639
749
  (await mustRun(deps, "npm", ["view", requested, "version", "gitHead", "--json"])).stdout,
640
750
  );
641
- const initial = deps.layers(project);
751
+ const initial = deps.layers(scope.pauseKey);
642
752
  const surfaces = await inspectSurfaces(deps);
643
- const brief = deps.brief(project);
753
+ const briefs: ScopedBrief[] = scope.selectors.map((selector) => ({
754
+ selector,
755
+ ...deps.brief(selector),
756
+ }));
644
757
  const installNeeded = !surfacesCurrent(surfaces, release.version, release.gitHead);
645
- if (!installNeeded && brief.current) {
758
+ if (!installNeeded && briefs.every((b) => b.current)) {
646
759
  return {
647
760
  previousVersion: surfaces.cliVersion,
648
761
  ...release,
@@ -673,17 +786,24 @@ export async function upgradeConductor(
673
786
  // either — the in-memory restore below still covers a clean rollback.
674
787
  }
675
788
 
676
- if (brief.kind === "missing") throw new Error("no ORCHESTRATOR.md exists for the configured project");
789
+ const missing = briefs.find((b) => b.kind === "missing");
790
+ if (missing !== undefined) {
791
+ throw new Error(
792
+ missing.selector === undefined
793
+ ? "no ORCHESTRATOR.md exists for the configured project"
794
+ : `no ORCHESTRATOR.md exists for project ${missing.selector}`,
795
+ );
796
+ }
677
797
  if (initial.herdr === "unknown") throw new Error("cannot determine whether herdr-fleet.service is active");
678
798
 
679
799
  deps.log(`target release: omp-conductor@${release.version} (${release.gitHead})`);
680
800
  if (!initial.paused) {
681
801
  deps.log("safety: pausing new issue claims");
682
- deps.setPaused(true, project);
802
+ deps.setPaused(true, scope.pauseKey);
683
803
  }
684
804
  deps.log("drain: waiting for live omp worker sessions");
685
805
  try {
686
- await waitForDrain(deps, project);
806
+ await waitForDrain(deps, scope);
687
807
  } catch (err) {
688
808
  const failure = err instanceof Error ? err.message : String(err);
689
809
  throw new Error(
@@ -721,7 +841,7 @@ export async function upgradeConductor(
721
841
 
722
842
  deps.log("brief: refreshing managed ORCHESTRATOR.md floor while preserving POLICY.md");
723
843
  briefChanged = true;
724
- await upgradeBrief(deps, brief.kind, project);
844
+ await upgradeBriefs(deps, briefs);
725
845
 
726
846
  if (initial.herdr === "active") {
727
847
  herdrReloadStarted = true;
@@ -746,13 +866,13 @@ export async function upgradeConductor(
746
866
  const active = await mustRun(deps, "systemctl", ["is-active", HERDR_UNIT]);
747
867
  if (active.stdout.trim() !== "active") throw new Error(`${HERDR_UNIT} is not active after restart`);
748
868
  }
749
- await waitForRecovery(deps, initial, project);
869
+ await waitForRecovery(deps, initial, scope);
750
870
  deps.log("verify 2/2: recovered fleet remains stable");
751
871
  await deps.sleep(1_000);
752
- await waitForRecovery(deps, initial, project);
872
+ await waitForRecovery(deps, initial, scope);
753
873
 
754
- if (!initial.paused) deps.setPaused(false, project);
755
- const restoredDispatch = deps.layers(project).dispatch;
874
+ if (!initial.paused) deps.setPaused(false, scope.pauseKey);
875
+ const restoredDispatch = deps.layers(scope.pauseKey).dispatch;
756
876
  if (restoredDispatch !== initial.dispatch) {
757
877
  throw new Error(
758
878
  `dispatch state is ${restoredDispatch}; expected to restore ${initial.dispatch}`,
@@ -760,9 +880,9 @@ export async function upgradeConductor(
760
880
  }
761
881
  } catch (err) {
762
882
  try {
763
- if (!deps.layers(project).paused) deps.setPaused(true, project);
883
+ if (!deps.layers(scope.pauseKey).paused) deps.setPaused(true, scope.pauseKey);
764
884
  } catch {
765
- deps.setPaused(true, project);
885
+ deps.setPaused(true, scope.pauseKey);
766
886
  }
767
887
  const failure = err instanceof Error ? err.message : String(err);
768
888
  deps.log(`upgrade failed: ${failure}`);
@@ -770,7 +890,7 @@ export async function upgradeConductor(
770
890
  await rollbackUpgrade(
771
891
  deps,
772
892
  surfaces,
773
- project,
893
+ scope,
774
894
  installTouched,
775
895
  briefChanged,
776
896
  herdrReloadStarted,