omp-conductor 0.15.10 → 0.15.12
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/README.md +273 -2544
- package/REFERENCE.md +2680 -0
- package/package.json +3 -2
- package/schema/config.schema.json +11 -23
- package/src/arm-challenge.ts +112 -0
- package/src/ask.ts +434 -0
- package/src/board.ts +81 -15
- package/src/brief-upgrade.ts +114 -8
- package/src/briefs/orchestrator.md +113 -34
- package/src/briefs/policy.md +33 -8
- package/src/briefs/worker.md +18 -9
- package/src/chain-check.ts +1 -1
- package/src/check-trailing-newlines.ts +82 -0
- package/src/cli.ts +225 -1406
- package/src/commands/arm.ts +21 -0
- package/src/commands/board.ts +23 -0
- package/src/commands/brief-upgrade.ts +186 -0
- package/src/commands/context.ts +150 -0
- package/src/commands/daemon.ts +71 -0
- package/src/commands/dashboard.ts +74 -0
- package/src/commands/decision.ts +103 -0
- package/src/commands/disarm.ts +21 -0
- package/src/commands/doctor.ts +100 -0
- package/src/commands/event.ts +62 -0
- package/src/commands/extend.ts +64 -0
- package/src/commands/friction.ts +56 -0
- package/src/commands/help.ts +9 -0
- package/src/commands/hold.ts +26 -0
- package/src/commands/intake.ts +155 -0
- package/src/commands/ledger.ts +69 -0
- package/src/commands/message.ts +96 -0
- package/src/commands/report.ts +206 -0
- package/src/commands/restart.ts +76 -0
- package/src/commands/resume.ts +58 -0
- package/src/commands/setup.ts +143 -0
- package/src/commands/start.ts +23 -0
- package/src/commands/stats.ts +131 -0
- package/src/commands/status.ts +48 -0
- package/src/commands/stop.ts +51 -0
- package/src/commands/tail.ts +109 -0
- package/src/commands/unblock.ts +39 -0
- package/src/commands/upgrade-install.ts +31 -0
- package/src/commands/upgrade-rollback.ts +32 -0
- package/src/commands/upgrade.ts +25 -0
- package/src/commands/verb.ts +83 -0
- package/src/commands/version.ts +30 -0
- package/src/commands/worker.ts +100 -0
- package/src/config-schema.ts +47 -1
- package/src/config.ts +45 -4
- package/src/daemon.ts +972 -101
- package/src/dashboard/app.js +459 -0
- package/src/dashboard/index.html +61 -0
- package/src/dashboard/server.ts +481 -0
- package/src/dashboard/style.css +348 -0
- package/src/decisions.ts +39 -14
- package/src/diff-flags.ts +131 -241
- package/src/doctor.ts +932 -0
- package/src/escalate.ts +2 -2
- package/src/failure-class.ts +66 -3
- package/src/fleet.ts +58 -1
- package/src/gitops.ts +157 -0
- package/src/graph-health.ts +1 -1
- package/src/label-projection.ts +1 -1
- package/src/lifecycle.ts +198 -2
- package/src/model-fallback.ts +177 -0
- package/src/notices.ts +9 -0
- package/src/omp.ts +93 -13
- package/src/orchestrator-tick.ts +414 -20
- package/src/orchestrator.ts +4 -4
- package/src/privileged.ts +10 -0
- package/src/release-policy.ts +342 -30
- package/src/reports.ts +19 -5
- package/src/session-host.ts +11 -5
- package/src/setup-host.ts +663 -25
- package/src/setup-install.ts +292 -28
- package/src/setup-wizard.ts +255 -74
- package/src/setup.ts +156 -35
- package/src/stats.ts +331 -0
- package/src/store.ts +219 -22
- package/src/tracker/github.ts +74 -4
- package/src/types.ts +215 -31
- package/src/unblock.ts +55 -11
- package/src/upgrade-journal.ts +220 -0
- package/src/upgrade-verify.ts +506 -0
- package/src/upgrade.ts +385 -58
- package/src/verbs/actions.ts +73 -1
- package/src/verbs/protocol.ts +29 -4
- package/src/verbs/server.ts +183 -20
- package/src/worker.ts +3 -3
- package/systemd/omp-conductor-recover.sh +433 -0
- package/systemd/omp-conductor.service.example +14 -3
- package/systemd/recover-unit-test.sh +428 -0
package/src/upgrade.ts
CHANGED
|
@@ -12,13 +12,28 @@ import {
|
|
|
12
12
|
type FleetLayers,
|
|
13
13
|
} from "./fleet.ts";
|
|
14
14
|
import { livingDaemon, restartDaemon } from "./lifecycle.ts";
|
|
15
|
-
import { configBackupDir, configPath, findProject, loadConfig, resolveCaps, writeConfigRaw } from "./config.ts";
|
|
15
|
+
import { configBackupDir, configPath, findProject, loadConfig, resolveCaps, stateDir, writeConfigRaw } from "./config.ts";
|
|
16
16
|
import { renderBriefForProject } from "./setup.ts";
|
|
17
17
|
import {
|
|
18
18
|
STAGED_SERVICE_NAME,
|
|
19
19
|
planHostRuntime,
|
|
20
20
|
writeHostRuntime,
|
|
21
21
|
} from "./setup-host.ts";
|
|
22
|
+
import {
|
|
23
|
+
appendJournal,
|
|
24
|
+
readUpgradeJournal,
|
|
25
|
+
upgradeJournalPath,
|
|
26
|
+
type UpgradeJournalEntry,
|
|
27
|
+
} from "./upgrade-journal.ts";
|
|
28
|
+
import {
|
|
29
|
+
enqueueUpgradeReport,
|
|
30
|
+
pendingUpgradeRequest,
|
|
31
|
+
reportProject,
|
|
32
|
+
requestProgression,
|
|
33
|
+
runCommand,
|
|
34
|
+
type PendingUpgradeRequest,
|
|
35
|
+
type UpgradeCommandResult,
|
|
36
|
+
} from "./upgrade-verify.ts";
|
|
22
37
|
|
|
23
38
|
const PACKAGE = "omp-conductor";
|
|
24
39
|
const HERDR_PLUGIN = "herdr-conductor";
|
|
@@ -28,15 +43,17 @@ const DRAIN_POLL_MS = 5_000;
|
|
|
28
43
|
const RECOVERY_POLL_MS = 2_000;
|
|
29
44
|
const RECOVERY_ATTEMPTS = 30;
|
|
30
45
|
|
|
31
|
-
export interface UpgradeCommandResult {
|
|
32
|
-
code: number;
|
|
33
|
-
stdout: string;
|
|
34
|
-
stderr: string;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
46
|
export interface UpgradeOptions {
|
|
38
47
|
version?: string;
|
|
39
48
|
project?: string;
|
|
49
|
+
/**
|
|
50
|
+
* Run as the detached fleet installer (#486): journal every surface the
|
|
51
|
+
* transaction touches, skip the in-process verification and leave dispatch
|
|
52
|
+
* paused — the first tick of the post-restart daemon verifies version,
|
|
53
|
+
* /healthz, ticks, pane and doctor, and closes or rolls back on that
|
|
54
|
+
* evidence. Only the transient unit's `upgrade-install` entry sets this.
|
|
55
|
+
*/
|
|
56
|
+
detached?: boolean;
|
|
40
57
|
}
|
|
41
58
|
|
|
42
59
|
export interface UpgradeResult {
|
|
@@ -79,25 +96,18 @@ export interface UpgradeDeps {
|
|
|
79
96
|
sleep(ms: number): Promise<void>;
|
|
80
97
|
env: NodeJS.ProcessEnv;
|
|
81
98
|
log(message: string): void;
|
|
99
|
+
/**
|
|
100
|
+
* The durable journal sink for the detached fleet installer (#486). Wired
|
|
101
|
+
* to the state-dir journal by `upgrade-install`/`upgrade-rollback`; absent
|
|
102
|
+
* on the human `upgrade` path, whose own process is its witness. The engine
|
|
103
|
+
* journals each surface boundary so the post-restart tick can verify against
|
|
104
|
+
* a record it did not write.
|
|
105
|
+
*/
|
|
106
|
+
journal?(entry: UpgradeJournalEntry): void;
|
|
82
107
|
}
|
|
83
108
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
const child = Bun.spawn([command, ...args], {
|
|
87
|
-
stdin: "ignore",
|
|
88
|
-
stdout: "pipe",
|
|
89
|
-
stderr: "pipe",
|
|
90
|
-
env: process.env,
|
|
91
|
-
});
|
|
92
|
-
const stdout = new Response(child.stdout).text();
|
|
93
|
-
const stderr = new Response(child.stderr).text();
|
|
94
|
-
const code = await child.exited;
|
|
95
|
-
return { code, stdout: await stdout, stderr: await stderr };
|
|
96
|
-
} catch (err) {
|
|
97
|
-
return { code: 127, stdout: "", stderr: err instanceof Error ? err.message : String(err) };
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
109
|
+
/** One command run to completion with captured output. Exported so the
|
|
110
|
+
* post-restart verifier and the rollback unit run processes the same way. */
|
|
101
111
|
export const DEFAULT_DEPS: UpgradeDeps = {
|
|
102
112
|
run: runCommand,
|
|
103
113
|
snapshot: statusSnapshot,
|
|
@@ -301,13 +311,39 @@ function previousHerdrInstall(source: string): readonly [string, readonly string
|
|
|
301
311
|
* exactly this transaction's pause while leaving any per-project operator hold
|
|
302
312
|
* standing.
|
|
303
313
|
*/
|
|
304
|
-
interface UpgradeScope {
|
|
314
|
+
export interface UpgradeScope {
|
|
305
315
|
/** Project selectors to address the project-aware deps with, never empty. */
|
|
306
316
|
selectors: readonly (string | undefined)[];
|
|
307
317
|
/** The sentinel this transaction pauses and resumes. */
|
|
308
318
|
pauseKey: string | undefined;
|
|
309
319
|
}
|
|
310
320
|
|
|
321
|
+
/**
|
|
322
|
+
* The subset of {@link UpgradeDeps} the pause/drain fence needs. Factored out
|
|
323
|
+
* so a caller that only wants the fence — `setup host` — can provide just
|
|
324
|
+
* these members instead of a full install-and-verify deps object. Every
|
|
325
|
+
* {@link UpgradeDeps} is a `DrainDeps`.
|
|
326
|
+
*/
|
|
327
|
+
export interface DrainDeps {
|
|
328
|
+
/** Live worker count per project selector; `undefined` = the one configured project. */
|
|
329
|
+
snapshot(project?: string): { liveWorkers: number };
|
|
330
|
+
/** The fleet's current dispatch/daemon layers for one project selector. */
|
|
331
|
+
layers(project?: string): FleetLayers;
|
|
332
|
+
/** Every configured project name, in config order (#389). */
|
|
333
|
+
projectNames(): readonly string[];
|
|
334
|
+
/**
|
|
335
|
+
* The daemon the transaction targets: running or not, its project when it
|
|
336
|
+
* recorded one, and a `generation` identifying the exact instance (#377).
|
|
337
|
+
*/
|
|
338
|
+
daemonIdentity(): { running: boolean; project?: string; generation?: string };
|
|
339
|
+
/** The active pause sentinel as an instance, when one is readable (#377). */
|
|
340
|
+
pauseState(project?: string): { source: string; reason?: string; since: number } | undefined;
|
|
341
|
+
/** Write or clear the durable pause sentinel for the scope's project. */
|
|
342
|
+
setPaused(value: boolean, project?: string): void;
|
|
343
|
+
sleep(ms: number): Promise<void>;
|
|
344
|
+
log(message: string): void;
|
|
345
|
+
}
|
|
346
|
+
|
|
311
347
|
/**
|
|
312
348
|
* Decides which projects a transaction covers, and refuses the requests that
|
|
313
349
|
* cannot be honoured truthfully.
|
|
@@ -326,7 +362,11 @@ interface UpgradeScope {
|
|
|
326
362
|
* A single-configured-project host keeps the historical bare selector, so its
|
|
327
363
|
* behaviour is unchanged whether or not the daemon recorded its name.
|
|
328
364
|
*/
|
|
329
|
-
function resolveScope(
|
|
365
|
+
export function resolveScope(
|
|
366
|
+
deps: DrainDeps,
|
|
367
|
+
verb: "upgrade" | "restart" | "setup host",
|
|
368
|
+
project?: string,
|
|
369
|
+
): UpgradeScope {
|
|
330
370
|
let configured: readonly string[] = [];
|
|
331
371
|
try {
|
|
332
372
|
configured = deps.projectNames();
|
|
@@ -362,8 +402,8 @@ function resolveScope(deps: UpgradeDeps, verb: "upgrade" | "restart", project?:
|
|
|
362
402
|
return { selectors: [undefined], pauseKey: undefined };
|
|
363
403
|
}
|
|
364
404
|
|
|
365
|
-
async function waitForDrain(
|
|
366
|
-
deps:
|
|
405
|
+
export async function waitForDrain(
|
|
406
|
+
deps: DrainDeps,
|
|
367
407
|
scope: UpgradeScope,
|
|
368
408
|
deadlineAt?: number,
|
|
369
409
|
stale?: () => string | undefined,
|
|
@@ -417,7 +457,7 @@ interface RestartBegun {
|
|
|
417
457
|
* stopped by it.
|
|
418
458
|
*/
|
|
419
459
|
function restartFenceProblem(
|
|
420
|
-
deps:
|
|
460
|
+
deps: DrainDeps,
|
|
421
461
|
scope: UpgradeScope,
|
|
422
462
|
begun: RestartBegun,
|
|
423
463
|
): string | undefined {
|
|
@@ -440,6 +480,59 @@ function restartFenceProblem(
|
|
|
440
480
|
return undefined;
|
|
441
481
|
}
|
|
442
482
|
|
|
483
|
+
/**
|
|
484
|
+
* Pause claims and drain live workers to idle — the destructive half of the
|
|
485
|
+
* trusted restart transaction, minus the restart. Exported so `setup host`
|
|
486
|
+
* runs the same fence `upgrade` and `restart` do, then performs its own unit
|
|
487
|
+
* installs instead of {@link drainAndRestart}'s daemon restart.
|
|
488
|
+
*
|
|
489
|
+
* Generation-scoped (#377) exactly as the full transaction is: the caller
|
|
490
|
+
* captures the restart-owned pause and the daemon generation, and the drain
|
|
491
|
+
* aborts if a `resume` lifts the pause, replaces the sentinel, or a newer
|
|
492
|
+
* daemon generation appears. On ANY throw after the pause the fleet stays
|
|
493
|
+
* paused (no resume in a catch), so a drain that outlives `timeoutMs` cannot
|
|
494
|
+
* silently resume dispatch over a wedged daemon. `timeoutMs` absent waits
|
|
495
|
+
* indefinitely, which is the `upgrade` posture.
|
|
496
|
+
*/
|
|
497
|
+
export async function pauseAndDrain(
|
|
498
|
+
deps: DrainDeps,
|
|
499
|
+
verb: "upgrade" | "restart" | "setup host",
|
|
500
|
+
o: { project?: string; timeoutMs?: number },
|
|
501
|
+
): Promise<{ scope: UpgradeScope; initialPaused: boolean }> {
|
|
502
|
+
// Host-wide by default (#389): the daemon this restarts serves every
|
|
503
|
+
// configured project, so the drain counts every project's workers and the
|
|
504
|
+
// pause covers all of them.
|
|
505
|
+
const scope = resolveScope(deps, verb, o.project);
|
|
506
|
+
const initial = deps.layers(scope.pauseKey);
|
|
507
|
+
if (!initial.paused) deps.setPaused(true, scope.pauseKey);
|
|
508
|
+
// The lock must be provable NOW. A pause that cannot be read as an instance
|
|
509
|
+
// — sentinel malformed or unreadable — would make the whole fence fail open
|
|
510
|
+
// (nothing to compare against), so the transaction refuses before waiting
|
|
511
|
+
// on anything it cannot act upon either way.
|
|
512
|
+
const pauseToken = deps.pauseState(scope.pauseKey);
|
|
513
|
+
if (pauseToken === undefined) {
|
|
514
|
+
throw new Error(
|
|
515
|
+
`${verb} cancelled: cannot prove the ${verb}-owned pause — the active pause sentinel is unreadable or malformed; nothing was restarted`,
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
const begun: RestartBegun = {
|
|
519
|
+
pauseToken,
|
|
520
|
+
daemon: deps.daemonIdentity(),
|
|
521
|
+
};
|
|
522
|
+
const stale = () => restartFenceProblem(deps, scope, begun);
|
|
523
|
+
await waitForDrain(
|
|
524
|
+
deps,
|
|
525
|
+
scope,
|
|
526
|
+
o.timeoutMs === undefined ? undefined : Date.now() + o.timeoutMs,
|
|
527
|
+
stale,
|
|
528
|
+
);
|
|
529
|
+
// The drain completed; the world may have moved on while it did. Re-prove
|
|
530
|
+
// the pause and the generation an instant before the destructive call.
|
|
531
|
+
const cancelled = stale();
|
|
532
|
+
if (cancelled !== undefined) throw new Error(cancelled);
|
|
533
|
+
return { scope, initialPaused: initial.paused };
|
|
534
|
+
}
|
|
535
|
+
|
|
443
536
|
/**
|
|
444
537
|
* Pause, drain, restart, restore — the trusted restart transaction, minus the
|
|
445
538
|
* install/verify steps of {@link upgradeConductor}.
|
|
@@ -467,34 +560,9 @@ export async function drainAndRestart(
|
|
|
467
560
|
deps: UpgradeDeps,
|
|
468
561
|
o: { project?: string; timeoutMs: number },
|
|
469
562
|
): Promise<void> {
|
|
470
|
-
|
|
471
|
-
// configured project, so the drain counts every project's workers and the
|
|
472
|
-
// pause covers all of them.
|
|
473
|
-
const scope = resolveScope(deps, "restart", o.project);
|
|
474
|
-
const initial = deps.layers(scope.pauseKey);
|
|
475
|
-
if (!initial.paused) deps.setPaused(true, scope.pauseKey);
|
|
476
|
-
// The lock must be provable NOW. A pause that cannot be read as an instance
|
|
477
|
-
// — sentinel malformed or unreadable — would make the whole fence fail open
|
|
478
|
-
// (nothing to compare against), so the transaction refuses before waiting
|
|
479
|
-
// on anything it cannot act upon either way.
|
|
480
|
-
const pauseToken = deps.pauseState(scope.pauseKey);
|
|
481
|
-
if (pauseToken === undefined) {
|
|
482
|
-
throw new Error(
|
|
483
|
-
"restart cancelled: cannot prove the restart-owned pause — the active pause sentinel is unreadable or malformed; nothing was restarted",
|
|
484
|
-
);
|
|
485
|
-
}
|
|
486
|
-
const begun: RestartBegun = {
|
|
487
|
-
pauseToken,
|
|
488
|
-
daemon: deps.daemonIdentity(),
|
|
489
|
-
};
|
|
490
|
-
const stale = () => restartFenceProblem(deps, scope, begun);
|
|
491
|
-
await waitForDrain(deps, scope, Date.now() + o.timeoutMs, stale);
|
|
492
|
-
// The drain completed; the world may have moved on while it did. Re-prove
|
|
493
|
-
// the pause and the generation an instant before the destructive call.
|
|
494
|
-
const cancelled = stale();
|
|
495
|
-
if (cancelled !== undefined) throw new Error(cancelled);
|
|
563
|
+
const { scope, initialPaused } = await pauseAndDrain(deps, "restart", o);
|
|
496
564
|
await deps.restartDaemon();
|
|
497
|
-
if (!
|
|
565
|
+
if (!initialPaused) deps.setPaused(false, scope.pauseKey);
|
|
498
566
|
}
|
|
499
567
|
|
|
500
568
|
function recoveryProblem(
|
|
@@ -576,7 +644,10 @@ async function upgradeBriefs(deps: UpgradeDeps, briefs: readonly ScopedBrief[]):
|
|
|
576
644
|
}
|
|
577
645
|
}
|
|
578
646
|
|
|
579
|
-
|
|
647
|
+
/** Restore every surface the upgrade touched, in reverse, from the identities
|
|
648
|
+
* captured before the first install. Exported so the detached rollback unit
|
|
649
|
+
* can restore what the detached installer recorded (#486). */
|
|
650
|
+
export async function rollbackUpgrade(
|
|
580
651
|
deps: UpgradeDeps,
|
|
581
652
|
previous: InstalledSurfaces,
|
|
582
653
|
scope: UpgradeScope,
|
|
@@ -736,6 +807,10 @@ export async function upgradeConductor(
|
|
|
736
807
|
overrides: Partial<UpgradeDeps> = {},
|
|
737
808
|
): Promise<UpgradeResult> {
|
|
738
809
|
const deps: UpgradeDeps = { ...DEFAULT_DEPS, ...overrides };
|
|
810
|
+
const detached = options.detached === true;
|
|
811
|
+
const journal = (entry: Omit<UpgradeJournalEntry, "at">, at = new Date()): void => {
|
|
812
|
+
deps.journal?.({ ...entry, at: at.toISOString() });
|
|
813
|
+
};
|
|
739
814
|
if (deps.env["HERDR_ENV"] !== undefined) {
|
|
740
815
|
throw new Error("run omp-conductor upgrade from a shell outside the target Herdr session");
|
|
741
816
|
}
|
|
@@ -748,6 +823,18 @@ export async function upgradeConductor(
|
|
|
748
823
|
const release = parseRegistry(
|
|
749
824
|
(await mustRun(deps, "npm", ["view", requested, "version", "gitHead", "--json"])).stdout,
|
|
750
825
|
);
|
|
826
|
+
if (detached) {
|
|
827
|
+
// The durable request: written before anything can pause, so a unit that
|
|
828
|
+
// dies after this line leaves a journal the returning process can act on
|
|
829
|
+
// (roll back, resume, or report) instead of a paused fleet with no record.
|
|
830
|
+
journal({
|
|
831
|
+
kind: "request",
|
|
832
|
+
ok: true,
|
|
833
|
+
version: release.version,
|
|
834
|
+
gitHead: release.gitHead,
|
|
835
|
+
unit: deps.env["OMP_CONDUCTOR_UNIT"],
|
|
836
|
+
});
|
|
837
|
+
}
|
|
751
838
|
const initial = deps.layers(scope.pauseKey);
|
|
752
839
|
const surfaces = await inspectSurfaces(deps);
|
|
753
840
|
const briefs: ScopedBrief[] = scope.selectors.map((selector) => ({
|
|
@@ -756,6 +843,15 @@ export async function upgradeConductor(
|
|
|
756
843
|
}));
|
|
757
844
|
const installNeeded = !surfacesCurrent(surfaces, release.version, release.gitHead);
|
|
758
845
|
if (!installNeeded && briefs.every((b) => b.current)) {
|
|
846
|
+
if (detached) {
|
|
847
|
+
journal({
|
|
848
|
+
kind: "outcome",
|
|
849
|
+
phase: "already-current",
|
|
850
|
+
ok: true,
|
|
851
|
+
version: release.version,
|
|
852
|
+
detail: "all three surfaces and every brief were already pinned to the target release",
|
|
853
|
+
});
|
|
854
|
+
}
|
|
759
855
|
return {
|
|
760
856
|
previousVersion: surfaces.cliVersion,
|
|
761
857
|
...release,
|
|
@@ -796,20 +892,51 @@ export async function upgradeConductor(
|
|
|
796
892
|
}
|
|
797
893
|
if (initial.herdr === "unknown") throw new Error("cannot determine whether herdr-fleet.service is active");
|
|
798
894
|
|
|
895
|
+
if (detached) {
|
|
896
|
+
// The durable record the returning process rolls back or verifies against:
|
|
897
|
+
// what was installed, what dispatch state the fleet began in, which pause
|
|
898
|
+
// sentinel this transaction owns, and the config snapshot for the restore.
|
|
899
|
+
journal({
|
|
900
|
+
kind: "snapshot",
|
|
901
|
+
ok: true,
|
|
902
|
+
version: release.version,
|
|
903
|
+
gitHead: release.gitHead,
|
|
904
|
+
unit: deps.env["OMP_CONDUCTOR_UNIT"],
|
|
905
|
+
previous: {
|
|
906
|
+
cliVersion: surfaces.cliVersion,
|
|
907
|
+
ompVersion: surfaces.ompVersion,
|
|
908
|
+
herdrSource: surfaces.herdrSource,
|
|
909
|
+
},
|
|
910
|
+
...(preUpgradeBackup === undefined ? {} : { configBackup: preUpgradeBackup }),
|
|
911
|
+
pauseKey: scope.pauseKey,
|
|
912
|
+
initialPaused: initial.paused,
|
|
913
|
+
initial: {
|
|
914
|
+
dispatch: initial.dispatch,
|
|
915
|
+
ticks: initial.ticks,
|
|
916
|
+
pane: initial.pane,
|
|
917
|
+
herdr: initial.herdr,
|
|
918
|
+
},
|
|
919
|
+
selectors: scope.selectors.map((s) => s ?? null),
|
|
920
|
+
});
|
|
921
|
+
}
|
|
922
|
+
|
|
799
923
|
deps.log(`target release: omp-conductor@${release.version} (${release.gitHead})`);
|
|
800
924
|
if (!initial.paused) {
|
|
801
925
|
deps.log("safety: pausing new issue claims");
|
|
802
926
|
deps.setPaused(true, scope.pauseKey);
|
|
803
927
|
}
|
|
928
|
+
journal({ kind: "phase", phase: "paused", surface: "dispatch", ok: true, version: release.version });
|
|
804
929
|
deps.log("drain: waiting for live omp worker sessions");
|
|
805
930
|
try {
|
|
806
931
|
await waitForDrain(deps, scope);
|
|
807
932
|
} catch (err) {
|
|
933
|
+
journal({ kind: "phase", phase: "drain", ok: false, version: release.version, detail: err instanceof Error ? err.message : String(err) });
|
|
808
934
|
const failure = err instanceof Error ? err.message : String(err);
|
|
809
935
|
throw new Error(
|
|
810
936
|
`upgrade failed while draining workers: ${failure}; no installation started; dispatch remains paused`,
|
|
811
937
|
);
|
|
812
938
|
}
|
|
939
|
+
journal({ kind: "phase", phase: "drain", ok: true, version: release.version });
|
|
813
940
|
deps.log("drain: complete");
|
|
814
941
|
|
|
815
942
|
let installTouched = false;
|
|
@@ -821,8 +948,10 @@ export async function upgradeConductor(
|
|
|
821
948
|
installTouched = true;
|
|
822
949
|
deps.log(`install 1/3: Bun-global omp-conductor CLI → ${release.version}`);
|
|
823
950
|
await mustRun(deps, "bun", ["add", "-g", `${PACKAGE}@${release.version}`]);
|
|
951
|
+
journal({ kind: "phase", phase: "install", surface: "cli", ok: true, version: release.version, gitHead: release.gitHead });
|
|
824
952
|
deps.log(`install 2/3: omp plugin omp-conductor → ${release.version}`);
|
|
825
953
|
await mustRun(deps, "omp", ["plugin", "install", `${PACKAGE}@${release.version}`]);
|
|
954
|
+
journal({ kind: "phase", phase: "install", surface: "omp", ok: true, version: release.version, gitHead: release.gitHead });
|
|
826
955
|
if (surfaces.herdrSource?.startsWith("local:")) {
|
|
827
956
|
await mustRun(deps, "herdr", ["plugin", "unlink", HERDR_PLUGIN]);
|
|
828
957
|
}
|
|
@@ -835,6 +964,7 @@ export async function upgradeConductor(
|
|
|
835
964
|
release.gitHead,
|
|
836
965
|
"--yes",
|
|
837
966
|
]);
|
|
967
|
+
journal({ kind: "phase", phase: "install", surface: "herdr", ok: true, version: release.version, gitHead: release.gitHead });
|
|
838
968
|
} else {
|
|
839
969
|
deps.log("install: CLI, omp plugin, and Herdr plugin already pinned to the target release");
|
|
840
970
|
}
|
|
@@ -842,16 +972,40 @@ export async function upgradeConductor(
|
|
|
842
972
|
deps.log("brief: refreshing managed ORCHESTRATOR.md floor while preserving POLICY.md");
|
|
843
973
|
briefChanged = true;
|
|
844
974
|
await upgradeBriefs(deps, briefs);
|
|
975
|
+
journal({ kind: "phase", phase: "brief", surface: "brief", ok: true, version: release.version });
|
|
845
976
|
|
|
846
977
|
if (initial.herdr === "active") {
|
|
847
978
|
herdrReloadStarted = true;
|
|
848
979
|
deps.log("reload: restarting herdr-fleet.service and recovering the orchestrator pane");
|
|
849
980
|
await mustRun(deps, "systemctl", ["restart", HERDR_UNIT]);
|
|
981
|
+
journal({ kind: "phase", phase: "restart", surface: "herdr", ok: true, version: release.version });
|
|
850
982
|
}
|
|
851
983
|
if (initial.dispatch !== "stopped") {
|
|
852
984
|
daemonReloadStarted = true;
|
|
853
985
|
deps.log("reload: restarting the omp-conductor dispatch daemon");
|
|
854
986
|
await deps.restartDaemon();
|
|
987
|
+
journal({ kind: "phase", phase: "restart", surface: "daemon", ok: true, version: release.version });
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
if (detached) {
|
|
991
|
+
// The installing process does not declare its own success: it journals
|
|
992
|
+
// that the transaction reached the reloads and stops, leaving dispatch
|
|
993
|
+
// paused exactly where it was. The first tick of the post-restart daemon
|
|
994
|
+
// verifies against this journal and is the only witness that closes the
|
|
995
|
+
// transaction (#486).
|
|
996
|
+
journal({
|
|
997
|
+
kind: "phase",
|
|
998
|
+
phase: "awaiting-verification",
|
|
999
|
+
ok: true,
|
|
1000
|
+
version: release.version,
|
|
1001
|
+
detail: "installed; the first tick after the restart verifies and restores dispatch",
|
|
1002
|
+
});
|
|
1003
|
+
return {
|
|
1004
|
+
previousVersion: surfaces.cliVersion,
|
|
1005
|
+
...release,
|
|
1006
|
+
alreadyCurrent: false,
|
|
1007
|
+
dispatch: initial.dispatch,
|
|
1008
|
+
};
|
|
855
1009
|
}
|
|
856
1010
|
|
|
857
1011
|
deps.log("verify 1/2: package identities, services, pane, ticks, and paused dispatch");
|
|
@@ -885,10 +1039,21 @@ export async function upgradeConductor(
|
|
|
885
1039
|
deps.setPaused(true, scope.pauseKey);
|
|
886
1040
|
}
|
|
887
1041
|
const failure = err instanceof Error ? err.message : String(err);
|
|
1042
|
+
journal({
|
|
1043
|
+
kind: "phase",
|
|
1044
|
+
phase: "failed",
|
|
1045
|
+
ok: false,
|
|
1046
|
+
version: release.version,
|
|
1047
|
+
detail: failure,
|
|
1048
|
+
});
|
|
888
1049
|
deps.log(`upgrade failed: ${failure}`);
|
|
1050
|
+
const journalLog = (message: string): void => {
|
|
1051
|
+
journal({ kind: "phase", phase: "rollback", ok: true, version: release.version, detail: message });
|
|
1052
|
+
deps.log(message);
|
|
1053
|
+
};
|
|
889
1054
|
try {
|
|
890
1055
|
await rollbackUpgrade(
|
|
891
|
-
deps,
|
|
1056
|
+
{ ...deps, log: journalLog },
|
|
892
1057
|
surfaces,
|
|
893
1058
|
scope,
|
|
894
1059
|
installTouched,
|
|
@@ -898,8 +1063,35 @@ export async function upgradeConductor(
|
|
|
898
1063
|
configBefore,
|
|
899
1064
|
preUpgradeBackup,
|
|
900
1065
|
);
|
|
1066
|
+
journal({ kind: "outcome", phase: "rolled-back", ok: false, version: release.version, detail: failure });
|
|
1067
|
+
enqueueUpgradeReport(
|
|
1068
|
+
"tier2",
|
|
1069
|
+
reportProject(scope.selectors),
|
|
1070
|
+
`fleet upgrade to omp-conductor@${release.version} failed and was rolled back`,
|
|
1071
|
+
[
|
|
1072
|
+
`Reason: ${failure}`,
|
|
1073
|
+
`Restored: cli=${surfaces.cliVersion}, omp=${surfaces.ompVersion ?? "missing"}, ` +
|
|
1074
|
+
`herdr=${surfaces.herdrSource ?? "missing"}`,
|
|
1075
|
+
...(preUpgradeBackup === undefined ? [] : [`Pre-upgrade config snapshot: ${preUpgradeBackup}`]),
|
|
1076
|
+
"Dispatch stays paused until the first tick after the restart records the rollback verified.",
|
|
1077
|
+
],
|
|
1078
|
+
`upgrade:${release.version}:rolled-back`,
|
|
1079
|
+
);
|
|
901
1080
|
} catch (rollbackErr) {
|
|
902
1081
|
const rollback = rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr);
|
|
1082
|
+
journal({ kind: "outcome", phase: "rollback-failed", ok: false, version: release.version, detail: rollback });
|
|
1083
|
+
enqueueUpgradeReport(
|
|
1084
|
+
"tier2",
|
|
1085
|
+
reportProject(scope.selectors),
|
|
1086
|
+
`upgrade to omp-conductor@${release.version} failed and ITS ROLLBACK ALSO FAILED`,
|
|
1087
|
+
[
|
|
1088
|
+
`Failure: ${failure}`,
|
|
1089
|
+
`Rollback failure: ${rollback}`,
|
|
1090
|
+
"The fleet may be at mixed versions; do not resume dispatch.",
|
|
1091
|
+
"Restore manually from the journal at " + upgradeJournalPath(stateDir()) + ".",
|
|
1092
|
+
],
|
|
1093
|
+
`upgrade:${release.version}:rollback-failed`,
|
|
1094
|
+
);
|
|
903
1095
|
throw new Error(
|
|
904
1096
|
`upgrade failed: ${failure}; rollback incomplete: ${rollback}; dispatch remains paused`,
|
|
905
1097
|
);
|
|
@@ -914,3 +1106,138 @@ export async function upgradeConductor(
|
|
|
914
1106
|
dispatch: initial.dispatch,
|
|
915
1107
|
};
|
|
916
1108
|
}
|
|
1109
|
+
|
|
1110
|
+
// ---------------------------------------------------------------------------
|
|
1111
|
+
// The detached fleet installer (#486)
|
|
1112
|
+
// ---------------------------------------------------------------------------
|
|
1113
|
+
|
|
1114
|
+
/**
|
|
1115
|
+
* Run the upgrade transaction as the detached fleet installer: `upgrade`'s
|
|
1116
|
+
* engine with a journal wired to the state-dir journal and the in-process
|
|
1117
|
+
* verification handed to the first tick after the restart. This is the
|
|
1118
|
+
* `upgrade-install` command the transient unit runs — the one process a
|
|
1119
|
+
* `systemctl restart herdr-fleet.service` and a daemon restart both pass by.
|
|
1120
|
+
*/
|
|
1121
|
+
export async function runDetachedUpgrade(options: UpgradeOptions = {}): Promise<UpgradeResult> {
|
|
1122
|
+
return upgradeConductor(
|
|
1123
|
+
{ ...options, detached: true },
|
|
1124
|
+
{
|
|
1125
|
+
journal: (entry) => appendJournal(stateDir(), entry),
|
|
1126
|
+
},
|
|
1127
|
+
);
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
// ---------------------------------------------------------------------------
|
|
1131
|
+
// The detached rollback unit (#486)
|
|
1132
|
+
// ---------------------------------------------------------------------------
|
|
1133
|
+
|
|
1134
|
+
export interface RollbackFromJournalResult {
|
|
1135
|
+
/** The version the rollback restored the fleet to. */
|
|
1136
|
+
restoredVersion: string;
|
|
1137
|
+
/** The version the failed upgrade had targeted. */
|
|
1138
|
+
failedVersion: string;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
/**
|
|
1142
|
+
* Restore every surface the failed upgrade touched, from the journal the
|
|
1143
|
+
* detached installer wrote. This is the `upgrade-rollback` command the
|
|
1144
|
+
* verifier's transient unit runs — the same trust boundary as the install:
|
|
1145
|
+
* the process that rolls back is not the process that failed, and it acts
|
|
1146
|
+
* only on identities the journal recorded before the first install touched
|
|
1147
|
+
* anything.
|
|
1148
|
+
*
|
|
1149
|
+
* Scoped to the newest request's progression: an older, already-closed
|
|
1150
|
+
* upgrade in the same append-only journal neither supplies the snapshot to
|
|
1151
|
+
* restore nor the phases that say which surfaces were touched, and its
|
|
1152
|
+
* terminal outcome cannot refuse this rollback.
|
|
1153
|
+
*/
|
|
1154
|
+
export async function rollbackFromJournal(
|
|
1155
|
+
overrides: Partial<UpgradeDeps> = {},
|
|
1156
|
+
root = stateDir(),
|
|
1157
|
+
): Promise<RollbackFromJournalResult> {
|
|
1158
|
+
const deps: UpgradeDeps = { ...DEFAULT_DEPS, ...overrides };
|
|
1159
|
+
const entries = readUpgradeJournal(root);
|
|
1160
|
+
const progression = requestProgression(entries);
|
|
1161
|
+
const request = pendingUpgradeRequest(entries);
|
|
1162
|
+
if (progression === undefined || request === undefined) {
|
|
1163
|
+
throw new Error("no upgrade request is journaled; nothing to roll back");
|
|
1164
|
+
}
|
|
1165
|
+
const snapshot = progression.find((entry) => entry.kind === "snapshot");
|
|
1166
|
+
if (snapshot === undefined || snapshot.previous === undefined) {
|
|
1167
|
+
throw new Error(
|
|
1168
|
+
`upgrade ${request.version} has no durable pre-install snapshot; refusing to guess what to restore`,
|
|
1169
|
+
);
|
|
1170
|
+
}
|
|
1171
|
+
const newestOutcome = [...progression].reverse().find((entry) => entry.kind === "outcome");
|
|
1172
|
+
if (newestOutcome !== undefined && newestOutcome.phase !== "rollback-requested") {
|
|
1173
|
+
// Already terminal (verified, rolled back in-process, aborted). A second
|
|
1174
|
+
// rollback would fight the newer outcome.
|
|
1175
|
+
throw new Error(
|
|
1176
|
+
`upgrade ${request.version} already closed as "${newestOutcome.phase}"; nothing to roll back`,
|
|
1177
|
+
);
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
const phases = progression.filter((entry) => entry.kind === "phase");
|
|
1181
|
+
const scope: UpgradeScope = {
|
|
1182
|
+
selectors: request.selectors,
|
|
1183
|
+
pauseKey: request.pauseKey,
|
|
1184
|
+
};
|
|
1185
|
+
const previous: InstalledSurfaces = {
|
|
1186
|
+
cliVersion: snapshot.previous.cliVersion,
|
|
1187
|
+
ompVersion: snapshot.previous.ompVersion,
|
|
1188
|
+
herdrSource: snapshot.previous.herdrSource,
|
|
1189
|
+
};
|
|
1190
|
+
let configBefore: string | undefined;
|
|
1191
|
+
if (request.configBackup !== undefined && existsSync(request.configBackup)) {
|
|
1192
|
+
try {
|
|
1193
|
+
configBefore = readFileSync(request.configBackup, "utf8");
|
|
1194
|
+
} catch {
|
|
1195
|
+
configBefore = undefined;
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
const journalLog = (message: string): void => {
|
|
1199
|
+
appendJournal(root, {
|
|
1200
|
+
at: new Date().toISOString(),
|
|
1201
|
+
kind: "phase",
|
|
1202
|
+
phase: "rollback",
|
|
1203
|
+
ok: true,
|
|
1204
|
+
version: request.version,
|
|
1205
|
+
detail: message,
|
|
1206
|
+
});
|
|
1207
|
+
process.stdout.write(`${message}\n`);
|
|
1208
|
+
};
|
|
1209
|
+
await rollbackUpgrade(
|
|
1210
|
+
{ ...deps, log: journalLog },
|
|
1211
|
+
previous,
|
|
1212
|
+
scope,
|
|
1213
|
+
phases.some((entry) => entry.phase === "install"),
|
|
1214
|
+
phases.some((entry) => entry.phase === "brief"),
|
|
1215
|
+
phases.some((entry) => entry.phase === "restart" && entry.surface === "herdr"),
|
|
1216
|
+
phases.some((entry) => entry.phase === "restart" && entry.surface === "daemon"),
|
|
1217
|
+
configBefore,
|
|
1218
|
+
request.configBackup,
|
|
1219
|
+
);
|
|
1220
|
+
|
|
1221
|
+
const restoredVersion = previous.cliVersion;
|
|
1222
|
+
appendJournal(root, {
|
|
1223
|
+
at: new Date().toISOString(),
|
|
1224
|
+
kind: "outcome",
|
|
1225
|
+
phase: "rolled-back",
|
|
1226
|
+
ok: false,
|
|
1227
|
+
version: request.version,
|
|
1228
|
+
detail: `restored ${previous.cliVersion} from the pre-install snapshot`,
|
|
1229
|
+
});
|
|
1230
|
+
enqueueUpgradeReport(
|
|
1231
|
+
"tier2",
|
|
1232
|
+
reportProject(request.selectors),
|
|
1233
|
+
`fleet restored to omp-conductor@${restoredVersion} after the failed upgrade to ${request.version}`,
|
|
1234
|
+
[
|
|
1235
|
+
"The transient rollback unit restored every surface from the durable snapshot;",
|
|
1236
|
+
"dispatch stays paused until the first tick after these restarts verifies the old version.",
|
|
1237
|
+
`Pre-upgrade config snapshot: ${request.configBackup ?? "none recorded"}`,
|
|
1238
|
+
],
|
|
1239
|
+
`upgrade:${request.version}:rolled-back`,
|
|
1240
|
+
);
|
|
1241
|
+
return { restoredVersion, failedVersion: request.version };
|
|
1242
|
+
}
|
|
1243
|
+
|