@coreplane/switchboard 1.255.0 → 1.257.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 (61) hide show
  1. package/dist/assets/config/config.example.yaml +14 -0
  2. package/dist/assets/deploy/cloudflare-memory/worker.ts +340 -7
  3. package/dist/assets/deploy/cloudflare-resident/drain.ts +100 -1
  4. package/dist/assets/deploy/cloudflare-resident/worker.ts +227 -34
  5. package/dist/assets/package-lock.json +3 -3
  6. package/dist/assets/package.json +5 -3
  7. package/dist/assets/project.json +17 -9
  8. package/dist/assets/source.json +3 -3
  9. package/dist/assets/src/agents/registry.ts +55 -1
  10. package/dist/assets/src/core/authz/grants.ts +12 -0
  11. package/dist/assets/src/core/authz/policy.ts +15 -0
  12. package/dist/assets/src/core/budgets.ts +51 -17
  13. package/dist/assets/src/core/coordinator/contract.ts +45 -5
  14. package/dist/assets/src/core/coordinator/driver.ts +17 -4
  15. package/dist/assets/src/core/delivery.ts +8 -5
  16. package/dist/assets/src/core/pipelineStanding.ts +57 -0
  17. package/dist/assets/src/core/plane/decide.ts +468 -12
  18. package/dist/assets/src/core/plane/findings.ts +120 -0
  19. package/dist/assets/src/core/reviewVerdict.ts +49 -0
  20. package/dist/assets/src/core/runEvents.ts +41 -14
  21. package/dist/assets/src/core/runFriction.ts +16 -0
  22. package/dist/assets/src/core/runLedger/types.ts +16 -3
  23. package/dist/assets/src/core/runRecord.ts +12 -0
  24. package/dist/assets/src/core/ship/contract.ts +34 -32
  25. package/dist/assets/src/core/ship/coordinator.ts +291 -95
  26. package/dist/assets/src/core/ship/renewal.ts +19 -17
  27. package/dist/assets/src/core/trace/attrs.ts +1 -1
  28. package/dist/assets/src/core/untrusted.ts +35 -0
  29. package/dist/assets/src/execution/residentCredentials.ts +7 -4
  30. package/dist/assets/web/dist/.vite/manifest.json +82 -57
  31. package/dist/assets/web/dist/assets/HomePage-DJqJQzrL.js +1 -0
  32. package/dist/assets/web/dist/assets/{PendingTurnRow-DGINv9XT.js → PendingTurnRow-fhKqKUVv.js} +1 -1
  33. package/dist/assets/web/dist/assets/PlanePage-Bood0hNO.js +1 -0
  34. package/dist/assets/web/dist/assets/{ResidentDetailPage-D_RD6wLo.js → ResidentDetailPage-DOe1HtUC.js} +1 -1
  35. package/dist/assets/web/dist/assets/{ResidentsIndexPage-BLkSuCxo.js → ResidentsIndexPage-TyCGjXM4.js} +1 -1
  36. package/dist/assets/web/dist/assets/RunFoldRow-C_4t-sit.js +1 -0
  37. package/dist/assets/web/dist/assets/RunRoutePage-BNepPMRX.js +9 -0
  38. package/dist/assets/web/dist/assets/RunsIndexPage-jZnMw1mM.js +1 -0
  39. package/dist/assets/web/dist/assets/{ScheduledPage-3aYsDf-q.js → ScheduledPage-BkNoudDP.js} +1 -1
  40. package/dist/assets/web/dist/assets/{SettingsPage-DG-p5Xy1.js → SettingsPage-COvWGXl-.js} +1 -1
  41. package/dist/assets/web/dist/assets/SilentTurn-DNAANKgN.js +2 -0
  42. package/dist/assets/web/dist/assets/SlackMark-CtiHrNjr.js +1 -0
  43. package/dist/assets/web/dist/assets/{StatusDot-CEnGlyAL.js → StatusDot-BgmkSV0S.js} +1 -1
  44. package/dist/assets/web/dist/assets/{Tooltip-CiunVowT.js → Tooltip-DEUPuCPW.js} +1 -1
  45. package/dist/assets/web/dist/assets/UnitRoutePage-DNhdzxvY.js +1 -0
  46. package/dist/assets/web/dist/assets/{dist-luhv3YSo.js → dist-CF3jz9LM.js} +1 -1
  47. package/dist/assets/web/dist/assets/durationTone-CVpX_yIk.js +1 -0
  48. package/dist/assets/web/dist/assets/main-7SAujY_s.js +28 -0
  49. package/dist/assets/web/dist/assets/main-DGC6WFqS.css +1 -0
  50. package/dist/assets/web/dist/assets/{sseReplay-DE6wv1Ua.js → sseReplay-BRJEIh83.js} +1 -1
  51. package/dist/cli.js +2941 -1116
  52. package/package.json +1 -1
  53. package/dist/assets/web/dist/assets/HomePage-Be7jLLnU.js +0 -3
  54. package/dist/assets/web/dist/assets/PlanePage-JEj-lqgz.js +0 -1
  55. package/dist/assets/web/dist/assets/RunFoldRow-CfuZqf_O.js +0 -1
  56. package/dist/assets/web/dist/assets/RunRoutePage-CmWYGR36.js +0 -9
  57. package/dist/assets/web/dist/assets/RunsIndexPage-DW-HHuZa.js +0 -1
  58. package/dist/assets/web/dist/assets/UnitRoutePage-iOYnmTcQ.js +0 -1
  59. package/dist/assets/web/dist/assets/durationTone-U_rOqo6r.js +0 -1
  60. package/dist/assets/web/dist/assets/main-C4QfwybO.css +0 -1
  61. package/dist/assets/web/dist/assets/main-mAKx_zo9.js +0 -28
@@ -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 --------------------------------------------------------------------------------
@@ -473,9 +523,10 @@ export type CoordinatorAction =
473
523
  * over an already-shipped pull request. */
474
524
  entry?: true;
475
525
  /** Set after a coding child died: the bot opens the pull request from the
476
- * pushed branch itself (title from the unit, body from this run's
477
- * submitted description when the record holds one) instead of answering
478
- * `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. */
479
530
  recover?: { runId: string };
480
531
  /** The pull request the machine has adopted (`state.pr`), when it holds
481
532
  * one: the child may have worked that pull request's own head branch,
@@ -536,6 +587,10 @@ export type ChildFacts =
536
587
  * `provider_transient` marks a child a gateway 5xx, a cut stream or a
537
588
  * gateway timeout ended past the harness's retry ladder (issue 1932). */
538
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;
539
594
  };
540
595
 
541
596
  /** What heads the unit's branch on GitHub: nothing, an open pull request, or —
@@ -599,7 +654,10 @@ export type StepReturn =
599
654
  | { type: "wait"; step: string; outcome: "event" | "timeout" }
600
655
  // `stopped` on a read: the instance row carries the hard stop's mark, so a
601
656
  // finished child ends its unit `stopped` whatever the child's own status.
602
- | { type: "read-record"; step: string; run: ChildFacts; stopped?: true; at: number }
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 }
603
661
  | { type: "pr-check"; step: string; pr: PrCheck; at: number }
604
662
  | { type: "merge"; step: string; outcome: "merged"; sha: string; at: number }
605
663
  // The door found the pull request already merged after the approval — auto-merge
@@ -612,7 +670,7 @@ export type StepReturn =
612
670
  * not be read, which the machine treats as pending (record 0055). A retry
613
671
  * ask answers `retried` instead: whether the bot dispatched the re-run —
614
672
  * false means there is nothing to wait for at the unchanged head. */
615
- | { type: "checks"; step: string; checks?: RoundChecks; retried?: boolean; at: number }
673
+ | { type: "checks"; step: string; checks?: RoundChecks; draft?: boolean; retried?: boolean; at: number }
616
674
  | { type: "wait-checks"; step: string; outcome: "event" | "timeout" }
617
675
  | { type: "sleep"; step: string };
618
676
 
@@ -627,11 +685,16 @@ export interface CheckFailure {
627
685
  flakeSuspect?: boolean;
628
686
  }
629
687
 
630
- /** The check runs at the reviewed head as the round's checks step reads them. */
688
+ /** The check runs at the reviewed head as the round's checks step reads them.
689
+ * `expected` names checks the head must still gain — a required check, or the
690
+ * repository's approve workflow whose run does not exist yet (issue 2063):
691
+ * the base's required contexts not among the reported runs. The table reads
692
+ * an expected-but-unreported check exactly as a pending one. */
631
693
  export interface RoundChecks {
632
694
  total: number;
633
695
  pending: string[];
634
696
  failed: CheckFailure[];
697
+ expected?: string[];
635
698
  }
636
699
 
637
700
  /** A failed check as a finding of the round (record 0055): a finding row like
@@ -695,12 +758,24 @@ export type UnitEnding =
695
758
  * review round once the receipt is posted (item 10's resume path). */
696
759
  | {
697
760
  kind: "held";
761
+ cause?: undefined;
698
762
  pr?: PrRef;
699
763
  round: RoundRef;
700
764
  findings: Finding[];
701
765
  verdict: "approve" | "request_changes";
702
766
  reviewRounds: number;
703
767
  }
768
+ /** The pull request is a draft (issue 2063): nothing can merge and a fix
769
+ * round would change nothing, so the checks step waits for the ready event
770
+ * inside its ask and, still a draft at the ask's end, the unit ends held —
771
+ * `held: draft — mark it ready to continue` — never an unnamed exit. */
772
+ | {
773
+ kind: "held";
774
+ cause: "draft";
775
+ pr?: PrRef;
776
+ round: RoundRef;
777
+ reviewRounds: number;
778
+ }
704
779
  | { kind: "merge_refused"; pr: PrRef; reason: string; reviewRounds: number }
705
780
  | { kind: "round_cap"; maxRounds: number; reviewRounds: number }
706
781
  | {
@@ -766,7 +841,7 @@ export type UnitEnding =
766
841
  * `transient` so it reads as a condition beside `checks_failed` and `held`
767
842
  * in the plane's table, never as the child failing on its task. */
768
843
  | { kind: "transient"; round: RoundRef; runId: string; reviewRounds: number }
769
- | { kind: "interrupted"; round: RoundRef; runId: string; reviewRounds: number }
844
+ | { kind: "interrupted"; round: RoundRef; runId: string; reviewRounds: number; cause?: InterruptionCause }
770
845
  | { kind: "refused"; refusal: string; message?: string; round: RoundRef; reviewRounds: number }
771
846
  /** The unit idles instead of ending (record 0051): with the resolved
772
847
  * `ship.idleDays` above zero, `end()` wraps an idling kind — every kind but
@@ -854,7 +929,10 @@ export {
854
929
  type AddressSeveritySource,
855
930
  };
856
931
 
857
- export interface UnitSession {
932
+ /** How far the unit's lease segments have got (decision 0046, the renewable
933
+ * lease): the segment's number, the renewals and dollars spent before it and
934
+ * the continuation facts — a progress counter, nothing conversational. */
935
+ export interface LeaseSegmentProgress {
858
936
  segment: number;
859
937
  renewalsSpent: number;
860
938
  spendUsd: number | null;
@@ -894,7 +972,7 @@ export interface UnitPipelineInput {
894
972
  * session's spend so far, the sha it continues from and the previous
895
973
  * segment's handoff. Every step name of a later segment is prefixed with
896
974
  * it, so the Workflow's durable steps never collide across segments. */
897
- session?: UnitSession;
975
+ session?: LeaseSegmentProgress;
898
976
  /** The instance's mark (agent-ship item 16): a generated one-unit plan — a
899
977
  * `plan` with an id and no `path` — whose unit runs in the requesting
900
978
  * thread and is re-issued with the request's own text, never a plan path. */
@@ -940,6 +1018,8 @@ type Phase =
940
1018
  * pushed: the pr-check recovers a pushed branch by opening its pull
941
1019
  * request; with nothing pushed the unit ends with the child's own reason. */
942
1020
  dead?: "failed" | "interrupted";
1021
+ /** What ended the dead child (issue 1876), for the `interrupted` ending's sentence. */
1022
+ cause?: InterruptionCause;
943
1023
  /** The dead child's record names a provider transient (`failure:
944
1024
  * provider_transient`, issue 1932): with nothing pushed, round 0 is
945
1025
  * re-run once instead of the unit aborting; a second transient in the
@@ -1085,14 +1165,17 @@ const loopOf = (s: UnitPipelineState): Loop => ({ maxRounds: s.input.caps.maxRou
1085
1165
  * A review round `n` and the findings step that follows it share `n`; the
1086
1166
  * module's positions are the loop's own. */
1087
1167
  function roundCarve(s: UnitPipelineState, round: RoundRef): Carve {
1088
- const kind = round.kind === "findings" ? "fix" : round.kind;
1089
- return carve(remainingMs(s), { kind, index: loopPosition(loopOf(s), kind, round.index) }, loopOf(s));
1168
+ return carve(
1169
+ remainingMs(s),
1170
+ { kind: round.kind, index: loopPosition(loopOf(s), round.kind, round.index) },
1171
+ loopOf(s),
1172
+ );
1090
1173
  }
1091
1174
 
1092
1175
  /** The prefix every step of this pipeline is named under: the unit id, and for
1093
1176
  * a renewal's segment the segment too (`U10/s2/…`), so the Workflow's durable
1094
1177
  * step cache never hands segment two the answers of segment one. */
1095
- export const stepPrefixOf = (unit: string, session: UnitSession | undefined): string =>
1178
+ export const stepPrefixOf = (unit: string, session: LeaseSegmentProgress | undefined): string =>
1096
1179
  session !== undefined && session.segment > 1 ? `${unit}/s${session.segment}` : unit;
1097
1180
  const stepPrefix = (s: UnitPipelineState) => stepPrefixOf(s.input.unit.id, s.input.session);
1098
1181
  const roundStep = (s: UnitPipelineState, round: RoundRef) =>
@@ -1623,19 +1706,29 @@ function enterChecks(s: UnitPipelineState, round: RoundRef, notes: CoordinatorNo
1623
1706
  };
1624
1707
  }
1625
1708
 
1626
- /** The order the checks step reads a head in (record 0055, issue 1991):
1627
- * a failed check is the round's answer as soon as it is read, whatever else
1628
- * is still pending — with the flake rule spending its one re-run first when
1629
- * every failure is a suspect — and only a head with no failed check waits on
1630
- * the pending ones; a head with no check reported gets one chunk of grace;
1631
- * the rest is green. One function so the siblings that fold more outcomes
1632
- * into the step (a held ending, a transient round re-run) read the same order. */
1709
+ /** The checks step's decision table over the reviewed head's facts (record
1710
+ * 0055; issues 1991 and 2063) — one function, read top to bottom, so every
1711
+ * sibling that folds more outcomes into the step (a held ending, a transient
1712
+ * round re-run) reads the same order:
1713
+ * (a) any failed check is the round's answer as soon as it is read, whatever
1714
+ * else is still pending — with the flake rule spending its one re-run
1715
+ * first when every failure is a suspect;
1716
+ * (d) no failure and the pull request a draft: the step waits for the ready
1717
+ * event — nothing can merge a draft and a fix round would change nothing;
1718
+ * (b) no failure and a check pending, queued, expected but not yet reported
1719
+ * (`expected`: a required check, or the repository's approve workflow
1720
+ * whose run does not exist yet), or GitHub unreadable: the head waits on
1721
+ * the settled event and is read again;
1722
+ * (—) no check reported at all: one chunk of grace, never more;
1723
+ * (c) every expected check reported green: the round proceeds with no wait. */
1633
1724
  function checksVerdict(
1634
1725
  checks: RoundChecks | undefined,
1635
1726
  p: { retried: boolean; graced: boolean },
1727
+ draft?: boolean,
1636
1728
  ):
1637
1729
  | { kind: "retry"; names: string[] }
1638
1730
  | { kind: "failed"; failed: CheckFailure[] }
1731
+ | { kind: "draft" }
1639
1732
  | { kind: "pending" }
1640
1733
  | { kind: "grace" }
1641
1734
  | { kind: "green" } {
@@ -1649,8 +1742,14 @@ function checksVerdict(
1649
1742
  return { kind: "retry", names: checks.failed.map((f) => f.name) };
1650
1743
  return { kind: "failed", failed: checks.failed };
1651
1744
  }
1652
- // GitHub unreadable answers as pending and is re-read at the chunk's end.
1653
- if (checks === undefined || checks.pending.length > 0) return { kind: "pending" };
1745
+ // A draft outranks the waits: its checks may sit green forever, and only a
1746
+ // person's "ready" changes anything — a red check above still gets its fix
1747
+ // round, since that work stands whether or not the pull request is a draft.
1748
+ if (draft === true) return { kind: "draft" };
1749
+ // GitHub unreadable answers as pending and is re-read at the chunk's end;
1750
+ // an expected check not yet reported (issue 2063) is pending the same way.
1751
+ if (checks === undefined || checks.pending.length > 0 || (checks.expected?.length ?? 0) > 0)
1752
+ return { kind: "pending" };
1654
1753
  // No check reported at the head: one chunk of grace — the first check starts
1655
1754
  // within minutes where CI exists — then the round proceeds, so a repository
1656
1755
  // without CI costs one chunk per round and never idles (record 0055).
@@ -1673,6 +1772,7 @@ function settleChecks(
1673
1772
  p: Extract<Phase, { at: "checks" }>,
1674
1773
  checks: RoundChecks | undefined,
1675
1774
  retried?: boolean,
1775
+ draft?: boolean,
1676
1776
  ): Transition {
1677
1777
  const { round } = p;
1678
1778
  const wait = (over: Partial<Extract<Phase, { at: "checks-wait" }>>): Transition => ({
@@ -1706,7 +1806,7 @@ function settleChecks(
1706
1806
  }
1707
1807
  return wait({ retried: true });
1708
1808
  }
1709
- const verdict = checksVerdict(checks, p);
1809
+ const verdict = checksVerdict(checks, p, draft);
1710
1810
  switch (verdict.kind) {
1711
1811
  case "retry":
1712
1812
  return {
@@ -1731,6 +1831,21 @@ function settleChecks(
1731
1831
  );
1732
1832
  return enterRound(next, { index: round.index, kind: "findings" }, notes);
1733
1833
  }
1834
+ case "draft":
1835
+ // A draft pull request (issue 2063): the unit idles on the settled event
1836
+ // — marking it ready starts the head's check suites, whose completion
1837
+ // fires `checks-settled-<head>` — and the re-read continues through the
1838
+ // table. Still a draft at the ask's end, the unit ends held naming the
1839
+ // draft and the person's next step, never an exit with no cause.
1840
+ if (s.clock - p.since >= p.waitMs)
1841
+ return end(s, {
1842
+ kind: "held",
1843
+ cause: "draft",
1844
+ ...(s.pr !== undefined ? { pr: s.pr } : {}),
1845
+ round: p.round,
1846
+ reviewRounds: s.reviewRounds,
1847
+ });
1848
+ return wait({});
1734
1849
  case "pending":
1735
1850
  // A head still pending or unreadable at the ask's end proceeds — the
1736
1851
  // ending's facts read names what is still pending, and the merge door
@@ -1812,9 +1927,17 @@ function settlePrCheck(s: UnitPipelineState, phase: Extract<Phase, { at: "pr-che
1812
1927
  // A dead child left nothing on the branch to recover: the unit ends with
1813
1928
  // the child's own reason — never the budget clip.
1814
1929
  if (phase.dead === "interrupted")
1815
- return end(s, { kind: "interrupted", round, runId: phase.runId, reviewRounds: s.reviewRounds }, [
1816
- roundNote(round, "aborted"),
1817
- ]);
1930
+ return end(
1931
+ s,
1932
+ {
1933
+ kind: "interrupted",
1934
+ round,
1935
+ runId: phase.runId,
1936
+ reviewRounds: s.reviewRounds,
1937
+ ...(phase.cause !== undefined ? { cause: phase.cause } : {}),
1938
+ },
1939
+ [roundNote(round, "aborted")],
1940
+ );
1818
1941
  if (phase.dead === "failed") {
1819
1942
  // A provider transient with nothing pushed is not the child's failure
1820
1943
  // (issue 1932): the ledger row and the branch are untouched, so round 0
@@ -1832,7 +1955,7 @@ function settlePrCheck(s: UnitPipelineState, phase: Extract<Phase, { at: "pr-che
1832
1955
  pr.unrecovered === "no_commits"
1833
1956
  ? `nothing heads \`${s.input.unit.branch}\`: no commits were pushed, so there was no work to recover`
1834
1957
  : pr.unrecovered === "no_base"
1835
- ? `\`${s.input.unit.branch}\` could not be given a pull request: the instance names no base branch to open it against, so whatever was pushed stays on the branch`
1958
+ ? `\`${s.input.unit.branch}\` could not be given a pull request: the pipeline names no base branch to open it against, so whatever was pushed stays on the branch`
1836
1959
  : `the pr-check found no pull request heading \`${s.input.unit.branch}\`, so nothing was recovered`;
1837
1960
  return end(
1838
1961
  s,
@@ -1971,9 +2094,17 @@ function roundOnOpenPr(
1971
2094
  // ends with the child's own reason — the ship-restart note for a bot
1972
2095
  // roll, the failure for a failed run — never as the round's inaction.
1973
2096
  if (phase.dead === "interrupted")
1974
- return end(next, { kind: "interrupted", round, runId: phase.runId, reviewRounds: next.reviewRounds }, [
1975
- roundNote(round, "aborted"),
1976
- ]);
2097
+ return end(
2098
+ next,
2099
+ {
2100
+ kind: "interrupted",
2101
+ round,
2102
+ runId: phase.runId,
2103
+ reviewRounds: next.reviewRounds,
2104
+ ...(phase.cause !== undefined ? { cause: phase.cause } : {}),
2105
+ },
2106
+ [roundNote(round, "aborted")],
2107
+ );
1977
2108
  if (phase.dead === "failed")
1978
2109
  return end(
1979
2110
  next,
@@ -2169,10 +2300,14 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
2169
2300
  case "read": {
2170
2301
  const r = ret as Extract<StepReturn, { type: "read-record" }>;
2171
2302
  if (!r.run.finished)
2303
+ // `restartedAs` (issues 1903/1876): the child's container was replaced
2304
+ // and it restarted from its request as a new run — the round carries on
2305
+ // waiting on the successor, and the unit never ends over a resume that
2306
+ // succeeded. The next wait and read follow the successor's id.
2172
2307
  return {
2173
2308
  state: {
2174
2309
  ...clocked,
2175
- phase: { at: "wait", round: p.round, runId: p.runId, n: p.n + 1, until: p.until },
2310
+ phase: { at: "wait", round: p.round, runId: r.restartedAs ?? p.runId, n: p.n + 1, until: p.until },
2176
2311
  },
2177
2312
  notes: [],
2178
2313
  };
@@ -2192,17 +2327,35 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
2192
2327
  [roundNote(p.round, "stopped")],
2193
2328
  );
2194
2329
  if (r.run.status === "interrupted") {
2330
+ const cause = r.run.interruption;
2195
2331
  // A dead CODING child may have pushed before the ledger closed it: the
2196
2332
  // pr-check recovers the branch. A review child has nothing on the
2197
2333
  // branch to recover, so its interruption still ends the unit at once.
2198
2334
  if (p.round.kind !== "review")
2199
2335
  return {
2200
- state: { ...clocked, phase: { at: "pr-check", round: p.round, runId: p.runId, dead: "interrupted" } },
2336
+ state: {
2337
+ ...clocked,
2338
+ phase: {
2339
+ at: "pr-check",
2340
+ round: p.round,
2341
+ runId: p.runId,
2342
+ dead: "interrupted",
2343
+ ...(cause !== undefined ? { cause } : {}),
2344
+ },
2345
+ },
2201
2346
  notes: [],
2202
2347
  };
2203
- return end(clocked, { kind: "interrupted", round: p.round, runId: p.runId, reviewRounds: s.reviewRounds }, [
2204
- roundNote(p.round, "aborted"),
2205
- ]);
2348
+ return end(
2349
+ clocked,
2350
+ {
2351
+ kind: "interrupted",
2352
+ round: p.round,
2353
+ runId: p.runId,
2354
+ reviewRounds: s.reviewRounds,
2355
+ ...(cause !== undefined ? { cause } : {}),
2356
+ },
2357
+ [roundNote(p.round, "aborted")],
2358
+ );
2206
2359
  }
2207
2360
  return p.round.kind === "review"
2208
2361
  ? settleReview(clocked, p.round, r.run)
@@ -2212,7 +2365,7 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
2212
2365
  return settlePrCheck(clocked, p, (ret as Extract<StepReturn, { type: "pr-check" }>).pr);
2213
2366
  case "checks": {
2214
2367
  const r = ret as Extract<StepReturn, { type: "checks" }>;
2215
- return settleChecks(clocked, p, r.checks, r.retried);
2368
+ return settleChecks(clocked, p, r.checks, r.retried, r.draft);
2216
2369
  }
2217
2370
  case "checks-wait":
2218
2371
  // The event fired or the chunk elapsed either way the head is read again.
@@ -2431,7 +2584,7 @@ export interface CommitChecksFacts {
2431
2584
  * the line is unchanged. */
2432
2585
  function mergeReadyHeadline(rounds: string, url: string, base: string, facts: MergeReadyFacts | undefined): string {
2433
2586
  if (facts?.mergeableState === "dirty")
2434
- return `⚠️ Approved but not merge-ready after ${rounds}: ${url} — the head conflicts with \`${base}\`: rebase onto \`${base}\`, push, and re-review. The approved work stands.`;
2587
+ return `⚠️ Approved but not merge-ready after ${rounds}: ${url} — the head conflicts with \`${base}\`: \`pulls rebase ${url}\` rebases it onto \`${base}\` (an unchanged patch carries the approval). The approved work stands.`;
2435
2588
  const fixups = facts?.fixupCommits ?? [];
2436
2589
  if (fixups.length > 0)
2437
2590
  return `⚠️ Approved but not merge-ready after ${rounds}: ${url} — ${fixups.length} unsquashed fix-up commit${fixups.length === 1 ? "" : "s"} on the head (${fixups.join("; ")}): squash into the unit's commit, push, and re-review.`;
@@ -2450,13 +2603,16 @@ function mergeReadyHeadline(rounds: string, url: string, base: string, facts: Me
2450
2603
  export function renderUnitReport(
2451
2604
  s: UnitPipelineState,
2452
2605
  facts?: MergeReadyFacts,
2453
- /** The level the report speaks at (routing-and-config item 28). The default
2454
- * is the full report — the row's and the board's copy; the thread's copy is
2455
- * rendered at the request's level, where the asides about the machinery
2456
- * (the level in force, the grant, the write-up pointer, the budget split,
2457
- * a segment boundary) are `verbose` and the outcome, the verdict, the
2458
- * findings left below the gate, the declined ones and what to do next are
2459
- * everyone's. */
2606
+ /** The level the report speaks at (routing-and-config item 28, record
2607
+ * 0066). The default is the full report — the row's and the board's copy;
2608
+ * the thread's copy is rendered at the request's level, where only the
2609
+ * outcome is everyone's: ONE line in the user's words — `✅ Merge-ready
2610
+ * after 2 review rounds: <url>`, `Held: <the human-gated row>`, `Stopped`,
2611
+ * `Round cap reached: <counts>` — and the verdict, the findings left below
2612
+ * the gate, the declined ones, the remaining-gate sentence, the level in
2613
+ * force, the grant, the write-up pointer, the budget split, the re-issue
2614
+ * prompt and a segment boundary are `verbose` asides; the full detail
2615
+ * stays on the pull request, where the round routes put it. */
2460
2616
  verbosity: Verbosity = "verbose",
2461
2617
  ): string {
2462
2618
  const e = s.ending;
@@ -2495,44 +2651,62 @@ export function renderUnitReport(
2495
2651
  switch (e.kind) {
2496
2652
  case "merged":
2497
2653
  if (e.by === "other")
2498
- 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.`;
2654
+ 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 pipeline reached it, by a person or by an earlier pipeline of this plan; the pipeline merged nothing. The unit is done and its dependents start on a base that carries it.`;
2499
2655
  return [
2500
- `✅ 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.`,
2501
- verdictLine,
2656
+ `✅ Merged after ${rounds}: ${e.pr.url} (squash \`${e.sha.slice(0, 7)}\`) — merged by the pipeline under \`plan:merge\`: the review approved at this head and the guards were green.`,
2657
+ aside(verdictLine),
2502
2658
  aside(levelLine),
2503
2659
  aside(grantLine),
2504
- skippedLine,
2505
- declinedLine,
2660
+ aside(skippedLine),
2661
+ aside(declinedLine),
2506
2662
  ]
2507
2663
  .filter(Boolean)
2508
2664
  .join("\n");
2509
2665
  case "already_landed":
2510
2666
  // No compare link, no renewal line, no re-issue prompt: there was
2511
2667
  // nothing to ship, so none of them has a question to answer.
2512
- return `✅ Already on \`${s.input.base}\`: the unit's scope landed before this attempt — ${e.landed.map((l) => `${l.what} (${l.where})`).join("; ")}. The coding child (run ${e.runId}) found it there and pushed nothing of its own: \`${s.input.unit.branch}\` has no commits over \`${s.input.base}\`, so there is no pull request to open or review. The unit is done and its dependents start on a base that carries it.`;
2668
+ return `✅ Already on \`${s.input.base}\`: the unit's scope landed before this pipeline — ${e.landed.map((l) => `${l.what} (${l.where})`).join("; ")}. The coding child (run ${e.runId}) found it there and pushed nothing of its own: \`${s.input.unit.branch}\` has no commits over \`${s.input.base}\`, so there is no pull request to open or review. The unit is done and its dependents start on a base that carries it.`;
2513
2669
  case "merge_ready":
2514
2670
  return [
2515
2671
  // A merge that already happened outranks the checks: there is no head left to gate.
2516
2672
  facts?.merged
2517
2673
  ? `✅ Merge-ready after ${rounds}: ${e.pr.url}`
2518
2674
  : mergeReadyHeadline(rounds, e.pr.url, s.input.base, facts),
2519
- verdictLine,
2675
+ aside(verdictLine),
2520
2676
  aside(levelLine),
2521
2677
  aside(grantLine),
2522
- skippedLine,
2523
- declinedLine,
2678
+ aside(skippedLine),
2679
+ aside(declinedLine),
2524
2680
  // What the driver read at the approved head when it composed this
2525
2681
  // ending (agent-ship item 9): a merge that already happened is named
2526
2682
  // as such, else the pull request's own auto-merge fact, else the gate.
2527
- facts?.merged
2528
- ? `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.`
2529
- : facts?.autoMergeEnabled
2530
- ? "Auto-merge is on for this pull request: the approval merges it once checks pass."
2531
- : "Remaining gate: a person's merge — the runner merges only when the instance's `merge` field says runner, and ship never approves.",
2683
+ // A `verbose` aside like the rest — the quiet thread copy is the
2684
+ // headline alone (record 0066).
2685
+ aside(
2686
+ facts?.merged
2687
+ ? `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 pipeline merged nothing.`
2688
+ : facts?.autoMergeEnabled
2689
+ ? "Auto-merge is on for this pull request: the approval merges it once checks pass."
2690
+ : "Remaining gate: a person's merge — the pipeline merges only when the plan's `merge` setting says so, and ship never approves.",
2691
+ ),
2532
2692
  ]
2533
2693
  .filter(Boolean)
2534
2694
  .join("\n");
2535
2695
  case "held": {
2696
+ // The draft hold (issue 2063): the pull request is a draft, so nothing
2697
+ // can merge and no fix round would change anything — the one line names
2698
+ // the cause and the person's exact next step.
2699
+ if (e.cause === "draft") {
2700
+ const link = e.pr !== undefined ? ` — ${e.pr.url}` : "";
2701
+ if (!shows(verbosity, "verbose")) return `⏸️ Held: draft — mark it ready to continue${link}`;
2702
+ const draftReissue = s.input.generated
2703
+ ? `To continue, mark it ready and re-issue \`agent:ship\` in this thread with only the PR URL${e.pr !== undefined ? ` (${e.pr.url})` : ""} — no new task text; the re-issued pipeline resumes at the review round.`
2704
+ : `To continue, mark it ready; ${reissue.charAt(0).toLowerCase()}${reissue.slice(1)}`;
2705
+ return join([
2706
+ `⏸️ Held after ${rounds}${e.pr !== undefined ? `: ${e.pr.url}` : ""} — the pull request is a draft, so nothing can merge and no fix round would change anything.`,
2707
+ draftReissue,
2708
+ ]);
2709
+ }
2536
2710
  // The person's next step is the report's whole point (issue 1990): the
2537
2711
  // human-gated rows are named with the reviewer's own words, and the
2538
2712
  // re-issue line says the attempt resumes at the review round — item 10's
@@ -2542,13 +2716,16 @@ export function renderUnitReport(
2542
2716
  // first (item 10), the very round this ending exists to avoid — so the
2543
2717
  // held case renders its own re-issue line instead of the shared one.
2544
2718
  const rows = e.findings.map((f) => `${f.id} (${f.severity}) — ${f.title}`).join("; ");
2719
+ // The quiet copy is the ending in the user's words (record 0066): the
2720
+ // held row named, the pull request linked, nothing about the machinery.
2721
+ if (!shows(verbosity, "verbose")) return `⏸️ Held: ${rows}${e.pr !== undefined ? ` — ${e.pr.url}` : ""}`;
2545
2722
  const heldReissue = s.input.generated
2546
2723
  ? `To continue, re-issue \`agent:ship\` in this thread with only the PR URL${e.pr !== undefined ? ` (${e.pr.url})` : ""} — no new task text.`
2547
2724
  : reissue;
2548
2725
  return join([
2549
2726
  `⏸️ ${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.`,
2550
- aside(levelLine),
2551
- `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.`,
2727
+ levelLine,
2728
+ `Next step: produce the receipt each finding names and post it on the pull request. ${heldReissue} The re-issued pipeline resumes at the review round — no coding round runs first.`,
2552
2729
  ]);
2553
2730
  }
2554
2731
  case "merge_refused":
@@ -2558,40 +2735,56 @@ export function renderUnitReport(
2558
2735
  // `already_landed`) and moves on to the dependents. The generated
2559
2736
  // plan's line already says to re-issue with the PR URL, which takes the
2560
2737
  // same recognition path.
2738
+ // The quiet copy: the outcome in the user's words with the person's
2739
+ // remedy — the hand merge is what a person must act on (record 0066).
2740
+ if (!shows(verbosity, "verbose")) return `⚠️ Not merged: ${e.reason} — ${e.pr.url}`;
2561
2741
  return join([
2562
- `⚠️ The review approved ${e.pr.url} but the runner did not merge it: ${e.reason}. A person decides what becomes of the pull request.`,
2742
+ `⚠️ The review approved ${e.pr.url} but the pipeline did not merge it: ${e.reason}. A person decides what becomes of the pull request.`,
2563
2743
  s.input.generated
2564
2744
  ? reissue
2565
2745
  : `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.`,
2566
2746
  ]);
2567
- case "round_cap":
2568
- return join([
2569
- `🧢 Ship stopped at a cap: the ${e.maxRounds}-round cap — no approval after ${rounds}.${prLine}`,
2570
- splitReport(s),
2571
- reissue,
2572
- ]);
2747
+ case "round_cap": {
2748
+ // The quiet copy counts the last review's open findings, only the
2749
+ // non-zero severities (record 0066): `Round cap reached: 2 blockers, 1 major`.
2750
+ if (!shows(verbosity, "verbose")) {
2751
+ const counts = severityCounts(s.findingsByRound[e.reviewRounds] ?? []);
2752
+ return `🧢 Round cap reached${counts ? `: ${counts}` : ""}${prUrl !== undefined ? ` — ${prUrl}` : ""}`;
2753
+ }
2754
+ // The cap bounds fix rounds, never the terminal steps (issue 2023): an
2755
+ // approval in the last allowed round still runs the checks step and the
2756
+ // merge, so a round_cap after an approve means the checks (or the merge
2757
+ // queue) failed at the approved head with no fix round left — and the
2758
+ // report names those findings instead of claiming no approval landed.
2759
+ const failedChecks = checkFindingsOf(s.findingsByRound[s.reviewRounds]);
2760
+ const headline =
2761
+ failedChecks.length > 0
2762
+ ? `🧢 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}`
2763
+ : `🧢 Ship stopped at a cap: the ${e.maxRounds}-round cap — no approval after ${rounds}.${prLine}`;
2764
+ return join([headline, splitReport(s), reissue]);
2765
+ }
2573
2766
  case "wall_clock_cap":
2767
+ if (!shows(verbosity, "verbose")) return `🧢 Out of budget — no approval after ${rounds}.${prLine}`;
2574
2768
  return join([
2575
2769
  `🧢 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}`,
2576
- aside(budgetSplitLine(e.spent, s.input.caps.maxMinutes)),
2770
+ budgetSplitLine(e.spent, s.input.caps.maxMinutes),
2577
2771
  splitReport(s),
2578
2772
  reissue,
2579
2773
  ]);
2580
2774
  case "review_pending":
2581
2775
  return join([
2582
- `⏳ 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.`,
2776
+ `⏳ 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 pipeline starts at the review round while the pull request still heads at the child's own last push.`,
2583
2777
  aside(budgetSplitLine(e.spent, s.input.caps.maxMinutes)),
2584
- reissue,
2778
+ aside(reissue),
2585
2779
  ]);
2586
2780
  case "stopped":
2781
+ if (!shows(verbosity, "verbose")) return `${e.mode === "hard" ? "⛔" : "⏹"} Stopped.${prLine}`;
2587
2782
  return join([
2588
2783
  `${e.mode === "hard" ? "⛔" : "⏹"} Ship stopped by operator (${e.mode} stop) after ${rounds}.${prLine}`,
2589
- aside(
2590
- writeUpPointer(
2591
- s,
2592
- e.round.kind,
2593
- e.round.kind === "review" ? s.reviewRunByRound[e.round.index] : s.lastCodingRunId,
2594
- ),
2784
+ writeUpPointer(
2785
+ s,
2786
+ e.round.kind,
2787
+ e.round.kind === "review" ? s.reviewRunByRound[e.round.index] : s.lastCodingRunId,
2595
2788
  ),
2596
2789
  e.postedReview
2597
2790
  ? "ℹ️ A changes-requested review was posted this round before the stop — its findings stand on the PR."
@@ -2599,14 +2792,13 @@ export function renderUnitReport(
2599
2792
  reissue,
2600
2793
  ]);
2601
2794
  case "aborted":
2795
+ if (!shows(verbosity, "verbose")) return `⚠️ Aborted after ${rounds}: ${e.reason}${prLine}`;
2602
2796
  return join([
2603
2797
  e.reason,
2604
- aside(
2605
- writeUpPointer(
2606
- s,
2607
- e.round?.kind ?? "coding",
2608
- e.round?.kind === "review" ? s.reviewRunByRound[e.round.index] : s.lastCodingRunId,
2609
- ),
2798
+ writeUpPointer(
2799
+ s,
2800
+ e.round?.kind ?? "coding",
2801
+ e.round?.kind === "review" ? s.reviewRunByRound[e.round.index] : s.lastCodingRunId,
2610
2802
  ),
2611
2803
  e.renewal !== undefined ? `🔁 Not renewed: ${e.renewal.line}.` : undefined,
2612
2804
  `⚠️ Ship aborted after ${rounds}.`,
@@ -2619,29 +2811,33 @@ export function renderUnitReport(
2619
2811
  if (!shows(verbosity, "verbose")) return "";
2620
2812
  return join([
2621
2813
  writeUpPointer(s, e.round.kind, e.runId),
2622
- `🔁 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` : ""}.`,
2814
+ `🔁 The unit's budget ran out with the unit unfinished — ${e.line}. A fresh ${s.input.caps.maxMinutes}-minute budget opens in this thread${e.from !== undefined ? ` from \`${e.from.slice(0, 7)}\`` : ""}, with the last run's write-up as its request; ${e.renewalsLeft} renewal${e.renewalsLeft === 1 ? "" : "s"} remain${e.spendUsd !== null ? `, $${e.spendUsd.toFixed(2)} spent so far` : ""}.`,
2623
2815
  aside(budgetSplitLine(e.spent, s.input.caps.maxMinutes)),
2624
2816
  ]);
2625
2817
  case "transient":
2818
+ if (!shows(verbosity, "verbose"))
2819
+ return `⚠️ Aborted after ${rounds}: the model provider failed twice; re-issue once it settles.${prLine}`;
2626
2820
  return join([
2627
2821
  `⚠️ 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.`,
2628
- aside(writeUpPointer(s, e.round.kind, s.lastCodingRunId)),
2822
+ writeUpPointer(s, e.round.kind, s.lastCodingRunId),
2629
2823
  `⚠️ Ship ended after ${rounds}; re-issue once the provider settles.`,
2630
2824
  reissue,
2631
2825
  ]);
2632
2826
  case "no_verdict":
2827
+ if (!shows(verbosity, "verbose"))
2828
+ return `⚠️ No verdict from review round ${e.round.index} — aborted after ${rounds}.${prLine}`;
2633
2829
  return join([
2634
2830
  `⚠️ 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.`,
2635
- aside(writeUpPointer(s, e.round.kind, s.reviewRunByRound[e.round.index])),
2831
+ writeUpPointer(s, e.round.kind, s.reviewRunByRound[e.round.index]),
2636
2832
  `⚠️ Ship aborted after ${rounds}.`,
2637
2833
  reissue,
2638
2834
  ]);
2639
2835
  case "interrupted":
2640
- return shipInterruptedNote(prUrl);
2836
+ return shipInterruptedNote(prUrl, e.cause);
2641
2837
  case "refused":
2642
2838
  return join([
2643
2839
  `🚫 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.`,
2644
- reissue,
2840
+ aside(reissue),
2645
2841
  ]);
2646
2842
  case "idle":
2647
2843
  // The old kind's sentence at this copy's level — the report is unchanged