omp-conductor 0.19.5 → 0.19.7

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/src/daemon.ts CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  loadConfig,
18
18
  resolveCaps,
19
19
  resolveReleaseGrants,
20
+ resolveSharedInstallAuthority,
20
21
  resolveReview,
21
22
  stateDir,
22
23
  } from "./config.ts";
@@ -63,6 +64,7 @@ import { inspectSurfaces, type InstalledSurfaces } from "./upgrade.ts";
63
64
  import { checkTelegramFreshness, type TelegramFreshness } from "./telegram-freshness.ts";
64
65
  import { processStartTimeMs } from "./upgrade-verify.ts";
65
66
  import {
67
+ closeWorkerPane,
66
68
  fleetLayers,
67
69
  herdrPaneOmpStarts,
68
70
  openWorkerPane,
@@ -72,6 +74,9 @@ import {
72
74
  releaseWorkerPane,
73
75
  reportWorkerPaneState,
74
76
  resolveHerdrSession,
77
+ retireWorkerPane,
78
+ type HerdrRun,
79
+ type WorkspaceOwnership,
75
80
  type WorkerPaneOutcome,
76
81
  } from "./fleet.ts";
77
82
  import { startOrchestrator } from "./orchestrator.ts";
@@ -171,6 +176,7 @@ import type {
171
176
  DigestBacklog,
172
177
  Escalation,
173
178
  InterruptCategory,
179
+ HandoffWithdrawal,
174
180
  IssueComment,
175
181
  IssueSnapshot,
176
182
  PrDiff,
@@ -338,6 +344,12 @@ interface Deps {
338
344
  workerDeps?: RunWorkerDeps;
339
345
  /** Harness per-process logs used to classify terminal worker routes. */
340
346
  harnessLogDir?: string;
347
+ /**
348
+ * The Herdr runner for the worker-representation surface (#1035). Production
349
+ * leaves it absent — the real `herdr` CLI answers; lifecycle tests inject a
350
+ * fake so convergence is proven without a terminal.
351
+ */
352
+ herdrRun?: HerdrRun;
341
353
  integrity: IntegrityGate;
342
354
  stall: StallGate;
343
355
  /**
@@ -451,6 +463,7 @@ interface Deps {
451
463
  export function verbDeps(d: Pick<Deps, "project" | "store" | "tracker" | "verbActions">): VerbDeps {
452
464
  return {
453
465
  project: () => findProject(loadConfig(), d.project.name),
466
+ projects: () => loadConfig().projects,
454
467
  store: d.store,
455
468
  tracker: d.tracker,
456
469
  actions: d.verbActions ?? githubVerbActions(d.project),
@@ -1909,6 +1922,17 @@ export async function handleIssue(
1909
1922
  // for a worker that has already blocked.
1910
1923
  let paneSeq = 0;
1911
1924
  const nextPaneSeq = (): number => (paneSeq += 1);
1925
+ /**
1926
+ * The injected Herdr runner, when this daemon carries one (#1035); every
1927
+ * representation call below shares it so tests see one argv stream. The
1928
+ * ownership adapter is always present in production — the store-backed
1929
+ * half of workspace discovery, so what a Herdr restart forgets, the
1930
+ * durable row remembers (#1035 review).
1931
+ */
1932
+ const herdr = {
1933
+ ...(d.herdrRun === undefined ? {} : { run: d.herdrRun }),
1934
+ ownership: workspaceOwnership(d.store),
1935
+ };
1912
1936
  /**
1913
1937
  * Project one authoritative transition onto the pane (#842).
1914
1938
  *
@@ -1919,6 +1943,7 @@ export async function handleIssue(
1919
1943
  const projectPaneState = (state: "working" | "idle" | "blocked" | "unknown", message?: string): void => {
1920
1944
  if (workerPane === undefined) return;
1921
1945
  const reported = reportWorkerPaneState(workerPane.paneId, workerPane.label, state, {
1946
+ ...herdr,
1922
1947
  seq: nextPaneSeq(),
1923
1948
  ...(message === undefined ? {} : { message }),
1924
1949
  });
@@ -2526,6 +2551,7 @@ export async function handleIssue(
2526
2551
  workerPane = { kind: "tracked", paneId: recorded.paneId, label: recorded.paneLabel, pid };
2527
2552
  store.updateRun(runId, { workerPid: pid });
2528
2553
  const state = reportWorkerPaneState(recorded.paneId, recorded.paneLabel, "working", {
2554
+ ...herdr,
2529
2555
  seq: nextPaneSeq(),
2530
2556
  });
2531
2557
  log(
@@ -2535,14 +2561,17 @@ export async function handleIssue(
2535
2561
  );
2536
2562
  return;
2537
2563
  }
2538
- const outcome = openWorkerPane({
2539
- project: project.name,
2540
- issue,
2541
- attempt,
2542
- runId,
2543
- pid,
2544
- ...(recorded?.sessionFile === undefined ? {} : { sessionFile: recorded.sessionFile }),
2545
- });
2564
+ const outcome = openWorkerPane(
2565
+ {
2566
+ project: project.name,
2567
+ issue,
2568
+ attempt,
2569
+ runId,
2570
+ pid,
2571
+ ...(recorded?.sessionFile === undefined ? {} : { sessionFile: recorded.sessionFile }),
2572
+ },
2573
+ herdr,
2574
+ );
2546
2575
  if (outcome.kind === "tracked") {
2547
2576
  workerPane = outcome;
2548
2577
  // Durable before it is announced: a pane the store does not know
@@ -2566,8 +2595,14 @@ export async function handleIssue(
2566
2595
  },
2567
2596
  release: () => {
2568
2597
  if (workerPane === undefined) return;
2569
- const released = releaseWorkerPane(workerPane.paneId, workerPane.label, { seq: nextPaneSeq() });
2570
- if (!released.ok) log(`#${issue} herdr pane release failed: ${released.reason}`);
2598
+ // Retirement, not release alone (#1035): a settled run keeps no
2599
+ // representation. The close reaches only the follower; the
2600
+ // authoritative child was never in the pane to be signalled.
2601
+ const retired = retireWorkerPane(workerPane.paneId, workerPane.label, {
2602
+ ...herdr,
2603
+ seq: nextPaneSeq(),
2604
+ });
2605
+ if (!retired.ok) log(`#${issue} herdr pane retirement failed: ${retired.reason}`);
2571
2606
  workerPane = undefined;
2572
2607
  },
2573
2608
  },
@@ -3062,17 +3097,19 @@ export async function handleIssue(
3062
3097
  * losing a dispatch pass over a version string would be absurd.
3063
3098
  */
3064
3099
  /**
3065
- * Make the workspace agree with the live run set, once per pass (#841).
3100
+ * Make the worker workspaces agree with the live run set, once per pass
3101
+ * (#841, reworked by #1035).
3066
3102
  *
3067
3103
  * Runs on every dispatch pass rather than only at startup, because the thing it
3068
3104
  * repairs — a Herdr restart — is not a conductor event and announces itself
3069
3105
  * nowhere. It is idempotent by construction: a reconciled fleet reports `intact`
3070
- * for every live worker and finds no stale panes, so repeated passes converge
3071
- * instead of accumulating panes or churning them.
3106
+ * for every live worker and finds nothing left to close, so repeated passes
3107
+ * converge instead of accumulating panes or churning them. This pass owns this
3108
+ * project's worker workspace even when no run is live — that is how a settled-out
3109
+ * project's stale shells converge to zero and its empty workspace is removed.
3072
3110
  *
3073
- * Nothing here can stop a worker. The only mutations are creating a pane, and
3074
- * releasing one whose run is not live; the authoritative child is never
3075
- * signalled, and no pane is ever closed.
3111
+ * Nothing here can stop a worker: panes hold followers only; the authoritative
3112
+ * child lives in this daemon's process tree and is never signalled.
3076
3113
  */
3077
3114
  /**
3078
3115
  * Pane re-establishment attempts spent per run, for this daemon process only
@@ -3086,7 +3123,25 @@ export async function handleIssue(
3086
3123
  */
3087
3124
  const paneAttemptsSpent = new Map<string, number>();
3088
3125
 
3089
- function reconcilePanes(d: Deps, project: string, log: (message: string) => void): void {
3126
+ /** Exported for the lifecycle tests (#1035): the pass is pure over the store
3127
+ * and the injected Herdr runner, so a test drives it exactly as the tick does. */
3128
+ /** The store-backed {@link WorkspaceOwnership} adapter (#1035 review): the
3129
+ * conductor store is the durable leg of workspace discovery, surviving both
3130
+ * daemon and Herdr restarts. One shape for every caller so they cannot
3131
+ * disagree about what ownership reads. */
3132
+ function workspaceOwnership(store: Deps["store"]): WorkspaceOwnership {
3133
+ return {
3134
+ recordedWorkspaces: (project) => store.workerWorkspaceIds(project),
3135
+ rememberWorkspace: (project, workspaceId) => store.rememberWorkerWorkspace(project, workspaceId),
3136
+ forgetWorkspace: (project, workspaceId) => store.forgetWorkerWorkspace(project, workspaceId),
3137
+ };
3138
+ }
3139
+
3140
+ export function reconcilePanes(
3141
+ d: Pick<Deps, "store" | "herdrRun">,
3142
+ project: string,
3143
+ log: (message: string) => void,
3144
+ ): void {
3090
3145
  const live = d.store.liveRuns(project);
3091
3146
  const liveKeys = new Set(live.map((run) => `${project}\u0000${run.id}`));
3092
3147
  for (const key of [...paneAttemptsSpent.keys()]) {
@@ -3106,8 +3161,10 @@ function reconcilePanes(d: Deps, project: string, log: (message: string) => void
3106
3161
  ...(run.paneLabel === undefined ? {} : { paneLabel: run.paneLabel }),
3107
3162
  ...(run.sessionFile === undefined ? {} : { sessionFile: run.sessionFile }),
3108
3163
  })),
3109
- {},
3164
+ { ...(d.herdrRun === undefined ? {} : { run: d.herdrRun }), ownership: workspaceOwnership(d.store) },
3110
3165
  attempts,
3166
+ PANE_REATTEMPT_MAX,
3167
+ [project],
3111
3168
  );
3112
3169
  if (!result.ok) {
3113
3170
  // An unreadable workspace is not evidence that anything is stale, so nothing
@@ -3120,6 +3177,14 @@ function reconcilePanes(d: Deps, project: string, log: (message: string) => void
3120
3177
  switch (outcome.kind) {
3121
3178
  case "intact":
3122
3179
  break;
3180
+ case "visual-unreadable":
3181
+ // The pane keeps standing: an unreadable follower table is no evidence
3182
+ // of death, and a live worker's representation is never destroyed on a
3183
+ // guess (#1035 review). Said once per pass, not acted on.
3184
+ log(
3185
+ `#${runIssue(live, outcome.runId)} herdr pane ${outcome.paneId} visual unreadable, left standing: ${outcome.reason}`,
3186
+ );
3187
+ break;
3123
3188
  case "reassociated":
3124
3189
  d.store.updateRun(outcome.runId, {
3125
3190
  paneId: outcome.paneId,
@@ -3136,11 +3201,11 @@ function reconcilePanes(d: Deps, project: string, log: (message: string) => void
3136
3201
  break;
3137
3202
  }
3138
3203
  // #998: a terminal refusing to split will refuse the next one too, and
3139
- // each retry made the pile worse — spend the whole budget at once.
3204
+ // each retry made the pile worse — spend the whole budget at once. The
3205
+ // phase is structured now (#1035 review): the old substring sniff died
3206
+ // when the reason strings changed and never fired again.
3140
3207
  const key = `${project}\u0000${outcome.runId}`;
3141
- const spent = outcome.reason.includes("pane_split_failed")
3142
- ? PANE_REATTEMPT_MAX
3143
- : (paneAttemptsSpent.get(key) ?? 0) + 1;
3208
+ const spent = outcome.phase === "split" ? PANE_REATTEMPT_MAX : (paneAttemptsSpent.get(key) ?? 0) + 1;
3144
3209
  paneAttemptsSpent.set(key, spent);
3145
3210
  // Once per run, at the moment the budget runs out — not once per pass.
3146
3211
  // Four identical lines a minute for #986 is what made the real cause
@@ -3156,11 +3221,33 @@ function reconcilePanes(d: Deps, project: string, log: (message: string) => void
3156
3221
  // Already reported when the budget ran out; the run keeps working with
3157
3222
  // no pane, and a later restart gets a fresh budget.
3158
3223
  break;
3159
- case "stale-released":
3160
- log(`released stale herdr pane ${outcome.paneId} (run ${outcome.runId || "unidentified"} is not live)`);
3224
+ case "stale-released": {
3225
+ const what =
3226
+ outcome.cause === "duplicate"
3227
+ ? "closed duplicate herdr pane"
3228
+ : outcome.cause === "unidentified"
3229
+ ? "closed unidentified herdr pane"
3230
+ : "released settled herdr pane";
3231
+ log(`${what} ${outcome.paneId} (run ${outcome.runId || "unidentified"}${outcome.cause === "settled" ? " is not live" : ""})`);
3161
3232
  break;
3162
- case "stale-release-failed":
3163
- log(`stale herdr pane ${outcome.paneId} could not be released: ${outcome.reason}`);
3233
+ }
3234
+ case "stale-release-failed": {
3235
+ const what =
3236
+ outcome.cause === "duplicate"
3237
+ ? "duplicate herdr pane"
3238
+ : outcome.cause === "unidentified"
3239
+ ? "unidentified herdr pane"
3240
+ : "settled herdr pane";
3241
+ log(`${what} ${outcome.paneId} could not be closed: ${outcome.reason}`);
3242
+ break;
3243
+ }
3244
+ case "workspace-removed":
3245
+ log(`removed empty herdr worker workspace ${outcome.workspaceId} for ${outcome.project}`);
3246
+ break;
3247
+ case "workspace-remove-failed":
3248
+ log(
3249
+ `herdr worker workspace ${outcome.workspaceId} for ${outcome.project} could not be removed: ${outcome.reason}`,
3250
+ );
3164
3251
  break;
3165
3252
  }
3166
3253
  }
@@ -6605,6 +6692,8 @@ export interface StatusSnapshot {
6605
6692
  * unknown outcome, or written off. An empty list is the only honest way to
6606
6693
  * say "everything authored this cycle actually went out" (#123). */
6607
6694
  openReports: ReportRecord[];
6695
+ /** Recent successful report/notice withdrawals, newest first. */
6696
+ handoffWithdrawals?: HandoffWithdrawal[];
6608
6697
  /** Ordinary outcomes and deferred escalations not yet associated with an
6609
6698
  * accepted digest report. */
6610
6699
  digestBacklog: DigestBacklog;
@@ -6791,6 +6880,7 @@ export function statusSnapshotFromStore(
6791
6880
  quarantinedRuns: store.quarantinedRuns(p.name),
6792
6881
  turnOverrides: store.listTurnOverrides(p.name),
6793
6882
  openReports: store.openReports(p.name),
6883
+ handoffWithdrawals: store.handoffWithdrawals(p.name),
6794
6884
  digestBacklog: store.digestBacklog(p.name),
6795
6885
  verbLedger: store.verbLedger(p.name, { limit: STATUS_LEDGER_SCAN }),
6796
6886
  liveWorkers: live.length,
@@ -7199,11 +7289,18 @@ interface ProjectRuntime {
7199
7289
  reportPass?: Promise<void>;
7200
7290
  }
7201
7291
 
7202
- function orchestratorStandingOrders(project: ProjectConfig): {
7292
+ export function orchestratorStandingOrders(
7293
+ project: ProjectConfig,
7294
+ projects: readonly ProjectConfig[],
7295
+ ): {
7203
7296
  brief: string;
7204
7297
  releaseGrants: ResolvedGrants;
7205
7298
  } {
7206
- const releaseGrants = resolveReleaseGrants(project);
7299
+ const installAuthority = resolveSharedInstallAuthority(projects);
7300
+ const releaseGrants = {
7301
+ ...resolveReleaseGrants(project),
7302
+ install: installAuthority.holder ?? "human",
7303
+ };
7207
7304
  const grantedShapes = RELEASE_SHAPES.filter((shape) => releaseGrants[shape] === "orchestrator");
7208
7305
  const deniedShapes = RELEASE_SHAPES.filter((shape) => releaseGrants[shape] !== "orchestrator");
7209
7306
  return {
@@ -7230,6 +7327,13 @@ function orchestratorStandingOrders(project: ProjectConfig): {
7230
7327
  ? "every release and deploy shape is mechanically blocked for you"
7231
7328
  : `you may invoke ${grantedShapes.join(", ")} — and only by the procedure in your POLICY.md`
7232
7329
  }.` + (deniedShapes.length === 0 ? "" : ` Blocked: ${deniedShapes.join(", ")}.`),
7330
+ ...(installAuthority.holder === undefined
7331
+ ? [
7332
+ `Host-global install authority conflicts: ${installAuthority.entries
7333
+ .map((entry) => `${entry.project}=${entry.holder}`)
7334
+ .join(", ")}. Install is blocked.`,
7335
+ ]
7336
+ : []),
7233
7337
  "Handle each escalation below before the next one.",
7234
7338
  ].join("\n"),
7235
7339
  };
@@ -7410,15 +7514,22 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
7410
7514
  // The workspace outlives the process, so a restart inherits panes whose
7411
7515
  // workers are gone (#842). A session-host child dies with the daemon that
7412
7516
  // owned its socket, so an orphaned row's pane is authoritatively dead
7413
- // whatever its recorded pid says pids are reused, and matching one
7414
- // would be the mechanism that makes a stranger's process read as a live
7415
- // worker. Release (never close) so lifecycle authority goes back to
7416
- // Herdr and the pane stops claiming a worker conductor no longer runs.
7417
- const orphanPane = releaseOrphanedWorkerPane(run);
7517
+ // whatever its recorded pid says. Ownership is proven before the close
7518
+ // (#1035 review): pane ids are reused after a Herdr restart, so only a
7519
+ // pane inside a workspace conductor owns token-marked or store-recorded,
7520
+ // which is what survives Herdr dropping tokens on restore whose
7521
+ // reported identity still agrees with this row is retired. An unowned
7522
+ // hit closes nothing and says so.
7523
+ const orphanPane = releaseOrphanedWorkerPane(
7524
+ { paneId: run.paneId, paneLabel: run.paneLabel, runId: run.id, project: run.project },
7525
+ { ownership: workspaceOwnership(store) },
7526
+ );
7418
7527
  if (orphanPane.kind === "released") {
7419
7528
  projectLog(
7420
- `#${run.issue} herdr pane ${orphanPane.paneId} released: its worker died with the previous daemon`,
7529
+ `#${run.issue} herdr pane ${orphanPane.paneId} retired: its worker died with the previous daemon`,
7421
7530
  );
7531
+ } else if (orphanPane.kind === "unowned") {
7532
+ projectLog(`#${run.issue} herdr pane ${orphanPane.paneId} left alone: ${orphanPane.reason}`);
7422
7533
  } else if (orphanPane.kind === "failed") {
7423
7534
  projectLog(`#${run.issue} herdr pane ${orphanPane.paneId} release failed: ${orphanPane.reason}`);
7424
7535
  }
@@ -7432,7 +7543,7 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
7432
7543
  log(`skipping orphan reconciliation: daemon pid ${alive.pid} is alive and owns the active runs`);
7433
7544
  }
7434
7545
 
7435
- const { brief, releaseGrants } = orchestratorStandingOrders(project);
7546
+ const { brief, releaseGrants } = orchestratorStandingOrders(project, cfg.projects);
7436
7547
  let orchestrator: OrchestratorHandle | undefined;
7437
7548
  let orchestratorVerbs: VerbListener | undefined;
7438
7549
  /** First start-failure cause, surfaced by the orchestrator-down incident (#288). */
package/src/doctor.ts CHANGED
@@ -68,6 +68,7 @@ import {
68
68
  claimedTelegramTopics,
69
69
  lockPidAlive,
70
70
  pidAlive,
71
+ killOf,
71
72
  readTelegramChannel,
72
73
  readTelegramDmOwner,
73
74
  readTelegramPollState,
@@ -85,6 +86,7 @@ import {
85
86
  import {
86
87
  herdrConductorPluginConfigDir,
87
88
  planHostRuntime,
89
+ RECOVER_SERVICE_NAME,
88
90
  STAGED_SERVICE_NAME,
89
91
  SYSTEMD_UNIT_DIR,
90
92
  tickCwdForProject,
@@ -229,6 +231,8 @@ export interface DoctorDeps {
229
231
  hasSystemd?: () => boolean;
230
232
  /** The installed unit text at an absolute path, or undefined when absent. */
231
233
  readUnit?: (path: string) => string | undefined;
234
+ /** Whether one installed systemd unit is currently failed; undefined when unreadable. */
235
+ unitFailed?: (name: string) => boolean | undefined;
232
236
  /** stat one path; undefined when it does not exist. */
233
237
  stat?: (path: string) => Stats | undefined;
234
238
  /** uid of a username (`id -u`), or undefined when it cannot be resolved. */
@@ -308,12 +312,11 @@ export interface DoctorDeps {
308
312
  * claim-only verdict checks only. */
309
313
  armScanDirs?: (project: ProjectConfig) => readonly string[];
310
314
  /** Whether a recorded claim or dm-owner pid is live, with omp-telegram's
311
- * topics.ts semantics (EPERM is dead). */
315
+ * topics.ts semantics (EPERM is dead). The one claim-liveness fact in this
316
+ * package: topic-pin's dead partition, its exact-pin check, project
317
+ * resolution and the telegram-plumbing verdict all derive from it, so two
318
+ * rows cannot answer one claim differently (#987). */
312
319
  pidAlive?: (pid: number) => boolean;
313
- /** Whether one claimed topic is live — the single fact `topic-pin` and
314
- * `telegram-plumbing` both read, so they cannot disagree about a claim
315
- * (#987). Defaults to {@link claimIsLive}. */
316
- claimIsLive?: (claim: { pid?: number }) => boolean;
317
320
  /** The provider allowance window nearest its ceiling, from `omp usage --json`
318
321
  * — what binds a subscription-billed fleet instead of a dollar cap (#984).
319
322
  * `undefined` when the provider reports no comparable window. */
@@ -397,6 +400,18 @@ function defaultReadUnit(path: string): string | undefined {
397
400
  }
398
401
  }
399
402
 
403
+ export function systemdStateFailed(state: string): boolean | undefined {
404
+ const normalized = state.trim();
405
+ if (normalized === "") return undefined;
406
+ return normalized === "failed";
407
+ }
408
+
409
+ function defaultUnitFailed(name: string): boolean | undefined {
410
+ const ran = spawnSync("systemctl", ["is-failed", name], { encoding: "utf8" });
411
+ if (ran.error !== undefined) return undefined;
412
+ return systemdStateFailed(ran.stdout ?? "");
413
+ }
414
+
400
415
  function defaultStat(path: string): Stats | undefined {
401
416
  try {
402
417
  return statSync(path);
@@ -660,7 +675,7 @@ function dbBackupProbe(probes: Probes, cfg: ConductorConfig | undefined): Findin
660
675
  return warnFinding(
661
676
  "db-backup",
662
677
  "conductor.db has no snapshot — losing it loses the audit trail with no recovery path",
663
- "take a conductor.db snapshot (the daemon's cadence files one; restore-db needs one to restore from)",
678
+ "run `omp-conductor snapshot-db` (the daemon cadence uses the same SQLite snapshot primitive)",
664
679
  );
665
680
  }
666
681
  const newestMtime = snapshots
@@ -670,7 +685,7 @@ function dbBackupProbe(probes: Probes, cfg: ConductorConfig | undefined): Findin
670
685
  return warnFinding(
671
686
  "db-backup",
672
687
  `conductor.db is newer than every snapshot (${snapshots.length} file(s)) — the newest writes were never snapshot`,
673
- "take a fresh conductor.db snapshot so restore-db has the current state",
688
+ "run `omp-conductor snapshot-db` so restore-db has the current state",
674
689
  );
675
690
  }
676
691
  return passFinding("db-backup", `conductor.db snapshot is fresh (${snapshots.length} file(s))`);
@@ -934,6 +949,22 @@ function recoveryProbe(probes: Probes): Finding {
934
949
  );
935
950
  }
936
951
 
952
+ function recoveryStateProbe(probes: Probes): Finding {
953
+ const id = "systemd-recovery-state";
954
+ if (!probes.hasSystemd()) return passFinding(id, "no systemd directory on this host — nothing to check");
955
+ if (probes.readUnit(join(SYSTEMD_UNIT_DIR, RECOVER_SERVICE_NAME)) === undefined) {
956
+ return passFinding(id, "recovery unit is not installed — systemd-recovery owns that finding");
957
+ }
958
+ const failed = probes.unitFailed(RECOVER_SERVICE_NAME);
959
+ if (failed === false) return passFinding(id, `${RECOVER_SERVICE_NAME} is not failed`);
960
+ const fix =
961
+ `inspect \`systemctl status ${RECOVER_SERVICE_NAME}\` and \`journalctl -u ${RECOVER_SERVICE_NAME}\`; ` +
962
+ `fix the root cause, then run \`systemctl reset-failed ${RECOVER_SERVICE_NAME}\` and rerun doctor`;
963
+ return failed === true
964
+ ? warnFinding(id, `${RECOVER_SERVICE_NAME} is in failed state — its last recovery did not complete`, fix)
965
+ : warnFinding(id, `${RECOVER_SERVICE_NAME} state could not be read`, fix);
966
+ }
967
+
937
968
  function installedUnitUser(probes: Probes): string | undefined {
938
969
  const unit = probes.readUnit(join(SYSTEMD_UNIT_DIR, STAGED_SERVICE_NAME));
939
970
  if (unit === undefined) return undefined;
@@ -1370,12 +1401,16 @@ function topicPinProbe(probes: Probes, p: ProjectConfig): Finding {
1370
1401
  `check omp-telegram's claim registry (threads.json in its state dir) is readable and the bridge is running, then re-run doctor — until then sends keep the pinned topic and degrade to the flat chat on a missing thread (#318)`,
1371
1402
  );
1372
1403
  }
1373
- // One liveness fact, shared with `telegram-plumbing` (#987). Before this the
1374
- // registry's rows were all treated as live here, so a pin aimed at a corpse
1375
- // read PASS while the plumbing row called the same claim dead.
1404
+ // One liveness fact for everything this probe decides the dead partition,
1405
+ // the exact-pin check below and identity resolution all read
1406
+ // `probes.pidAlive` through the same helper, so diverging injections cannot
1407
+ // split the rows' answer again (#987). Before #987 every registry row was
1408
+ // treated as live here, so a pin aimed at a corpse read PASS while the
1409
+ // plumbing row called the same claim dead.
1410
+ const isLive = (claim: { pid?: number }): boolean => claimIsLive(claim, killOf(probes.pidAlive));
1376
1411
  const all = result.claims;
1377
- const claims = all.filter((claim) => probes.claimIsLive(claim));
1378
- const dead = all.filter((claim) => !probes.claimIsLive(claim));
1412
+ const claims = all.filter(isLive);
1413
+ const dead = all.filter((claim) => !isLive(claim));
1379
1414
  // Named, never dropped: the row is the only index to the remote topic, so
1380
1415
  // deleting it strands the topic instead of cleaning it up.
1381
1416
  const deadNote =
@@ -1397,10 +1432,12 @@ function topicPinProbe(probes: Probes, p: ProjectConfig): Finding {
1397
1432
  `[${p.name}] pinned topic ${pinned} — no live claims to compare (the bridge has claimed no topics yet); the pin stands`,
1398
1433
  );
1399
1434
  }
1435
+ // `claims` is already the live partition, so a dead pinned row cannot win
1436
+ // this check (#987).
1400
1437
  if (claims.some((claim) => claim.threadId === pinned)) {
1401
1438
  return passFinding("topic-pin", `[${p.name}] pinned topic ${pinned} is a live claim${deadNote}`);
1402
1439
  }
1403
- const match = resolveProjectClaim(claims, p.name);
1440
+ const match = resolveProjectClaim(claims, p.name, probes.pidAlive);
1404
1441
  if (match.kind === "match") {
1405
1442
  return passFinding(
1406
1443
  "topic-pin",
@@ -1713,7 +1750,7 @@ function parseEnvKey(text: string, key: string): string | undefined {
1713
1750
 
1714
1751
  /**
1715
1752
  * Install-surface parity (#904). omp-conductor installs onto three surfaces —
1716
- * the Bun-global CLI/daemon tree, the omp plugin, and the herdr recovery
1753
+ * the discovered CLI package tree, the omp plugin, and the herdr recovery
1717
1754
  * plugin — and only a live `upgrade` invocation ever compared them. On a
1718
1755
  * fleet whose installs are manual they diverge silently: measured on this
1719
1756
  * host on 2026-08-22, the omp plugin sat on the withdrawn 0.18.1 release for
@@ -2002,6 +2039,7 @@ export async function runDoctor(projectName: string | undefined, opts: DoctorDep
2002
2039
  // the first one stands in for the rendering seam.
2003
2040
  findings.push(unitProbe(probes, projects[0], cfg));
2004
2041
  findings.push(recoveryProbe(probes));
2042
+ findings.push(recoveryStateProbe(probes));
2005
2043
  findings.push(ownershipProbe(probes));
2006
2044
  // #541 seam checks, host-global: the live herdr config and the plugin's
2007
2045
  // config.env are single files on the host, not per-project facts.
@@ -2106,6 +2144,7 @@ export function defaultProbes(): Probes {
2106
2144
  repoLabels: defaultRepoLabels,
2107
2145
  hasSystemd: () => existsSync(SYSTEMD_UNIT_DIR),
2108
2146
  readUnit: defaultReadUnit,
2147
+ unitFailed: defaultUnitFailed,
2109
2148
  stat: defaultStat,
2110
2149
  uidOf: defaultUidOf,
2111
2150
  dbIntegrity: defaultDbIntegrity,
@@ -2159,7 +2198,6 @@ export function defaultProbes(): Probes {
2159
2198
  return claimDir === cwdDir ? [cwdDir] : [cwdDir, claimDir];
2160
2199
  },
2161
2200
  pidAlive,
2162
- claimIsLive: (claim) => claimIsLive(claim),
2163
2201
  allowanceWindow: async () => bindingAllowanceWindow(await sharedUsageSource().read()),
2164
2202
  lockPidAlive,
2165
2203
  lockFresh: (mtimeMs) => Date.now() - mtimeMs < TELEGRAM_LOCK_FRESH_MS,
package/src/escalate.ts CHANGED
@@ -467,11 +467,13 @@ export interface ClaimedTopic {
467
467
  * own maintenance, and every topic-addressed send silently degrades to the main
468
468
  * chat: tier-2 pages, reports, digests, arm challenges, direct messages (#407).
469
469
  *
470
- * The operator's pin still wins whenever it is live. Only a pin that is
471
- * *provably* absent from the current claims is replaced, and only by the claim
472
- * this project can be identified with — so a deliberately separate alerts topic
473
- * is never hijacked by the pane's own thread. Unavailable bridge state changes
474
- * nothing, and #318's stale-topic retry remains the last line of defence.
470
+ * The operator's pin still wins whenever it is live. Only a pin that no *live*
471
+ * claim carries is replaced, and only by the claim this project can be
472
+ * identified with — so a deliberately separate alerts topic is never hijacked
473
+ * by the pane's own thread. A dead row wearing the pin id does not keep it in
474
+ * place: the row is the bridge's past, not a destination (#987). Unavailable
475
+ * bridge state changes nothing, and #318's stale-topic retry remains the last
476
+ * line of defence.
475
477
  *
476
478
  * Identity is read from the herdr space first, and only then from the claim's
477
479
  * title. The bridge titles a topic `ownAgentName ?? basename(cwd)`, and both
@@ -483,14 +485,19 @@ export interface ClaimedTopic {
483
485
  * The substitution is logged, naming the project and which identity answered.
484
486
  * Never an id: a log line is a place these leak from.
485
487
  */
486
- export function resolveProjectTopicId(project: ProjectConfig): number | undefined {
488
+ export function resolveProjectTopicId(project: ProjectConfig, alive?: (pid: number) => boolean): number | undefined {
487
489
  const pinned = project.escalation.telegramTopicId;
488
490
  if (pinned === undefined) return undefined;
489
491
  const result = claimedTelegramTopics();
490
492
  if (result.kind !== "ok" || result.claims.length === 0) return pinned;
491
493
  const claims = result.claims;
492
- if (claims.some((claim) => claim.threadId === pinned)) return pinned;
493
- const match = claimForProject(claims, project.name);
494
+ // The pin wins only while a *live* claim carries it; a dead pinned row falls
495
+ // through to the live-claim substitution below instead of winning the
496
+ // exact-pin check (#987).
497
+ if (claims.some((claim) => claim.threadId === pinned && claimIsLive(claim, alive === undefined ? undefined : killOf(alive)))) {
498
+ return pinned;
499
+ }
500
+ const match = claimForProject(claims, project.name, alive);
494
501
  if (match === undefined) return pinned;
495
502
  warn(
496
503
  `escalation.telegramTopicId for ${project.name} is no longer a claimed topic; ` +
@@ -519,25 +526,32 @@ export type ProjectClaim =
519
526
  * How the live claims answer to a project, identity spellings in order:
520
527
  * unique herdr space, then unique title.
521
528
  *
522
- * Ambiguity is not a coin toss: paging the wrong project's topic is worse
523
- * than the flat-chat degrade #318 already handles, and scanning the wrong
524
- * pane's session misses the reply entirely. The title is a fallback for
525
- * bridges that never captured a space, so it runs only when *no* claim
526
- * carries the project's space several claims wearing the space is an
527
- * ambiguity the title cannot resolve, because the titled claim may be a
528
- * sibling pane, not this project (#626).
529
+ * Only live claims take part ({@link claimIsLive}): a dead row neither
530
+ * answers on its own nor turns this project's one live pane into an
531
+ * ambiguity (#987). Ambiguity is not a coin toss: paging the wrong project's
532
+ * topic is worse than the flat-chat degrade #318 already handles, and
533
+ * scanning the wrong pane's session misses the reply entirely. The title is
534
+ * a fallback for bridges that never captured a space, so it runs only when
535
+ * *no* live claim carries the project's space — several live claims wearing
536
+ * the space is an ambiguity the title cannot resolve, because the titled
537
+ * claim may be a sibling pane, not this project (#626).
529
538
  */
530
539
  export function resolveProjectClaim(
531
540
  claims: readonly ClaimedTopic[],
532
541
  projectName: string,
542
+ alive?: (pid: number) => boolean,
533
543
  ): ProjectClaim {
534
- const bySpace = claims.filter((claim) => claim.workspaceLabel === projectName);
544
+ // Dead claims do not vote: a closed pane's row must not turn this project's
545
+ // one live claim into an ambiguity, and must not answer by title on its own
546
+ // (#987). Ambiguity among genuinely live claims is unchanged (#626).
547
+ const live = claims.filter((claim) => claimIsLive(claim, alive === undefined ? undefined : killOf(alive)));
548
+ const bySpace = live.filter((claim) => claim.workspaceLabel === projectName);
535
549
  if (bySpace.length > 0) {
536
550
  return bySpace.length === 1
537
551
  ? { kind: "match", claim: bySpace[0]! }
538
552
  : { kind: "ambiguous", claimants: bySpace };
539
553
  }
540
- const byTitle = claims.filter((claim) => claim.name === projectName);
554
+ const byTitle = live.filter((claim) => claim.name === projectName);
541
555
  if (byTitle.length === 0) return { kind: "none" };
542
556
  return byTitle.length === 1
543
557
  ? { kind: "match", claim: byTitle[0]! }
@@ -556,8 +570,9 @@ export function resolveProjectClaim(
556
570
  export function claimForProject(
557
571
  claims: readonly ClaimedTopic[],
558
572
  projectName: string,
573
+ alive?: (pid: number) => boolean,
559
574
  ): ClaimedTopic | undefined {
560
- const match = resolveProjectClaim(claims, projectName);
575
+ const match = resolveProjectClaim(claims, projectName, alive);
561
576
  return match.kind === "match" ? match.claim : undefined;
562
577
  }
563
578
 
@@ -574,10 +589,13 @@ export function claimForProject(
574
589
  * the claim names no file — the caller then falls back to the cwd-derived
575
590
  * directory, which is the honest answer for a host that predates claims.
576
591
  */
577
- export function resolveClaimedSessionFile(project: ProjectConfig): string | undefined {
592
+ export function resolveClaimedSessionFile(
593
+ project: ProjectConfig,
594
+ alive?: (pid: number) => boolean,
595
+ ): string | undefined {
578
596
  const result = claimedTelegramTopics();
579
597
  if (result.kind !== "ok" || result.claims.length === 0) return undefined;
580
- return claimForProject(result.claims, project.name)?.sessionFile;
598
+ return claimForProject(result.claims, project.name, alive)?.sessionFile;
581
599
  }
582
600
 
583
601
  /**
@@ -1111,7 +1129,7 @@ export type TelegramPlumbingScan = { dirs: readonly string[] };
1111
1129
  * Throwing is how `pidAlive` spells "dead", which is the contract it applies to
1112
1130
  * whatever this returns.
1113
1131
  */
1114
- function killOf(alive: (pid: number) => boolean): (target: number, signal: number) => void {
1132
+ export function killOf(alive: (pid: number) => boolean): (target: number, signal: number) => void {
1115
1133
  return (target) => {
1116
1134
  if (!alive(target)) throw new Error("not alive");
1117
1135
  };