omp-conductor 0.18.2 → 0.19.0

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.
Files changed (60) hide show
  1. package/README.md +105 -40
  2. package/REFERENCE.md +865 -30
  3. package/package.json +1 -1
  4. package/schema/config.schema.json +26 -0
  5. package/src/admission.ts +212 -26
  6. package/src/ask.ts +288 -1
  7. package/src/briefs/orchestrator.md +6 -5
  8. package/src/cli.ts +5 -1
  9. package/src/command-help.ts +9 -1
  10. package/src/command-manifest.ts +36 -3
  11. package/src/commands/arm.ts +5 -1
  12. package/src/commands/context.ts +2 -0
  13. package/src/commands/message.ts +26 -2
  14. package/src/commands/reconcile-units.ts +104 -0
  15. package/src/commands/release-composition.ts +232 -0
  16. package/src/commands/resume.ts +2 -27
  17. package/src/commands/setup.ts +101 -16
  18. package/src/commands/stats.ts +11 -30
  19. package/src/commands/tail.ts +31 -1
  20. package/src/commands/upgrade.ts +20 -3
  21. package/src/commands/verb.ts +2 -1
  22. package/src/config-schema.ts +19 -0
  23. package/src/config.ts +80 -0
  24. package/src/credential-class.ts +366 -0
  25. package/src/daemon.ts +1218 -288
  26. package/src/dashboard/app.js +504 -2
  27. package/src/dashboard/controls.ts +336 -0
  28. package/src/dashboard/index.html +30 -0
  29. package/src/dashboard/server.ts +271 -30
  30. package/src/dashboard/style.css +116 -0
  31. package/src/dashboard/transcript.ts +173 -0
  32. package/src/doctor.ts +377 -20
  33. package/src/failure-class.ts +59 -0
  34. package/src/fleet.ts +497 -15
  35. package/src/host.ts +6 -130
  36. package/src/omp.ts +29 -0
  37. package/src/orchestrator-tick.ts +343 -88
  38. package/src/pause.ts +233 -0
  39. package/src/settlement.ts +159 -2
  40. package/src/setup-answers.ts +97 -0
  41. package/src/setup-host.ts +321 -1155
  42. package/src/setup-install.ts +204 -27
  43. package/src/setup-wizard.ts +111 -50
  44. package/src/setup.ts +33 -0
  45. package/src/spend-telemetry.ts +117 -0
  46. package/src/stats.ts +35 -0
  47. package/src/status-render.ts +348 -19
  48. package/src/store.ts +1229 -55
  49. package/src/telegram-freshness.ts +269 -0
  50. package/src/to-spec.ts +27 -0
  51. package/src/types.ts +697 -4
  52. package/src/unblock.ts +22 -0
  53. package/src/unit-reconcile.ts +303 -0
  54. package/src/upgrade-verify.ts +8 -1
  55. package/src/upgrade.ts +299 -12
  56. package/src/verbs/actions.ts +124 -10
  57. package/src/verbs/protocol.ts +70 -2
  58. package/src/verbs/server.ts +447 -8
  59. package/src/wake.ts +48 -0
  60. package/src/worker.ts +403 -3
package/src/doctor.ts CHANGED
@@ -61,6 +61,7 @@ import {
61
61
  sessionDirForCwd,
62
62
  telegramStateDir,
63
63
  } from "./fleet.ts";
64
+ import { pauseInstance } from "./pause.ts";
64
65
  import type { TelegramHealth } from "./status-render.ts";
65
66
  import {
66
67
  claimedTelegramTopics,
@@ -101,6 +102,27 @@ import {
101
102
  } from "./orchestrator-tick.ts";
102
103
  import type { ConductorConfig, ProjectConfig, RepoTarget, RunState } from "./types.ts";
103
104
  import { DEFAULT_ARM_PROOF, type ArmProof } from "./types.ts";
105
+ import {
106
+ DEFAULT_DEPS as UPGRADE_DEPS,
107
+ expectedHerdrSource,
108
+ inspectSurfaces,
109
+ releaseIdentity,
110
+ type InstalledSurfaces,
111
+ } from "./upgrade.ts";
112
+ import {
113
+ TELEGRAM_PACKAGE,
114
+ checkTelegramFreshness,
115
+ type TelegramFreshness,
116
+ } from "./telegram-freshness.ts";
117
+ import { judgeSpendTelemetry, spendTelemetryDetail } from "./spend-telemetry.ts";
118
+
119
+ /** One read of this host's installed surfaces: the three identities, or why
120
+ * they could not be read. A failed read is a finding (`warn`, "unverified"),
121
+ * never a thrown doctor run and never a silent pass (#904). */
122
+ export type SurfaceRead =
123
+ | { ok: true; surfaces: InstalledSurfaces }
124
+ | { ok: false; detail: string };
125
+
104
126
  /**
105
127
  * `doctor`'s finding vocabulary. `pass`/`warn`/`fail` — a warning renders the
106
128
  * same but never flips the exit code; exit is nonzero iff any finding fails.
@@ -131,8 +153,18 @@ export interface DoctorReport {
131
153
  findings: Finding[];
132
154
  }
133
155
 
134
- /** How many of the most recent completed runs are sampled for spend telemetry. */
156
+ /** How many *working* runs the spend-telemetry judgement wants. */
135
157
  export const SPEND_SAMPLE_RUNS = 5;
158
+ /**
159
+ * How many rows to read to find them (#970).
160
+ *
161
+ * Over-sampled deliberately: the judgement discards 0-turn runs, and on this
162
+ * project 26 of 48 zero-spend rows are exactly those. Reading only
163
+ * `SPEND_SAMPLE_RUNS` rows would let a burst of administrative kills push the
164
+ * real evidence out of the window and report "nothing to judge yet" on a fleet
165
+ * that had plenty to judge.
166
+ */
167
+ export const SPEND_SAMPLE_ROWS = SPEND_SAMPLE_RUNS * 4;
136
168
 
137
169
  /** The past incidents this doctor flags, quoted in the finding so an operator
138
170
  * who never lived them knows which failure mode the check exists to prevent. */
@@ -144,10 +176,6 @@ const INCIDENTS = {
144
176
  spend:
145
177
  "spend telemetry was once absent, so the USD cap never fired — $0.00 spend is not proof of no spend",
146
178
  ghauth: "gh auth expired under a live daemon and every tracker call failed silently",
147
- workerAcl:
148
- "the #835 incident: a setup host granted the worker's path ACLs before restarting the fleet, an OMP startup chmod'd " +
149
- "the agent config dir back to 0700 and rewrote the ACL mask, and the next two admitted workers died on EACCES " +
150
- "before connecting — the named ACL entry was still there, only its effective permissions were gone",
151
179
  } as const;
152
180
 
153
181
  // ------------------------------------------------------------------ dependencies
@@ -172,10 +200,12 @@ export interface CanonicalUnits {
172
200
  }
173
201
 
174
202
  /** One sampled run row: `state` keeps spend from in-flight rows, which are not
175
- * a telemetry statement yet. */
203
+ * a telemetry statement yet, and `turns` separates a run that reported nothing
204
+ * from one that genuinely did nothing (#970). */
176
205
  export interface RunSpendRow {
177
206
  state: RunState;
178
207
  spendUsd: number;
208
+ turns: number;
179
209
  }
180
210
 
181
211
  /** Injectable seams. Every field defaults to the production wiring, which
@@ -222,6 +252,21 @@ export interface DoctorDeps {
222
252
  canonicalUnits?: (project: ProjectConfig, cfg: ConductorConfig) => CanonicalUnits;
223
253
  /** Whether herdr is installed on this host (a `herdr` on PATH). */
224
254
  herdrInstalled?: () => boolean;
255
+ /**
256
+ * Whether a worker session could load its harness at all (#910), exercised
257
+ * the way a worker does: the installed peer resolved from this package's own
258
+ * directory under `bun --no-install`, so neither import can fall through to
259
+ * Bun's ambient cache. `ok: false` means no worker on this host can start.
260
+ */
261
+ workerHarness?: () => Promise<{ ok: true; version: string } | { ok: false; detail: string }>;
262
+ /**
263
+ * The pause sentinel for one project as an instance (#938): who set it, why,
264
+ * when, and — for a fence whose lifetime is one process's — the owning pid.
265
+ */
266
+ pauseFence?: (project?: string) => { source: string; reason?: string; since: number; owner?: number } | undefined;
267
+ /** Whether one pid is a live process. Injected so the abandoned-fence
268
+ * finding is testable without spawning anything. */
269
+ pidLive?: (pid: number) => boolean;
225
270
  /** Live `herdr --session <s> agent list`, parsed through the tick's own
226
271
  * parser (the same "one JSON line on stdout" contract recover.sh reads). */
227
272
  herdrAgents?: (session: string) => HerdrAgentList;
@@ -274,6 +319,14 @@ export interface DoctorDeps {
274
319
  /** The fleet agent name the tick config of one project names, or undefined
275
320
  * when there is no (readable) tick — the expected live herdr pane identity. */
276
321
  tickAgentName?: (project: ProjectConfig) => string | undefined;
322
+ /** The three installed identities this host carries, read through
323
+ * `upgrade`'s own seam; a failed read is a named finding, never a throw. */
324
+ installedSurfaces?: () => Promise<SurfaceRead>;
325
+ /** The `omp-telegram` install/daemon/published triple (#961). */
326
+ telegramFreshness?: () => Promise<TelegramFreshness>;
327
+ /** The commit one published version was cut from, for judging the herdr
328
+ * plugin's pin; `undefined` when the registry cannot be read. */
329
+ releaseGitHead?: (version: string) => Promise<string | undefined>;
277
330
  /** Clock, so a run is deterministic in tests. */
278
331
  now?: () => number;
279
332
  /** The one opt-in side effect: send one self-identified Telegram probe. */
@@ -446,8 +499,8 @@ function defaultRecentRuns(project: string, limit: number): RunSpendRow[] {
446
499
  try {
447
500
  const placeholders = [...LIVE_STATES].map(() => "?").join(", ");
448
501
  const rows = database
449
- .query<{ state: string; spendUsd: number }, [string, ...string[], number]>(
450
- `SELECT state, spendUsd FROM runs
502
+ .query<{ state: string; spendUsd: number; turns: number }, [string, ...string[], number]>(
503
+ `SELECT state, spendUsd, turns FROM runs
451
504
  WHERE project = ? AND state NOT IN (${placeholders})
452
505
  ORDER BY startedAt DESC, rowid DESC
453
506
  LIMIT ?`,
@@ -457,7 +510,9 @@ function defaultRecentRuns(project: string, limit: number): RunSpendRow[] {
457
510
  for (const row of rows) {
458
511
  const state = row.state as RunState;
459
512
  // A state vocabulary the store never had is not "completed".
460
- if (!LIVE_STATES.includes(state)) out.push({ state, spendUsd: row.spendUsd });
513
+ if (!LIVE_STATES.includes(state)) {
514
+ out.push({ state, spendUsd: row.spendUsd, turns: row.turns });
515
+ }
461
516
  }
462
517
  return out;
463
518
  } catch {
@@ -1041,22 +1096,40 @@ async function telegramProbe(probes: Probes, project: ProjectConfig | undefined,
1041
1096
  );
1042
1097
  }
1043
1098
 
1044
- /** Spend telemetry: warn when the last K completed runs all recorded $0.00 —
1045
- * the USD cap cannot fire on zeros ($0.00 is not proof of no spend). */
1099
+ /**
1100
+ * Spend telemetry, through the shared judgement (#970).
1101
+ *
1102
+ * The predicate this replaced fired only when *every* sampled run reported
1103
+ * $0.00, and counted 0-turn kills as evidence. Replayed over this fleet's whole
1104
+ * history it would have fired 14 times while staying silent through 37 windows
1105
+ * that had lost a majority of their telemetry — so partial loss, the common
1106
+ * case, was the invisible one. See `spend-telemetry.ts` for the measurements.
1107
+ */
1046
1108
  function spendProbe(rows: RunSpendRow[], limit: number): Finding {
1047
- if (rows.length < limit) {
1048
- return passFinding("spend-telemetry", `${rows.length} completed run(s) observed — fewer than ${limit}, nothing to judge yet`);
1049
- }
1050
- const window = rows.slice(0, limit);
1051
- if (window.every((r) => r.spendUsd === 0)) {
1109
+ const verdict = judgeSpendTelemetry(rows, limit);
1110
+ const detail = spendTelemetryDetail(verdict);
1111
+ if (detail !== undefined) {
1052
1112
  return warnFinding(
1053
1113
  "spend-telemetry",
1054
- `the last ${limit} completed runs all recorded $0.00 spend — ${INCIDENTS.spend}`,
1114
+ `${detail} — ${INCIDENTS.spend}`,
1055
1115
  "verify harness spend reporting (the per-run spendUsd column / `omp usage --json`); a USD cap on top of zeros never fires",
1056
1116
  );
1057
1117
  }
1058
- const total = window.reduce((sum, r) => sum + r.spendUsd, 0);
1059
- return passFinding("spend-telemetry", `spend observed on the last ${window.length} completed runs ($${total.toFixed(2)} total)`);
1118
+ if (verdict.kind === "insufficient") {
1119
+ return passFinding(
1120
+ "spend-telemetry",
1121
+ `${verdict.worked} completed run(s) did any work — fewer than ${verdict.needed}, nothing to judge yet`,
1122
+ );
1123
+ }
1124
+ // Healthy. Naming the metered share rather than only the total: a window with
1125
+ // one missing row is fine and saying so is how a reader learns the baseline
1126
+ // is not zero.
1127
+ const healthy = verdict as Extract<typeof verdict, { kind: "healthy" }>;
1128
+ return passFinding(
1129
+ "spend-telemetry",
1130
+ `spend observed on ${healthy.worked - healthy.missing} of the last ${healthy.worked} working runs ` +
1131
+ `($${healthy.totalUsd.toFixed(2)} total)`,
1132
+ );
1060
1133
  }
1061
1134
 
1062
1135
  /** Live `herdr --session <session> agent list` through the project's own
@@ -1411,6 +1484,45 @@ function telegramPlumbingProbe(probes: Probes, p: ProjectConfig): Finding {
1411
1484
  * pages instead of recovering. Absent ~ the unrecoverable default; an explicit
1412
1485
  * `true` is a deliberate (desktop) choice and only warned.
1413
1486
  */
1487
+ /**
1488
+ * A dispatch fence whose owning process is gone (#938).
1489
+ *
1490
+ * Only a fence that *declared* an owner can be judged: `setPaused` records
1491
+ * `owner=` for a setup transaction, whose lifetime is that process's, and
1492
+ * deliberately not for an operator `hold` or the fail-closed hold a failed
1493
+ * apply leaves behind — those are meant to outlive every process, and calling
1494
+ * one "abandoned" would be worse than saying nothing about it.
1495
+ *
1496
+ * Measured 2026-08-21T13:24Z: an operator abandoned a setup that looked hung;
1497
+ * the policy change had applied, and dispatch sat under
1498
+ * `source=setup reason="setup apply fence"` until somebody thought to run
1499
+ * `resume`. Nothing anywhere said the process that wrote it was gone.
1500
+ *
1501
+ * This never clears anything, and that is deliberate. A pid is reusable, a
1502
+ * paused fleet is the safe state, and resuming dispatch nobody authorised is a
1503
+ * worse outcome than a fence that needs one command. So it names the command.
1504
+ */
1505
+ function fenceProbe(probes: Probes, project: string | undefined): Finding {
1506
+ const id = project === undefined ? "dispatch-fence" : `dispatch-fence:${project}`;
1507
+ const fence = probes.pauseFence(project);
1508
+ if (fence === undefined) return passFinding(id, "dispatch is not held by a fence");
1509
+ const why = fence.reason === undefined ? "" : ` — "${fence.reason}"`;
1510
+ if (fence.owner === undefined) {
1511
+ // An operator hold, or the durable hold a failed apply leaves on purpose.
1512
+ // Both are somebody's decision rather than a leak, whatever their age.
1513
+ return passFinding(id, `dispatch held by ${fence.source}${why} (no owning process; nothing to expire)`);
1514
+ }
1515
+ if (probes.pidLive(fence.owner)) {
1516
+ return passFinding(id, `dispatch held by a live ${fence.source} (pid ${fence.owner})`);
1517
+ }
1518
+ return warnFinding(
1519
+ id,
1520
+ `dispatch is held by ${fence.source}${why}, whose process (pid ${fence.owner}) is gone: the fence outlived ` +
1521
+ `the transaction that set it, and has stood since ${new Date(fence.since).toISOString()}`,
1522
+ `nothing here clears it — confirm no setup is running, then \`omp-conductor resume${project === undefined ? "" : ` --project ${project}`}\``,
1523
+ );
1524
+ }
1525
+
1414
1526
  function herdrResumeProbe(probes: Probes): Finding {
1415
1527
  if (!probes.herdrInstalled()) {
1416
1528
  return passFinding("herdr-resume", "herdr not installed — nothing to check");
@@ -1505,6 +1617,169 @@ function parseEnvKey(text: string, key: string): string | undefined {
1505
1617
  return undefined;
1506
1618
  }
1507
1619
 
1620
+ /**
1621
+ * Install-surface parity (#904). omp-conductor installs onto three surfaces —
1622
+ * the Bun-global CLI/daemon tree, the omp plugin, and the herdr recovery
1623
+ * plugin — and only a live `upgrade` invocation ever compared them. On a
1624
+ * fleet whose installs are manual they diverge silently: measured on this
1625
+ * host on 2026-08-22, the omp plugin sat on the withdrawn 0.18.1 release for
1626
+ * about a day beside a 0.18.0 daemon, with no finding, no status line and no
1627
+ * escalation.
1628
+ *
1629
+ * The question is inter-surface agreement on the host, never staleness
1630
+ * against the registry: a fleet may deliberately sit on an older release,
1631
+ * but never on two at once. `expectedGitHead` is the commit the CLI's own
1632
+ * version was published from, so the herdr pin can be judged against the
1633
+ * same release; `undefined` means the registry could not be read and the pin
1634
+ * is reported as unverified rather than as a mismatch.
1635
+ */
1636
+ /**
1637
+ * The bootstrap deadlock (#910), which is a conjunction rather than a fault:
1638
+ *
1639
+ * - no worker on this host can start, because the *installed* code cannot
1640
+ * load its harness; and
1641
+ * - the fix for that ships through a release, and a release needs a merged
1642
+ * worker pull request — so the broken install is what blocks its own
1643
+ * replacement.
1644
+ *
1645
+ * Kept as its own finding, never folded into the stale-setup ones, because the
1646
+ * two states call for opposite actions. "Host runtime differs — run setup host"
1647
+ * is *correct advice* for a stale install and *useless* here: no amount of
1648
+ * re-running setup replaces the installed package, and diagnosing that cost
1649
+ * real time on 2026-08-20..22. So this names the one command that breaks the
1650
+ * cycle (#908's bootstrap identity), and an ordinary stale host still reads
1651
+ * exactly as it did.
1652
+ */
1653
+ function bootstrapDeadlockProbe(
1654
+ harness: { ok: true; version: string } | { ok: false; detail: string },
1655
+ ): Finding {
1656
+ if (harness.ok) {
1657
+ return passFinding("bootstrap-deadlock", `workers can load the installed harness (${harness.version})`);
1658
+ }
1659
+ return failFinding(
1660
+ "bootstrap-deadlock",
1661
+ `no worker can start: the installed conductor cannot load its harness — ${harness.detail}. ` +
1662
+ "A release cannot publish the fix either, because publishing needs a merged worker pull request, " +
1663
+ "so the installed package blocks its own replacement",
1664
+ "install the fix by exact commit instead of by version: `omp-conductor upgrade --bootstrap <sha> " +
1665
+ "--source <checkout at that sha>` (its checks run against the source first, and it refuses rather " +
1666
+ "than installing a partly verified tree)",
1667
+ );
1668
+ }
1669
+
1670
+ /**
1671
+ * Is the plugin a mandated conductor contract runs on actually current? (#961)
1672
+ *
1673
+ * Sibling to {@link surfaceParityProbe}, and deliberately a separate finding:
1674
+ * that one is about the three surfaces of *this* package agreeing with each
1675
+ * other, this one is about the one peer the floor's reply-in-topic instruction
1676
+ * depends on being new enough to honour it.
1677
+ *
1678
+ * A `warn`, never a `fail`. Nothing here is broken on this host's own terms —
1679
+ * the fleet dispatches, merges and reports fine on a stale plugin. What breaks
1680
+ * is one documented instruction, and the remedy is an install the operator owns,
1681
+ * so failing the run would make `doctor` red for something conductor must not
1682
+ * fix itself.
1683
+ */
1684
+ export function telegramFreshnessProbe(freshness: TelegramFreshness): Finding {
1685
+ const id = "telegram-plugin-freshness";
1686
+ const { installed, daemon, published } = freshness.surfaces;
1687
+ const named =
1688
+ `installed=${installed.kind === "version" ? installed.version : installed.kind}, ` +
1689
+ `daemon=${daemon.kind === "version" ? daemon.version : daemon.kind}, ` +
1690
+ `published=${published.kind === "version" ? published.version : published.kind}`;
1691
+ switch (freshness.state) {
1692
+ case "current":
1693
+ return passFinding(id, `${TELEGRAM_PACKAGE} is current (${named})`);
1694
+ case "installed-behind":
1695
+ return warnFinding(
1696
+ id,
1697
+ `${freshness.detail} (${named})`,
1698
+ `install ${TELEGRAM_PACKAGE}@${published.kind === "version" ? published.version : "latest"} and restart its daemon; a targetless telegram_send needs the newer target ladder to reply in the topic a message arrived in (#882)`,
1699
+ );
1700
+ case "daemon-stale":
1701
+ return warnFinding(
1702
+ id,
1703
+ `${freshness.detail} (${named})`,
1704
+ `restart the ${TELEGRAM_PACKAGE} daemon so it serves the installed version`,
1705
+ );
1706
+ case "not-installed":
1707
+ return warnFinding(
1708
+ id,
1709
+ `${freshness.detail} (${named})`,
1710
+ `install ${TELEGRAM_PACKAGE}, or stop relying on Telegram delivery for this fleet`,
1711
+ );
1712
+ case "unknown":
1713
+ // Not a finding about the plugin: a finding about this check. An
1714
+ // unreachable registry must never read as "behind" *or* as "current".
1715
+ return warnFinding(
1716
+ id,
1717
+ `${TELEGRAM_PACKAGE} freshness unverified: ${freshness.detail ?? "no surface answered"} (${named})`,
1718
+ "no action needed if this host has no outbound npm access; otherwise re-run doctor when it does",
1719
+ );
1720
+ }
1721
+ }
1722
+
1723
+ export function surfaceParityProbe(
1724
+ read: SurfaceRead,
1725
+ expectedGitHead: string | undefined,
1726
+ herdrExpected: boolean,
1727
+ ): Finding {
1728
+ const id = "install-surfaces";
1729
+ if (!read.ok) {
1730
+ return warnFinding(
1731
+ id,
1732
+ `installed surfaces unreadable: ${read.detail} — parity between the CLI, the omp plugin and the herdr plugin is unverified`,
1733
+ "check that `omp-conductor`, `omp` and (when this host runs herdr) `herdr` are on PATH for this user, then re-run doctor",
1734
+ );
1735
+ }
1736
+ const { cliVersion, ompVersion, herdrSource } = read.surfaces;
1737
+ const named = `cli=${cliVersion}, omp=${ompVersion ?? "absent"}, herdr=${herdrSource ?? "absent"}`;
1738
+ // A proven disagreement first: two different releases live on one host is
1739
+ // the fault this probe exists for, and it outranks anything absent.
1740
+ if (ompVersion !== undefined && ompVersion !== cliVersion) {
1741
+ return failFinding(
1742
+ id,
1743
+ `installed surfaces disagree: the omp plugin is ${ompVersion} while the CLI/daemon tree is ${cliVersion} (${named})`,
1744
+ `run \`omp-conductor upgrade --to ${cliVersion}\` (or to the release this fleet should be on) so all three surfaces carry one identity`,
1745
+ );
1746
+ }
1747
+ if (herdrExpected && herdrSource !== undefined && expectedGitHead !== undefined) {
1748
+ if (herdrSource.startsWith("local:")) {
1749
+ return warnFinding(
1750
+ id,
1751
+ `the herdr plugin is linked to a local checkout, not a released pin (${named})`,
1752
+ `run \`omp-conductor upgrade --to ${cliVersion}\` to replace the link with the released pin, or keep the link deliberately while developing`,
1753
+ );
1754
+ }
1755
+ const expected = expectedHerdrSource(expectedGitHead);
1756
+ if (herdrSource !== expected) {
1757
+ return failFinding(
1758
+ id,
1759
+ `installed surfaces disagree: the herdr plugin is pinned to ${herdrSource} while ${cliVersion} was published from ${expectedGitHead} (${named})`,
1760
+ `run \`omp-conductor upgrade --to ${cliVersion}\` so the herdr recovery plugin matches the release the rest of the host runs`,
1761
+ );
1762
+ }
1763
+ }
1764
+ // Nothing disagrees. Anything absent is named as absent — a surface this
1765
+ // host does not carry is not a mismatch, and a pin that could not be
1766
+ // verified is not a pass in disguise.
1767
+ const absent: string[] = [];
1768
+ if (ompVersion === undefined) absent.push("omp plugin not installed — a tick cannot arm without it");
1769
+ if (herdrExpected && herdrSource === undefined) absent.push("herdr plugin not installed — pane recovery cannot run");
1770
+ if (herdrExpected && herdrSource !== undefined && expectedGitHead === undefined) {
1771
+ absent.push(`herdr pin ${herdrSource} unverified — the registry did not answer what ${cliVersion} was published from`);
1772
+ }
1773
+ if (absent.length > 0) {
1774
+ return warnFinding(
1775
+ id,
1776
+ `${absent.join("; ")} (${named})`,
1777
+ `run \`omp-conductor upgrade --to ${cliVersion}\` to install every surface this host needs from one release`,
1778
+ );
1779
+ }
1780
+ return passFinding(id, `one identity across every installed surface (${named})`);
1781
+ }
1782
+
1508
1783
  /**
1509
1784
  * Run every probe and assemble the stable report.
1510
1785
  *
@@ -1574,6 +1849,17 @@ export async function runDoctor(projectName: string | undefined, opts: DoctorDep
1574
1849
  findings.push(dbProbe(probes));
1575
1850
  findings.push(dbBackupProbe(probes, cfg));
1576
1851
  findings.push(await ghAuthProbe(probes, configuredRepos(cfg)));
1852
+ // Install-surface parity is host-wide, like the unit and the store: one
1853
+ // read of the three identities, judged once (#904).
1854
+ const surfaces = await probes.installedSurfaces();
1855
+ const cliVersion = surfaces.ok ? surfaces.surfaces.cliVersion : undefined;
1856
+ const expectedGitHead = cliVersion === undefined ? undefined : await probes.releaseGitHead(cliVersion);
1857
+ findings.push(surfaceParityProbe(surfaces, expectedGitHead, probes.herdrInstalled()));
1858
+ // Host-wide for the same reason: one plugin install serves every project, so
1859
+ // it is judged once rather than repeated per project (#961).
1860
+ findings.push(telegramFreshnessProbe(await probes.telegramFreshness()));
1861
+ // Host-wide like the surfaces: one install, one harness, one answer (#910).
1862
+ findings.push(bootstrapDeadlockProbe(await probes.workerHarness()));
1577
1863
  // The run-session root is one host-wide fact — where every run's session dir
1578
1864
  // (and the omp settings overlay inside it) lands — probed once and shared
1579
1865
  // across the per-project omp-settings findings.
@@ -1582,7 +1868,7 @@ export async function runDoctor(projectName: string | undefined, opts: DoctorDep
1582
1868
  // The collected run sample across the resolved project set, newest-first;
1583
1869
  // spend telemetry is a single host-wide finding, not one per project.
1584
1870
  const spendRows: RunSpendRow[] = [];
1585
- for (const p of projects) spendRows.push(...probes.recentRuns(p.name, SPEND_SAMPLE_RUNS));
1871
+ for (const p of projects) spendRows.push(...probes.recentRuns(p.name, SPEND_SAMPLE_ROWS));
1586
1872
 
1587
1873
  // Per-project probes run for every resolved project, each named in its
1588
1874
  // finding so a multi-project run stays legible. When nothing resolved
@@ -1630,9 +1916,13 @@ export async function runDoctor(projectName: string | undefined, opts: DoctorDep
1630
1916
  if (projects.length === 0) {
1631
1917
  findings.push(timezoneProbe(undefined));
1632
1918
  findings.push(await telegramProbe(probes, undefined, checkedAt));
1919
+ findings.push(fenceProbe(probes, undefined));
1633
1920
  } else {
1634
1921
  for (const p of projects) findings.push(timezoneProbe(p));
1635
1922
  for (const p of projects) findings.push(await telegramProbe(probes, p, checkedAt));
1923
+ // Per project, because the sentinel is: one fleet's fence must never be
1924
+ // reported against another's name.
1925
+ for (const p of projects) findings.push(fenceProbe(probes, p.name));
1636
1926
  }
1637
1927
  findings.push(spendProbe(spendRows, SPEND_SAMPLE_RUNS));
1638
1928
 
@@ -1654,6 +1944,41 @@ export async function runDoctor(projectName: string | undefined, opts: DoctorDep
1654
1944
  };
1655
1945
  }
1656
1946
 
1947
+ /**
1948
+ * Exercise the worker's real import contract out of process (#910).
1949
+ *
1950
+ * `harness-loader.ts` is the module a worker session loads its harness through,
1951
+ * and it is runnable — so this runs exactly that, with `--no-install`, and reads
1952
+ * its verdict rather than re-deriving one. Anything else would be a second
1953
+ * opinion about the launch path, which is how a doctor comes to disagree with
1954
+ * the thing it is checking.
1955
+ */
1956
+ async function defaultWorkerHarness(): Promise<{ ok: true; version: string } | { ok: false; detail: string }> {
1957
+ const loader = join(import.meta.dir, "harness-loader.ts");
1958
+ try {
1959
+ const child = Bun.spawn(["bun", "--no-install", loader], {
1960
+ stdin: "ignore",
1961
+ stdout: "pipe",
1962
+ stderr: "pipe",
1963
+ env: process.env,
1964
+ });
1965
+ const stdout = new Response(child.stdout).text();
1966
+ const stderr = new Response(child.stderr).text();
1967
+ const code = await child.exited;
1968
+ if (code !== 0) {
1969
+ const detail = ((await stderr).trim() || (await stdout).trim()).split("\n")[0] ?? `exit ${code}`;
1970
+ return { ok: false, detail };
1971
+ }
1972
+ const parsed: unknown = JSON.parse((await stdout).trim());
1973
+ const version = parsed !== null && typeof parsed === "object" ? Reflect.get(parsed, "version") : undefined;
1974
+ return typeof version === "string" && version.length > 0
1975
+ ? { ok: true, version }
1976
+ : { ok: false, detail: "the harness loaded but reported no version" };
1977
+ } catch (err) {
1978
+ return { ok: false, detail: err instanceof Error ? err.message : String(err) };
1979
+ }
1980
+ }
1981
+
1657
1982
  /** The default wiring — every production transport the rest of the package uses. */
1658
1983
  export function defaultProbes(): Probes {
1659
1984
  return {
@@ -1674,6 +1999,9 @@ export function defaultProbes(): Probes {
1674
1999
  telegramSend: telegramReportSend,
1675
2000
  canonicalUnits: defaultCanonicalUnits,
1676
2001
  herdrInstalled: () => Bun.which("herdr") !== null,
2002
+ pauseFence: (project) => pauseInstance(project),
2003
+ workerHarness: defaultWorkerHarness,
2004
+ pidLive: (pid) => pidAlive(pid),
1677
2005
  herdrAgents: defaultHerdrAgents,
1678
2006
  herdrSession: () => resolveHerdrSessionWithBridge(),
1679
2007
  herdrConfig: defaultHerdrConfig,
@@ -1721,6 +2049,35 @@ export function defaultProbes(): Probes {
1721
2049
  if (tick.kind !== "ok") return undefined;
1722
2050
  return tick.config.agentName ?? DEFAULT_FLEET_AGENT_NAME;
1723
2051
  },
2052
+ installedSurfaces: async () => {
2053
+ // `upgrade`'s own reader, so doctor cannot disagree with the transaction
2054
+ // about what is installed. A host with no herdr is asked only about the
2055
+ // two surfaces it has, rather than failing the whole read (#904).
2056
+ try {
2057
+ const surfaces = await inspectSurfaces(UPGRADE_DEPS, { readHerdr: Bun.which("herdr") !== null });
2058
+ return { ok: true, surfaces };
2059
+ } catch (err) {
2060
+ return { ok: false, detail: messageOf(err) };
2061
+ }
2062
+ },
2063
+ telegramFreshness: () =>
2064
+ // The module's own reader, so `doctor` and `status` cannot disagree about
2065
+ // what is installed — the same one-seam rule `installedSurfaces` follows.
2066
+ checkTelegramFreshness({
2067
+ run: async (cmd, args) => {
2068
+ const r = await UPGRADE_DEPS.run(cmd, args);
2069
+ return { code: r.code, stdout: r.stdout };
2070
+ },
2071
+ }),
2072
+ releaseGitHead: async (version) => {
2073
+ try {
2074
+ return (await releaseIdentity(UPGRADE_DEPS, version)).gitHead;
2075
+ } catch {
2076
+ // An unreadable registry makes the herdr pin unverifiable, never
2077
+ // wrong: the probe says so instead of inventing a mismatch.
2078
+ return undefined;
2079
+ }
2080
+ },
1724
2081
  now: Date.now,
1725
2082
  probeTelegram: false,
1726
2083
  };
@@ -103,6 +103,23 @@ export function infraLogSignature(log: string): string | undefined {
103
103
  return INFRA_LOG_SIGNATURES.find((signature) => lower.includes(signature));
104
104
  }
105
105
 
106
+ /**
107
+ * The cap kills that produced nothing: the worker reached a ceiling with no PR,
108
+ * no observed head and no salvage commit.
109
+ *
110
+ * Named here — beside the table that assigns them — because two callers need
111
+ * exactly the same set and neither may guess at it: the escalate recovery
112
+ * quotes the transcript's last tool calls for these classes, and the one-shot
113
+ * model escalation (#807) fires only for these. Their `-progress` siblings are
114
+ * deliberately absent: a cap kill *with* work to continue from is a
115
+ * decomposition verdict, and retrying it on a stronger tier would hide an
116
+ * oversized slice behind a capability problem.
117
+ */
118
+ export const SPINNING_CAP_CLASSES: readonly FailureClass[] = [
119
+ "turn-cap-spinning",
120
+ "wall-clock-cap-spinning",
121
+ ];
122
+
106
123
  /**
107
124
  * A stable fingerprint of the infrastructure signature list (#638). The
108
125
  * historical reconciliation persists a per-project review cursor stamped with
@@ -315,6 +332,48 @@ export function dispatchInfra(
315
332
  // front of the thrown message, so a bare /^git / would never match a real row.
316
333
  const DISPATCH_GIT_ERROR = /^(?:Error: )?git .+ exited \d+/s;
317
334
 
335
+ /**
336
+ * Evidence that one review round's resumed session never got as far as a turn,
337
+ * or `undefined` when the round actually ran (#903).
338
+ *
339
+ * The measurement is the round's OWN session, never the run row's cumulative
340
+ * counters: a revision reuses its run, so `run.turns` already carries every
341
+ * turn the original attempt spent and would never read zero.
342
+ *
343
+ * Deliberately wider than {@link neverStarted}: any zero-turn round that
344
+ * produced nothing counts, whatever the error text says. The reference
345
+ * incident is why. During the 2026-08-21/22 worker-UID outage every revision
346
+ * dispatch died at turn 0 on `EACCES .../config.yml` — an error string no
347
+ * signature list contained — and each death still consumed one of the PR's
348
+ * three review rounds, until the ceiling refused any further round and the PR
349
+ * could not be finished at all. The discriminator that matters is "did the
350
+ * resumed session take a turn", not which errno the host produced; matching on
351
+ * errno text is how the next outage spells its failure differently and lands
352
+ * back in the same deadlock.
353
+ *
354
+ * Two things keep the widening honest, and both are the caller's:
355
+ * - it decides only whether a round is CHARGED, never whether a run is
356
+ * classified — run-level classification is untouched, so an unrecognised
357
+ * turn-0 run still reads `unknown` and still spends its attempt;
358
+ * - a retried round is bounded by {@link REVIEW_ROUND_INFRA_MAX_RETRIES}, so
359
+ * a permanently broken host escalates instead of retrying forever.
360
+ *
361
+ * A round that produced a PR, a head or a salvage did work no matter what its
362
+ * turn counter says, and is never this.
363
+ */
364
+ export function reviewRoundNeverWorked(round: {
365
+ turns: number;
366
+ prUrl?: string;
367
+ headSha?: string;
368
+ salvageSha?: string;
369
+ }): string | undefined {
370
+ if (round.turns !== 0) return undefined;
371
+ if (round.prUrl !== undefined || round.headSha !== undefined || round.salvageSha !== undefined) {
372
+ return undefined;
373
+ }
374
+ return "the resumed session never took a turn: no turn, no commit and no pushed head";
375
+ }
376
+
318
377
  /** The prefix the draining/restarting process writes to a run it killed. */
319
378
  const ADMIN_RESTART_MARKER = "admin restart:";
320
379