omp-conductor 0.9.1 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/board.ts CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  } from "./fleet.ts";
13
13
  import { probeCodeGraph, type CodeGraphHealth } from "./graph-health.ts";
14
14
  import { healthCheck, livingDaemon } from "./lifecycle.ts";
15
+ import type { WorkerPausePhase } from "./worker.ts";
15
16
  import { dbPath, openStore } from "./store.ts";
16
17
  import { formatTranscriptLine } from "./transcript.ts";
17
18
  import { makeTracker } from "./tracker/github.ts";
@@ -72,9 +73,9 @@ const COLUMN_DEFS = [
72
73
  { key: "blocked", title: "BLOCKED" },
73
74
  { key: "failed", title: "FAILED" },
74
75
  { key: "orphaned", title: "ORPHANED" },
75
- { key: "parked", title: "PARKED" },
76
76
  { key: "merged", title: "MERGED" },
77
- { key: "history", title: "HISTORY" },
77
+ { key: "settled", title: "SETTLED" },
78
+ { key: "parked", title: "PARKED" },
78
79
  ] as const satisfies readonly { key: string; title: string }[];
79
80
 
80
81
  export type BoardLane = (typeof COLUMN_DEFS)[number]["key"];
@@ -136,6 +137,11 @@ export interface BoardHealth {
136
137
  codeGraph?: CodeGraphHealth;
137
138
  }
138
139
 
140
+ interface BoardHealthProbe {
141
+ health: BoardHealth;
142
+ pausedPhases: ReadonlyMap<number, WorkerPausePhase>;
143
+ }
144
+
139
145
  /**
140
146
  * Which issues currently carry each label the board reasons about, read from
141
147
  * the tracker rather than inferred from run rows.
@@ -151,11 +157,15 @@ export interface BoardLabels {
151
157
  inProgress: ReadonlySet<number>;
152
158
  blocked: ReadonlySet<number>;
153
159
  failed: ReadonlySet<number>;
154
- /** Issues whose newest run is terminal and unlabelled but that are still open
155
- * on the tracker the PARKED lane (#173), probed on the same 10s cadence as
156
- * the label sets. An issue missing from this set (closed, or the probe could
157
- * not tell) keeps today's HISTORY/undefined behaviour rather than parking. */
158
- open: ReadonlySet<number>;
160
+ /**
161
+ * Issues the tracker reports CLOSED, derived only from a *successful* label
162
+ * snapshot on the same 10s cadence as the other sets. This is the one fact
163
+ * that separates SETTLED (finished work, issue closed) from PARKED (a human
164
+ * still has to look): absence from this set means open **or** the probe could
165
+ * not tell, and either way the run parks rather than falsely asserting
166
+ * closure. On a failed read the last good set survives behind {@link error}.
167
+ */
168
+ closed: ReadonlySet<number>;
159
169
  /** Epoch ms of the last read that succeeded; 0 when none has. */
160
170
  readAt: number;
161
171
  /** Message from the most recent failed read. The sets above are then the last
@@ -171,7 +181,7 @@ const UNREAD_LABELS: BoardLabels = {
171
181
  inProgress: new Set<number>(),
172
182
  blocked: new Set<number>(),
173
183
  failed: new Set<number>(),
174
- open: new Set<number>(),
184
+ closed: new Set<number>(),
175
185
  readAt: 0,
176
186
  };
177
187
 
@@ -179,6 +189,7 @@ export interface BoardSnapshot {
179
189
  project: ProjectConfig;
180
190
  status: StatusSnapshot;
181
191
  health: BoardHealth;
192
+ pausedPhases: ReadonlyMap<number, WorkerPausePhase>;
182
193
  labels: BoardLabels;
183
194
  /** Live re-verification of pushed rows (#173): run id → what the tracker says
184
195
  * the PR looks like now. Rendered as a `now:` suffix on the card. */
@@ -225,7 +236,7 @@ function ansiColor(key: BoardLane): string {
225
236
  return YELLOW;
226
237
  case "failed":
227
238
  return RED;
228
- case "history":
239
+ case "settled":
229
240
  return DIM;
230
241
  default:
231
242
  return MAGENTA;
@@ -285,10 +296,11 @@ function humanDuration(ms: number): string {
285
296
  * parent issues a human had already cleared (#81, #86) — every one of them a
286
297
  * historical terminal run, not one of them failed work.
287
298
  *
288
- * So: the store decides the live lanes, because a running worker is the most
299
+ * so: the store decides the live lanes, because a running worker is the most
289
300
  * current fact there is; the tracker's current labels decide every stopped
290
301
  * lane; each issue matches exactly once, first branch wins; and last-run
291
- * history is answered in HISTORY, under its own name.
302
+ * history is answered in SETTLED (issue closed) and PARKED (issue still open,
303
+ * labels cleared), under their own names.
292
304
  */
293
305
  function laneOf(
294
306
  snapshot: BoardSnapshot,
@@ -322,17 +334,19 @@ function laneOf(
322
334
  if (queued.has(issue)) return "queue";
323
335
  if (run?.state === "merged") return "merged";
324
336
  if (run === undefined) return undefined;
325
- // PARKED (#173): a terminal run row with no state or queue label left on it,
326
- // on an issue the tracker says is still open. A human cleared the labels but
327
- // has not acted the blank lane the four labels used to imply is a lie, and
328
- // closed or unreadable issues keep the HISTORY/undefined fallthrough below.
329
- // Deliberately not subject to MERGED_HISTORY_MS: parked work does not stop
330
- // being the issue's last word after 24 hours.
331
- if (PARKED_STATES.has(run.state) && labels.open.has(issue)) return "parked";
332
- // What is left is a terminal run row with no current claim on it — a closed
333
- // issue, or one a human already cleared. Recent rows stay readable under
334
- // HISTORY for the same 24 hours MERGED uses; older ones are not news.
335
- return (run.endedAt ?? run.startedAt) >= snapshot.now - MERGED_HISTORY_MS ? "history" : undefined;
337
+ // SETTLED vs PARKED, decided by the one fact the board can prove: whether the
338
+ // tracker confirmed the issue closed. Absence from `closed` means open OR the
339
+ // probe could not tell, and either way the run parks a failed label read
340
+ // must render the issue as "a human has to look", never as finished work
341
+ // (#218). PARKED is deliberately not subject to MERGED_HISTORY_MS: parked work
342
+ // does not stop being the issue's last word after 24 hours.
343
+ if (PARKED_STATES.has(run.state) && !labels.closed.has(issue)) return "parked";
344
+ // What is left is a confirmed-closed issue with a terminal run row. Recent
345
+ // rows stay readable under SETTLED for the same 24 hours MERGED uses; older
346
+ // ones are not news. Named for what it means the run settled and the issue
347
+ // is closed not for the vague archive HISTORY read as, which is how one lane
348
+ // came to look like two different things (#218).
349
+ return (run.endedAt ?? run.startedAt) >= snapshot.now - MERGED_HISTORY_MS ? "settled" : undefined;
336
350
  }
337
351
 
338
352
  /** Description lines for an issue the store has no run row for. Only the lanes
@@ -467,7 +481,7 @@ export function boardLabelsEqual(a: BoardLabels, b: BoardLabels): boolean {
467
481
  sameIssueSet(a.inProgress, b.inProgress) &&
468
482
  sameIssueSet(a.blocked, b.blocked) &&
469
483
  sameIssueSet(a.failed, b.failed) &&
470
- sameIssueSet(a.open, b.open) &&
484
+ sameIssueSet(a.closed, b.closed) &&
471
485
  (a.error === undefined) === (b.error === undefined)
472
486
  );
473
487
  }
@@ -551,43 +565,54 @@ function normalizeCursor(snapshot: BoardSnapshot, cursor: BoardCursor): void {
551
565
  cursor.transcriptOffset = Math.max(0, cursor.transcriptOffset);
552
566
  }
553
567
 
554
- function runCardLines(run: RunRecord, snapshot: BoardSnapshot): string[] {
568
+ function runCardLines(run: RunRecord, snapshot: BoardSnapshot, lane: BoardLane): string[] {
555
569
  const endedAt = run.endedAt ?? snapshot.now;
556
570
  const duration = humanDuration(endedAt - run.startedAt);
571
+ const phase = lane === "running" ? snapshot.pausedPhases.get(run.issue) : undefined;
572
+ const pausePrefix = phase === "paused" ? "⏸ PAUSED " : phase === "pausing" ? "… pausing " : "";
557
573
  const lines = [
558
574
  // The class, when the sweep has attached one and nothing has recovered it
559
575
  // yet (#132): a row state says "failed", which four completed issues on this
560
576
  // fleet also said. The class says which of the two this is.
561
577
  run.failureClass === undefined || run.recoveredAt !== undefined
562
- ? `#${run.issue} · ${run.repo}`
563
- : `#${run.issue} · ${run.repo} [${run.failureClass}]`,
578
+ ? `${pausePrefix}#${run.issue} · ${run.repo}`
579
+ : `${pausePrefix}#${run.issue} · ${run.repo} [${run.failureClass}]`,
564
580
  `attempt ${run.attempt} · ${run.turns}/${run.maxTurns}t`,
565
581
  `$${run.spendUsd.toFixed(2)} · ${duration}`,
566
582
  ];
567
- // #173: a live re-verification of a pushed row, when the 60s probe has one.
568
- // Guarded on the pushed states: a stale entry must not follow a row that
569
- // settled into another lane and keep claiming "now: green" about a PR that
570
- // already merged or failed.
571
- const live =
572
- run.state === "pushed-green" || run.state === "pushed-pending" ? snapshot.pr.get(run.id) : undefined;
573
- if (live !== undefined) {
574
- const reason = live.reason.split(/\s+/, 1)[0] ?? "";
575
- lines.push(
576
- live.status === "failed" ? `now: red ${reason === "" ? "failed" : reason}` : `now: ${prLabel(live.status)}`,
577
- );
578
- }
579
- // #173: a terminal blocked/failed run whose state label is gone reads as the
580
- // lane it sits in — which the label being absent made PARKED, not the truth.
581
- if (isLastRun(run, snapshot.labels)) lines.push(`last run: ${run.state}`);
582
- // Ordered by what needs a human first. An unsalvaged tree outranks even a
583
- // last error: the error describes a run that is over, the tree is work that
584
- // is still at risk and an issue that will not dispatch (#118).
583
+ // Exactly one fourth line, so a card stays within the four content lines that
584
+ // renderColumn's slot arithmetic (`slots = floor((height - 2) / 5)`) allocates
585
+ // for it. Every fact a card can carry folds into this single priority:
586
+ // an unsalvaged tree outranks even a live verdict (#118) — the error describes
587
+ // a run that is over, the tree is work still at risk — then the live probe for
588
+ // a pushed row, then the word the lane name cannot carry, then the run's own
589
+ // evidence.
590
+ //
591
+ // `isLastRun` is deliberately not a card line: it belongs in the detail header
592
+ // (renderDetail's `stateShown`), where a full width can spell out `last run:
593
+ // blocked`. Putting it here too would blow the budget and repeat the #218
594
+ // conflation on the two-line boundary.
585
595
  if (run.salvageError !== undefined && run.salvageAckAt === undefined) lines.push("UNSALVAGED WIP");
586
- else if (run.state === "pushed-pending") lines.push("checks pending");
587
- else if (run.salvageSha !== undefined) lines.push(`wip @ ${run.salvageSha.slice(0, 7)}`);
588
- else if (run.lastError !== undefined) lines.push(run.lastError.replace(/\s+/g, " "));
589
- else if (run.prUrl !== undefined) lines.push(run.prUrl.replace(/^https?:\/\//, ""));
590
- else lines.push(run.branch);
596
+ else {
597
+ // #173: a live re-verification of a pushed row, when the 60s probe has one.
598
+ // Guarded on the pushed states: a stale entry must not follow a row that
599
+ // settled into another lane and keep claiming "now: green" about a PR that
600
+ // already merged or failed.
601
+ const live =
602
+ run.state === "pushed-green" || run.state === "pushed-pending" ? snapshot.pr.get(run.id) : undefined;
603
+ if (live !== undefined) {
604
+ const reason = live.reason.split(/\s+/, 1)[0] ?? "";
605
+ lines.push(
606
+ live.status === "failed" ? `now: red — ${reason === "" ? "failed" : reason}` : `now: ${prLabel(live.status)}`,
607
+ );
608
+ } else if (lane === "settled") lines.push("issue closed");
609
+ else if (lane === "parked") lines.push("needs a label");
610
+ else if (run.state === "pushed-pending") lines.push("checks pending");
611
+ else if (run.salvageSha !== undefined) lines.push(`wip @ ${run.salvageSha.slice(0, 7)}`);
612
+ else if (run.lastError !== undefined) lines.push(run.lastError.replace(/\s+/g, " "));
613
+ else if (run.prUrl !== undefined) lines.push(run.prUrl.replace(/^https?:\/\//, ""));
614
+ else lines.push(run.branch);
615
+ }
591
616
  return lines;
592
617
  }
593
618
 
@@ -616,7 +641,7 @@ function renderColumn(
616
641
  lines.push(styledCell(" (empty)", width, DIM));
617
642
  } else {
618
643
  for (const [offset, card] of shown.entries()) {
619
- const raw = card.kind === "run" ? runCardLines(card.run, snapshot) : [`#${card.issue}`, card.reason, card.note];
644
+ const raw = card.kind === "run" ? runCardLines(card.run, snapshot, key) : [`#${card.issue}`, card.reason, card.note];
620
645
  while (raw.length < 4) raw.push("");
621
646
  const selected = selectedColumn && start + offset === selectedCard;
622
647
  for (const [lineIndex, value] of raw.entries()) {
@@ -703,7 +728,7 @@ function admissionLine(snapshot: BoardSnapshot): string {
703
728
  const queue =
704
729
  dispatch === undefined
705
730
  ? "dispatch not recorded"
706
- : `${dispatch.degraded ? "DEGRADED · " : ""}${dispatch.ready} ready · ${dispatch.routed} routed · ${dispatch.admitted} admitted`;
731
+ : `${dispatch.degraded ? "DEGRADED · " : ""}${dispatch.ready} ready · ${dispatch.claimed ?? 0} in flight · ${dispatch.routed} spare · ${dispatch.admitted} admitted`;
707
732
  const holdText = dispatch?.holds.map((hold) => `${hold.reason} ${hold.count}`).join(", ");
708
733
  const holds = holdText === undefined || holdText === "" ? "none" : holdText;
709
734
  return (
@@ -804,8 +829,20 @@ function renderDetail(snapshot: BoardSnapshot, cursor: BoardCursor, width: numbe
804
829
  // #173: a blocked/failed run whose label is gone is the reason this card is
805
830
  // parked; the header says so rather than presenting the state as current.
806
831
  const stateShown = isLastRun(run, snapshot.labels) ? `last run: ${run.state}` : run.state;
832
+ const pausePhase = snapshot.pausedPhases.get(run.issue);
807
833
  const metadata = [
808
834
  styledCell(` RUN #${run.issue} ${run.repo} ${stateShown} `, width, `${BOLD}${REVERSE}`),
835
+ ...(pausePhase === undefined
836
+ ? []
837
+ : [
838
+ styledCell(
839
+ pausePhase === "paused"
840
+ ? "worker paused · wall clock frozen"
841
+ : "worker pausing · draining to harness idle",
842
+ width,
843
+ YELLOW,
844
+ ),
845
+ ]),
809
846
  styledCell(`attempt ${run.attempt} · ${run.turns}/${run.maxTurns} turns · $${run.spendUsd.toFixed(2)} · ${humanDuration((run.endedAt ?? snapshot.now) - run.startedAt)}`, width),
810
847
  styledCell(`branch ${run.branch}`, width, DIM),
811
848
  styledCell(`worktree ${run.worktree || "removed"}`, width, DIM),
@@ -828,12 +865,15 @@ function renderHelp(width: number, height: number): string[] {
828
865
  "↑/↓ or k/j select card",
829
866
  "Enter inspect/follow transcript",
830
867
  "u unblock selected blocked, failed or orphaned issue",
868
+ "space pause/resume selected worker",
831
869
  "i open selected issue",
832
870
  "p open selected pull request",
833
871
  "r refresh health now",
834
872
  "? close help",
835
873
  "Esc back, then quit",
836
874
  "q / Ctrl-C back, then quit",
875
+ "",
876
+ "PARKED = issue open, no label — nothing dispatches it until you label it",
837
877
  ];
838
878
  const top = Math.max(0, Math.floor((height - lines.length) / 2));
839
879
  return [
@@ -882,7 +922,37 @@ export function renderBoard(
882
922
  ].join("\n");
883
923
  }
884
924
 
885
- async function probeBoardHealth(project: ProjectConfig): Promise<BoardHealth> {
925
+ export function workerPhasesFromHealthz(
926
+ body: string | undefined,
927
+ project: string,
928
+ ): ReadonlyMap<number, WorkerPausePhase> {
929
+ const phases = new Map<number, WorkerPausePhase>();
930
+ if (body === undefined) return phases;
931
+ try {
932
+ const payload = JSON.parse(body) as unknown;
933
+ if (payload === null || typeof payload !== "object") return phases;
934
+ if (Reflect.get(payload, "project") !== project) return phases;
935
+ const workers = Reflect.get(payload, "workers");
936
+ if (!Array.isArray(workers)) return phases;
937
+ for (const worker of workers) {
938
+ if (worker === null || typeof worker !== "object") continue;
939
+ const issue = Reflect.get(worker, "issue");
940
+ const phase = Reflect.get(worker, "phase");
941
+ if (
942
+ Number.isSafeInteger(issue) &&
943
+ (issue as number) > 0 &&
944
+ (phase === "pausing" || phase === "paused")
945
+ ) {
946
+ phases.set(issue as number, phase);
947
+ }
948
+ }
949
+ } catch {
950
+ // An unreadable health body means no trustworthy pause phase.
951
+ }
952
+ return phases;
953
+ }
954
+
955
+ async function probeBoardHealth(project: ProjectConfig): Promise<BoardHealthProbe> {
886
956
  const layers = fleetLayers(project.name);
887
957
  const record = livingDaemon();
888
958
  const wrongRecord = record?.project !== undefined && record.project !== project.name;
@@ -903,7 +973,18 @@ async function probeBoardHealth(project: ProjectConfig): Promise<BoardHealth> {
903
973
  }
904
974
  }
905
975
  const cachedGraph = daemon === "ok" ? codeGraphFromHealthz(health?.body, project.name) : undefined;
906
- return { layers, telegram, daemon, codeGraph: cachedGraph ?? (await probeCodeGraph(project)) };
976
+ return {
977
+ health: {
978
+ layers,
979
+ telegram,
980
+ daemon,
981
+ codeGraph: cachedGraph ?? (await probeCodeGraph(project)),
982
+ },
983
+ pausedPhases:
984
+ daemon === "ok"
985
+ ? workerPhasesFromHealthz(health?.body, project.name)
986
+ : new Map<number, WorkerPausePhase>(),
987
+ };
907
988
  }
908
989
 
909
990
  /**
@@ -916,7 +997,7 @@ async function probeBoardHealth(project: ProjectConfig): Promise<BoardHealth> {
916
997
  *
917
998
  * One open-issue snapshot per pass, never one list call per label (#203): the
918
999
  * four lifecycle sets are derived in memory from it by {@link boardLabelSets},
919
- * and PARKED openness is read off the same snapshot instead of a per-candidate
1000
+ * and SETTLED closure is read off the same snapshot instead of a per-candidate
920
1001
  * `issueState` fan-out. The single read rides the conditional-request cache, so
921
1002
  * an idle board costs no primary rate-limit budget at all. The cadence stays
922
1003
  * the ten-second health refresh, never the one-second redraw path.
@@ -947,11 +1028,19 @@ export async function probeBoardLabels(
947
1028
  const parkedCandidates = [...newestRun.values()].filter(
948
1029
  (run) => PARKED_STATES.has(run.state) && !labelled.has(run.issue),
949
1030
  );
950
- // The snapshot IS the set of open issues, so openness needs no per-issue
951
- // lookup: a candidate whose issue appears in it is open.
1031
+ // The snapshot IS the set of open issues, so closure needs no per-issue
1032
+ // lookup: a candidate whose issue does NOT appear in it is closed, *because
1033
+ // this read succeeded*. That last clause is the whole distinction on the
1034
+ // failed path — `closed` is only ever derived here, inside the try, so an
1035
+ // unreadable probe survives as the previous set (or empty) and an issue the
1036
+ // board could not confirm closed keeps parking rather than falsely reading
1037
+ // as SETTLED (#218). Absence from `closed` is therefore open OR unknown,
1038
+ // and both mean PARKED.
952
1039
  const openNumbers = new Set(open.map((issue) => issue.number));
953
- const openSet = new Set(parkedCandidates.map((run) => run.issue).filter((n) => openNumbers.has(n)));
954
- return { queued, inProgress, blocked, failed, open: openSet, readAt: Date.now() };
1040
+ const closed = new Set(
1041
+ parkedCandidates.map((run) => run.issue).filter((n) => !openNumbers.has(n)),
1042
+ );
1043
+ return { queued, inProgress, blocked, failed, closed, readAt: Date.now() };
955
1044
  } catch (err) {
956
1045
  return {
957
1046
  ...(previous ?? UNREAD_LABELS),
@@ -994,6 +1083,41 @@ async function unblock(project: ProjectConfig, issue: number): Promise<string> {
994
1083
  if (code !== 0) return (stderr || stdout).trim().replace(/\s+/g, " ") || `#${issue}: unblock failed`;
995
1084
  return summarizeUnblockOutput(issue, stdout);
996
1085
  }
1086
+
1087
+ async function toggleWorkerPause(
1088
+ project: ProjectConfig,
1089
+ issue: number,
1090
+ phase: WorkerPausePhase | undefined,
1091
+ ): Promise<string> {
1092
+ if (phase === "pausing") return "still pausing — wait";
1093
+ const daemon = livingDaemon();
1094
+ if (daemon === undefined) return "daemon is not running";
1095
+ if (daemon.project !== undefined && daemon.project !== project.name) {
1096
+ return `daemon serves project "${daemon.project}", not requested project "${project.name}"`;
1097
+ }
1098
+ const action = phase === "paused" ? "resume" : "pause";
1099
+ try {
1100
+ const response = await fetch(
1101
+ `http://127.0.0.1:${daemon.port}/runs/${issue}/${action}`,
1102
+ {
1103
+ method: "PUT",
1104
+ headers: { "content-type": "application/json" },
1105
+ body: JSON.stringify({ project: project.name }),
1106
+ },
1107
+ );
1108
+ const payload = (await response.json()) as { error?: unknown; phase?: unknown };
1109
+ if (!response.ok) {
1110
+ return typeof payload.error === "string"
1111
+ ? payload.error
1112
+ : `daemon returned HTTP ${response.status}`;
1113
+ }
1114
+ return typeof payload.phase === "string"
1115
+ ? `#${issue} worker ${payload.phase}`
1116
+ : "daemon returned an invalid worker-control response";
1117
+ } catch (err) {
1118
+ return err instanceof Error ? err.message : String(err);
1119
+ }
1120
+ }
997
1121
  function enqueue(queue: KeyInput[], key: KeyInput, wake: (() => void) | undefined): void {
998
1122
  queue.push(key);
999
1123
  wake?.();
@@ -1025,7 +1149,7 @@ export async function boardSnapshotOnce(projectName?: string): Promise<BoardSnap
1025
1149
  const store: Store = openStore(dbPath());
1026
1150
  try {
1027
1151
  const now = Date.now();
1028
- const [health, labels, planUsage] = await Promise.all([
1152
+ const [healthProbe, labels, planUsage] = await Promise.all([
1029
1153
  probeBoardHealth(project),
1030
1154
  probeBoardLabels(project, store),
1031
1155
  readPlanUsage(caps.planUsage, sharedUsageSource()),
@@ -1033,7 +1157,8 @@ export async function boardSnapshotOnce(projectName?: string): Promise<BoardSnap
1033
1157
  return {
1034
1158
  project,
1035
1159
  status: statusSnapshotFromStore(project, caps, store, planUsage),
1036
- health,
1160
+ health: healthProbe.health,
1161
+ pausedPhases: healthProbe.pausedPhases,
1037
1162
  labels,
1038
1163
  pr: new Map(),
1039
1164
  runs: store.recentRuns(project.name, now - MERGED_HISTORY_MS),
@@ -1111,11 +1236,13 @@ export async function runBoard(projectName?: string): Promise<void> {
1111
1236
  onCall: () => trackerCalls.push(Date.now()),
1112
1237
  onNotModified: () => trackerFree.push(Date.now()),
1113
1238
  });
1114
- let [health, labels, planUsage] = await Promise.all([
1239
+ let [healthProbe, labels, planUsage] = await Promise.all([
1115
1240
  probeBoardHealth(project),
1116
1241
  probeBoardLabels(project, store, undefined, boardTracker),
1117
1242
  readPlanUsage(caps.planUsage, sharedUsageSource()),
1118
1243
  ]);
1244
+ let health = healthProbe.health;
1245
+ let pausedPhases = healthProbe.pausedPhases;
1119
1246
  let healthAt = Date.now();
1120
1247
  let healthRefresh: Promise<void> | undefined;
1121
1248
  // The tracker read is gated separately from health and the plan allowance:
@@ -1157,8 +1284,9 @@ export async function runBoard(projectName?: string): Promise<void> {
1157
1284
  // subprocess, and an allowance does not move at 1 Hz.
1158
1285
  readPlanUsage(caps.planUsage, sharedUsageSource()),
1159
1286
  ])
1160
- .then(([nextHealth, nextPlanUsage]) => {
1161
- health = nextHealth;
1287
+ .then(([nextProbe, nextPlanUsage]) => {
1288
+ health = nextProbe.health;
1289
+ pausedPhases = nextProbe.pausedPhases;
1162
1290
  planUsage = nextPlanUsage;
1163
1291
  enqueue(queue, { name: "refresh" }, wake);
1164
1292
  })
@@ -1212,6 +1340,7 @@ export async function runBoard(projectName?: string): Promise<void> {
1212
1340
  project,
1213
1341
  status: statusSnapshotFromStore(project, caps, store, planUsage),
1214
1342
  health,
1343
+ pausedPhases,
1215
1344
  labels,
1216
1345
  pr: prProbe,
1217
1346
  runs: store.recentRuns(project.name, now - MERGED_HISTORY_MS),
@@ -1303,6 +1432,17 @@ export async function runBoard(projectName?: string): Promise<void> {
1303
1432
  else notice = (await openUrl(url)) ? `opened ${url}` : `failed to open ${url}`;
1304
1433
  continue;
1305
1434
  }
1435
+ if (name === "space") {
1436
+ const lane = COLUMN_DEFS[cursor.column]?.key;
1437
+ if (card === undefined || card.kind !== "run" || lane !== "running") {
1438
+ notice = "pause/resume is available for RUNNING cards";
1439
+ } else {
1440
+ const issue = cardIssue(card);
1441
+ notice = await toggleWorkerPause(project, issue, pausedPhases.get(issue));
1442
+ healthAt = 0;
1443
+ }
1444
+ continue;
1445
+ }
1306
1446
  if (name === "u") {
1307
1447
  // Lane, not run state. `unblock` clears the blocked and failed labels
1308
1448
  // unconditionally and the in-progress label once the newest run row is
@@ -191,6 +191,39 @@ Keep the queue worth draining.
191
191
  every attempt.
192
192
  - An issue that has exhausted its attempts is not a retry candidate. Diagnose it,
193
193
  split it, or hand it back to a human.
194
+ - **Grooming is throughput-bound, so delegate the finding.** N workers drain the
195
+ queue in parallel while you refill it alone: on a three-worker fleet grooming
196
+ has to produce roughly three well-specced issues in the time one worker takes
197
+ to finish one. Auditing candidates serially cannot keep up, and the board then
198
+ reads "0 ready / workers idle" while you are doing exactly what this duty asks.
199
+ When the queue is below the grooming trigger, fan out read-only `scout`
200
+ subagents over backlog clusters **in one batch** rather than auditing one issue
201
+ at a time. Scouts do the finding; you still do the deciding and you still write
202
+ the brief. Name the authoritative source in every scout brief and forbid
203
+ unnamed fallbacks. The quality bar above does not move.
204
+ - **Give every scout the same return contract**, or it comes back with prose
205
+ nobody can act on:
206
+ - verdict — `ALREADY DONE` / `PROMOTABLE` / `NEEDS DECOMPOSITION` / `BLOCKED` /
207
+ `NEEDS PRODUCT DECISION`
208
+ - routing — exactly one repo, or `MULTI` with the split
209
+ - evidence — the file or symbol proving `ALREADY DONE`, never a title match
210
+ - entry points — the 3-6 files to change or read first
211
+ - existing tests covering the behaviour, by path
212
+ - the one thing most likely to be silently faked
213
+ - source — where the code was read: the clone/ref and how fresh it is. A
214
+ scout that cannot reach a source it trusts returns `BLOCKED` and says so;
215
+ silent fallback to an unnamed source is the failure mode of delegated
216
+ research — stale evidence reads exactly like good evidence.
217
+ - **Disqualifying an issue is a successful grooming outcome.** Measured on this
218
+ package's own fleet: four scouts over sixteen backlog issues promoted four and
219
+ *disqualified six* that looked promotable from their titles — four written
220
+ against a tenancy model a later epic retired, two blocked behind open
221
+ prerequisites named in their own bodies. Each would have burned a worker's whole
222
+ attempt to discover. On anything old, check first whether a later epic has
223
+ invalidated the issue's premise; that check is mechanical, read-only, and
224
+ exactly what a scout is cheap at and you are expensive at.
225
+ - Examine widely, promote narrowly. Sixteen examined and four promoted is the
226
+ shape to aim for — the cap is on what you **promote**, never on what you look at.
194
227
 
195
228
  ## Duty 3 — report
196
229
 
@@ -200,12 +233,23 @@ decides whether this tick ends in a message or in silence.
200
233
  *How* a report is delivered is not yours, and is not negotiable: run
201
234
  `omp-conductor report --text "<the whole report>"` (add `--kind digest` for the
202
235
  daily digest). It persists the text before anything is sent and prints a report
203
- id; the daemon retries until it lands and `omp-conductor status` lists whatever
236
+ report id; the daemon retries until it lands and `omp-conductor status` lists whatever
204
237
  has not. Writing a report as end-of-turn text on a tick reaches nobody — that is
205
238
  how a suite release and two tier-2 escalations went missing on 2026-08-06 — and
206
239
  `telegram_send` reaches somebody but leaves no record that it did, so a report
207
240
  sent that way is undetectable when it does not arrive.
208
241
 
242
+ A report is an update. It never contains a request: no "needs you" header, no
243
+ "let me know", no embedded options. Anything needing a decision, approval or
244
+ answer leaves as its own ask (`telegram_ask`) at the moment it is known — the
245
+ question in one sentence, your recommendation, and the options with their
246
+ consequences, the recommended one marked. Batch several questions into one
247
+ ask (the surface takes up to five); never one call per question, and never a
248
+ numbered menu typed into a plain message. Still open a `decision` row for
249
+ anything you ask: the ask is how it reaches a human, the row is what stops it
250
+ being forgotten. In both directions the delivery contract is explicit: a
251
+ message you did not explicitly send is a message that did not arrive.
252
+
209
253
  ## Human messages
210
254
 
211
255
  A human writing to you between ticks is not a tick. Answer with a **single
@@ -104,9 +104,12 @@ two mistakes. `omp-conductor status` lists anything still undelivered.
104
104
  an answer to their message, or a question of your own. It is not a report: it
105
105
  leaves no record that anything went out. And a `cancelled` or errored
106
106
  `telegram_ask` is a delivery failure, not an answer: re-deliver the question with
107
- `telegram_send`, or report the channel as broken. It is never "asked once, no
107
+ with `telegram_send`, or report the channel as broken. It is never "asked once, no
108
108
  reply, dropped".
109
109
 
110
+ Reports never carry questions: anything needing an answer goes out as its own
111
+ ask, with a recommendation and options.
112
+
110
113
  No scope licenses narration. No progress updates, no "checking the queue
111
114
  now", no restating this brief back. Evidence, or silence.
112
115