omp-conductor 0.19.4 → 0.19.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -25,7 +25,7 @@ import { readFileSync } from "node:fs";
25
25
  import { loadConfig, stateDir } from "./config.ts";
26
26
  import { DEFAULT_PORT } from "./lifecycle.ts";
27
27
  import { dbPath, openStore } from "./store.ts";
28
- import { appendJournal, readUpgradeJournal, upgradeJournalPath, type UpgradeCheck, type UpgradeJournalEntry } from "./upgrade-journal.ts";
28
+ import { appendJournal, readUpgradeJournal, upgradeJournalPath, type JournalSurfaces, type UpgradeCheck, type UpgradeJournalEntry } from "./upgrade-journal.ts";
29
29
  import type { DoctorReport } from "./doctor.ts";
30
30
  import type { FleetLayers } from "./status-render.ts";
31
31
  import type { ReportDraft, ReportKind } from "./types.ts";
@@ -135,6 +135,17 @@ function newestRequestIndex(entries: readonly UpgradeJournalEntry[]): number {
135
135
  return -1;
136
136
  }
137
137
 
138
+ /** Newest terminal outcome in one request's progression, without copying it. */
139
+ function newestOutcome(
140
+ progression: readonly UpgradeJournalEntry[],
141
+ ): UpgradeJournalEntry | undefined {
142
+ for (let i = progression.length - 1; i >= 0; i--) {
143
+ const entry = progression[i]!;
144
+ if (entry.kind === "outcome") return entry;
145
+ }
146
+ return undefined;
147
+ }
148
+
138
149
  /**
139
150
  * Classify one journal: no request, a request whose outcome is already
140
151
  * terminal, or a request still in flight — with the phase evidence that says
@@ -152,7 +163,7 @@ export function classifyUpgrade(
152
163
  const start = newestRequestIndex(entries);
153
164
  if (start === -1) return { kind: "none" };
154
165
  const progression = entries.slice(start);
155
- const outcome = [...progression].reverse().find((entry) => entry.kind === "outcome");
166
+ const outcome = newestOutcome(progression);
156
167
  if (outcome !== undefined) {
157
168
  return { kind: "closed", phase: outcome.phase ?? "outcome" };
158
169
  }
@@ -167,15 +178,36 @@ export function classifyUpgrade(
167
178
  return { kind: "incomplete" };
168
179
  }
169
180
 
181
+ /** Terminal outcomes that prove no broken or mixed install remains active. */
182
+ const RESUMABLE_OUTCOMES = new Set([
183
+ "already-current",
184
+ "recovered",
185
+ "recovery-acknowledged",
186
+ "verified",
187
+ ]);
188
+ /** Outcomes that require a durable host-wide safety fence. */
189
+ const RECOVERY_HOLD_OUTCOMES = new Set([
190
+ "rollback-failed",
191
+ "rollback-requested",
192
+ "rollback-unavailable",
193
+ "rolled-back",
194
+ ]);
195
+ /** Closed rollback outcomes whose installed surfaces must be re-verified. */
196
+ const RECOVERY_CHECK_OUTCOMES = new Set(["rollback-failed", "rolled-back"]);
197
+
170
198
  /** One pending request plus the snapshot its verifier needs. */
171
199
  export interface PendingUpgradeRequest {
172
200
  version: string;
173
201
  gitHead?: string;
202
+ pauseSince?: number;
174
203
  initialPaused?: boolean;
175
204
  pauseKey?: string;
176
205
  selectors: readonly (string | undefined)[];
206
+ installHolder?: UpgradeJournalEntry["installHolder"];
207
+ installProjects?: readonly string[];
177
208
  initial?: UpgradeJournalEntry["initial"];
178
209
  configBackup?: string;
210
+ previous?: JournalSurfaces;
179
211
  /**
180
212
  * Epoch-ms when the install began (the journal snapshot's write time): the
181
213
  * deadline the live orchestrator session must have restarted after (#832).
@@ -202,19 +234,104 @@ export function pendingUpgradeRequest(
202
234
  const request = progression[0]!;
203
235
  if (request.ok === false) return undefined;
204
236
  const snapshot = progression.find((entry) => entry.kind === "snapshot");
237
+ const pausePhase = progression.find(
238
+ (entry) => entry.kind === "phase" && entry.phase === "paused",
239
+ );
205
240
  const snapshotAt = snapshot === undefined ? undefined : Date.parse(snapshot.at);
206
241
  return {
207
242
  version: request.version ?? "unknown",
208
243
  gitHead: request.gitHead,
209
244
  initialPaused: snapshot?.initialPaused,
210
245
  pauseKey: snapshot?.pauseKey,
211
- selectors: (snapshot?.selectors ?? []).map((selector) => selector ?? undefined),
246
+ selectors: (snapshot?.selectors ?? request.selectors ?? []).map((selector) => selector ?? undefined),
247
+ pauseSince: pausePhase?.pauseSince,
212
248
  initial: snapshot?.initial,
213
249
  configBackup: snapshot?.configBackup,
250
+ previous: snapshot?.previous,
251
+ installHolder: request.installHolder,
252
+ installProjects: request.installProjects,
214
253
  ...(snapshotAt !== undefined && Number.isFinite(snapshotAt) ? { reloadAfterMs: snapshotAt } : {}),
215
254
  };
216
255
  }
217
256
 
257
+ function reportProjects(request: PendingUpgradeRequest): readonly (string | undefined)[] {
258
+ return request.installProjects ?? request.selectors;
259
+ }
260
+
261
+ function installAttribution(request: PendingUpgradeRequest): string {
262
+ const projects =
263
+ reportProjects(request).length === 0
264
+ ? "all configured projects"
265
+ : reportProjects(request).map((selector) => selector ?? "(default)").join(", ");
266
+ return `Host-global install holder: ${request.installHolder ?? "unknown"}; affected projects: ${projects}.`;
267
+ }
268
+
269
+ export interface UpgradeRecoveryStatus {
270
+ failedVersion: string;
271
+ phase: string;
272
+ journalPath: string;
273
+ verificationOwed: string;
274
+ }
275
+
276
+ /**
277
+ * The unresolved safety hold represented by the newest journal request.
278
+ * Status reads this same lifecycle verdict as the verifier; it never guesses
279
+ * from the pause reason alone.
280
+ */
281
+ export function upgradeRecoveryStatus(
282
+ entries: readonly UpgradeJournalEntry[],
283
+ root = stateDir(),
284
+ ): UpgradeRecoveryStatus | undefined {
285
+ const request = pendingUpgradeRequest(entries);
286
+ const state = classifyUpgrade(entries);
287
+ if (
288
+ request === undefined ||
289
+ state.kind !== "closed" ||
290
+ !RECOVERY_HOLD_OUTCOMES.has(state.phase)
291
+ ) {
292
+ return undefined;
293
+ }
294
+ const expected = request.previous;
295
+ const surfaces =
296
+ expected === undefined
297
+ ? "the pre-upgrade surface snapshot is missing"
298
+ : [
299
+ `CLI=${expected.cliVersion}`,
300
+ `omp=${expected.ompVersion ?? "absent"}`,
301
+ `herdr=${expected.herdrSource ?? "absent"}`,
302
+ ].join(", ");
303
+ const verificationOwed =
304
+ state.phase === "rollback-requested"
305
+ ? `detached rollback completion, then installed-surface verification against ${surfaces}`
306
+ : `installed-surface verification against ${surfaces}`;
307
+ return {
308
+ failedVersion: request.version,
309
+ phase: state.phase,
310
+ journalPath: upgradeJournalPath(root),
311
+ verificationOwed,
312
+ };
313
+ }
314
+
315
+ /**
316
+ * Record the operator's explicit choice to release an unresolved upgrade
317
+ * recovery fence. `resume` calls this before removing an upgrade-owned pause,
318
+ * so the append-only journal distinguishes acknowledgement from verification.
319
+ */
320
+ export function acknowledgeUpgradeRecovery(root = stateDir()): boolean {
321
+ const entries = readUpgradeJournal(root);
322
+ const recovery = upgradeRecoveryStatus(entries, root);
323
+ if (recovery === undefined) return false;
324
+ appendJournal(root, {
325
+ at: new Date().toISOString(),
326
+ kind: "outcome",
327
+ phase: "recovery-acknowledged",
328
+ ok: true,
329
+ version: recovery.failedVersion,
330
+ detail: `operator resumed dispatch with ${recovery.verificationOwed} still owed`,
331
+ });
332
+ return true;
333
+ }
334
+
218
335
  // ---------------------------------------------------------------------------
219
336
  // The live-orchestrator session attestation (#832)
220
337
  // ---------------------------------------------------------------------------
@@ -387,12 +504,18 @@ export interface UpgradeVerifyDeps {
387
504
  layers(project?: string): FleetLayers;
388
505
  health(port: number): Promise<{ ok: boolean; body?: string }>;
389
506
  doctor(projectName: string): Promise<DoctorReport>;
507
+ /** Read all three package/plugin identities from the live host. */
508
+ surfaces(): Promise<JournalSurfaces>;
390
509
  /** The durable outbox enqueue — one row, daemon-owned delivery. */
391
510
  enqueue(draft: ReportDraft): void;
392
511
  /** Read one pause sentinel (who set it, when) — daemon-owned state. */
393
512
  pause(project?: string): { source: string; reason?: string; since: number } | undefined;
394
513
  /** Lift the pause sentinel for a project, clearing the legacy global too. */
395
514
  resume(project?: string): void;
515
+ /** Escalate a project pause to the host-global safety fence. */
516
+ holdGlobal(): void;
517
+ /** Remove only the exact dedicated recovery fence observed by this pass. */
518
+ releaseGlobalRecovery(expectedSince: number | undefined): void;
396
519
  /** Start the detached rollback unit for a version. */
397
520
  launchRollback(
398
521
  version: string,
@@ -419,6 +542,8 @@ export interface UpgradeVerifyResult {
419
542
  | "none"
420
543
  | "already-closed"
421
544
  | "verified"
545
+ | "recovered"
546
+ | "recovery-owed"
422
547
  | "aborted"
423
548
  | "rollback-requested"
424
549
  | "rollback-unavailable";
@@ -453,41 +578,55 @@ export async function verifyPendingUpgrade(deps: UpgradeVerifyDeps): Promise<Upg
453
578
  if (request === undefined) return { handled: "none" };
454
579
  const state = classifyUpgrade(entries);
455
580
 
456
- const journal = (entry: Omit<UpgradeJournalEntry, "at">): void => {
581
+ const journal = (entry: Omit<UpgradeJournalEntry, "at">, at = now()): void => {
457
582
  const write = deps.journal ?? ((line: UpgradeJournalEntry) => appendJournal(root, line));
458
- write({ ...entry, version: entry.version ?? request.version, at: new Date(now()).toISOString() });
583
+ write({ ...entry, version: entry.version ?? request.version, at: new Date(at).toISOString() });
459
584
  };
460
585
 
461
586
  // ---------------------------------------------------------------- terminal
462
587
  if (state.kind === "closed") {
463
- // The transaction is already terminal (verified, rolled back, aborted,
464
- // rollback requested). One thing may still be owed: the install paused
465
- // dispatch, and a crash took the process that should have resumed it. The
466
- // resume is safe to redo it only acts while the sentinel is still the
467
- // install's own — so it runs on every visit to a closed request.
468
- resumeUpgradePause(deps, request);
588
+ // A closed failure is still a safety hold: rollback-requested means the
589
+ // rollback unit has not landed yet, while rollback-unavailable and
590
+ // rollback-failed mean the host may contain mixed surfaces. Only outcomes
591
+ // that prove the transaction safe may release its pause.
592
+ const outcome = newestOutcome(progression);
593
+ const parsedOutcomeAt = outcome === undefined ? Number.NaN : Date.parse(outcome.at);
594
+ const outcomeAt = Number.isFinite(parsedOutcomeAt) ? parsedOutcomeAt : undefined;
595
+ if (RECOVERY_CHECK_OUTCOMES.has(state.phase)) {
596
+ return verifyRecoveredUpgrade(deps, request, state.phase, outcomeAt, journal, now);
597
+ }
598
+ if (RECOVERY_HOLD_OUTCOMES.has(state.phase)) deps.holdGlobal();
599
+ if (RESUMABLE_OUTCOMES.has(state.phase) && outcomeAt !== undefined) {
600
+ resumeUpgradePause(deps, request, outcomeAt);
601
+ }
469
602
  return { handled: "already-closed", version: request.version, detail: state.phase };
470
603
  }
471
604
 
472
605
  // ------------------------------------------------------------ never started
473
606
  if (state.kind === "no-start") {
474
- // The unit died before journaling a single phase. Nothing was installed;
475
- // the sentinel can still exist if it crashed between pausing and writing
476
- // the pause line clear it, close the request, and page tier 2 that the
477
- // fleet could not install its own fix.
478
- resumeUpgradePause(deps, request);
607
+ // The unit died before journaling a single phase. Nothing was installed,
608
+ // but no phase recorded the exact pause instance either. Close the request
609
+ // without guessing that an upgrade-owned sentinel still belongs to it.
610
+ const abortedAt = now();
479
611
  deps.enqueue({
480
- project: deps.projectName,
612
+ project: reportProject(reportProjects(request)),
481
613
  kind: "tier2",
482
614
  body: [
483
615
  `Fleet upgrade to omp-conductor@${request.version} never started (journal: ${upgradeJournalPath(root)}).`,
484
- "The detached install unit died before touching any surface; dispatch was not paused.",
616
+ installAttribution(request),
617
+ "The detached install unit died before touching any surface.",
618
+ "Dispatch may still hold an unidentifiable install pause; check `status` and clear it explicitly with `omp-conductor resume`.",
485
619
  `Re-run the request, or install by hand with \`omp-conductor upgrade --to ${request.version}\`.`,
486
620
  ].join("\n"),
487
- at: now(),
621
+ at: abortedAt,
488
622
  dedupeKey: `upgrade:${request.version}:aborted`,
489
623
  });
490
- journal({ kind: "outcome", phase: "aborted", ok: false, detail: "requested upgrade never started a phase" });
624
+ journal({
625
+ kind: "outcome",
626
+ phase: "aborted",
627
+ ok: false,
628
+ detail: "requested upgrade never started a phase",
629
+ }, abortedAt);
491
630
  return { handled: "aborted", version: request.version, detail: "no phase was ever journaled" };
492
631
  }
493
632
 
@@ -518,43 +657,133 @@ export async function verifyPendingUpgrade(deps: UpgradeVerifyDeps): Promise<Upg
518
657
  );
519
658
  }
520
659
 
521
- // Good. Resume dispatch only when the install paused it, and only while the
522
- // sentinel is still the install's own an operator's later hold is never
523
- // lifted by an upgrade closing.
524
- if (request.initialPaused === false) {
525
- const owned = deps.pause(request.pauseKey);
526
- if (owned !== undefined && owned.source === "upgrade") {
527
- deps.resume(request.pauseKey);
528
- deps.log(`upgrade verified — dispatch resumed (${request.pauseKey ?? "global"})`);
529
- }
530
- }
660
+ // Good. Persist the evidence before releasing anything: if this process dies
661
+ // after the outcome, the next tick can finish the idempotent resume.
662
+ const verifiedAt = now();
531
663
  deps.enqueue({
532
- project: deps.projectName,
664
+ project: reportProject(reportProjects(request)),
533
665
  kind: "material",
534
666
  body: [
535
667
  `omp-conductor@${request.version} is installed and verified on the first tick after the restart.`,
668
+ installAttribution(request),
536
669
  "",
537
670
  ...checks.map((check) => `${check.ok ? "ok" : "FAIL"} ${check.name}${check.detail === undefined ? "" : ` — ${check.detail}`}`),
538
671
  "",
539
672
  "Dispatch restored to its prior state.",
540
673
  ].join("\n"),
541
- at: now(),
674
+ at: verifiedAt,
542
675
  dedupeKey: `upgrade:${request.version}:verified`,
543
676
  });
544
- journal({ kind: "outcome", phase: "verified", ok: true, checks });
677
+ journal({ kind: "outcome", phase: "verified", ok: true, checks }, verifiedAt);
678
+ const paused = deps.pause(request.pauseKey);
679
+ resumeUpgradePause(deps, request, verifiedAt);
680
+ if (paused !== undefined && deps.pause(request.pauseKey) === undefined) {
681
+ deps.log(`upgrade verified — dispatch resumed (${request.pauseKey ?? "global"})`);
682
+ }
545
683
  return { handled: "verified", version: request.version };
546
684
  }
547
685
 
686
+ async function verifyRecoveredUpgrade(
687
+ deps: UpgradeVerifyDeps,
688
+ request: PendingUpgradeRequest,
689
+ failedPhase: string,
690
+ failedOutcomeAt: number | undefined,
691
+ journal: (entry: Omit<UpgradeJournalEntry, "at">, at?: number) => void,
692
+ now: () => number,
693
+ ): Promise<UpgradeVerifyResult> {
694
+ const globalPause = deps.pause();
695
+ const globalRecoverySince =
696
+ globalPause?.source === "upgrade-recovery" ? globalPause.since : undefined;
697
+ const checks: UpgradeCheck[] = [];
698
+ const expected = request.previous;
699
+ if (expected === undefined) {
700
+ checks.push({ name: "installed-surfaces", ok: false, detail: "pre-upgrade snapshot is missing" });
701
+ } else {
702
+ try {
703
+ const actual = await deps.surfaces();
704
+ for (const [name, wanted, found] of [
705
+ ["cli", expected.cliVersion, actual.cliVersion],
706
+ ["omp", expected.ompVersion, actual.ompVersion],
707
+ ["herdr", expected.herdrSource, actual.herdrSource],
708
+ ] as const) {
709
+ checks.push({
710
+ name: `surface:${name}`,
711
+ ok: wanted === found,
712
+ detail: `expected ${wanted ?? "absent"}; found ${found ?? "absent"}`,
713
+ });
714
+ }
715
+ } catch (err) {
716
+ checks.push({
717
+ name: "installed-surfaces",
718
+ ok: false,
719
+ detail: err instanceof Error ? err.message : String(err),
720
+ });
721
+ }
722
+ }
723
+ const failed = checks.find((check) => !check.ok);
724
+ if (failed !== undefined) {
725
+ // A recovery verdict owns the host-wide gate. Re-establish it on every
726
+ // owed pass so a legacy failed journal or a lost sentinel cannot admit.
727
+ deps.holdGlobal();
728
+ return {
729
+ handled: "recovery-owed",
730
+ version: request.version,
731
+ detail: `${failedPhase}; ${failed.name}: ${failed.detail ?? "failed"}`,
732
+ };
733
+ }
734
+
735
+ const recoveredAt = now();
736
+ deps.enqueue({
737
+ project: reportProject(reportProjects(request)),
738
+ kind: "material",
739
+ body: [
740
+ `The failed upgrade to omp-conductor@${request.version} is recovered.`,
741
+ installAttribution(request),
742
+ "",
743
+ ...checks.map((check) => `ok ${check.name} — ${check.detail ?? "matched"}`),
744
+ "",
745
+ "Every installed surface matches the durable pre-upgrade snapshot; dispatch restored to its prior state.",
746
+ ].join("\n"),
747
+ at: recoveredAt,
748
+ dedupeKey: `upgrade:${request.version}:recovered`,
749
+ });
750
+ journal({
751
+ kind: "outcome",
752
+ phase: "recovered",
753
+ ok: true,
754
+ detail: `verified recovery after ${failedPhase}`,
755
+ checks,
756
+ }, recoveredAt);
757
+ resumeUpgradePause(deps, request, failedOutcomeAt);
758
+ deps.releaseGlobalRecovery(globalRecoverySince);
759
+ return { handled: "recovered", version: request.version, detail: failedPhase };
760
+ }
761
+
548
762
  /**
549
763
  * Lift the install's own pause sentinel, and only that one. `pauseKey` is the
550
764
  * sentinel the engine paused; a fixed `initialPaused` true means the fleet was
551
765
  * already held and the install never paused it, so nothing is lifted. A pause
552
766
  * that an operator re-created (source changed) is left standing.
767
+ *
768
+ * New journals carry the exact pause creation instant, so identical
769
+ * provenance can never make a newer transaction look owned. `notAfter` is the
770
+ * compatibility bound for older journals that predate that identity field:
771
+ * only a pause that existed before the safe terminal outcome may be released.
553
772
  */
554
- function resumeUpgradePause(deps: UpgradeVerifyDeps, request: PendingUpgradeRequest): void {
773
+ function resumeUpgradePause(
774
+ deps: UpgradeVerifyDeps,
775
+ request: PendingUpgradeRequest,
776
+ notAfter?: number,
777
+ ): void {
555
778
  if (request.initialPaused === true) return;
556
779
  const owned = deps.pause(request.pauseKey);
557
- if (owned !== undefined && owned.source === "upgrade") {
780
+ const sameTransaction =
781
+ request.pauseSince !== undefined
782
+ ? owned !== undefined &&
783
+ owned.since >= request.pauseSince &&
784
+ (notAfter === undefined || owned.since <= notAfter)
785
+ : notAfter !== undefined && owned !== undefined && owned.since <= notAfter;
786
+ if (owned?.source === "upgrade" && sameTransaction) {
558
787
  deps.resume(request.pauseKey);
559
788
  }
560
789
  }
@@ -687,6 +916,10 @@ async function triggerUpgradeRollback(
687
916
  at: number,
688
917
  why: string,
689
918
  ): Promise<UpgradeVerifyResult> {
919
+ // Every install surface is host-wide even when the request named one
920
+ // project. Once recovery is required, the safety fence must gate every
921
+ // project served by the shared daemon.
922
+ deps.holdGlobal();
690
923
  // The marker is the spawn guard: present-and-ok means a rollback unit is
691
924
  // already on its way and a crash after the spawn must not spawn a second
692
925
  // one. Present-and-failed means nothing started, so a retry is a first
@@ -711,10 +944,11 @@ async function triggerUpgradeRollback(
711
944
  // and must not be quietly mixed.
712
945
  journal({ kind: "outcome", phase: "rollback-unavailable", ok: false, detail: spawned.stderr });
713
946
  deps.enqueue({
714
- project: deps.projectName,
947
+ project: reportProject(reportProjects(request)),
715
948
  kind: "tier2",
716
949
  body: [
717
950
  `Fleet upgrade to omp-conductor@${request.version} is INCOMPLETE (${why}) and its detached rollback could not start.`,
951
+ installAttribution(request),
718
952
  `Reason: ${spawned.stderr}`,
719
953
  "",
720
954
  "Surfaces may be at mixed versions; do not resume dispatch.",
@@ -727,10 +961,11 @@ async function triggerUpgradeRollback(
727
961
  }
728
962
  journal({ kind: "outcome", phase: "rollback-requested", ok: false, detail: why });
729
963
  deps.enqueue({
730
- project: deps.projectName,
964
+ project: reportProject(reportProjects(request)),
731
965
  kind: "tier2",
732
966
  body: [
733
967
  `Fleet upgrade to omp-conductor@${request.version} did not verify (${why}) and is being rolled back by ${spawned.unit}.`,
968
+ installAttribution(request),
734
969
  "Dispatch stays paused until the rollback lands and a later tick verifies the old version.",
735
970
  "",
736
971
  `Journal: ${upgradeJournalPath(root)}`,