omp-conductor 0.19.2 → 0.19.3

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.
@@ -170,6 +170,16 @@ const START_FAILURE_SIGNATURES = [
170
170
  // symptom this replaced (the child dying on the peer import) already read as
171
171
  // a start failure, so charging one here would be a regression dressed as a fix.
172
172
  "worker identity unavailable",
173
+ // The 2026-08-2x host faults (#986), each recorded verbatim in `lastError`
174
+ // by the launcher or the worker identity gate — all before the session took
175
+ // a turn:
176
+ // - the child died before it could connect to the dispatcher;
177
+ // - node could not resolve a package out of the worker harness tree;
178
+ // - the worker's settings file was unreadable (the EACCES outage).
179
+ "exited 1 before connecting",
180
+ "cannot find package",
181
+ "enoent while resolving package",
182
+ "failed to read settings config",
173
183
  ] as const;
174
184
 
175
185
  /**
@@ -395,6 +405,128 @@ export function adminRestartAttribution(lastError: string | undefined): string |
395
405
  return firstLine.trim();
396
406
  }
397
407
 
408
+ /** The launcher's own statement that the session child died mid-run, before
409
+ * the session ended. Written by `omp.ts` when the child exits while the
410
+ * dispatcher still owns it; the exit code is the child's own, so a signal
411
+ * death reads >=128 (143 = SIGTERM, 137 = SIGKILL) — an external kill,
412
+ * never anything the worker's work decided (#986). */
413
+ const CHILD_EXIT_MARKER = /session child exited (\d+) before the session ended/;
414
+
415
+ /**
416
+ * Evidence that this run's session child was terminated by a signal, or
417
+ * `undefined` when it was not.
418
+ *
419
+ * Read from the surfaces a run persists — `lastError` first, then `report`,
420
+ * because the prompt-throw path records the launcher's message as the report
421
+ * and leaves `lastError` empty. Deliberately narrowed to signal-range codes:
422
+ * a clean nonzero exit is some process's verdict nobody has named yet, and
423
+ * naming it `admin-kill` would waive an attempt that may have been spent.
424
+ */
425
+ export function childSignalDeath(
426
+ run: Pick<RunRecord, "lastError" | "report">,
427
+ ): string | undefined {
428
+ const text = run.lastError ?? run.report;
429
+ if (text === undefined) return undefined;
430
+ const match = CHILD_EXIT_MARKER.exec(text);
431
+ if (match === null) return undefined;
432
+ const code = Number(match[1]);
433
+ if (!Number.isFinite(code) || code < 128) return undefined;
434
+ return match[0];
435
+ }
436
+
437
+ /** The reason the settle sweep writes beside a reviewer-closed PR
438
+ * (`settlementFor`), matched as recorded evidence so rows written before
439
+ * that sweep stamped a class read the same way (#986). */
440
+ const REVIEWER_CLOSED_MARKER = "closed without merging";
441
+
442
+ /**
443
+ * Evidence that a reviewer closed this run's pushed work without merging it,
444
+ * or `undefined` when nothing says so. Two independent signals, either
445
+ * sufficient: the tracker fact (`facts.pr === "closed"`), or the settle
446
+ * sweep's own recorded reason in `lastError`. A review decision is not a
447
+ * worker failure, which is exactly what `returned-for-revision` means; the
448
+ * only new reading here is applying it to rows whose PR closed while the row
449
+ * itself had already gone terminal-failed.
450
+ */
451
+ export function reviewerClosed(
452
+ run: Pick<RunRecord, "lastError" | "prUrl">,
453
+ facts: ClassifyFacts,
454
+ ): string | undefined {
455
+ if (run.lastError !== undefined && run.lastError.includes(REVIEWER_CLOSED_MARKER)) {
456
+ return run.lastError.split("\n")[0]?.trim();
457
+ }
458
+ if (facts.pr === "closed" && run.prUrl !== undefined) {
459
+ return `${run.prUrl} closed without merging`;
460
+ }
461
+ return undefined;
462
+ }
463
+
464
+ /** The harness's own name for giving up on a provider that kept answering with
465
+ * empty assistant turns: it retries, then ends the session on the retry cap
466
+ * and says so in its maintenance routing (`route: "empty-stop-retry-cap"`,
467
+ * beside `Assistant returned empty stop after retry cap; try switching
468
+ * models`). A closed marker written by the harness, not vendor prose. */
469
+ const EMPTY_STOP_MARKER = "empty-stop-retry-cap";
470
+
471
+ /**
472
+ * Evidence that the provider answered with empty turns until the harness's own
473
+ * retry cap ended the session, or `undefined` when nothing recorded says so
474
+ * (#986).
475
+ *
476
+ * Read from the terminal evidence the settlement sweep recorded, because this
477
+ * marker is written by the harness's session log rather than by the transcript
478
+ * or the row — the run itself has no error, no verdict and no idea it died,
479
+ * which is precisely why two of these read as `unknown` on 2026-08-23 at
480
+ * 100/180 and 54/180 turns while a third run on the same model finished green
481
+ * in the same window.
482
+ *
483
+ * Provider flake, not worker logic: the recovery continues from whatever the
484
+ * attempt pushed rather than charging an implementation attempt for it.
485
+ */
486
+ export function modelEmptyStop(
487
+ run: Pick<RunRecord, "terminalEvidence">,
488
+ ): string | undefined {
489
+ if (run.terminalEvidence === undefined) return undefined;
490
+ if (!run.terminalEvidence.includes(EMPTY_STOP_MARKER)) return undefined;
491
+ return run.terminalEvidence;
492
+ }
493
+
494
+ /**
495
+ * Evidence that this run ended without ever delivering a settlement verdict,
496
+ * or `undefined` when something on the row says otherwise (#986).
497
+ *
498
+ * The shape: state `failed`, turns taken, no error anywhere, and a final
499
+ * report whose words are mid-delivery narration — "Now I'll execute …", "Type
500
+ * check passes. Now the test suite:" — with no `state:`/`status:` line the
501
+ * result parser could read. The session reached its worker and died between
502
+ * turns without handing over; measured over two weeks of the reference fleet,
503
+ * this one shape was half of all `unknown`.
504
+ *
505
+ * Deliberately narrow at every edge: turn zero belongs to
506
+ * {@link neverStarted}, any error text keeps the row `unknown` (an
507
+ * unrecognised error says something happened), and an absent report leaves
508
+ * genuinely nothing to name. The class charges its attempt exactly as
509
+ * `unknown` did — only the name becomes specific.
510
+ */
511
+ export function noVerdictExit(run: RunRecord): string | undefined {
512
+ if (run.state !== "failed") return undefined;
513
+ if (run.turns <= 0) return undefined;
514
+ // Blank counts as absent: a row whose `lastError` is an empty string carries
515
+ // no error information, and reading it as "something happened" is what kept
516
+ // four real rows unnamed. Any non-blank text, recognised or not, still wins —
517
+ // an unrecognised error says something happened.
518
+ if (run.lastError !== undefined && run.lastError.trim() !== "") return undefined;
519
+ if (run.report === undefined || run.report.trim() === "") return undefined;
520
+ // Mirror the two verdict spellings the result path reads (`worker.ts`
521
+ // STATE_LINE_PATTERN for prose settlements, `renderSettlement`'s leading
522
+ // `status:` line for structured yields). Either one present means a verdict
523
+ // was delivered and this function must stay silent.
524
+ if (/^state:\s*\S+\s*$/im.test(run.report)) return undefined;
525
+ if (/^status:\s*\S+\s*$/im.test(run.report)) return undefined;
526
+ const lastWords = run.report.split("\n")[0]?.trim() ?? "";
527
+ return `the session ended without delivering a settlement verdict; last words: "${lastWords.slice(0, 80)}"`;
528
+ }
529
+
398
530
  export function classifyRun(
399
531
  run: RunRecord,
400
532
  facts: ClassifyFacts,
@@ -424,6 +556,23 @@ export function classifyRun(
424
556
  };
425
557
  }
426
558
 
559
+ // The launcher's own statement that the session child died on a signal
560
+ // mid-run: an external kill — a drain, a restart, the host — not anything
561
+ // the worker's work decided (#986). Ahead of the provider branches on
562
+ // purpose: a transcript can carry an older, long-recovered stream fault,
563
+ // and the launcher's terminal statement outranks whatever the session said
564
+ // earlier. Requeued free like every other administrative kill.
565
+ if (run.state === "failed") {
566
+ const marker = childSignalDeath(run);
567
+ if (marker !== undefined) {
568
+ return {
569
+ cls: "admin-kill",
570
+ recovery: "requeue",
571
+ evidence: `killed externally (${marker}) — the session child was terminated, not finished`,
572
+ };
573
+ }
574
+ }
575
+
427
576
  // A billing state, not an implementation failure. Its own class because it is
428
577
  // the one outage an operator fixes with a card rather than a diagnosis, and
429
578
  // because burying a self-describing provider error in `unknown` erodes what
@@ -507,6 +656,19 @@ export function classifyRun(
507
656
  }
508
657
  }
509
658
 
659
+ // A reviewer closed this run's pushed work without merging it: a review
660
+ // decision, not a worker failure (#986). Two rows reach here — the settle
661
+ // sweep's own recorded reason in `lastError` on a row that predates the
662
+ // class, and a live `closed` tracker fact on a row whose worker died after
663
+ // pushing but before claiming. Recovery `none` mirrors the settle sweep
664
+ // exactly: the queue label is the remedy, and no action is performed here.
665
+ if (run.state === "failed") {
666
+ const closed = reviewerClosed(run, facts);
667
+ if (closed !== undefined) {
668
+ return { cls: "returned-for-revision", recovery: "none", evidence: closed };
669
+ }
670
+ }
671
+
510
672
  if (run.state === "blocked") {
511
673
  return {
512
674
  cls: "question",
@@ -639,6 +801,36 @@ export function classifyRun(
639
801
  }
640
802
  }
641
803
 
804
+ // The provider answered with empty turns until the harness gave up (#986).
805
+ // Below every branch that reads a real error on purpose: a row carrying its
806
+ // own provider fault is that fault, and this marker only decides rows that
807
+ // would otherwise be `unknown`. Continues rather than requeues, because the
808
+ // attempt's pushed work is the honest place to resume from, and a provider
809
+ // flake must not charge an implementation attempt (#1001). `killed` rows
810
+ // never reach here — a cap kill is the operative fact about a run that hit
811
+ // its ceiling, whatever the provider did earlier in the session.
812
+ if (run.state === "failed") {
813
+ const emptyStop = modelEmptyStop(run);
814
+ if (emptyStop !== undefined) {
815
+ return {
816
+ cls: "model-empty-stop",
817
+ recovery: "continue",
818
+ evidence: `the provider returned empty turns until the harness's retry cap ended the session (${emptyStop}) — turns ${run.turns}/${run.maxTurns}`,
819
+ };
820
+ }
821
+ }
822
+
823
+ // The session reached its worker and died between turns without handing over
824
+ // a verdict (#986): the largest single shape inside `unknown`. Named rather
825
+ // than requeued blind — an operator reading `status` learns the run stopped
826
+ // mid-delivery, which is a different question from "nothing is known".
827
+ if (run.state === "failed") {
828
+ const narration = noVerdictExit(run);
829
+ if (narration !== undefined) {
830
+ return { cls: "no-verdict", recovery: "escalate", evidence: narration };
831
+ }
832
+ }
833
+
642
834
  // Deliberately escalate rather than retry. An unrecognised shape is a gap in
643
835
  // this table, and a silent requeue would spend a budget on a cause nobody has
644
836
  // named — the exact behaviour #132 exists to end.
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. */
@@ -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
 
@@ -1717,12 +1931,20 @@ export function codeGraphFromHealthz(body: string | undefined, project: string):
1717
1931
  }
1718
1932
 
1719
1933
 
1934
+ /** One live pause as `/healthz` reports it: the phase, plus who asked and
1935
+ * when where the daemon recorded provenance (#997). */
1936
+ export interface WorkerPauseView {
1937
+ phase: WorkerPausePhase;
1938
+ source?: string;
1939
+ pausedAtMs?: number;
1940
+ }
1941
+
1720
1942
  /** Live worker pause phases from a daemon `/healthz` body for one project. */
1721
1943
  export function workerPhasesFromHealthz(
1722
1944
  body: string | undefined,
1723
1945
  project: string,
1724
- ): ReadonlyMap<number, WorkerPausePhase> {
1725
- const phases = new Map<number, WorkerPausePhase>();
1946
+ ): ReadonlyMap<number, WorkerPauseView> {
1947
+ const phases = new Map<number, WorkerPauseView>();
1726
1948
  if (body === undefined) return phases;
1727
1949
  try {
1728
1950
  const payload = JSON.parse(body) as unknown;
@@ -1757,7 +1979,15 @@ export function workerPhasesFromHealthz(
1757
1979
  (issue as number) > 0 &&
1758
1980
  (phase === "pausing" || phase === "paused")
1759
1981
  ) {
1760
- phases.set(issue as number, phase);
1982
+ const source = Reflect.get(worker, "source");
1983
+ const pausedAtMs = Reflect.get(worker, "pausedAtMs");
1984
+ phases.set(issue as number, {
1985
+ phase,
1986
+ ...(typeof source === "string" && source !== "" ? { source } : {}),
1987
+ ...(Number.isSafeInteger(pausedAtMs) && (pausedAtMs as number) > 0
1988
+ ? { pausedAtMs: pausedAtMs as number }
1989
+ : {}),
1990
+ });
1761
1991
  }
1762
1992
  }
1763
1993
  } catch {
@@ -1840,7 +2070,7 @@ export type FleetStatusReport = StatusSnapshot & {
1840
2070
  brief: string | undefined;
1841
2071
  decisions: string | undefined;
1842
2072
  failureClasses: string | undefined;
1843
- workerPhases: { issue: number; phase: WorkerPausePhase }[];
2073
+ workerPhases: { issue: number; view: WorkerPauseView }[];
1844
2074
  intake: string | undefined;
1845
2075
  /** The project-scoped durable to-spec grooming lifecycle as status lines
1846
2076
  * (#809), or nothing when there is nothing to report. */
@@ -1892,7 +2122,7 @@ export async function collectFleetStatus(projectName?: string): Promise<FleetSta
1892
2122
  // throwing probe cannot leak its handle.
1893
2123
  const store = openStore(dbPath());
1894
2124
  const workerPhases = [...workerPhasesFromHealthz(healthBody, project.name)].map(
1895
- ([issue, phase]) => ({ issue, phase }),
2125
+ ([issue, view]) => ({ issue, view }),
1896
2126
  );
1897
2127
  // The newest host-wide stop/restart provenance (#378). Read here — not in
1898
2128
  // `statusSnapshot`, which is synchronous and belongs to the daemon module —
@@ -1959,7 +2189,7 @@ export function renderFleetStatusReport(report: FleetStatusReport): string {
1959
2189
  report.brief,
1960
2190
  report.decisions,
1961
2191
  report.failureClasses,
1962
- new Map(report.workerPhases.map(({ issue, phase }) => [issue, phase])),
2192
+ new Map(report.workerPhases.map(({ issue, view }) => [issue, view])),
1963
2193
  report.intake,
1964
2194
  report.lastStop,
1965
2195
  report.siblings,
@@ -2008,6 +2238,10 @@ function briefStatusLine(project: ProjectConfig): string | undefined {
2008
2238
  * empty because empty is the healthy answer. `classified, no action` is the
2009
2239
  * signal — review returns and holds — named as such, never as a backlog.
2010
2240
  */
2241
+ /** How many surviving `unknown` rows a status render explains (#986). A board
2242
+ * is read at a glance: enough to show a pattern, never a backlog dump. */
2243
+ const UNKNOWN_EVIDENCE_SHOWN = 5;
2244
+
2011
2245
  function failureClassBlock(projectName: string): string | undefined {
2012
2246
  const path = dbPath();
2013
2247
  if (!existsSync(path)) return undefined;
@@ -2030,6 +2264,20 @@ function failureClassBlock(projectName: string): string | undefined {
2030
2264
  ...recorded.map((c) => ` ${c.cls.padEnd(24)}${c.n}`),
2031
2265
  );
2032
2266
  }
2267
+ // A surviving `unknown` is an unnamed cause, and the only way anyone closes
2268
+ // that gap is by seeing what the settle sweep actually knew about the row
2269
+ // (#986). Bounded to the newest few: this is a status board, not a log, and
2270
+ // a fleet with a long unknown history must not turn one render into a scan.
2271
+ const unknown = store.unknownRuns(projectName, UNKNOWN_EVIDENCE_SHOWN);
2272
+ const withEvidence = unknown.filter((run) => run.terminalEvidence !== undefined);
2273
+ if (withEvidence.length > 0) {
2274
+ lines.push(
2275
+ "unknown, with terminal evidence",
2276
+ ...withEvidence.map(
2277
+ (run) => ` #${run.issue} ${(run.terminalEvidence ?? "").slice(0, 90)}`,
2278
+ ),
2279
+ );
2280
+ }
2033
2281
  return lines.join("\n");
2034
2282
  } catch {
2035
2283
  return undefined;