@coreplane/switchboard 1.254.1 → 1.256.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/dist/assets/.dockerignore +3 -0
  2. package/dist/assets/Dockerfile +12 -1
  3. package/dist/assets/config/config.example.yaml +6 -1
  4. package/dist/assets/deploy/cloudflare/worker.ts +39 -23
  5. package/dist/assets/deploy/cloudflare-memory/worker.ts +552 -5
  6. package/dist/assets/deploy/cloudflare-memory/wrangler.template.jsonc +5 -0
  7. package/dist/assets/deploy/cloudflare-resident/Dockerfile +13 -1
  8. package/dist/assets/deploy/cloudflare-resident/drain.ts +55 -1
  9. package/dist/assets/deploy/cloudflare-resident/levels.ts +84 -0
  10. package/dist/assets/deploy/cloudflare-resident/prepare-commit-msg +17 -0
  11. package/dist/assets/deploy/cloudflare-resident/worker.ts +358 -42
  12. package/dist/assets/deploy/cloudflare-sandbox/Dockerfile +11 -1
  13. package/dist/assets/deploy/cloudflare-sandbox/prepare-commit-msg +17 -0
  14. package/dist/assets/deploy/cloudflare-sandbox/worker.ts +21 -4
  15. package/dist/assets/deploy/hooks/prepare-commit-msg +17 -0
  16. package/dist/assets/deploy/secrets.manifest.json +12 -0
  17. package/dist/assets/package-lock.json +3 -3
  18. package/dist/assets/package.json +1 -1
  19. package/dist/assets/source.json +3 -3
  20. package/dist/assets/src/agents/registry.ts +52 -0
  21. package/dist/assets/src/core/budgets.ts +35 -2
  22. package/dist/assets/src/core/coordinator/contract.ts +6 -0
  23. package/dist/assets/src/core/coordinator/driver.ts +59 -6
  24. package/dist/assets/src/core/costs.ts +39 -16
  25. package/dist/assets/src/core/pipelineStanding.ts +62 -0
  26. package/dist/assets/src/core/plane/decide.ts +389 -22
  27. package/dist/assets/src/core/refusal.ts +3 -0
  28. package/dist/assets/src/core/reviewVerdict.ts +64 -2
  29. package/dist/assets/src/core/runEvents.ts +31 -12
  30. package/dist/assets/src/core/runLedger/sessionLog.ts +128 -0
  31. package/dist/assets/src/core/runLedger/types.ts +3 -0
  32. package/dist/assets/src/core/runRecord.ts +24 -7
  33. package/dist/assets/src/core/ship/contract.ts +20 -30
  34. package/dist/assets/src/core/ship/coordinator.ts +519 -126
  35. package/dist/assets/src/core/trace/workerTrace.ts +3 -0
  36. package/dist/assets/src/execution/sandboxErrors.ts +77 -6
  37. package/dist/assets/web/dist/.vite/manifest.json +61 -60
  38. package/dist/assets/web/dist/assets/CostsPage-BaeWnm-o.js +1 -0
  39. package/dist/assets/web/dist/assets/{DeliveryPage-ngPsO2to.js → DeliveryPage-BBAyLwPq.js} +1 -1
  40. package/dist/assets/web/dist/assets/{HomePage-DvxTHzPx.js → HomePage-BEiStFwW.js} +1 -1
  41. package/dist/assets/web/dist/assets/PendingTurnRow-DGINv9XT.js +1 -0
  42. package/dist/assets/web/dist/assets/{PlanePage-DpWfiX4C.js → PlanePage-JEj-lqgz.js} +1 -1
  43. package/dist/assets/web/dist/assets/{ResidentDetailPage-DG86v39Y.js → ResidentDetailPage-jYhEeeyu.js} +1 -1
  44. package/dist/assets/web/dist/assets/{ResidentsIndexPage-x6p689VH.js → ResidentsIndexPage-DvFmlV4U.js} +1 -1
  45. package/dist/assets/web/dist/assets/RunFoldRow-DvWzR1JQ.js +1 -0
  46. package/dist/assets/web/dist/assets/RunRoutePage-CvZ-TOT3.js +9 -0
  47. package/dist/assets/web/dist/assets/{RunsIndexPage-CuzFchcn.js → RunsIndexPage-JqVSDOok.js} +1 -1
  48. package/dist/assets/web/dist/assets/{ScheduledPage-BuLmfcbG.js → ScheduledPage-Viim-bus.js} +1 -1
  49. package/dist/assets/web/dist/assets/{SettingsPage-BujWkdU_.js → SettingsPage-CYUy8McC.js} +1 -1
  50. package/dist/assets/web/dist/assets/{StatusDot-C8Bc0pTX.js → StatusDot-Dv6UMaPy.js} +1 -1
  51. package/dist/assets/web/dist/assets/{Tooltip-BWwJx27K.js → Tooltip-Bd-5Rypv.js} +1 -1
  52. package/dist/assets/web/dist/assets/UnitRoutePage-B81wEnKN.js +1 -0
  53. package/dist/assets/web/dist/assets/budgets-c1eumrqD.js +1 -0
  54. package/dist/assets/web/dist/assets/{dist-CpnyQGOb.js → dist-Nl3uaxrP.js} +1 -1
  55. package/dist/assets/web/dist/assets/indexRow-DborJPFp.js +1 -0
  56. package/dist/assets/web/dist/assets/{main-Comxmwi4.js → main-DRxWlffc.js} +2 -2
  57. package/dist/assets/web/dist/assets/{sseReplay-IzTdD4-3.js → sseReplay-DE6wv1Ua.js} +6 -6
  58. package/dist/cli.js +4400 -3079
  59. package/package.json +1 -1
  60. package/dist/assets/web/dist/assets/CostsPage-BuKjw3nv.js +0 -1
  61. package/dist/assets/web/dist/assets/PendingTurnRow-ZYIRCCZ2.js +0 -1
  62. package/dist/assets/web/dist/assets/RunFoldRow-DG29LOTs.js +0 -1
  63. package/dist/assets/web/dist/assets/RunRoutePage-ysJBY8xQ.js +0 -9
  64. package/dist/assets/web/dist/assets/UnitRoutePage-B9kjA1AT.js +0 -1
  65. package/dist/assets/web/dist/assets/budgets-CbIyPAER.js +0 -1
  66. package/dist/assets/web/dist/assets/indexRow-DABQtONT.js +0 -1
@@ -41,6 +41,7 @@ import {
41
41
  findingsAtOrAbove,
42
42
  formatFinding,
43
43
  isAddressSeverity,
44
+ severityCounts,
44
45
  type AddressSeverity,
45
46
  type AddressSeveritySource,
46
47
  type Finding,
@@ -76,21 +77,70 @@ export interface ShipCaps {
76
77
  * rounds that must still follow, capped at the preset's ask, refused under the
77
78
  * round's floor. Nothing here holds a reserve of its own. */
78
79
 
79
- /** What a ship pipeline's thread and card say when the bot died under it (run-
80
- * history item 36): the work it did stands on GitHub with nobody driving it,
81
- * so the note names the PR when one was opened and the exact re-issue that
82
- * continues the loop — the same entry the preflight's resume-at-review takes
83
- * (agent-ship item 10). Without a PR the task itself is the re-issue: round 0
84
- * runs again on the pipeline's own deterministic branch. The coordinator says
85
- * the same when a child of its closed `interrupted`. */
86
- export function shipInterruptedNote(prUrl?: string): string {
80
+ /** What actually ended an interrupted child, from the ledger that saw it
81
+ * (issue 1876): the ending's sentence names the cause in the user's nouns —
82
+ * the bot restarted, the resident container was replaced, the sandbox failed
83
+ * — never "the bot restarted" for a site three causes reach. Read off the
84
+ * child's record by the bot's `read-record` (`child_interrupted` / the resume
85
+ * notes) and carried on the `interrupted` ending; absent when the record
86
+ * named none. */
87
+ export type InterruptionCause = "bot_restart" | "container_replaced" | "sandbox_fault";
88
+
89
+ /** What a ship pipeline's thread and card say when its child died under it
90
+ * (run-history item 36): the work it did stands on GitHub with nobody driving
91
+ * it, so the note names the actual cause (issue 1876 — the site is reached by
92
+ * a bot restart, a resident container replacement and a sandbox fault, and
93
+ * only the ledger's word picks one; with none the sentence claims no cause),
94
+ * the PR when one was opened and the exact re-issue that continues the loop —
95
+ * the same entry the preflight's resume-at-review takes (agent-ship item 10).
96
+ * Without a PR the task itself is the re-issue: round 0 runs again on the
97
+ * pipeline's own deterministic branch. A child whose container was replaced
98
+ * resumes from its request by itself (issue 1903) and the pipeline keeps
99
+ * waiting — this note is written only when that resume failed or no resume
100
+ * applied. */
101
+ /** The cause an interruption's recorded words name (issue 1876): the ledger
102
+ * that saw the roll wrote them — the relaunch refusals and the lost-workspace
103
+ * notes name the replaced container, the sandbox executor's wordings the
104
+ * sandbox, the boot gap names the restart — and the ending's sentence repeats
105
+ * the cause, never "the bot restarted" for words that say otherwise. Each
106
+ * pattern is anchored on the exact phrases those ledgers write —
107
+ * `src/core/harness/contract.ts` and `src/core/dispatch/relaunch.ts`
108
+ * ("container replaced under the run", "replacement container"),
109
+ * `src/core/dispatch/reattach.ts` ("workspace lost", "could not be
110
+ * re-attached"), `src/execution/sandboxLifecycle.ts` and the sandbox worker's
111
+ * failures ("sandbox recycled", "sandbox worker", "sandbox exec"),
112
+ * `src/core/boot.ts` and the resume notes ("bot restarted", the deploy
113
+ * hand-off's "next generation") — so an unrelated word ("regenerating…", a
114
+ * reason merely mentioning a sandbox path) cannot classify. Unrecognized
115
+ * words are no cause: the sentence then claims none. */
116
+ export function interruptionCauseOfWords(words: string): InterruptionCause | undefined {
117
+ if (
118
+ /\bcontainer (?:was )?replaced\b|\breplac(?:ed|ement) container\b|\bworkspace lost\b|\bcould not be re-attached\b/i.test(
119
+ words,
120
+ )
121
+ )
122
+ return "container_replaced";
123
+ if (/\bsandbox (?:recycled|worker|exec|runtime)\b/i.test(words)) return "sandbox_fault";
124
+ if (/\bbot restart(?:ed|s)?\b|\b(?:next|previous|another) generation\b/i.test(words)) return "bot_restart";
125
+ return undefined;
126
+ }
127
+
128
+ export function shipInterruptedNote(prUrl?: string, cause?: InterruptionCause): string {
87
129
  const stands = prUrl
88
130
  ? `Its work stands on GitHub: ${prUrl}.`
89
131
  : "Whatever it pushed stands on its pipeline branch; no PR was opened yet.";
90
132
  const reissue = prUrl
91
133
  ? `To continue the review loop, re-issue \`agent:ship\` in this thread with only the PR URL (${prUrl}).`
92
134
  : "To continue, re-issue `agent:ship` in this thread with the task — round 0 runs again on the same branch.";
93
- return `⚠️ The bot restarted while this ship pipeline was running, so the pipeline stopped. ${stands} ${reissue}`;
135
+ const opening =
136
+ cause === "container_replaced"
137
+ ? "⚠️ The resident container running this pipeline's child was replaced (a deploy's image swap) and the child could not resume, so the pipeline stopped."
138
+ : cause === "sandbox_fault"
139
+ ? "⚠️ The sandbox running this pipeline's child failed and the child could not resume, so the pipeline stopped."
140
+ : cause === "bot_restart"
141
+ ? "⚠️ The bot restarted while this ship pipeline was running, so the pipeline stopped."
142
+ : "⚠️ This ship pipeline's child was interrupted and could not resume, so the pipeline stopped.";
143
+ return `${opening} ${stands} ${reissue}`;
94
144
  }
95
145
 
96
146
  // ---- the plan graph --------------------------------------------------------------------------------
@@ -404,6 +454,11 @@ export interface RoundRef {
404
454
  /** Round 0 is the coding round; review round n and its findings step share n. */
405
455
  index: number;
406
456
  kind: RoundKind;
457
+ /** The round's attempt when it was re-run (agent-ship item 9, issue 1932):
458
+ * 2 for round 0's one re-run after a provider transient with nothing
459
+ * pushed. Absent on a first attempt; suffixes the round's step names so the
460
+ * Workflow's durable cache never hands the re-run the dead attempt's answers. */
461
+ attempt?: number;
407
462
  }
408
463
  export type ChildPreset = "coding" | "review";
409
464
 
@@ -468,9 +523,10 @@ export type CoordinatorAction =
468
523
  * over an already-shipped pull request. */
469
524
  entry?: true;
470
525
  /** Set after a coding child died: the bot opens the pull request from the
471
- * pushed branch itself (title from the unit, body from this run's
472
- * submitted description when the record holds one) instead of answering
473
- * `none` over stranded work. */
526
+ * pushed branch itself (title from this run's submitted description when
527
+ * the record holds one, else a conventional fallback from the unit's
528
+ * title — issue 1877; body from the description when the record holds
529
+ * one) instead of answering `none` over stranded work. */
474
530
  recover?: { runId: string };
475
531
  /** The pull request the machine has adopted (`state.pr`), when it holds
476
532
  * one: the child may have worked that pull request's own head branch,
@@ -480,7 +536,10 @@ export type CoordinatorAction =
480
536
  * over a record fact written minutes earlier. */
481
537
  pr?: number;
482
538
  }
483
- | { type: "merge"; step: string; prNumber: number; headSha: string }
539
+ /** `queued`: the pull request is in the base's merge queue — the bot reads
540
+ * the queue's outcome (merged, still queued, or removed with the reason)
541
+ * instead of attempting the squash. */
542
+ | { type: "merge"; step: string; prNumber: number; headSha: string; queued?: true }
484
543
  /** Read the check runs at the reviewed head (record 0055, the round verdict):
485
544
  * the merge door's own reading, folded into the round. `retry` names the
486
545
  * failed checks whose one flake re-run the machine is spending: the bot
@@ -524,6 +583,14 @@ export type ChildFacts =
524
583
  leaseStartedAt?: number;
525
584
  costUsd?: number | null;
526
585
  handoffLists?: Handoff;
586
+ /** The failure by name off a `failed` run's record (run-history item 57):
587
+ * `provider_transient` marks a child a gateway 5xx, a cut stream or a
588
+ * gateway timeout ended past the harness's retry ladder (issue 1932). */
589
+ failure?: { kind: string };
590
+ /** What ended an `interrupted` child, off its record's own events (issue
591
+ * 1876): the ending's sentence names it instead of claiming a bot
592
+ * restart for every cause. */
593
+ interruption?: InterruptionCause;
527
594
  };
528
595
 
529
596
  /** What heads the unit's branch on GitHub: nothing, an open pull request, or —
@@ -580,14 +647,25 @@ export type StepReturn =
580
647
  | { type: "spawn"; step: string; outcome: "busy"; runId?: string; at: number }
581
648
  | { type: "spawn"; step: string; outcome: "refused"; refusal: string; message?: string; at: number }
582
649
  | { type: "spawn"; step: string; outcome: "failed"; reason: string; at: number }
650
+ // The hosted parent's hard stop landed (record 0060; issue 1924): the bot
651
+ // refuses the spawn over the instance row's stop mark, and the unit ends
652
+ // `stopped` — nothing more is run.
653
+ | { type: "spawn"; step: string; outcome: "stopped"; at: number }
583
654
  | { type: "wait"; step: string; outcome: "event" | "timeout" }
584
- | { type: "read-record"; step: string; run: ChildFacts; at: number }
655
+ // `stopped` on a read: the instance row carries the hard stop's mark, so a
656
+ // finished child ends its unit `stopped` whatever the child's own status.
657
+ /** `restartedAs`: the interrupted child restarted from its request as this
658
+ * run (issue 1903 — a replaced container's child resumes by itself), so the
659
+ * machine keeps waiting on the successor instead of ending the unit. */
660
+ | { type: "read-record"; step: string; run: ChildFacts; stopped?: true; restartedAs?: string; at: number }
585
661
  | { type: "pr-check"; step: string; pr: PrCheck; at: number }
586
662
  | { type: "merge"; step: string; outcome: "merged"; sha: string; at: number }
587
663
  // The door found the pull request already merged after the approval — auto-merge
588
664
  // fired, or a person merged — so the runner merged nothing (`by: other`).
589
665
  | { type: "merge"; step: string; outcome: "merged"; by: "other"; sha: string; mergedAt: string; at: number }
590
- | { type: "merge"; step: string; outcome: "pending" | "refused"; reason: string; at: number }
666
+ /** The door enqueued the pull request (or found it still queued), or the
667
+ * queue removed it — the reason is the queue's own (issue 2011). */
668
+ | { type: "merge"; step: string; outcome: "pending" | "refused" | "enqueued" | "removed"; reason: string; at: number }
591
669
  /** The checks read at the reviewed head; `checks` absent means GitHub could
592
670
  * not be read, which the machine treats as pending (record 0055). A retry
593
671
  * ask answers `retried` instead: whether the bot dispatched the re-run —
@@ -629,6 +707,20 @@ export function checkFinding(f: CheckFailure): Finding {
629
707
  };
630
708
  }
631
709
 
710
+ /** A merge-queue removal as a finding of the round (issue 2011): the queue's
711
+ * reason — a failing check inside the queue, a conflict — rides the findings
712
+ * brief exactly as a red check does, severity `blocking` so the level in
713
+ * force always counts it, and a fix round follows. */
714
+ export function queueRemovalFinding(reason: string): Finding {
715
+ return {
716
+ id: "check:merge-queue",
717
+ severity: "blocking",
718
+ file: "merge queue",
719
+ title: `removed from the merge queue — ${reason}`,
720
+ check: true,
721
+ };
722
+ }
723
+
632
724
  /** The check findings of a review round: the rows the checks step appended to
633
725
  * the round's findings, told apart by provenance — the `check` flag only
634
726
  * `checkFinding` sets, never the id: a reviewer's id is a free string, and a
@@ -653,6 +745,20 @@ export type UnitEnding =
653
745
  * The unit is done and its dependents start on a base that carries it. */
654
746
  | { kind: "already_landed"; landed: HandoffLanded[]; round: RoundRef; runId: string; reviewRounds: number }
655
747
  | { kind: "merge_ready"; pr: PrRef; reviewRounds: number }
748
+ /** Every finding the round would act on is human-gated — a receipt only a
749
+ * person can produce (issue 1990; the reviewer set the flag through
750
+ * `submit_verdict`, agent-review item 5) — so a fix round could change
751
+ * nothing: the unit ends held for a person, the ending carrying the
752
+ * human-gated rows, and a re-issue with the pull request resumes at the
753
+ * review round once the receipt is posted (item 10's resume path). */
754
+ | {
755
+ kind: "held";
756
+ pr?: PrRef;
757
+ round: RoundRef;
758
+ findings: Finding[];
759
+ verdict: "approve" | "request_changes";
760
+ reviewRounds: number;
761
+ }
656
762
  | { kind: "merge_refused"; pr: PrRef; reason: string; reviewRounds: number }
657
763
  | { kind: "round_cap"; maxRounds: number; reviewRounds: number }
658
764
  | {
@@ -710,7 +816,15 @@ export type UnitEnding =
710
816
  spent: ShipBudgetSpent;
711
817
  }
712
818
  | { kind: "no_verdict"; round: RoundRef; reviewRounds: number; finalReply?: string }
713
- | { kind: "interrupted"; round: RoundRef; runId: string; reviewRounds: number }
819
+ /** Round 0's coding child died on a provider transient — a model-gateway
820
+ * 5xx, a cut stream, a gateway timeout, past the harness's retry ladder —
821
+ * with nothing pushed, TWICE: the first such death re-ran the round once
822
+ * (the ledger row and the branch untouched, a re-run costs only minutes),
823
+ * and the second is the ending (agent-ship item 9, issue 1932). Named
824
+ * `transient` so it reads as a condition beside `checks_failed` and `held`
825
+ * in the plane's table, never as the child failing on its task. */
826
+ | { kind: "transient"; round: RoundRef; runId: string; reviewRounds: number }
827
+ | { kind: "interrupted"; round: RoundRef; runId: string; reviewRounds: number; cause?: InterruptionCause }
714
828
  | { kind: "refused"; refusal: string; message?: string; round: RoundRef; reviewRounds: number }
715
829
  /** The unit idles instead of ending (record 0051): with the resolved
716
830
  * `ship.idleDays` above zero, `end()` wraps an idling kind — every kind but
@@ -737,18 +851,24 @@ export type UnitEnding =
737
851
  reviewRounds: number;
738
852
  };
739
853
 
740
- /** The kinds that idle: every ending but the four ended ones — the unit is
854
+ /** The kinds that idle: every ending but the ended ones — the unit is
741
855
  * unfinished (a cap, a stop, an abort, a refused merge, a segment's end) and
742
- * a reply could continue it. `merged`, `already_landed`, `merge_ready` and
743
- * `refused` never idle: the first two are done, merge-ready waits only for a
744
- * person's merge, and a refused child would be refused again. */
745
- export type IdleWhy = Exclude<UnitEnding["kind"], "idle" | "merged" | "already_landed" | "merge_ready" | "refused">;
856
+ * a reply could continue it. `merged`, `already_landed`, `merge_ready`,
857
+ * `held` and `refused` never idle: the first two are done, merge-ready waits
858
+ * only for a person's merge, held waits only for a person's receipt (a fix
859
+ * round could change nothing, so nothing here can continue it), and a
860
+ * refused child would be refused again. */
861
+ export type IdleWhy = Exclude<
862
+ UnitEnding["kind"],
863
+ "idle" | "merged" | "already_landed" | "merge_ready" | "held" | "refused"
864
+ >;
746
865
 
747
866
  const NEVER_IDLES: ReadonlySet<UnitEnding["kind"]> = new Set([
748
867
  "idle",
749
868
  "merged",
750
869
  "already_landed",
751
870
  "merge_ready",
871
+ "held",
752
872
  "refused",
753
873
  ]);
754
874
 
@@ -878,9 +998,19 @@ type Phase =
878
998
  * pushed: the pr-check recovers a pushed branch by opening its pull
879
999
  * request; with nothing pushed the unit ends with the child's own reason. */
880
1000
  dead?: "failed" | "interrupted";
1001
+ /** What ended the dead child (issue 1876), for the `interrupted` ending's sentence. */
1002
+ cause?: InterruptionCause;
1003
+ /** The dead child's record names a provider transient (`failure:
1004
+ * provider_transient`, issue 1932): with nothing pushed, round 0 is
1005
+ * re-run once instead of the unit aborting; a second transient in the
1006
+ * same round is the `transient` ending. */
1007
+ transient?: true;
881
1008
  }
882
- | { at: "merge"; pr: PrRef; headSha: string; n: number; since: number; waitMs: number }
883
- | { at: "merge-wait"; pr: PrRef; headSha: string; n: number; since: number; waitMs: number }
1009
+ /** `queued`: the door enqueued the pull request — the base takes changes
1010
+ * only through a merge queue (issue 2011) — so every later ask reads the
1011
+ * queue's outcome instead of attempting the squash again. */
1012
+ | { at: "merge"; pr: PrRef; headSha: string; n: number; since: number; waitMs: number; queued?: true }
1013
+ | { at: "merge-wait"; pr: PrRef; headSha: string; n: number; since: number; waitMs: number; queued?: true }
884
1014
  /** The round's checks step (record 0055): the check runs at the reviewed head
885
1015
  * are read after an approve settles, before merge_ready or the merge door.
886
1016
  * `graced`: a head with no check reported has had its one-chunk grace;
@@ -1025,7 +1155,8 @@ function roundCarve(s: UnitPipelineState, round: RoundRef): Carve {
1025
1155
  export const stepPrefixOf = (unit: string, session: UnitSession | undefined): string =>
1026
1156
  session !== undefined && session.segment > 1 ? `${unit}/s${session.segment}` : unit;
1027
1157
  const stepPrefix = (s: UnitPipelineState) => stepPrefixOf(s.input.unit.id, s.input.session);
1028
- const roundStep = (s: UnitPipelineState, round: RoundRef) => `${stepPrefix(s)}/${round.index}/${round.kind}`;
1158
+ const roundStep = (s: UnitPipelineState, round: RoundRef) =>
1159
+ `${stepPrefix(s)}/${round.index}/${round.kind}${round.attempt !== undefined ? `/a${round.attempt}` : ""}`;
1029
1160
 
1030
1161
  function briefFor(s: UnitPipelineState, round: RoundRef): Brief {
1031
1162
  const unit = s.input.unit.id;
@@ -1149,7 +1280,13 @@ export function nextAction(s: UnitPipelineState): CoordinatorAction {
1149
1280
  timeoutMs: Math.max(MIN, Math.min(MERGE_WAIT_CHUNK_MS, p.waitMs - (s.clock - p.since))),
1150
1281
  };
1151
1282
  case "merge":
1152
- return { type: "merge", step: `${unit}/merge/${p.n}`, prNumber: p.pr.number, headSha: p.headSha };
1283
+ return {
1284
+ type: "merge",
1285
+ step: `${unit}/merge/${p.n}`,
1286
+ prNumber: p.pr.number,
1287
+ headSha: p.headSha,
1288
+ ...(p.queued === true ? { queued: true as const } : {}),
1289
+ };
1153
1290
  case "merge-wait":
1154
1291
  return {
1155
1292
  type: "wait-checks",
@@ -1194,7 +1331,10 @@ function end(s: UnitPipelineState, ending: UnitEnding, notes: CoordinatorNote[]
1194
1331
  function idleEnding(s: UnitPipelineState, ending: UnitEnding): Extract<UnitEnding, { kind: "idle" }> | undefined {
1195
1332
  if ((s.input.idleDays ?? 0) <= 0) return undefined;
1196
1333
  if (NEVER_IDLES.has(ending.kind)) return undefined;
1197
- const old = ending as Exclude<UnitEnding, { kind: "idle" | "merged" | "already_landed" | "merge_ready" | "refused" }>;
1334
+ const old = ending as Exclude<
1335
+ UnitEnding,
1336
+ { kind: "idle" | "merged" | "already_landed" | "merge_ready" | "held" | "refused" }
1337
+ >;
1198
1338
  const grant = s.input.grant ?? DEFAULT_GRANT;
1199
1339
  // Unspent: an idle spends no renewal — the wake's segment does (this plan's
1200
1340
  // fifth unit) — so the row says what the grant still holds.
@@ -1234,6 +1374,29 @@ function idleEnding(s: UnitPipelineState, ending: UnitEnding): Extract<UnitEndin
1234
1374
  /** A gated finding as the gate note names it: `F1 (minor)`. */
1235
1375
  const gateLabel = (f: Finding): string => `${f.id} (${f.severity})`;
1236
1376
 
1377
+ /** The held decision (issue 1990): the findings the round would act on — the
1378
+ * gated set on an approve, every finding on a request_changes — are all
1379
+ * human-gated, read off the reviewer's own flag and never prose. One
1380
+ * actionable finding beside a human-gated one keeps the fix round: the
1381
+ * dispositions cover the human-gated row like any other (declined, with the
1382
+ * person named). An empty set decides nothing. */
1383
+ const allHumanGated = (findings: readonly Finding[]): boolean =>
1384
+ findings.length > 0 && findings.every((f) => f.humanGated === true);
1385
+
1386
+ const heldEnding = (
1387
+ s: UnitPipelineState,
1388
+ round: RoundRef,
1389
+ findings: Finding[],
1390
+ verdict: "approve" | "request_changes",
1391
+ ): UnitEnding => ({
1392
+ kind: "held",
1393
+ ...(s.pr !== undefined ? { pr: s.pr } : {}),
1394
+ round,
1395
+ findings,
1396
+ verdict,
1397
+ reviewRounds: s.reviewRounds,
1398
+ });
1399
+
1237
1400
  const roundNote = (round: RoundRef, outcome: ShipRoundOutcome): RoundNote => ({
1238
1401
  type: "round",
1239
1402
  index: round.index,
@@ -1332,7 +1495,19 @@ function settleCoding(
1332
1495
  // request (agent-ship items 10 and 15), and only a branch with
1333
1496
  // nothing on it ends the unit with the child's own reason.
1334
1497
  if (facts.status === "failed")
1335
- return { state: { ...next, phase: { at: "pr-check", round, runId, dead: "failed" } }, notes: [] };
1498
+ return {
1499
+ state: {
1500
+ ...next,
1501
+ phase: {
1502
+ at: "pr-check",
1503
+ round,
1504
+ runId,
1505
+ dead: "failed",
1506
+ ...(facts.failure?.kind === "provider_transient" ? { transient: true as const } : {}),
1507
+ },
1508
+ },
1509
+ notes: [],
1510
+ };
1336
1511
  next = {
1337
1512
  ...next,
1338
1513
  phase: {
@@ -1429,6 +1604,9 @@ function settleReview(
1429
1604
  },
1430
1605
  notes,
1431
1606
  );
1607
+ // Every gated finding is human-gated (issue 1990): a fix round could
1608
+ // change nothing, so the unit ends held for a person instead.
1609
+ if (allHumanGated(gated)) return end(next, heldEnding(next, round, gated, "approve"), notes);
1432
1610
  if (next.reviewRounds >= next.input.caps.maxRounds)
1433
1611
  return end(
1434
1612
  next,
@@ -1457,6 +1635,11 @@ function settleReview(
1457
1635
  },
1458
1636
  notes,
1459
1637
  );
1638
+ // Every finding of the round is human-gated (issue 1990): no fix round can
1639
+ // change anything, so the unit ends held for a person's receipt instead of
1640
+ // spending a coding child — or the round cap — on it.
1641
+ if (allHumanGated(verdict.findings ?? []))
1642
+ return end(next, heldEnding(next, round, verdict.findings ?? [], "request_changes"), notes);
1460
1643
  if (next.reviewRounds >= next.input.caps.maxRounds)
1461
1644
  return end(
1462
1645
  next,
@@ -1500,14 +1683,51 @@ function enterChecks(s: UnitPipelineState, round: RoundRef, notes: CoordinatorNo
1500
1683
  };
1501
1684
  }
1502
1685
 
1686
+ /** The order the checks step reads a head in (record 0055, issue 1991):
1687
+ * a failed check is the round's answer as soon as it is read, whatever else
1688
+ * is still pending — with the flake rule spending its one re-run first when
1689
+ * every failure is a suspect — and only a head with no failed check waits on
1690
+ * the pending ones; a head with no check reported gets one chunk of grace;
1691
+ * the rest is green. One function so the siblings that fold more outcomes
1692
+ * into the step (a held ending, a transient round re-run) read the same order. */
1693
+ function checksVerdict(
1694
+ checks: RoundChecks | undefined,
1695
+ p: { retried: boolean; graced: boolean },
1696
+ ):
1697
+ | { kind: "retry"; names: string[] }
1698
+ | { kind: "failed"; failed: CheckFailure[] }
1699
+ | { kind: "pending" }
1700
+ | { kind: "grace" }
1701
+ | { kind: "green" } {
1702
+ if (checks !== undefined && checks.failed.length > 0) {
1703
+ // The flake rule (record 0055): a suspected flake — a test timeout or
1704
+ // runner stall on a shard whose test files the pull request's changed
1705
+ // paths never touch — is re-run once before it becomes a finding. One
1706
+ // real failure among them makes the round's answer already known, so the
1707
+ // re-run is spent only when every failure is a suspect.
1708
+ if (!p.retried && checks.failed.every((f) => f.flakeSuspect === true))
1709
+ return { kind: "retry", names: checks.failed.map((f) => f.name) };
1710
+ return { kind: "failed", failed: checks.failed };
1711
+ }
1712
+ // GitHub unreadable answers as pending and is re-read at the chunk's end.
1713
+ if (checks === undefined || checks.pending.length > 0) return { kind: "pending" };
1714
+ // No check reported at the head: one chunk of grace — the first check starts
1715
+ // within minutes where CI exists — then the round proceeds, so a repository
1716
+ // without CI costs one chunk per round and never idles (record 0055).
1717
+ if (checks.total === 0 && !p.graced) return { kind: "grace" };
1718
+ return { kind: "green" };
1719
+ }
1720
+
1503
1721
  /** What the checks step answered, folded into the round (record 0055). Pure
1504
1722
  * over the phase: a retry ask waits for the head to settle and reads again;
1505
- * an unreadable or pending head waits a chunk inside the step's ask and then
1723
+ * a failed check becomes a check finding under a round note of its own and
1724
+ * the findings step runs as for any changes-requested round — never
1725
+ * merge_ready, never the merge door — as soon as it is read, whatever else
1726
+ * is still pending (issue 1991); only a head with no failed check waits a
1727
+ * chunk inside the step's ask on what is unreadable or pending and then
1506
1728
  * proceeds — the ending's facts read names what is still pending; a head with
1507
- * no check reported waits one chunk of grace and never more; a failed check
1508
- * becomes a check finding under a round note of its own and the findings step
1509
- * runs as for any changes-requested round — never merge_ready, never the
1510
- * merge door; a green head proceeds with no wait added. */
1729
+ * no check reported waits one chunk of grace and never more; a green head
1730
+ * proceeds with no wait added. */
1511
1731
  function settleChecks(
1512
1732
  s: UnitPipelineState,
1513
1733
  p: Extract<Phase, { at: "checks" }>,
@@ -1546,48 +1766,43 @@ function settleChecks(
1546
1766
  }
1547
1767
  return wait({ retried: true });
1548
1768
  }
1549
- // GitHub unreadable answers as pending and is re-read at the chunk's end
1550
- // (record 0055); a head still pending or unreadable at the ask's end
1551
- // proceeds — the ending's facts read names what is still pending, and the
1552
- // merge door (under `merge: runner`) is the guard that never merges over it.
1553
- if (checks === undefined || checks.pending.length > 0) {
1554
- if (s.clock - p.since >= p.waitMs) return approveOutcome(s, []);
1555
- return wait({});
1556
- }
1557
- if (checks.failed.length > 0) {
1558
- // The flake rule (record 0055): a suspected flake — a test timeout or
1559
- // runner stall on a shard whose test files the pull request's changed
1560
- // paths never touch — is re-run once before it becomes a finding. One
1561
- // real failure among them makes the round's answer already known, so the
1562
- // re-run is spent only when every failure is a suspect.
1563
- if (!p.retried && checks.failed.every((f) => f.flakeSuspect === true))
1769
+ const verdict = checksVerdict(checks, p);
1770
+ switch (verdict.kind) {
1771
+ case "retry":
1564
1772
  return {
1565
- state: { ...s, phase: { ...p, n: p.n + 1, retry: checks.failed.map((f) => f.name) } },
1773
+ state: { ...s, phase: { ...p, n: p.n + 1, retry: verdict.names } },
1566
1774
  notes: [],
1567
1775
  };
1568
- const findings = [...(s.findingsByRound[round.index] ?? []), ...checks.failed.map(checkFinding)];
1569
- const next: UnitPipelineState = {
1570
- ...s,
1571
- findingsByRound: { ...s.findingsByRound, [round.index]: findings },
1572
- };
1573
- // The failed checks get a round note of their own (record 0055) — never
1574
- // the parser-mismatch gate — and the findings step runs as for any
1575
- // changes-requested round: dispositions, a fix round, a re-review.
1576
- const notes: CoordinatorNote[] = [roundNote(round, "checks_failed")];
1577
- if (next.reviewRounds >= next.input.caps.maxRounds)
1578
- return end(
1579
- next,
1580
- { kind: "round_cap", maxRounds: next.input.caps.maxRounds, reviewRounds: next.reviewRounds },
1581
- notes,
1582
- );
1583
- return enterRound(next, { index: round.index, kind: "findings" }, notes);
1776
+ case "failed": {
1777
+ const findings = [...(s.findingsByRound[round.index] ?? []), ...verdict.failed.map(checkFinding)];
1778
+ const next: UnitPipelineState = {
1779
+ ...s,
1780
+ findingsByRound: { ...s.findingsByRound, [round.index]: findings },
1781
+ };
1782
+ // The failed checks get a round note of their own (record 0055) — never
1783
+ // the parser-mismatch gate — and the findings step runs as for any
1784
+ // changes-requested round: dispositions, a fix round, a re-review.
1785
+ const notes: CoordinatorNote[] = [roundNote(round, "checks_failed")];
1786
+ if (next.reviewRounds >= next.input.caps.maxRounds)
1787
+ return end(
1788
+ next,
1789
+ { kind: "round_cap", maxRounds: next.input.caps.maxRounds, reviewRounds: next.reviewRounds },
1790
+ notes,
1791
+ );
1792
+ return enterRound(next, { index: round.index, kind: "findings" }, notes);
1793
+ }
1794
+ case "pending":
1795
+ // A head still pending or unreadable at the ask's end proceeds — the
1796
+ // ending's facts read names what is still pending, and the merge door
1797
+ // (under `merge: runner`) is the guard that never merges over it.
1798
+ if (s.clock - p.since >= p.waitMs) return approveOutcome(s, []);
1799
+ return wait({});
1800
+ case "grace":
1801
+ return wait({ graced: true });
1802
+ case "green":
1803
+ // Green (or none reported after the grace): today's path, no wait added.
1804
+ return approveOutcome(s, []);
1584
1805
  }
1585
- // No check reported at the head: one chunk of grace — the first check starts
1586
- // within minutes where CI exists — then the round proceeds, so a repository
1587
- // without CI costs one chunk per round and never idles (record 0055).
1588
- if (checks.total === 0 && !p.graced) return wait({ graced: true });
1589
- // Green (or none reported after the grace): today's path, no wait added.
1590
- return approveOutcome(s, []);
1591
1806
  }
1592
1807
 
1593
1808
  /** An approve past the checks read: merge_ready for a person, the merge door
@@ -1657,10 +1872,28 @@ function settlePrCheck(s: UnitPipelineState, phase: Extract<Phase, { at: "pr-che
1657
1872
  // A dead child left nothing on the branch to recover: the unit ends with
1658
1873
  // the child's own reason — never the budget clip.
1659
1874
  if (phase.dead === "interrupted")
1660
- return end(s, { kind: "interrupted", round, runId: phase.runId, reviewRounds: s.reviewRounds }, [
1661
- roundNote(round, "aborted"),
1662
- ]);
1875
+ return end(
1876
+ s,
1877
+ {
1878
+ kind: "interrupted",
1879
+ round,
1880
+ runId: phase.runId,
1881
+ reviewRounds: s.reviewRounds,
1882
+ ...(phase.cause !== undefined ? { cause: phase.cause } : {}),
1883
+ },
1884
+ [roundNote(round, "aborted")],
1885
+ );
1663
1886
  if (phase.dead === "failed") {
1887
+ // A provider transient with nothing pushed is not the child's failure
1888
+ // (issue 1932): the ledger row and the branch are untouched, so round 0
1889
+ // is re-run once — a fresh attempt under fresh step names — and only a
1890
+ // second transient in the same round is the ending, named `transient`.
1891
+ if (phase.transient && round.kind === "coding" && pr.unrecovered === "no_commits") {
1892
+ if ((round.attempt ?? 1) < 2) return enterRound(s, { ...round, attempt: 2 }, [roundNote(round, "transient")]);
1893
+ return end(s, { kind: "transient", round, runId: phase.runId, reviewRounds: s.reviewRounds }, [
1894
+ roundNote(round, "transient"),
1895
+ ]);
1896
+ }
1664
1897
  // The abort repeats the bot's reason for recovering nothing, and claims
1665
1898
  // no more than the answer carried.
1666
1899
  const why =
@@ -1806,9 +2039,17 @@ function roundOnOpenPr(
1806
2039
  // ends with the child's own reason — the ship-restart note for a bot
1807
2040
  // roll, the failure for a failed run — never as the round's inaction.
1808
2041
  if (phase.dead === "interrupted")
1809
- return end(next, { kind: "interrupted", round, runId: phase.runId, reviewRounds: next.reviewRounds }, [
1810
- roundNote(round, "aborted"),
1811
- ]);
2042
+ return end(
2043
+ next,
2044
+ {
2045
+ kind: "interrupted",
2046
+ round,
2047
+ runId: phase.runId,
2048
+ reviewRounds: next.reviewRounds,
2049
+ ...(phase.cause !== undefined ? { cause: phase.cause } : {}),
2050
+ },
2051
+ [roundNote(round, "aborted")],
2052
+ );
1812
2053
  if (phase.dead === "failed")
1813
2054
  return end(
1814
2055
  next,
@@ -1967,6 +2208,12 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
1967
2208
  round: p.round,
1968
2209
  reviewRounds: s.reviewRounds,
1969
2210
  });
2211
+ case "stopped":
2212
+ // The hosted parent's hard stop (record 0060; issue 1924): the spawn
2213
+ // was refused over the stop mark, so the unit ends stopped here.
2214
+ return end(clocked, { kind: "stopped", mode: "hard", round: p.round, reviewRounds: s.reviewRounds }, [
2215
+ roundNote(p.round, "stopped"),
2216
+ ]);
1970
2217
  case "failed":
1971
2218
  return end(clocked, {
1972
2219
  kind: "aborted",
@@ -1998,25 +2245,62 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
1998
2245
  case "read": {
1999
2246
  const r = ret as Extract<StepReturn, { type: "read-record" }>;
2000
2247
  if (!r.run.finished)
2248
+ // `restartedAs` (issues 1903/1876): the child's container was replaced
2249
+ // and it restarted from its request as a new run — the round carries on
2250
+ // waiting on the successor, and the unit never ends over a resume that
2251
+ // succeeded. The next wait and read follow the successor's id.
2001
2252
  return {
2002
2253
  state: {
2003
2254
  ...clocked,
2004
- phase: { at: "wait", round: p.round, runId: p.runId, n: p.n + 1, until: p.until },
2255
+ phase: { at: "wait", round: p.round, runId: r.restartedAs ?? p.runId, n: p.n + 1, until: p.until },
2005
2256
  },
2006
2257
  notes: [],
2007
2258
  };
2259
+ // The hosted parent's hard stop landed while this child ran (record 0060;
2260
+ // issue 1924): the unit ends stopped as the child ends, whatever the
2261
+ // child's own status — the runner runs nothing more of it.
2262
+ if (r.stopped === true)
2263
+ return end(
2264
+ clocked,
2265
+ {
2266
+ kind: "stopped",
2267
+ mode: "hard",
2268
+ round: p.round,
2269
+ reviewRounds: s.reviewRounds,
2270
+ ...(r.run.finalReply !== undefined ? { finalReply: r.run.finalReply } : {}),
2271
+ },
2272
+ [roundNote(p.round, "stopped")],
2273
+ );
2008
2274
  if (r.run.status === "interrupted") {
2275
+ const cause = r.run.interruption;
2009
2276
  // A dead CODING child may have pushed before the ledger closed it: the
2010
2277
  // pr-check recovers the branch. A review child has nothing on the
2011
2278
  // branch to recover, so its interruption still ends the unit at once.
2012
2279
  if (p.round.kind !== "review")
2013
2280
  return {
2014
- state: { ...clocked, phase: { at: "pr-check", round: p.round, runId: p.runId, dead: "interrupted" } },
2281
+ state: {
2282
+ ...clocked,
2283
+ phase: {
2284
+ at: "pr-check",
2285
+ round: p.round,
2286
+ runId: p.runId,
2287
+ dead: "interrupted",
2288
+ ...(cause !== undefined ? { cause } : {}),
2289
+ },
2290
+ },
2015
2291
  notes: [],
2016
2292
  };
2017
- return end(clocked, { kind: "interrupted", round: p.round, runId: p.runId, reviewRounds: s.reviewRounds }, [
2018
- roundNote(p.round, "aborted"),
2019
- ]);
2293
+ return end(
2294
+ clocked,
2295
+ {
2296
+ kind: "interrupted",
2297
+ round: p.round,
2298
+ runId: p.runId,
2299
+ reviewRounds: s.reviewRounds,
2300
+ ...(cause !== undefined ? { cause } : {}),
2301
+ },
2302
+ [roundNote(p.round, "aborted")],
2303
+ );
2020
2304
  }
2021
2305
  return p.round.kind === "review"
2022
2306
  ? settleReview(clocked, p.round, r.run)
@@ -2064,27 +2348,81 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
2064
2348
  : end(clocked, { kind: "merged", by: "runner", pr: p.pr, sha: r.sha, reviewRounds: s.reviewRounds });
2065
2349
  if (r.outcome === "refused")
2066
2350
  return end(clocked, { kind: "merge_refused", pr: p.pr, reason: r.reason, reviewRounds: s.reviewRounds });
2351
+ if (r.outcome === "removed") {
2352
+ // The queue removed the pull request — a failing check in the queue, a
2353
+ // conflict: the removal reason becomes a finding of the round, like a
2354
+ // red check does through the checks step, and a fix round follows
2355
+ // (issue 2011). With no review run to brief the fix from (a resume
2356
+ // straight at the merge decision) a person decides, the refusal
2357
+ // carrying the queue's own reason.
2358
+ const round: RoundRef = { index: s.reviewRounds, kind: "findings" };
2359
+ if (s.reviewRunByRound[round.index] === undefined)
2360
+ return end(clocked, {
2361
+ kind: "merge_refused",
2362
+ pr: p.pr,
2363
+ reason: `the merge queue removed the pull request: ${r.reason}`,
2364
+ reviewRounds: s.reviewRounds,
2365
+ });
2366
+ const findings = [...(s.findingsByRound[round.index] ?? []), queueRemovalFinding(r.reason)];
2367
+ const next: UnitPipelineState = {
2368
+ ...clocked,
2369
+ findingsByRound: { ...s.findingsByRound, [round.index]: findings },
2370
+ };
2371
+ const notes: CoordinatorNote[] = [roundNote(round, "dequeued")];
2372
+ if (next.reviewRounds >= next.input.caps.maxRounds)
2373
+ return end(
2374
+ next,
2375
+ { kind: "round_cap", maxRounds: next.input.caps.maxRounds, reviewRounds: next.reviewRounds },
2376
+ notes,
2377
+ );
2378
+ return enterRound(next, round, notes);
2379
+ }
2067
2380
  const waited = r.at - p.since;
2068
2381
  if (waited >= p.waitMs)
2069
2382
  return end(clocked, {
2070
2383
  kind: "merge_refused",
2071
2384
  pr: p.pr,
2072
- reason: `still pending after ${Math.round(waited / MIN)} minutes (${r.reason})`,
2385
+ reason:
2386
+ r.outcome === "enqueued"
2387
+ ? `still in the merge queue after ${Math.round(waited / MIN)} minutes (${r.reason}) — the queue merges it on its own; a re-issued plan finds it merged`
2388
+ : `still pending after ${Math.round(waited / MIN)} minutes (${r.reason})`,
2073
2389
  reviewRounds: s.reviewRounds,
2074
2390
  });
2391
+ // `enqueued` waits like `pending`, marked so every later ask reads the
2392
+ // queue's outcome; the boundary is noted once, when the queue takes it.
2393
+ const queued = r.outcome === "enqueued" ? true : p.queued;
2075
2394
  return {
2076
2395
  state: {
2077
2396
  ...clocked,
2078
- phase: { at: "merge-wait", pr: p.pr, headSha: p.headSha, n: p.n, since: p.since, waitMs: p.waitMs },
2397
+ phase: {
2398
+ at: "merge-wait",
2399
+ pr: p.pr,
2400
+ headSha: p.headSha,
2401
+ n: p.n,
2402
+ since: p.since,
2403
+ waitMs: p.waitMs,
2404
+ ...(queued === true ? { queued: true as const } : {}),
2405
+ },
2079
2406
  },
2080
- notes: [],
2407
+ notes:
2408
+ r.outcome === "enqueued" && p.queued !== true
2409
+ ? [roundNote({ index: s.reviewRounds, kind: "review" }, "enqueued")]
2410
+ : [],
2081
2411
  };
2082
2412
  }
2083
2413
  case "merge-wait":
2084
2414
  return {
2085
2415
  state: {
2086
2416
  ...s,
2087
- phase: { at: "merge", pr: p.pr, headSha: p.headSha, n: p.n + 1, since: p.since, waitMs: p.waitMs },
2417
+ phase: {
2418
+ at: "merge",
2419
+ pr: p.pr,
2420
+ headSha: p.headSha,
2421
+ n: p.n + 1,
2422
+ since: p.since,
2423
+ waitMs: p.waitMs,
2424
+ ...(p.queued === true ? { queued: true as const } : {}),
2425
+ },
2088
2426
  },
2089
2427
  notes: [],
2090
2428
  };
@@ -2210,13 +2548,16 @@ function mergeReadyHeadline(rounds: string, url: string, base: string, facts: Me
2210
2548
  export function renderUnitReport(
2211
2549
  s: UnitPipelineState,
2212
2550
  facts?: MergeReadyFacts,
2213
- /** The level the report speaks at (routing-and-config item 28). The default
2214
- * is the full report — the row's and the board's copy; the thread's copy is
2215
- * rendered at the request's level, where the asides about the machinery
2216
- * (the level in force, the grant, the write-up pointer, the budget split,
2217
- * a segment boundary) are `verbose` and the outcome, the verdict, the
2218
- * findings left below the gate, the declined ones and what to do next are
2219
- * everyone's. */
2551
+ /** The level the report speaks at (routing-and-config item 28, record
2552
+ * 0066). The default is the full report — the row's and the board's copy;
2553
+ * the thread's copy is rendered at the request's level, where only the
2554
+ * outcome is everyone's: ONE line in the user's words — `✅ Merge-ready
2555
+ * after 2 review rounds: <url>`, `Held: <the human-gated row>`, `Stopped`,
2556
+ * `Round cap reached: <counts>` — and the verdict, the findings left below
2557
+ * the gate, the declined ones, the remaining-gate sentence, the level in
2558
+ * force, the grant, the write-up pointer, the budget split, the re-issue
2559
+ * prompt and a segment boundary are `verbose` asides; the full detail
2560
+ * stays on the pull request, where the round routes put it. */
2220
2561
  verbosity: Verbosity = "verbose",
2221
2562
  ): string {
2222
2563
  const e = s.ending;
@@ -2258,11 +2599,11 @@ export function renderUnitReport(
2258
2599
  return `✅ Already merged: ${e.pr.url} (merge commit \`${e.sha.slice(0, 7)}\`, merged ${e.mergedAt}) — the pull request heading \`${s.input.unit.branch}\` was merged before this attempt reached it, by a person or by an earlier attempt of this plan; the runner merged nothing. The unit is done and its dependents start on a base that carries it.`;
2259
2600
  return [
2260
2601
  `✅ Merged after ${rounds}: ${e.pr.url} (squash \`${e.sha.slice(0, 7)}\`) — merged by the plan runner under \`plan:merge\`: the review approved at this head and the guards were green.`,
2261
- verdictLine,
2602
+ aside(verdictLine),
2262
2603
  aside(levelLine),
2263
2604
  aside(grantLine),
2264
- skippedLine,
2265
- declinedLine,
2605
+ aside(skippedLine),
2606
+ aside(declinedLine),
2266
2607
  ]
2267
2608
  .filter(Boolean)
2268
2609
  .join("\n");
@@ -2276,22 +2617,48 @@ export function renderUnitReport(
2276
2617
  facts?.merged
2277
2618
  ? `✅ Merge-ready after ${rounds}: ${e.pr.url}`
2278
2619
  : mergeReadyHeadline(rounds, e.pr.url, s.input.base, facts),
2279
- verdictLine,
2620
+ aside(verdictLine),
2280
2621
  aside(levelLine),
2281
2622
  aside(grantLine),
2282
- skippedLine,
2283
- declinedLine,
2623
+ aside(skippedLine),
2624
+ aside(declinedLine),
2284
2625
  // What the driver read at the approved head when it composed this
2285
2626
  // ending (agent-ship item 9): a merge that already happened is named
2286
2627
  // as such, else the pull request's own auto-merge fact, else the gate.
2287
- facts?.merged
2288
- ? `Already merged: ${e.pr.url} (merge commit \`${facts.merged.sha.slice(0, 7)}\`, merged ${facts.merged.mergedAt}) — auto-merge or a person merged it after the approval; the runner merged nothing.`
2289
- : facts?.autoMergeEnabled
2290
- ? "Auto-merge is on for this pull request: the approval merges it once checks pass."
2291
- : "Remaining gate: a person's merge — the runner merges only when the instance's `merge` field says runner, and ship never approves.",
2628
+ // A `verbose` aside like the rest — the quiet thread copy is the
2629
+ // headline alone (record 0066).
2630
+ aside(
2631
+ facts?.merged
2632
+ ? `Already merged: ${e.pr.url} (merge commit \`${facts.merged.sha.slice(0, 7)}\`, merged ${facts.merged.mergedAt}) — auto-merge or a person merged it after the approval; the runner merged nothing.`
2633
+ : facts?.autoMergeEnabled
2634
+ ? "Auto-merge is on for this pull request: the approval merges it once checks pass."
2635
+ : "Remaining gate: a person's merge — the runner merges only when the instance's `merge` field says runner, and ship never approves.",
2636
+ ),
2292
2637
  ]
2293
2638
  .filter(Boolean)
2294
2639
  .join("\n");
2640
+ case "held": {
2641
+ // The person's next step is the report's whole point (issue 1990): the
2642
+ // human-gated rows are named with the reviewer's own words, and the
2643
+ // re-issue line says the attempt resumes at the review round — item 10's
2644
+ // resume path — once the receipt stands on the pull request. That path
2645
+ // fires only when the invocation carries NO new task text: re-issuing
2646
+ // with the task would ADOPT the pull request and run a coding round
2647
+ // first (item 10), the very round this ending exists to avoid — so the
2648
+ // held case renders its own re-issue line instead of the shared one.
2649
+ const rows = e.findings.map((f) => `${f.id} (${f.severity}) — ${f.title}`).join("; ");
2650
+ // The quiet copy is the ending in the user's words (record 0066): the
2651
+ // held row named, the pull request linked, nothing about the machinery.
2652
+ if (!shows(verbosity, "verbose")) return `⏸️ Held: ${rows}${e.pr !== undefined ? ` — ${e.pr.url}` : ""}`;
2653
+ const heldReissue = s.input.generated
2654
+ ? `To continue, re-issue \`agent:ship\` in this thread with only the PR URL${e.pr !== undefined ? ` (${e.pr.url})` : ""} — no new task text.`
2655
+ : reissue;
2656
+ return join([
2657
+ `⏸️ ${e.verdict === "approve" ? "Approved but held" : "Changes requested but held"} after ${rounds}${e.pr !== undefined ? `: ${e.pr.url}` : ""} — every finding of review round ${e.round.index} is human-gated, a receipt only a person can produce: ${rows}. No fix round was opened: a coding child cannot produce the receipt.`,
2658
+ levelLine,
2659
+ `Next step: produce the receipt each finding names and post it on the pull request. ${heldReissue} The re-issued attempt resumes at the review round — no coding round runs first.`,
2660
+ ]);
2661
+ }
2295
2662
  case "merge_refused":
2296
2663
  // The approved work is on the branch, so the remedy is a person's hand
2297
2664
  // merge, never a re-run: a seeded plan re-issued afterwards finds the
@@ -2299,22 +2666,39 @@ export function renderUnitReport(
2299
2666
  // `already_landed`) and moves on to the dependents. The generated
2300
2667
  // plan's line already says to re-issue with the PR URL, which takes the
2301
2668
  // same recognition path.
2669
+ // The quiet copy: the outcome in the user's words with the person's
2670
+ // remedy — the hand merge is what a person must act on (record 0066).
2671
+ if (!shows(verbosity, "verbose")) return `⚠️ Not merged: ${e.reason} — ${e.pr.url}`;
2302
2672
  return join([
2303
2673
  `⚠️ The review approved ${e.pr.url} but the runner did not merge it: ${e.reason}. A person decides what becomes of the pull request.`,
2304
2674
  s.input.generated
2305
2675
  ? reissue
2306
2676
  : `The approved work is on the branch: rebase or fix it, push, and merge it by hand. Then re-issue the plan naming the remaining units — a unit whose pull request has merged is recognized and not run again, and its dependents start from there.`,
2307
2677
  ]);
2308
- case "round_cap":
2309
- return join([
2310
- `🧢 Ship stopped at a cap: the ${e.maxRounds}-round cap — no approval after ${rounds}.${prLine}`,
2311
- splitReport(s),
2312
- reissue,
2313
- ]);
2678
+ case "round_cap": {
2679
+ // The quiet copy counts the last review's open findings, only the
2680
+ // non-zero severities (record 0066): `Round cap reached: 2 blockers, 1 major`.
2681
+ if (!shows(verbosity, "verbose")) {
2682
+ const counts = severityCounts(s.findingsByRound[e.reviewRounds] ?? []);
2683
+ return `🧢 Round cap reached${counts ? `: ${counts}` : ""}${prUrl !== undefined ? ` — ${prUrl}` : ""}`;
2684
+ }
2685
+ // The cap bounds fix rounds, never the terminal steps (issue 2023): an
2686
+ // approval in the last allowed round still runs the checks step and the
2687
+ // merge, so a round_cap after an approve means the checks (or the merge
2688
+ // queue) failed at the approved head with no fix round left — and the
2689
+ // report names those findings instead of claiming no approval landed.
2690
+ const failedChecks = checkFindingsOf(s.findingsByRound[s.reviewRounds]);
2691
+ const headline =
2692
+ failedChecks.length > 0
2693
+ ? `🧢 Ship stopped at a cap: the ${e.maxRounds}-round cap — the review of round ${e.reviewRounds} approved, but ${failedChecks.map((f) => `\`${f.id}\``).join(", ")} failed at the approved head and no fix round remains.${prLine}`
2694
+ : `🧢 Ship stopped at a cap: the ${e.maxRounds}-round cap — no approval after ${rounds}.${prLine}`;
2695
+ return join([headline, splitReport(s), reissue]);
2696
+ }
2314
2697
  case "wall_clock_cap":
2698
+ if (!shows(verbosity, "verbose")) return `🧢 Out of budget — no approval after ${rounds}.${prLine}`;
2315
2699
  return join([
2316
2700
  `🧢 Ship stopped at a cap: the remaining pipeline time (~${Math.max(0, Math.round(e.remainingMs / MIN))} min of the ${s.input.caps.maxMinutes}-minute budget) cannot hold another round${e.refused ? ` (the ${e.refused.round} round would get ${e.refused.minutes} min, under its floor of ${e.refused.floor})` : ""} — no approval after ${rounds}.${prLine}`,
2317
- aside(budgetSplitLine(e.spent, s.input.caps.maxMinutes)),
2701
+ budgetSplitLine(e.spent, s.input.caps.maxMinutes),
2318
2702
  splitReport(s),
2319
2703
  reissue,
2320
2704
  ]);
@@ -2322,17 +2706,16 @@ export function renderUnitReport(
2322
2706
  return join([
2323
2707
  `⏳ Review pending: the coding child shipped ${e.pr.url}${e.headSha !== undefined ? ` (head \`${e.headSha.slice(0, 7)}\`)` : ""} but the remaining pipeline time cannot hold the review round — the work stands, only the review is missing. The next attempt starts at the review round while the pull request still heads at the child's own last push.`,
2324
2708
  aside(budgetSplitLine(e.spent, s.input.caps.maxMinutes)),
2325
- reissue,
2709
+ aside(reissue),
2326
2710
  ]);
2327
2711
  case "stopped":
2712
+ if (!shows(verbosity, "verbose")) return `${e.mode === "hard" ? "⛔" : "⏹"} Stopped.${prLine}`;
2328
2713
  return join([
2329
2714
  `${e.mode === "hard" ? "⛔" : "⏹"} Ship stopped by operator (${e.mode} stop) after ${rounds}.${prLine}`,
2330
- aside(
2331
- writeUpPointer(
2332
- s,
2333
- e.round.kind,
2334
- e.round.kind === "review" ? s.reviewRunByRound[e.round.index] : s.lastCodingRunId,
2335
- ),
2715
+ writeUpPointer(
2716
+ s,
2717
+ e.round.kind,
2718
+ e.round.kind === "review" ? s.reviewRunByRound[e.round.index] : s.lastCodingRunId,
2336
2719
  ),
2337
2720
  e.postedReview
2338
2721
  ? "ℹ️ A changes-requested review was posted this round before the stop — its findings stand on the PR."
@@ -2340,14 +2723,13 @@ export function renderUnitReport(
2340
2723
  reissue,
2341
2724
  ]);
2342
2725
  case "aborted":
2726
+ if (!shows(verbosity, "verbose")) return `⚠️ Aborted after ${rounds}: ${e.reason}${prLine}`;
2343
2727
  return join([
2344
2728
  e.reason,
2345
- aside(
2346
- writeUpPointer(
2347
- s,
2348
- e.round?.kind ?? "coding",
2349
- e.round?.kind === "review" ? s.reviewRunByRound[e.round.index] : s.lastCodingRunId,
2350
- ),
2729
+ writeUpPointer(
2730
+ s,
2731
+ e.round?.kind ?? "coding",
2732
+ e.round?.kind === "review" ? s.reviewRunByRound[e.round.index] : s.lastCodingRunId,
2351
2733
  ),
2352
2734
  e.renewal !== undefined ? `🔁 Not renewed: ${e.renewal.line}.` : undefined,
2353
2735
  `⚠️ Ship aborted after ${rounds}.`,
@@ -2363,19 +2745,30 @@ export function renderUnitReport(
2363
2745
  `🔁 Segment ${e.segment - 1} ended at its lease with the unit unfinished — ${e.line}. Segment ${e.segment} opens in this thread${e.from !== undefined ? ` from \`${e.from.slice(0, 7)}\`` : ""} under a fresh ${s.input.caps.maxMinutes}-minute lease, with this segment's write-up as its request; ${e.renewalsLeft} renewal${e.renewalsLeft === 1 ? "" : "s"} remain${e.spendUsd !== null ? `, $${e.spendUsd.toFixed(2)} spent so far` : ""}.`,
2364
2746
  aside(budgetSplitLine(e.spent, s.input.caps.maxMinutes)),
2365
2747
  ]);
2748
+ case "transient":
2749
+ if (!shows(verbosity, "verbose"))
2750
+ return `⚠️ Aborted after ${rounds}: the model provider failed twice; re-issue once it settles.${prLine}`;
2751
+ return join([
2752
+ `⚠️ The coding child of round ${e.round.index} (run ${e.runId}) died on a provider transient — a model-gateway 5xx, a cut stream or a gateway timeout past the harness's retry ladder — with nothing pushed, after the round was already re-run once for the same reason. The task itself was never the problem.`,
2753
+ writeUpPointer(s, e.round.kind, s.lastCodingRunId),
2754
+ `⚠️ Ship ended after ${rounds}; re-issue once the provider settles.`,
2755
+ reissue,
2756
+ ]);
2366
2757
  case "no_verdict":
2758
+ if (!shows(verbosity, "verbose"))
2759
+ return `⚠️ No verdict from review round ${e.round.index} — aborted after ${rounds}.${prLine}`;
2367
2760
  return join([
2368
2761
  `⚠️ Review round ${e.round.index} ended without a submitted verdict (budget, refusal, or stop) — ship never converts that into a request for changes, so no findings step ran.`,
2369
- aside(writeUpPointer(s, e.round.kind, s.reviewRunByRound[e.round.index])),
2762
+ writeUpPointer(s, e.round.kind, s.reviewRunByRound[e.round.index]),
2370
2763
  `⚠️ Ship aborted after ${rounds}.`,
2371
2764
  reissue,
2372
2765
  ]);
2373
2766
  case "interrupted":
2374
- return shipInterruptedNote(prUrl);
2767
+ return shipInterruptedNote(prUrl, e.cause);
2375
2768
  case "refused":
2376
2769
  return join([
2377
2770
  `🚫 The ${presetOf(e.round.kind)} child of round ${e.round.index} was refused by the authorize stage (${e.refusal})${e.message ? `: ${e.message}` : ""} — every child is authorized as the requesting user, so the pipeline ends here.`,
2378
- reissue,
2771
+ aside(reissue),
2379
2772
  ]);
2380
2773
  case "idle":
2381
2774
  // The old kind's sentence at this copy's level — the report is unchanged