omp-conductor 0.19.2 → 0.19.4

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/fleet.ts CHANGED
@@ -24,7 +24,12 @@ import {
24
24
  import { homedir } from "node:os";
25
25
  import { dirname, join, sep } from "node:path";
26
26
  import { findProject, loadConfig, resolveArmProof, stateDir } from "./config.ts";
27
- import { clearArmTransaction, readArmAcknowledgement, recordArmChallenge } from "./arm-challenge.ts";
27
+ import {
28
+ clearArmTransaction,
29
+ FLEET_ARM_KEY,
30
+ readArmAcknowledgement,
31
+ recordArmChallenge,
32
+ } from "./arm-challenge.ts";
28
33
  import {
29
34
  claimedTelegramTopics,
30
35
  lockPidAlive,
@@ -398,14 +403,18 @@ export async function armTicks(projectName?: string, deps: ArmDeps = {}): Promis
398
403
  return { path, alreadyArmed, owner: channel.owner, proof };
399
404
  }
400
405
 
406
+ const send = deps.sendChallenge ?? sendTelegramMessage;
407
+ const timeoutMs = deps.timeoutMs ?? ARM_CHALLENGE_TIMEOUT_MS;
401
408
  const code = makeChallengeCode();
409
+ // Self-describing (#991): with two live challenges in one chat the operator
410
+ // was working out which was which from message order, and nothing said how
411
+ // long a code stayed good — so the safe move was to scroll for the newest,
412
+ // which is the work conductor-owned verification was meant to end.
402
413
  const text =
403
414
  `Fleet arming check${named === undefined ? "" : ` — project ${named}`}. ` +
404
415
  `Reply to this chat with exactly:\n${code}\n` +
416
+ `Valid for ${armClock(timeoutMs)}. ` +
405
417
  `Nothing will be dispatched until that reply is seen in the orchestrator session.`;
406
-
407
- const send = deps.sendChallenge ?? sendTelegramMessage;
408
- const timeoutMs = deps.timeoutMs ?? ARM_CHALLENGE_TIMEOUT_MS;
409
418
  const sentAt = (deps.now ?? Date.now)();
410
419
  // The orchestrator's inbound adapter can only acknowledge an *active*
411
420
  // challenge, so the authenticated pending record (hash + expiry, never the
@@ -462,6 +471,149 @@ export async function armTicks(projectName?: string, deps: ArmDeps = {}): Promis
462
471
  return { path, alreadyArmed, owner: channel.owner, challenge: code, proof };
463
472
  }
464
473
 
474
+ export interface FleetArmResult {
475
+ /** Every project this ceremony armed, in configuration order. */
476
+ armed: { project: string; path: string; alreadyArmed: boolean }[];
477
+ owner: string;
478
+ /** The transaction the single reply proved. */
479
+ challengeId: string;
480
+ }
481
+
482
+ /**
483
+ * One ceremony for the whole fleet (#991).
484
+ *
485
+ * Arming was per project: a two-project fleet meant two sequential handshakes
486
+ * with two codes in one chat, though nothing about the fleet's state differed
487
+ * between them. This sends **one** challenge and arms every configured project
488
+ * from the single matching reply — which is a fleet-wide pending record, not a
489
+ * loop that sends N challenges and waits for N replies. That loop is the
490
+ * current friction with one command wrapped around it.
491
+ *
492
+ * Every project is validated before anything is sent, so a fleet whose second
493
+ * project has no `armedFile` refuses the ceremony instead of arming the first
494
+ * and then failing — a half-armed fleet is worse than an unarmed one, because
495
+ * only one of its halves dispatches.
496
+ *
497
+ * A project whose declared proof is `claim-only` is armed by this too: an
498
+ * authenticated round-trip is strictly stronger than the plumbing verdict that
499
+ * policy would have accepted, so satisfying the weaker gate with the stronger
500
+ * proof cannot weaken it.
501
+ */
502
+ export async function armFleet(
503
+ projectNames: readonly string[],
504
+ deps: ArmDeps = {},
505
+ ): Promise<FleetArmResult> {
506
+ if (projectNames.length === 0) throw new Error("arm: no projects are configured");
507
+
508
+ // Resolve and validate every project first. Nothing is sent and no marker is
509
+ // written until the whole fleet is known to be armable.
510
+ const targets: { project: string; armedFile: string; accessFile: string; cwd: string; stateKey?: string }[] = [];
511
+ for (const name of projectNames) {
512
+ const tick = resolveTickConfig(name);
513
+ if (tick.kind === "invalid") throw new Error(`tick config invalid at ${tick.path}: ${tick.problem}`);
514
+ if (tick.kind === "absent") {
515
+ throw new Error(
516
+ `no ${TICK_CONFIG_FILE} under ${tickConfigSearchRoots(name).join(" or ")} for ${name} — ` +
517
+ `nothing would read an arm marker; drop a tick config first`,
518
+ );
519
+ }
520
+ if (tick.config.armedFile === undefined) {
521
+ throw new Error(`${tick.path} has no armedFile — ${name}'s heartbeat is ungated; add armedFile before arming`);
522
+ }
523
+ if (tick.config.accessFile === undefined) {
524
+ throw new Error(`${tick.path} has no accessFile — arm cannot prove an inbound channel for ${name}`);
525
+ }
526
+ targets.push({
527
+ project: name,
528
+ armedFile: tick.config.armedFile,
529
+ accessFile: tick.config.accessFile,
530
+ cwd: tick.cwd,
531
+ ...(tick.config.project === undefined ? {} : { stateKey: tick.config.project }),
532
+ });
533
+ }
534
+
535
+ // One bot, one chat: the channel is a host fact, so the first target's
536
+ // access file speaks for the fleet. A project pointing at a different file
537
+ // would be a different bot, which this ceremony cannot span — and would
538
+ // announce itself here as a down channel rather than silently arming.
539
+ const first = targets[0]!;
540
+ const channel = readPairedChannel(first.accessFile);
541
+ if (channel.kind === "down") {
542
+ throw new Error(
543
+ `escalation channel is not up (${first.accessFile}): ${channel.reason} — ` +
544
+ `pair the bot (/telegram pair) and enable the bridge (/telegram on) before arming`,
545
+ );
546
+ }
547
+ const token = readBotToken();
548
+ if (token === undefined) {
549
+ throw new Error(
550
+ `no TELEGRAM_BOT_TOKEN in ${join(telegramStateDir(), ".env")} — the arm challenge cannot send without it`,
551
+ );
552
+ }
553
+
554
+ // The first project that resolves a live topic carries the ceremony, and the
555
+ // message says so: a fleet-wide question still has to land somewhere an
556
+ // operator is reading, and every session's adapter can acknowledge it because
557
+ // the pending record is fleet-wide rather than topic-scoped.
558
+ let sendTopic: number | undefined;
559
+ for (const target of targets) {
560
+ try {
561
+ sendTopic = resolveProjectTopicId(findProject(loadConfig(), target.project));
562
+ } catch {
563
+ continue; /* no project config — try the next */
564
+ }
565
+ if (sendTopic !== undefined) break;
566
+ }
567
+
568
+ const send = deps.sendChallenge ?? sendTelegramMessage;
569
+ const timeoutMs = deps.timeoutMs ?? ARM_CHALLENGE_TIMEOUT_MS;
570
+ const code = makeChallengeCode();
571
+ const names = targets.map((t) => t.project).join(", ");
572
+ const text =
573
+ `Fleet arming check — ${String(targets.length)} project(s): ${names}. ` +
574
+ `Reply to this chat with exactly:\n${code}\n` +
575
+ `Valid for ${armClock(timeoutMs)}, and one reply arms all of them. ` +
576
+ `Nothing will be dispatched until that reply is seen in the orchestrator session.`;
577
+
578
+ const sentAt = (deps.now ?? Date.now)();
579
+ const challengeId = recordArmChallenge(FLEET_ARM_KEY, code, sentAt, sentAt + timeoutMs);
580
+ try {
581
+ await send(token, channel.owner, text, sendTopic);
582
+ } catch (err) {
583
+ clearArmTransaction(FLEET_ARM_KEY, challengeId);
584
+ throw new Error(
585
+ `arm: outbound sendMessage failed — NOT armed: ${err instanceof Error ? err.message : String(err)}`,
586
+ );
587
+ }
588
+ deps.progress?.(
589
+ `arm: one challenge sent to ${channel.owner}${sendTopic === undefined ? "" : ` (topic ${sendTopic})`} for ${names} — ` +
590
+ `waiting up to ${armClock(timeoutMs)} for the reply. Dispatch stays held until it arrives.`,
591
+ );
592
+
593
+ if (!(await waitForArmAcknowledgement(challengeId, timeoutMs, deps))) {
594
+ clearArmTransaction(FLEET_ARM_KEY, challengeId);
595
+ throw new Error(
596
+ `arm: the fleet challenge was never acknowledged in time — NOTHING armed.\n` +
597
+ `No acknowledgement for challenge ${challengeId} arrived, so no project's marker was written.\n` +
598
+ `Inbound Telegram is not reaching the omp session. Check, in order:\n` +
599
+ ` * is the bridge polling? attach and run: /telegram status\n` +
600
+ ` * is another process holding this bot token? Telegram allows exactly one\n` +
601
+ ` getUpdates consumer and rejects the second with HTTP 409.\n` +
602
+ ` * did you reply in the chat the challenge names, not another one?\n`,
603
+ );
604
+ }
605
+
606
+ // Proven once, applied to every project. Markers are written after the proof,
607
+ // so a refused ceremony leaves the fleet exactly as it was.
608
+ const armed = targets.map((target) => {
609
+ const state = resolveArmState(target.armedFile, target.project);
610
+ writeArmedMarker(target.armedFile, channel.owner, state);
611
+ return { project: target.project, path: target.armedFile, alreadyArmed: state.armed };
612
+ });
613
+ clearArmTransaction(FLEET_ARM_KEY, challengeId);
614
+ return { armed, owner: channel.owner, challengeId };
615
+ }
616
+
465
617
  export interface HoldResult {
466
618
  wasPaused: boolean;
467
619
  /** Absent when the caller kept the heartbeat armed. */
@@ -663,7 +815,7 @@ export function startHerdrFleet(projectName?: string, deps: HerdrStartDeps = {})
663
815
  }
664
816
 
665
817
  export interface HerdrAgent {
666
- name: string;
818
+ name?: string;
667
819
  paneId: string;
668
820
  agent?: string;
669
821
  sessionPath?: string;
@@ -746,6 +898,18 @@ export interface WorkerPaneDeps {
746
898
  */
747
899
  export const WORKER_PANE_SOURCE = "omp-conductor";
748
900
 
901
+ /**
902
+ * How many times this daemon re-establishes one run's pane before it stops
903
+ * trying (#998).
904
+ *
905
+ * The pathology being bounded is unboundedness, not frequency: a pane that
906
+ * cannot be created is not going to start working on the next pass, and the
907
+ * retries actively make it worse — `pane_split_failed: ghostty error -2` is the
908
+ * terminal the loop eventually produces for itself. A run with no pane keeps
909
+ * working; only its representation is missing.
910
+ */
911
+ export const PANE_REATTEMPT_MAX = 3;
912
+
749
913
  export function workerPaneLabel(identity: WorkerPaneIdentity): string {
750
914
  return `worker-${identity.project}-${identity.issue}-a${identity.attempt}-${identity.runId.slice(0, 8)}`;
751
915
  }
@@ -764,16 +928,46 @@ function defaultViewer(identity: WorkerPaneIdentity): readonly string[] {
764
928
  return ["omp-conductor", "tail", String(identity.issue), "--project", identity.project];
765
929
  }
766
930
 
931
+ /**
932
+ * The pane id inside `herdr pane split`'s answer.
933
+ *
934
+ * herdr answers every CLI call with a one-line JSON envelope
935
+ * (`{"id":"cli:pane:split","result":{"pane":{"pane_id":"w2:pT",…}}}`), so raw
936
+ * stdout is never a pane id. Passing it through verbatim made every worker
937
+ * launch fail `rename` with `pane_not_found` while leaking the pane it had just
938
+ * created (#992) — and the test fake that answered a bare `%7` is why the suite
939
+ * agreed. Parse the field; a shape this does not recognise is no pane id at
940
+ * all, never a best guess scraped out of the payload.
941
+ */
942
+ function parsePaneId(stdout: string): string | undefined {
943
+ const line = firstLine(stdout);
944
+ if (line === "") return undefined;
945
+ let payload: unknown;
946
+ try {
947
+ payload = JSON.parse(line);
948
+ } catch {
949
+ return undefined;
950
+ }
951
+ if (payload === null || typeof payload !== "object") return undefined;
952
+ const result = (payload as Record<string, unknown>)["result"];
953
+ if (result === null || typeof result !== "object") return undefined;
954
+ const pane = (result as Record<string, unknown>)["pane"];
955
+ if (pane === null || typeof pane !== "object") return undefined;
956
+ const paneId = (pane as Record<string, unknown>)["pane_id"];
957
+ return typeof paneId === "string" && paneId.trim() !== "" ? paneId.trim() : undefined;
958
+ }
959
+
767
960
  /**
768
961
  * Create the pane, run the follower in it, and report the child's identity and
769
962
  * initial state — or say exactly why it could not.
770
963
  *
771
964
  * Every step is checked, and the first failure returns `unavailable` with the
772
- * reason: a partially established representation (a pane with no agent identity,
773
- * say) is worse than none, because it is a pane the operator would read as a
774
- * tracked worker. What this deliberately does NOT do is decide what a failure
775
- * means for the launch failing closed versus running degraded is #841's
776
- * policy, and inventing it here would pre-empt it.
965
+ * reason and closes the pane the split had already created, because a
966
+ * partially established representation is worse than none: it is a pane the
967
+ * operator would read as a tracked worker, and before #992 it was left behind
968
+ * on every single launch. What this deliberately does NOT do is decide what a
969
+ * failure means for the launch failing closed versus running degraded is
970
+ * #841's policy, and inventing it here would pre-empt it.
777
971
  */
778
972
  export function openWorkerPane(identity: WorkerPaneIdentity, deps: WorkerPaneDeps = {}): WorkerPaneOutcome {
779
973
  const run = deps.run ?? realHerdrRun;
@@ -785,14 +979,19 @@ export function openWorkerPane(identity: WorkerPaneIdentity, deps: WorkerPaneDep
785
979
  if (!split.ok) {
786
980
  return { kind: "unavailable", reason: `herdr pane split failed: ${firstLine(split.stderr) || "no output"}` };
787
981
  }
788
- const paneId = firstLine(split.stdout);
789
- if (paneId === "") {
982
+ const paneId = parsePaneId(split.stdout);
983
+ if (paneId === undefined) {
790
984
  return { kind: "unavailable", reason: "herdr pane split reported no pane id" };
791
985
  }
986
+ // The split has created a pane. From here every exit must take it with it.
987
+ const abandon = (reason: string): WorkerPaneOutcome => {
988
+ run([...base, "close", paneId]);
989
+ return { kind: "unavailable", reason };
990
+ };
792
991
 
793
992
  const named = run([...base, "rename", paneId, label]);
794
993
  if (!named.ok) {
795
- return { kind: "unavailable", reason: `herdr pane rename failed: ${firstLine(named.stderr) || "no output"}` };
994
+ return abandon(`herdr pane rename failed: ${firstLine(named.stderr) || "no output"}`);
796
995
  }
797
996
 
798
997
  // Identity before display: the pane must be attributable to this exact run
@@ -813,15 +1012,12 @@ export function openWorkerPane(identity: WorkerPaneIdentity, deps: WorkerPaneDep
813
1012
  WORKER_PANE_SOURCE,
814
1013
  ]);
815
1014
  if (!identified.ok) {
816
- return {
817
- kind: "unavailable",
818
- reason: `herdr pane report-agent-session failed: ${firstLine(identified.stderr) || "no output"}`,
819
- };
1015
+ return abandon(`herdr pane report-agent-session failed: ${firstLine(identified.stderr) || "no output"}`);
820
1016
  }
821
1017
 
822
1018
  const started = run([...base, "run", paneId, ...deps.viewer?.(identity) ?? defaultViewer(identity)]);
823
1019
  if (!started.ok) {
824
- return { kind: "unavailable", reason: `herdr pane run failed: ${firstLine(started.stderr) || "no output"}` };
1020
+ return abandon(`herdr pane run failed: ${firstLine(started.stderr) || "no output"}`);
825
1021
  }
826
1022
 
827
1023
  // A worker that has just spawned is working by definition. The ongoing
@@ -829,7 +1025,7 @@ export function openWorkerPane(identity: WorkerPaneIdentity, deps: WorkerPaneDep
829
1025
  // events — never from the pane's output.
830
1026
  const reported = reportWorkerPaneState(paneId, label, "working", { run, session });
831
1027
  if (!reported.ok) {
832
- return { kind: "unavailable", reason: reported.reason };
1028
+ return abandon(reported.reason);
833
1029
  }
834
1030
  return { kind: "tracked", paneId, label, pid: identity.pid };
835
1031
  }
@@ -982,8 +1178,15 @@ export type WorkerPaneReconciliation =
982
1178
  | { kind: "intact"; runId: string; paneId: string }
983
1179
  /** Herdr lost the pane (a restart); a new one now represents the same child. */
984
1180
  | { kind: "reassociated"; runId: string; paneId: string; label: string }
985
- /** No representation, and the reason. The run keeps working regardless. */
986
- | { kind: "untracked"; runId: string; reason: string }
1181
+ /** No representation, and the reason. The run keeps working regardless.
1182
+ * `attempted` marks the ones that spent a re-establishment attempt (#998),
1183
+ * so the caller can bound them; the no-pid case costs nothing. */
1184
+ | { kind: "untracked"; runId: string; reason: string; attempted?: true }
1185
+ /** The attempt budget for this run is spent, so nothing was tried this pass
1186
+ * (#998). Before this existed, a run whose pane could not be created was
1187
+ * retried on every reconciliation pass forever — and each attempt leaked a
1188
+ * pane until #992, ending in `ghostty error -2` once enough had piled up. */
1189
+ | { kind: "attempts-exhausted"; runId: string; attempts: number }
987
1190
  /** A conductor pane whose run is not live: handed back to Herdr. */
988
1191
  | { kind: "stale-released"; paneId: string; runId: string }
989
1192
  | { kind: "stale-release-failed"; paneId: string; runId: string; reason: string };
@@ -1011,6 +1214,10 @@ export type WorkerPaneReconciliation =
1011
1214
  export function reconcileWorkerPanes(
1012
1215
  live: readonly LiveWorkerPane[],
1013
1216
  deps: WorkerPaneDeps = {},
1217
+ /** Attempts already spent per run id, owned by the caller so this stays pure
1218
+ * and a daemon restart legitimately gets a fresh budget (#998). */
1219
+ attempts: ReadonlyMap<string, number> = new Map(),
1220
+ maxAttempts = PANE_REATTEMPT_MAX,
1014
1221
  ): { ok: true; outcomes: WorkerPaneReconciliation[] } | { ok: false; reason: string } {
1015
1222
  const listed = listWorkerPanes(deps);
1016
1223
  if (!listed.ok) return { ok: false, reason: listed.reason };
@@ -1036,6 +1243,13 @@ export function reconcileWorkerPanes(
1036
1243
  });
1037
1244
  continue;
1038
1245
  }
1246
+ // The budget, checked before the attempt: an exhausted run is skipped
1247
+ // silently here and reported once by the caller (#998).
1248
+ const spent = attempts.get(worker.runId) ?? 0;
1249
+ if (spent >= maxAttempts) {
1250
+ outcomes.push({ kind: "attempts-exhausted", runId: worker.runId, attempts: spent });
1251
+ continue;
1252
+ }
1039
1253
  const opened = openWorkerPane(
1040
1254
  {
1041
1255
  project: worker.project,
@@ -1050,7 +1264,7 @@ export function reconcileWorkerPanes(
1050
1264
  outcomes.push(
1051
1265
  opened.kind === "tracked"
1052
1266
  ? { kind: "reassociated", runId: worker.runId, paneId: opened.paneId, label: opened.label }
1053
- : { kind: "untracked", runId: worker.runId, reason: opened.reason },
1267
+ : { kind: "untracked", runId: worker.runId, reason: opened.reason, attempted: true },
1054
1268
  );
1055
1269
  }
1056
1270
 
@@ -1354,16 +1568,22 @@ export function parseHerdrAgentList(rawOutput: string): HerdrAgent[] {
1354
1568
  const agent = row as { readonly [key: string]: unknown };
1355
1569
  const name = agent["name"];
1356
1570
  const paneId = agent["pane_id"];
1357
- if (typeof name !== "string" || typeof paneId !== "string") {
1571
+ if (typeof paneId !== "string") {
1572
+ throw new Error(
1573
+ "herdr agent list row is missing a string `pane_id` — cannot identify the pane",
1574
+ );
1575
+ }
1576
+ if (name !== undefined && name !== null && typeof name !== "string") {
1358
1577
  throw new Error(
1359
- "herdr agent list row is missing a string `name`/`pane_id`" +
1578
+ `herdr agent list row for pane ${paneId} has a non-string \`name\` (${typeof name}) ` +
1360
1579
  "cannot tell whether it is the conductor pane",
1361
1580
  );
1362
1581
  }
1582
+ const parsedName = typeof name === "string" ? name : undefined;
1363
1583
  const rawAgent = agent["agent"];
1364
1584
  if (rawAgent !== undefined && rawAgent !== null && typeof rawAgent !== "string") {
1365
1585
  throw new Error(
1366
- `herdr agent list row for ${name} has a non-string \`agent\` (${typeof rawAgent}) — ` +
1586
+ `herdr agent list row for ${parsedName ?? `pane ${paneId}`} has a non-string \`agent\` (${typeof rawAgent}) — ` +
1367
1587
  `cannot tell whether an agent is live`,
1368
1588
  );
1369
1589
  }
@@ -1377,7 +1597,7 @@ export function parseHerdrAgentList(rawOutput: string): HerdrAgent[] {
1377
1597
  }
1378
1598
  }
1379
1599
  out.push({
1380
- name,
1600
+ ...(parsedName === undefined ? {} : { name: parsedName }),
1381
1601
  paneId,
1382
1602
  ...(liveAgent === undefined ? {} : { agent: liveAgent }),
1383
1603
  ...(sessionPath === undefined ? {} : { sessionPath }),
@@ -1717,12 +1937,20 @@ export function codeGraphFromHealthz(body: string | undefined, project: string):
1717
1937
  }
1718
1938
 
1719
1939
 
1940
+ /** One live pause as `/healthz` reports it: the phase, plus who asked and
1941
+ * when where the daemon recorded provenance (#997). */
1942
+ export interface WorkerPauseView {
1943
+ phase: WorkerPausePhase;
1944
+ source?: string;
1945
+ pausedAtMs?: number;
1946
+ }
1947
+
1720
1948
  /** Live worker pause phases from a daemon `/healthz` body for one project. */
1721
1949
  export function workerPhasesFromHealthz(
1722
1950
  body: string | undefined,
1723
1951
  project: string,
1724
- ): ReadonlyMap<number, WorkerPausePhase> {
1725
- const phases = new Map<number, WorkerPausePhase>();
1952
+ ): ReadonlyMap<number, WorkerPauseView> {
1953
+ const phases = new Map<number, WorkerPauseView>();
1726
1954
  if (body === undefined) return phases;
1727
1955
  try {
1728
1956
  const payload = JSON.parse(body) as unknown;
@@ -1757,7 +1985,15 @@ export function workerPhasesFromHealthz(
1757
1985
  (issue as number) > 0 &&
1758
1986
  (phase === "pausing" || phase === "paused")
1759
1987
  ) {
1760
- phases.set(issue as number, phase);
1988
+ const source = Reflect.get(worker, "source");
1989
+ const pausedAtMs = Reflect.get(worker, "pausedAtMs");
1990
+ phases.set(issue as number, {
1991
+ phase,
1992
+ ...(typeof source === "string" && source !== "" ? { source } : {}),
1993
+ ...(Number.isSafeInteger(pausedAtMs) && (pausedAtMs as number) > 0
1994
+ ? { pausedAtMs: pausedAtMs as number }
1995
+ : {}),
1996
+ });
1761
1997
  }
1762
1998
  }
1763
1999
  } catch {
@@ -1840,7 +2076,7 @@ export type FleetStatusReport = StatusSnapshot & {
1840
2076
  brief: string | undefined;
1841
2077
  decisions: string | undefined;
1842
2078
  failureClasses: string | undefined;
1843
- workerPhases: { issue: number; phase: WorkerPausePhase }[];
2079
+ workerPhases: { issue: number; view: WorkerPauseView }[];
1844
2080
  intake: string | undefined;
1845
2081
  /** The project-scoped durable to-spec grooming lifecycle as status lines
1846
2082
  * (#809), or nothing when there is nothing to report. */
@@ -1892,7 +2128,7 @@ export async function collectFleetStatus(projectName?: string): Promise<FleetSta
1892
2128
  // throwing probe cannot leak its handle.
1893
2129
  const store = openStore(dbPath());
1894
2130
  const workerPhases = [...workerPhasesFromHealthz(healthBody, project.name)].map(
1895
- ([issue, phase]) => ({ issue, phase }),
2131
+ ([issue, view]) => ({ issue, view }),
1896
2132
  );
1897
2133
  // The newest host-wide stop/restart provenance (#378). Read here — not in
1898
2134
  // `statusSnapshot`, which is synchronous and belongs to the daemon module —
@@ -1959,7 +2195,7 @@ export function renderFleetStatusReport(report: FleetStatusReport): string {
1959
2195
  report.brief,
1960
2196
  report.decisions,
1961
2197
  report.failureClasses,
1962
- new Map(report.workerPhases.map(({ issue, phase }) => [issue, phase])),
2198
+ new Map(report.workerPhases.map(({ issue, view }) => [issue, view])),
1963
2199
  report.intake,
1964
2200
  report.lastStop,
1965
2201
  report.siblings,
@@ -2008,6 +2244,10 @@ function briefStatusLine(project: ProjectConfig): string | undefined {
2008
2244
  * empty because empty is the healthy answer. `classified, no action` is the
2009
2245
  * signal — review returns and holds — named as such, never as a backlog.
2010
2246
  */
2247
+ /** How many surviving `unknown` rows a status render explains (#986). A board
2248
+ * is read at a glance: enough to show a pattern, never a backlog dump. */
2249
+ const UNKNOWN_EVIDENCE_SHOWN = 5;
2250
+
2011
2251
  function failureClassBlock(projectName: string): string | undefined {
2012
2252
  const path = dbPath();
2013
2253
  if (!existsSync(path)) return undefined;
@@ -2030,6 +2270,20 @@ function failureClassBlock(projectName: string): string | undefined {
2030
2270
  ...recorded.map((c) => ` ${c.cls.padEnd(24)}${c.n}`),
2031
2271
  );
2032
2272
  }
2273
+ // A surviving `unknown` is an unnamed cause, and the only way anyone closes
2274
+ // that gap is by seeing what the settle sweep actually knew about the row
2275
+ // (#986). Bounded to the newest few: this is a status board, not a log, and
2276
+ // a fleet with a long unknown history must not turn one render into a scan.
2277
+ const unknown = store.unknownRuns(projectName, UNKNOWN_EVIDENCE_SHOWN);
2278
+ const withEvidence = unknown.filter((run) => run.terminalEvidence !== undefined);
2279
+ if (withEvidence.length > 0) {
2280
+ lines.push(
2281
+ "unknown, with terminal evidence",
2282
+ ...withEvidence.map(
2283
+ (run) => ` #${run.issue} ${(run.terminalEvidence ?? "").slice(0, 90)}`,
2284
+ ),
2285
+ );
2286
+ }
2033
2287
  return lines.join("\n");
2034
2288
  } catch {
2035
2289
  return undefined;