@coreplane/switchboard 1.242.0 → 1.244.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 (153) hide show
  1. package/dist/assets/config/config.example.yaml +20 -1
  2. package/dist/assets/deploy/cloudflare-memory/worker.ts +234 -29
  3. package/dist/assets/deploy/cloudflare-resident/gc.ts +8 -1
  4. package/dist/assets/deploy/cloudflare-resident/worker.ts +483 -75
  5. package/dist/assets/deploy/cloudflare-sandbox/worker.ts +13 -2
  6. package/dist/assets/package-lock.json +3 -3
  7. package/dist/assets/package.json +1 -1
  8. package/dist/assets/project.json +3 -3
  9. package/dist/assets/source.json +3 -3
  10. package/dist/assets/src/agents/registry.ts +31 -35
  11. package/dist/assets/src/config/profile.ts +68 -3
  12. package/dist/assets/src/core/authz/policy.ts +4 -0
  13. package/dist/assets/src/core/authz/types.ts +10 -0
  14. package/dist/assets/src/core/budgets.ts +349 -0
  15. package/dist/assets/src/core/coordinator/contract.ts +32 -0
  16. package/dist/assets/src/core/coordinator/driver.ts +75 -22
  17. package/dist/assets/src/core/costs.ts +9 -76
  18. package/dist/assets/src/core/delivery.ts +1 -1
  19. package/dist/assets/src/core/modelPricing.ts +212 -0
  20. package/dist/assets/src/core/prDescriptionTypes.ts +29 -24
  21. package/dist/assets/src/core/reviewVerdict.ts +7 -0
  22. package/dist/assets/src/core/runEvents.ts +72 -5
  23. package/dist/assets/src/core/runFriction.ts +3 -1
  24. package/dist/assets/src/core/runRecord.ts +78 -0
  25. package/dist/assets/src/core/runUsage.ts +158 -47
  26. package/dist/assets/src/core/schedules.ts +3 -0
  27. package/dist/assets/src/core/ship/contract.ts +2 -1
  28. package/dist/assets/src/core/ship/coordinator.ts +312 -97
  29. package/dist/assets/src/core/ship/handoff.ts +9 -0
  30. package/dist/assets/src/core/ship/renewal.ts +155 -0
  31. package/dist/assets/src/core/trace/attrs.ts +11 -0
  32. package/dist/assets/src/core/trace/types.ts +4 -3
  33. package/dist/assets/src/execution/bashTimeout.ts +8 -5
  34. package/dist/assets/src/execution/residentRefresh.ts +52 -7
  35. package/dist/assets/src/execution/residentSteps.ts +1 -1
  36. package/dist/assets/src/execution/residentText.ts +8 -3
  37. package/dist/assets/src/mcp/registry.ts +3 -0
  38. package/dist/assets/web/dist/.vite/manifest.json +432 -429
  39. package/dist/assets/web/dist/assets/{AppShell-IDMGG6yi.js → AppShell-Ck4hSZDi.js} +1 -1
  40. package/dist/assets/web/dist/assets/CostsPage-DvhjDMka.js +2 -0
  41. package/dist/assets/web/dist/assets/DeliveryPage-DBlI_Yq2.js +1 -0
  42. package/dist/assets/web/dist/assets/HomePage-XFWPxDiJ.js +2 -0
  43. package/dist/assets/web/dist/assets/{NotFoundPage-C97EhJcQ.js → NotFoundPage-CiGrVOyv.js} +1 -1
  44. package/dist/assets/web/dist/assets/PendingTurnRow-BHCSpsPf.js +1 -0
  45. package/dist/assets/web/dist/assets/{ResidentDetailPage-DTMBgnIW.js → ResidentDetailPage-C5USV_8P.js} +1 -1
  46. package/dist/assets/web/dist/assets/ResidentsIndexPage-CSKisWnX.js +1 -0
  47. package/dist/assets/web/dist/assets/RunFoldRow-BPMQxTcl.js +1 -0
  48. package/dist/assets/web/dist/assets/RunRoutePage-CZe9Yodw.js +9 -0
  49. package/dist/assets/web/dist/assets/RunsIndexPage-BfA333Qy.js +1 -0
  50. package/dist/assets/web/dist/assets/{RunsTabs-BUfdk0lH.js → RunsTabs-BWRHzhtT.js} +1 -1
  51. package/dist/assets/web/dist/assets/ScheduledPage-DBaChcJZ.js +1 -0
  52. package/dist/assets/web/dist/assets/SettingsPage-CqVi9-Ac.js +1 -0
  53. package/dist/assets/web/dist/assets/{StatusDot-ELoXHlFt.js → StatusDot-_Q_bmReh.js} +1 -1
  54. package/dist/assets/web/dist/assets/{Tooltip-BoeFwYP2.js → Tooltip-Cafsgd4m.js} +1 -1
  55. package/dist/assets/web/dist/assets/UnitRoutePage-CdoBZwtf.js +1 -0
  56. package/dist/assets/web/dist/assets/{angular-html-oBNfPJR0.js → angular-html-Dail07rr.js} +1 -1
  57. package/dist/assets/web/dist/assets/{angular-ts-BvNwsyWA.js → angular-ts-BReExio8.js} +1 -1
  58. package/dist/assets/web/dist/assets/{apl-CNUdRlYf.js → apl-DhNO6Yyx.js} +1 -1
  59. package/dist/assets/web/dist/assets/{astro-Zb0NriSe.js → astro-BYotTY4C.js} +1 -1
  60. package/dist/assets/web/dist/assets/{blade-qPRVheqq.js → blade-mUxTtD1X.js} +1 -1
  61. package/dist/assets/web/dist/assets/{c-D8Awx4YO.js → c-DhuiwcSQ.js} +1 -1
  62. package/dist/assets/web/dist/assets/{chapel-Bt72Mhsx.js → chapel-GD2vFtRi.js} +1 -1
  63. package/dist/assets/web/dist/assets/{cobol-BOBacexg.js → cobol-BhFDwkes.js} +1 -1
  64. package/dist/assets/web/dist/assets/{coffee-E4u0liHW.js → coffee-DWUgbE0r.js} +1 -1
  65. package/dist/assets/web/dist/assets/{cpp-q2sLNlul.js → cpp-CO9XFjGW.js} +1 -1
  66. package/dist/assets/web/dist/assets/{crystal-DyWqUnlb.js → crystal-B9pVurpn.js} +1 -1
  67. package/dist/assets/web/dist/assets/{css-CQY0hFsD.js → css-CFVVV6lI.js} +1 -1
  68. package/dist/assets/web/dist/assets/{dist-BU5UivXC.js → dist-BtPs2AkQ.js} +2 -2
  69. package/dist/assets/web/dist/assets/{edge-C1MwhJkX.js → edge-BDhIDQIb.js} +1 -1
  70. package/dist/assets/web/dist/assets/{elixir-Bb3YbHfn.js → elixir-DG3J8WoD.js} +1 -1
  71. package/dist/assets/web/dist/assets/{elm-DAN9IGQw.js → elm-FiOdiaOE.js} +1 -1
  72. package/dist/assets/web/dist/assets/{erb-BEB8Xlsj.js → erb-vxbtW503.js} +1 -1
  73. package/dist/assets/web/dist/assets/format-BldUwl_R.js +1 -0
  74. package/dist/assets/web/dist/assets/{git-rebase-tqpjRfxO.js → git-rebase-DINz8M3H.js} +1 -1
  75. package/dist/assets/web/dist/assets/{glimmer-js-Ccbo65zR.js → glimmer-js-yann6r9i.js} +1 -1
  76. package/dist/assets/web/dist/assets/{glimmer-ts-B6WBVMpE.js → glimmer-ts-CXTbrZn_.js} +1 -1
  77. package/dist/assets/web/dist/assets/{glsl-tRec3Fcu.js → glsl-DNYC-hq0.js} +1 -1
  78. package/dist/assets/web/dist/assets/{graphql-P8kbxT4F.js → graphql-CwzhHzi9.js} +1 -1
  79. package/dist/assets/web/dist/assets/{hack-H9Zhkagy.js → hack-BBXF1cwR.js} +1 -1
  80. package/dist/assets/web/dist/assets/{haml-DtEnpn7Z.js → haml-oEqBcsTy.js} +1 -1
  81. package/dist/assets/web/dist/assets/{handlebars-DkgPfoAz.js → handlebars-YE5thkT1.js} +1 -1
  82. package/dist/assets/web/dist/assets/{html-D30RXpIs.js → html-Dp4jRQ1j.js} +1 -1
  83. package/dist/assets/web/dist/assets/{html-derivative-DwozLrEx.js → html-derivative-DCT1GYtQ.js} +1 -1
  84. package/dist/assets/web/dist/assets/{http-DXuzBAPm.js → http-BPcGbmzJ.js} +1 -1
  85. package/dist/assets/web/dist/assets/{hurl-d1UUJIt_.js → hurl-CQnNHwyd.js} +1 -1
  86. package/dist/assets/web/dist/assets/indexRow-BmK74Vp1.js +1 -0
  87. package/dist/assets/web/dist/assets/{java-DL0gWf34.js → java-Dm8Ovj6W.js} +1 -1
  88. package/dist/assets/web/dist/assets/{javascript-Cl7vavnS.js → javascript-Btm9oJK9.js} +1 -1
  89. package/dist/assets/web/dist/assets/{jinja-CfQOWMX9.js → jinja-VZYjWF_U.js} +1 -1
  90. package/dist/assets/web/dist/assets/{jison-KdYirlqm.js → jison-Dcmjxa9x.js} +1 -1
  91. package/dist/assets/web/dist/assets/{json-C9cDQ-Qj.js → json-DoWiOGZ8.js} +1 -1
  92. package/dist/assets/web/dist/assets/{jsx-CRx5NItd.js → jsx-Dvmo55ma.js} +1 -1
  93. package/dist/assets/web/dist/assets/{julia-CmsQsZQl.js → julia-d8AtfV26.js} +1 -1
  94. package/dist/assets/web/dist/assets/{just-CsM3Q8TE.js → just-Cc5EStA-.js} +1 -1
  95. package/dist/assets/web/dist/assets/{latex-BXCh5YRX.js → latex-B5fKKIH7.js} +1 -1
  96. package/dist/assets/web/dist/assets/{liquid-BF2vwK8p.js → liquid-DUYI9JND.js} +1 -1
  97. package/dist/assets/web/dist/assets/{lua-DcMBATrl.js → lua-BhMwKWpw.js} +1 -1
  98. package/dist/assets/web/dist/assets/main-C4366PZF.css +1 -0
  99. package/dist/assets/web/dist/assets/main-DlTNAC6c.js +28 -0
  100. package/dist/assets/web/dist/assets/{marko-88MndKvG.js → marko-Cnf2cPAi.js} +1 -1
  101. package/dist/assets/web/dist/assets/{mdc-DJ4kVAd8.js → mdc-FDDXoir9.js} +1 -1
  102. package/dist/assets/web/dist/assets/{nginx-CP6mRgtV.js → nginx-q-8U_1jR.js} +1 -1
  103. package/dist/assets/web/dist/assets/{nim-QQfj3fpF.js → nim-DIA0TUWs.js} +1 -1
  104. package/dist/assets/web/dist/assets/{org-Bke3eBzc.js → org-CR88hy7S.js} +1 -1
  105. package/dist/assets/web/dist/assets/{perl-Bkh0N7Kz.js → perl-Bkd8N7ja.js} +1 -1
  106. package/dist/assets/web/dist/assets/{php-Dnv1Piya.js → php-BOs84mvy.js} +1 -1
  107. package/dist/assets/web/dist/assets/{pug-D6peFFI7.js → pug-BbpZBb3w.js} +1 -1
  108. package/dist/assets/web/dist/assets/{qml-D8PEs-C-.js → qml-D2jQpjFE.js} +1 -1
  109. package/dist/assets/web/dist/assets/{r-B1EL9b_j.js → r-DKzcAQ22.js} +1 -1
  110. package/dist/assets/web/dist/assets/{razor-BsMTIh2b.js → razor-SOQMWcC9.js} +1 -1
  111. package/dist/assets/web/dist/assets/{regexp-HhvC8spD.js → regexp-B6KK4W8d.js} +1 -1
  112. package/dist/assets/web/dist/assets/{rst-D5paAxpg.js → rst-kPSg-NPf.js} +1 -1
  113. package/dist/assets/web/dist/assets/{ruby-BrQwhLrl.js → ruby-cKS-JYLH.js} +1 -1
  114. package/dist/assets/web/dist/assets/{sas-afot2B1o.js → sas-DNxzE5gs.js} +1 -1
  115. package/dist/assets/web/dist/assets/{scss-Efm-mwuG.js → scss-DGcHQ5OO.js} +1 -1
  116. package/dist/assets/web/dist/assets/{shellscript-BK0Vv5fT.js → shellscript-CRZeZBa5.js} +1 -1
  117. package/dist/assets/web/dist/assets/{shellsession-BGqlMC7N.js → shellsession-DSNEL0HX.js} +1 -1
  118. package/dist/assets/web/dist/assets/{soy-M0b4UwGM.js → soy-zisNbkDO.js} +1 -1
  119. package/dist/assets/web/dist/assets/{sql-sxe6IE9j.js → sql-MG06KMMb.js} +1 -1
  120. package/dist/assets/web/dist/assets/{sseReplay-g7ml86LM.js → sseReplay-DaRtc7nI.js} +5 -5
  121. package/dist/assets/web/dist/assets/{stata-nPF_ddLP.js → stata-CB_wKwv9.js} +1 -1
  122. package/dist/assets/web/dist/assets/{surrealql-D_GrC6u7.js → surrealql-DYdSt7re.js} +1 -1
  123. package/dist/assets/web/dist/assets/{svelte-DwL1AtNP.js → svelte-DHO-F7c7.js} +1 -1
  124. package/dist/assets/web/dist/assets/{templ-7s7LTDkc.js → templ-CUL5L4Lr.js} +1 -1
  125. package/dist/assets/web/dist/assets/{tex-Bx-5fMxe.js → tex-_JNEKjVW.js} +1 -1
  126. package/dist/assets/web/dist/assets/{ts-tags-DUMJnke_.js → ts-tags--9EpEV_8.js} +1 -1
  127. package/dist/assets/web/dist/assets/{tsx-BN8biPDe.js → tsx-BcNq2x2u.js} +1 -1
  128. package/dist/assets/web/dist/assets/{twig-8nIu84TN.js → twig-EsY5Dk_D.js} +1 -1
  129. package/dist/assets/web/dist/assets/{typescript-BooSPq_S.js → typescript-CyA_HiUH.js} +1 -1
  130. package/dist/assets/web/dist/assets/{typst-CTBiBsem.js → typst-SzaHlDt4.js} +1 -1
  131. package/dist/assets/web/dist/assets/{vue-C6Ft4Lea.js → vue-D3cn5acV.js} +1 -1
  132. package/dist/assets/web/dist/assets/{vue-html-Ba36dD5D.js → vue-html-6MxhUxOa.js} +1 -1
  133. package/dist/assets/web/dist/assets/{vue-vine-NkFexVo2.js → vue-vine-qJnqhgo9.js} +1 -1
  134. package/dist/assets/web/dist/assets/{xml-C_THnHXZ.js → xml-DyRpC7xv.js} +1 -1
  135. package/dist/assets/web/dist/assets/{xsl-D8G5xqjY.js → xsl-BXMRPAsg.js} +1 -1
  136. package/dist/assets/web/dist/assets/{yaml-jAMIJzge.js → yaml-BNmPCtAN.js} +1 -1
  137. package/dist/cli.js +5843 -2246
  138. package/package.json +1 -1
  139. package/dist/assets/web/dist/assets/CostsPage-CtmKhhOF.js +0 -2
  140. package/dist/assets/web/dist/assets/DeliveryPage-n1tz5I_v.js +0 -1
  141. package/dist/assets/web/dist/assets/HomePage-QtH1EwYF.js +0 -2
  142. package/dist/assets/web/dist/assets/PendingTurnRow-BZA_vQt3.js +0 -1
  143. package/dist/assets/web/dist/assets/ResidentsIndexPage-CaDvXhzJ.js +0 -1
  144. package/dist/assets/web/dist/assets/RunFoldRow-mgyLW0oV.js +0 -1
  145. package/dist/assets/web/dist/assets/RunRoutePage-DypJYMQa.js +0 -6
  146. package/dist/assets/web/dist/assets/RunsIndexPage-DYraPoWD.js +0 -1
  147. package/dist/assets/web/dist/assets/ScheduledPage-DXD2gLJk.js +0 -1
  148. package/dist/assets/web/dist/assets/SettingsPage-BIGio8Y0.js +0 -1
  149. package/dist/assets/web/dist/assets/UnitRoutePage-jhCrWW3i.js +0 -1
  150. package/dist/assets/web/dist/assets/indexRow-BD1VT8o8.js +0 -1
  151. package/dist/assets/web/dist/assets/localIso-L06jV29p.js +0 -1
  152. package/dist/assets/web/dist/assets/main-B2fX10aW.css +0 -1
  153. package/dist/assets/web/dist/assets/main-D4EA1g6n.js +0 -28
@@ -30,12 +30,25 @@
30
30
  // arrive in the input rather than from the agent registry.
31
31
 
32
32
  import type { ShipRoundOutcome } from "../runEvents.js";
33
+ import type { Handoff } from "./handoff.js";
34
+ import { progressOf, renderRenewal, renewalDecision, type PushedHeadFact, type RenewalDecision } from "./renewal.js";
33
35
  import type { RunStatus } from "../runRecord.js";
34
36
  import { normalizeHead, sameCommit } from "../reviewedHead.js";
35
37
  import { formatFinding, type Finding, type FindingDisposition, type ReviewVerdictKind } from "../reviewVerdict.js";
36
38
  import { parsePlanUnit, planUnitIds } from "./contract.js";
37
-
38
- const MIN = 60_000;
39
+ import {
40
+ carve,
41
+ DEFAULT_GRANT,
42
+ loopPosition,
43
+ MINUTE_MS,
44
+ SHIP_WAIT,
45
+ type Carve,
46
+ type Grant,
47
+ type GrantSource,
48
+ type Loop,
49
+ } from "../budgets.js";
50
+
51
+ const MIN = MINUTE_MS;
39
52
 
40
53
  /** What a pipeline runs under: the rounds cap from the `ship` config block, and
41
54
  * the wall clock from the parent's EFFECTIVE profile — the ship preset's
@@ -45,34 +58,10 @@ export interface ShipCaps {
45
58
  maxMinutes: number;
46
59
  }
47
60
 
48
- /** A round is dispatched only when at least this much of the pipeline budget
49
- * remains (the reservation check, agent-ship item 8): a child clipped below
50
- * this cannot do useful work, so the pipeline reports the cap instead of
51
- * burning an attach and a model turn on a doomed round. */
52
- export const SHIP_ROUND_RESERVE_MS = 3 * MIN;
53
-
54
- /** What a round-0 coding child must leave on the pipeline's clock: two review
55
- * rounds (a review and, after a fix, its re-review) and one merge poll — the
56
- * loop the pipeline exists to run. The coding child's budget is clipped to
57
- * the remaining wall clock MINUS this reserve (never under the two minutes a
58
- * spawn accepts), so a child that uses its whole directive still hands the
59
- * pipeline a clock that holds the review; without the clip a 40-minute
60
- * pipeline hands 39 minutes to the child and caps out with the pull request
61
- * shipped and unreviewed. */
62
- export const SHIP_LOOP_RESERVE_MS = 2 * SHIP_ROUND_RESERVE_MS + 5 * MIN;
63
-
64
- /** What a findings child (the fix after a review asked for changes) must leave
65
- * on the clock: the re-review and one merge poll — one round's reserve and
66
- * five minutes. Never the whole loop's: by the time a fix round runs, the
67
- * first review has already happened, and holding two rounds back from a late
68
- * fix would cap a pipeline that still has the time. */
69
- export const SHIP_FIX_RESERVE_MS = SHIP_ROUND_RESERVE_MS + 5 * MIN;
70
-
71
- /** The floor `validateShip` holds `ship.maxMinutes` to: the loop's reserve
72
- * plus one round's — under it no coding child can both work and leave the
73
- * review its time, so the config is refused at load rather than left to cap
74
- * out on every unit. */
75
- export const SHIP_MIN_MAX_MINUTES = (SHIP_LOOP_RESERVE_MS + SHIP_ROUND_RESERVE_MS) / MIN;
61
+ /** A round's minutes come from `carve` in `src/core/budgets.ts` (agent-ship
62
+ * item 8, decision 0046): the remainder minus the reserve derived over the
63
+ * rounds that must still follow, capped at the preset's ask, refused under the
64
+ * round's floor. Nothing here holds a reserve of its own. */
76
65
 
77
66
  /** What a ship pipeline's thread and card say when the bot died under it (run-
78
67
  * history item 36): the work it did stands on GitHub with nobody driving it,
@@ -414,7 +403,14 @@ export function presetOf(kind: RoundKind): ChildPreset {
414
403
  * turn: the unit's contract, or the review turn from the pull request and the
415
404
  * prior rounds' records, or the findings message from the review run's verdict. */
416
405
  export type Brief =
417
- | { kind: "contract"; unit: string; rebase: { branch: string; onto: string } }
406
+ | {
407
+ kind: "contract";
408
+ unit: string;
409
+ rebase: { branch: string; onto: string };
410
+ /** A renewal's segment (decision 0046): the child continues the previous
411
+ * segment's work from `from`, briefed with that run's write-up and handoff. */
412
+ continue?: { segment: number; from?: string; previousRunId?: string };
413
+ }
418
414
  | {
419
415
  kind: "review";
420
416
  unit: string;
@@ -478,6 +474,13 @@ export type ChildFacts =
478
474
  /** The dispositions a coding run recorded, as it submitted them: the machine matches them to the round's findings. */
479
475
  dispositions?: FindingDisposition[];
480
476
  handoff?: boolean;
477
+ /** The renewal's facts (decision 0046): the heads the run pushed, when
478
+ * its lease began, what it cost (null when a model had no price) and
479
+ * the handoff's lists — progress is read off these, never asked. */
480
+ pushed?: PushedHeadFact[];
481
+ leaseStartedAt?: number;
482
+ costUsd?: number | null;
483
+ handoffLists?: Handoff;
481
484
  };
482
485
 
483
486
  /** What heads the unit's branch on GitHub: nothing, an open pull request, or —
@@ -525,7 +528,14 @@ export type UnitEnding =
525
528
  | { kind: "merge_ready"; pr: PrRef; reviewRounds: number }
526
529
  | { kind: "merge_refused"; pr: PrRef; reason: string; reviewRounds: number }
527
530
  | { kind: "round_cap"; maxRounds: number; reviewRounds: number }
528
- | { kind: "wall_clock_cap"; remainingMs: number; reviewRounds: number; spent: ShipBudgetSpent }
531
+ | {
532
+ kind: "wall_clock_cap";
533
+ remainingMs: number;
534
+ reviewRounds: number;
535
+ spent: ShipBudgetSpent;
536
+ /** The round the remainder could not hold: what it would have got and the floor it fell under. */
537
+ refused?: { round: RoundKind; minutes: number; floor: number };
538
+ }
529
539
  /** The wall clock capped AFTER the coding child opened or updated the pull
530
540
  * request: the work stands and only the review is missing, so the ending
531
541
  * names the pull request and "review pending" instead of calling the unit a
@@ -540,7 +550,38 @@ export type UnitEnding =
540
550
  /** A changes-requested review posted this round before the stop. */
541
551
  postedReview?: boolean;
542
552
  }
543
- | { kind: "aborted"; reason: string; round?: RoundRef; reviewRounds: number; finalReply?: string }
553
+ | {
554
+ kind: "aborted";
555
+ reason: string;
556
+ round?: RoundRef;
557
+ reviewRounds: number;
558
+ finalReply?: string;
559
+ /** A round-0 end without a pull request that was judged for renewal and
560
+ * refused: the decision and the card's sentence (decision 0046). */
561
+ renewal?: { decision: Extract<RenewalDecision, { renew: false }>; line: string };
562
+ }
563
+ /** The coding round ended at its lease with the unit unfinished, the row
564
+ * showed progress and the grant renewed: this segment is over and the next
565
+ * opens in the same thread from `from` under a fresh lease, the coding
566
+ * child's write-up as its request (decision 0046, Renewal). */
567
+ | {
568
+ kind: "continued";
569
+ round: RoundRef;
570
+ /** The coding child whose write-up and handoff the continuation is briefed with. */
571
+ runId: string;
572
+ /** The segment the renewal opens (the first segment is 1). */
573
+ segment: number;
574
+ from?: string;
575
+ renewalsLeft: number;
576
+ /** The session's spend so far, this segment's children included; null when a run's model had no price. */
577
+ spendUsd: number | null;
578
+ handoff?: Handoff;
579
+ /** The coding child's write-up: the checkpoint the continuation is briefed with. */
580
+ finalReply?: string;
581
+ line: string;
582
+ reviewRounds: number;
583
+ spent: ShipBudgetSpent;
584
+ }
544
585
  | { kind: "no_verdict"; round: RoundRef; reviewRounds: number; finalReply?: string }
545
586
  | { kind: "interrupted"; round: RoundRef; runId: string; reviewRounds: number }
546
587
  | { kind: "refused"; refusal: string; message?: string; round: RoundRef; reviewRounds: number };
@@ -578,15 +619,22 @@ export function findingsAtOrAbove(findings: readonly Finding[], level: AddressSe
578
619
  return findings.filter((f) => severityRank(f.severity) <= severityRank(level));
579
620
  }
580
621
 
622
+ export interface UnitSession {
623
+ segment: number;
624
+ renewalsSpent: number;
625
+ spendUsd: number | null;
626
+ continueFrom?: string;
627
+ /** The previous segment's coding run: its write-up and handoff brief the continuation. */
628
+ previousRunId?: string;
629
+ previousHandoff?: Handoff;
630
+ }
631
+
581
632
  export interface UnitPipelineInput {
582
633
  unit: { id: string; branch: string };
583
634
  repo: string;
584
635
  /** The pull request's base — the branch the unit is created from and rebased onto. */
585
636
  base: string;
586
637
  caps: ShipCaps;
587
- /** Each child preset's own wall-clock budget (its `maxMinutes`), the number a
588
- * round's budget is clipped from — supplied by the bot, which holds the registry. */
589
- childMinutes: Readonly<Record<ChildPreset, number>>;
590
638
  /** Who merges: the instance's `merge` field as the plan route answers it —
591
639
  * `runner` (a seeded plan, under its grant) or `person` (a task, or a
592
640
  * record without the field). */
@@ -597,6 +645,18 @@ export interface UnitPipelineInput {
597
645
  addressSeverity?: AddressSeverity;
598
646
  /** Which layer set the level in force; named in the round header and the ending. */
599
647
  addressSeveritySource?: AddressSeveritySource;
648
+ /** The grant the request carried (decision 0046, the renewable lease):
649
+ * renewals and a cost cap, and which layer granted it — named in the report;
650
+ * absent reads as zero renewals and no cap, the org's. Nothing here spends
651
+ * it yet: the renewal decision is the segment's end, not this unit's. */
652
+ grant?: Grant;
653
+ grantSource?: GrantSource;
654
+ /** The segment this pipeline runs (decision 0046, Renewal): absent for the
655
+ * first; a renewal's carries its number, the renewals spent before it, the
656
+ * session's spend so far, the sha it continues from and the previous
657
+ * segment's handoff. Every step name of a later segment is prefixed with
658
+ * it, so the Workflow's durable steps never collide across segments. */
659
+ session?: UnitSession;
600
660
  /** The instance's mark (agent-ship item 16): a generated one-unit plan — a
601
661
  * `plan` with an id and no `path` — whose unit runs in the requesting
602
662
  * thread and is re-issued with the request's own text, never a plan path. */
@@ -614,7 +674,7 @@ type Phase =
614
674
  /** Before anything is created: what already heads the branch — a merged pull request ends the unit here. */
615
675
  | { at: "pre-check" }
616
676
  | { at: "branch" }
617
- | { at: "spawn"; round: RoundRef; busy: number }
677
+ | { at: "spawn"; round: RoundRef; busy: number; minutes: number; holds: number }
618
678
  | { at: "busy-wait"; round: RoundRef; runId?: string; n: number }
619
679
  /** `until`: when the child's budget plus the margin runs out, counted from the spawn's answer — the wait's last slice ends there. */
620
680
  | { at: "wait"; round: RoundRef; runId: string; n: number; until: number }
@@ -625,13 +685,17 @@ type Phase =
625
685
  runId: string;
626
686
  childHead?: string;
627
687
  finalReply?: string;
688
+ /** The renewal's facts off the child's record (decision 0046): its pushed heads, its lease's start, its handoff. */
689
+ childPushed?: PushedHeadFact[];
690
+ childLeaseStartedAt?: number;
691
+ childHandoff?: Handoff;
628
692
  /** The coding child died (`failed` or `interrupted`) after it may have
629
693
  * pushed: the pr-check recovers a pushed branch by opening its pull
630
694
  * request; with nothing pushed the unit ends with the child's own reason. */
631
695
  dead?: "failed" | "interrupted";
632
696
  }
633
- | { at: "merge"; pr: PrRef; headSha: string; n: number; since: number }
634
- | { at: "merge-wait"; pr: PrRef; headSha: string; n: number; since: number }
697
+ | { at: "merge"; pr: PrRef; headSha: string; n: number; since: number; waitMs: number }
698
+ | { at: "merge-wait"; pr: PrRef; headSha: string; n: number; since: number; waitMs: number }
635
699
  | { at: "ended" };
636
700
 
637
701
  export interface UnitPipelineState {
@@ -658,11 +722,14 @@ export interface UnitPipelineState {
658
722
  readonly lastCodingRunId?: string;
659
723
  /** How the budget went so far, accrued as each answer moves the clock. */
660
724
  readonly spentMs: ShipBudgetSpent;
725
+ /** The session's dollars so far: the input's from earlier segments plus each
726
+ * child's cost as its record is read; null once any run's cost is unknown. */
727
+ readonly spendUsd: number | null;
661
728
  readonly ending?: UnitEnding;
662
729
  }
663
730
 
664
731
  /** Past a child's budget, the parent asks the bot instead of waiting on. */
665
- export const WAIT_MARGIN_MS = 5 * MIN;
732
+ export const WAIT_MARGIN_MS = SHIP_WAIT.marginMinutes * MIN;
666
733
  /** One slice of a wait on a child. The child's budget plus the margin is
667
734
  * walked in chunks with a `read-record` between them, so an event the engine
668
735
  * never delivered — refused, lost, sent to an instance that had ended — costs
@@ -670,19 +737,18 @@ export const WAIT_MARGIN_MS = 5 * MIN;
670
737
  * is the machine's one cadence for asking the bot what it cannot be told (the
671
738
  * margin and the merge poll are the same number), and it keeps a round to a
672
739
  * few steps: a coding child's 45 minutes are ten waits and ten reads. */
673
- export const WAIT_CHUNK_MS = 5 * MIN;
674
- /** How long the merge step waits for the guards at most. While checks are
675
- * pending the machine waits on the intake's `checks-settled-<head>` event
676
- * (http-ingress.md item 12) — one bounded wait per ask, the remainder of this
677
- * cap, as the fallback when the event never arrives. */
678
- export const MERGE_WAIT_MAX_MS = 60 * MIN;
740
+ export const WAIT_CHUNK_MS = SHIP_WAIT.chunkMinutes * MIN;
741
+ /** The merge wait is a round of its own: its minutes are carved from the
742
+ * pipeline's remainder when the door is first asked (the merge wait's ask and
743
+ * floor are rows of `src/core/budgets.ts`), and the wait below is sliced
744
+ * from that carve. */
679
745
  /** One merge wait's fallback timeout: the old poll's cadence. The event wakes
680
746
  * the machine at once when the intake delivers it; without one (the webhook
681
747
  * not configured, a delivery lost) the door is still re-asked every chunk, so
682
748
  * a merge is never slower than the poll it replaced. */
683
- export const MERGE_WAIT_CHUNK_MS = 5 * MIN;
749
+ export const MERGE_WAIT_CHUNK_MS = SHIP_WAIT.mergeChunkMinutes * MIN;
684
750
  /** A `busy` without the live run's id: nothing to wait on, so a short sleep before the spawn is asked again. */
685
- export const BUSY_RETRY_MS = 2 * MIN;
751
+ export const BUSY_RETRY_MS = SHIP_WAIT.busyRetryMinutes * MIN;
686
752
 
687
753
  // ---- the unit pipeline: opening and the next action -----------------------------------------------
688
754
 
@@ -694,6 +760,7 @@ export function openUnitPipeline(input: UnitPipelineInput, at: number): UnitPipe
694
760
  phase: { at: "pre-check" },
695
761
  reviewRounds: 0,
696
762
  spentMs: { coding: 0, review: 0, waiting: 0 },
763
+ spendUsd: input.session?.spendUsd ?? 0,
697
764
  findingsByRound: {},
698
765
  dispositionsByRound: {},
699
766
  reviewRunByRound: {},
@@ -721,36 +788,45 @@ function waitSliceMs(clock: number, until: number): number {
721
788
  return remaining > 0 ? Math.min(WAIT_CHUNK_MS, remaining) : WAIT_CHUNK_MS;
722
789
  }
723
790
 
724
- /** The child's budget: its preset's own, clipped to the pipeline's remaining
725
- * wall clock (agent-ship item 8's clip), never under the two minutes a
726
- * spawn accepts. */
727
- function budgetMinutesFor(s: UnitPipelineState, round: RoundRef): number {
728
- const headroom = remainingMs(s) - reserveFor(round.kind);
729
- return Math.max(2, Math.min(s.input.childMinutes[presetOf(round.kind)], Math.floor(headroom / MIN)));
730
- }
791
+ /** The pipeline's loop as the config allows it. */
792
+ const loopOf = (s: UnitPipelineState): Loop => ({ maxRounds: s.input.caps.maxRounds });
731
793
 
732
- /** What a child of each round kind leaves on the pipeline's clock: round 0's
733
- * coding child the whole loop (two reviews and the merge poll,
734
- * SHIP_LOOP_RESERVE_MS), a findings child the re-review and the merge poll
735
- * (SHIP_FIX_RESERVE_MS), a review child nothing — the review is what the
736
- * reserve was held for. */
737
- function reserveFor(kind: RoundKind): number {
738
- switch (kind) {
739
- case "coding":
740
- return SHIP_LOOP_RESERVE_MS;
741
- case "findings":
742
- return SHIP_FIX_RESERVE_MS;
743
- case "review":
744
- return 0;
745
- }
794
+ /** A round's carve (agent-ship item 8): the remainder minus the reserve for
795
+ * the rounds after it, capped at its preset's ask, refused under its floor.
796
+ * A review round `n` and the findings step that follows it share `n`; the
797
+ * module's positions are the loop's own. */
798
+ function roundCarve(s: UnitPipelineState, round: RoundRef): Carve {
799
+ const kind = round.kind === "findings" ? "fix" : round.kind;
800
+ return carve(remainingMs(s), { kind, index: loopPosition(loopOf(s), kind, round.index) }, loopOf(s));
746
801
  }
747
802
 
748
- const roundStep = (s: UnitPipelineState, round: RoundRef) => `${s.input.unit.id}/${round.index}/${round.kind}`;
803
+ /** The prefix every step of this pipeline is named under: the unit id, and for
804
+ * a renewal's segment the segment too (`U10/s2/…`), so the Workflow's durable
805
+ * step cache never hands segment two the answers of segment one. */
806
+ export const stepPrefixOf = (unit: string, session: UnitSession | undefined): string =>
807
+ session !== undefined && session.segment > 1 ? `${unit}/s${session.segment}` : unit;
808
+ const stepPrefix = (s: UnitPipelineState) => stepPrefixOf(s.input.unit.id, s.input.session);
809
+ const roundStep = (s: UnitPipelineState, round: RoundRef) => `${stepPrefix(s)}/${round.index}/${round.kind}`;
749
810
 
750
811
  function briefFor(s: UnitPipelineState, round: RoundRef): Brief {
751
812
  const unit = s.input.unit.id;
752
- if (round.kind === "coding")
753
- return { kind: "contract", unit, rebase: { branch: s.input.unit.branch, onto: s.input.base } };
813
+ if (round.kind === "coding") {
814
+ const session = s.input.session;
815
+ return {
816
+ kind: "contract",
817
+ unit,
818
+ rebase: { branch: s.input.unit.branch, onto: s.input.base },
819
+ ...(session !== undefined && session.segment > 1
820
+ ? {
821
+ continue: {
822
+ segment: session.segment,
823
+ ...(session.continueFrom !== undefined ? { from: session.continueFrom } : {}),
824
+ ...(session.previousRunId !== undefined ? { previousRunId: session.previousRunId } : {}),
825
+ },
826
+ }
827
+ : {}),
828
+ };
829
+ }
754
830
  const pr = s.pr!.number;
755
831
  if (round.kind === "findings") return { kind: "findings", unit, pr, reviewRunId: s.reviewRunByRound[round.index]! };
756
832
  const priorReview = s.reviewRunByRound[round.index - 1];
@@ -786,7 +862,7 @@ export function matchDispositions(
786
862
  /** The step the machine is at. Pure over the state: asked before every step
787
863
  * and again after a replay, it names the same step for the same state. */
788
864
  export function nextAction(s: UnitPipelineState): CoordinatorAction {
789
- const unit = s.input.unit.id;
865
+ const unit = stepPrefix(s);
790
866
  const p = s.phase;
791
867
  switch (p.at) {
792
868
  case "pre-check":
@@ -800,7 +876,7 @@ export function nextAction(s: UnitPipelineState): CoordinatorAction {
800
876
  step: roundStep(s, p.round),
801
877
  preset,
802
878
  round: p.round,
803
- budgetMinutes: budgetMinutesFor(s, p.round),
879
+ budgetMinutes: p.minutes,
804
880
  brief: briefFor(s, p.round),
805
881
  };
806
882
  }
@@ -831,7 +907,7 @@ export function nextAction(s: UnitPipelineState): CoordinatorAction {
831
907
  type: "wait-checks",
832
908
  step: `${unit}/merge/wait/${p.n}`,
833
909
  headSha: p.headSha,
834
- timeoutMs: Math.max(MIN, Math.min(MERGE_WAIT_CHUNK_MS, MERGE_WAIT_MAX_MS - (s.clock - p.since))),
910
+ timeoutMs: Math.max(MIN, Math.min(MERGE_WAIT_CHUNK_MS, p.waitMs - (s.clock - p.since))),
835
911
  };
836
912
  case "ended":
837
913
  return { type: "end", step: `${unit}/end`, ending: s.ending! };
@@ -847,6 +923,14 @@ interface Transition {
847
923
 
848
924
  const ENDED: Phase = { at: "ended" };
849
925
 
926
+ /** The session's dollars after one more child: unknown (null) once any run's
927
+ * cost is — a total that left a run out would understate the spend a cap
928
+ * judges — and a record without the field counts as unknown too. */
929
+ function addSpend(sum: number | null, cost: number | null | undefined): number | null {
930
+ if (sum === null || cost === null || cost === undefined) return null;
931
+ return sum + cost;
932
+ }
933
+
850
934
  function end(s: UnitPipelineState, ending: UnitEnding, notes: CoordinatorNote[] = []): Transition {
851
935
  return { state: { ...s, phase: ENDED, ending }, notes: [...notes, { type: "ended", ending }] };
852
936
  }
@@ -861,7 +945,7 @@ const roundNote = (round: RoundRef, outcome: ShipRoundOutcome): CoordinatorNote
861
945
  /** The cap's ending: `review_pending` when the clock ran out entering a
862
946
  * review round with the child's pull request standing — the work shipped and
863
947
  * only the review is missing — else the wall-clock cap. Both carry the split. */
864
- function capEnding(s: UnitPipelineState, round?: RoundRef): UnitEnding {
948
+ function capEnding(s: UnitPipelineState, round?: RoundRef, refused?: Carve): UnitEnding {
865
949
  if (round?.kind === "review" && s.pr !== undefined)
866
950
  return {
867
951
  kind: "review_pending",
@@ -870,16 +954,28 @@ function capEnding(s: UnitPipelineState, round?: RoundRef): UnitEnding {
870
954
  reviewRounds: s.reviewRounds,
871
955
  spent: s.spentMs,
872
956
  };
873
- return { kind: "wall_clock_cap", remainingMs: remainingMs(s), reviewRounds: s.reviewRounds, spent: s.spentMs };
957
+ return {
958
+ kind: "wall_clock_cap",
959
+ remainingMs: remainingMs(s),
960
+ reviewRounds: s.reviewRounds,
961
+ spent: s.spentMs,
962
+ ...(round !== undefined && refused?.kind === "refused"
963
+ ? { refused: { round: round.kind, minutes: refused.minutes, floor: refused.floor } }
964
+ : {}),
965
+ };
874
966
  }
875
967
 
876
- /** Start a round if the reservation holds (agent-ship item 8's check: a
877
- * child clipped under the reserve cannot do useful work). */
968
+ /** Start a round if its carve holds (agent-ship item 8): a round the
969
+ * remainder cannot carve above its floor is not dispatched, and the unit ends
970
+ * at the cap naming the round, what it would have got and the floor. */
878
971
  function enterRound(s: UnitPipelineState, round: RoundRef, notes: CoordinatorNote[] = []): Transition {
879
- const remaining = remainingMs(s);
880
- if (remaining < SHIP_ROUND_RESERVE_MS) return end(s, capEnding(s, round), notes);
972
+ const carved = roundCarve(s, round);
973
+ if (carved.kind === "refused") return end(s, capEnding(s, round, carved), notes);
881
974
  const reviewRounds = round.kind === "review" ? round.index : s.reviewRounds;
882
- return { state: { ...s, reviewRounds, phase: { at: "spawn", round, busy: 0 } }, notes };
975
+ return {
976
+ state: { ...s, reviewRounds, phase: { at: "spawn", round, busy: 0, minutes: carved.minutes, holds: carved.holds } },
977
+ notes,
978
+ };
883
979
  }
884
980
 
885
981
  /** The next review round, or the round cap. */
@@ -905,6 +1001,7 @@ function settleCoding(
905
1001
  // round's findings enter the state (agent-ship item 6).
906
1002
  let next: UnitPipelineState = {
907
1003
  ...s,
1004
+ spendUsd: addSpend(s.spendUsd, facts.costUsd),
908
1005
  ...(facts.pr !== undefined ? { pr: { number: facts.pr.number, url: facts.pr.url } } : {}),
909
1006
  ...(round.kind === "findings" && facts.dispositions !== undefined
910
1007
  ? {
@@ -942,6 +1039,9 @@ function settleCoding(
942
1039
  runId,
943
1040
  ...(facts.headSha !== undefined ? { childHead: facts.headSha } : {}),
944
1041
  ...(facts.finalReply !== undefined ? { finalReply: facts.finalReply } : {}),
1042
+ ...(facts.pushed !== undefined ? { childPushed: facts.pushed } : {}),
1043
+ ...(facts.leaseStartedAt !== undefined ? { childLeaseStartedAt: facts.leaseStartedAt } : {}),
1044
+ ...(facts.handoffLists !== undefined ? { childHandoff: facts.handoffLists } : {}),
945
1045
  },
946
1046
  };
947
1047
  return { state: next, notes: [] };
@@ -981,6 +1081,7 @@ function settleReview(
981
1081
  const verdict = facts.verdict;
982
1082
  const next: UnitPipelineState = {
983
1083
  ...s,
1084
+ spendUsd: addSpend(s.spendUsd, facts.costUsd),
984
1085
  findingsByRound: { ...s.findingsByRound, [round.index]: verdict.findings },
985
1086
  ...(facts.reviewHead !== undefined ? { lastReviewHead: facts.reviewHead } : {}),
986
1087
  ...(verdict.summary !== undefined ? { lastVerdictSummary: verdict.summary } : {}),
@@ -1042,7 +1143,26 @@ function settleReview(
1042
1143
  { kind: "merge_refused", pr, reason: "no approved head is known to merge at", reviewRounds: next.reviewRounds },
1043
1144
  notes,
1044
1145
  );
1045
- return { state: { ...next, phase: { at: "merge", pr, headSha, n: 1, since: next.clock } }, notes };
1146
+ const mergeWait = carve(
1147
+ remainingMs(next),
1148
+ { kind: "merge", index: loopPosition(loopOf(next), "merge") },
1149
+ loopOf(next),
1150
+ );
1151
+ if (mergeWait.kind === "refused")
1152
+ return end(
1153
+ next,
1154
+ {
1155
+ kind: "merge_refused",
1156
+ pr,
1157
+ reason: `the remaining ${mergeWait.minutes} minutes of the pipeline are under the merge wait's floor of ${mergeWait.floor}`,
1158
+ reviewRounds: next.reviewRounds,
1159
+ },
1160
+ notes,
1161
+ );
1162
+ return {
1163
+ state: { ...next, phase: { at: "merge", pr, headSha, n: 1, since: next.clock, waitMs: mergeWait.minutes * MIN } },
1164
+ notes,
1165
+ };
1046
1166
  }
1047
1167
  // request_changes: the verdict settled (and posted) — now a stop
1048
1168
  // short-circuits the findings step, naming the review standing on the pull request.
@@ -1123,15 +1243,72 @@ function settlePrCheck(s: UnitPipelineState, phase: Extract<Phase, { at: "pr-che
1123
1243
  [roundNote(round, "aborted")],
1124
1244
  );
1125
1245
  }
1126
- const reason =
1127
- round.index === 0
1128
- ? `⚠️ Ship ended at round 0: the coding round ended without opening a pull request (a clarifying question, a budget write-up, an unproven push or a description-less push ends the pipeline here). No review round ran.`
1129
- : `⚠️ Round ${round.index}'s findings step left no open pull request heading \`${s.input.unit.branch}\` — the pull request was closed out from under the pipeline and none was reopened, so there is nothing to re-review.`;
1246
+ // Round 0 ended without a pull request: the segment is over with the unit
1247
+ // unfinished, and the grant decides whether the next opens (decision
1248
+ // 0046, Renewal). Progress is read off the child's record — a head pushed
1249
+ // to the unit's branch since the segment started, or a handoff that moved —
1250
+ // never off its words; the decision then asks the grant's count, the cap
1251
+ // and the fit, in that order, and a refusal names the clause. A plain
1252
+ // abort keeps its old shape when nothing was pushed under a grant of zero:
1253
+ // a clarifying question is not a stop to explain.
1254
+ if (round.index === 0) {
1255
+ const grant = s.input.grant ?? DEFAULT_GRANT;
1256
+ const session = s.input.session;
1257
+ const progress = progressOf({
1258
+ branch: s.input.unit.branch,
1259
+ pushed: phase.childPushed ?? [],
1260
+ ...(session?.continueFrom !== undefined ? { startHead: session.continueFrom } : {}),
1261
+ ...(phase.childLeaseStartedAt !== undefined ? { leaseStartedAt: phase.childLeaseStartedAt } : {}),
1262
+ ...(session?.previousHandoff !== undefined && phase.childHandoff !== undefined
1263
+ ? { handoff: { previous: session.previousHandoff, current: phase.childHandoff } }
1264
+ : {}),
1265
+ });
1266
+ const decision = renewalDecision({
1267
+ grant,
1268
+ renewalsSpent: session?.renewalsSpent ?? 0,
1269
+ spendUsd: s.spendUsd,
1270
+ progress,
1271
+ pipeline: s.input.caps,
1272
+ });
1273
+ const line = renderRenewal(decision, grant);
1274
+ if (decision.renew)
1275
+ return end(
1276
+ s,
1277
+ {
1278
+ kind: "continued",
1279
+ round,
1280
+ runId: phase.runId,
1281
+ segment: decision.segment,
1282
+ ...(decision.from !== undefined ? { from: decision.from } : {}),
1283
+ renewalsLeft: decision.renewalsLeft,
1284
+ spendUsd: s.spendUsd,
1285
+ ...(phase.childHandoff !== undefined ? { handoff: phase.childHandoff } : {}),
1286
+ ...(phase.finalReply !== undefined ? { finalReply: phase.finalReply } : {}),
1287
+ line,
1288
+ reviewRounds: s.reviewRounds,
1289
+ spent: s.spentMs,
1290
+ },
1291
+ [roundNote(round, "continued")],
1292
+ );
1293
+ const judged = grant.renewals > 0 || progress.progressed;
1294
+ return end(
1295
+ s,
1296
+ {
1297
+ kind: "aborted",
1298
+ reason: `⚠️ Ship ended at round 0: the coding round ended without opening a pull request (a clarifying question, a budget write-up, an unproven push or a description-less push ends the pipeline here). No review round ran.`,
1299
+ round,
1300
+ reviewRounds: s.reviewRounds,
1301
+ ...(phase.finalReply !== undefined ? { finalReply: phase.finalReply } : {}),
1302
+ ...(judged ? { renewal: { decision, line } } : {}),
1303
+ },
1304
+ [roundNote(round, "aborted")],
1305
+ );
1306
+ }
1130
1307
  return end(
1131
1308
  s,
1132
1309
  {
1133
1310
  kind: "aborted",
1134
- reason,
1311
+ reason: `⚠️ Round ${round.index}'s findings step left no open pull request heading \`${s.input.unit.branch}\` — the pull request was closed out from under the pipeline and none was reopened, so there is nothing to re-review.`,
1135
1312
  round,
1136
1313
  reviewRounds: s.reviewRounds,
1137
1314
  ...(phase.finalReply !== undefined ? { finalReply: phase.finalReply } : {}),
@@ -1252,7 +1429,7 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
1252
1429
  case "spawned":
1253
1430
  case "alreadySpawned": {
1254
1431
  // The child's budget runs from the spawn's answer; the wait walks it, plus the margin, in chunks.
1255
- const until = clocked.clock + budgetMinutesFor(s, p.round) * MIN + WAIT_MARGIN_MS;
1432
+ const until = clocked.clock + p.minutes * MIN + WAIT_MARGIN_MS;
1256
1433
  const runs =
1257
1434
  p.round.kind === "review"
1258
1435
  ? { reviewRunByRound: { ...s.reviewRunByRound, [p.round.index]: r.runId } }
@@ -1270,7 +1447,8 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
1270
1447
  case "busy": {
1271
1448
  // Another run holds the unit's thread: wait for its end, then ask
1272
1449
  // again — unless the pipeline's wall clock ran out meanwhile.
1273
- if (remainingMs(clocked) < SHIP_ROUND_RESERVE_MS) return end(clocked, capEnding(clocked, p.round));
1450
+ const recarved = roundCarve(clocked, p.round);
1451
+ if (recarved.kind === "refused") return end(clocked, capEnding(clocked, p.round, recarved));
1274
1452
  return {
1275
1453
  state: {
1276
1454
  ...clocked,
@@ -1302,8 +1480,19 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
1302
1480
  }
1303
1481
  break;
1304
1482
  }
1305
- case "busy-wait":
1306
- return { state: { ...s, phase: { at: "spawn", round: p.round, busy: p.n } }, notes: [] };
1483
+ case "busy-wait": {
1484
+ // The clock moved while the spawn was busy: the round is carved again
1485
+ // from what remains, and a carve now under the floor ends at the cap.
1486
+ const carved = roundCarve(s, p.round);
1487
+ if (carved.kind === "refused") return end(s, capEnding(s, p.round, carved));
1488
+ return {
1489
+ state: {
1490
+ ...s,
1491
+ phase: { at: "spawn", round: p.round, busy: p.n, minutes: carved.minutes, holds: carved.holds },
1492
+ },
1493
+ notes: [],
1494
+ };
1495
+ }
1307
1496
  case "wait":
1308
1497
  return {
1309
1498
  state: { ...s, phase: { at: "read", round: p.round, runId: p.runId, n: p.n, until: p.until } },
@@ -1356,7 +1545,7 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
1356
1545
  if (r.outcome === "refused")
1357
1546
  return end(clocked, { kind: "merge_refused", pr: p.pr, reason: r.reason, reviewRounds: s.reviewRounds });
1358
1547
  const waited = r.at - p.since;
1359
- if (waited >= MERGE_WAIT_MAX_MS)
1548
+ if (waited >= p.waitMs)
1360
1549
  return end(clocked, {
1361
1550
  kind: "merge_refused",
1362
1551
  pr: p.pr,
@@ -1364,13 +1553,19 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
1364
1553
  reviewRounds: s.reviewRounds,
1365
1554
  });
1366
1555
  return {
1367
- state: { ...clocked, phase: { at: "merge-wait", pr: p.pr, headSha: p.headSha, n: p.n, since: p.since } },
1556
+ state: {
1557
+ ...clocked,
1558
+ phase: { at: "merge-wait", pr: p.pr, headSha: p.headSha, n: p.n, since: p.since, waitMs: p.waitMs },
1559
+ },
1368
1560
  notes: [],
1369
1561
  };
1370
1562
  }
1371
1563
  case "merge-wait":
1372
1564
  return {
1373
- state: { ...s, phase: { at: "merge", pr: p.pr, headSha: p.headSha, n: p.n + 1, since: p.since } },
1565
+ state: {
1566
+ ...s,
1567
+ phase: { at: "merge", pr: p.pr, headSha: p.headSha, n: p.n + 1, since: p.since, waitMs: p.waitMs },
1568
+ },
1374
1569
  notes: [],
1375
1570
  };
1376
1571
  case "ended":
@@ -1458,6 +1653,12 @@ export function renderUnitReport(s: UnitPipelineState, facts?: MergeReadyFacts):
1458
1653
  // stated decision, never a silent one.
1459
1654
  const level = s.input.addressSeverity ?? DEFAULT_ADDRESS_SEVERITY;
1460
1655
  const levelLine = `Severity addressed: ${level} and above (set by ${s.input.addressSeveritySource ?? "org"}).`;
1656
+ // The grant as the instance carries it (decision 0046): spent of granted,
1657
+ // the cap when one is set, and who granted it — zero of zero until a scope
1658
+ // or a directive says otherwise, and nothing spends it before the renewal
1659
+ // decision exists.
1660
+ const grant = s.input.grant ?? DEFAULT_GRANT;
1661
+ const grantLine = `Renewals: 0 of ${grant.renewals} spent${grant.costCapUsd !== undefined ? `, cost cap $${grant.costCapUsd}` : ""} (granted by ${s.input.grantSource ?? "org"}).`;
1461
1662
  const lastFindings = s.findingsByRound[e.reviewRounds] ?? [];
1462
1663
  const skipped = lastFindings.filter((f) => !findingsAtOrAbove([f], level).length);
1463
1664
  const skippedLine =
@@ -1473,6 +1674,7 @@ export function renderUnitReport(s: UnitPipelineState, facts?: MergeReadyFacts):
1473
1674
  `✅ 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.`,
1474
1675
  verdictLine,
1475
1676
  levelLine,
1677
+ grantLine,
1476
1678
  ...(skippedLine ? [skippedLine] : []),
1477
1679
  declinedLine,
1478
1680
  ].join("\n");
@@ -1481,6 +1683,7 @@ export function renderUnitReport(s: UnitPipelineState, facts?: MergeReadyFacts):
1481
1683
  `✅ Merge-ready after ${rounds}: ${e.pr.url}`,
1482
1684
  verdictLine,
1483
1685
  levelLine,
1686
+ grantLine,
1484
1687
  ...(skippedLine ? [skippedLine] : []),
1485
1688
  declinedLine,
1486
1689
  // What the driver read at the approved head when it composed this
@@ -1505,7 +1708,7 @@ export function renderUnitReport(s: UnitPipelineState, facts?: MergeReadyFacts):
1505
1708
  ]);
1506
1709
  case "wall_clock_cap":
1507
1710
  return join([
1508
- `🧢 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 — no approval after ${rounds}.${prLine}`,
1711
+ `🧢 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}`,
1509
1712
  budgetSplitLine(e.spent, s.input.caps.maxMinutes),
1510
1713
  splitReport(s),
1511
1714
  reissue,
@@ -1526,7 +1729,19 @@ export function renderUnitReport(s: UnitPipelineState, facts?: MergeReadyFacts):
1526
1729
  reissue,
1527
1730
  ]);
1528
1731
  case "aborted":
1529
- return join([e.finalReply, e.reason, `⚠️ Ship aborted after ${rounds}.`, reissue]);
1732
+ return join([
1733
+ e.finalReply,
1734
+ e.reason,
1735
+ e.renewal !== undefined ? `🔁 Not renewed: ${e.renewal.line}.` : undefined,
1736
+ `⚠️ Ship aborted after ${rounds}.`,
1737
+ reissue,
1738
+ ]);
1739
+ case "continued":
1740
+ return join([
1741
+ e.finalReply,
1742
+ `🔁 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` : ""}.`,
1743
+ budgetSplitLine(e.spent, s.input.caps.maxMinutes),
1744
+ ]);
1530
1745
  case "no_verdict":
1531
1746
  return join([
1532
1747
  `⚠️ 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.`,