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 +1 -1
- package/src/commands/resume.ts +5 -0
- package/src/daemon.ts +31 -5
- package/src/dashboard/controls.ts +3 -0
- package/src/status-render.ts +11 -0
- package/src/upgrade-journal.ts +2 -0
- package/src/upgrade-verify.ts +246 -32
- package/src/upgrade.ts +23 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "omp-conductor",
|
|
3
|
-
"version": "0.19.
|
|
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.",
|
package/src/commands/resume.ts
CHANGED
|
@@ -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 (
|
|
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
|
-
//
|
|
6678
|
-
//
|
|
6679
|
-
|
|
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
|
}
|
package/src/status-render.ts
CHANGED
|
@@ -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,
|
package/src/upgrade-journal.ts
CHANGED
|
@@ -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. */
|
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,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"
|
|
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(
|
|
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
|
-
//
|
|
464
|
-
// rollback
|
|
465
|
-
//
|
|
466
|
-
//
|
|
467
|
-
|
|
468
|
-
|
|
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
|
-
//
|
|
476
|
-
//
|
|
477
|
-
|
|
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
|
|
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:
|
|
604
|
+
at: abortedAt,
|
|
488
605
|
dedupeKey: `upgrade:${request.version}:aborted`,
|
|
489
606
|
});
|
|
490
|
-
journal({
|
|
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.
|
|
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
|
-
}
|
|
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:
|
|
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(
|
|
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
|
-
|
|
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({
|
|
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, {
|