omp-conductor 0.20.2 → 0.20.3

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/lifecycle.ts CHANGED
@@ -194,6 +194,103 @@ export function livingDaemon(): DaemonRecord | undefined {
194
194
  return undefined;
195
195
  }
196
196
 
197
+ /**
198
+ * `DaemonRecord.logFile` for a daemon nobody spawned — which is how systemd
199
+ * runs it (`ExecStart … omp-conductor daemon`) and how a hand-run foreground
200
+ * `daemon` works too. The field is required and `status` prints it, so it has
201
+ * to say something true: a foreground daemon opened no log of its own —
202
+ * whoever started it owns its stdout, be that the journal, a terminal, or a
203
+ * pane.
204
+ */
205
+ const FOREGROUND_LOG = "<inherited stdout — started in the foreground>";
206
+
207
+ /** What this process would publish as its own boot record, given its flags. */
208
+ function foregroundRecord(o: { port?: number; project?: string }): DaemonRecord {
209
+ return {
210
+ pid: process.pid,
211
+ port: o.port ?? DEFAULT_PORT,
212
+ startedAt: Date.now(),
213
+ logFile: FOREGROUND_LOG,
214
+ ...(o.project === undefined ? {} : { project: o.project }),
215
+ };
216
+ }
217
+
218
+ export type ForegroundBoot =
219
+ | { kind: "claimed"; record: DaemonRecord }
220
+ | { kind: "foreign-live"; pid: number };
221
+
222
+ /**
223
+ * The boot half of pidfile ownership (#1114): a daemon that starts without a
224
+ * managed spawn — systemd's `ExecStart`, a reboot, a bare hand-run — publishes
225
+ * its own record here, so the single-owner guard (`startDaemon`'s live-record
226
+ * refusal), `wake`, `status` and orphan reconciliation are armed no matter who
227
+ * started the process.
228
+ *
229
+ * A foreign live record is refused, never stolen; a stale one is cleared by
230
+ * {@link livingDaemon} on the way past; and a record already naming this pid
231
+ * was written by whoever spawned us and knows the real log file — replacing
232
+ * it with a guess would be a downgrade.
233
+ */
234
+ export function publishForegroundRecord(
235
+ o: { port?: number; project?: string } = {},
236
+ ): ForegroundBoot {
237
+ const running = livingDaemon();
238
+ if (running !== undefined && running.pid !== process.pid) {
239
+ return { kind: "foreign-live", pid: running.pid };
240
+ }
241
+ if (running !== undefined) return { kind: "claimed", record: running };
242
+ const record = foregroundRecord(o);
243
+ writeRecord(record);
244
+ return { kind: "claimed", record };
245
+ }
246
+
247
+ /**
248
+ * The running half of pidfile ownership (#1114): the record can vanish under
249
+ * a perfectly healthy daemon — an external sweep of the shared runtime
250
+ * directory, a tmp cleaner — and nothing else re-arms the single-owner guard
251
+ * until the next restart. The daemon itself repairs that: absent or stale
252
+ * means it publishes again; a live foreign record is left alone (a `--once`
253
+ * drill may legitimately own visibility mid-tick).
254
+ */
255
+ export function republishSelfRecord(o: { port?: number; project?: string } = {}): void {
256
+ const current = livingDaemon();
257
+ // Ours is already published, or a live foreign process owns visibility:
258
+ // either way there is nothing to repair.
259
+ if (current !== undefined) return;
260
+ writeRecord(foregroundRecord(o));
261
+ }
262
+
263
+ /** How often a running daemon re-asserts its record ({@link republishSelfRecord}). */
264
+ export const RECORD_REPAIR_INTERVAL_MS = 60_000;
265
+
266
+ /** Stops the periodic repair started by {@link startRecordRepair}. */
267
+ export interface RecordRepair {
268
+ stop(): void;
269
+ }
270
+
271
+ /**
272
+ * Re-assert the record on an interval while the daemon runs. A failed repair
273
+ * never propagates: the pidfile is visibility, not correctness, and a failed
274
+ * attempt retries on the next tick rather than killing the dispatcher over it.
275
+ */
276
+ export function startRecordRepair(
277
+ o: { port?: number; project?: string } = {},
278
+ intervalMs = RECORD_REPAIR_INTERVAL_MS,
279
+ ): RecordRepair {
280
+ const timer = setInterval(() => {
281
+ try {
282
+ republishSelfRecord(o);
283
+ } catch (err) {
284
+ process.stderr.write(
285
+ `omp-conductor: could not re-assert the daemon pidfile: ${(err as Error).message}\n`,
286
+ );
287
+ }
288
+ }, intervalMs);
289
+ // Bookkeeping must never hold the event loop open at shutdown.
290
+ timer.unref?.();
291
+ return { stop: () => clearInterval(timer) };
292
+ }
293
+
197
294
  /**
198
295
  * The `daemon --once` lease file, one per process. Created `O_EXCL` so two
199
296
  * simultaneous `--once` runs cannot both win ownership, and it names the
@@ -1186,6 +1283,10 @@ export type SystemctlResult = {
1186
1283
  stderr: string;
1187
1284
  /** Binary not found (ENOENT). Distinct from a failed invocation of a present binary. */
1188
1285
  missing?: boolean;
1286
+ /** The client bound expired (spawnSync ETIMEDOUT). A state-changing verb
1287
+ * that reports this is re-judged from the unit's observed state before it
1288
+ * becomes a failure (#1112). */
1289
+ timedOut?: boolean;
1189
1290
  };
1190
1291
 
1191
1292
  export type SystemctlFn = (args: string[]) => SystemctlResult;
@@ -1251,14 +1352,169 @@ function testSystemctl(args: string[]): SystemctlResult {
1251
1352
  }
1252
1353
  }
1253
1354
 
1254
- function defaultSystemctl(args: string[]): SystemctlResult {
1255
- if (process.env["NODE_ENV"] === "test") return testSystemctl(args);
1355
+ /** Verbs whose completion is a unit job: systemd holds the client until the
1356
+ * stop/start finishes, which legitimately takes as long as the unit's own
1357
+ * graceful-shutdown budget. Everything else answers from manager state and
1358
+ * keeps the short probe bound (#1112). */
1359
+ const STATE_CHANGING_VERBS: Record<string, true> = {
1360
+ start: true,
1361
+ stop: true,
1362
+ restart: true,
1363
+ "try-restart": true,
1364
+ reload: true,
1365
+ "reload-or-restart": true,
1366
+ };
1367
+
1368
+ /**
1369
+ * Bound for probe verbs (`show`, `is-active`, `is-enabled`, …): a hung dbus
1370
+ * becomes `unknown` ownership instead of a hang. Callers must NOT treat that
1371
+ * timeout as "no unit". State-changing verbs get their own budget below — a
1372
+ * 15s bound on `restart` is what declared a healthy restart failed and rolled
1373
+ * a good release back (#1112).
1374
+ */
1375
+ const SYSTEMCTL_PROBE_TIMEOUT_MS = 15_000;
1376
+
1377
+ /**
1378
+ * systemd's built-in `TimeoutStopSec`: how long any unit without an explicit
1379
+ * override may legitimately spend stopping before systemd itself escalates.
1380
+ * A client bound for a state-changing verb never sits below this floor —
1381
+ * smaller is structurally able to lose against a healthy, graceful stop.
1382
+ */
1383
+ const SYSTEMD_DEFAULT_TIMEOUT_STOP_MS = 90_000;
1384
+
1385
+ /** Slack over the unit's own stop budget: the start half of a restart plus
1386
+ * scheduling delay, before the client's stopwatch means anything at all. */
1387
+ const STATE_VERB_SLACK_MS = 30_000;
1388
+
1389
+ type RawSystemctl = (args: string[], timeoutMs: number) => SystemctlResult;
1390
+
1391
+ /** One unit-state reading, in the shape the post-timeout verdict needs. */
1392
+ interface UnitStateSample {
1393
+ activeState?: string;
1394
+ /** `ExecMainStartTimestampMonotonic` — changes on every fresh start of the
1395
+ * unit, which is what makes "the old instance" distinguishable. */
1396
+ startStampMonotonic?: string;
1397
+ /** `TimeoutStopUSec` — integer microseconds, or `infinity`. */
1398
+ timeoutStopUSec?: string;
1399
+ }
1400
+
1401
+ const STATE_SAMPLE_PROPERTIES = [
1402
+ "--property=ActiveState",
1403
+ "--property=ExecMainStartTimestampMonotonic",
1404
+ "--property=TimeoutStopUSec",
1405
+ ] as const;
1406
+
1407
+ function sampleUnitState(unit: string, raw: RawSystemctl): UnitStateSample | undefined {
1408
+ const ran = raw(
1409
+ ["show", unit, "--value", ...STATE_SAMPLE_PROPERTIES],
1410
+ SYSTEMCTL_PROBE_TIMEOUT_MS,
1411
+ );
1412
+ if (!ran.ok) return undefined;
1413
+ const values = ran.stdout.split("\n");
1414
+ const pick = (index: number): string | undefined => {
1415
+ const value = values[index]?.trim();
1416
+ return value === undefined || value === "" ? undefined : value;
1417
+ };
1418
+ return {
1419
+ activeState: pick(0),
1420
+ startStampMonotonic: pick(1),
1421
+ timeoutStopUSec: pick(2),
1422
+ };
1423
+ }
1424
+
1425
+ /**
1426
+ * The client bound for one state-changing verb: at least the unit's own
1427
+ * `TimeoutStopUSec` — read live, because any fixed constant re-creates this
1428
+ * bug on whatever host drains slower than it — and never below systemd's own
1429
+ * default stop budget. An unreadable budget gets the documented fallback.
1430
+ */
1431
+ function stateVerbTimeoutMs(sample: UnitStateSample | undefined): number {
1432
+ let stopMs = SYSTEMD_DEFAULT_TIMEOUT_STOP_MS;
1433
+ const usec =
1434
+ sample?.timeoutStopUSec === undefined ? Number.NaN : Number.parseInt(sample.timeoutStopUSec, 10);
1435
+ if (Number.isFinite(usec) && usec > 0) {
1436
+ stopMs = Math.max(Math.ceil(usec / 1000), stopMs);
1437
+ }
1438
+ return stopMs + STATE_VERB_SLACK_MS;
1439
+ }
1440
+
1441
+ /**
1442
+ * The stopwatch lost; the unit's own state decides. A state-changing verb that
1443
+ * outlived the client bound succeeded exactly when the manager shows the
1444
+ * verb's goal reached: not-active for `stop`, active with a FRESH start
1445
+ * timestamp for the start/restart family — an unchanged timestamp means the
1446
+ * old instance is still the running one. Anything else stays a failure that
1447
+ * carries both facts.
1448
+ */
1449
+ function stateVerbTimeoutVerdict(
1450
+ verb: string,
1451
+ unit: string,
1452
+ before: UnitStateSample | undefined,
1453
+ after: UnitStateSample | undefined,
1454
+ timedOut: SystemctlResult,
1455
+ timeoutMs: number,
1456
+ ): SystemctlResult {
1457
+ const base =
1458
+ `systemctl ${verb} ${unit} exceeded its ${Math.round(timeoutMs / 1000)}s client bound ` +
1459
+ `(${timedOut.stderr.trim() || "client timeout"})`;
1460
+ if (after?.activeState === undefined) {
1461
+ return {
1462
+ ...timedOut,
1463
+ stderr:
1464
+ `${base}, and the unit's state could not be re-read afterwards — ` +
1465
+ `the outcome is unproven, not failed`,
1466
+ };
1467
+ }
1468
+ if (verb === "stop") {
1469
+ if (after.activeState !== "active" && after.activeState !== "deactivating") {
1470
+ // Goal state reached: the stop completed, whatever the stopwatch said.
1471
+ return { ok: true, stdout: "", stderr: "" };
1472
+ }
1473
+ return {
1474
+ ...timedOut,
1475
+ stderr: `${base}; the unit is still ${after.activeState} — the stop did not complete`,
1476
+ };
1477
+ }
1478
+ const freshStart =
1479
+ before !== undefined &&
1480
+ after.startStampMonotonic !== undefined &&
1481
+ after.startStampMonotonic !== before.startStampMonotonic;
1482
+ if (after.activeState === "active" && freshStart) {
1483
+ // The unit restarted; the client simply gave up first.
1484
+ return { ok: true, stdout: "", stderr: "" };
1485
+ }
1486
+ const why =
1487
+ after.activeState !== "active"
1488
+ ? `the unit is ${after.activeState}`
1489
+ : before === undefined
1490
+ ? "the unit is active but no pre-call sample exists to prove the start is fresh"
1491
+ : "its start timestamp is unchanged, so the running instance predates the call";
1492
+ return { ...timedOut, stderr: `${base}; ${why} — the ${verb} did not complete` };
1493
+ }
1494
+
1495
+ /**
1496
+ * Run one systemctl invocation with #1112 semantics: probes keep the short
1497
+ * hung-dbus bound, state-changing verbs wait out the unit's own stop budget,
1498
+ * and a state-changing verb that outlives even that is judged by the unit's
1499
+ * observed state rather than the client's stopwatch. Exported for tests,
1500
+ * which inject their own {@link RawSystemctl}.
1501
+ */
1502
+ export function runSystemctlInvocation(args: string[], raw: RawSystemctl): SystemctlResult {
1503
+ const verb = args[0] ?? "";
1504
+ if (STATE_CHANGING_VERBS[verb] !== true) return raw(args, SYSTEMCTL_PROBE_TIMEOUT_MS);
1505
+ const unit = args.length > 1 && !args[1]!.startsWith("-") ? args[1]! : SYSTEMD_UNIT;
1506
+ const before = sampleUnitState(unit, raw);
1507
+ const timeoutMs = stateVerbTimeoutMs(before);
1508
+ const ran = raw(args, timeoutMs);
1509
+ if (ran.ok || !ran.timedOut) return ran;
1510
+ return stateVerbTimeoutVerdict(verb, unit, before, sampleUnitState(unit, raw), ran, timeoutMs);
1511
+ }
1512
+
1513
+ function spawnSystemctl(args: string[], timeoutMs: number): SystemctlResult {
1256
1514
  try {
1257
1515
  const res = spawnSync("systemctl", args, {
1258
1516
  encoding: "utf8",
1259
- // Bound the wait: a hung dbus becomes `unknown` ownership, not a hang.
1260
- // Callers must NOT treat that timeout as "no unit".
1261
- timeout: 15_000,
1517
+ timeout: timeoutMs,
1262
1518
  env: process.env,
1263
1519
  });
1264
1520
  if (res.error) {
@@ -1268,6 +1524,7 @@ function defaultSystemctl(args: string[]): SystemctlResult {
1268
1524
  stdout: "",
1269
1525
  stderr: err.message,
1270
1526
  ...(err.code === "ENOENT" ? { missing: true } : {}),
1527
+ ...(err.code === "ETIMEDOUT" ? { timedOut: true } : {}),
1271
1528
  };
1272
1529
  }
1273
1530
  return {
@@ -1280,6 +1537,11 @@ function defaultSystemctl(args: string[]): SystemctlResult {
1280
1537
  }
1281
1538
  }
1282
1539
 
1540
+ function defaultSystemctl(args: string[]): SystemctlResult {
1541
+ if (process.env["NODE_ENV"] === "test") return testSystemctl(args);
1542
+ return runSystemctlInvocation(args, spawnSystemctl);
1543
+ }
1544
+
1283
1545
  let systemctl: SystemctlFn = defaultSystemctl;
1284
1546
 
1285
1547
  /** Shared manager runner. Production reaches the hardcoded binary; test
package/src/ready-gate.ts CHANGED
@@ -95,6 +95,65 @@ export function acceptanceCriteriaSection(text: string): { heading: string; crit
95
95
  return { heading: lines[start]!.trim(), criteria };
96
96
  }
97
97
 
98
+ /** The leading run of backticked tokens in a bullet: `` `a.ts`, `b/c.ts` ``
99
+ * reads as two paths, while a backticked token after prose is prose, never a
100
+ * declaration. Mirrors admission's private helper of the same shape — the
101
+ * gate cannot import it, and a second grammar beside the first would make
102
+ * issues that render correctly for one parse wrongly for the other. */
103
+ function leadingBacktickedRun(text: string): string[] {
104
+ const trimmed = text.trim();
105
+ if (!trimmed.startsWith("`")) return [];
106
+ const run: string[] = [];
107
+ let pos = 0;
108
+ while (true) {
109
+ if (trimmed[pos] !== "`") break;
110
+ const close = trimmed.indexOf("`", pos + 1);
111
+ if (close < 0) break;
112
+ const token = trimmed.slice(pos + 1, close).trim();
113
+ if (token === "") break;
114
+ run.push(token);
115
+ pos = close + 1;
116
+ const sep = /^[,\s]+/.exec(trimmed.slice(pos));
117
+ if (sep === null) break;
118
+ pos += sep[0].length;
119
+ }
120
+ return run;
121
+ }
122
+
123
+ /**
124
+ * The read-only entry points an issue declares: files its worker must read —
125
+ * the caller to understand, the file that proves the convention — but never
126
+ * write (#1108). The grammar mirrors the write-lane section: a `#`-to-`######`
127
+ * heading spelling `Read-only` optionally followed by `entry points`, an
128
+ * optional trailing colon, then the contiguous bullet run any non-bullet line
129
+ * ends; each bullet contributes its leading backticked run, so an issue that
130
+ * renders correctly for the lane sections renders correctly here too. Unlike
131
+ * the lane there is no inline form and no bare-token fallback: marking a file
132
+ * read-only is a deliberate act, and the backticked bullet is the whole shape.
133
+ *
134
+ * Every occurrence in the text counts, where a lane declaration replaces:
135
+ * read-only lists accumulate, so a correction comment adds references without
136
+ * restating the body's.
137
+ */
138
+ export function readOnlyEntryPoints(text: string): string[] {
139
+ const files: string[] = [];
140
+ let inSection = false;
141
+ for (const line of text.split("\n")) {
142
+ if (/^[ \t]*#{1,6}[ \t]+read[-\s]only(?:[ \t]+entry[ \t]+points)?[ \t]*[:.]?[ \t]*$/i.test(line)) {
143
+ inSection = true;
144
+ continue;
145
+ }
146
+ if (!inSection || line.trim() === "") continue;
147
+ const marker = line.match(/^[ \t]*(?:[-*+]|\d+[.)])[ \t]+(?:\[[ xX]\][ \t]+)?/);
148
+ if (marker === null) {
149
+ inSection = false;
150
+ continue;
151
+ }
152
+ files.push(...leadingBacktickedRun(line.slice(marker[0].length)));
153
+ }
154
+ return files;
155
+ }
156
+
98
157
  /** Trimmed non-empty strings out of a value the type system claims is a string
99
158
  * array. Defensive because the gate is the last thing between a hand-edited
100
159
  * store row and a dispatched worker: a malformed field is a refusal, never a
@@ -114,7 +173,7 @@ function strings(value: unknown): string[] {
114
173
  * May this verdict be promoted mechanically?
115
174
  *
116
175
  * Every check is one named miss. The order is fixed — verdict, readability,
117
- * acceptance criteria, write lane, proof commands, sizing evidence,
176
+ * acceptance criteria, write lane, entry points, proof commands, sizing evidence,
118
177
  * dependencies, premise, routing, state labels — so the same input always
119
178
  * produces the same list and a digest line is stable across ticks.
120
179
  *
@@ -181,6 +240,7 @@ export function readyGate(input: ReadyGateInput): ReadyGateVerdict {
181
240
  ? undefined
182
241
  : (writeLaneSectionHeading(body) ??
183
242
  comments.map((comment) => writeLaneSectionHeading(comment.body)).find((found) => found !== undefined));
243
+ const declared = lane === undefined ? [] : [...new Set(strings(lane.files))].sort();
184
244
  if (verdictLane.length === 0) {
185
245
  // The schema requires at least one path, so this is a hand-edited or
186
246
  // corrupt row. An empty lane on both sides would otherwise *match*, and
@@ -190,11 +250,46 @@ export function readyGate(input: ReadyGateInput): ReadyGateVerdict {
190
250
  missing.push(
191
251
  `the write-lane section (${heading}) parsed no path-like files — name them as backticked bullets directly under the heading`,
192
252
  );
193
- } else {
194
- const declared = lane === undefined ? [] : [...new Set(strings(lane.files))].sort();
195
- if (declared.length !== verdictLane.length || declared.some((path, index) => path !== verdictLane[index])) {
253
+ } else if (declared.length !== verdictLane.length || declared.some((path, index) => path !== verdictLane[index])) {
254
+ missing.push(
255
+ `the issue's write lane [${declared.join(", ")}] disagrees with the verdict's file lane [${verdictLane.join(", ")}]`,
256
+ );
257
+ }
258
+
259
+ // An entry point outside the lane is usually legitimate — the reference
260
+ // file, the caller to understand first — so the gate refuses the *unstated*
261
+ // one, not the idea (#1108): eight settlement flags in one day were lane
262
+ // escapes, several into files the issue itself had already named as an
263
+ // entry point. Putting the file in the lane it is meant to be written in,
264
+ // or listing it under a `## Read-only entry points` heading, both satisfy
265
+ // the check; a hard refusal for every reference would push promoters into
266
+ // stopping the one field workers most need.
267
+ //
268
+ // Judged only when the two lane lists agree: a disagreement is refused
269
+ // anyway, and entry points computed against a lane about to be rewritten
270
+ // would be noise stacked on the actionable fact. No parsed lane means
271
+ // nothing to stand outside of.
272
+ const entryPoints = [...new Set(strings(result.entryPoints))];
273
+ const lanesAgree =
274
+ verdictLane.length > 0 &&
275
+ declared.length === verdictLane.length &&
276
+ declared.every((path, index) => path === verdictLane[index]);
277
+ if (entryPoints.length > 0 && lanesAgree) {
278
+ const readOnly = [
279
+ ...new Set([body, ...comments.map((comment) => comment.body)].flatMap((text) => readOnlyEntryPoints(text))),
280
+ ];
281
+ // Exact member or anything under a trailing-slash directory entry — the
282
+ // containment the lane enforcement itself applies to written files
283
+ // (diff-flags.ts:withinLane).
284
+ const escaped = entryPoints.filter(
285
+ (path) =>
286
+ !declared.includes(path) &&
287
+ !declared.some((entry) => entry.endsWith("/") && path.startsWith(entry)) &&
288
+ !readOnly.includes(path),
289
+ );
290
+ if (escaped.length > 0) {
196
291
  missing.push(
197
- `the issue's write lane [${declared.join(", ")}] disagrees with the verdict's file lane [${verdictLane.join(", ")}]`,
292
+ `entry points ${escaped.join(", ")} are neither in the issue's write lane [${declared.join(", ")}] nor marked read-only — put each in the \`## Exact write lane\` bullets meant for writing, or declare it under \`## Read-only entry points\``,
198
293
  );
199
294
  }
200
295
  }
package/src/upgrade.ts CHANGED
@@ -1696,7 +1696,8 @@ export async function upgradeConductor(
1696
1696
  journal({ kind: "phase", phase: "drain", ok: false, version: release.version, detail: err instanceof Error ? err.message : String(err) });
1697
1697
  const failure = err instanceof Error ? err.message : String(err);
1698
1698
  throw new Error(
1699
- `upgrade failed while draining workers: ${failure}; no installation started; dispatch remains paused`,
1699
+ `upgrade failed while draining workers: ${failure}; no installation started; ` +
1700
+ `dispatch remains paused — resume dispatch with \`omp-conductor resume --all\``,
1700
1701
  );
1701
1702
  }
1702
1703
  journal({ kind: "phase", phase: "drain", ok: true, version: release.version });
@@ -1916,7 +1917,8 @@ export async function upgradeConductor(
1916
1917
  `Restored: cli=${surfaces.cliVersion}, omp=${surfaces.ompVersion ?? "missing"}, ` +
1917
1918
  `herdr=${surfaces.herdrSource ?? "missing"}`,
1918
1919
  ...(preUpgradeBackup === undefined ? [] : [`Pre-upgrade config snapshot: ${preUpgradeBackup}`]),
1919
- "Dispatch stays paused until the first tick after the restart records the rollback verified.",
1920
+ "Dispatch stays paused until the first tick after the restart records the rollback verified; " +
1921
+ "if it is still paused, resume it with `omp-conductor resume --all`.",
1920
1922
  ],
1921
1923
  `upgrade:${release.version}:rolled-back`,
1922
1924
  );
@@ -1931,16 +1933,21 @@ export async function upgradeConductor(
1931
1933
  `Failure: ${failure}`,
1932
1934
  installAttribution,
1933
1935
  `Rollback failure: ${rollback}`,
1934
- "The fleet may be at mixed versions; do not resume dispatch.",
1936
+ "The fleet may be at mixed versions; do not resume dispatch until versions are verified.",
1937
+ "Once one version is confirmed everywhere, `omp-conductor resume --all` lifts the pause.",
1935
1938
  "Restore manually from the journal at " + upgradeJournalPath(stateDir()) + ".",
1936
1939
  ],
1937
1940
  `upgrade:${release.version}:rollback-failed`,
1938
1941
  );
1939
1942
  throw new Error(
1940
- `upgrade failed: ${failure}; rollback incomplete: ${rollback}; dispatch remains paused`,
1943
+ `upgrade failed: ${failure}; rollback incomplete: ${rollback}; dispatch remains paused — ` +
1944
+ `\`omp-conductor resume --all\` lifts the pause once mixed versions are ruled out`,
1941
1945
  );
1942
1946
  }
1943
- throw new Error(`upgrade failed: ${failure}; previous installation restored; dispatch remains paused`);
1947
+ throw new Error(
1948
+ `upgrade failed: ${failure}; previous installation restored; dispatch remains paused — ` +
1949
+ `resume dispatch with \`omp-conductor resume --all\``,
1950
+ );
1944
1951
  }
1945
1952
 
1946
1953
  // The install landed; name any host-runtime destination this version no