omp-conductor 0.19.3 → 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/README.md +4 -3
- package/package.json +1 -1
- package/src/commands/resume.ts +5 -0
- package/src/daemon.ts +122 -6
- package/src/dashboard/controls.ts +3 -0
- package/src/fleet.ts +11 -5
- package/src/settlement.ts +24 -10
- package/src/status-render.ts +11 -0
- package/src/store.ts +1 -1
- package/src/upgrade-journal.ts +2 -0
- package/src/upgrade-verify.ts +246 -32
- package/src/upgrade.ts +23 -1
package/README.md
CHANGED
|
@@ -386,9 +386,10 @@ message on stderr) on every uncertainty:
|
|
|
386
386
|
- the tick config does not parse — the agent name would be a guess
|
|
387
387
|
- `herdr agent list` is unreachable, prints nothing, or prints output with no
|
|
388
388
|
explicit `agents` array; only a real `agents: []` means "no agents"
|
|
389
|
-
- an agent row is unreadable — a missing `
|
|
390
|
-
present with a non-string value. An
|
|
391
|
-
|
|
389
|
+
- an agent row is unreadable — a missing `pane_id`, a non-string non-null
|
|
390
|
+
`name`, or an `agent` field present with a non-string value. An absent or
|
|
391
|
+
`null` `name` is an unnamed pane; an absent or `null` `agent` is the sticky
|
|
392
|
+
claim herdr reports after the agent exits, and both stay normal answers
|
|
392
393
|
- the configured agent name is not unique, or the claimed pane runs some other agent
|
|
393
394
|
- `pane process-info` fails, or the claim is live `omp` but names no recognizable
|
|
394
395
|
omp foreground PID — "cannot see it" is never reported as "it is stopped"
|
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";
|
|
@@ -116,11 +116,13 @@ import {
|
|
|
116
116
|
import {
|
|
117
117
|
adoptSalvagedPrs,
|
|
118
118
|
classifyAndRecover,
|
|
119
|
+
harnessLogDir,
|
|
119
120
|
collectSettlementFlags,
|
|
120
121
|
formatQuarantinedRuns,
|
|
121
122
|
formatSalvagedRuns,
|
|
122
123
|
reactToProviderCredit,
|
|
123
124
|
readSessionError,
|
|
125
|
+
readTerminalEvidence,
|
|
124
126
|
reconcileOrphanedRuns,
|
|
125
127
|
reconcileGroomingClosures,
|
|
126
128
|
reconcileStaleLabels,
|
|
@@ -133,6 +135,7 @@ import { materializeOmpSettings } from "./omp-settings.ts";
|
|
|
133
135
|
import { evaluateDecisionConditions, probeNpmVersion, probeRateLimitReset } from "./decisions.ts";
|
|
134
136
|
import {
|
|
135
137
|
infraLogSignature,
|
|
138
|
+
classifyRun,
|
|
136
139
|
infraSignatureVersion,
|
|
137
140
|
providerCreditRefusal,
|
|
138
141
|
providerTransientFault,
|
|
@@ -333,6 +336,8 @@ interface Deps {
|
|
|
333
336
|
workerControls: WorkerControlRegistry;
|
|
334
337
|
/** Session seam for lifecycle integration tests; production uses the real harness. */
|
|
335
338
|
workerDeps?: RunWorkerDeps;
|
|
339
|
+
/** Harness per-process logs used to classify terminal worker routes. */
|
|
340
|
+
harnessLogDir?: string;
|
|
336
341
|
integrity: IntegrityGate;
|
|
337
342
|
stall: StallGate;
|
|
338
343
|
/**
|
|
@@ -3959,7 +3964,10 @@ export async function handleReviewRevision(d: Deps, revision: ReviewRevisionReco
|
|
|
3959
3964
|
if (await settleStopBeforeSession()) return;
|
|
3960
3965
|
if (await settleDrainBeforeSession()) return;
|
|
3961
3966
|
|
|
3962
|
-
|
|
3967
|
+
// A review revision is a new worker process on the same run row. Clear the
|
|
3968
|
+
// prior process evidence before launch so a failed wake cannot inherit an
|
|
3969
|
+
// earlier round's harness route.
|
|
3970
|
+
store.updateRun(runId, { worktree: worktreePath, workerPid: null, terminalEvidence: null });
|
|
3963
3971
|
|
|
3964
3972
|
// The row's own counters are cumulative across the revision: the revision
|
|
3965
3973
|
// worker meters its own session from zero, so its deltas are added to the
|
|
@@ -3972,6 +3980,7 @@ export async function handleReviewRevision(d: Deps, revision: ReviewRevisionReco
|
|
|
3972
3980
|
log(`#${issue} review round ${revision.round} → resuming ${branch} (${priorSessionFile})`);
|
|
3973
3981
|
|
|
3974
3982
|
|
|
3983
|
+
let workerStartedAt: number | undefined;
|
|
3975
3984
|
let result: WorkerResult;
|
|
3976
3985
|
try {
|
|
3977
3986
|
const runAllowanceUsd = runSpendAllowanceUsd(caps);
|
|
@@ -3997,7 +4006,9 @@ export async function handleReviewRevision(d: Deps, revision: ReviewRevisionReco
|
|
|
3997
4006
|
socketPath: join(sessionDir, "ipc.sock"),
|
|
3998
4007
|
verbSocketPath: verbListener.path,
|
|
3999
4008
|
onSpawn: (pid) => {
|
|
4009
|
+
workerStartedAt = Date.now();
|
|
4000
4010
|
verbListener?.bindPid(pid);
|
|
4011
|
+
store.updateRun(runId, { workerPid: pid });
|
|
4001
4012
|
},
|
|
4002
4013
|
onChildLog: (line) => {
|
|
4003
4014
|
log(`#${issue} ${line}`);
|
|
@@ -4092,6 +4103,18 @@ export async function handleReviewRevision(d: Deps, revision: ReviewRevisionReco
|
|
|
4092
4103
|
...(verified.reason === undefined ? [] : ["", verified.reason]),
|
|
4093
4104
|
result.report,
|
|
4094
4105
|
].join("\n");
|
|
4106
|
+
const latestRun = store.getRun(runId) ?? run;
|
|
4107
|
+
const terminalEvidence =
|
|
4108
|
+
state === "failed" || state === "killed"
|
|
4109
|
+
? readTerminalEvidence(
|
|
4110
|
+
{
|
|
4111
|
+
sessionFile: result.sessionFile,
|
|
4112
|
+
startedAt: workerStartedAt ?? latestRun.startedAt,
|
|
4113
|
+
...(latestRun.workerPid === undefined ? {} : { workerPid: latestRun.workerPid }),
|
|
4114
|
+
},
|
|
4115
|
+
d.harnessLogDir ?? harnessLogDir(),
|
|
4116
|
+
)
|
|
4117
|
+
: undefined;
|
|
4095
4118
|
|
|
4096
4119
|
const terminalPatch: Partial<RunRecord> = {
|
|
4097
4120
|
endedAt: Date.now(),
|
|
@@ -4112,9 +4135,76 @@ export async function handleReviewRevision(d: Deps, revision: ReviewRevisionReco
|
|
|
4112
4135
|
...(result.headSha === undefined ? {} : { headSha: result.headSha }),
|
|
4113
4136
|
sessionFile: result.sessionFile,
|
|
4114
4137
|
...(result.graphTools === undefined ? {} : { graphTools: result.graphTools }),
|
|
4138
|
+
...(terminalEvidence === undefined ? {} : { terminalEvidence }),
|
|
4115
4139
|
report: finalReport,
|
|
4116
4140
|
...settlement?.patch,
|
|
4117
4141
|
};
|
|
4142
|
+
const terminalClassification =
|
|
4143
|
+
terminalEvidence === undefined
|
|
4144
|
+
? undefined
|
|
4145
|
+
: classifyRun(
|
|
4146
|
+
{
|
|
4147
|
+
...latestRun,
|
|
4148
|
+
...terminalPatch,
|
|
4149
|
+
state,
|
|
4150
|
+
terminalEvidence,
|
|
4151
|
+
},
|
|
4152
|
+
{},
|
|
4153
|
+
caps,
|
|
4154
|
+
);
|
|
4155
|
+
if (terminalClassification?.cls === "model-empty-stop") {
|
|
4156
|
+
const recoveredAt = Date.now();
|
|
4157
|
+
store.updateRun(runId, {
|
|
4158
|
+
...terminalPatch,
|
|
4159
|
+
state: "failed",
|
|
4160
|
+
failureClass: terminalClassification.cls,
|
|
4161
|
+
recoveryAction: terminalClassification.recovery,
|
|
4162
|
+
recoveredAt,
|
|
4163
|
+
});
|
|
4164
|
+
const continuations = store.continuationsFor(project.name, issue);
|
|
4165
|
+
if (hasContinuationBudget(continuations, caps.maxContinuationsPerIssue)) {
|
|
4166
|
+
// Keep the same review row, round, findings, session, branch and PR.
|
|
4167
|
+
// Restore the exact pushed-green origin state while moving this stop
|
|
4168
|
+
// into the explicit continuation counter; the next claim must not
|
|
4169
|
+
// depend on re-verifying a failed-state origin at an unchanged head.
|
|
4170
|
+
const chargedRun = store.getRun(runId) ?? latestRun;
|
|
4171
|
+
store.updateRun(runId, {
|
|
4172
|
+
...terminalPatch,
|
|
4173
|
+
state: "pushed-green",
|
|
4174
|
+
failureClass: null,
|
|
4175
|
+
recoveryAction: null,
|
|
4176
|
+
recoveredAt: null,
|
|
4177
|
+
continuationCharges: (chargedRun.continuationCharges ?? 0) + 1,
|
|
4178
|
+
workerPid: null,
|
|
4179
|
+
lastError: terminalClassification.evidence,
|
|
4180
|
+
});
|
|
4181
|
+
store.requeueReviewRevision(revision.id);
|
|
4182
|
+
log(
|
|
4183
|
+
`#${issue} review round ${revision.round} re-queued after a provider empty-stop ` +
|
|
4184
|
+
`(${continuations}/${caps.maxContinuationsPerIssue} continuations): ${terminalClassification.evidence}`,
|
|
4185
|
+
);
|
|
4186
|
+
return;
|
|
4187
|
+
}
|
|
4188
|
+
|
|
4189
|
+
store.settleReviewRevision(revision.id, "failed", recoveredAt);
|
|
4190
|
+
swapLabel(store, project.name, issue, inProgress, project.stateLabels.failed);
|
|
4191
|
+
const model = result.model ?? latestRun.resolvedModel ?? latestRun.model ?? "configured model";
|
|
4192
|
+
const provider = result.provider ?? latestRun.resolvedProvider ?? "provider";
|
|
4193
|
+
await safeEscalate(d, {
|
|
4194
|
+
tier: 1,
|
|
4195
|
+
project: project.name,
|
|
4196
|
+
issue,
|
|
4197
|
+
runId,
|
|
4198
|
+
summary: `#${issue} review round ${revision.round}: the model kept returning empty responses`,
|
|
4199
|
+
detail:
|
|
4200
|
+
`${provider}/${model} ended ${continuations} review session(s) with empty responses until ` +
|
|
4201
|
+
`the harness retry cap. The same review round was preserved while continuation budget remained; ` +
|
|
4202
|
+
`that budget is now exhausted at ${caps.maxContinuationsPerIssue}. No implementation failure or ` +
|
|
4203
|
+
`additional review round was charged.\n\n${terminalClassification.evidence}`,
|
|
4204
|
+
});
|
|
4205
|
+
log(`#${issue} review round ${revision.round} provider empty-stops exhausted the continuation budget`);
|
|
4206
|
+
return;
|
|
4207
|
+
}
|
|
4118
4208
|
// #903: an infrastructure kill spends no review round.
|
|
4119
4209
|
//
|
|
4120
4210
|
// A dispatch that dies before the resumed session takes a turn — a host
|
|
@@ -5205,6 +5295,7 @@ export function upgradeVerifyDepsFor(d: Pick<Deps, "project" | "store">): Upgrad
|
|
|
5205
5295
|
layers: (project) => fleetLayers(project),
|
|
5206
5296
|
health: healthCheck,
|
|
5207
5297
|
doctor: (name) => runDoctor(name),
|
|
5298
|
+
surfaces: () => inspectSurfaces({ run: runCommand, log, env: process.env }),
|
|
5208
5299
|
enqueue: (draft) => {
|
|
5209
5300
|
try {
|
|
5210
5301
|
d.store.enqueueReport(draft);
|
|
@@ -5214,6 +5305,21 @@ export function upgradeVerifyDepsFor(d: Pick<Deps, "project" | "store">): Upgrad
|
|
|
5214
5305
|
},
|
|
5215
5306
|
pause: pauseInstance,
|
|
5216
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
|
+
},
|
|
5217
5323
|
launchRollback: async (version) => {
|
|
5218
5324
|
const launched = await launchTransientUnit(
|
|
5219
5325
|
(argv) => runCommand(argv[0]!, argv.slice(1)),
|
|
@@ -5243,7 +5349,11 @@ async function verifyPendingUpgradeTick(
|
|
|
5243
5349
|
const verifyDeps = upgradeVerifyDepsFor(d);
|
|
5244
5350
|
try {
|
|
5245
5351
|
const outcome = await run(verifyDeps);
|
|
5246
|
-
if (
|
|
5352
|
+
if (
|
|
5353
|
+
outcome.handled !== "none" &&
|
|
5354
|
+
outcome.handled !== "already-closed" &&
|
|
5355
|
+
outcome.handled !== "recovery-owed"
|
|
5356
|
+
) {
|
|
5247
5357
|
log(
|
|
5248
5358
|
`upgrade journal handled: ${outcome.handled}` +
|
|
5249
5359
|
`${outcome.version === undefined ? "" : ` for omp-conductor@${outcome.version}`}` +
|
|
@@ -6390,6 +6500,8 @@ export interface StatusSnapshot {
|
|
|
6390
6500
|
* reading like a mistake (#220).
|
|
6391
6501
|
*/
|
|
6392
6502
|
pauseReason?: string;
|
|
6503
|
+
/** Unresolved host-wide upgrade recovery, rendered identically for every project. */
|
|
6504
|
+
upgradeRecovery?: UpgradeRecoveryStatus;
|
|
6393
6505
|
/**
|
|
6394
6506
|
* The project's active self-expiring drain (#484): present only while a
|
|
6395
6507
|
* fresh, valid drain record exists. Paused and drained are deliberately two
|
|
@@ -6584,9 +6696,12 @@ export function statusSnapshotFromStore(
|
|
|
6584
6696
|
const oldestLabelOpAt = store.oldestPendingLabelOpAt(p.name);
|
|
6585
6697
|
// Read once: `status` renders the degrade row off this while it is down.
|
|
6586
6698
|
const orchestratorDown = store.orchestratorIncident(p.name);
|
|
6587
|
-
//
|
|
6588
|
-
//
|
|
6589
|
-
|
|
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());
|
|
6590
6705
|
// Same cost discipline as the pause read: the drain record is a file read,
|
|
6591
6706
|
// and the active-drain view is only built when one is actually fresh. The
|
|
6592
6707
|
// read is observational — it never mutates the record — so a status read
|
|
@@ -6651,6 +6766,7 @@ export function statusSnapshotFromStore(
|
|
|
6651
6766
|
stateDir: stateDir(),
|
|
6652
6767
|
paused: isPaused(p.name),
|
|
6653
6768
|
...(reason === undefined ? {} : { pauseReason: reason }),
|
|
6769
|
+
...(upgradeRecovery === undefined ? {} : { upgradeRecovery }),
|
|
6654
6770
|
...(drain.kind === "active"
|
|
6655
6771
|
? {
|
|
6656
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/fleet.ts
CHANGED
|
@@ -815,7 +815,7 @@ export function startHerdrFleet(projectName?: string, deps: HerdrStartDeps = {})
|
|
|
815
815
|
}
|
|
816
816
|
|
|
817
817
|
export interface HerdrAgent {
|
|
818
|
-
name
|
|
818
|
+
name?: string;
|
|
819
819
|
paneId: string;
|
|
820
820
|
agent?: string;
|
|
821
821
|
sessionPath?: string;
|
|
@@ -1568,16 +1568,22 @@ export function parseHerdrAgentList(rawOutput: string): HerdrAgent[] {
|
|
|
1568
1568
|
const agent = row as { readonly [key: string]: unknown };
|
|
1569
1569
|
const name = agent["name"];
|
|
1570
1570
|
const paneId = agent["pane_id"];
|
|
1571
|
-
if (typeof
|
|
1571
|
+
if (typeof paneId !== "string") {
|
|
1572
|
+
throw new Error(
|
|
1573
|
+
"herdr agent list row is missing a string `pane_id` — cannot identify the pane",
|
|
1574
|
+
);
|
|
1575
|
+
}
|
|
1576
|
+
if (name !== undefined && name !== null && typeof name !== "string") {
|
|
1572
1577
|
throw new Error(
|
|
1573
|
-
|
|
1578
|
+
`herdr agent list row for pane ${paneId} has a non-string \`name\` (${typeof name}) — ` +
|
|
1574
1579
|
"cannot tell whether it is the conductor pane",
|
|
1575
1580
|
);
|
|
1576
1581
|
}
|
|
1582
|
+
const parsedName = typeof name === "string" ? name : undefined;
|
|
1577
1583
|
const rawAgent = agent["agent"];
|
|
1578
1584
|
if (rawAgent !== undefined && rawAgent !== null && typeof rawAgent !== "string") {
|
|
1579
1585
|
throw new Error(
|
|
1580
|
-
`herdr agent list row for ${
|
|
1586
|
+
`herdr agent list row for ${parsedName ?? `pane ${paneId}`} has a non-string \`agent\` (${typeof rawAgent}) — ` +
|
|
1581
1587
|
`cannot tell whether an agent is live`,
|
|
1582
1588
|
);
|
|
1583
1589
|
}
|
|
@@ -1591,7 +1597,7 @@ export function parseHerdrAgentList(rawOutput: string): HerdrAgent[] {
|
|
|
1591
1597
|
}
|
|
1592
1598
|
}
|
|
1593
1599
|
out.push({
|
|
1594
|
-
name,
|
|
1600
|
+
...(parsedName === undefined ? {} : { name: parsedName }),
|
|
1595
1601
|
paneId,
|
|
1596
1602
|
...(liveAgent === undefined ? {} : { agent: liveAgent }),
|
|
1597
1603
|
...(sessionPath === undefined ? {} : { sessionPath }),
|
package/src/settlement.ts
CHANGED
|
@@ -1287,10 +1287,10 @@ export function harnessLogDir(): string {
|
|
|
1287
1287
|
* per process. These name why a session ended when the row cannot: the run
|
|
1288
1288
|
* itself records no error, because from its side nothing failed (#986). */
|
|
1289
1289
|
const HARNESS_ROUTE_MARKERS = [
|
|
1290
|
-
"empty-stop-retry-cap",
|
|
1291
|
-
"empty-stop-handled",
|
|
1292
|
-
"bottom-checkCompaction",
|
|
1293
|
-
"context-overflow",
|
|
1290
|
+
{ marker: "empty-stop-retry-cap", terminal: true },
|
|
1291
|
+
{ marker: "empty-stop-handled", terminal: false },
|
|
1292
|
+
{ marker: "bottom-checkCompaction", terminal: false },
|
|
1293
|
+
{ marker: "context-overflow", terminal: true },
|
|
1294
1294
|
] as const;
|
|
1295
1295
|
|
|
1296
1296
|
/**
|
|
@@ -1373,13 +1373,27 @@ function readHarnessRoute(
|
|
|
1373
1373
|
} catch {
|
|
1374
1374
|
return undefined;
|
|
1375
1375
|
}
|
|
1376
|
-
//
|
|
1377
|
-
//
|
|
1376
|
+
// A terminal failure route remains the cause even when the harness performs
|
|
1377
|
+
// post-agent maintenance afterwards. The live #1001 logs record
|
|
1378
|
+
// `empty-stop-retry-cap` and then `bottom-checkCompaction` milliseconds later;
|
|
1379
|
+
// choosing the last marker hid the failure behind routine bookkeeping.
|
|
1378
1380
|
let found: string | undefined;
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
if (
|
|
1382
|
-
|
|
1381
|
+
let foundAt = -1;
|
|
1382
|
+
for (const route of HARNESS_ROUTE_MARKERS) {
|
|
1383
|
+
if (!route.terminal) continue;
|
|
1384
|
+
const at = text.lastIndexOf(route.marker);
|
|
1385
|
+
if (at > foundAt) {
|
|
1386
|
+
found = route.marker;
|
|
1387
|
+
foundAt = at;
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
if (found !== undefined) return `harness route ${found}`;
|
|
1391
|
+
for (const route of HARNESS_ROUTE_MARKERS) {
|
|
1392
|
+
const at = text.lastIndexOf(route.marker);
|
|
1393
|
+
if (at > foundAt) {
|
|
1394
|
+
found = route.marker;
|
|
1395
|
+
foundAt = at;
|
|
1396
|
+
}
|
|
1383
1397
|
}
|
|
1384
1398
|
return found === undefined ? undefined : `harness route ${found}`;
|
|
1385
1399
|
}
|
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/store.ts
CHANGED
|
@@ -3758,7 +3758,7 @@ export function openStore(dbPath: string): Store {
|
|
|
3758
3758
|
ELSE 0
|
|
3759
3759
|
END,
|
|
3760
3760
|
continuationCharges = COALESCE(continuationCharges, 0) + CASE
|
|
3761
|
-
WHEN state = 'failed' AND failureClass
|
|
3761
|
+
WHEN state = 'failed' AND failureClass IN ('returned-for-revision', 'model-empty-stop') THEN 1
|
|
3762
3762
|
WHEN state = 'killed' AND (failureClass IS NULL OR failureClass NOT IN (${CHARGEABLE_CONTINUATION_EXCLUSIONS})) THEN 1
|
|
3763
3763
|
ELSE 0
|
|
3764
3764
|
END
|
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, {
|