omp-conductor 0.15.9 → 0.15.11

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 (86) hide show
  1. package/README.md +273 -2543
  2. package/REFERENCE.md +2638 -0
  3. package/package.json +3 -2
  4. package/schema/config.schema.json +8 -23
  5. package/src/arm-challenge.ts +112 -0
  6. package/src/ask.ts +434 -0
  7. package/src/board.ts +81 -15
  8. package/src/brief-upgrade.ts +114 -8
  9. package/src/briefs/orchestrator.md +55 -29
  10. package/src/briefs/policy.md +14 -5
  11. package/src/briefs/worker.md +7 -1
  12. package/src/chain-check.ts +1 -1
  13. package/src/check-trailing-newlines.ts +82 -0
  14. package/src/cli.ts +190 -1391
  15. package/src/commands/arm.ts +21 -0
  16. package/src/commands/board.ts +23 -0
  17. package/src/commands/brief-upgrade.ts +186 -0
  18. package/src/commands/context.ts +49 -0
  19. package/src/commands/daemon.ts +71 -0
  20. package/src/commands/dashboard.ts +74 -0
  21. package/src/commands/decision.ts +103 -0
  22. package/src/commands/disarm.ts +21 -0
  23. package/src/commands/doctor.ts +98 -0
  24. package/src/commands/event.ts +62 -0
  25. package/src/commands/extend.ts +64 -0
  26. package/src/commands/friction.ts +56 -0
  27. package/src/commands/help.ts +9 -0
  28. package/src/commands/hold.ts +26 -0
  29. package/src/commands/intake.ts +134 -0
  30. package/src/commands/ledger.ts +69 -0
  31. package/src/commands/message.ts +48 -0
  32. package/src/commands/report.ts +170 -0
  33. package/src/commands/restart.ts +76 -0
  34. package/src/commands/resume.ts +58 -0
  35. package/src/commands/setup.ts +93 -0
  36. package/src/commands/start.ts +23 -0
  37. package/src/commands/stats.ts +131 -0
  38. package/src/commands/status.ts +48 -0
  39. package/src/commands/stop.ts +51 -0
  40. package/src/commands/tail.ts +109 -0
  41. package/src/commands/unblock.ts +39 -0
  42. package/src/commands/upgrade-install.ts +31 -0
  43. package/src/commands/upgrade-rollback.ts +23 -0
  44. package/src/commands/upgrade.ts +25 -0
  45. package/src/commands/verb.ts +83 -0
  46. package/src/commands/version.ts +30 -0
  47. package/src/commands/worker.ts +100 -0
  48. package/src/config-schema.ts +38 -1
  49. package/src/config.ts +10 -3
  50. package/src/daemon.ts +613 -94
  51. package/src/dashboard/app.js +120 -0
  52. package/src/dashboard/index.html +34 -0
  53. package/src/dashboard/server.ts +267 -0
  54. package/src/dashboard/style.css +180 -0
  55. package/src/decisions.ts +39 -14
  56. package/src/diff-flags.ts +131 -241
  57. package/src/doctor.ts +795 -0
  58. package/src/escalate.ts +60 -19
  59. package/src/failure-class.ts +29 -3
  60. package/src/fleet.ts +58 -1
  61. package/src/graph-health.ts +1 -1
  62. package/src/label-projection.ts +1 -1
  63. package/src/lifecycle.ts +198 -2
  64. package/src/notices.ts +9 -0
  65. package/src/omp.ts +2 -0
  66. package/src/orchestrator-tick.ts +315 -17
  67. package/src/release-policy.ts +135 -23
  68. package/src/reports.ts +19 -5
  69. package/src/setup-host.ts +420 -8
  70. package/src/setup-install.ts +69 -14
  71. package/src/setup-wizard.ts +199 -61
  72. package/src/setup.ts +131 -35
  73. package/src/stats.ts +331 -0
  74. package/src/store.ts +206 -21
  75. package/src/tracker/github.ts +27 -4
  76. package/src/types.ts +144 -31
  77. package/src/unblock.ts +55 -11
  78. package/src/upgrade-journal.ts +220 -0
  79. package/src/upgrade-verify.ts +506 -0
  80. package/src/upgrade.ts +295 -26
  81. package/src/verbs/actions.ts +73 -1
  82. package/src/verbs/protocol.ts +29 -4
  83. package/src/verbs/server.ts +183 -20
  84. package/systemd/omp-conductor-recover.sh +433 -0
  85. package/systemd/omp-conductor.service.example +7 -0
  86. package/systemd/recover-unit-test.sh +428 -0
@@ -0,0 +1,506 @@
1
+ /**
2
+ * The durable seam between the detached fleet installer and the daemon that
3
+ * verifies it (#486).
4
+ *
5
+ * `omp-conductor upgrade` has always been a transaction *inside* one process:
6
+ * it paused, drained, installed, reloaded and verified, and the process that
7
+ * verified was the process that restarted the fleet. The fleet-installs-itself
8
+ * path replaces that wrapper, not the transaction: a detached transient unit
9
+ * performs the install while `upgrade-journal.ts` records every surface it
10
+ * touches, and the first tick of the post-restart daemon verifies against the
11
+ * journal — a process that did not run the install, which is the only witness
12
+ * worth trusting after the restarts.
13
+ *
14
+ * This module is a leaf on purpose. The daemon's privileged tick and the
15
+ * upgrade engine both need the verifier, the request classification and the
16
+ * transient-unit naming, and they already import each other — `upgrade.ts`
17
+ * takes its pause helpers from `daemon.ts`, and the daemon runs the engine's
18
+ * rollback. Putting the shared seams here instead of importing `upgrade.ts`
19
+ * from the daemon keeps that graph acyclic; everything the verifier touches
20
+ * that belongs to a host state is injected through `UpgradeVerifyDeps`, so the
21
+ * only runtime imports are leaves (journal, config, store).
22
+ */
23
+
24
+ import { loadConfig, stateDir } from "./config.ts";
25
+ import { DEFAULT_PORT } from "./lifecycle.ts";
26
+ import { dbPath, openStore } from "./store.ts";
27
+ import { appendJournal, readUpgradeJournal, upgradeJournalPath, type UpgradeCheck, type UpgradeJournalEntry } from "./upgrade-journal.ts";
28
+ import type { DoctorReport } from "./doctor.ts";
29
+ import type { FleetLayers } from "./fleet.ts";
30
+ import type { ReportDraft, ReportKind } from "./types.ts";
31
+
32
+ /** One command run to completion with captured output. The transient unit, the
33
+ * post-restart verifier and the rollback unit all run processes this way —
34
+ * one named construction site, so the seam tests can inject it everywhere. */
35
+ export interface UpgradeCommandResult {
36
+ code: number;
37
+ stdout: string;
38
+ stderr: string;
39
+ }
40
+
41
+ export async function runCommand(command: string, args: readonly string[]): Promise<UpgradeCommandResult> {
42
+ try {
43
+ const child = Bun.spawn([command, ...args], {
44
+ stdin: "ignore",
45
+ stdout: "pipe",
46
+ stderr: "pipe",
47
+ env: process.env,
48
+ });
49
+ const stdout = new Response(child.stdout).text();
50
+ const stderr = new Response(child.stderr).text();
51
+ const code = await child.exited;
52
+ return { code, stdout: await stdout, stderr: await stderr };
53
+ } catch (err) {
54
+ return { code: 127, stdout: "", stderr: err instanceof Error ? err.message : String(err) };
55
+ }
56
+ }
57
+
58
+ /**
59
+ * The project a host-wide outcome report is filed under: the first in-scope
60
+ * project, falling back to the configured first project, then the constant.
61
+ */
62
+ export function reportProject(selectors: readonly (string | undefined)[]): string {
63
+ for (const selector of selectors) {
64
+ if (selector !== undefined) return selector;
65
+ }
66
+ try {
67
+ const projects = loadConfig().projects;
68
+ if (projects.length > 0) return projects[0]!.name;
69
+ } catch {
70
+ // no config: the fallback below stands
71
+ }
72
+ return "conductor";
73
+ }
74
+
75
+ /**
76
+ * Hand a durable outbox row over in the detached context, where no tick is
77
+ * alive to do it. The daemon's report outbox owns delivery from here (bounded
78
+ * retries via the same store rows `omp-conductor report` writes), so a report
79
+ * survives the process that enqueued it. Refusal to queue is logged and lost
80
+ * — the journal on disk is the harder of the two records and is always kept.
81
+ */
82
+ export function enqueueUpgradeReport(
83
+ kind: ReportKind,
84
+ project: string,
85
+ summary: string,
86
+ lines: string[],
87
+ dedupeKey: string,
88
+ ): void {
89
+ try {
90
+ const store = openStore(dbPath());
91
+ try {
92
+ store.enqueueReport({
93
+ project,
94
+ kind,
95
+ body: [summary, "", ...lines].join("\n"),
96
+ at: Date.now(),
97
+ dedupeKey,
98
+ });
99
+ } finally {
100
+ store.close();
101
+ }
102
+ } catch (err) {
103
+ // logged by nothing: the journal carries the same outcome, and `status`
104
+ // surfaces the reports block. A full disk must not loop here.
105
+ process.stderr.write(`upgrade report could not be queued: ${err instanceof Error ? err.message : String(err)}\n`);
106
+ }
107
+ }
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // Classifying the journal
111
+ // ---------------------------------------------------------------------------
112
+
113
+ /** The state a pending request has reached, read off the journal. */
114
+ export type PendingUpgradeState =
115
+ | { kind: "none" }
116
+ | { kind: "closed"; phase: string }
117
+ | { kind: "no-start" }
118
+ | { kind: "awaiting-verification" }
119
+ | { kind: "incomplete" }
120
+ | { kind: "failed" };
121
+
122
+ /** Index of the newest request line, or -1 when none exists. */
123
+ function newestRequestIndex(entries: readonly UpgradeJournalEntry[]): number {
124
+ for (let i = entries.length - 1; i >= 0; i--) {
125
+ if (entries[i]!.kind === "request") return i;
126
+ }
127
+ return -1;
128
+ }
129
+
130
+ /**
131
+ * Classify one journal: no request, a request whose outcome is already
132
+ * terminal, or a request still in flight — with the phase evidence that says
133
+ * how far it got. An outcome is terminal: the verifier writes it last, after
134
+ * the durable report is already queued, so a later tick reads it and does
135
+ * nothing rather than re-reporting.
136
+ *
137
+ * The journal is append-only across upgrades, so the newest request is the one
138
+ * under test: an older request's terminal outcome must not close or shadow a
139
+ * newer request, and only the newest request's progression decides its state.
140
+ */
141
+ export function classifyUpgrade(
142
+ entries: readonly UpgradeJournalEntry[],
143
+ ): PendingUpgradeState {
144
+ const start = newestRequestIndex(entries);
145
+ if (start === -1) return { kind: "none" };
146
+ const progression = entries.slice(start);
147
+ const outcome = [...progression].reverse().find((entry) => entry.kind === "outcome");
148
+ if (outcome !== undefined) {
149
+ return { kind: "closed", phase: outcome.phase ?? "outcome" };
150
+ }
151
+ const phases = progression.filter((entry) => entry.kind === "phase");
152
+ if (phases.length === 0) return { kind: "no-start" };
153
+ if (phases.some((entry) => entry.phase === "awaiting-verification" && entry.ok)) {
154
+ return { kind: "awaiting-verification" };
155
+ }
156
+ if (phases.some((entry) => entry.ok === false)) {
157
+ return { kind: "failed" };
158
+ }
159
+ return { kind: "incomplete" };
160
+ }
161
+
162
+ /** One pending request plus the snapshot its verifier needs. */
163
+ export interface PendingUpgradeRequest {
164
+ version: string;
165
+ gitHead?: string;
166
+ initialPaused?: boolean;
167
+ pauseKey?: string;
168
+ selectors: readonly (string | undefined)[];
169
+ initial?: UpgradeJournalEntry["initial"];
170
+ configBackup?: string;
171
+ }
172
+
173
+ /** The newest request plus everything journaled after it: the view one
174
+ * transaction owns. Older closed upgrades stay in the journal but do not
175
+ * contribute identity or phases to the one in flight. */
176
+ export function requestProgression(
177
+ entries: readonly UpgradeJournalEntry[],
178
+ ): readonly UpgradeJournalEntry[] | undefined {
179
+ const start = newestRequestIndex(entries);
180
+ if (start === -1) return undefined;
181
+ return entries.slice(start);
182
+ }
183
+
184
+ export function pendingUpgradeRequest(
185
+ entries: readonly UpgradeJournalEntry[],
186
+ ): PendingUpgradeRequest | undefined {
187
+ const progression = requestProgression(entries);
188
+ if (progression === undefined) return undefined;
189
+ const request = progression[0]!;
190
+ if (request.ok === false) return undefined;
191
+ const snapshot = progression.find((entry) => entry.kind === "snapshot");
192
+ return {
193
+ version: request.version ?? "unknown",
194
+ gitHead: request.gitHead,
195
+ initialPaused: snapshot?.initialPaused,
196
+ pauseKey: snapshot?.pauseKey,
197
+ selectors: (snapshot?.selectors ?? []).map((selector) => selector ?? undefined),
198
+ initial: snapshot?.initial,
199
+ configBackup: snapshot?.configBackup,
200
+ };
201
+ }
202
+
203
+ // ---------------------------------------------------------------------------
204
+ // The post-restart verifier
205
+ // ---------------------------------------------------------------------------
206
+
207
+ /**
208
+ * Every injectable process/state seam the post-restart verifier touches.
209
+ *
210
+ * The verifier runs in the daemon, so everything that belongs to the daemon's
211
+ * live state (the pause sentinel, the layers, the outbox, the health probe)
212
+ * is injected rather than imported — the daemon wires its own functions, and
213
+ * a test drives the whole pass with fakes.
214
+ */
215
+ export interface UpgradeVerifyDeps {
216
+ projectName: string;
217
+ /** The journal directory; defaults to the conductor state dir. */
218
+ journalRoot?: string;
219
+ /** Sink for journal lines; defaults to the state-dir journal. */
220
+ journal?: (entry: UpgradeJournalEntry) => void;
221
+ run: (command: string, args: readonly string[]) => Promise<UpgradeCommandResult>;
222
+ layers(project?: string): FleetLayers;
223
+ health(port: number): Promise<{ ok: boolean; body?: string }>;
224
+ doctor(projectName: string): Promise<DoctorReport>;
225
+ /** The durable outbox enqueue — one row, daemon-owned delivery. */
226
+ enqueue(draft: ReportDraft): void;
227
+ /** Read one pause sentinel (who set it, when) — daemon-owned state. */
228
+ pause(project?: string): { source: string; reason?: string; since: number } | undefined;
229
+ /** Lift the pause sentinel for a project, clearing the legacy global too. */
230
+ resume(project?: string): void;
231
+ /** Start the detached rollback unit for a version. */
232
+ launchRollback(
233
+ version: string,
234
+ ): Promise<{ ok: true; unit: string } | { ok: false; stderr: string }>;
235
+ log(message: string): void;
236
+ now?(): number;
237
+ }
238
+
239
+ /** What one verify pass did with the pending request. */
240
+ export interface UpgradeVerifyResult {
241
+ handled:
242
+ | "none"
243
+ | "already-closed"
244
+ | "verified"
245
+ | "aborted"
246
+ | "rollback-requested"
247
+ | "rollback-unavailable";
248
+ version?: string;
249
+ detail?: string;
250
+ }
251
+
252
+ /**
253
+ * The first-tick verification for a fleet-initiated install (#486).
254
+ *
255
+ * The installing process journals progress and stops; whichever process comes
256
+ * back — the restarted daemon's first tick — reads that journal and confirms
257
+ * independently, then closes the transaction on the evidence: the installed
258
+ * version matches the request, `/healthz` answers, ticks and pane match the
259
+ * layers the install began under, and `doctor` has no failing finding.
260
+ *
261
+ * Every action is ordered for a crash: the durable outbox row goes in before
262
+ * the terminal journal outcome, and the outcome's dedupe key makes the
263
+ * at-least-once re-enqueue a no-op — so a tick that died mid-close re-runs
264
+ * the whole pass on the next tick without doubling the report or the rollback
265
+ * unit. The rollback unit is spawned at most once: a `rollback` phase marker
266
+ * journaled with its outcome is the crash guard, and only a marker that
267
+ * records a failed spawn (nothing was started) may retry.
268
+ */
269
+ export async function verifyPendingUpgrade(deps: UpgradeVerifyDeps): Promise<UpgradeVerifyResult> {
270
+ const root = deps.journalRoot ?? stateDir();
271
+ const now = deps.now ?? Date.now;
272
+ const entries = readUpgradeJournal(root);
273
+ const progression = requestProgression(entries);
274
+ if (progression === undefined) return { handled: "none" };
275
+ const request = pendingUpgradeRequest(entries);
276
+ if (request === undefined) return { handled: "none" };
277
+ const state = classifyUpgrade(entries);
278
+
279
+ const journal = (entry: Omit<UpgradeJournalEntry, "at">): void => {
280
+ const write = deps.journal ?? ((line: UpgradeJournalEntry) => appendJournal(root, line));
281
+ write({ ...entry, version: entry.version ?? request.version, at: new Date(now()).toISOString() });
282
+ };
283
+
284
+ // ---------------------------------------------------------------- terminal
285
+ if (state.kind === "closed") {
286
+ // The transaction is already terminal (verified, rolled back, aborted,
287
+ // rollback requested). One thing may still be owed: the install paused
288
+ // dispatch, and a crash took the process that should have resumed it. The
289
+ // resume is safe to redo — it only acts while the sentinel is still the
290
+ // install's own — so it runs on every visit to a closed request.
291
+ resumeUpgradePause(deps, request);
292
+ return { handled: "already-closed", version: request.version, detail: state.phase };
293
+ }
294
+
295
+ // ------------------------------------------------------------ never started
296
+ if (state.kind === "no-start") {
297
+ // The unit died before journaling a single phase. Nothing was installed;
298
+ // the sentinel can still exist if it crashed between pausing and writing
299
+ // the pause line — clear it, close the request, and page tier 2 that the
300
+ // fleet could not install its own fix.
301
+ resumeUpgradePause(deps, request);
302
+ deps.enqueue({
303
+ project: deps.projectName,
304
+ kind: "tier2",
305
+ body: [
306
+ `Fleet upgrade to omp-conductor@${request.version} never started (journal: ${upgradeJournalPath(root)}).`,
307
+ "The detached install unit died before touching any surface; dispatch was not paused.",
308
+ `Re-run the request, or install by hand with \`omp-conductor upgrade --to ${request.version}\`.`,
309
+ ].join("\n"),
310
+ at: now(),
311
+ dedupeKey: `upgrade:${request.version}:aborted`,
312
+ });
313
+ journal({ kind: "outcome", phase: "aborted", ok: false, detail: "requested upgrade never started a phase" });
314
+ return { handled: "aborted", version: request.version, detail: "no phase was ever journaled" };
315
+ }
316
+
317
+ // ----------------------------------------------- incomplete or failed mid-run
318
+ if (state.kind === "incomplete" || state.kind === "failed") {
319
+ // The installer died mid-transaction or journaled a failure it could not
320
+ // roll back itself. Surfaces may be mixed; the fixed answer is the
321
+ // snapshot rollback, detached, plus a tier-2 page naming the journal.
322
+ deps.log(
323
+ `upgrade to ${request.version} left an ${state.kind} journal — triggering the detached rollback`,
324
+ );
325
+ return triggerUpgradeRollback(deps, root, journal, request, progression, now(), "mid-flight failure");
326
+ }
327
+
328
+ // ------------------------------------------------------- awaiting verification
329
+ const checks: UpgradeCheck[] = [];
330
+ await runUpgradeChecks(deps, request, checks);
331
+ const failed = checks.find((check) => !check.ok);
332
+ if (failed !== undefined) {
333
+ return triggerUpgradeRollback(
334
+ deps,
335
+ root,
336
+ journal,
337
+ request,
338
+ progression,
339
+ now(),
340
+ `verification failed on ${failed.name} (${failed.detail ?? "no detail"})`,
341
+ );
342
+ }
343
+
344
+ // Good. Resume dispatch only when the install paused it, and only while the
345
+ // sentinel is still the install's own — an operator's later hold is never
346
+ // lifted by an upgrade closing.
347
+ if (request.initialPaused === false) {
348
+ const owned = deps.pause(request.pauseKey);
349
+ if (owned !== undefined && owned.source === "upgrade") {
350
+ deps.resume(request.pauseKey);
351
+ deps.log(`upgrade verified — dispatch resumed (${request.pauseKey ?? "global"})`);
352
+ }
353
+ }
354
+ deps.enqueue({
355
+ project: deps.projectName,
356
+ kind: "material",
357
+ body: [
358
+ `omp-conductor@${request.version} is installed and verified on the first tick after the restart.`,
359
+ "",
360
+ ...checks.map((check) => `${check.ok ? "ok" : "FAIL"} ${check.name}${check.detail === undefined ? "" : ` — ${check.detail}`}`),
361
+ "",
362
+ "Dispatch restored to its prior state.",
363
+ ].join("\n"),
364
+ at: now(),
365
+ dedupeKey: `upgrade:${request.version}:verified`,
366
+ });
367
+ journal({ kind: "outcome", phase: "verified", ok: true, checks });
368
+ return { handled: "verified", version: request.version };
369
+ }
370
+
371
+ /**
372
+ * Lift the install's own pause sentinel, and only that one. `pauseKey` is the
373
+ * sentinel the engine paused; a fixed `initialPaused` true means the fleet was
374
+ * already held and the install never paused it, so nothing is lifted. A pause
375
+ * that an operator re-created (source changed) is left standing.
376
+ */
377
+ function resumeUpgradePause(deps: UpgradeVerifyDeps, request: PendingUpgradeRequest): void {
378
+ if (request.initialPaused === true) return;
379
+ const owned = deps.pause(request.pauseKey);
380
+ if (owned !== undefined && owned.source === "upgrade") {
381
+ deps.resume(request.pauseKey);
382
+ }
383
+ }
384
+
385
+ /**
386
+ * The independent checks the returning process runs. Each compares a live
387
+ * fact with the journal's record of the world the install began in:
388
+ *
389
+ * - `version` — the installed CLI (all three surfaces are pinned to one
390
+ * release by the engine, so the CLI read stands for the set);
391
+ * - `ticks` / `pane` / `herdr` — delta against the pre-install layers, the
392
+ * same predicate the in-process upgrade used, so a fleet that was armed
393
+ * stays armed and a live pane stays live;
394
+ * - `health` — the daemon that came back answers `/healthz`;
395
+ * - `doctor` — no failing finding.
396
+ */
397
+ export async function runUpgradeChecks(
398
+ deps: UpgradeVerifyDeps,
399
+ request: PendingUpgradeRequest,
400
+ checks: UpgradeCheck[],
401
+ ): Promise<void> {
402
+ const version = await deps.run("omp-conductor", ["--version"]);
403
+ checks.push({
404
+ name: "version",
405
+ ok: version.code === 0 && version.stdout.trim() === request.version,
406
+ detail: version.code === 0 ? version.stdout.trim() : version.stderr.trim() || `exit ${version.code}`,
407
+ });
408
+
409
+ const layers = deps.layers(deps.projectName);
410
+ const before = request.initial;
411
+ if (before !== undefined) {
412
+ checks.push({ name: "ticks", ok: layers.ticks === before.ticks, detail: `${before.ticks} → ${layers.ticks}` });
413
+ checks.push({ name: "pane", ok: layers.pane === before.pane, detail: `${before.pane} → ${layers.pane}` });
414
+ if (before.herdr === "active") {
415
+ checks.push({ name: "herdr", ok: layers.herdr === "active", detail: layers.herdr });
416
+ }
417
+ }
418
+ checks.push({
419
+ name: "health",
420
+ ok: layers.daemon.running && (await liveHealth(deps, layers.daemon.port ?? DEFAULT_PORT)).ok,
421
+ detail: layers.daemon.running ? undefined : "no daemon record",
422
+ });
423
+
424
+ const doctor = await deps.doctor(deps.projectName);
425
+ const failing = doctor.findings.filter((finding) => finding.status === "fail");
426
+ checks.push({
427
+ name: "doctor",
428
+ ok: failing.length === 0,
429
+ detail: failing.length === 0 ? `status ${doctor.status}` : `${failing[0]!.id}: ${failing[0]!.summary}`,
430
+ });
431
+ }
432
+
433
+ async function liveHealth(
434
+ deps: UpgradeVerifyDeps,
435
+ port: number,
436
+ ): Promise<{ ok: boolean; body?: string }> {
437
+ try {
438
+ return await deps.health(port);
439
+ } catch (err) {
440
+ return { ok: false, body: err instanceof Error ? err.message : String(err) };
441
+ }
442
+ }
443
+
444
+ /** Spawn the detached rollback once and page the evidence, whatever the cause. */
445
+ async function triggerUpgradeRollback(
446
+ deps: UpgradeVerifyDeps,
447
+ root: string,
448
+ journal: (entry: Omit<UpgradeJournalEntry, "at">) => void,
449
+ request: PendingUpgradeRequest,
450
+ progress: readonly UpgradeJournalEntry[],
451
+ at: number,
452
+ why: string,
453
+ ): Promise<UpgradeVerifyResult> {
454
+ // The marker is the spawn guard: present-and-ok means a rollback unit is
455
+ // already on its way and a crash after the spawn must not spawn a second
456
+ // one. Present-and-failed means nothing started, so a retry is a first
457
+ // attempt, not a double. Scoped to this request's progression — an older
458
+ // upgrade's rollback marker must not suppress a new one.
459
+ const marker = progress.find((entry) => entry.kind === "phase" && entry.phase === "rollback");
460
+ let spawned: { ok: true; unit: string } | { ok: false; stderr: string };
461
+ if (marker?.ok === true) {
462
+ spawned = { ok: true, unit: marker.unit ?? "omp-conductor-upgrade-rollback" };
463
+ } else {
464
+ spawned = await deps.launchRollback(request.version);
465
+ journal({
466
+ kind: "phase",
467
+ phase: "rollback",
468
+ ok: spawned.ok,
469
+ ...(spawned.ok ? { unit: spawned.unit } : { detail: spawned.stderr }),
470
+ });
471
+ }
472
+ if (!spawned.ok) {
473
+ // Nothing was spawned, so the next tick may legitimately retry a first
474
+ // attempt. Page with the journal path — the fleet may be half-upgraded
475
+ // and must not be quietly mixed.
476
+ journal({ kind: "outcome", phase: "rollback-unavailable", ok: false, detail: spawned.stderr });
477
+ deps.enqueue({
478
+ project: deps.projectName,
479
+ kind: "tier2",
480
+ body: [
481
+ `Fleet upgrade to omp-conductor@${request.version} is INCOMPLETE (${why}) and its detached rollback could not start.`,
482
+ `Reason: ${spawned.stderr}`,
483
+ "",
484
+ "Surfaces may be at mixed versions; do not resume dispatch.",
485
+ `Restore manually from ${upgradeJournalPath(root)}.`,
486
+ ].join("\n"),
487
+ at,
488
+ dedupeKey: `upgrade:${request.version}:rollback-unavailable`,
489
+ });
490
+ return { handled: "rollback-unavailable", version: request.version, detail: spawned.stderr };
491
+ }
492
+ journal({ kind: "outcome", phase: "rollback-requested", ok: false, detail: why });
493
+ deps.enqueue({
494
+ project: deps.projectName,
495
+ kind: "tier2",
496
+ body: [
497
+ `Fleet upgrade to omp-conductor@${request.version} did not verify (${why}) and is being rolled back by ${spawned.unit}.`,
498
+ "Dispatch stays paused until the rollback lands and a later tick verifies the old version.",
499
+ "",
500
+ `Journal: ${upgradeJournalPath(root)}`,
501
+ ].join("\n"),
502
+ at,
503
+ dedupeKey: `upgrade:${request.version}:rollback-requested`,
504
+ });
505
+ return { handled: "rollback-requested", version: request.version, detail: `${why}; rollback unit ${spawned.unit}` };
506
+ }