omp-conductor 0.18.1 → 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 (69) hide show
  1. package/README.md +106 -41
  2. package/REFERENCE.md +866 -31
  3. package/agents/to-spec.md +6 -2
  4. package/package.json +1 -1
  5. package/schema/config.schema.json +32 -1
  6. package/src/admission.ts +212 -26
  7. package/src/arm-challenge.ts +250 -57
  8. package/src/ask.ts +288 -1
  9. package/src/briefs/orchestrator.md +27 -13
  10. package/src/briefs/to-spec.md +6 -2
  11. package/src/cli.ts +127 -2
  12. package/src/command-help.ts +9 -1
  13. package/src/command-manifest.ts +52 -8
  14. package/src/commands/arm.ts +6 -2
  15. package/src/commands/context.ts +2 -0
  16. package/src/commands/intake.ts +4 -19
  17. package/src/commands/message.ts +26 -2
  18. package/src/commands/reconcile-units.ts +104 -0
  19. package/src/commands/release-composition.ts +232 -0
  20. package/src/commands/resume.ts +2 -27
  21. package/src/commands/setup.ts +101 -16
  22. package/src/commands/stats.ts +11 -30
  23. package/src/commands/tail.ts +31 -1
  24. package/src/commands/upgrade.ts +20 -3
  25. package/src/commands/verb.ts +2 -1
  26. package/src/commands/watch.ts +4 -17
  27. package/src/config-schema.ts +38 -6
  28. package/src/config.ts +103 -8
  29. package/src/credential-class.ts +366 -0
  30. package/src/daemon.ts +1368 -529
  31. package/src/dashboard/app.js +504 -2
  32. package/src/dashboard/controls.ts +336 -0
  33. package/src/dashboard/index.html +30 -0
  34. package/src/dashboard/server.ts +271 -30
  35. package/src/dashboard/style.css +116 -0
  36. package/src/dashboard/transcript.ts +173 -0
  37. package/src/decisions.ts +19 -11
  38. package/src/doctor.ts +431 -148
  39. package/src/escalate.ts +22 -11
  40. package/src/failure-class.ts +59 -0
  41. package/src/fleet.ts +587 -230
  42. package/src/host.ts +6 -455
  43. package/src/omp-settings.ts +19 -0
  44. package/src/omp.ts +40 -56
  45. package/src/orchestrator-tick.ts +564 -121
  46. package/src/pause.ts +233 -0
  47. package/src/session-host.ts +6 -41
  48. package/src/settlement.ts +159 -2
  49. package/src/setup-answers.ts +97 -0
  50. package/src/setup-host.ts +343 -1160
  51. package/src/setup-install.ts +204 -27
  52. package/src/setup-wizard.ts +252 -51
  53. package/src/setup.ts +87 -4
  54. package/src/spend-telemetry.ts +117 -0
  55. package/src/stats.ts +35 -0
  56. package/src/status-render.ts +485 -19
  57. package/src/store.ts +1229 -55
  58. package/src/telegram-freshness.ts +269 -0
  59. package/src/to-spec.ts +50 -2
  60. package/src/types.ts +759 -10
  61. package/src/unblock.ts +22 -0
  62. package/src/unit-reconcile.ts +303 -0
  63. package/src/upgrade-verify.ts +8 -1
  64. package/src/upgrade.ts +299 -12
  65. package/src/verbs/actions.ts +124 -10
  66. package/src/verbs/protocol.ts +70 -2
  67. package/src/verbs/server.ts +485 -11
  68. package/src/wake.ts +48 -0
  69. package/src/worker.ts +401 -14
package/src/doctor.ts CHANGED
@@ -46,9 +46,11 @@ import {
46
46
  dbBackupDirFor,
47
47
  findProject,
48
48
  loadConfig,
49
+ resolveArmProof,
49
50
  resolveCaps,
50
51
  stateDir,
51
52
  } from "./config.ts";
53
+ import { observeArmChallenge } from "./arm-challenge.ts";
52
54
  import { ompSettingsOverlay, sessionRootDir } from "./omp-settings.ts";
53
55
  import {
54
56
  DEFAULT_HERDR_UNIT,
@@ -59,6 +61,7 @@ import {
59
61
  sessionDirForCwd,
60
62
  telegramStateDir,
61
63
  } from "./fleet.ts";
64
+ import { pauseInstance } from "./pause.ts";
62
65
  import type { TelegramHealth } from "./status-render.ts";
63
66
  import {
64
67
  claimedTelegramTopics,
@@ -85,10 +88,7 @@ import {
85
88
  SYSTEMD_UNIT_DIR,
86
89
  tickCwdForProject,
87
90
  totalConfiguredWorkers,
88
- workerAclHealth,
89
- type WorkerAclHealth,
90
91
  } from "./setup-host.ts";
91
- import { WORKER_ACCOUNT } from "./host.ts";
92
92
  import { checkTokenScopes, type ScopeCheck } from "./setup.ts";
93
93
  import { DB_SNAPSHOT_STEM, dbPath, LIVE_STATES } from "./store.ts";
94
94
  import { telegramReportSend, type ReportSend } from "./reports.ts";
@@ -101,6 +101,27 @@ import {
101
101
  type HerdrAgentList,
102
102
  } from "./orchestrator-tick.ts";
103
103
  import type { ConductorConfig, ProjectConfig, RepoTarget, RunState } from "./types.ts";
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 };
104
125
 
105
126
  /**
106
127
  * `doctor`'s finding vocabulary. `pass`/`warn`/`fail` — a warning renders the
@@ -132,8 +153,18 @@ export interface DoctorReport {
132
153
  findings: Finding[];
133
154
  }
134
155
 
135
- /** How many of the most recent completed runs are sampled for spend telemetry. */
156
+ /** How many *working* runs the spend-telemetry judgement wants. */
136
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;
137
168
 
138
169
  /** The past incidents this doctor flags, quoted in the finding so an operator
139
170
  * who never lived them knows which failure mode the check exists to prevent. */
@@ -145,10 +176,6 @@ const INCIDENTS = {
145
176
  spend:
146
177
  "spend telemetry was once absent, so the USD cap never fired — $0.00 spend is not proof of no spend",
147
178
  ghauth: "gh auth expired under a live daemon and every tracker call failed silently",
148
- workerAcl:
149
- "the #835 incident: a setup host granted the worker's path ACLs before restarting the fleet, an OMP startup chmod'd " +
150
- "the agent config dir back to 0700 and rewrote the ACL mask, and the next two admitted workers died on EACCES " +
151
- "before connecting — the named ACL entry was still there, only its effective permissions were gone",
152
179
  } as const;
153
180
 
154
181
  // ------------------------------------------------------------------ dependencies
@@ -173,10 +200,12 @@ export interface CanonicalUnits {
173
200
  }
174
201
 
175
202
  /** One sampled run row: `state` keeps spend from in-flight rows, which are not
176
- * 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). */
177
205
  export interface RunSpendRow {
178
206
  state: RunState;
179
207
  spendUsd: number;
208
+ turns: number;
180
209
  }
181
210
 
182
211
  /** Injectable seams. Every field defaults to the production wiring, which
@@ -223,6 +252,21 @@ export interface DoctorDeps {
223
252
  canonicalUnits?: (project: ProjectConfig, cfg: ConductorConfig) => CanonicalUnits;
224
253
  /** Whether herdr is installed on this host (a `herdr` on PATH). */
225
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;
226
270
  /** Live `herdr --session <s> agent list`, parsed through the tick's own
227
271
  * parser (the same "one JSON line on stdout" contract recover.sh reads). */
228
272
  herdrAgents?: (session: string) => HerdrAgentList;
@@ -256,8 +300,10 @@ export interface DoctorDeps {
256
300
  /** The topic id an arm challenge would send into for this project —
257
301
  * resolveProjectTopicId's pin-or-live-claim; undefined means flat chat. */
258
302
  armSendTopic?: (project: ProjectConfig) => number | undefined;
259
- /** The session directories arm scans for the challenge reply on this
260
- * project — tick-cwd-derived plus the live claim's own directory. */
303
+ /** The session surface the claim-only plumbing verdict judges for this
304
+ * project — tick-cwd-derived plus the live claim's own directory. The
305
+ * challenge proof reads conductor state instead (#614), so this feeds
306
+ * claim-only verdict checks only. */
261
307
  armScanDirs?: (project: ProjectConfig) => readonly string[];
262
308
  /** Whether a recorded claim or dm-owner pid is live, with omp-telegram's
263
309
  * topics.ts semantics (EPERM is dead). */
@@ -273,15 +319,16 @@ export interface DoctorDeps {
273
319
  /** The fleet agent name the tick config of one project names, or undefined
274
320
  * when there is no (readable) tick — the expected live herdr pane identity. */
275
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>;
276
330
  /** Clock, so a run is deterministic in tests. */
277
331
  now?: () => number;
278
- /** The linked worker config paths' effective-ACL verdict for the dedicated
279
- * worker account (#835): `checkable` paths judged, `missing` the ones that
280
- * do not currently grant the worker's needed effective access. Read-only
281
- * and injectable so no test ever touches the host's ACLs; the production
282
- * wiring is the identity plan's own getfacl probe, so `doctor` and
283
- * `setup host` cannot disagree about an ACL. */
284
- workerAclHealth?: () => WorkerAclHealth;
285
332
  /** The one opt-in side effect: send one self-identified Telegram probe. */
286
333
  probeTelegram?: boolean;
287
334
  }
@@ -452,8 +499,8 @@ function defaultRecentRuns(project: string, limit: number): RunSpendRow[] {
452
499
  try {
453
500
  const placeholders = [...LIVE_STATES].map(() => "?").join(", ");
454
501
  const rows = database
455
- .query<{ state: string; spendUsd: number }, [string, ...string[], number]>(
456
- `SELECT state, spendUsd FROM runs
502
+ .query<{ state: string; spendUsd: number; turns: number }, [string, ...string[], number]>(
503
+ `SELECT state, spendUsd, turns FROM runs
457
504
  WHERE project = ? AND state NOT IN (${placeholders})
458
505
  ORDER BY startedAt DESC, rowid DESC
459
506
  LIMIT ?`,
@@ -463,7 +510,9 @@ function defaultRecentRuns(project: string, limit: number): RunSpendRow[] {
463
510
  for (const row of rows) {
464
511
  const state = row.state as RunState;
465
512
  // A state vocabulary the store never had is not "completed".
466
- 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
+ }
467
516
  }
468
517
  return out;
469
518
  } catch {
@@ -762,6 +811,12 @@ function unresolvableShell(unit: string): boolean {
762
811
  * state resolved at staging time, so a rendered value doctor cannot reproduce
763
812
  * is excluded from both sides rather than reported as a difference — an
764
813
  * unresolvable canonical is "cannot compare", never drift (#511).
814
+ *
815
+ * The service `PATH=` line has no such exemption: since #879 it is canonical
816
+ * on both sides (`defaultServiceRuntime` composes it from the resolved fleet
817
+ * binaries plus the standard system directories, never from the rendering
818
+ * process's environment), so a differing installed value is genuine drift and
819
+ * must fail here with the value named.
765
820
  */
766
821
  export function unitDrift(installed: string, canonical: string): { differences: string[] } {
767
822
  const want = canonical
@@ -916,36 +971,6 @@ function ownershipProbe(probes: Probes): Finding {
916
971
  return failFinding("systemd-ownership", summary, fix);
917
972
  }
918
973
 
919
- /**
920
- * The dedicated worker account's path ACLs (#835): every linked worker config
921
- * path must grant the worker its *effective* search/read permissions. The
922
- * installed OMP harness chmods its agent config dir back to 0700 on every
923
- * open, and a chmod rewrites the ACL mask — the named entry survives as
924
- * `user:omp-worker:--x #effective:---` while the worker's access silently
925
- * vanishes, which is exactly the incident this finding exists to catch
926
- * (`${INCIDENTS.workerAcl}`). The verdict comes from the identity plan's own
927
- * probe through the read-only {@link DoctorDeps.workerAclHealth} seam, so a
928
- * pass here is a pass the plan agrees with and a fail names the paths whose
929
- * mask is stripping the grant.
930
- */
931
- function workerAclProbeFinding(probes: Probes): Finding {
932
- const health = probes.workerAclHealth();
933
- if (health.checkable === 0) {
934
- return passFinding("worker-acl", "no linked worker config paths on this host yet — nothing to check");
935
- }
936
- if (health.missing.length === 0) {
937
- return passFinding(
938
- "worker-acl",
939
- `${health.checkable} linked worker config path(s) grant ${WORKER_ACCOUNT} effective search/read access`,
940
- );
941
- }
942
- return failFinding(
943
- "worker-acl",
944
- `the ${WORKER_ACCOUNT} account's ACL is not effective on ${health.missing.join(", ")} — a named entry the ACL mask strips is ` +
945
- "unreachable by the worker, and the next admitted worker dies on EACCES before connecting",
946
- "re-run `omp-conductor setup host`: the transaction re-applies the ACL grants (mask included) after the final fleet restart",
947
- );
948
- }
949
974
 
950
975
  /** IANA timezone in reporting config — an invalid zone silently mis-schedules
951
976
  * the availability window and the daily digest (#273). */
@@ -1071,22 +1096,40 @@ async function telegramProbe(probes: Probes, project: ProjectConfig | undefined,
1071
1096
  );
1072
1097
  }
1073
1098
 
1074
- /** Spend telemetry: warn when the last K completed runs all recorded $0.00 —
1075
- * 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
+ */
1076
1108
  function spendProbe(rows: RunSpendRow[], limit: number): Finding {
1077
- if (rows.length < limit) {
1078
- return passFinding("spend-telemetry", `${rows.length} completed run(s) observed — fewer than ${limit}, nothing to judge yet`);
1079
- }
1080
- const window = rows.slice(0, limit);
1081
- if (window.every((r) => r.spendUsd === 0)) {
1109
+ const verdict = judgeSpendTelemetry(rows, limit);
1110
+ const detail = spendTelemetryDetail(verdict);
1111
+ if (detail !== undefined) {
1082
1112
  return warnFinding(
1083
1113
  "spend-telemetry",
1084
- `the last ${limit} completed runs all recorded $0.00 spend — ${INCIDENTS.spend}`,
1114
+ `${detail} — ${INCIDENTS.spend}`,
1085
1115
  "verify harness spend reporting (the per-run spendUsd column / `omp usage --json`); a USD cap on top of zeros never fires",
1086
1116
  );
1087
1117
  }
1088
- const total = window.reduce((sum, r) => sum + r.spendUsd, 0);
1089
- 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
+ );
1090
1133
  }
1091
1134
 
1092
1135
  /** Live `herdr --session <session> agent list` through the project's own
@@ -1173,91 +1216,43 @@ function herdrAgentNameProbe(probes: Probes, p: ProjectConfig): Finding {
1173
1216
  }
1174
1217
 
1175
1218
  /**
1176
- * #600 check — the orchestrator's claimed session file vs the directory arm
1177
- * scans.
1219
+ * #614 check — the state of this project's arming acknowledgement transaction.
1178
1220
  *
1179
- * A pane resumed from a session created elsewhere keeps the original transcript
1180
- * path (herdr pins the pane to it), so the claimed file can live outside the
1181
- * session directory the tick cwd implies. arm follows the claim, so the
1182
- * mismatch alone is handled but it is exactly the shape of host that once
1183
- * made arming silently impossible, and an operator reading transcript paths
1184
- * should not have to rediscover it. A claimed file *outside the session tree*
1185
- * is worse: arm refuses to arm there (the reply could never be seen), so
1186
- * doctor fails rather than letting the fleet sit disarmed.
1221
+ * The challenge proof is conductor-owned state now: the host records a pending
1222
+ * challenge and waits for the acknowledgement the orchestrator's inbound
1223
+ * adapter writes. No transcript location is diagnosed any more where (or
1224
+ * whether) a session file lives is not part of arming so what is worth
1225
+ * reporting is a transaction left open: a challenge still pending and whether
1226
+ * it has been acknowledged, or an expired record nothing settled. Every shape
1227
+ * is inert by construction (a reply past expiry, or for a replaced id, is
1228
+ * refused), so this informs; only the stale leftover warns.
1187
1229
  */
1188
- function armSessionDirProbe(probes: Probes, p: ProjectConfig): Finding {
1189
- const result = probes.claimedTopics();
1190
- if (result.kind !== "ok") {
1191
- // The registry could not be read, so the claim's session file is unknown,
1192
- // not known-absent. Arm scans the tick-cwd directory either way, but the
1193
- // finding must stay diagnostic — this is exactly the shape that once made
1194
- // arming silently impossible, and "no claim" would certify it as fine. A
1195
- // missing registry is the same unknown to this probe: the bridge may never
1196
- // have claimed, but nothing proves the claim names no session file.
1230
+ function armAckProbe(probes: Probes, p: ProjectConfig): Finding {
1231
+ const now = probes.now();
1232
+ const sighting = observeArmChallenge(p.name);
1233
+ if (sighting === undefined) {
1234
+ return passFinding("arm-ack", `[${p.name}] no pending arm challenge the acknowledgement handshake is idle`);
1235
+ }
1236
+ const window =
1237
+ sighting.sentAt === undefined || sighting.expiresAt === undefined
1238
+ ? "an unreadable record window"
1239
+ : `sent ${new Date(sighting.sentAt).toISOString()}, expires ${new Date(sighting.expiresAt).toISOString()}`;
1240
+ if (sighting.expiresAt !== undefined && now >= sighting.expiresAt) {
1197
1241
  return warnFinding(
1198
- "arm-session-dir",
1199
- `[${p.name}] ` +
1200
- (result.kind === "missing"
1201
- ? `omp-telegram has no claim registry (threads.json in its state dir) — arm scans the tick-cwd session directory, but doctor cannot tell whether the claim names a session file elsewhere`
1202
- : `omp-telegram's claim registry is unreadable (${result.problem}) — arm scans the tick-cwd session directory, but doctor cannot tell whether the claim names a session file elsewhere`),
1203
- `check omp-telegram's claim registry (threads.json in its state dir) is readable and the bridge is running, then re-run doctor`,
1242
+ "arm-ack",
1243
+ `[${p.name}] an arming challenge from ${window} expired without being settled — it is inert (replies past expiry are refused), but it lingers until this project's next arm replaces it`,
1244
+ `no action needed; the next \`arm\` for this project settles it — re-run doctor afterwards to confirm`,
1204
1245
  );
1205
1246
  }
1206
- if (result.claims.length === 0) {
1247
+ if (sighting.acknowledgedAt !== undefined) {
1207
1248
  return passFinding(
1208
- "arm-session-dir",
1209
- `[${p.name}] no live omp-telegram claim arm scans the tick-cwd session directory`,
1210
- );
1211
- }
1212
- const claims = result.claims;
1213
- const match = resolveProjectClaim(claims, p.name);
1214
- const scanDir = sessionDirForCwd(tickCwdForProject(p));
1215
- if (match.kind === "ambiguous") {
1216
- // Several live claims answer to the project, so the claim's session file
1217
- // cannot be told from a sibling pane's. Arm scans the tick-cwd directory
1218
- // either way, but certifying that fallback as the scan is the miss this
1219
- // probe exists to surface: the resumed transcript may live under another
1220
- // claim while doctor reads the fallback as the whole story (#626).
1221
- return warnFinding(
1222
- "arm-session-dir",
1223
- `[${p.name}] several live claims answer to this project (topics ${match.claimants.map((c) => c.threadId).join(", ")}), so the claim's session file is a coin toss — arm scans the tick-cwd session directory, but the resumed transcript may live elsewhere`,
1224
- `make exactly one live claim answer to this project: rename the other panes' herdr spaces or re-claim them one at a time, then re-run doctor — or start the orchestrator pane's session from the tick cwd ${scanDir}`,
1225
- );
1226
- }
1227
- const claim = match.kind === "match" ? match.claim : undefined;
1228
- if (claim === undefined) {
1229
- return passFinding(
1230
- "arm-session-dir",
1231
- `[${p.name}] no live omp-telegram claim answers to this project — arm scans the tick-cwd session directory`,
1232
- );
1233
- }
1234
- if (claim.sessionFile === undefined) {
1235
- return passFinding(
1236
- "arm-session-dir",
1237
- `[${p.name}] the live claim names no session file — arm scans the tick-cwd session directory`,
1238
- );
1239
- }
1240
- const root = sessionsRoot();
1241
- const claimDir = dirname(claim.sessionFile);
1242
- if (claimDir !== root && !claimDir.startsWith(join(root, sep))) {
1243
- return failFinding(
1244
- "arm-session-dir",
1245
- `[${p.name}] the orchestrator's claimed session file ${claim.sessionFile} is outside the session tree arm scans (${root}) — ` +
1246
- `arm cannot arm while the reply can only land there`,
1247
- `start the orchestrator pane's session under ${root}, or from the tick cwd ${scanDir}`,
1248
- );
1249
- }
1250
- if (claimDir !== scanDir) {
1251
- return warnFinding(
1252
- "arm-session-dir",
1253
- `[${p.name}] the orchestrator's claimed session file ${claim.sessionFile} lives in ${claimDir}, not the tick-cwd session directory ${scanDir} — ` +
1254
- `arm follows the claim, so arming works, but this is the resumed-pane shape #600 names`,
1255
- `optional tidy-up: start the pane's session from the tick cwd so transcript and cwd agree again`,
1249
+ "arm-ack",
1250
+ `[${p.name}] an arm challenge is pending (${window}) and was acknowledged ${new Date(sighting.acknowledgedAt).toISOString()} — the host consumes it on settle`,
1256
1251
  );
1257
1252
  }
1258
1253
  return passFinding(
1259
- "arm-session-dir",
1260
- `[${p.name}] the orchestrator's claimed session file is inside the session directory arm scans`,
1254
+ "arm-ack",
1255
+ `[${p.name}] an arm challenge is pending (${window}), not yet acknowledged live if \`arm\` is running right now, otherwise its window simply runs out`,
1261
1256
  );
1262
1257
  }
1263
1258
 
@@ -1379,9 +1374,20 @@ function telegramPlumbingProbe(probes: Probes, p: ProjectConfig): Finding {
1379
1374
  `[${p.name}] outbound-only paging — no tick accessFile to arm, so no live claim, DM owner or poll ownership is required`,
1380
1375
  );
1381
1376
  }
1377
+ // Transcript-surface identity checks belong to the claim-only proof alone:
1378
+ // since #614 the challenge proof reads conductor acknowledgement state, so
1379
+ // judging it by where a transcript lives could report arming impossible
1380
+ // while `arm` succeeds. An unreadable config keeps the default proof —
1381
+ // fail-safe to the stricter verdict, exactly like armTicks.
1382
+ let proof: ArmProof = DEFAULT_ARM_PROOF;
1383
+ try {
1384
+ proof = resolveArmProof(p);
1385
+ } catch {
1386
+ /* unreadable config — keep today's stricter default */
1387
+ }
1382
1388
  const verdict = telegramPlumbingVerdict(
1383
1389
  probes.armSendTopic(p),
1384
- { dirs: probes.armScanDirs(p) },
1390
+ proof === "claim-only" ? { dirs: probes.armScanDirs(p) } : undefined,
1385
1391
  {
1386
1392
  channel: probes.channelState(p),
1387
1393
  registry: probes.claimedTopics(),
@@ -1478,6 +1484,45 @@ function telegramPlumbingProbe(probes: Probes, p: ProjectConfig): Finding {
1478
1484
  * pages instead of recovering. Absent ~ the unrecoverable default; an explicit
1479
1485
  * `true` is a deliberate (desktop) choice and only warned.
1480
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
+
1481
1526
  function herdrResumeProbe(probes: Probes): Finding {
1482
1527
  if (!probes.herdrInstalled()) {
1483
1528
  return passFinding("herdr-resume", "herdr not installed — nothing to check");
@@ -1572,6 +1617,169 @@ function parseEnvKey(text: string, key: string): string | undefined {
1572
1617
  return undefined;
1573
1618
  }
1574
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
+
1575
1783
  /**
1576
1784
  * Run every probe and assemble the stable report.
1577
1785
  *
@@ -1641,6 +1849,17 @@ export async function runDoctor(projectName: string | undefined, opts: DoctorDep
1641
1849
  findings.push(dbProbe(probes));
1642
1850
  findings.push(dbBackupProbe(probes, cfg));
1643
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()));
1644
1863
  // The run-session root is one host-wide fact — where every run's session dir
1645
1864
  // (and the omp settings overlay inside it) lands — probed once and shared
1646
1865
  // across the per-project omp-settings findings.
@@ -1649,7 +1868,7 @@ export async function runDoctor(projectName: string | undefined, opts: DoctorDep
1649
1868
  // The collected run sample across the resolved project set, newest-first;
1650
1869
  // spend telemetry is a single host-wide finding, not one per project.
1651
1870
  const spendRows: RunSpendRow[] = [];
1652
- 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));
1653
1872
 
1654
1873
  // Per-project probes run for every resolved project, each named in its
1655
1874
  // finding so a multi-project run stays legible. When nothing resolved
@@ -1663,7 +1882,7 @@ export async function runDoctor(projectName: string | undefined, opts: DoctorDep
1663
1882
  passFinding("herdr-agent-name", projectProblem === undefined ? "no project resolved — nothing to check" : `agent name uncheckable: ${projectProblem}`),
1664
1883
  );
1665
1884
  findings.push(
1666
- passFinding("arm-session-dir", projectProblem === undefined ? "no project resolved — nothing to check" : `arm session dir uncheckable: ${projectProblem}`),
1885
+ passFinding("arm-ack", projectProblem === undefined ? "no project resolved — nothing to check" : `arm acknowledgement uncheckable: ${projectProblem}`),
1667
1886
  );
1668
1887
  findings.push(
1669
1888
  passFinding("topic-pin", projectProblem === undefined ? "no project resolved — nothing to check" : `topic pin uncheckable: ${projectProblem}`),
@@ -1678,7 +1897,7 @@ export async function runDoctor(projectName: string | undefined, opts: DoctorDep
1678
1897
  for (const p of projects) {
1679
1898
  findings.push(await labelProbe(probes, p));
1680
1899
  findings.push(herdrAgentNameProbe(probes, p));
1681
- findings.push(armSessionDirProbe(probes, p));
1900
+ findings.push(armAckProbe(probes, p));
1682
1901
  findings.push(topicPinProbe(probes, p));
1683
1902
  findings.push(telegramPlumbingProbe(probes, p));
1684
1903
  findings.push(ompSettingsProbe(p, sessionRootState));
@@ -1690,10 +1909,6 @@ export async function runDoctor(projectName: string | undefined, opts: DoctorDep
1690
1909
  findings.push(unitProbe(probes, projects[0], cfg));
1691
1910
  findings.push(recoveryProbe(probes));
1692
1911
  findings.push(ownershipProbe(probes));
1693
- // The worker's path ACLs are a host-wide fact like the unit ownership: one
1694
- // shared daemon account, one shared worker identity, one shared set of
1695
- // linked config paths. A finding once, never once per project.
1696
- findings.push(workerAclProbeFinding(probes));
1697
1912
  // #541 seam checks, host-global: the live herdr config and the plugin's
1698
1913
  // config.env are single files on the host, not per-project facts.
1699
1914
  findings.push(herdrResumeProbe(probes));
@@ -1701,9 +1916,13 @@ export async function runDoctor(projectName: string | undefined, opts: DoctorDep
1701
1916
  if (projects.length === 0) {
1702
1917
  findings.push(timezoneProbe(undefined));
1703
1918
  findings.push(await telegramProbe(probes, undefined, checkedAt));
1919
+ findings.push(fenceProbe(probes, undefined));
1704
1920
  } else {
1705
1921
  for (const p of projects) findings.push(timezoneProbe(p));
1706
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));
1707
1926
  }
1708
1927
  findings.push(spendProbe(spendRows, SPEND_SAMPLE_RUNS));
1709
1928
 
@@ -1725,6 +1944,41 @@ export async function runDoctor(projectName: string | undefined, opts: DoctorDep
1725
1944
  };
1726
1945
  }
1727
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
+
1728
1982
  /** The default wiring — every production transport the rest of the package uses. */
1729
1983
  export function defaultProbes(): Probes {
1730
1984
  return {
@@ -1737,9 +1991,6 @@ export function defaultProbes(): Probes {
1737
1991
  readUnit: defaultReadUnit,
1738
1992
  stat: defaultStat,
1739
1993
  uidOf: defaultUidOf,
1740
- // The same effective-ACL read the identity plan plans with — doctor and
1741
- // setup cannot disagree about a worker grant (#835).
1742
- workerAclHealth: () => workerAclHealth(join(homedir(), ".omp", "agent")),
1743
1994
  dbIntegrity: defaultDbIntegrity,
1744
1995
  snapshotDirState: defaultSnapshotDirState,
1745
1996
  sessionRootState: defaultSessionRootState,
@@ -1748,6 +1999,9 @@ export function defaultProbes(): Probes {
1748
1999
  telegramSend: telegramReportSend,
1749
2000
  canonicalUnits: defaultCanonicalUnits,
1750
2001
  herdrInstalled: () => Bun.which("herdr") !== null,
2002
+ pauseFence: (project) => pauseInstance(project),
2003
+ workerHarness: defaultWorkerHarness,
2004
+ pidLive: (pid) => pidAlive(pid),
1751
2005
  herdrAgents: defaultHerdrAgents,
1752
2006
  herdrSession: () => resolveHerdrSessionWithBridge(),
1753
2007
  herdrConfig: defaultHerdrConfig,
@@ -1795,6 +2049,35 @@ export function defaultProbes(): Probes {
1795
2049
  if (tick.kind !== "ok") return undefined;
1796
2050
  return tick.config.agentName ?? DEFAULT_FLEET_AGENT_NAME;
1797
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
+ },
1798
2081
  now: Date.now,
1799
2082
  probeTelegram: false,
1800
2083
  };