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.
- package/package.json +1 -1
- package/src/cli.ts +2 -0
- package/src/command-help.ts +18 -8
- package/src/command-manifest.ts +22 -5
- package/src/commands/context.ts +1 -0
- package/src/commands/report.ts +63 -0
- package/src/commands/restore-db.ts +1 -1
- package/src/commands/resume.ts +5 -0
- package/src/commands/snapshot-db.ts +67 -0
- package/src/commands/upgrade-install.ts +1 -1
- package/src/commands/upgrade.ts +2 -2
- package/src/commands/verb.ts +1 -0
- package/src/commands/watch.ts +14 -2
- package/src/config.ts +17 -0
- package/src/daemon.ts +54 -8
- package/src/dashboard/controls.ts +3 -0
- package/src/doctor.ts +36 -3
- package/src/fleet.ts +51 -3
- package/src/orchestrator-tick.ts +14 -3
- package/src/setup-host.ts +10 -10
- package/src/setup-wizard.ts +1 -1
- package/src/status-render.ts +21 -0
- package/src/store.ts +296 -22
- package/src/types.ts +32 -3
- package/src/upgrade-journal.ts +37 -1
- package/src/upgrade-verify.ts +272 -37
- package/src/upgrade.ts +324 -23
- package/src/verbs/actions.ts +6 -1
- package/src/verbs/server.ts +54 -20
- package/systemd/omp-conductor-recover.sh +55 -7
- package/systemd/recover-unit-test.sh +99 -6
package/src/upgrade-verify.ts
CHANGED
|
@@ -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 =
|
|
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"
|
|
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(
|
|
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
|
-
//
|
|
464
|
-
// rollback
|
|
465
|
-
//
|
|
466
|
-
//
|
|
467
|
-
|
|
468
|
-
|
|
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
|
-
//
|
|
476
|
-
//
|
|
477
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
621
|
+
at: abortedAt,
|
|
488
622
|
dedupeKey: `upgrade:${request.version}:aborted`,
|
|
489
623
|
});
|
|
490
|
-
journal({
|
|
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.
|
|
522
|
-
//
|
|
523
|
-
|
|
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:
|
|
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:
|
|
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(
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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)}`,
|