@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
@@ -0,0 +1,349 @@
1
+ // Every wall clock in Switchboard as one table (docs/decisions/0046; docs/reference/specs/harness-pi.md
2
+ // item 15; docs/reference/specs/agent-ship.md item 8). A budget is a LEASE a
3
+ // parent carves from its own remainder, never a constant a file holds on its
4
+ // own: a preset ASKS for a lease and does useful work above a FLOOR; a parent
5
+ // that runs a loop holds back a RESERVE, derived from the floors and the
6
+ // provisioning of every round that must still follow, plus the merge wait's
7
+ // floor; and the lease covers everything the run does, the loop, the write-up
8
+ // and the post-step, each an ALLOWANCE named here. The registry reads its
9
+ // `maxMinutes` and `maxTurns` from this module and `budgets.check.test.ts`
10
+ // asserts the fits, so a number that breaks another's assumption is a red
11
+ // build. `carve` is called by the ship coordinator for every round (agent-ship
12
+ // item 8), `fit` by the config validator at load and by the fork over a clipped
13
+ // request, and `carveChildOfParent` by the conductor's spawn.
14
+ //
15
+ // Deliberately free of node: imports and of the agent registry, so the
16
+ // Workflow-driven coordinator and the deploy Workers can bundle it — the
17
+ // dependency runs registry → budgets, never the reverse.
18
+
19
+ export const MINUTE_MS = 60_000;
20
+
21
+ /** One calendar day in milliseconds: the unit the daily cost and delivery ranges
22
+ * step by (`dayOf`, the day count of a range). A day is not a lease, but it is
23
+ * a duration, and every duration is read from this table rather than written
24
+ * as a literal where it is used. */
25
+ export const DAY_MS = 24 * 60 * MINUTE_MS;
26
+
27
+ /** How long the confirmation a routed write is offered as stays pending
28
+ * (docs/decisions/0044; docs/reference/specs/routing-and-config.md item 25):
29
+ * the connect ticket's ten minutes. The bot passes it to the config object,
30
+ * which stamps the expiry on its own clock. */
31
+ export const CONFIRMATION_TTL_MS = 10 * MINUTE_MS;
32
+
33
+ /** The presets that run the tool loop, and the one pipeline preset. */
34
+ export const LOOP_PRESETS = ["general", "coding", "review", "research", "explore", "conductor"] as const;
35
+ export type LoopPreset = (typeof LOOP_PRESETS)[number];
36
+ export type Preset = LoopPreset | "ship";
37
+
38
+ /** What each preset asks for when nothing above it is tighter, in minutes. The
39
+ * pipeline's (`ship`) is the wall clock one segment of its loop runs under;
40
+ * a deployment's `ship.maxMinutes` replaces it, held to `fit` below. */
41
+ export const ASKS: Readonly<Record<Preset, number>> = {
42
+ general: 5,
43
+ coding: 45,
44
+ review: 25,
45
+ ship: 120,
46
+ research: 8,
47
+ explore: 120,
48
+ conductor: 120,
49
+ };
50
+
51
+ /** The rounds a ship loop is made of. `fix` is a coding child handed the
52
+ * review's findings; `merge` is the runner's wait on the guards. */
53
+ export type RoundKind = "coding" | "review" | "fix" | "merge";
54
+
55
+ /** The least lease in which a round does useful work, in minutes. A carve that
56
+ * falls under the floor is refused rather than dispatched: a two-minute
57
+ * review or fix costs an attach and a model turn and finishes nothing.
58
+ * Review's is the ledger's 90th percentile of completed reviews (5.1 min over
59
+ * 181); coding's and the merge wait's are guesses until the ledger says. */
60
+ export const PRESET_FLOORS: Readonly<Record<LoopPreset, number>> = {
61
+ general: 2,
62
+ coding: 10,
63
+ review: 5,
64
+ research: 3,
65
+ explore: 15,
66
+ conductor: 15,
67
+ };
68
+ export const FLOORS: Readonly<Record<RoundKind, number>> = {
69
+ coding: PRESET_FLOORS.coding,
70
+ fix: PRESET_FLOORS.coding,
71
+ review: PRESET_FLOORS.review,
72
+ merge: 10,
73
+ };
74
+
75
+ /** The merge wait's own ask: how long the runner waits on the guards at most
76
+ * when the remainder allows it. */
77
+ export const MERGE_WAIT_ASK_MINUTES = 60;
78
+
79
+ /** The ship runner's waits, in minutes: the margin a child's wait allows past
80
+ * its budget, the slice a wait is asked in, the merge door's re-ask cadence,
81
+ * and the pause before a busy spawn is asked again. */
82
+ export const SHIP_WAIT = { marginMinutes: 5, chunkMinutes: 5, mergeChunkMinutes: 5, busyRetryMinutes: 2 } as const;
83
+
84
+ /** The named amounts a lease holds back, in minutes. Each stands for a step
85
+ * every run or round pays: `provision` is attach and restore before the
86
+ * harness's clock starts; `writeUp` is the final answer after the loop ends;
87
+ * `commandWriteUp` is what the last command leaves for that answer; `execCall`
88
+ * is the exec client's wait past a command's own budget; `bearerGrace` is how
89
+ * far past the lease the model bearer stays valid for the last call's tail. */
90
+ export const ALLOWANCES = {
91
+ provision: 3,
92
+ writeUp: 3,
93
+ commandWriteUp: 1,
94
+ execCall: 0.5,
95
+ bearerGrace: 1,
96
+ } as const;
97
+
98
+ /** The post-step turn a preset runs after its loop, in minutes: the coding
99
+ * run's description turn, the review's verdict turn, none for the rest. */
100
+ export const POST_STEP_MINUTES: Readonly<Record<Preset, number>> = {
101
+ coding: 5,
102
+ review: 3,
103
+ general: 0,
104
+ research: 0,
105
+ explore: 0,
106
+ conductor: 0,
107
+ ship: 0,
108
+ };
109
+
110
+ /** The post-step allowance of a preset named by its def's `name`: a name
111
+ * outside the table (a test's, a command run's) runs no post-step. */
112
+ export function postStepMinutes(preset: string): number {
113
+ return (POST_STEP_MINUTES as Readonly<Record<string, number>>)[preset] ?? 0;
114
+ }
115
+
116
+ /** The post-step turn's lease, in minutes (fractional when the remainder is:
117
+ * the harness turns it back into ms): the preset's allowance, or the lease's
118
+ * remainder when that is less — never under a minute, so a turn the loop's
119
+ * write-up crowded still gets its one chance. The bearer's grace covers that
120
+ * floor for a turn that starts at or before the lease's end; a turn that
121
+ * starts later runs on a bearer that may expire under it, and its refused
122
+ * call fails soft — the run's answer already stands. Without a remainder
123
+ * (no harness session says) the allowance stands. */
124
+ export function postStepLease(preset: string, remainingMs: number | undefined): number {
125
+ const allowance = postStepMinutes(preset);
126
+ if (remainingMs === undefined) return allowance;
127
+ return Math.min(allowance, Math.max(1, remainingMs / MINUTE_MS));
128
+ }
129
+
130
+ /** A follow-up turn's lease, in ms: the lesser of what it asks and what the
131
+ * run's lease still holds, never under a minute — the grace covers that floor
132
+ * when the turn starts by the lease's end; later, the bearer may expire under
133
+ * the turn and its call is refused, failing soft. */
134
+ export function turnLeaseMs(askMinutes: number, remainingMs: number): number {
135
+ return Math.min(askMinutes * MINUTE_MS, Math.max(MINUTE_MS, remainingMs));
136
+ }
137
+
138
+ /** The wrap-up warning's place before the loop's end: three minutes, or a
139
+ * quarter of the loop when the loop is shorter than twelve. */
140
+ export const WRAP_UP_WARNING = { minutes: 3, fraction: 0.25 } as const;
141
+
142
+ /** The clocks a harness keeps for one lease (docs/reference/specs/harness-pi.md
143
+ * items 6 and 15). The lease ENDS at `deadline`; the LOOP ENDS at `loopEnd`,
144
+ * the write-up allowance and the preset's post-step earlier, so both run
145
+ * inside the lease; the WARNING is steered at `warnAt`; the write-up is
146
+ * BOUNDED by `finaleMs`. A follow-up turn on the session (`kind: "turn"`)
147
+ * holds nothing back — its deliverable is a tool call, not a write-up — and
148
+ * its loop ends at its deadline. A lease shorter than its hold-back has no
149
+ * loop time: the loop ends at its start, never before it. */
150
+ export interface LoopClock {
151
+ startedAt: number;
152
+ deadline: number;
153
+ loopEnd: number;
154
+ warnAt: number;
155
+ finaleMs: number;
156
+ }
157
+
158
+ export function loopClock(
159
+ startedAt: number,
160
+ remainingMs: number,
161
+ preset: string,
162
+ kind: "loop" | "turn" = "loop",
163
+ ): LoopClock {
164
+ const deadline = startedAt + remainingMs;
165
+ const holdBackMs = kind === "loop" ? (ALLOWANCES.writeUp + postStepMinutes(preset)) * MINUTE_MS : 0;
166
+ const loopEnd = Math.max(startedAt, deadline - holdBackMs);
167
+ const warnAt =
168
+ loopEnd - Math.min(WRAP_UP_WARNING.minutes * MINUTE_MS, (loopEnd - startedAt) * WRAP_UP_WARNING.fraction);
169
+ return { startedAt, deadline, loopEnd, warnAt, finaleMs: ALLOWANCES.writeUp * MINUTE_MS };
170
+ }
171
+
172
+ /** When a run's model-proxy bearer expires: the lease's end plus the grace
173
+ * (docs/reference/specs/model-proxy.md item 2). */
174
+ export function bearerExpiresAt(leaseEndsAt: number): number {
175
+ return leaseEndsAt + ALLOWANCES.bearerGrace * MINUTE_MS;
176
+ }
177
+
178
+ /** The bearer's expiry as the mint at provisioning sets it, before the lease
179
+ * has started: the provisioning allowance, the lease and the grace — replaced
180
+ * by `bearerExpiresAt` the moment the harness starts the lease. */
181
+ export function provisionalBearerExpiresAt(now: number, leaseMinutes: number): number {
182
+ return now + (ALLOWANCES.provision + leaseMinutes + ALLOWANCES.bearerGrace) * MINUTE_MS;
183
+ }
184
+
185
+ /** The per-command bash budget's rows: the default when a call names none, the
186
+ * ceiling a call may raise it to, and the floor under which a number is a
187
+ * typo (docs/reference/specs/execution.md item 11). */
188
+ export const BASH_COMMAND = { defaultMinutes: 5, maxMinutes: 20, minMs: 1_000 } as const;
189
+
190
+ /** The pace that marks a run as looping rather than working: a model turn
191
+ * every ten seconds, sustained for the whole wall clock. A busy run takes
192
+ * 20–40 s a turn (a model think plus a tool call), so a run that averages six
193
+ * a minute from start to end is re-issuing calls, not making progress — and
194
+ * its turn cap ends it before the wall clock would, with a write-up that
195
+ * says so (docs/reference/specs/harness-pi.md item 15). */
196
+ export const RUNAWAY_TURNS_PER_MINUTE = 6;
197
+
198
+ /** The turn cap a wall clock implies: `maxMinutes × RUNAWAY_TURNS_PER_MINUTE`.
199
+ * Every preset that runs the loop derives its `maxTurns` from this, so the
200
+ * cap is never a number a good run reaches — the minutes are the budget. */
201
+ export function runawayTurnCap(maxMinutes: number): number {
202
+ return maxMinutes * RUNAWAY_TURNS_PER_MINUTE;
203
+ }
204
+
205
+ /** A pipeline's loop as the config allows it: `maxRounds` counts review rounds. */
206
+ export interface Loop {
207
+ maxRounds: number;
208
+ }
209
+
210
+ /** A round's position in its loop: its kind and its index, so the reserve is
211
+ * the rounds after that index, never a table keyed by kind alone. */
212
+ export interface RoundPosition {
213
+ kind: RoundKind;
214
+ index: number;
215
+ }
216
+
217
+ /** The rounds of a loop in order: the coding round, then a review and, after
218
+ * every review but the last, a fix, then the merge wait. */
219
+ export function loopRounds(loop: Loop): RoundKind[] {
220
+ const rounds: RoundKind[] = ["coding"];
221
+ for (let i = 0; i < loop.maxRounds; i++) {
222
+ rounds.push("review");
223
+ if (i < loop.maxRounds - 1) rounds.push("fix");
224
+ }
225
+ rounds.push("merge");
226
+ return rounds;
227
+ }
228
+
229
+ /** Where a round sits in `loopRounds`: the coding round first; review round
230
+ * `n` (counted from 1) and the fix that follows it at `2n − 1` and `2n`; the
231
+ * merge wait last. The ship coordinator numbers its rounds this way, so the
232
+ * reserve it carves with is the one the fit assumed. */
233
+ export function loopPosition(loop: Loop, kind: RoundKind, n = 0): number {
234
+ switch (kind) {
235
+ case "coding":
236
+ return 0;
237
+ case "review":
238
+ return 2 * n - 1;
239
+ case "fix":
240
+ return 2 * n;
241
+ case "merge":
242
+ return 2 * loop.maxRounds;
243
+ }
244
+ }
245
+
246
+ /** A child spawned outside any loop (a conductor's): the parent's whole
247
+ * remainder in whole minutes, refused under the child preset's floor — a
248
+ * child under its floor costs a thread and a model turn and finishes nothing. */
249
+ export function carveChildOfParent(
250
+ remainingMs: number,
251
+ preset: LoopPreset,
252
+ ): { kind: "carved"; minutes: number } | { kind: "refused"; reason: "under floor"; minutes: number; floor: number } {
253
+ const minutes = Math.max(0, Math.floor(remainingMs / MINUTE_MS));
254
+ const floor = PRESET_FLOORS[preset];
255
+ if (minutes < floor) return { kind: "refused", reason: "under floor", minutes, floor };
256
+ return { kind: "carved", minutes };
257
+ }
258
+
259
+ /** The minutes a round holds back for what must follow it in the loop: the
260
+ * floor plus provisioning of every later review and fix, and the merge
261
+ * wait's floor. A merge holds nothing back; a round with no loop (a
262
+ * conductor's child) holds nothing back either. */
263
+ export function reserveMinutes(
264
+ round: RoundPosition,
265
+ loop: Loop | undefined,
266
+ floors: Readonly<Record<RoundKind, number>> = FLOORS,
267
+ ): number {
268
+ if (!loop) return 0;
269
+ let reserve = 0;
270
+ for (const kind of loopRounds(loop).slice(round.index + 1)) {
271
+ reserve += kind === "merge" ? floors.merge : floors[kind] + ALLOWANCES.provision;
272
+ }
273
+ return reserve;
274
+ }
275
+
276
+ /** What a round asks for: the preset's ask for a coding or fix round, the
277
+ * review's for a review, the merge wait's own for the merge. */
278
+ export function roundAskMinutes(kind: RoundKind): number {
279
+ switch (kind) {
280
+ case "coding":
281
+ case "fix":
282
+ return ASKS.coding;
283
+ case "review":
284
+ return ASKS.review;
285
+ case "merge":
286
+ return MERGE_WAIT_ASK_MINUTES;
287
+ }
288
+ }
289
+
290
+ /** A carve's result: the minutes with what bounded them (`ask` when the round
291
+ * got its whole ask, `parent` when the remainder minus the reserve was
292
+ * tighter) and what the parent holds back, or a refusal when the minutes the
293
+ * remainder leaves fall under the round's floor. */
294
+ export type Carve =
295
+ | { kind: "carved"; minutes: number; boundedBy: "ask" | "parent"; holds: number }
296
+ | { kind: "refused"; reason: "under floor"; minutes: number; floor: number; holds: number };
297
+
298
+ /** The only place a round's minutes are computed: the parent's remainder in
299
+ * whole minutes minus the round's reserve, capped at the round's ask, refused
300
+ * under the round's floor. `loop` is undefined for a child outside any loop. */
301
+ export function carve(remainingMs: number, round: RoundPosition, loop: Loop | undefined): Carve {
302
+ const holds = reserveMinutes(round, loop);
303
+ const ask = roundAskMinutes(round.kind);
304
+ const headroom = Math.floor(remainingMs / MINUTE_MS) - holds;
305
+ const minutes = Math.min(ask, headroom);
306
+ const floor = FLOORS[round.kind];
307
+ // A refusal reports what the remainder left, never a negative number.
308
+ if (minutes < floor) return { kind: "refused", reason: "under floor", minutes: Math.max(0, minutes), floor, holds };
309
+ return { kind: "carved", minutes, boundedBy: minutes === ask ? "ask" : "parent", holds };
310
+ }
311
+
312
+ /** A pipeline's wall clock and its loop: what `fit` judges. */
313
+ export interface Pipeline extends Loop {
314
+ maxMinutes: number;
315
+ }
316
+
317
+ /** The fit: a pipeline holds its first child at its ask and every later round
318
+ * at its floor — `provision + ask(coding) + reserve(coding, loop) ≤
319
+ * maxMinutes`. Asserted at verify over the registry, at config load over the
320
+ * deployment's `ship` block (`validateShip`), and at the fork over a request a
321
+ * boundary or a `budget:` directive clipped. `need` is the sum a refusal names. */
322
+ export function fit(pipeline: Pipeline): { ok: boolean; need: number; have: number } {
323
+ const need = ALLOWANCES.provision + ASKS.coding + reserveMinutes({ kind: "coding", index: 0 }, pipeline);
324
+ return { ok: pipeline.maxMinutes >= need, need, have: pipeline.maxMinutes };
325
+ }
326
+
327
+ // ---- the grant (decision 0046, Renewal: the grant decides, the lease continues) ----
328
+
329
+ /** What the request authorizes for the whole problem beyond one lease: a count
330
+ * of renewals and a cost cap in dollars. A lease is sized by the infrastructure
331
+ * that must outlive it; the grant is sized by the person — a scope's word or
332
+ * the request's `renewals:` directive — and the runner spends it one segment at
333
+ * a time. Zero renewals and no cap by default, so nothing renews until someone
334
+ * says so; `costCapUsd` absent is no cap, never zero. */
335
+ export interface Grant {
336
+ renewals: number;
337
+ costCapUsd?: number;
338
+ }
339
+
340
+ /** Which layer's word the grant is: the request's directive, the user's scope,
341
+ * the channel's, or the org's `ship.grant` — the default counts as the org's. */
342
+ export type GrantSource = "org" | "channel" | "user" | "run";
343
+
344
+ export const DEFAULT_GRANT: Grant = { renewals: 0 };
345
+
346
+ /** The most renewals one request may carry: thirteen segments of the ship
347
+ * preset's ask are a day, the longest problem a person hands over in one
348
+ * message before a plan should carry it. */
349
+ export const GRANT_RENEWALS_MAX = 12;
@@ -8,6 +8,7 @@
8
8
  // parent record the spawn route reads the requester from, and the names the
9
9
  // routes decide on.
10
10
  //
11
+ import type { Grant, GrantSource } from "../budgets.js";
11
12
  import type { AddressSeverity, AddressSeveritySource } from "../ship/coordinator.js";
12
13
 
13
14
  // A coordinator is a Workflow instance in the shim Worker whose children are
@@ -181,6 +182,13 @@ export interface CoordinatorInstance {
181
182
  * the machine reads one value; absent reads as the default (`minor`, org). */
182
183
  addressSeverity?: AddressSeverity;
183
184
  addressSeveritySource?: AddressSeveritySource;
185
+ /** The grant (decision 0046, the renewable lease): the renewals and cost cap
186
+ * the request carries, resolved once by the ship fork (directive count >
187
+ * user > channel > org) and written here beside `merge`; absent reads as
188
+ * zero renewals and no cap, the org's. Nothing renews until the renewal
189
+ * decision reads it. */
190
+ grant?: Grant;
191
+ grantSource?: GrantSource;
184
192
  /** The pipeline's caps as the profile gate clipped them: the rounds cap and the wall clock per unit. */
185
193
  caps?: { maxRounds: number; maxMinutes: number };
186
194
  /** The status card in the requesting thread, when the channel has one — what
@@ -195,6 +203,13 @@ export interface CoordinatorInstance {
195
203
  attempt?: number;
196
204
  }
197
205
 
206
+ export interface UnitSegment {
207
+ index: number;
208
+ from?: string;
209
+ runId?: string;
210
+ at: number;
211
+ }
212
+
198
213
  /** One unit of the plan an instance runs (a task string is a generated plan of
199
214
  * one unit, `U1`): its branch, the units it waits on, and — as the runner
200
215
  * reaches it — its thread, its pull request, the round boundaries the card
@@ -229,6 +244,13 @@ export interface CoordinatorUnit {
229
244
  * opened or updated): the next attempt's pre-check starts at the review
230
245
  * round when the open pull request still heads exactly here. */
231
246
  lastPush?: string;
247
+ /** The renewals the unit spent (decision 0046, Renewal): one row per segment
248
+ * the grant opened after the first, keyed by the segment's index — written
249
+ * by `unit-end` on a `continued` ending before the segment runs, so a runner
250
+ * reclaimed between a segment's end and its renewal finds the row and never
251
+ * renews the same segment twice. `from` is the sha the segment continues
252
+ * from, `runId` the coding run whose write-up briefs it. */
253
+ segments?: UnitSegment[];
232
254
  /** The round boundaries the coordinator reported, oldest first (the `ship_round` vocabulary). */
233
255
  rounds: Array<{ index: number; agent: string; outcome: string; at: number }>;
234
256
  /** How the unit ended: the ending's kind and the thread's report, when it has. */
@@ -281,6 +303,15 @@ export function isCoordinatorInstance(v: unknown): v is CoordinatorInstance {
281
303
  }
282
304
 
283
305
  /** Structural check on a unit row from outside the process. */
306
+ const isSegment = (v: unknown): boolean =>
307
+ isObject(v) &&
308
+ typeof v.index === "number" &&
309
+ Number.isInteger(v.index) &&
310
+ v.index >= 2 &&
311
+ (v.from === undefined || isText(v.from)) &&
312
+ (v.runId === undefined || isText(v.runId)) &&
313
+ typeof v.at === "number";
314
+
284
315
  export function isCoordinatorUnit(v: unknown): v is CoordinatorUnit {
285
316
  if (!isObject(v)) return false;
286
317
  const r = v;
@@ -293,6 +324,7 @@ export function isCoordinatorUnit(v: unknown): v is CoordinatorUnit {
293
324
  if (r.pr !== undefined && !isPr(r.pr)) return false;
294
325
  if (r.resume !== undefined && !isResume(r.resume)) return false;
295
326
  if (r.lastPush !== undefined && !isText(r.lastPush)) return false;
327
+ if (r.segments !== undefined && (!Array.isArray(r.segments) || !r.segments.every(isSegment))) return false;
296
328
  if (
297
329
  !Array.isArray(r.rounds) ||
298
330
  r.rounds.length > MAX_ROUNDS ||
@@ -33,6 +33,7 @@
33
33
  // plan's re-issue. A task string's ship branch waits for a person. Node-free:
34
34
  // the shim Worker imports this by relative path.
35
35
 
36
+ import { DEFAULT_GRANT, GRANT_RENEWALS_MAX, type Grant, type GrantSource } from "../budgets.js";
36
37
  import {
37
38
  applyReturn,
38
39
  cursorFinished,
@@ -54,8 +55,9 @@ import {
54
55
  type ShipCaps,
55
56
  type StepReturn,
56
57
  type UnitEnding,
57
- type UnitPipelineInput,
58
58
  type UnitPipelineState,
59
+ stepPrefixOf,
60
+ type UnitSession,
59
61
  } from "../ship/coordinator.js";
60
62
  import { checksSettledEventType, isCoordinatorUnit, runFinishedEventType, type CoordinatorUnit } from "./contract.js";
61
63
 
@@ -165,12 +167,14 @@ interface PlanFacts {
165
167
  /** The severity to address, beside `merge`: the level an approve's findings are held to, and which layer set it. */
166
168
  addressSeverity: AddressSeverity;
167
169
  addressSeveritySource: AddressSeveritySource;
170
+ /** The grant beside them (decision 0046): what a renewal could spend, and which layer granted it. */
171
+ grant: Grant;
172
+ grantSource: GrantSource;
168
173
  /** The instance's mark as the plan route answers it: a generated one-unit plan (a `plan` with no `path`). */
169
174
  generated: boolean;
170
175
  repo: string;
171
176
  base: string;
172
177
  caps: ShipCaps;
173
- childMinutes: UnitPipelineInput["childMinutes"];
174
178
  units: CoordinatorUnit[];
175
179
  }
176
180
 
@@ -178,18 +182,27 @@ const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "obj
178
182
  const isMinutes = (v: unknown): v is Record<string, number> =>
179
183
  isRecord(v) && Object.values(v).every((n) => typeof n === "number" && Number.isFinite(n));
180
184
 
185
+ /** The grant as the plan route answers it; anything unreadable — a count
186
+ * outside the module's ceiling included — is the default: nothing renews on a guess. */
187
+ function readGrant(raw: unknown): Grant {
188
+ if (
189
+ !isRecord(raw) ||
190
+ typeof raw.renewals !== "number" ||
191
+ !Number.isInteger(raw.renewals) ||
192
+ raw.renewals < 0 ||
193
+ raw.renewals > GRANT_RENEWALS_MAX
194
+ )
195
+ return DEFAULT_GRANT;
196
+ const cap = raw.costCapUsd;
197
+ return { renewals: raw.renewals, ...(typeof cap === "number" && cap > 0 ? { costCapUsd: cap } : {}) };
198
+ }
199
+
181
200
  function readPlan(a: BotAnswer): PlanFacts {
182
201
  const b = a.body;
183
202
  if (b.ok !== true) throw new UnreadableAnswer("plan", a, "not ok");
184
203
  if (typeof b.repo !== "string" || typeof b.base !== "string") throw new UnreadableAnswer("plan", a, "repo and base");
185
204
  if (!isMinutes(b.caps) || typeof b.caps.maxRounds !== "number" || typeof b.caps.maxMinutes !== "number")
186
205
  throw new UnreadableAnswer("plan", a, "caps");
187
- if (
188
- !isMinutes(b.childMinutes) ||
189
- typeof b.childMinutes.coding !== "number" ||
190
- typeof b.childMinutes.review !== "number"
191
- )
192
- throw new UnreadableAnswer("plan", a, "childMinutes");
193
206
  const units: unknown = b.units;
194
207
  if (!Array.isArray(units) || !units.every(isCoordinatorUnit)) throw new UnreadableAnswer("plan", a, "units");
195
208
  return {
@@ -200,11 +213,13 @@ function readPlan(a: BotAnswer): PlanFacts {
200
213
  b.addressSeveritySource === "run" || b.addressSeveritySource === "user" || b.addressSeveritySource === "channel"
201
214
  ? b.addressSeveritySource
202
215
  : "org",
216
+ grant: readGrant(b.grant),
217
+ grantSource:
218
+ b.grantSource === "run" || b.grantSource === "user" || b.grantSource === "channel" ? b.grantSource : "org",
203
219
  generated: b.generated === true,
204
220
  repo: b.repo,
205
221
  base: b.base,
206
222
  caps: { maxRounds: b.caps.maxRounds, maxMinutes: b.caps.maxMinutes },
207
- childMinutes: { coding: b.childMinutes.coding, review: b.childMinutes.review },
208
223
  units,
209
224
  };
210
225
  }
@@ -261,6 +276,10 @@ function readRecordReturn(step: string, a: BotAnswer): StepReturn {
261
276
  reviewHead,
262
277
  dispositions,
263
278
  handoff,
279
+ pushed,
280
+ leaseStartedAt,
281
+ costUsd,
282
+ handoffLists,
264
283
  } = facts;
265
284
  return {
266
285
  type: "read-record",
@@ -278,6 +297,11 @@ function readRecordReturn(step: string, a: BotAnswer): StepReturn {
278
297
  ...(reviewHead !== undefined ? { reviewHead } : {}),
279
298
  ...(dispositions !== undefined ? { dispositions } : {}),
280
299
  ...(handoff !== undefined ? { handoff } : {}),
300
+ // The renewal's facts (decision 0046), as the record carries them.
301
+ ...(Array.isArray(pushed) ? { pushed } : {}),
302
+ ...(typeof leaseStartedAt === "number" ? { leaseStartedAt } : {}),
303
+ ...(typeof costUsd === "number" || costUsd === null ? { costUsd } : {}),
304
+ ...(handoffLists !== undefined ? { handoffLists } : {}),
281
305
  },
282
306
  at: a.body.at,
283
307
  };
@@ -466,11 +490,16 @@ async function runUnit(
466
490
  instanceId: string,
467
491
  node: PlanUnitNode,
468
492
  plan: PlanFacts,
493
+ session?: UnitSession,
469
494
  ): Promise<UnitEnding> {
470
495
  const unit = node.id;
496
+ // A renewal's segment names its steps under the segment (`U10/s2/…`), so
497
+ // the Workflow's durable step cache never answers segment two with segment
498
+ // one's results (decision 0046).
499
+ const prefix = stepPrefixOf(unit, session);
471
500
  const tag = { parentInstanceId: instanceId, unit };
472
501
  const start = readUnitStart(
473
- answerOf("unit-start", await step.do(`${unit}/start`, STEP_CONFIG, () => call(bot, "unit-start", tag))),
502
+ answerOf("unit-start", await step.do(`${prefix}/start`, STEP_CONFIG, () => call(bot, "unit-start", tag))),
474
503
  );
475
504
  // A resume at review (agent-ship item 10) rides the unit's row: the pull
476
505
  // request of ship's own the requester named opens the pipeline at its first
@@ -486,7 +515,6 @@ async function runUnit(
486
515
  repo: plan.repo,
487
516
  base: plan.base,
488
517
  caps: plan.caps,
489
- childMinutes: plan.childMinutes,
490
518
  // The instance's field decides who merges (record 0031's merge grant),
491
519
  // carried here by the plan route: the hand-off wrote `runner` on a
492
520
  // seeded plan and `person` on a task, and the door re-checks it — the
@@ -494,9 +522,12 @@ async function runUnit(
494
522
  merge: plan.merge,
495
523
  addressSeverity: plan.addressSeverity,
496
524
  addressSeveritySource: plan.addressSeveritySource,
525
+ grant: plan.grant,
526
+ grantSource: plan.grantSource,
497
527
  generated: plan.generated,
498
528
  ...(resume !== undefined ? { resume } : {}),
499
529
  ...(lastPush !== undefined ? { lastPush } : {}),
530
+ ...(session !== undefined ? { session } : {}),
500
531
  },
501
532
  start.at,
502
533
  );
@@ -509,7 +540,7 @@ async function runUnit(
509
540
  for (const note of transition.notes) {
510
541
  if (note.type === "round") {
511
542
  const body = { ...tag, index: note.index, agent: note.agent, outcome: note.outcome };
512
- await step.do(`${unit}/note/${++notes}`, STEP_CONFIG, () => call(bot, "round", body));
543
+ await step.do(`${prefix}/note/${++notes}`, STEP_CONFIG, () => call(bot, "round", body));
513
544
  } else {
514
545
  // A merge_ready ending names the pull request as it is at the APPROVED
515
546
  // head (agent-ship item 9): one more pr-check reads the facts fresh —
@@ -521,10 +552,10 @@ async function runUnit(
521
552
  if (note.ending.kind === "merge_ready") {
522
553
  try {
523
554
  const check = prCheckReturn(
524
- `${unit}/end/pr-facts`,
555
+ `${prefix}/end/pr-facts`,
525
556
  answerOf(
526
557
  "pr-check",
527
- await step.do(`${unit}/end/pr-facts`, STEP_CONFIG, () => call(bot, "pr-check", tag)),
558
+ await step.do(`${prefix}/end/pr-facts`, STEP_CONFIG, () => call(bot, "pr-check", tag)),
528
559
  ),
529
560
  );
530
561
  if (check.type === "pr-check" && check.pr.state === "merged")
@@ -548,8 +579,20 @@ async function runUnit(
548
579
  ? { headSha: note.ending.headSha }
549
580
  : {}),
550
581
  ...(state.lastCodingRunId !== undefined ? { codingRunId: state.lastCodingRunId } : {}),
582
+ // A continued ending is a segment's end, not the unit's: the bot
583
+ // writes the renewal as a row keyed by the next segment's index
584
+ // (decision 0046), so a runner reclaimed here never renews twice.
585
+ ...(note.ending.kind === "continued"
586
+ ? {
587
+ segment: {
588
+ index: note.ending.segment,
589
+ ...(note.ending.from !== undefined ? { from: note.ending.from } : {}),
590
+ runId: note.ending.runId,
591
+ },
592
+ }
593
+ : {}),
551
594
  };
552
- await step.do(`${unit}/end`, STEP_CONFIG, () => call(bot, "unit-end", body));
595
+ await step.do(`${prefix}/end`, STEP_CONFIG, () => call(bot, "unit-end", body));
553
596
  }
554
597
  }
555
598
  }
@@ -583,13 +626,23 @@ async function walk(step: StepRunner, bot: CoordinatorBot, instanceId: string):
583
626
  const [next] = readyUnits(graph, cursor);
584
627
  if (next === undefined) break;
585
628
  cursor = startUnit(graph, cursor, next);
586
- const ending = await runUnit(
587
- step,
588
- bot,
589
- instanceId,
590
- graph.units.find((u) => u.id === next)!,
591
- plan,
592
- );
629
+ const node = graph.units.find((u) => u.id === next)!;
630
+ let ending = await runUnit(step, bot, instanceId, node, plan);
631
+ // The lease continues while the grant renews (decision 0046): each
632
+ // `continued` ending opens the next segment of the same unit — a fresh
633
+ // pipeline under a fresh lease, from the recorded sha, briefed with the
634
+ // previous segment's write-up — until the unit ends some other way.
635
+ while (ending.kind === "continued") {
636
+ const c = ending;
637
+ ending = await runUnit(step, bot, instanceId, node, plan, {
638
+ segment: c.segment,
639
+ renewalsSpent: c.segment - 1,
640
+ spendUsd: c.spendUsd,
641
+ ...(c.from !== undefined ? { continueFrom: c.from } : {}),
642
+ previousRunId: c.runId,
643
+ ...(c.handoff !== undefined ? { previousHandoff: c.handoff } : {}),
644
+ });
645
+ }
593
646
  endings[next] = ending.kind;
594
647
  cursor = settleUnit(graph, cursor, next, ending.kind === "merged" ? "done" : "failed");
595
648
  }