omp-conductor 0.3.21 → 0.3.23

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/board.ts CHANGED
@@ -14,6 +14,7 @@ import { probeCodeGraph, type CodeGraphHealth } from "./graph-health.ts";
14
14
  import { healthCheck, livingDaemon } from "./lifecycle.ts";
15
15
  import { dbPath, openStore } from "./store.ts";
16
16
  import { formatTranscriptLine } from "./transcript.ts";
17
+ import { makeTracker } from "./tracker/github.ts";
17
18
  import type { AdmissionHoldReason, ProjectConfig, RunRecord, RunState, Store } from "./types.ts";
18
19
 
19
20
  const REFRESH_MS = 1_000;
@@ -35,23 +36,52 @@ const YELLOW = `${CSI}33m`;
35
36
  const RED = `${CSI}31m`;
36
37
  const MAGENTA = `${CSI}35m`;
37
38
 
39
+ /**
40
+ * The board's lanes, in operator reading order.
41
+ *
42
+ * Each entry used to carry the `RunState[]` that filled it, which is the defect
43
+ * #109 reports: a lane filled from the newest run row answers "what happened
44
+ * last" under a heading an operator reads as "what needs action". Membership is
45
+ * now decided once, by {@link laneOf}, from state that is true right now.
46
+ */
38
47
  const COLUMN_DEFS = [
39
- { key: "queue", title: "QUEUE", states: [] },
40
- { key: "claimed", title: "CLAIMED", states: ["claimed"] },
41
- { key: "running", title: "RUNNING", states: ["running"] },
42
- { key: "green", title: "GREEN", states: ["pushed-pending", "pushed-green"] },
43
- { key: "blocked", title: "BLOCKED", states: ["blocked"] },
44
- { key: "failed", title: "FAILED", states: ["failed", "killed", "orphaned"] },
45
- { key: "merged", title: "MERGED", states: ["merged"] },
46
- ] as const satisfies readonly { key: string; title: string; states: readonly RunState[] }[];
47
-
48
- type ColumnKey = (typeof COLUMN_DEFS)[number]["key"];
48
+ { key: "queue", title: "QUEUE" },
49
+ { key: "claimed", title: "CLAIMED" },
50
+ { key: "running", title: "RUNNING" },
51
+ { key: "green", title: "GREEN" },
52
+ { key: "blocked", title: "BLOCKED" },
53
+ { key: "failed", title: "FAILED" },
54
+ { key: "orphaned", title: "ORPHANED" },
55
+ { key: "merged", title: "MERGED" },
56
+ { key: "history", title: "HISTORY" },
57
+ ] as const satisfies readonly { key: string; title: string }[];
58
+
59
+ export type BoardLane = (typeof COLUMN_DEFS)[number]["key"];
60
+
61
+ /** Run states that mean a worker process or an open pull request exists *now*,
62
+ * and the lane each is drawn in. Being in this table is why the store outranks
63
+ * every label in {@link laneOf}: no label is more current than a live run. */
64
+ const LIVE_LANES: Partial<Record<RunState, BoardLane>> = {
65
+ claimed: "claimed",
66
+ running: "running",
67
+ "pushed-pending": "green",
68
+ "pushed-green": "green",
69
+ };
70
+
49
71
  type DaemonBoardState = "stopped" | "ok" | "unreachable" | "other-project";
50
72
 
51
- interface QueueCard {
52
- kind: "queue";
73
+ /**
74
+ * A card the board can only describe from tracker state, because the store has
75
+ * no run row for the issue: it was placed by a label it currently carries, or
76
+ * by an admission hold recorded in the latest tick.
77
+ */
78
+ interface IssueCard {
79
+ kind: "issue";
53
80
  issue: number;
54
- reason: AdmissionHoldReason;
81
+ /** Why it is in this lane: the hold keeping it out of dispatch, or the label. */
82
+ reason: string;
83
+ /** Where that fact came from, in one short line. */
84
+ note: string;
55
85
  }
56
86
 
57
87
  interface RunCard {
@@ -59,7 +89,7 @@ interface RunCard {
59
89
  run: RunRecord;
60
90
  }
61
91
 
62
- type BoardCard = QueueCard | RunCard;
92
+ type BoardCard = IssueCard | RunCard;
63
93
 
64
94
  export interface BoardHealth {
65
95
  layers: FleetLayers;
@@ -68,10 +98,44 @@ export interface BoardHealth {
68
98
  codeGraph?: CodeGraphHealth;
69
99
  }
70
100
 
101
+ /**
102
+ * Which issues currently carry each label the board reasons about, read from
103
+ * the tracker rather than inferred from run rows.
104
+ *
105
+ * The store cannot answer this. It only knows what it did itself, so a label a
106
+ * human added or removed, and an issue closed out from under a run row, are
107
+ * both invisible to it — which is how eleven historical terminal runs were
108
+ * drawn as failed work on 2026-08-08 (#109).
109
+ */
110
+ export interface BoardLabels {
111
+ /** Open issues carrying `ProjectConfig.queueLabel` — what dispatches next. */
112
+ queued: ReadonlySet<number>;
113
+ inProgress: ReadonlySet<number>;
114
+ blocked: ReadonlySet<number>;
115
+ failed: ReadonlySet<number>;
116
+ /** Epoch ms of the last read that succeeded; 0 when none has. */
117
+ readAt: number;
118
+ /** Message from the most recent failed read. The sets above are then the last
119
+ * good ones: blanking QUEUE and FAILED is a louder lie than showing them
120
+ * stale, so the failure is reported in the header instead. */
121
+ error?: string;
122
+ }
123
+
124
+ /** The sets before the first read lands, and the fallback when a read has never
125
+ * succeeded. `readAt: 0` is what {@link labelFact} reads as "unread". */
126
+ const UNREAD_LABELS: BoardLabels = {
127
+ queued: new Set<number>(),
128
+ inProgress: new Set<number>(),
129
+ blocked: new Set<number>(),
130
+ failed: new Set<number>(),
131
+ readAt: 0,
132
+ };
133
+
71
134
  export interface BoardSnapshot {
72
135
  project: ProjectConfig;
73
136
  status: StatusSnapshot;
74
137
  health: BoardHealth;
138
+ labels: BoardLabels;
75
139
  runs: RunRecord[];
76
140
  now: number;
77
141
  }
@@ -91,7 +155,7 @@ interface KeyInput {
91
155
  ctrl?: boolean;
92
156
  }
93
157
 
94
- function ansiColor(key: ColumnKey): string {
158
+ function ansiColor(key: BoardLane): string {
95
159
  switch (key) {
96
160
  case "running":
97
161
  return CYAN;
@@ -100,9 +164,12 @@ function ansiColor(key: ColumnKey): string {
100
164
  return GREEN;
101
165
  case "claimed":
102
166
  case "blocked":
167
+ case "orphaned":
103
168
  return YELLOW;
104
169
  case "failed":
105
170
  return RED;
171
+ case "history":
172
+ return DIM;
106
173
  default:
107
174
  return MAGENTA;
108
175
  }
@@ -147,36 +214,145 @@ function humanDuration(ms: number): string {
147
214
  return `${Math.floor(hours / 24)}d ${hours % 24}h`;
148
215
  }
149
216
 
150
- function queueCards(snapshot: BoardSnapshot): QueueCard[] {
151
- const cards: QueueCard[] = [];
152
- const seen = new Set<number>();
217
+ /**
218
+ * The board's single lane rule.
219
+ *
220
+ * `omp-conductor board` answers one operator question — "what needs action, and
221
+ * what will dispatch next?" — so every lane has to be a statement about state
222
+ * *now*. What an issue's newest run row last did is a different question, and
223
+ * answering it under an operational heading is the 2026-08-08 dogfood failure
224
+ * in #109: the board read `FAILED 11` on conductor 0.3.19 while the tracker
225
+ * carried four `agent:failed` issues (#82, #140, #297, #307). The other seven
226
+ * were two requeued issues also drawn in QUEUE (#43, #321), a deliberately
227
+ * protected dirty orphan (#319), two closed issues (#135, #310) and two open
228
+ * parent issues a human had already cleared (#81, #86) — every one of them a
229
+ * historical terminal run, not one of them failed work.
230
+ *
231
+ * So: the store decides the live lanes, because a running worker is the most
232
+ * current fact there is; the tracker's current labels decide every stopped
233
+ * lane; each issue matches exactly once, first branch wins; and last-run
234
+ * history is answered in HISTORY, under its own name.
235
+ */
236
+ function laneOf(
237
+ snapshot: BoardSnapshot,
238
+ issue: number,
239
+ run: RunRecord | undefined,
240
+ queued: ReadonlySet<number>,
241
+ ): BoardLane | undefined {
242
+ const live = run === undefined ? undefined : LIVE_LANES[run.state];
243
+ if (live !== undefined) return live;
244
+
245
+ const { labels } = snapshot;
246
+ // State labels outrank the queue label, because that is the order
247
+ // `routing.isEligible` reads them in: it requires the queue label AND the
248
+ // absence of all three state labels, so an issue carrying both will never be
249
+ // dispatched no matter how queued it looks. Drawing it under QUEUE — "waiting
250
+ // for dispatch" — is the same class of lie as #109 itself, just inverted, and
251
+ // it is not hypothetical: veltro#319 on 2026-08-09 carried `ready-for-agent`
252
+ // and `agent:in-progress` together, so a queue-first rule puts the protected
253
+ // dirty orphan back in the queue lane this issue moved it out of.
254
+ if (labels.failed.has(issue)) return "failed";
255
+ if (labels.blocked.has(issue)) return "blocked";
256
+ // The tracker says a worker owns this issue and the store says none is
257
+ // running. Nothing will touch it until a human does, and its worktree is kept
258
+ // because it may hold uncommitted work: that is a protected orphan, and
259
+ // calling it a failure is what put #319 under FAILED.
260
+ if (labels.inProgress.has(issue)) return "orphaned";
261
+ // What is left carrying the queue label really is next up, held or not.
262
+ // Requeued work is queued work: #43 and #321 carried `ready-for-agent` and a
263
+ // terminal newest run at the same time, and with no state label on them the
264
+ // label is the whole of what happens next.
265
+ if (queued.has(issue)) return "queue";
266
+ if (run?.state === "merged") return "merged";
267
+ if (run === undefined) return undefined;
268
+ // What is left is a terminal run row with no current claim on it — a closed
269
+ // issue, or one a human already cleared. Recent rows stay readable under
270
+ // HISTORY for the same 24 hours MERGED uses; older ones are not news.
271
+ return (run.endedAt ?? run.startedAt) >= snapshot.now - MERGED_HISTORY_MS ? "history" : undefined;
272
+ }
273
+
274
+ /** Description lines for an issue the store has no run row for. Only the lanes
275
+ * a label or a hold can place on their own reach this: queue, failed, blocked
276
+ * and orphaned. */
277
+ function issueCard(
278
+ snapshot: BoardSnapshot,
279
+ issue: number,
280
+ lane: BoardLane,
281
+ holds: ReadonlyMap<number, AdmissionHoldReason>,
282
+ ): IssueCard {
283
+ const hold = holds.get(issue);
284
+ if (lane === "queue") {
285
+ return hold === undefined
286
+ ? { kind: "issue", issue, reason: "queued", note: "waiting for dispatch" }
287
+ : { kind: "issue", issue, reason: hold.replaceAll("-", " "), note: "held in latest tick" };
288
+ }
289
+ const { blocked, failed, inProgress } = snapshot.project.stateLabels;
290
+ const label = lane === "failed" ? failed : lane === "blocked" ? blocked : inProgress;
291
+ return { kind: "issue", issue, reason: label, note: "no run recorded" };
292
+ }
293
+
294
+ /** The whole board, partitioned. Rebuilt per call rather than cached: it is
295
+ * tens of issues wide and redraws once a second, so a cache would buy nothing
296
+ * and would go stale the moment a caller mutated a snapshot in place. */
297
+ function laneCards(snapshot: BoardSnapshot): Map<BoardLane, BoardCard[]> {
298
+ const lanes = new Map<BoardLane, BoardCard[]>();
299
+ for (const column of COLUMN_DEFS) lanes.set(column.key, []);
300
+
301
+ // `recentRuns` already yields the newest attempt per issue; indexing it keeps
302
+ // one card per issue even if a caller hands over a wider run list.
303
+ const newest = new Map<number, RunRecord>();
304
+ for (const run of snapshot.runs) if (!newest.has(run.issue)) newest.set(run.issue, run);
305
+
306
+ // Holds name issues the latest dispatch pass looked at and kept back, so they
307
+ // are queue members by definition. They join the label set rather than
308
+ // replacing it: the label read is authoritative and uncapped, while the hold
309
+ // sample is bounded to five per reason but survives a tracker read that just
310
+ // failed.
311
+ const queued = new Set<number>(snapshot.labels.queued);
312
+ const holds = new Map<number, AdmissionHoldReason>();
153
313
  for (const hold of snapshot.status.dispatch?.holds ?? []) {
154
314
  for (const issue of hold.issues) {
155
- if (seen.has(issue)) continue;
156
- seen.add(issue);
157
- cards.push({ kind: "queue", issue, reason: hold.reason });
315
+ queued.add(issue);
316
+ if (!holds.has(issue)) holds.set(issue, hold.reason);
158
317
  }
159
318
  }
160
- return cards;
319
+
320
+ const place = (issue: number, run: RunRecord | undefined): void => {
321
+ const lane = laneOf(snapshot, issue, run, queued);
322
+ if (lane === undefined) return;
323
+ lanes.get(lane)!.push(run === undefined ? issueCard(snapshot, issue, lane, holds) : { kind: "run", run });
324
+ };
325
+
326
+ for (const [issue, run] of newest) place(issue, run);
327
+ // Issues the tracker knows about that this store never ran, or whose run rows
328
+ // have aged out. FAILED has to count them: the operator is asking how many
329
+ // issues are failed, not how many failures this database still remembers.
330
+ const labelled = [...queued, ...snapshot.labels.failed, ...snapshot.labels.blocked, ...snapshot.labels.inProgress];
331
+ for (const issue of [...new Set(labelled)].sort((a, b) => a - b)) {
332
+ if (!newest.has(issue)) place(issue, undefined);
333
+ }
334
+ return lanes;
161
335
  }
162
336
 
163
- function cardsFor(snapshot: BoardSnapshot, key: ColumnKey): BoardCard[] {
164
- if (key === "queue") return queueCards(snapshot);
165
- const states: readonly RunState[] = COLUMN_DEFS.find((column) => column.key === key)?.states ?? [];
166
- return snapshot.runs.filter((run) => states.includes(run.state)).map((run) => ({ kind: "run", run }));
337
+ function cardsFor(snapshot: BoardSnapshot, key: BoardLane): BoardCard[] {
338
+ return laneCards(snapshot).get(key) ?? [];
167
339
  }
168
340
 
169
- function columnCount(snapshot: BoardSnapshot, key: ColumnKey): number {
170
- if (key === "queue") return snapshot.status.dispatch?.ready ?? 0;
171
- return cardsFor(snapshot, key).length;
341
+ /** Issue numbers per lane, in render order. Exported so the lane rule can be
342
+ * asserted directly including that no issue is in two lanes, the defect this
343
+ * model exists to make impossible. */
344
+ export function boardLanes(snapshot: BoardSnapshot): Map<BoardLane, number[]> {
345
+ const lanes = new Map<BoardLane, number[]>();
346
+ for (const [lane, cards] of laneCards(snapshot)) lanes.set(lane, cards.map(cardIssue));
347
+ return lanes;
172
348
  }
173
349
 
174
350
  function cardIssue(card: BoardCard): number {
175
- return card.kind === "queue" ? card.issue : card.run.issue;
351
+ return card.kind === "issue" ? card.issue : card.run.issue;
176
352
  }
177
353
 
178
354
  function cardKey(card: BoardCard): string {
179
- return card.kind === "queue" ? `queue:${card.issue}` : `run:${card.run.id}`;
355
+ return card.kind === "issue" ? `issue:${card.issue}` : `run:${card.run.id}`;
180
356
  }
181
357
 
182
358
  function focusedCard(snapshot: BoardSnapshot, cursor: BoardCursor): BoardCard | undefined {
@@ -249,13 +425,9 @@ function runCardLines(run: RunRecord, snapshot: BoardSnapshot): string[] {
249
425
  return lines;
250
426
  }
251
427
 
252
- function queueCardLines(card: QueueCard): string[] {
253
- return [`#${card.issue}`, card.reason.replaceAll("-", " "), "held in latest tick"];
254
- }
255
-
256
428
  function renderColumn(
257
429
  snapshot: BoardSnapshot,
258
- key: ColumnKey,
430
+ key: BoardLane,
259
431
  title: string,
260
432
  selectedColumn: boolean,
261
433
  selectedCard: number,
@@ -263,7 +435,6 @@ function renderColumn(
263
435
  height: number,
264
436
  ): string[] {
265
437
  const cards = cardsFor(snapshot, key);
266
- const count = columnCount(snapshot, key);
267
438
  const color = ansiColor(key);
268
439
  const slots = Math.max(1, Math.floor(Math.max(0, height - 2) / 5));
269
440
  const start = selectedColumn
@@ -271,16 +442,15 @@ function renderColumn(
271
442
  : 0;
272
443
  const shown = cards.slice(start, start + slots);
273
444
  const lines = [
274
- styledCell(` ${title} ${count}`, width, `${BOLD}${color}`),
445
+ styledCell(` ${title} ${cards.length}`, width, `${BOLD}${color}`),
275
446
  styledCell(start === 0 ? "─".repeat(width) : ` ↑ ${start} earlier`, width, DIM),
276
447
  ];
277
448
 
278
449
  if (cards.length === 0) {
279
- const empty = key === "queue" && count > 0 ? `${count} ready; no hold sample` : "(empty)";
280
- lines.push(styledCell(` ${empty}`, width, DIM));
450
+ lines.push(styledCell(" (empty)", width, DIM));
281
451
  } else {
282
452
  for (const [offset, card] of shown.entries()) {
283
- const raw = card.kind === "run" ? runCardLines(card.run, snapshot) : queueCardLines(card);
453
+ const raw = card.kind === "run" ? runCardLines(card.run, snapshot) : [`#${card.issue}`, card.reason, card.note];
284
454
  while (raw.length < 4) raw.push("");
285
455
  const selected = selectedColumn && start + offset === selectedCard;
286
456
  for (const [lineIndex, value] of raw.entries()) {
@@ -315,13 +485,24 @@ function healthLine(snapshot: BoardSnapshot): string {
315
485
  ].join(" · ");
316
486
  }
317
487
 
488
+ /** How current the label sets behind the lanes are. A lane rule is only as
489
+ * authoritative as its last successful read, so a stale one says so instead of
490
+ * letting an empty QUEUE read as an empty queue. */
491
+ function labelFact(snapshot: BoardSnapshot): string {
492
+ const { readAt, error } = snapshot.labels;
493
+ if (error === undefined) return "tracker ok";
494
+ const detail = error.replace(/\s+/g, " ").slice(0, 80);
495
+ if (readAt === 0) return `tracker unread — ${detail}`;
496
+ return `tracker stale ${humanDuration(snapshot.now - readAt)} — ${detail}`;
497
+ }
498
+
318
499
  function integrationLine(snapshot: BoardSnapshot): string {
319
500
  const { telegram, codeGraph } = snapshot.health;
320
501
  const graph = codeGraph?.configured
321
502
  ? `${codeGraph.status} ${codeGraph.repos.filter((repo) => repo.index === "present").length}/${codeGraph.repos.length} indexed`
322
503
  : "off";
323
504
  const telegramDetail = telegram.detail?.replace(/\s+/g, " ").slice(0, 80);
324
- return `graph ${graph} · telegram ${telegram.kind}${telegramDetail === undefined ? "" : ` — ${telegramDetail}`}`;
505
+ return `graph ${graph} · telegram ${telegram.kind}${telegramDetail === undefined ? "" : ` — ${telegramDetail}`} · ${labelFact(snapshot)}`;
325
506
  }
326
507
 
327
508
  function admissionLine(snapshot: BoardSnapshot): string {
@@ -408,12 +589,12 @@ function wrap(value: string, width: number): string[] {
408
589
  function renderDetail(snapshot: BoardSnapshot, cursor: BoardCursor, width: number, height: number): string[] {
409
590
  const card = focusedCard(snapshot, cursor);
410
591
  if (card === undefined) return [styledCell("No card selected.", width, DIM)];
411
- if (card.kind === "queue") {
592
+ if (card.kind === "issue") {
412
593
  return [
413
- styledCell(` QUEUED ISSUE #${card.issue} `, width, `${BOLD}${REVERSE}`),
414
- styledCell(`Latest hold: ${card.reason}`, width),
594
+ styledCell(` ISSUE #${card.issue} `, width, `${BOLD}${REVERSE}`),
595
+ styledCell(`${card.reason} ${card.note}`, width),
415
596
  "",
416
- styledCell("No run exists yet, so there is no transcript to inspect.", width, DIM),
597
+ styledCell("The store has no run row for this issue, so there is no transcript to inspect.", width, DIM),
417
598
  ];
418
599
  }
419
600
 
@@ -441,7 +622,7 @@ function renderHelp(width: number, height: number): string[] {
441
622
  "←/→ or h/l select column",
442
623
  "↑/↓ or k/j select card",
443
624
  "Enter inspect/follow transcript",
444
- "u unblock selected failed or blocked issue",
625
+ "u unblock selected blocked, failed or orphaned issue",
445
626
  "i open selected issue",
446
627
  "p open selected pull request",
447
628
  "r refresh health now",
@@ -520,6 +701,45 @@ async function probeBoardHealth(project: ProjectConfig): Promise<BoardHealth> {
520
701
  return { layers, telegram, daemon, codeGraph: cachedGraph ?? (await probeCodeGraph(project)) };
521
702
  }
522
703
 
704
+ /**
705
+ * The label sets behind every stopped lane.
706
+ *
707
+ * Reading them is the whole of the #109 fix: the store only knows what it did
708
+ * itself, so a label a human added or removed — and an issue closed out from
709
+ * under a run row — are invisible to it, and a board built from run rows alone
710
+ * reports last-run history as current work.
711
+ *
712
+ * "Open issues carrying label L" is a read the tracker port spells exactly
713
+ * once, as `listReady`, taking the label from `queueLabel`. Substituting the
714
+ * label reuses that one read for the state labels rather than adding a twelfth
715
+ * method every future adapter would have to implement for a read-only surface.
716
+ * Four `gh` calls in parallel, on the ten-second health cadence and never on
717
+ * the one-second redraw path: the same order of cost as the health probe it
718
+ * runs beside.
719
+ *
720
+ * Never throws. A failed read keeps the previous sets on screen and records
721
+ * why, because blanking QUEUE and FAILED is a louder lie than showing them
722
+ * stale — {@link labelFact} puts the staleness in the header.
723
+ */
724
+ async function probeBoardLabels(project: ProjectConfig, previous?: BoardLabels): Promise<BoardLabels> {
725
+ const openIssuesLabelled = async (label: string): Promise<Set<number>> =>
726
+ new Set((await makeTracker({ ...project, queueLabel: label }).listReady()).map((issue) => issue.number));
727
+ try {
728
+ const [queued, inProgress, blocked, failed] = await Promise.all([
729
+ openIssuesLabelled(project.queueLabel),
730
+ openIssuesLabelled(project.stateLabels.inProgress),
731
+ openIssuesLabelled(project.stateLabels.blocked),
732
+ openIssuesLabelled(project.stateLabels.failed),
733
+ ]);
734
+ return { queued, inProgress, blocked, failed, readAt: Date.now() };
735
+ } catch (err) {
736
+ return {
737
+ ...(previous ?? UNREAD_LABELS),
738
+ error: err instanceof Error ? err.message : String(err),
739
+ };
740
+ }
741
+ }
742
+
523
743
  function issueUrl(project: ProjectConfig, issue: number): string {
524
744
  return `https://github.com/${project.tracker.repo}/issues/${issue}`;
525
745
  }
@@ -587,7 +807,7 @@ export async function runBoard(projectName?: string): Promise<void> {
587
807
  let stopping = false;
588
808
  let help = false;
589
809
  let notice = "";
590
- let health = await probeBoardHealth(project);
810
+ let [health, labels] = await Promise.all([probeBoardHealth(project), probeBoardLabels(project)]);
591
811
  let healthAt = Date.now();
592
812
  let healthRefresh: Promise<void> | undefined;
593
813
 
@@ -611,9 +831,10 @@ export async function runBoard(projectName?: string): Promise<void> {
611
831
  const now = Date.now();
612
832
  if (now - healthAt >= HEALTH_REFRESH_MS && healthRefresh === undefined) {
613
833
  healthAt = now;
614
- healthRefresh = probeBoardHealth(project)
615
- .then((next) => {
616
- health = next;
834
+ healthRefresh = Promise.all([probeBoardHealth(project), probeBoardLabels(project, labels)])
835
+ .then(([nextHealth, nextLabels]) => {
836
+ health = nextHealth;
837
+ labels = nextLabels;
617
838
  enqueue(queue, { name: "refresh" }, wake);
618
839
  })
619
840
  .catch((err: unknown) => {
@@ -627,6 +848,7 @@ export async function runBoard(projectName?: string): Promise<void> {
627
848
  project,
628
849
  status: statusSnapshotFromStore(project, caps, store),
629
850
  health,
851
+ labels,
630
852
  runs: store.recentRuns(project.name, now - MERGED_HISTORY_MS),
631
853
  now,
632
854
  };
@@ -712,11 +934,26 @@ export async function runBoard(projectName?: string): Promise<void> {
712
934
  continue;
713
935
  }
714
936
  if (name === "u") {
715
- const state = card?.kind === "run" ? card.run.state : undefined;
716
- if (card === undefined || card.kind !== "run" || !["blocked", "failed", "killed", "orphaned"].includes(state!)) {
717
- notice = "unblock is available for blocked or failed runs";
937
+ // Lane, not run state. `unblock` clears the blocked and failed labels
938
+ // unconditionally and the in-progress label once the newest run row is
939
+ // terminal (#18), so it is meaningful for exactly the three lanes those
940
+ // labels define — including an issue with no run row at all, which the
941
+ // old newest-run check could not reach.
942
+ //
943
+ // On an ORPHANED card backed by an `orphaned` row this is the release
944
+ // path: that row is terminal, so the in-progress label finally comes
945
+ // off. On an ORPHANED card with no run row it is not, and `unblock`
946
+ // says so itself rather than being talked out of it here — a missing
947
+ // row cannot be told apart from a claim that raced the store write, so
948
+ // it leaves the label in place by design and prints that line. Being
949
+ // wrong the other way is two workers on one issue; the exit for a card
950
+ // stuck like that is escalation, not a wider gate here. Both outcomes
951
+ // are worth offering; neither is promised.
952
+ const lane = COLUMN_DEFS[cursor.column]?.key;
953
+ if (card === undefined || (lane !== "blocked" && lane !== "failed" && lane !== "orphaned")) {
954
+ notice = "unblock is available for blocked, failed or orphaned issues";
718
955
  } else {
719
- notice = await unblock(project, card.run.issue);
956
+ notice = await unblock(project, cardIssue(card));
720
957
  healthAt = 0;
721
958
  }
722
959
  }
@@ -87,7 +87,8 @@ checked in this order:
87
87
  `merged`. Never release the label: the open-PR guard only objects to an *open*
88
88
  PR, so a re-claim would re-implement a PR that is already on the base branch.
89
89
  Close the issue if the merge satisfied it (a merge with no closing keyword
90
- leaves it open), take the queue label off, and remove the in-progress label last.
90
+ leaves it open) and take the queue label off. The in-progress label is not
91
+ yours to strip: the conductor releases it when the run row settles.
91
92
  - **A PR closed without merging.** A human read the work and said no; the row says
92
93
  `failed`. Read the rejection before you touch anything — most of the time a
93
94
  review comment is a spec change. Fold what it says into the issue, then release
@@ -114,6 +115,16 @@ checked in this order:
114
115
  - **Genuinely nothing** (clean tree, no commits, no PR). Release the label and let
115
116
  the next tick re-claim it clean.
116
117
 
118
+ Releasing a label, in any of these cases, is `omp-conductor unblock <n>` — never
119
+ a hand edit, for the reason above: the verb writes through the same tracker the
120
+ dispatcher does. It takes the blocked and failed labels off unconditionally, and
121
+ `agent:in-progress` too once the newest run row is terminal — which includes the
122
+ `orphaned` row a daemon restart writes for a worker that died with it, so that is
123
+ the usual answer here. Two situations leave in-progress in place, and it says
124
+ which in its output: a run that is genuinely still live, where declining is the
125
+ correct answer, and no run row at all to prove the worker is gone. In that second
126
+ case, escalate the stuck issue rather than editing its labels.
127
+
117
128
  Never leave an orphan holding a slot "to be safe": a label nobody is working under
118
129
  is not safety, it is a deadlocked fleet that looks busy.
119
130
 
@@ -151,6 +162,13 @@ actually asked, in one message, from evidence you already hold or go and fetch.
151
162
  Then stop. Do not continue loop narration in the same reply, and do not restate
152
163
  in-progress work unless they asked for it. The loop resumes on the next tick.
153
164
 
165
+ A message may also reach you **mid-tick** (delivery is steering: it arrives
166
+ between two of your tool calls). Treat it as an interrupt, not a new tick:
167
+ answer it immediately with `telegram_send` in one message, then return to the
168
+ duty you were in the middle of and finish it. Never abandon or restart the tick
169
+ because a message arrived, and never batch the answer "for the report" — the
170
+ person is waiting now.
171
+
154
172
  ## Escalation tiers
155
173
 
156
174
  | Tier | Meaning | Handled by |
@@ -234,7 +252,11 @@ The protocol, in order:
234
252
  text is what you *apply* on a yes — it is not what you send.
235
253
  2. **Ask, once — a single yes/no question, written for a phone.** Explicitly
236
254
  call `telegram_ask`; never use the generic `ask` UI. Confirm that the tool
237
- delivered the question to the configured Telegram chat. Telegram renders
255
+ delivered the question to the configured Telegram chat. It is mounted on a
256
+ locally injected tick only once your operator has configured the bridge's
257
+ notify destination, and a tick that lacks it says so in its own text. When it
258
+ does, send the same single yes/no question with `telegram_send` and treat
259
+ your operator's later reply as the answer — never assume one. Telegram renders
238
260
  none of your markdown, so asterisks and backticks arrive as literal characters:
239
261
  - Lead with one plain sentence: what changes, and why, in your own words.
240
262
  - Then show only the lines that actually change, compact, under two short