omp-conductor 0.19.4 → 0.19.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omp-conductor",
3
- "version": "0.19.4",
3
+ "version": "0.19.5",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "A 24/7 dispatcher that takes ready GitHub issues to green, mergeable PRs using omp coding sessions, with tiered escalation first to an orchestrator session and then to a human.",
@@ -13,10 +13,15 @@
13
13
 
14
14
  import type { CommandContext } from "./context.ts";
15
15
  import { armState, clearPaneHaltIfResolvable, releaseHold } from "../fleet.ts";
16
+ import { acknowledgeUpgradeRecovery } from "../upgrade-verify.ts";
16
17
  import { wakeDispatch } from "../wake.ts";
17
18
 
18
19
  export async function resumeCommand(ctx: CommandContext): Promise<void> {
19
20
  for (const project of ctx.targetProjects()) {
21
+ // `releaseHold(project)` also removes the host-global sentinel. Record an
22
+ // unresolved recovery acknowledgement first even when a project-local pause
23
+ // shadows that global fence; the helper is a no-op when nothing is owed.
24
+ acknowledgeUpgradeRecovery();
20
25
  releaseHold(project.name);
21
26
  const pin = clearPaneHaltIfResolvable(project.name);
22
27
  const arm = armState(project.name);
package/src/daemon.ts CHANGED
@@ -58,7 +58,7 @@ import {
58
58
  setPaused,
59
59
  } from "./pause.ts";
60
60
  import { appendJournal, launchTransientUnit, readUpgradeJournal } from "./upgrade-journal.ts";
61
- import { runCommand, verifyPendingUpgrade, type UpgradeVerifyDeps } from "./upgrade-verify.ts";
61
+ import { runCommand, upgradeRecoveryStatus, verifyPendingUpgrade, type UpgradeRecoveryStatus, type UpgradeVerifyDeps } from "./upgrade-verify.ts";
62
62
  import { inspectSurfaces, type InstalledSurfaces } from "./upgrade.ts";
63
63
  import { checkTelegramFreshness, type TelegramFreshness } from "./telegram-freshness.ts";
64
64
  import { processStartTimeMs } from "./upgrade-verify.ts";
@@ -5295,6 +5295,7 @@ export function upgradeVerifyDepsFor(d: Pick<Deps, "project" | "store">): Upgrad
5295
5295
  layers: (project) => fleetLayers(project),
5296
5296
  health: healthCheck,
5297
5297
  doctor: (name) => runDoctor(name),
5298
+ surfaces: () => inspectSurfaces({ run: runCommand, log, env: process.env }),
5298
5299
  enqueue: (draft) => {
5299
5300
  try {
5300
5301
  d.store.enqueueReport(draft);
@@ -5304,6 +5305,21 @@ export function upgradeVerifyDepsFor(d: Pick<Deps, "project" | "store">): Upgrad
5304
5305
  },
5305
5306
  pause: pauseInstance,
5306
5307
  resume: (project) => setPaused(false, undefined, project),
5308
+ holdGlobal: () => {
5309
+ if (pauseInstance() === undefined) {
5310
+ setPaused(true, { source: "upgrade-recovery", reason: "upgrade recovery verification required" });
5311
+ }
5312
+ },
5313
+ releaseGlobalRecovery: (expectedSince) => {
5314
+ const pause = pauseInstance();
5315
+ if (
5316
+ pause?.source === "upgrade-recovery" &&
5317
+ expectedSince !== undefined &&
5318
+ pause.since === expectedSince
5319
+ ) {
5320
+ setPaused(false);
5321
+ }
5322
+ },
5307
5323
  launchRollback: async (version) => {
5308
5324
  const launched = await launchTransientUnit(
5309
5325
  (argv) => runCommand(argv[0]!, argv.slice(1)),
@@ -5333,7 +5349,11 @@ async function verifyPendingUpgradeTick(
5333
5349
  const verifyDeps = upgradeVerifyDepsFor(d);
5334
5350
  try {
5335
5351
  const outcome = await run(verifyDeps);
5336
- if (outcome.handled !== "none" && outcome.handled !== "already-closed") {
5352
+ if (
5353
+ outcome.handled !== "none" &&
5354
+ outcome.handled !== "already-closed" &&
5355
+ outcome.handled !== "recovery-owed"
5356
+ ) {
5337
5357
  log(
5338
5358
  `upgrade journal handled: ${outcome.handled}` +
5339
5359
  `${outcome.version === undefined ? "" : ` for omp-conductor@${outcome.version}`}` +
@@ -6480,6 +6500,8 @@ export interface StatusSnapshot {
6480
6500
  * reading like a mistake (#220).
6481
6501
  */
6482
6502
  pauseReason?: string;
6503
+ /** Unresolved host-wide upgrade recovery, rendered identically for every project. */
6504
+ upgradeRecovery?: UpgradeRecoveryStatus;
6483
6505
  /**
6484
6506
  * The project's active self-expiring drain (#484): present only while a
6485
6507
  * fresh, valid drain record exists. Paused and drained are deliberately two
@@ -6674,9 +6696,12 @@ export function statusSnapshotFromStore(
6674
6696
  const oldestLabelOpAt = store.oldestPendingLabelOpAt(p.name);
6675
6697
  // Read once: `status` renders the degrade row off this while it is down.
6676
6698
  const orchestratorDown = store.orchestratorIncident(p.name);
6677
- // Read once: the provenance read touches the filesystem, and the renderer
6678
- // should never pay for it twice per status.
6679
- const reason = pauseProvenance(p.name)?.reason;
6699
+ // The journal is the authoritative recovery lifecycle. Render unresolved
6700
+ // recovery even when an operator/project pause currently wins provenance,
6701
+ // or before the verifier has re-created a missing global fence.
6702
+ const pause = pauseProvenance(p.name);
6703
+ const reason = pause?.reason;
6704
+ const upgradeRecovery = upgradeRecoveryStatus(readUpgradeJournal(stateDir()), stateDir());
6680
6705
  // Same cost discipline as the pause read: the drain record is a file read,
6681
6706
  // and the active-drain view is only built when one is actually fresh. The
6682
6707
  // read is observational — it never mutates the record — so a status read
@@ -6741,6 +6766,7 @@ export function statusSnapshotFromStore(
6741
6766
  stateDir: stateDir(),
6742
6767
  paused: isPaused(p.name),
6743
6768
  ...(reason === undefined ? {} : { pauseReason: reason }),
6769
+ ...(upgradeRecovery === undefined ? {} : { upgradeRecovery }),
6744
6770
  ...(drain.kind === "active"
6745
6771
  ? {
6746
6772
  drain: {
@@ -46,6 +46,7 @@ import { unblockIssue } from "../unblock.ts";
46
46
  import { makeTracker } from "../tracker/github.ts";
47
47
  import { dbPath, openStore } from "../store.ts";
48
48
  import { loadConfig, resolveCaps } from "../config.ts";
49
+ import { acknowledgeUpgradeRecovery } from "../upgrade-verify.ts";
49
50
  import type { ProjectConfig } from "../types.ts";
50
51
 
51
52
  /** The source string every dashboard mutation attributes itself with. */
@@ -70,6 +71,7 @@ export interface ControlDeps {
70
71
  project: ProjectConfig;
71
72
  hold?: typeof hold;
72
73
  releaseHold?: typeof releaseHold;
74
+ acknowledgeUpgradeRecovery?: typeof acknowledgeUpgradeRecovery;
73
75
  disarmTicks?: typeof disarmTicks;
74
76
  /**
75
77
  * Forwards one request to the owning daemon's own HTTP surface. Returns
@@ -119,6 +121,7 @@ export function dashboardPause(body: unknown, d: ControlDeps): ControlOutcome {
119
121
 
120
122
  /** Clear the pause. Idempotent, exactly as `resume` is on the CLI. */
121
123
  export function dashboardResume(d: ControlDeps): ControlOutcome {
124
+ (d.acknowledgeUpgradeRecovery ?? acknowledgeUpgradeRecovery)();
122
125
  (d.releaseHold ?? releaseHold)(d.project.name);
123
126
  return { status: 200, body: { paused: false } };
124
127
  }
@@ -564,8 +564,19 @@ export function formatFleetStatus(
564
564
  })()
565
565
  : `dispatch ${layers.dispatch}`;
566
566
 
567
+ const upgradeRecoveryBlock =
568
+ s.upgradeRecovery === undefined
569
+ ? undefined
570
+ : [
571
+ `upgrade recovery required for failed omp-conductor@${s.upgradeRecovery.failedVersion} (${s.upgradeRecovery.phase})`,
572
+ ` journal ${s.upgradeRecovery.journalPath}`,
573
+ ` owed ${s.upgradeRecovery.verificationOwed}`,
574
+ " release automatic after successful verification; operator acknowledgement: `omp-conductor resume`",
575
+ ].join("\n");
576
+
567
577
  return [
568
578
  dispatchLine,
579
+ ...(upgradeRecoveryBlock === undefined ? [] : [upgradeRecoveryBlock]),
569
580
  tickLine,
570
581
  ...(nextTickLine === undefined ? [] : [nextTickLine]),
571
582
  paneLine,
@@ -74,6 +74,8 @@ export interface UpgradeJournalEntry {
74
74
  pauseKey?: string;
75
75
  /** Whether dispatch was already paused when the request began. */
76
76
  initialPaused?: boolean;
77
+ /** Creation instant of the exact pause sentinel this transaction acquired. */
78
+ pauseSince?: number;
77
79
  /** The fleet layers the install began under, for delta verification. */
78
80
  initial?: JournalFleetState;
79
81
  /** The projects the install covers, in the form the upgrade engine uses. */
@@ -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,34 @@ 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)[];
177
206
  initial?: UpgradeJournalEntry["initial"];
178
207
  configBackup?: string;
208
+ previous?: JournalSurfaces;
179
209
  /**
180
210
  * Epoch-ms when the install began (the journal snapshot's write time): the
181
211
  * deadline the live orchestrator session must have restarted after (#832).
@@ -202,6 +232,9 @@ export function pendingUpgradeRequest(
202
232
  const request = progression[0]!;
203
233
  if (request.ok === false) return undefined;
204
234
  const snapshot = progression.find((entry) => entry.kind === "snapshot");
235
+ const pausePhase = progression.find(
236
+ (entry) => entry.kind === "phase" && entry.phase === "paused",
237
+ );
205
238
  const snapshotAt = snapshot === undefined ? undefined : Date.parse(snapshot.at);
206
239
  return {
207
240
  version: request.version ?? "unknown",
@@ -209,12 +242,80 @@ export function pendingUpgradeRequest(
209
242
  initialPaused: snapshot?.initialPaused,
210
243
  pauseKey: snapshot?.pauseKey,
211
244
  selectors: (snapshot?.selectors ?? []).map((selector) => selector ?? undefined),
245
+ pauseSince: pausePhase?.pauseSince,
212
246
  initial: snapshot?.initial,
213
247
  configBackup: snapshot?.configBackup,
248
+ previous: snapshot?.previous,
214
249
  ...(snapshotAt !== undefined && Number.isFinite(snapshotAt) ? { reloadAfterMs: snapshotAt } : {}),
215
250
  };
216
251
  }
217
252
 
253
+ export interface UpgradeRecoveryStatus {
254
+ failedVersion: string;
255
+ phase: string;
256
+ journalPath: string;
257
+ verificationOwed: string;
258
+ }
259
+
260
+ /**
261
+ * The unresolved safety hold represented by the newest journal request.
262
+ * Status reads this same lifecycle verdict as the verifier; it never guesses
263
+ * from the pause reason alone.
264
+ */
265
+ export function upgradeRecoveryStatus(
266
+ entries: readonly UpgradeJournalEntry[],
267
+ root = stateDir(),
268
+ ): UpgradeRecoveryStatus | undefined {
269
+ const request = pendingUpgradeRequest(entries);
270
+ const state = classifyUpgrade(entries);
271
+ if (
272
+ request === undefined ||
273
+ state.kind !== "closed" ||
274
+ !RECOVERY_HOLD_OUTCOMES.has(state.phase)
275
+ ) {
276
+ return undefined;
277
+ }
278
+ const expected = request.previous;
279
+ const surfaces =
280
+ expected === undefined
281
+ ? "the pre-upgrade surface snapshot is missing"
282
+ : [
283
+ `CLI=${expected.cliVersion}`,
284
+ `omp=${expected.ompVersion ?? "absent"}`,
285
+ `herdr=${expected.herdrSource ?? "absent"}`,
286
+ ].join(", ");
287
+ const verificationOwed =
288
+ state.phase === "rollback-requested"
289
+ ? `detached rollback completion, then installed-surface verification against ${surfaces}`
290
+ : `installed-surface verification against ${surfaces}`;
291
+ return {
292
+ failedVersion: request.version,
293
+ phase: state.phase,
294
+ journalPath: upgradeJournalPath(root),
295
+ verificationOwed,
296
+ };
297
+ }
298
+
299
+ /**
300
+ * Record the operator's explicit choice to release an unresolved upgrade
301
+ * recovery fence. `resume` calls this before removing an upgrade-owned pause,
302
+ * so the append-only journal distinguishes acknowledgement from verification.
303
+ */
304
+ export function acknowledgeUpgradeRecovery(root = stateDir()): boolean {
305
+ const entries = readUpgradeJournal(root);
306
+ const recovery = upgradeRecoveryStatus(entries, root);
307
+ if (recovery === undefined) return false;
308
+ appendJournal(root, {
309
+ at: new Date().toISOString(),
310
+ kind: "outcome",
311
+ phase: "recovery-acknowledged",
312
+ ok: true,
313
+ version: recovery.failedVersion,
314
+ detail: `operator resumed dispatch with ${recovery.verificationOwed} still owed`,
315
+ });
316
+ return true;
317
+ }
318
+
218
319
  // ---------------------------------------------------------------------------
219
320
  // The live-orchestrator session attestation (#832)
220
321
  // ---------------------------------------------------------------------------
@@ -387,12 +488,18 @@ export interface UpgradeVerifyDeps {
387
488
  layers(project?: string): FleetLayers;
388
489
  health(port: number): Promise<{ ok: boolean; body?: string }>;
389
490
  doctor(projectName: string): Promise<DoctorReport>;
491
+ /** Read all three package/plugin identities from the live host. */
492
+ surfaces(): Promise<JournalSurfaces>;
390
493
  /** The durable outbox enqueue — one row, daemon-owned delivery. */
391
494
  enqueue(draft: ReportDraft): void;
392
495
  /** Read one pause sentinel (who set it, when) — daemon-owned state. */
393
496
  pause(project?: string): { source: string; reason?: string; since: number } | undefined;
394
497
  /** Lift the pause sentinel for a project, clearing the legacy global too. */
395
498
  resume(project?: string): void;
499
+ /** Escalate a project pause to the host-global safety fence. */
500
+ holdGlobal(): void;
501
+ /** Remove only the exact dedicated recovery fence observed by this pass. */
502
+ releaseGlobalRecovery(expectedSince: number | undefined): void;
396
503
  /** Start the detached rollback unit for a version. */
397
504
  launchRollback(
398
505
  version: string,
@@ -419,6 +526,8 @@ export interface UpgradeVerifyResult {
419
526
  | "none"
420
527
  | "already-closed"
421
528
  | "verified"
529
+ | "recovered"
530
+ | "recovery-owed"
422
531
  | "aborted"
423
532
  | "rollback-requested"
424
533
  | "rollback-unavailable";
@@ -453,41 +562,54 @@ export async function verifyPendingUpgrade(deps: UpgradeVerifyDeps): Promise<Upg
453
562
  if (request === undefined) return { handled: "none" };
454
563
  const state = classifyUpgrade(entries);
455
564
 
456
- const journal = (entry: Omit<UpgradeJournalEntry, "at">): void => {
565
+ const journal = (entry: Omit<UpgradeJournalEntry, "at">, at = now()): void => {
457
566
  const write = deps.journal ?? ((line: UpgradeJournalEntry) => appendJournal(root, line));
458
- write({ ...entry, version: entry.version ?? request.version, at: new Date(now()).toISOString() });
567
+ write({ ...entry, version: entry.version ?? request.version, at: new Date(at).toISOString() });
459
568
  };
460
569
 
461
570
  // ---------------------------------------------------------------- terminal
462
571
  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);
572
+ // A closed failure is still a safety hold: rollback-requested means the
573
+ // rollback unit has not landed yet, while rollback-unavailable and
574
+ // rollback-failed mean the host may contain mixed surfaces. Only outcomes
575
+ // that prove the transaction safe may release its pause.
576
+ const outcome = newestOutcome(progression);
577
+ const parsedOutcomeAt = outcome === undefined ? Number.NaN : Date.parse(outcome.at);
578
+ const outcomeAt = Number.isFinite(parsedOutcomeAt) ? parsedOutcomeAt : undefined;
579
+ if (RECOVERY_CHECK_OUTCOMES.has(state.phase)) {
580
+ return verifyRecoveredUpgrade(deps, request, state.phase, outcomeAt, journal, now);
581
+ }
582
+ if (RECOVERY_HOLD_OUTCOMES.has(state.phase)) deps.holdGlobal();
583
+ if (RESUMABLE_OUTCOMES.has(state.phase) && outcomeAt !== undefined) {
584
+ resumeUpgradePause(deps, request, outcomeAt);
585
+ }
469
586
  return { handled: "already-closed", version: request.version, detail: state.phase };
470
587
  }
471
588
 
472
589
  // ------------------------------------------------------------ never started
473
590
  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);
591
+ // The unit died before journaling a single phase. Nothing was installed,
592
+ // but no phase recorded the exact pause instance either. Close the request
593
+ // without guessing that an upgrade-owned sentinel still belongs to it.
594
+ const abortedAt = now();
479
595
  deps.enqueue({
480
596
  project: deps.projectName,
481
597
  kind: "tier2",
482
598
  body: [
483
599
  `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.",
600
+ "The detached install unit died before touching any surface.",
601
+ "Dispatch may still hold an unidentifiable install pause; check `status` and clear it explicitly with `omp-conductor resume`.",
485
602
  `Re-run the request, or install by hand with \`omp-conductor upgrade --to ${request.version}\`.`,
486
603
  ].join("\n"),
487
- at: now(),
604
+ at: abortedAt,
488
605
  dedupeKey: `upgrade:${request.version}:aborted`,
489
606
  });
490
- journal({ kind: "outcome", phase: "aborted", ok: false, detail: "requested upgrade never started a phase" });
607
+ journal({
608
+ kind: "outcome",
609
+ phase: "aborted",
610
+ ok: false,
611
+ detail: "requested upgrade never started a phase",
612
+ }, abortedAt);
491
613
  return { handled: "aborted", version: request.version, detail: "no phase was ever journaled" };
492
614
  }
493
615
 
@@ -518,16 +640,9 @@ export async function verifyPendingUpgrade(deps: UpgradeVerifyDeps): Promise<Upg
518
640
  );
519
641
  }
520
642
 
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
- }
643
+ // Good. Persist the evidence before releasing anything: if this process dies
644
+ // after the outcome, the next tick can finish the idempotent resume.
645
+ const verifiedAt = now();
531
646
  deps.enqueue({
532
647
  project: deps.projectName,
533
648
  kind: "material",
@@ -538,23 +653,118 @@ export async function verifyPendingUpgrade(deps: UpgradeVerifyDeps): Promise<Upg
538
653
  "",
539
654
  "Dispatch restored to its prior state.",
540
655
  ].join("\n"),
541
- at: now(),
656
+ at: verifiedAt,
542
657
  dedupeKey: `upgrade:${request.version}:verified`,
543
658
  });
544
- journal({ kind: "outcome", phase: "verified", ok: true, checks });
659
+ journal({ kind: "outcome", phase: "verified", ok: true, checks }, verifiedAt);
660
+ const paused = deps.pause(request.pauseKey);
661
+ resumeUpgradePause(deps, request, verifiedAt);
662
+ if (paused !== undefined && deps.pause(request.pauseKey) === undefined) {
663
+ deps.log(`upgrade verified — dispatch resumed (${request.pauseKey ?? "global"})`);
664
+ }
545
665
  return { handled: "verified", version: request.version };
546
666
  }
547
667
 
668
+ async function verifyRecoveredUpgrade(
669
+ deps: UpgradeVerifyDeps,
670
+ request: PendingUpgradeRequest,
671
+ failedPhase: string,
672
+ failedOutcomeAt: number | undefined,
673
+ journal: (entry: Omit<UpgradeJournalEntry, "at">, at?: number) => void,
674
+ now: () => number,
675
+ ): Promise<UpgradeVerifyResult> {
676
+ const globalPause = deps.pause();
677
+ const globalRecoverySince =
678
+ globalPause?.source === "upgrade-recovery" ? globalPause.since : undefined;
679
+ const checks: UpgradeCheck[] = [];
680
+ const expected = request.previous;
681
+ if (expected === undefined) {
682
+ checks.push({ name: "installed-surfaces", ok: false, detail: "pre-upgrade snapshot is missing" });
683
+ } else {
684
+ try {
685
+ const actual = await deps.surfaces();
686
+ for (const [name, wanted, found] of [
687
+ ["cli", expected.cliVersion, actual.cliVersion],
688
+ ["omp", expected.ompVersion, actual.ompVersion],
689
+ ["herdr", expected.herdrSource, actual.herdrSource],
690
+ ] as const) {
691
+ checks.push({
692
+ name: `surface:${name}`,
693
+ ok: wanted === found,
694
+ detail: `expected ${wanted ?? "absent"}; found ${found ?? "absent"}`,
695
+ });
696
+ }
697
+ } catch (err) {
698
+ checks.push({
699
+ name: "installed-surfaces",
700
+ ok: false,
701
+ detail: err instanceof Error ? err.message : String(err),
702
+ });
703
+ }
704
+ }
705
+ const failed = checks.find((check) => !check.ok);
706
+ if (failed !== undefined) {
707
+ // A recovery verdict owns the host-wide gate. Re-establish it on every
708
+ // owed pass so a legacy failed journal or a lost sentinel cannot admit.
709
+ deps.holdGlobal();
710
+ return {
711
+ handled: "recovery-owed",
712
+ version: request.version,
713
+ detail: `${failedPhase}; ${failed.name}: ${failed.detail ?? "failed"}`,
714
+ };
715
+ }
716
+
717
+ const recoveredAt = now();
718
+ deps.enqueue({
719
+ project: deps.projectName,
720
+ kind: "material",
721
+ body: [
722
+ `The failed upgrade to omp-conductor@${request.version} is recovered.`,
723
+ "",
724
+ ...checks.map((check) => `ok ${check.name} — ${check.detail ?? "matched"}`),
725
+ "",
726
+ "Every installed surface matches the durable pre-upgrade snapshot; dispatch restored to its prior state.",
727
+ ].join("\n"),
728
+ at: recoveredAt,
729
+ dedupeKey: `upgrade:${request.version}:recovered`,
730
+ });
731
+ journal({
732
+ kind: "outcome",
733
+ phase: "recovered",
734
+ ok: true,
735
+ detail: `verified recovery after ${failedPhase}`,
736
+ checks,
737
+ }, recoveredAt);
738
+ resumeUpgradePause(deps, request, failedOutcomeAt);
739
+ deps.releaseGlobalRecovery(globalRecoverySince);
740
+ return { handled: "recovered", version: request.version, detail: failedPhase };
741
+ }
742
+
548
743
  /**
549
744
  * Lift the install's own pause sentinel, and only that one. `pauseKey` is the
550
745
  * sentinel the engine paused; a fixed `initialPaused` true means the fleet was
551
746
  * already held and the install never paused it, so nothing is lifted. A pause
552
747
  * that an operator re-created (source changed) is left standing.
748
+ *
749
+ * New journals carry the exact pause creation instant, so identical
750
+ * provenance can never make a newer transaction look owned. `notAfter` is the
751
+ * compatibility bound for older journals that predate that identity field:
752
+ * only a pause that existed before the safe terminal outcome may be released.
553
753
  */
554
- function resumeUpgradePause(deps: UpgradeVerifyDeps, request: PendingUpgradeRequest): void {
754
+ function resumeUpgradePause(
755
+ deps: UpgradeVerifyDeps,
756
+ request: PendingUpgradeRequest,
757
+ notAfter?: number,
758
+ ): void {
555
759
  if (request.initialPaused === true) return;
556
760
  const owned = deps.pause(request.pauseKey);
557
- if (owned !== undefined && owned.source === "upgrade") {
761
+ const sameTransaction =
762
+ request.pauseSince !== undefined
763
+ ? owned !== undefined &&
764
+ owned.since >= request.pauseSince &&
765
+ (notAfter === undefined || owned.since <= notAfter)
766
+ : notAfter !== undefined && owned !== undefined && owned.since <= notAfter;
767
+ if (owned?.source === "upgrade" && sameTransaction) {
558
768
  deps.resume(request.pauseKey);
559
769
  }
560
770
  }
@@ -687,6 +897,10 @@ async function triggerUpgradeRollback(
687
897
  at: number,
688
898
  why: string,
689
899
  ): Promise<UpgradeVerifyResult> {
900
+ // Every install surface is host-wide even when the request named one
901
+ // project. Once recovery is required, the safety fence must gate every
902
+ // project served by the shared daemon.
903
+ deps.holdGlobal();
690
904
  // The marker is the spawn guard: present-and-ok means a rollback unit is
691
905
  // already on its way and a crash after the spawn must not spawn a second
692
906
  // one. Present-and-failed means nothing started, so a retry is a first
package/src/upgrade.ts CHANGED
@@ -129,6 +129,8 @@ export interface UpgradeDeps {
129
129
  */
130
130
  pauseState(project?: string): { source: string; reason?: string; since: number } | undefined;
131
131
  setPaused(value: boolean, project?: string): void;
132
+ /** Host-global recovery fence for host-wide install surfaces. */
133
+ setGlobalRecoveryPaused(value: boolean): void;
132
134
  restartDaemon(): Promise<void>;
133
135
  sleep(ms: number): Promise<void>;
134
136
  /**
@@ -205,6 +207,14 @@ export const DEFAULT_DEPS: UpgradeDeps = {
205
207
  pauseState: (project) => pauseInstance(project),
206
208
  setPaused: (v, project) =>
207
209
  setPaused(v, { source: "upgrade", reason: "upgrade, draining" }, project),
210
+ setGlobalRecoveryPaused: (v) => {
211
+ const current = pauseInstance();
212
+ if (v && current === undefined) {
213
+ setPaused(true, { source: "upgrade-recovery", reason: "upgrade recovery verification required" });
214
+ } else if (!v && current?.source === "upgrade-recovery") {
215
+ setPaused(false);
216
+ }
217
+ },
208
218
  restartDaemon: async () => {
209
219
  await restartDaemon({});
210
220
  },
@@ -1413,7 +1423,14 @@ export async function upgradeConductor(
1413
1423
  deps.log("safety: pausing new issue claims");
1414
1424
  deps.setPaused(true, scope.pauseKey);
1415
1425
  }
1416
- journal({ kind: "phase", phase: "paused", surface: "dispatch", ok: true, version: release.version });
1426
+ journal({
1427
+ kind: "phase",
1428
+ phase: "paused",
1429
+ surface: "dispatch",
1430
+ ok: true,
1431
+ version: release.version,
1432
+ pauseSince: deps.pauseState(scope.pauseKey)?.since,
1433
+ });
1417
1434
  deps.log("drain: waiting for live omp worker sessions");
1418
1435
  try {
1419
1436
  await waitForDrain(deps, scope);
@@ -1595,6 +1612,7 @@ export async function upgradeConductor(
1595
1612
  } catch {
1596
1613
  deps.setPaused(true, scope.pauseKey);
1597
1614
  }
1615
+ deps.setGlobalRecoveryPaused(true);
1598
1616
  const failure = err instanceof Error ? err.message : String(err);
1599
1617
  journal({
1600
1618
  kind: "phase",
@@ -1779,6 +1797,10 @@ export async function rollbackFromJournal(
1779
1797
  configBefore,
1780
1798
  request.configBackup,
1781
1799
  );
1800
+ // The restored bytes remain host-wide unverified state until the returning
1801
+ // daemon reads all three surfaces. A project-scoped request cannot leave its
1802
+ // siblings admitting in that interval.
1803
+ deps.setGlobalRecoveryPaused(true);
1782
1804
 
1783
1805
  const restoredVersion = previous.cliVersion;
1784
1806
  appendJournal(root, {