omp-conductor 0.3.23 → 0.3.25

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/daemon.ts CHANGED
@@ -387,16 +387,30 @@ async function safeEscalate(d: Pick<Deps, "escalate">, e: Escalation): Promise<b
387
387
  * `checkIntegrity` is — this wording is the whole thing a human acts on, so it
388
388
  * is worth a test holding it, and the sha in it is the only pointer to work
389
389
  * that no longer has any other copy.
390
+ *
391
+ * `retained` is not cosmetic. These lines used to promise a tree "kept for
392
+ * inspection" unconditionally, which was true only because salvage ran only on
393
+ * the paths that keep one. A blocked run's tree is removed the moment its work
394
+ * is safely on the branch, and sending an operator to a path this process just
395
+ * deleted is the same class of mistake as #118 itself.
390
396
  */
391
- export function salvageLines(outcome: SalvageOutcome, worktree: string): string[] {
392
- const kept = `Worktree kept for inspection: ${worktree}`;
397
+ export function salvageLines(
398
+ outcome: SalvageOutcome,
399
+ worktree: string,
400
+ retained: boolean,
401
+ ): string[] {
402
+ const fate = retained
403
+ ? `Worktree kept for inspection: ${worktree}`
404
+ : `Worktree removed: ${worktree}`;
393
405
 
394
- if (outcome.kind === "nothing") return [`${kept} — nothing uncommitted to salvage`];
406
+ if (outcome.kind === "nothing") return [`${fate} — nothing uncommitted to salvage`];
395
407
 
396
408
  if (outcome.kind === "failed") {
397
409
  return [
398
410
  `WIP SALVAGE FAILED: ${outcome.error}`,
399
- `Uncommitted work in ${worktree} is the only copy of it, and the next attempt removes that tree.`,
411
+ `Uncommitted work in ${worktree} is the only copy of it, so the tree was kept.`,
412
+ "This issue is held out of dispatch until the tree is recovered by hand and",
413
+ "`omp-conductor unblock <n> --force` records that you accepted it.",
400
414
  ];
401
415
  }
402
416
 
@@ -415,35 +429,80 @@ export function salvageLines(outcome: SalvageOutcome, worktree: string): string[
415
429
  : `${count}; new: ${outcome.newPaths.slice(0, 12).join(", ")}${
416
430
  outcome.newPaths.length > 12 ? `, … +${outcome.newPaths.length - 12} more` : ""
417
431
  }`;
418
- return [where, manifest, kept];
432
+ return [where, manifest, fate];
433
+ }
434
+
435
+ /** Everything a settled run has to record and say about its worktree. */
436
+ export interface WorktreeSettlement {
437
+ outcome: SalvageOutcome;
438
+ /** Whether the tree still exists now the run is over. */
439
+ retained: boolean;
440
+ /** Escalation lines naming where the work went. */
441
+ lines: string[];
442
+ /** Row fields recording the durable ref, or the failure that blocks a re-claim. */
443
+ patch: Pick<RunRecord, "salvageSha" | "salvageError">;
419
444
  }
420
445
 
421
446
  /**
422
- * Commits and pushes whatever a dead run left uncommitted, logs the outcome and
423
- * returns the escalation lines that say where that work now lives.
447
+ * Decides what becomes of a finished run's worktree: save the work, then keep
448
+ * or remove the tree, then say which.
424
449
  *
425
- * Only ever called on a non-graceful end a cap kill, a crashed session, a
426
- * dispatch error. A `blocked` run stopped on purpose, with turns still in hand
427
- * and a brief that tells it to report rather than push, so nothing is committed
428
- * behind its back. The rest never got the chance: the kill is external and
429
- * lands mid-edit, in the tree the next attempt removes `--force`.
450
+ * One function because the two halves are one decision and splitting them is
451
+ * how #118 happened the removal at the end of dispatch had no idea whether
452
+ * anything had been saved, and the salvage at the top of the failure branch had
453
+ * no idea the blocked branch fell through to a `--force` removal.
454
+ *
455
+ * A salvage that *fails* retains the tree whatever the caller asked for. There
456
+ * was real work, git refused to commit it, and the tree is now the only copy in
457
+ * existence: deleting it on schedule would be the data loss this whole path
458
+ * exists to prevent. The issue is held out of dispatch until an operator says
459
+ * otherwise, because the next attempt's `worktree remove --force` would finish
460
+ * the job (see `admitCandidates`).
461
+ *
462
+ * Exported so a test can drive the real decision against a real git tree.
430
463
  */
431
- async function salvage(
432
- issue: number,
433
- attempt: number,
434
- reason: string,
435
- worktree: string,
436
- ): Promise<string[]> {
437
- const lines = salvageLines(await salvageWip(worktree, issue, attempt, reason), worktree);
464
+ export async function settleWorktree(
465
+ args: {
466
+ issue: number;
467
+ attempt: number;
468
+ /** Clause for the commit subject: "killed by the turns cap", "blocked …". */
469
+ ending: string;
470
+ worktree: string;
471
+ } & (
472
+ | /** Terminal-failure and orphan trees are evidence, and are kept even when clean. */
473
+ { tree: "keep" }
474
+ | { tree: "remove"; mirrorPath: string }
475
+ ),
476
+ ): Promise<WorktreeSettlement> {
477
+ const { issue, attempt, ending, worktree } = args;
478
+ const outcome = await salvageWip(worktree, issue, attempt, ending);
479
+ const retained = args.tree === "keep" || outcome.kind === "failed";
480
+ if (!retained && args.tree === "remove") await removeWorktree(args.mirrorPath, worktree);
481
+
482
+ const lines = salvageLines(outcome, worktree, retained);
438
483
  log(`#${issue} salvage: ${lines.join(" ")}`);
439
- return lines;
484
+ return {
485
+ outcome,
486
+ retained,
487
+ lines,
488
+ patch:
489
+ outcome.kind === "salvaged"
490
+ ? { salvageSha: outcome.sha }
491
+ : outcome.kind === "failed"
492
+ ? { salvageError: outcome.error }
493
+ : {},
494
+ };
440
495
  }
441
496
 
442
- /** How a run's end is named — in the salvage commit, and to whoever reads it. */
497
+ /**
498
+ * How a run's end is named — in the salvage commit, and to whoever reads it.
499
+ * The whole clause, not a bare reason: a graceful block was not killed by
500
+ * anything, and the commit subject is read during recovery.
501
+ */
443
502
  function endedBy(killedBy: KilledBy | undefined): string {
444
- if (killedBy === "turns") return "the turns cap";
445
- if (killedBy === "wallclock") return "the wall-clock cap";
446
- return "a failed run";
503
+ if (killedBy === "turns") return "killed by the turns cap";
504
+ if (killedBy === "wallclock") return "killed by the wall-clock cap";
505
+ return "killed by a failed run";
447
506
  }
448
507
 
449
508
  /**
@@ -460,7 +519,7 @@ export async function buildBrief(
460
519
  r: Routed,
461
520
  branch: string,
462
521
  worktree: string,
463
- opts: { continuation?: boolean; defaultBranch?: string } = {},
522
+ opts: { continuation?: boolean; defaultBranch?: string; salvagedSha?: string } = {},
464
523
  ): Promise<string> {
465
524
  // Read per dispatch rather than caching: editing the brief then takes effect
466
525
  // on the next issue instead of needing a daemon restart.
@@ -474,6 +533,14 @@ export async function buildBrief(
474
533
  "",
475
534
  `You are **resuming** issue #${r.issue.number}. Branch \`${branch}\` already exists`,
476
535
  "and was reattached with prior commits (and possibly a salvaged WIP tip).",
536
+ ...(opts.salvagedSha === undefined
537
+ ? []
538
+ : [
539
+ "",
540
+ `The previous attempt's uncommitted work was preserved for you as commit`,
541
+ `\`${opts.salvagedSha}\` on this branch. It is the tip you are continuing from,`,
542
+ "and it is the only copy of that work — do not reset past it or force-push over it.",
543
+ ]),
477
544
  "Before writing anything:",
478
545
  "",
479
546
  "```bash",
@@ -599,9 +666,10 @@ async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<void> {
599
666
 
600
667
  let claimed = false;
601
668
  let run: RunRecord | undefined;
602
- // Hoisted out of the try so the catch path can still name the tree: a crash
603
- // mid-dispatch is one of the non-graceful ends whose uncommitted work has to
604
- // be salvaged too, and it is the path least likely to have committed first.
669
+ // Hoisted out of the try so the catch path can still name and save the tree:
670
+ // a crash mid-dispatch is one of the ends whose uncommitted work has to be
671
+ // salvaged too, and it is the path least likely to have committed first.
672
+ const mirrorPath = mirrorPathFor(r.repo, project.mirrorRoot);
605
673
  let worktreePath: string | undefined;
606
674
  let turnLimit: TurnLimitController | undefined;
607
675
 
@@ -612,6 +680,10 @@ async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<void> {
612
680
  // issue out, and a human decides what to do with the orphan.
613
681
  await tracker.addLabel(issue, inProgress);
614
682
  claimed = true;
683
+ // Read before this attempt's own row exists, so `latestRun` still means the
684
+ // attempt whose work this one inherits.
685
+ const priorSalvage = store.latestRun(project.name, issue)?.salvageSha;
686
+
615
687
 
616
688
  run = store.createRun({
617
689
  project: project.name,
@@ -635,7 +707,6 @@ async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<void> {
635
707
  // tolerates a mirror or tree that is not there yet, so this is safe on a
636
708
  // first attempt. addWorktree does its own ensureMirror; calling it here too
637
709
  // would cost a second network fetch per attempt.
638
- const mirrorPath = mirrorPathFor(r.repo, project.mirrorRoot);
639
710
  await removeWorktree(mirrorPath, worktreePathFor(project.workspaceRoot, issue));
640
711
  const provisioned = await addWorktree(
641
712
  r.repo,
@@ -664,6 +735,9 @@ async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<void> {
664
735
  brief: await buildBrief(project, r, branch, worktreePath, {
665
736
  continuation: provisioned.reattached,
666
737
  defaultBranch: r.repo.defaultBranch,
738
+ ...(provisioned.reattached && priorSalvage !== undefined
739
+ ? { salvagedSha: priorSalvage }
740
+ : {}),
667
741
  }),
668
742
  cwd: worktreePath,
669
743
  caps,
@@ -706,6 +780,27 @@ async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<void> {
706
780
  const finalReport =
707
781
  verified.reason === undefined ? result.report : `${verified.reason}\n\n${result.report}`;
708
782
 
783
+ // What becomes of the tree, decided once, before any label or page. A
784
+ // `pushed-*` run is the only end that does not salvage: its deliverable is
785
+ // already on a remote branch, whatever is left loose in the tree is by the
786
+ // worker's own account not part of it, and appending a WIP commit would
787
+ // turn the green PR this daemon just verified red. Every other end is
788
+ // continuable, so its tree is treated as work.
789
+ const settlement =
790
+ state === "pushed-green" || state === "pushed-pending" || state === "merged"
791
+ ? undefined
792
+ : await settleWorktree({
793
+ issue,
794
+ attempt,
795
+ ending:
796
+ state === "blocked" ? "blocked for an operator decision" : endedBy(result.killedBy),
797
+ worktree: worktreePath,
798
+ ...(state === "failed" || state === "killed"
799
+ ? ({ tree: "keep" } as const)
800
+ : ({ tree: "remove", mirrorPath } as const)),
801
+ });
802
+ if (settlement === undefined) await removeWorktree(mirrorPath, worktreePath);
803
+
709
804
  store.updateRun(runId, {
710
805
  state,
711
806
  endedAt: Date.now(),
@@ -715,8 +810,11 @@ async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<void> {
715
810
  headSha: result.headSha,
716
811
  sessionFile: result.sessionFile,
717
812
  ...(verified.reason === undefined ? {} : { lastError: verified.reason }),
813
+ ...settlement?.patch,
718
814
  });
719
815
 
816
+ const salvaged = settlement?.lines ?? [];
817
+
720
818
  if (state === "blocked") {
721
819
  await swapLabel(tracker, issue, inProgress, project.stateLabels.blocked);
722
820
  await safeEscalate(d, {
@@ -725,7 +823,7 @@ async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<void> {
725
823
  issue,
726
824
  runId,
727
825
  summary: `#${issue} is blocked on attempt ${attempt} and needs a decision`,
728
- detail: [`${r.issue.title}`, r.issue.url, "", result.report].join("\n"),
826
+ detail: [`${r.issue.title}`, r.issue.url, "", ...salvaged, "", result.report].join("\n"),
729
827
  });
730
828
  } else if (state === "failed" || state === "killed") {
731
829
  // A turns cap consumes the independent continuation budget, not an
@@ -735,7 +833,6 @@ async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<void> {
735
833
  const continueTurns =
736
834
  result.killedBy === "turns" &&
737
835
  hasContinuationBudget(continuation, caps.maxContinuationsPerIssue);
738
- const salvaged = await salvage(issue, attempt, endedBy(result.killedBy), worktreePath);
739
836
 
740
837
  if (continueTurns) {
741
838
  await tracker.removeLabel(issue, inProgress);
@@ -790,13 +887,6 @@ async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<void> {
790
887
  // checks or merge settle, preventing another worker from duplicating it.
791
888
  log(`#${issue} ${state}${result.prUrl ? ` ${result.prUrl}` : ""}`);
792
889
  }
793
-
794
- // A failed or killed tree is evidence — keep it. Anything else is just
795
- // disk, and the mirror means re-provisioning is cheap. (The kept tree is
796
- // wiped by the next attempt, not left to accumulate forever.)
797
- if (state !== "failed" && state !== "killed") {
798
- await removeWorktree(mirrorPath, worktreePath);
799
- }
800
890
  } catch (err) {
801
891
  // Dispatch setup can fail after the controller opens but before runWorker's
802
892
  // inner settlement guard exists. Latch it before any terminal write or await.
@@ -804,8 +894,25 @@ async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<void> {
804
894
  turnLimit = undefined;
805
895
  const detail = errText(err);
806
896
  log(`#${issue} errored: ${detail}`);
897
+ // A crash lands anywhere, including mid-edit in a tree holding the only
898
+ // copy of real work. Nothing else on this path so much as looks at it.
899
+ const settlement =
900
+ worktreePath === undefined
901
+ ? undefined
902
+ : await settleWorktree({
903
+ issue,
904
+ attempt,
905
+ ending: "killed by a dispatch error",
906
+ worktree: worktreePath,
907
+ tree: "keep",
908
+ });
807
909
  if (run) {
808
- store.updateRun(run.id, { state: "failed", endedAt: Date.now(), lastError: detail });
910
+ store.updateRun(run.id, {
911
+ state: "failed",
912
+ endedAt: Date.now(),
913
+ lastError: detail,
914
+ ...settlement?.patch,
915
+ });
809
916
  }
810
917
  if (claimed) {
811
918
  // Leaving the issue stuck as in-progress would hide it from both the
@@ -816,13 +923,8 @@ async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<void> {
816
923
  log(`#${issue} could not be relabelled: ${errText(relabelErr)}`);
817
924
  }
818
925
  }
819
- // A crash lands anywhere, including mid-edit in a tree holding the only
820
- // copy of real work. Nothing else on this path so much as looks at it.
821
- const salvaged =
822
- worktreePath === undefined
823
- ? []
824
- : await salvage(issue, attempt, "a dispatch error", worktreePath);
825
926
 
927
+ const salvaged = settlement?.lines ?? [];
826
928
  await safeEscalate(d, {
827
929
  tier: 1,
828
930
  project: project.name,
@@ -893,26 +995,33 @@ export function settlementFor(pr: PrState | undefined, prUrl: string): Settlemen
893
995
  * hand-editing one, and `unblock` refused to clear that particular label — so
894
996
  * both issues were permanently unclaimable with no supported way back (#18).
895
997
  *
896
- * Never throws, and that is the point of it being a function rather than a
897
- * bare `removeLabel`. Callers are sweeps: the store transition this accompanies
898
- * has already been written, so a tracker that fails here must cost one label,
899
- * not the remaining rows. There is no retry the row is terminal, so no later
900
- * tick revisits it which makes the log line the entire record of the miss and
901
- * why it names both the label and the reason. The operator's recovery for that
902
- * rare case is `omp-conductor unblock`, which clears the label itself once the
903
- * newest run is terminal.
998
+ * Never throws, and reports whether the label is provably gone, because the
999
+ * caller has to decide what to write to the store on the strength of it. A
1000
+ * sweep must not lose the rest of its rows to one unreachable tracker, and it
1001
+ * must not terminalise a row whose label it failed to drop: the settlement
1002
+ * sweep only ever revisits `pushed-*` rows, so a row written terminal is a row
1003
+ * nothing asks about again, and swallowing the failure under it would recreate
1004
+ * the exact permanent-`agent:in-progress` state of #18 in the one case that
1005
+ * still reaches it. Answering false instead leaves the row where the next tick
1006
+ * will find it.
1007
+ *
1008
+ * Removing a label the issue does not carry is success, not failure: the GitHub
1009
+ * adapter treats an absent label as a no-op, so false means the tracker could
1010
+ * not be reached or refused — a condition that passes.
904
1011
  */
905
1012
  export async function releaseInProgress(
906
1013
  d: Pick<Deps, "project" | "tracker">,
907
1014
  issue: number,
908
1015
  why: string,
909
- ): Promise<void> {
1016
+ ): Promise<boolean> {
910
1017
  const label = d.project.stateLabels.inProgress;
911
1018
  try {
912
1019
  await d.tracker.removeLabel(issue, label);
913
1020
  log(`#${issue} released ${label}: ${why}`);
1021
+ return true;
914
1022
  } catch (err) {
915
- log(`#${issue} could not release ${label} (${errText(err)}) — ${why}; clear it with \`unblock\``);
1023
+ log(`#${issue} could not release ${label} (${errText(err)}) — ${why}; retrying next tick`);
1024
+ return false;
916
1025
  }
917
1026
  }
918
1027
 
@@ -946,6 +1055,15 @@ export async function releaseInProgress(
946
1055
  * the same Tracker port the dispatcher claimed the issue with — orphan detection
947
1056
  * is only trustworthy while every state label on the tracker came from this
948
1057
  * package.
1058
+ *
1059
+ * The two writes are ordered label-then-row, and the order is load-bearing. This
1060
+ * sweep is the only thing that revisits a `pushed-*` row, so the terminal state
1061
+ * is also the row's exit from it: written first, a tracker that then failed on
1062
+ * the label would leave `agent:in-progress` with nothing left to retry it — #18
1063
+ * exactly, in the last window able to reach it. Writing the label first makes
1064
+ * failure cost a repeated `gh` call on the next tick instead, and the row stays
1065
+ * in the busy set throughout, so no second worker can be sent at the issue while
1066
+ * it waits.
949
1067
  */
950
1068
  export async function settlePushedGreen(
951
1069
  d: Pick<Deps, "project" | "tracker" | "store">,
@@ -978,14 +1096,19 @@ export async function settlePushedGreen(
978
1096
 
979
1097
  const settlement = settlementFor(pr, run.prUrl);
980
1098
  if (settlement !== undefined) {
1099
+ // Label first, row second, and the order is the whole safety argument.
1100
+ // The sweep only ever revisits `pushed-*` rows, so writing the terminal
1101
+ // state first would put this row beyond every later tick — and a tracker
1102
+ // that failed on the label in that instant would strand
1103
+ // `agent:in-progress` permanently, which is #18 again in the one window
1104
+ // still able to reach it. Leaving the row `pushed-*` costs a stale active
1105
+ // row until the tracker answers, and the busy set keeps the issue
1106
+ // occupied meanwhile, so nothing can be dispatched onto it in between.
1107
+ if (!(await releaseInProgress(d, run.issue, settlement.reason))) continue;
981
1108
  const patch: Partial<RunRecord> = { state: settlement.state, endedAt: Date.now() };
982
1109
  if (settlement.state === "failed") patch.lastError = settlement.reason;
983
1110
  store.updateRun(run.id, patch);
984
1111
  log(`#${run.issue} settled: ${settlement.reason}`);
985
- // Store and tracker in the same breath, for the reason above: the row is
986
- // terminal, so the label's interlock is spent. `releaseInProgress` never
987
- // throws, so a tracker hiccup costs this label and not the later rows.
988
- await releaseInProgress(d, run.issue, settlement.reason);
989
1112
  continue;
990
1113
  }
991
1114
 
@@ -1002,13 +1125,13 @@ export async function settlePushedGreen(
1002
1125
  store.updateRun(run.id, { state: "pushed-green", lastError: undefined });
1003
1126
  log(`#${run.issue} checks settled: ${verification.reason}`);
1004
1127
  } else if (verification.status === "failed") {
1128
+ // Equally terminal, so the same label-first order for the same reason:
1129
+ // this row is about to leave the sweep's reach. The green branch above
1130
+ // releases nothing — that row is still awaiting a merge, and its live PR
1131
+ // is exactly the work the label must keep guarding.
1132
+ if (!(await releaseInProgress(d, run.issue, verification.reason))) continue;
1005
1133
  store.updateRun(run.id, { state: "failed", lastError: verification.reason });
1006
1134
  log(`#${run.issue} checks failed: ${verification.reason}`);
1007
- // Equally terminal: a red check on a pushed row ends the attempt, so the
1008
- // same release applies. The green branch above deliberately does not —
1009
- // that row is still awaiting a merge, and its live PR is exactly the work
1010
- // the label must keep guarding.
1011
- await releaseInProgress(d, run.issue, verification.reason);
1012
1135
  } else {
1013
1136
  store.updateRun(run.id, { lastError: verification.reason });
1014
1137
  }
@@ -1257,6 +1380,32 @@ export async function admitCandidates(
1257
1380
  continue;
1258
1381
  }
1259
1382
 
1383
+ // Fail closed on work that exists only in a worktree. `addWorktree` clears
1384
+ // the tree at <workspaceRoot>/<issue> before it provisions, so admitting
1385
+ // this issue is what finally destroys the copy the salvage could not save
1386
+ // (#118). Nothing here can recover it — git already refused once — so the
1387
+ // only safe move is to refuse the claim and keep saying why until an
1388
+ // operator has looked and run `unblock --force`.
1389
+ const newest = store.latestRun(project.name, issue);
1390
+ if (newest?.salvageError !== undefined && newest.salvageAckAt === undefined) {
1391
+ hold(issue, "unsalvaged-wip");
1392
+ await safeEscalate(d, {
1393
+ tier: 1,
1394
+ project: project.name,
1395
+ issue,
1396
+ summary: `#${issue} is holding unsalvaged work and will not be re-claimed`,
1397
+ detail: [
1398
+ r.issue.title,
1399
+ r.issue.url,
1400
+ `Attempt ${newest.attempt} could not commit its uncommitted changes: ${newest.salvageError}`,
1401
+ `The only copy is the worktree ${newest.worktree === "" ? "(path not recorded)" : newest.worktree}.`,
1402
+ "Dispatch is held because claiming this issue removes that tree.",
1403
+ "Recover it by hand, then `omp-conductor unblock <n> --force` to release the hold.",
1404
+ ].join("\n"),
1405
+ });
1406
+ continue;
1407
+ }
1408
+
1260
1409
  // Soft concurrency per epic: at most one in-flight child of a given parent.
1261
1410
  // No parent means today's concurrent admission. Cheap local filters already
1262
1411
  // ran; this sits before the open-PR API call so a held sibling frees the
@@ -1653,6 +1802,9 @@ export interface StatusSnapshot {
1653
1802
  caps: Caps;
1654
1803
  /** Occupied issues: live workers plus green PRs awaiting a human merge. */
1655
1804
  activeRuns: RunRecord[];
1805
+ /** Newest attempts holding a preserved WIP tip, or a tree that is still the
1806
+ * only copy of work the daemon could not save. */
1807
+ salvagedRuns: RunRecord[];
1656
1808
  /** Runs backed by a worker process — the number capacity compares against. */
1657
1809
  liveWorkers: number;
1658
1810
  runsToday: number;
@@ -1673,6 +1825,7 @@ export function statusSnapshotFromStore(p: ProjectConfig, caps: Caps, store: Sto
1673
1825
  paused: isPaused(),
1674
1826
  caps,
1675
1827
  activeRuns: store.activeRuns(p.name),
1828
+ salvagedRuns: store.salvagedRuns(p.name),
1676
1829
  liveWorkers: store.liveRuns(p.name).length,
1677
1830
  runsToday: store.runsStartedSince(p.name, since),
1678
1831
  spendTodayUsd: store.spendSince(p.name, since),
@@ -1716,6 +1869,32 @@ export function formatDispatchSummary(summary?: DispatchSummary): string {
1716
1869
  return lines.join("\n");
1717
1870
  }
1718
1871
 
1872
+ /**
1873
+ * The WIP block: every issue whose newest attempt left work behind, and
1874
+ * whether that work is safe.
1875
+ *
1876
+ * Blocked runs used to be invisible here, which is exactly how #118 stayed
1877
+ * invisible for a full attempt cycle — the operator saw a blocked issue and had
1878
+ * no way to tell "stopped with 34 uncommitted files" from "stopped clean".
1879
+ * A preserved line is informational; an UNSALVAGED line is an alarm, and it
1880
+ * names the directory because that directory is the work.
1881
+ */
1882
+ export function formatSalvagedRuns(runs: readonly RunRecord[]): string[] {
1883
+ if (runs.length === 0) return [];
1884
+ const lines = ["", "wip"];
1885
+ for (const r of runs) {
1886
+ lines.push(
1887
+ r.salvageError !== undefined && r.salvageAckAt === undefined
1888
+ ? ` #${r.issue} UNSALVAGED ${r.worktree === "" ? "(path not recorded)" : r.worktree} — ` +
1889
+ `only copy, dispatch held (${r.salvageError})`
1890
+ : r.salvageError !== undefined
1891
+ ? ` #${r.issue} accepted as lost attempt ${r.attempt} (${r.salvageError})`
1892
+ : ` #${r.issue} preserved ${r.salvageSha ?? "?"} on ${r.branch} (attempt ${r.attempt}, ${r.state})`,
1893
+ );
1894
+ }
1895
+ return lines;
1896
+ }
1897
+
1719
1898
  export function formatStatus(s: StatusSnapshot): string {
1720
1899
  const lines = [
1721
1900
  `project ${s.project}${s.paused ? " (PAUSED)" : ""}`,
@@ -1748,6 +1927,7 @@ export function formatStatus(s: StatusSnapshot): string {
1748
1927
  );
1749
1928
  }
1750
1929
  }
1930
+ lines.push(...formatSalvagedRuns(s.salvagedRuns));
1751
1931
  // Deploy hint: a restart while workers are live orphans them (salvage runs
1752
1932
  // first — #35). Prefer pause + drain to zero live workers when you can wait.
1753
1933
  if (s.liveWorkers > 0) {
@@ -1852,12 +2032,20 @@ export async function reconcileOrphanedRuns(
1852
2032
  const endedAt = Date.now();
1853
2033
  for (const r of stale) {
1854
2034
  // Salvage before the row flips: the worktree path is on the record, and
1855
- // salvageWip is a no-op for a missing/clean tree. Reason string matches
1856
- // the cap-kill wording so triage reads the same either way.
1857
- if (r.worktree !== "") {
1858
- await salvage(r.issue, r.attempt, "a daemon restart", r.worktree);
1859
- }
1860
- store.updateRun(r.id, { state: "orphaned", endedAt });
2035
+ // salvageWip is a no-op for a missing/clean tree. The clause matches the
2036
+ // cap-kill wording so triage reads the same either way, and the tree is
2037
+ // kept because an orphan's remains are the orchestrator's drain-duty call.
2038
+ const settlement =
2039
+ r.worktree === ""
2040
+ ? undefined
2041
+ : await settleWorktree({
2042
+ issue: r.issue,
2043
+ attempt: r.attempt,
2044
+ ending: "killed by a daemon restart",
2045
+ worktree: r.worktree,
2046
+ tree: "keep",
2047
+ });
2048
+ store.updateRun(r.id, { state: "orphaned", endedAt, ...settlement?.patch });
1861
2049
  }
1862
2050
  return stale;
1863
2051
  }
package/src/fleet.ts CHANGED
@@ -28,8 +28,16 @@ import { createInterface } from "node:readline";
28
28
  import { homedir } from "node:os";
29
29
  import { dirname, join } from "node:path";
30
30
  import { findProject, loadConfig, stateDir } from "./config.ts";
31
+ import { readApprovalSurface } from "./approval-surface.ts";
31
32
  import { probeCodeGraph, type CodeGraphHealth } from "./graph-health.ts";
32
- import { formatDispatchSummary, isPaused, setPaused, statusSnapshot, type StatusSnapshot } from "./daemon.ts";
33
+ import {
34
+ formatDispatchSummary,
35
+ formatSalvagedRuns,
36
+ isPaused,
37
+ setPaused,
38
+ statusSnapshot,
39
+ type StatusSnapshot,
40
+ } from "./daemon.ts";
33
41
  import {
34
42
  healthCheck,
35
43
  isAlive,
@@ -1028,6 +1036,7 @@ function formatProjectBody(s: StatusSnapshot): string {
1028
1036
  );
1029
1037
  }
1030
1038
  }
1039
+ lines.push(...formatSalvagedRuns(s.salvagedRuns));
1031
1040
  if (s.liveWorkers > 0) {
1032
1041
  lines.push(
1033
1042
  "",
@@ -1082,62 +1091,11 @@ function readPairedChannel(path: string): Channel {
1082
1091
  }
1083
1092
 
1084
1093
  /**
1085
- * Whether the *approval* half of the Telegram surface works, which is a
1086
- * different question from whether inbound works and was never asked before.
1087
- *
1088
- * `telegram_ask` is mounted by omp-telegram's `before_agent_start` handler only
1089
- * for a turn whose prompt resolves a notify target. A Telegram-originated turn
1090
- * resolves one from its own `<telegram-message>` wrapper; a locally injected
1091
- * orchestrator tick has no wrapper, so it resolves one only through
1092
- * `notifyTarget()` — which needs `notifyMode` set to "away" or "always" *and* a
1093
- * destination, this session's forum topic under `topicsChat` or the flat
1094
- * `notifyChat`. With neither, the tool is simply absent from the tick.
1095
- *
1096
- * That is exactly what happened on 2026-08-09 06:17Z: the fleet's access.json
1097
- * had no `notifyMode`, so the locally injected tick could not ask the
1098
- * Learning-loop yes/no question the package floor requires — while this very
1099
- * status line reported `telegram ok (@tbcoder_bot; inbound configured)`
1100
- * throughout (#114). A health row that stays green through a broken contract is
1101
- * worse than no row, so the approval surface is now part of it.
1102
- *
1103
- * The legacy `away: true` boolean counts: `loadAccess()` migrates it to
1104
- * `notifyMode: "away"` on read, so a fleet still carrying it resolves a target
1105
- * and must not be reported as broken.
1094
+ * The approval half of the Telegram surface is a different question from
1095
+ * whether inbound works, and it is answered by {@link readApprovalSurface} —
1096
+ * shared with the orchestrator tick so this row and the tick's own warning can
1097
+ * never disagree about whether an amendment can be asked.
1106
1098
  */
1107
- type ApprovalSurface = { kind: "ready" } | { kind: "missing"; reason: string };
1108
-
1109
- function readApprovalSurface(path: string): ApprovalSurface {
1110
- let parsed: unknown;
1111
- try {
1112
- parsed = JSON.parse(readFileSync(path, "utf8"));
1113
- } catch {
1114
- return { kind: "missing", reason: `telegram_ask unavailable on local ticks: cannot read ${path}` };
1115
- }
1116
- if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
1117
- return { kind: "missing", reason: `telegram_ask unavailable on local ticks: ${path} is not an object` };
1118
- }
1119
- const access = parsed as { readonly [key: string]: unknown };
1120
- const mode = access["notifyMode"];
1121
- const active = mode === "away" || mode === "always" || access["away"] === true;
1122
- if (!active) {
1123
- return {
1124
- kind: "missing",
1125
- reason:
1126
- `telegram_ask unavailable on local ticks: no notifyMode in ${path} — ` +
1127
- `set it to "always" and give it a destination (notifyChat, or topicsChat for a forum)`,
1128
- };
1129
- }
1130
- const destination = access["notifyChat"] ?? access["topicsChat"];
1131
- if (typeof destination !== "string" || destination.length === 0) {
1132
- return {
1133
- kind: "missing",
1134
- reason:
1135
- `telegram_ask unavailable on local ticks: notifyMode is set but ${path} names no destination — ` +
1136
- "set notifyChat to the paired owner id (or topicsChat for a forum)",
1137
- };
1138
- }
1139
- return { kind: "ready" };
1140
- }
1141
1099
 
1142
1100
  function readBotToken(): string | undefined {
1143
1101
  const env = process.env["TELEGRAM_BOT_TOKEN"];
@@ -1205,6 +1163,8 @@ export async function probeTelegramHealth(
1205
1163
  // Inbound first: a bridge that is down says nothing about the approval
1206
1164
  // surface, and stacking two remedies on one row buries the one to act on.
1207
1165
  if (channel.kind === "down") return { kind: "degraded", detail: `${username}; inbound ${channel.reason}` };
1166
+ // The token is already proven: `getMe` succeeded above. What is left is
1167
+ // whether an answer could come back, which is the access file's business.
1208
1168
  const approval = readApprovalSurface(accessPath);
1209
1169
  if (approval.kind === "missing") {
1210
1170
  return { kind: "degraded", detail: `${username}; inbound configured; ${approval.reason}` };