omp-conductor 0.3.19 → 0.3.21

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/README.md CHANGED
@@ -194,12 +194,17 @@ or installation, so an operator cannot drain one queue and kill another queue's
194
194
  workers.
195
195
 
196
196
  Ticks remain in their existing armed or disarmed state, so an ordinary update
197
- does not halt the exact pane or require another Telegram arm challenge. Any
198
- installation, reload, or verification failure leaves dispatch paused instead of
199
- bringing up a mixed fleet. The command never publishes npm, edits an install
200
- root, or delegates lifecycle steps to an AI session. It refuses to run inside a
201
- Herdr-managed session because an updater that restarts itself cannot verify the
202
- result.
197
+ does not halt the exact pane or require another Telegram arm challenge. Progress
198
+ names the Bun-global CLI, omp plugin, Herdr plugin, brief, reloads, and both
199
+ verification passes separately.
200
+
201
+ An installation, brief, reload, or verification failure pauses dispatch and
202
+ attempts to restore the exact CLI/plugin identities that were present before the
203
+ command. If rollback also fails, the error names every failed restoration and
204
+ keeps dispatch paused; it never brings a known mixed fleet back into service.
205
+ The command never publishes npm, edits an install root, or delegates lifecycle
206
+ steps to an AI session. It refuses to run inside a Herdr-managed session because
207
+ an updater that restarts itself cannot verify the result.
203
208
 
204
209
  The bundled `skill://conductor-update` remains available as a natural-language
205
210
  entry point, but it only runs this command; the lifecycle is implemented and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omp-conductor",
3
- "version": "0.3.19",
3
+ "version": "0.3.21",
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.",
@@ -121,24 +121,37 @@ any agent is: it is long-lived, it can see everything that merged since the last
121
121
  release, and batching is exactly the judgement a per-issue session cannot make.
122
122
 
123
123
  So frame it as **three** options, in this order, and name the default. Each one
124
- maps to an `authority` answer the wizard asks for directly — the interview
125
- decides *which answer to give*, and `/conductor setup` is what records it. Never
126
- write authority into the brief by hand: the config is what words the brief, and
127
- a hand-edit is a second source that outlives the operator's memory of making it.
124
+ maps to two independent wizard answers:
125
+
126
+ - `authority.release` says **who owns the release decision**;
127
+ - `releasePolicy` is the mechanical tool gate that says whether release-shaped
128
+ commands may run at all.
129
+
130
+ The interview decides both answers, and `/conductor setup` records them. Never
131
+ write either value into the brief or hand-edit `config.json`: config is the
132
+ single source, and the wizard's dry run is where the operator sees the combined
133
+ grant.
128
134
 
129
135
  1. **Humans release.** *(default, and what the package ships)* Work ends at a
130
- green PR. Merging is a separate human action; releasing is a separate human
131
- action after that. Neither a worker nor the orchestrator ever tags, pins,
132
- publishes, or deploys. "This needs releasing" becomes something the
133
- orchestrator *reports*. → `authority: merge=human, release=human`; answer
134
- **no** to both wizard confirms.
135
- 2. **The orchestrator releases up to a named boundary.** Delegating part of the
136
- release to the supervising session, with the stopping line written down.
137
- usually `merge=orchestrator, release=orchestrator`, with the boundary in
138
- the procedure rather than in the grant; `merge=orchestrator, release=human`
139
- when the line falls before the tag.
140
- 3. **The orchestrator releases fully.** → `merge=orchestrator,
141
- release=orchestrator`.
136
+ green PR. Merging is a separate decision; releasing is a separate human
137
+ action. Neither a worker nor the orchestrator tags, pins, publishes, or
138
+ deploys. "This needs releasing" becomes something the orchestrator reports.
139
+ → `authority.release=human`, `releasePolicy=none`; answer **no** to both
140
+ release confirms.
141
+ 2. **The orchestrator releases up to a named boundary.** Delegate release
142
+ authority only when its leg includes the first release action (for example,
143
+ creating a tag or dispatching a release workflow). usually
144
+ `authority.release=orchestrator`, `releasePolicy=operator-brief`, with the
145
+ boundary in the procedure rather than in the grant. If its leg ends before
146
+ that action, use `authority.release=human`, `releasePolicy=none`.
147
+ 3. **The orchestrator releases fully.** →
148
+ `authority.release=orchestrator`, `releasePolicy=operator-brief`.
149
+
150
+ Merge authority is its own confirm in every option. Also say what opening the
151
+ tool gate means: it is project-wide and is passed to worker sessions as well as
152
+ the orchestrator. It does not grant workers release authority — their brief
153
+ still stops them at a green PR — but it removes the mechanical block. Never open
154
+ it until the operator has reviewed the release procedure and stopping boundary.
142
155
 
143
156
  Then ask the question that makes option 2 real:
144
157
 
@@ -443,12 +456,15 @@ PR URL, an issue number, or a named check actually read.* "Should be fine",
443
456
  "looks green" and "probably passing" are not evidence.
444
457
 
445
458
  Know what is *not* up there, though, because operators expect it to be: the
446
- orchestrator's own merge and release authority is **not** a hard boundary. It is
447
- config the two `authority` answers and it defaults to none. What is fixed
448
- above the banner is that a *worker* never merges or releases, and that PRs land
449
- one at a time with a freshness re-check. Delegating a release to the
450
- orchestrator does not touch either of those, so it needs no negotiation with the
451
- shipped half.
459
+ orchestrator's merge and release authority is **not** a hard boundary. It comes
460
+ from the two `authority` answers in config. Release execution has a second,
461
+ independent config control: `releasePolicy=none` mechanically blocks tags,
462
+ publishing, releases, and deploy commands even when
463
+ `authority.release=orchestrator`; `operator-brief` opens that gate for the
464
+ reviewed procedure. What is fixed above the banner is that a *worker* never
465
+ merges or releases, and that PRs land one at a time with a freshness re-check.
466
+ Changing either release control needs no negotiation with the shipped half, but
467
+ both must agree with the procedure below the banner.
452
468
 
453
469
  **Below the banner — rewrite from the interview.**
454
470
 
@@ -552,9 +568,11 @@ label; whether to rename the state labels; routing label prefix; then per repo t
552
568
  routing key, clone URL, default branch and **pre-push gates** (your Step 2
553
569
  proposal, in `cmd @ cwd` form); whether to add another repo; whether to set up
554
570
  **code-graph discovery** and the root its clones live under (Step 8); caps; the
555
- authority confirms; the worker model; the Telegram chat id for tier 2; the
556
- escalation fallback; whether an orchestrator session already runs elsewhere; the
557
- report scope; and finally whether to write `ORCHESTRATOR.md` + `POLICY.md`.
571
+ merge and release authority confirms; the independent **Release tool gate**
572
+ confirm (`none` or `operator-brief`); the worker model; the Telegram chat id for
573
+ tier 2; the escalation fallback; whether an orchestrator session already runs
574
+ elsewhere; the report scope; and finally whether to write `ORCHESTRATOR.md` +
575
+ `POLICY.md`.
558
576
 
559
577
  The end of the wizard is load-bearing:
560
578
 
@@ -565,6 +583,12 @@ The end of the wizard is load-bearing:
565
583
  - **Nothing changes before consent.** Setup creates no label, file, database, or
566
584
  arm marker before the operator agrees. Never answer that consent prompt for
567
585
  the operator.
586
+ - **Review authority and gate together.** If the procedure assigns tags,
587
+ publishing, release creation, or deploy commands to the orchestrator, the dry
588
+ run must show both `release=orchestrator` and
589
+ `releasePolicy=operator-brief`. `release=orchestrator` with
590
+ `releasePolicy=none` assigns the job while mechanically preventing it. A
591
+ human-owned release should normally remain `releasePolicy=none`.
568
592
  - **The host plan is part of consent.** For an external orchestrator, setup names
569
593
  the heartbeat file and both safety gates. It also names the staged systemd
570
594
  unit. An invalid existing heartbeat stops setup before any write.
package/src/cli.ts CHANGED
@@ -394,10 +394,12 @@ try {
394
394
  project: flag(argv, "project"),
395
395
  });
396
396
  process.stdout.write(
397
- result.alreadyCurrent
398
- ? `conductor ${result.version} already current on all three surfaces\n`
399
- : `upgraded conductor ${result.previousVersion} → ${result.version} (${result.gitHead})\n` +
400
- `dispatch ${result.dispatch} restored\n`,
397
+ `${result.alreadyCurrent ? "already current" : "upgrade complete"}:\n` +
398
+ ` Bun-global CLI omp-conductor@${result.version}\n` +
399
+ ` omp plugin omp-conductor@${result.version}\n` +
400
+ ` Herdr plugin herdr-conductor@${result.gitHead}\n` +
401
+ ` orchestrator brief managed ORCHESTRATOR.md floor current; POLICY.md preserved\n` +
402
+ ` dispatch ${result.dispatch}${result.alreadyCurrent ? "" : " restored"}\n`,
401
403
  );
402
404
  break;
403
405
  }
package/src/upgrade.ts CHANGED
@@ -125,33 +125,55 @@ function parseRegistry(raw: string): { version: string; gitHead: string } {
125
125
  }
126
126
 
127
127
  function ompPluginVersion(raw: string): string | undefined {
128
+ let parsed: unknown;
128
129
  try {
129
- const parsed = JSON.parse(raw) as { npm?: Array<{ name?: unknown; version?: unknown }> };
130
- const plugin = parsed.npm?.find((entry) => entry.name === PACKAGE);
131
- return typeof plugin?.version === "string" ? plugin.version : undefined;
130
+ parsed = JSON.parse(raw);
132
131
  } catch {
133
- return undefined;
132
+ throw new Error("omp plugin list returned invalid JSON");
134
133
  }
134
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
135
+ throw new Error("omp plugin list returned an invalid inventory");
136
+ }
137
+ const npm = Reflect.get(parsed, "npm");
138
+ if (!Array.isArray(npm)) throw new Error("omp plugin list returned no npm inventory");
139
+ const plugin = npm.find(
140
+ (entry) => entry !== null && typeof entry === "object" && Reflect.get(entry, "name") === PACKAGE,
141
+ );
142
+ if (plugin === undefined) return undefined;
143
+ const version = Reflect.get(plugin, "version");
144
+ if (typeof version !== "string" || version.length === 0) {
145
+ throw new Error("installed omp-conductor plugin has no version");
146
+ }
147
+ return version;
135
148
  }
136
149
 
137
150
  function herdrPluginSource(raw: string): string | undefined {
138
151
  const line = raw.split("\n").find((candidate) => /^- herdr-conductor\b/.test(candidate.trim()));
139
- return line?.match(/\[([^\]]+)\]\s*$/)?.[1];
152
+ if (line === undefined) return undefined;
153
+ const source = line.match(/\[([^\]]+)\]\s*$/)?.[1];
154
+ if (source === undefined) {
155
+ throw new Error("cannot determine installed Herdr plugin source");
156
+ }
157
+ return source;
140
158
  }
141
159
 
142
- async function inspectSurfaces(deps: UpgradeDeps): Promise<{
160
+ interface InstalledSurfaces {
143
161
  cliVersion: string;
144
162
  ompVersion?: string;
145
163
  herdrSource?: string;
146
- }> {
164
+ }
165
+
166
+ async function inspectSurfaces(deps: UpgradeDeps): Promise<InstalledSurfaces> {
147
167
  const session = deps.env["HERDR_SESSION"] || "fleet";
148
168
  const [cli, omp, herdr] = await Promise.all([
149
169
  mustRun(deps, "omp-conductor", ["--version"]),
150
170
  mustRun(deps, "omp", ["plugin", "list", "--json"]),
151
171
  mustRun(deps, "herdr", ["--session", session, "plugin", "list"]),
152
172
  ]);
173
+ const cliVersion = cli.stdout.trim();
174
+ if (cliVersion.length === 0) throw new Error("installed omp-conductor CLI has no version");
153
175
  return {
154
- cliVersion: cli.stdout.trim(),
176
+ cliVersion,
155
177
  ompVersion: ompPluginVersion(omp.stdout),
156
178
  herdrSource: herdrPluginSource(herdr.stdout),
157
179
  };
@@ -173,6 +195,19 @@ function surfacesCurrent(
173
195
  );
174
196
  }
175
197
 
198
+ function previousHerdrInstall(source: string): readonly [string, readonly string[]] | undefined {
199
+ if (source.startsWith("local:")) {
200
+ return ["herdr", ["plugin", "link", source.slice("local:".length), "--enabled"]];
201
+ }
202
+ if (!source.startsWith("github:")) return undefined;
203
+ const at = source.lastIndexOf("@");
204
+ if (at < "github:".length) return undefined;
205
+ return [
206
+ "herdr",
207
+ ["plugin", "install", source.slice("github:".length, at), "--ref", source.slice(at + 1), "--yes"],
208
+ ];
209
+ }
210
+
176
211
  async function waitForDrain(deps: UpgradeDeps, project?: string): Promise<void> {
177
212
  let last = -1;
178
213
  while (true) {
@@ -230,6 +265,122 @@ async function upgradeBrief(
230
265
  await mustRun(deps, "omp-conductor", ["brief-upgrade", "--migrate", "--apply", ...selected]);
231
266
  }
232
267
 
268
+ async function rollbackUpgrade(
269
+ deps: UpgradeDeps,
270
+ previous: InstalledSurfaces,
271
+ project: string | undefined,
272
+ installTouched: boolean,
273
+ briefChanged: boolean,
274
+ herdrReloadStarted: boolean,
275
+ daemonReloadStarted: boolean,
276
+ ): Promise<void> {
277
+ const failures: string[] = [];
278
+ const restore = async (label: string, command: string, args: readonly string[]): Promise<void> => {
279
+ deps.log(`rollback: ${label}`);
280
+ try {
281
+ await mustRun(deps, command, args);
282
+ } catch (err) {
283
+ failures.push(err instanceof Error ? err.message : String(err));
284
+ }
285
+ };
286
+ const removeIfPresent = async (
287
+ label: string,
288
+ command: string,
289
+ args: readonly string[],
290
+ ): Promise<void> => {
291
+ deps.log(`rollback: ${label}`);
292
+ try {
293
+ await deps.run(command, args);
294
+ } catch {
295
+ // Exact post-rollback identity verification decides whether absence is safe.
296
+ }
297
+ };
298
+
299
+ if (installTouched) {
300
+ await restore("Bun-global omp-conductor CLI", "bun", [
301
+ "add",
302
+ "-g",
303
+ `${PACKAGE}@${previous.cliVersion}`,
304
+ ]);
305
+ if (previous.ompVersion === undefined) {
306
+ await removeIfPresent("remove newly installed omp plugin", "omp", [
307
+ "plugin",
308
+ "uninstall",
309
+ PACKAGE,
310
+ ]);
311
+ } else {
312
+ await restore("omp plugin omp-conductor", "omp", [
313
+ "plugin",
314
+ "install",
315
+ `${PACKAGE}@${previous.ompVersion}`,
316
+ ]);
317
+ }
318
+
319
+ if (previous.herdrSource === undefined) {
320
+ await removeIfPresent("remove newly installed Herdr plugin", "herdr", [
321
+ "plugin",
322
+ "uninstall",
323
+ HERDR_PLUGIN,
324
+ ]);
325
+ } else {
326
+ const install = previousHerdrInstall(previous.herdrSource);
327
+ if (install === undefined) {
328
+ failures.push(`cannot restore unknown Herdr source ${previous.herdrSource}`);
329
+ } else {
330
+ if (previous.herdrSource.startsWith("local:")) {
331
+ await removeIfPresent("remove managed Herdr plugin", "herdr", [
332
+ "plugin",
333
+ "uninstall",
334
+ HERDR_PLUGIN,
335
+ ]);
336
+ }
337
+ await restore("Herdr plugin herdr-conductor", install[0], install[1]);
338
+ }
339
+ }
340
+
341
+ }
342
+ if (briefChanged) {
343
+ try {
344
+ deps.log("rollback: ORCHESTRATOR.md package floor");
345
+ await upgradeBrief(deps, "overlay", project);
346
+ } catch (err) {
347
+ failures.push(err instanceof Error ? err.message : String(err));
348
+ }
349
+ }
350
+
351
+ if (herdrReloadStarted) {
352
+ await restore("restart herdr-fleet.service", "systemctl", ["restart", HERDR_UNIT]);
353
+ }
354
+ if (daemonReloadStarted) {
355
+ try {
356
+ deps.log("rollback: restart omp-conductor dispatch daemon");
357
+ await deps.restartDaemon();
358
+ } catch (err) {
359
+ failures.push(err instanceof Error ? err.message : String(err));
360
+ }
361
+ }
362
+
363
+ if (installTouched) {
364
+ try {
365
+ const restored = await inspectSurfaces(deps);
366
+ if (
367
+ restored.cliVersion !== previous.cliVersion ||
368
+ restored.ompVersion !== previous.ompVersion ||
369
+ restored.herdrSource !== previous.herdrSource
370
+ ) {
371
+ failures.push(
372
+ `restored identities differ: cli=${restored.cliVersion}, omp=${restored.ompVersion ?? "missing"}, ` +
373
+ `herdr=${restored.herdrSource ?? "missing"}`,
374
+ );
375
+ }
376
+ } catch (err) {
377
+ failures.push(err instanceof Error ? err.message : String(err));
378
+ }
379
+ }
380
+
381
+ if (failures.length > 0) throw new Error(failures.join("; "));
382
+ }
383
+
233
384
  export async function upgradeConductor(
234
385
  options: UpgradeOptions = {},
235
386
  overrides: Partial<UpgradeDeps> = {},
@@ -268,54 +419,113 @@ export async function upgradeConductor(
268
419
  if (brief.kind === "missing") throw new Error("no ORCHESTRATOR.md exists for the configured project");
269
420
  if (initial.herdr === "unknown") throw new Error("cannot determine whether herdr-fleet.service is active");
270
421
 
271
- if (initial.dispatch === "running") deps.setPaused(true);
272
- await waitForDrain(deps, project);
273
-
274
- deps.log(`upgrading conductor ${surfaces.cliVersion} → ${release.version}`);
275
- if (installNeeded) {
276
- await mustRun(deps, "bun", ["add", "-g", `${PACKAGE}@${release.version}`]);
277
- await mustRun(deps, "omp", ["plugin", "install", `${PACKAGE}@${release.version}`]);
278
- if (surfaces.herdrSource?.startsWith("local:")) {
279
- await mustRun(deps, "herdr", ["plugin", "unlink", HERDR_PLUGIN]);
280
- }
281
- await mustRun(deps, "herdr", [
282
- "plugin",
283
- "install",
284
- HERDR_SOURCE,
285
- "--ref",
286
- release.gitHead,
287
- "--yes",
288
- ]);
422
+ deps.log(`target release: omp-conductor@${release.version} (${release.gitHead})`);
423
+ if (!initial.paused) {
424
+ deps.log("safety: pausing new issue claims");
425
+ deps.setPaused(true);
289
426
  }
290
- await upgradeBrief(deps, brief.kind, project);
291
-
292
- if (initial.herdr === "active") {
293
- await mustRun(deps, "systemctl", ["restart", HERDR_UNIT]);
294
- }
295
- if (initial.dispatch !== "stopped") {
296
- await deps.restartDaemon();
297
- }
298
-
299
- const installed = await inspectSurfaces(deps);
300
- if (!surfacesCurrent(installed, release.version, release.gitHead)) {
427
+ deps.log("drain: waiting for live omp worker sessions");
428
+ try {
429
+ await waitForDrain(deps, project);
430
+ } catch (err) {
431
+ const failure = err instanceof Error ? err.message : String(err);
301
432
  throw new Error(
302
- `upgrade verification failed: cli=${installed.cliVersion}, omp=${installed.ompVersion ?? "missing"}, ` +
303
- `herdr=${installed.herdrSource ?? "missing"}`,
433
+ `upgrade failed while draining workers: ${failure}; no installation started; dispatch remains paused`,
304
434
  );
305
435
  }
306
- if (initial.herdr === "active") {
307
- const active = await mustRun(deps, "systemctl", ["is-active", HERDR_UNIT]);
308
- if (active.stdout.trim() !== "active") throw new Error(`${HERDR_UNIT} is not active after restart`);
309
- }
310
- await waitForRecovery(deps, initial, project);
311
- await deps.sleep(1_000);
312
- await waitForRecovery(deps, initial, project);
313
-
314
- if (initial.dispatch === "running") {
315
- deps.setPaused(false);
316
- if (deps.layers(project).dispatch !== "running") {
317
- throw new Error("upgrade installed successfully, but dispatch did not resume");
436
+ deps.log("drain: complete");
437
+
438
+ let installTouched = false;
439
+ let briefChanged = false;
440
+ let herdrReloadStarted = false;
441
+ let daemonReloadStarted = false;
442
+ try {
443
+ if (installNeeded) {
444
+ installTouched = true;
445
+ deps.log(`install 1/3: Bun-global omp-conductor CLI → ${release.version}`);
446
+ await mustRun(deps, "bun", ["add", "-g", `${PACKAGE}@${release.version}`]);
447
+ deps.log(`install 2/3: omp plugin omp-conductor ${release.version}`);
448
+ await mustRun(deps, "omp", ["plugin", "install", `${PACKAGE}@${release.version}`]);
449
+ if (surfaces.herdrSource?.startsWith("local:")) {
450
+ await mustRun(deps, "herdr", ["plugin", "unlink", HERDR_PLUGIN]);
451
+ }
452
+ deps.log(`install 3/3: Herdr plugin herdr-conductor → ${release.gitHead}`);
453
+ await mustRun(deps, "herdr", [
454
+ "plugin",
455
+ "install",
456
+ HERDR_SOURCE,
457
+ "--ref",
458
+ release.gitHead,
459
+ "--yes",
460
+ ]);
461
+ } else {
462
+ deps.log("install: CLI, omp plugin, and Herdr plugin already pinned to the target release");
463
+ }
464
+
465
+ deps.log("brief: refreshing managed ORCHESTRATOR.md floor while preserving POLICY.md");
466
+ briefChanged = true;
467
+ await upgradeBrief(deps, brief.kind, project);
468
+
469
+ if (initial.herdr === "active") {
470
+ herdrReloadStarted = true;
471
+ deps.log("reload: restarting herdr-fleet.service and recovering the orchestrator pane");
472
+ await mustRun(deps, "systemctl", ["restart", HERDR_UNIT]);
473
+ }
474
+ if (initial.dispatch !== "stopped") {
475
+ daemonReloadStarted = true;
476
+ deps.log("reload: restarting the omp-conductor dispatch daemon");
477
+ await deps.restartDaemon();
478
+ }
479
+
480
+ deps.log("verify 1/2: package identities, services, pane, ticks, and paused dispatch");
481
+ const installed = await inspectSurfaces(deps);
482
+ if (!surfacesCurrent(installed, release.version, release.gitHead)) {
483
+ throw new Error(
484
+ `installed identities differ: cli=${installed.cliVersion}, omp=${installed.ompVersion ?? "missing"}, ` +
485
+ `herdr=${installed.herdrSource ?? "missing"}`,
486
+ );
487
+ }
488
+ if (initial.herdr === "active") {
489
+ const active = await mustRun(deps, "systemctl", ["is-active", HERDR_UNIT]);
490
+ if (active.stdout.trim() !== "active") throw new Error(`${HERDR_UNIT} is not active after restart`);
491
+ }
492
+ await waitForRecovery(deps, initial, project);
493
+ deps.log("verify 2/2: recovered fleet remains stable");
494
+ await deps.sleep(1_000);
495
+ await waitForRecovery(deps, initial, project);
496
+
497
+ if (!initial.paused) deps.setPaused(false);
498
+ const restoredDispatch = deps.layers(project).dispatch;
499
+ if (restoredDispatch !== initial.dispatch) {
500
+ throw new Error(
501
+ `dispatch state is ${restoredDispatch}; expected to restore ${initial.dispatch}`,
502
+ );
503
+ }
504
+ } catch (err) {
505
+ try {
506
+ if (!deps.layers(project).paused) deps.setPaused(true);
507
+ } catch {
508
+ deps.setPaused(true);
509
+ }
510
+ const failure = err instanceof Error ? err.message : String(err);
511
+ deps.log(`upgrade failed: ${failure}`);
512
+ try {
513
+ await rollbackUpgrade(
514
+ deps,
515
+ surfaces,
516
+ project,
517
+ installTouched,
518
+ briefChanged,
519
+ herdrReloadStarted,
520
+ daemonReloadStarted,
521
+ );
522
+ } catch (rollbackErr) {
523
+ const rollback = rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr);
524
+ throw new Error(
525
+ `upgrade failed: ${failure}; rollback incomplete: ${rollback}; dispatch remains paused`,
526
+ );
318
527
  }
528
+ throw new Error(`upgrade failed: ${failure}; previous installation restored; dispatch remains paused`);
319
529
  }
320
530
 
321
531
  return {