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 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 `name`/`pane_id`, or an `agent` field
390
- present with a non-string value. An *absent* or `null` `agent` is the sticky
391
- claim herdr reports after the agent exits, and stays a normal answer
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",
3
+ "version": "0.19.5",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "A 24/7 dispatcher that takes ready GitHub issues to green, mergeable PRs using omp coding sessions, with tiered escalation first to an orchestrator session and then to a human.",
@@ -13,10 +13,15 @@
13
13
 
14
14
  import type { CommandContext } from "./context.ts";
15
15
  import { armState, clearPaneHaltIfResolvable, releaseHold } from "../fleet.ts";
16
+ import { acknowledgeUpgradeRecovery } from "../upgrade-verify.ts";
16
17
  import { wakeDispatch } from "../wake.ts";
17
18
 
18
19
  export async function resumeCommand(ctx: CommandContext): Promise<void> {
19
20
  for (const project of ctx.targetProjects()) {
21
+ // `releaseHold(project)` also removes the host-global sentinel. Record an
22
+ // unresolved recovery acknowledgement first even when a project-local pause
23
+ // shadows that global fence; the helper is a no-op when nothing is owed.
24
+ acknowledgeUpgradeRecovery();
20
25
  releaseHold(project.name);
21
26
  const pin = clearPaneHaltIfResolvable(project.name);
22
27
  const arm = armState(project.name);
package/src/daemon.ts CHANGED
@@ -58,7 +58,7 @@ import {
58
58
  setPaused,
59
59
  } from "./pause.ts";
60
60
  import { appendJournal, launchTransientUnit, readUpgradeJournal } from "./upgrade-journal.ts";
61
- import { runCommand, verifyPendingUpgrade, type UpgradeVerifyDeps } from "./upgrade-verify.ts";
61
+ import { runCommand, upgradeRecoveryStatus, verifyPendingUpgrade, type UpgradeRecoveryStatus, type UpgradeVerifyDeps } from "./upgrade-verify.ts";
62
62
  import { inspectSurfaces, type InstalledSurfaces } from "./upgrade.ts";
63
63
  import { checkTelegramFreshness, type TelegramFreshness } from "./telegram-freshness.ts";
64
64
  import { processStartTimeMs } from "./upgrade-verify.ts";
@@ -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
- store.updateRun(runId, { worktree: worktreePath });
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 (outcome.handled !== "none" && outcome.handled !== "already-closed") {
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
- // Read once: the provenance read touches the filesystem, and the renderer
6588
- // should never pay for it twice per status.
6589
- const reason = pauseProvenance(p.name)?.reason;
6699
+ // The journal is the authoritative recovery lifecycle. Render unresolved
6700
+ // recovery even when an operator/project pause currently wins provenance,
6701
+ // or before the verifier has re-created a missing global fence.
6702
+ const pause = pauseProvenance(p.name);
6703
+ const reason = pause?.reason;
6704
+ const upgradeRecovery = upgradeRecoveryStatus(readUpgradeJournal(stateDir()), stateDir());
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: string;
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 name !== "string" || typeof paneId !== "string") {
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
- "herdr agent list row is missing a string `name`/`pane_id`" +
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 ${name} has a non-string \`agent\` (${typeof rawAgent}) — ` +
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
- // Last match wins: the terminal route is the one that ended the session, and
1377
- // `empty-stop-handled` appearing earlier is a retry that worked.
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
- for (const marker of HARNESS_ROUTE_MARKERS) {
1380
- const at = text.lastIndexOf(marker);
1381
- if (at === -1) continue;
1382
- if (found === undefined || at > text.lastIndexOf(found)) found = marker;
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
  }
@@ -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 = 'returned-for-revision' THEN 1
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
@@ -74,6 +74,8 @@ export interface UpgradeJournalEntry {
74
74
  pauseKey?: string;
75
75
  /** Whether dispatch was already paused when the request began. */
76
76
  initialPaused?: boolean;
77
+ /** Creation instant of the exact pause sentinel this transaction acquired. */
78
+ pauseSince?: number;
77
79
  /** The fleet layers the install began under, for delta verification. */
78
80
  initial?: JournalFleetState;
79
81
  /** The projects the install covers, in the form the upgrade engine uses. */
@@ -25,7 +25,7 @@ import { readFileSync } from "node:fs";
25
25
  import { loadConfig, stateDir } from "./config.ts";
26
26
  import { DEFAULT_PORT } from "./lifecycle.ts";
27
27
  import { dbPath, openStore } from "./store.ts";
28
- import { appendJournal, readUpgradeJournal, upgradeJournalPath, type UpgradeCheck, type UpgradeJournalEntry } from "./upgrade-journal.ts";
28
+ import { appendJournal, readUpgradeJournal, upgradeJournalPath, type JournalSurfaces, type UpgradeCheck, type UpgradeJournalEntry } from "./upgrade-journal.ts";
29
29
  import type { DoctorReport } from "./doctor.ts";
30
30
  import type { FleetLayers } from "./status-render.ts";
31
31
  import type { ReportDraft, ReportKind } from "./types.ts";
@@ -135,6 +135,17 @@ function newestRequestIndex(entries: readonly UpgradeJournalEntry[]): number {
135
135
  return -1;
136
136
  }
137
137
 
138
+ /** Newest terminal outcome in one request's progression, without copying it. */
139
+ function newestOutcome(
140
+ progression: readonly UpgradeJournalEntry[],
141
+ ): UpgradeJournalEntry | undefined {
142
+ for (let i = progression.length - 1; i >= 0; i--) {
143
+ const entry = progression[i]!;
144
+ if (entry.kind === "outcome") return entry;
145
+ }
146
+ return undefined;
147
+ }
148
+
138
149
  /**
139
150
  * Classify one journal: no request, a request whose outcome is already
140
151
  * terminal, or a request still in flight — with the phase evidence that says
@@ -152,7 +163,7 @@ export function classifyUpgrade(
152
163
  const start = newestRequestIndex(entries);
153
164
  if (start === -1) return { kind: "none" };
154
165
  const progression = entries.slice(start);
155
- const outcome = [...progression].reverse().find((entry) => entry.kind === "outcome");
166
+ const outcome = newestOutcome(progression);
156
167
  if (outcome !== undefined) {
157
168
  return { kind: "closed", phase: outcome.phase ?? "outcome" };
158
169
  }
@@ -167,15 +178,34 @@ export function classifyUpgrade(
167
178
  return { kind: "incomplete" };
168
179
  }
169
180
 
181
+ /** Terminal outcomes that prove no broken or mixed install remains active. */
182
+ const RESUMABLE_OUTCOMES = new Set([
183
+ "already-current",
184
+ "recovered",
185
+ "recovery-acknowledged",
186
+ "verified",
187
+ ]);
188
+ /** Outcomes that require a durable host-wide safety fence. */
189
+ const RECOVERY_HOLD_OUTCOMES = new Set([
190
+ "rollback-failed",
191
+ "rollback-requested",
192
+ "rollback-unavailable",
193
+ "rolled-back",
194
+ ]);
195
+ /** Closed rollback outcomes whose installed surfaces must be re-verified. */
196
+ const RECOVERY_CHECK_OUTCOMES = new Set(["rollback-failed", "rolled-back"]);
197
+
170
198
  /** One pending request plus the snapshot its verifier needs. */
171
199
  export interface PendingUpgradeRequest {
172
200
  version: string;
173
201
  gitHead?: string;
202
+ pauseSince?: number;
174
203
  initialPaused?: boolean;
175
204
  pauseKey?: string;
176
205
  selectors: readonly (string | undefined)[];
177
206
  initial?: UpgradeJournalEntry["initial"];
178
207
  configBackup?: string;
208
+ previous?: JournalSurfaces;
179
209
  /**
180
210
  * Epoch-ms when the install began (the journal snapshot's write time): the
181
211
  * deadline the live orchestrator session must have restarted after (#832).
@@ -202,6 +232,9 @@ export function pendingUpgradeRequest(
202
232
  const request = progression[0]!;
203
233
  if (request.ok === false) return undefined;
204
234
  const snapshot = progression.find((entry) => entry.kind === "snapshot");
235
+ const pausePhase = progression.find(
236
+ (entry) => entry.kind === "phase" && entry.phase === "paused",
237
+ );
205
238
  const snapshotAt = snapshot === undefined ? undefined : Date.parse(snapshot.at);
206
239
  return {
207
240
  version: request.version ?? "unknown",
@@ -209,12 +242,80 @@ export function pendingUpgradeRequest(
209
242
  initialPaused: snapshot?.initialPaused,
210
243
  pauseKey: snapshot?.pauseKey,
211
244
  selectors: (snapshot?.selectors ?? []).map((selector) => selector ?? undefined),
245
+ pauseSince: pausePhase?.pauseSince,
212
246
  initial: snapshot?.initial,
213
247
  configBackup: snapshot?.configBackup,
248
+ previous: snapshot?.previous,
214
249
  ...(snapshotAt !== undefined && Number.isFinite(snapshotAt) ? { reloadAfterMs: snapshotAt } : {}),
215
250
  };
216
251
  }
217
252
 
253
+ export interface UpgradeRecoveryStatus {
254
+ failedVersion: string;
255
+ phase: string;
256
+ journalPath: string;
257
+ verificationOwed: string;
258
+ }
259
+
260
+ /**
261
+ * The unresolved safety hold represented by the newest journal request.
262
+ * Status reads this same lifecycle verdict as the verifier; it never guesses
263
+ * from the pause reason alone.
264
+ */
265
+ export function upgradeRecoveryStatus(
266
+ entries: readonly UpgradeJournalEntry[],
267
+ root = stateDir(),
268
+ ): UpgradeRecoveryStatus | undefined {
269
+ const request = pendingUpgradeRequest(entries);
270
+ const state = classifyUpgrade(entries);
271
+ if (
272
+ request === undefined ||
273
+ state.kind !== "closed" ||
274
+ !RECOVERY_HOLD_OUTCOMES.has(state.phase)
275
+ ) {
276
+ return undefined;
277
+ }
278
+ const expected = request.previous;
279
+ const surfaces =
280
+ expected === undefined
281
+ ? "the pre-upgrade surface snapshot is missing"
282
+ : [
283
+ `CLI=${expected.cliVersion}`,
284
+ `omp=${expected.ompVersion ?? "absent"}`,
285
+ `herdr=${expected.herdrSource ?? "absent"}`,
286
+ ].join(", ");
287
+ const verificationOwed =
288
+ state.phase === "rollback-requested"
289
+ ? `detached rollback completion, then installed-surface verification against ${surfaces}`
290
+ : `installed-surface verification against ${surfaces}`;
291
+ return {
292
+ failedVersion: request.version,
293
+ phase: state.phase,
294
+ journalPath: upgradeJournalPath(root),
295
+ verificationOwed,
296
+ };
297
+ }
298
+
299
+ /**
300
+ * Record the operator's explicit choice to release an unresolved upgrade
301
+ * recovery fence. `resume` calls this before removing an upgrade-owned pause,
302
+ * so the append-only journal distinguishes acknowledgement from verification.
303
+ */
304
+ export function acknowledgeUpgradeRecovery(root = stateDir()): boolean {
305
+ const entries = readUpgradeJournal(root);
306
+ const recovery = upgradeRecoveryStatus(entries, root);
307
+ if (recovery === undefined) return false;
308
+ appendJournal(root, {
309
+ at: new Date().toISOString(),
310
+ kind: "outcome",
311
+ phase: "recovery-acknowledged",
312
+ ok: true,
313
+ version: recovery.failedVersion,
314
+ detail: `operator resumed dispatch with ${recovery.verificationOwed} still owed`,
315
+ });
316
+ return true;
317
+ }
318
+
218
319
  // ---------------------------------------------------------------------------
219
320
  // The live-orchestrator session attestation (#832)
220
321
  // ---------------------------------------------------------------------------
@@ -387,12 +488,18 @@ export interface UpgradeVerifyDeps {
387
488
  layers(project?: string): FleetLayers;
388
489
  health(port: number): Promise<{ ok: boolean; body?: string }>;
389
490
  doctor(projectName: string): Promise<DoctorReport>;
491
+ /** Read all three package/plugin identities from the live host. */
492
+ surfaces(): Promise<JournalSurfaces>;
390
493
  /** The durable outbox enqueue — one row, daemon-owned delivery. */
391
494
  enqueue(draft: ReportDraft): void;
392
495
  /** Read one pause sentinel (who set it, when) — daemon-owned state. */
393
496
  pause(project?: string): { source: string; reason?: string; since: number } | undefined;
394
497
  /** Lift the pause sentinel for a project, clearing the legacy global too. */
395
498
  resume(project?: string): void;
499
+ /** Escalate a project pause to the host-global safety fence. */
500
+ holdGlobal(): void;
501
+ /** Remove only the exact dedicated recovery fence observed by this pass. */
502
+ releaseGlobalRecovery(expectedSince: number | undefined): void;
396
503
  /** Start the detached rollback unit for a version. */
397
504
  launchRollback(
398
505
  version: string,
@@ -419,6 +526,8 @@ export interface UpgradeVerifyResult {
419
526
  | "none"
420
527
  | "already-closed"
421
528
  | "verified"
529
+ | "recovered"
530
+ | "recovery-owed"
422
531
  | "aborted"
423
532
  | "rollback-requested"
424
533
  | "rollback-unavailable";
@@ -453,41 +562,54 @@ export async function verifyPendingUpgrade(deps: UpgradeVerifyDeps): Promise<Upg
453
562
  if (request === undefined) return { handled: "none" };
454
563
  const state = classifyUpgrade(entries);
455
564
 
456
- const journal = (entry: Omit<UpgradeJournalEntry, "at">): void => {
565
+ const journal = (entry: Omit<UpgradeJournalEntry, "at">, at = now()): void => {
457
566
  const write = deps.journal ?? ((line: UpgradeJournalEntry) => appendJournal(root, line));
458
- write({ ...entry, version: entry.version ?? request.version, at: new Date(now()).toISOString() });
567
+ write({ ...entry, version: entry.version ?? request.version, at: new Date(at).toISOString() });
459
568
  };
460
569
 
461
570
  // ---------------------------------------------------------------- terminal
462
571
  if (state.kind === "closed") {
463
- // The transaction is already terminal (verified, rolled back, aborted,
464
- // rollback requested). One thing may still be owed: the install paused
465
- // dispatch, and a crash took the process that should have resumed it. The
466
- // resume is safe to redo it only acts while the sentinel is still the
467
- // install's own — so it runs on every visit to a closed request.
468
- resumeUpgradePause(deps, request);
572
+ // A closed failure is still a safety hold: rollback-requested means the
573
+ // rollback unit has not landed yet, while rollback-unavailable and
574
+ // rollback-failed mean the host may contain mixed surfaces. Only outcomes
575
+ // that prove the transaction safe may release its pause.
576
+ const outcome = newestOutcome(progression);
577
+ const parsedOutcomeAt = outcome === undefined ? Number.NaN : Date.parse(outcome.at);
578
+ const outcomeAt = Number.isFinite(parsedOutcomeAt) ? parsedOutcomeAt : undefined;
579
+ if (RECOVERY_CHECK_OUTCOMES.has(state.phase)) {
580
+ return verifyRecoveredUpgrade(deps, request, state.phase, outcomeAt, journal, now);
581
+ }
582
+ if (RECOVERY_HOLD_OUTCOMES.has(state.phase)) deps.holdGlobal();
583
+ if (RESUMABLE_OUTCOMES.has(state.phase) && outcomeAt !== undefined) {
584
+ resumeUpgradePause(deps, request, outcomeAt);
585
+ }
469
586
  return { handled: "already-closed", version: request.version, detail: state.phase };
470
587
  }
471
588
 
472
589
  // ------------------------------------------------------------ never started
473
590
  if (state.kind === "no-start") {
474
- // The unit died before journaling a single phase. Nothing was installed;
475
- // the sentinel can still exist if it crashed between pausing and writing
476
- // the pause line clear it, close the request, and page tier 2 that the
477
- // fleet could not install its own fix.
478
- resumeUpgradePause(deps, request);
591
+ // The unit died before journaling a single phase. Nothing was installed,
592
+ // but no phase recorded the exact pause instance either. Close the request
593
+ // without guessing that an upgrade-owned sentinel still belongs to it.
594
+ const abortedAt = now();
479
595
  deps.enqueue({
480
596
  project: deps.projectName,
481
597
  kind: "tier2",
482
598
  body: [
483
599
  `Fleet upgrade to omp-conductor@${request.version} never started (journal: ${upgradeJournalPath(root)}).`,
484
- "The detached install unit died before touching any surface; dispatch was not paused.",
600
+ "The detached install unit died before touching any surface.",
601
+ "Dispatch may still hold an unidentifiable install pause; check `status` and clear it explicitly with `omp-conductor resume`.",
485
602
  `Re-run the request, or install by hand with \`omp-conductor upgrade --to ${request.version}\`.`,
486
603
  ].join("\n"),
487
- at: now(),
604
+ at: abortedAt,
488
605
  dedupeKey: `upgrade:${request.version}:aborted`,
489
606
  });
490
- journal({ kind: "outcome", phase: "aborted", ok: false, detail: "requested upgrade never started a phase" });
607
+ journal({
608
+ kind: "outcome",
609
+ phase: "aborted",
610
+ ok: false,
611
+ detail: "requested upgrade never started a phase",
612
+ }, abortedAt);
491
613
  return { handled: "aborted", version: request.version, detail: "no phase was ever journaled" };
492
614
  }
493
615
 
@@ -518,16 +640,9 @@ export async function verifyPendingUpgrade(deps: UpgradeVerifyDeps): Promise<Upg
518
640
  );
519
641
  }
520
642
 
521
- // Good. Resume dispatch only when the install paused it, and only while the
522
- // sentinel is still the install's own an operator's later hold is never
523
- // lifted by an upgrade closing.
524
- if (request.initialPaused === false) {
525
- const owned = deps.pause(request.pauseKey);
526
- if (owned !== undefined && owned.source === "upgrade") {
527
- deps.resume(request.pauseKey);
528
- deps.log(`upgrade verified — dispatch resumed (${request.pauseKey ?? "global"})`);
529
- }
530
- }
643
+ // Good. Persist the evidence before releasing anything: if this process dies
644
+ // after the outcome, the next tick can finish the idempotent resume.
645
+ const verifiedAt = now();
531
646
  deps.enqueue({
532
647
  project: deps.projectName,
533
648
  kind: "material",
@@ -538,23 +653,118 @@ export async function verifyPendingUpgrade(deps: UpgradeVerifyDeps): Promise<Upg
538
653
  "",
539
654
  "Dispatch restored to its prior state.",
540
655
  ].join("\n"),
541
- at: now(),
656
+ at: verifiedAt,
542
657
  dedupeKey: `upgrade:${request.version}:verified`,
543
658
  });
544
- journal({ kind: "outcome", phase: "verified", ok: true, checks });
659
+ journal({ kind: "outcome", phase: "verified", ok: true, checks }, verifiedAt);
660
+ const paused = deps.pause(request.pauseKey);
661
+ resumeUpgradePause(deps, request, verifiedAt);
662
+ if (paused !== undefined && deps.pause(request.pauseKey) === undefined) {
663
+ deps.log(`upgrade verified — dispatch resumed (${request.pauseKey ?? "global"})`);
664
+ }
545
665
  return { handled: "verified", version: request.version };
546
666
  }
547
667
 
668
+ async function verifyRecoveredUpgrade(
669
+ deps: UpgradeVerifyDeps,
670
+ request: PendingUpgradeRequest,
671
+ failedPhase: string,
672
+ failedOutcomeAt: number | undefined,
673
+ journal: (entry: Omit<UpgradeJournalEntry, "at">, at?: number) => void,
674
+ now: () => number,
675
+ ): Promise<UpgradeVerifyResult> {
676
+ const globalPause = deps.pause();
677
+ const globalRecoverySince =
678
+ globalPause?.source === "upgrade-recovery" ? globalPause.since : undefined;
679
+ const checks: UpgradeCheck[] = [];
680
+ const expected = request.previous;
681
+ if (expected === undefined) {
682
+ checks.push({ name: "installed-surfaces", ok: false, detail: "pre-upgrade snapshot is missing" });
683
+ } else {
684
+ try {
685
+ const actual = await deps.surfaces();
686
+ for (const [name, wanted, found] of [
687
+ ["cli", expected.cliVersion, actual.cliVersion],
688
+ ["omp", expected.ompVersion, actual.ompVersion],
689
+ ["herdr", expected.herdrSource, actual.herdrSource],
690
+ ] as const) {
691
+ checks.push({
692
+ name: `surface:${name}`,
693
+ ok: wanted === found,
694
+ detail: `expected ${wanted ?? "absent"}; found ${found ?? "absent"}`,
695
+ });
696
+ }
697
+ } catch (err) {
698
+ checks.push({
699
+ name: "installed-surfaces",
700
+ ok: false,
701
+ detail: err instanceof Error ? err.message : String(err),
702
+ });
703
+ }
704
+ }
705
+ const failed = checks.find((check) => !check.ok);
706
+ if (failed !== undefined) {
707
+ // A recovery verdict owns the host-wide gate. Re-establish it on every
708
+ // owed pass so a legacy failed journal or a lost sentinel cannot admit.
709
+ deps.holdGlobal();
710
+ return {
711
+ handled: "recovery-owed",
712
+ version: request.version,
713
+ detail: `${failedPhase}; ${failed.name}: ${failed.detail ?? "failed"}`,
714
+ };
715
+ }
716
+
717
+ const recoveredAt = now();
718
+ deps.enqueue({
719
+ project: deps.projectName,
720
+ kind: "material",
721
+ body: [
722
+ `The failed upgrade to omp-conductor@${request.version} is recovered.`,
723
+ "",
724
+ ...checks.map((check) => `ok ${check.name} — ${check.detail ?? "matched"}`),
725
+ "",
726
+ "Every installed surface matches the durable pre-upgrade snapshot; dispatch restored to its prior state.",
727
+ ].join("\n"),
728
+ at: recoveredAt,
729
+ dedupeKey: `upgrade:${request.version}:recovered`,
730
+ });
731
+ journal({
732
+ kind: "outcome",
733
+ phase: "recovered",
734
+ ok: true,
735
+ detail: `verified recovery after ${failedPhase}`,
736
+ checks,
737
+ }, recoveredAt);
738
+ resumeUpgradePause(deps, request, failedOutcomeAt);
739
+ deps.releaseGlobalRecovery(globalRecoverySince);
740
+ return { handled: "recovered", version: request.version, detail: failedPhase };
741
+ }
742
+
548
743
  /**
549
744
  * Lift the install's own pause sentinel, and only that one. `pauseKey` is the
550
745
  * sentinel the engine paused; a fixed `initialPaused` true means the fleet was
551
746
  * already held and the install never paused it, so nothing is lifted. A pause
552
747
  * that an operator re-created (source changed) is left standing.
748
+ *
749
+ * New journals carry the exact pause creation instant, so identical
750
+ * provenance can never make a newer transaction look owned. `notAfter` is the
751
+ * compatibility bound for older journals that predate that identity field:
752
+ * only a pause that existed before the safe terminal outcome may be released.
553
753
  */
554
- function resumeUpgradePause(deps: UpgradeVerifyDeps, request: PendingUpgradeRequest): void {
754
+ function resumeUpgradePause(
755
+ deps: UpgradeVerifyDeps,
756
+ request: PendingUpgradeRequest,
757
+ notAfter?: number,
758
+ ): void {
555
759
  if (request.initialPaused === true) return;
556
760
  const owned = deps.pause(request.pauseKey);
557
- if (owned !== undefined && owned.source === "upgrade") {
761
+ const sameTransaction =
762
+ request.pauseSince !== undefined
763
+ ? owned !== undefined &&
764
+ owned.since >= request.pauseSince &&
765
+ (notAfter === undefined || owned.since <= notAfter)
766
+ : notAfter !== undefined && owned !== undefined && owned.since <= notAfter;
767
+ if (owned?.source === "upgrade" && sameTransaction) {
558
768
  deps.resume(request.pauseKey);
559
769
  }
560
770
  }
@@ -687,6 +897,10 @@ async function triggerUpgradeRollback(
687
897
  at: number,
688
898
  why: string,
689
899
  ): Promise<UpgradeVerifyResult> {
900
+ // Every install surface is host-wide even when the request named one
901
+ // project. Once recovery is required, the safety fence must gate every
902
+ // project served by the shared daemon.
903
+ deps.holdGlobal();
690
904
  // The marker is the spawn guard: present-and-ok means a rollback unit is
691
905
  // already on its way and a crash after the spawn must not spawn a second
692
906
  // one. Present-and-failed means nothing started, so a retry is a first
package/src/upgrade.ts CHANGED
@@ -129,6 +129,8 @@ export interface UpgradeDeps {
129
129
  */
130
130
  pauseState(project?: string): { source: string; reason?: string; since: number } | undefined;
131
131
  setPaused(value: boolean, project?: string): void;
132
+ /** Host-global recovery fence for host-wide install surfaces. */
133
+ setGlobalRecoveryPaused(value: boolean): void;
132
134
  restartDaemon(): Promise<void>;
133
135
  sleep(ms: number): Promise<void>;
134
136
  /**
@@ -205,6 +207,14 @@ export const DEFAULT_DEPS: UpgradeDeps = {
205
207
  pauseState: (project) => pauseInstance(project),
206
208
  setPaused: (v, project) =>
207
209
  setPaused(v, { source: "upgrade", reason: "upgrade, draining" }, project),
210
+ setGlobalRecoveryPaused: (v) => {
211
+ const current = pauseInstance();
212
+ if (v && current === undefined) {
213
+ setPaused(true, { source: "upgrade-recovery", reason: "upgrade recovery verification required" });
214
+ } else if (!v && current?.source === "upgrade-recovery") {
215
+ setPaused(false);
216
+ }
217
+ },
208
218
  restartDaemon: async () => {
209
219
  await restartDaemon({});
210
220
  },
@@ -1413,7 +1423,14 @@ export async function upgradeConductor(
1413
1423
  deps.log("safety: pausing new issue claims");
1414
1424
  deps.setPaused(true, scope.pauseKey);
1415
1425
  }
1416
- journal({ kind: "phase", phase: "paused", surface: "dispatch", ok: true, version: release.version });
1426
+ journal({
1427
+ kind: "phase",
1428
+ phase: "paused",
1429
+ surface: "dispatch",
1430
+ ok: true,
1431
+ version: release.version,
1432
+ pauseSince: deps.pauseState(scope.pauseKey)?.since,
1433
+ });
1417
1434
  deps.log("drain: waiting for live omp worker sessions");
1418
1435
  try {
1419
1436
  await waitForDrain(deps, scope);
@@ -1595,6 +1612,7 @@ export async function upgradeConductor(
1595
1612
  } catch {
1596
1613
  deps.setPaused(true, scope.pauseKey);
1597
1614
  }
1615
+ deps.setGlobalRecoveryPaused(true);
1598
1616
  const failure = err instanceof Error ? err.message : String(err);
1599
1617
  journal({
1600
1618
  kind: "phase",
@@ -1779,6 +1797,10 @@ export async function rollbackFromJournal(
1779
1797
  configBefore,
1780
1798
  request.configBackup,
1781
1799
  );
1800
+ // The restored bytes remain host-wide unverified state until the returning
1801
+ // daemon reads all three surfaces. A project-scoped request cannot leave its
1802
+ // siblings admitting in that interval.
1803
+ deps.setGlobalRecoveryPaused(true);
1782
1804
 
1783
1805
  const restoredVersion = previous.cliVersion;
1784
1806
  appendJournal(root, {