omp-conductor 0.19.6 → 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/REFERENCE.md CHANGED
@@ -848,6 +848,22 @@ the queue label is refused with `file-lane-unparseable` and an actionable
848
848
  message, because promoting beside overlapping work on a section that plainly
849
849
  tried to declare is the exact gap the interlock exists to close.
850
850
 
851
+ Since #1036, a promotion also checks a contract. When the issue carries a
852
+ durable `promotable` grooming verdict (the store's grooming row, written by
853
+ the to-spec batch), adding the queue label first recovers that verdict's strict
854
+ payload and requires its `PROMOTABLE` result, then compares the lane admission
855
+ would enforce against the verdict's `fileLane` as deduplicated,
856
+ order-independent path sets — exact in both directions, so a missing path
857
+ refuses exactly like an extra one, and an order-only difference promotes. Any
858
+ disagreement refuses with `promotion-brief-mismatch`, naming both sets; so
859
+ does evidence behind a `promotable` row that does not recover as a strict
860
+ PROMOTABLE result, and an issue unreadable at promotion time behind such a
861
+ row. The durable verdict is what made the issue dispatchable, so the gate
862
+ fails closed before any tracker mutation or daemon wake — but it never edits
863
+ the issue itself: the remediation is applying the verdict's `proposedBrief`
864
+ to the issue (its `## Exact write lane` included), then adding the label
865
+ again. An issue without a `promotable` row promotes exactly as before.
866
+
851
867
  #### What status shows
852
868
 
853
869
  `status` renders the two lifecycle kinds as two blocks, because "a branch is
@@ -3099,7 +3115,7 @@ daemon, across a process boundary, not in a prompt the model can rewrite.
3099
3115
  | `conductor_pr_status` | worker or orchestrator | Read-only — nothing to gate. A worker reads only its own run's PR; an orchestrator may name any syntactically valid PR URL, open, merged, or closed, and gets its live state and head (checks are reported when available; a merged or closed PR reports its state instead of an `expected OPEN` refusal). |
3100
3116
  | `conductor_pr_update_branch` | orchestrator, or the worker owning the run | The PR belongs to this project and is open. A worker may only name its own run's PR. |
3101
3117
  | `conductor_pr_merge` | **orchestrator only** | Ordinarily, `authority.merge` equals the caller. A hand-edited `recoveryMerges` entry may instead authorize one exact unrecorded PR/head/reason while held. In both paths, while the project has an [active release composition](#the-active-release-composition-850), the PR must be named in it or carry an explicit operator override; then `headSha` equals the live head *at execution time*; checks are green at that same SHA; no [review evidence](#the-exact-head-review-gate-888) stands at that head without a recorded `conductor_pr_review_clear`; the project route and migration chain are valid; the project's single merge slot is free. |
3102
- | `conductor_label` | **orchestrator only** | The label is in the project's own vocabulary. Lifecycle labels stay the daemon's. Adding the queue label echoes the parsed [file lane](#the-file-lane-declaration), or refuses with `file-lane-unparseable` when a clearly delimited write-lane section parsed nothing — it never claims fail-open beside a declaration that was actually attempted. |
3118
+ | `conductor_label` | **orchestrator only** | The label is in the project's own vocabulary. Lifecycle labels stay the daemon's. Adding the queue label echoes the parsed [file lane](#the-file-lane-declaration), or refuses with `file-lane-unparseable` when a clearly delimited write-lane section parsed nothing — it never claims fail-open beside a declaration that was actually attempted. When the issue carries a durable `promotable` grooming verdict, adding the queue label also verifies the current brief against that verdict before any mutation: a brief that disagrees with the verdict's `fileLane` (or evidence that no longer recovers as a strict PROMOTABLE result) refuses with `promotion-brief-mismatch` — apply the verdict's `proposedBrief`, then re-add ([the file-lane declaration](#the-file-lane-declaration)). |
3103
3119
  | `conductor_release` | **orchestrator only** | `authority.release` equals the caller; the per-shape grant permits it; the artefact or environment was declared; the release preconditions hold; the `reason` is in the closed enum. `version-bump-pr` creates or re-validates one deterministic version-only PR and, on a later call, merges only its exact green head through the project's single merge slot. |
3104
3120
  | `conductor_install` | **orchestrator only** | Gated like a release act: the `install` shape defaults to `human` and a grant is what moves it. The daemon refuses a version npm does not expose with a full `gitHead`, refuses while another install is still in flight, and otherwise starts a detached transient unit that pauses, drains, installs the CLI/omp plugin/Herdr plugin and reloads — outside this session and the daemon. The unit never declares its own success; the first tick after the restart verifies and reports through the durable outbox. |
3105
3121
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omp-conductor",
3
- "version": "0.19.6",
3
+ "version": "0.19.7",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "A 24/7 dispatcher that takes ready GitHub issues to green, mergeable PRs using omp coding sessions, with tiered escalation first to an orchestrator session and then to a human.",
package/src/daemon.ts CHANGED
@@ -64,6 +64,7 @@ import { inspectSurfaces, type InstalledSurfaces } from "./upgrade.ts";
64
64
  import { checkTelegramFreshness, type TelegramFreshness } from "./telegram-freshness.ts";
65
65
  import { processStartTimeMs } from "./upgrade-verify.ts";
66
66
  import {
67
+ closeWorkerPane,
67
68
  fleetLayers,
68
69
  herdrPaneOmpStarts,
69
70
  openWorkerPane,
@@ -73,6 +74,9 @@ import {
73
74
  releaseWorkerPane,
74
75
  reportWorkerPaneState,
75
76
  resolveHerdrSession,
77
+ retireWorkerPane,
78
+ type HerdrRun,
79
+ type WorkspaceOwnership,
76
80
  type WorkerPaneOutcome,
77
81
  } from "./fleet.ts";
78
82
  import { startOrchestrator } from "./orchestrator.ts";
@@ -340,6 +344,12 @@ interface Deps {
340
344
  workerDeps?: RunWorkerDeps;
341
345
  /** Harness per-process logs used to classify terminal worker routes. */
342
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;
343
353
  integrity: IntegrityGate;
344
354
  stall: StallGate;
345
355
  /**
@@ -1912,6 +1922,17 @@ export async function handleIssue(
1912
1922
  // for a worker that has already blocked.
1913
1923
  let paneSeq = 0;
1914
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
+ };
1915
1936
  /**
1916
1937
  * Project one authoritative transition onto the pane (#842).
1917
1938
  *
@@ -1922,6 +1943,7 @@ export async function handleIssue(
1922
1943
  const projectPaneState = (state: "working" | "idle" | "blocked" | "unknown", message?: string): void => {
1923
1944
  if (workerPane === undefined) return;
1924
1945
  const reported = reportWorkerPaneState(workerPane.paneId, workerPane.label, state, {
1946
+ ...herdr,
1925
1947
  seq: nextPaneSeq(),
1926
1948
  ...(message === undefined ? {} : { message }),
1927
1949
  });
@@ -2529,6 +2551,7 @@ export async function handleIssue(
2529
2551
  workerPane = { kind: "tracked", paneId: recorded.paneId, label: recorded.paneLabel, pid };
2530
2552
  store.updateRun(runId, { workerPid: pid });
2531
2553
  const state = reportWorkerPaneState(recorded.paneId, recorded.paneLabel, "working", {
2554
+ ...herdr,
2532
2555
  seq: nextPaneSeq(),
2533
2556
  });
2534
2557
  log(
@@ -2538,14 +2561,17 @@ export async function handleIssue(
2538
2561
  );
2539
2562
  return;
2540
2563
  }
2541
- const outcome = openWorkerPane({
2542
- project: project.name,
2543
- issue,
2544
- attempt,
2545
- runId,
2546
- pid,
2547
- ...(recorded?.sessionFile === undefined ? {} : { sessionFile: recorded.sessionFile }),
2548
- });
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
+ );
2549
2575
  if (outcome.kind === "tracked") {
2550
2576
  workerPane = outcome;
2551
2577
  // Durable before it is announced: a pane the store does not know
@@ -2569,8 +2595,14 @@ export async function handleIssue(
2569
2595
  },
2570
2596
  release: () => {
2571
2597
  if (workerPane === undefined) return;
2572
- const released = releaseWorkerPane(workerPane.paneId, workerPane.label, { seq: nextPaneSeq() });
2573
- 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}`);
2574
2606
  workerPane = undefined;
2575
2607
  },
2576
2608
  },
@@ -3065,17 +3097,19 @@ export async function handleIssue(
3065
3097
  * losing a dispatch pass over a version string would be absurd.
3066
3098
  */
3067
3099
  /**
3068
- * 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).
3069
3102
  *
3070
3103
  * Runs on every dispatch pass rather than only at startup, because the thing it
3071
3104
  * repairs — a Herdr restart — is not a conductor event and announces itself
3072
3105
  * nowhere. It is idempotent by construction: a reconciled fleet reports `intact`
3073
- * for every live worker and finds no stale panes, so repeated passes converge
3074
- * 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.
3075
3110
  *
3076
- * Nothing here can stop a worker. The only mutations are creating a pane, and
3077
- * releasing one whose run is not live; the authoritative child is never
3078
- * 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.
3079
3113
  */
3080
3114
  /**
3081
3115
  * Pane re-establishment attempts spent per run, for this daemon process only
@@ -3089,7 +3123,25 @@ export async function handleIssue(
3089
3123
  */
3090
3124
  const paneAttemptsSpent = new Map<string, number>();
3091
3125
 
3092
- 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 {
3093
3145
  const live = d.store.liveRuns(project);
3094
3146
  const liveKeys = new Set(live.map((run) => `${project}\u0000${run.id}`));
3095
3147
  for (const key of [...paneAttemptsSpent.keys()]) {
@@ -3109,8 +3161,10 @@ function reconcilePanes(d: Deps, project: string, log: (message: string) => void
3109
3161
  ...(run.paneLabel === undefined ? {} : { paneLabel: run.paneLabel }),
3110
3162
  ...(run.sessionFile === undefined ? {} : { sessionFile: run.sessionFile }),
3111
3163
  })),
3112
- {},
3164
+ { ...(d.herdrRun === undefined ? {} : { run: d.herdrRun }), ownership: workspaceOwnership(d.store) },
3113
3165
  attempts,
3166
+ PANE_REATTEMPT_MAX,
3167
+ [project],
3114
3168
  );
3115
3169
  if (!result.ok) {
3116
3170
  // An unreadable workspace is not evidence that anything is stale, so nothing
@@ -3123,6 +3177,14 @@ function reconcilePanes(d: Deps, project: string, log: (message: string) => void
3123
3177
  switch (outcome.kind) {
3124
3178
  case "intact":
3125
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;
3126
3188
  case "reassociated":
3127
3189
  d.store.updateRun(outcome.runId, {
3128
3190
  paneId: outcome.paneId,
@@ -3139,11 +3201,11 @@ function reconcilePanes(d: Deps, project: string, log: (message: string) => void
3139
3201
  break;
3140
3202
  }
3141
3203
  // #998: a terminal refusing to split will refuse the next one too, and
3142
- // 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.
3143
3207
  const key = `${project}\u0000${outcome.runId}`;
3144
- const spent = outcome.reason.includes("pane_split_failed")
3145
- ? PANE_REATTEMPT_MAX
3146
- : (paneAttemptsSpent.get(key) ?? 0) + 1;
3208
+ const spent = outcome.phase === "split" ? PANE_REATTEMPT_MAX : (paneAttemptsSpent.get(key) ?? 0) + 1;
3147
3209
  paneAttemptsSpent.set(key, spent);
3148
3210
  // Once per run, at the moment the budget runs out — not once per pass.
3149
3211
  // Four identical lines a minute for #986 is what made the real cause
@@ -3159,11 +3221,33 @@ function reconcilePanes(d: Deps, project: string, log: (message: string) => void
3159
3221
  // Already reported when the budget ran out; the run keeps working with
3160
3222
  // no pane, and a later restart gets a fresh budget.
3161
3223
  break;
3162
- case "stale-released":
3163
- 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" : ""})`);
3232
+ break;
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}`);
3164
3246
  break;
3165
- case "stale-release-failed":
3166
- log(`stale herdr pane ${outcome.paneId} could not be released: ${outcome.reason}`);
3247
+ case "workspace-remove-failed":
3248
+ log(
3249
+ `herdr worker workspace ${outcome.workspaceId} for ${outcome.project} could not be removed: ${outcome.reason}`,
3250
+ );
3167
3251
  break;
3168
3252
  }
3169
3253
  }
@@ -7430,15 +7514,22 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
7430
7514
  // The workspace outlives the process, so a restart inherits panes whose
7431
7515
  // workers are gone (#842). A session-host child dies with the daemon that
7432
7516
  // owned its socket, so an orphaned row's pane is authoritatively dead
7433
- // whatever its recorded pid says pids are reused, and matching one
7434
- // would be the mechanism that makes a stranger's process read as a live
7435
- // worker. Release (never close) so lifecycle authority goes back to
7436
- // Herdr and the pane stops claiming a worker conductor no longer runs.
7437
- 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
+ );
7438
7527
  if (orphanPane.kind === "released") {
7439
7528
  projectLog(
7440
- `#${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`,
7441
7530
  );
7531
+ } else if (orphanPane.kind === "unowned") {
7532
+ projectLog(`#${run.issue} herdr pane ${orphanPane.paneId} left alone: ${orphanPane.reason}`);
7442
7533
  } else if (orphanPane.kind === "failed") {
7443
7534
  projectLog(`#${run.issue} herdr pane ${orphanPane.paneId} release failed: ${orphanPane.reason}`);
7444
7535
  }
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,
@@ -311,12 +312,11 @@ export interface DoctorDeps {
311
312
  * claim-only verdict checks only. */
312
313
  armScanDirs?: (project: ProjectConfig) => readonly string[];
313
314
  /** Whether a recorded claim or dm-owner pid is live, with omp-telegram's
314
- * 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). */
315
319
  pidAlive?: (pid: number) => boolean;
316
- /** Whether one claimed topic is live — the single fact `topic-pin` and
317
- * `telegram-plumbing` both read, so they cannot disagree about a claim
318
- * (#987). Defaults to {@link claimIsLive}. */
319
- claimIsLive?: (claim: { pid?: number }) => boolean;
320
320
  /** The provider allowance window nearest its ceiling, from `omp usage --json`
321
321
  * — what binds a subscription-billed fleet instead of a dollar cap (#984).
322
322
  * `undefined` when the provider reports no comparable window. */
@@ -1401,12 +1401,16 @@ function topicPinProbe(probes: Probes, p: ProjectConfig): Finding {
1401
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)`,
1402
1402
  );
1403
1403
  }
1404
- // One liveness fact, shared with `telegram-plumbing` (#987). Before this the
1405
- // registry's rows were all treated as live here, so a pin aimed at a corpse
1406
- // 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));
1407
1411
  const all = result.claims;
1408
- const claims = all.filter((claim) => probes.claimIsLive(claim));
1409
- const dead = all.filter((claim) => !probes.claimIsLive(claim));
1412
+ const claims = all.filter(isLive);
1413
+ const dead = all.filter((claim) => !isLive(claim));
1410
1414
  // Named, never dropped: the row is the only index to the remote topic, so
1411
1415
  // deleting it strands the topic instead of cleaning it up.
1412
1416
  const deadNote =
@@ -1428,10 +1432,12 @@ function topicPinProbe(probes: Probes, p: ProjectConfig): Finding {
1428
1432
  `[${p.name}] pinned topic ${pinned} — no live claims to compare (the bridge has claimed no topics yet); the pin stands`,
1429
1433
  );
1430
1434
  }
1435
+ // `claims` is already the live partition, so a dead pinned row cannot win
1436
+ // this check (#987).
1431
1437
  if (claims.some((claim) => claim.threadId === pinned)) {
1432
1438
  return passFinding("topic-pin", `[${p.name}] pinned topic ${pinned} is a live claim${deadNote}`);
1433
1439
  }
1434
- const match = resolveProjectClaim(claims, p.name);
1440
+ const match = resolveProjectClaim(claims, p.name, probes.pidAlive);
1435
1441
  if (match.kind === "match") {
1436
1442
  return passFinding(
1437
1443
  "topic-pin",
@@ -2192,7 +2198,6 @@ export function defaultProbes(): Probes {
2192
2198
  return claimDir === cwdDir ? [cwdDir] : [cwdDir, claimDir];
2193
2199
  },
2194
2200
  pidAlive,
2195
- claimIsLive: (claim) => claimIsLive(claim),
2196
2201
  allowanceWindow: async () => bindingAllowanceWindow(await sharedUsageSource().read()),
2197
2202
  lockPidAlive,
2198
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
  };