@coreplane/switchboard 1.242.0 → 1.243.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 (59) hide show
  1. package/dist/assets/config/config.example.yaml +20 -1
  2. package/dist/assets/deploy/cloudflare-memory/worker.ts +86 -29
  3. package/dist/assets/deploy/cloudflare-resident/worker.ts +105 -13
  4. package/dist/assets/package-lock.json +3 -3
  5. package/dist/assets/package.json +1 -1
  6. package/dist/assets/project.json +3 -3
  7. package/dist/assets/source.json +3 -3
  8. package/dist/assets/src/agents/registry.ts +30 -34
  9. package/dist/assets/src/config/profile.ts +68 -3
  10. package/dist/assets/src/core/authz/policy.ts +4 -0
  11. package/dist/assets/src/core/budgets.ts +313 -0
  12. package/dist/assets/src/core/coordinator/driver.ts +0 -10
  13. package/dist/assets/src/core/costs.ts +9 -76
  14. package/dist/assets/src/core/modelPricing.ts +212 -0
  15. package/dist/assets/src/core/prDescriptionTypes.ts +29 -24
  16. package/dist/assets/src/core/reviewVerdict.ts +7 -0
  17. package/dist/assets/src/core/runEvents.ts +28 -4
  18. package/dist/assets/src/core/runFriction.ts +2 -1
  19. package/dist/assets/src/core/runRecord.ts +47 -0
  20. package/dist/assets/src/core/runUsage.ts +158 -47
  21. package/dist/assets/src/core/schedules.ts +3 -0
  22. package/dist/assets/src/core/ship/coordinator.ts +101 -84
  23. package/dist/assets/src/core/ship/handoff.ts +9 -0
  24. package/dist/assets/src/execution/bashTimeout.ts +8 -5
  25. package/dist/assets/src/execution/residentRefresh.ts +28 -3
  26. package/dist/assets/src/execution/residentSteps.ts +1 -1
  27. package/dist/assets/web/dist/.vite/manifest.json +67 -64
  28. package/dist/assets/web/dist/assets/CostsPage-CwXOmkeQ.js +2 -0
  29. package/dist/assets/web/dist/assets/HomePage-PRxjQiGG.js +2 -0
  30. package/dist/assets/web/dist/assets/PendingTurnRow-BT9RhFZ7.js +1 -0
  31. package/dist/assets/web/dist/assets/{ResidentDetailPage-DTMBgnIW.js → ResidentDetailPage-DACalNVF.js} +1 -1
  32. package/dist/assets/web/dist/assets/ResidentsIndexPage-SHPdu6uZ.js +1 -0
  33. package/dist/assets/web/dist/assets/RunFoldRow-0SdOmOr5.js +1 -0
  34. package/dist/assets/web/dist/assets/RunRoutePage-BaFS2p8I.js +9 -0
  35. package/dist/assets/web/dist/assets/RunsIndexPage-DYI-iALj.js +1 -0
  36. package/dist/assets/web/dist/assets/ScheduledPage-DJ8HiCPt.js +1 -0
  37. package/dist/assets/web/dist/assets/{SettingsPage-BIGio8Y0.js → SettingsPage-DLiN5IgY.js} +1 -1
  38. package/dist/assets/web/dist/assets/{StatusDot-ELoXHlFt.js → StatusDot-Dw0T1M-P.js} +1 -1
  39. package/dist/assets/web/dist/assets/{Tooltip-BoeFwYP2.js → Tooltip-BbLuIAiS.js} +1 -1
  40. package/dist/assets/web/dist/assets/UnitRoutePage-DicUG96U.js +1 -0
  41. package/dist/assets/web/dist/assets/{dist-BU5UivXC.js → dist-twkFmSUY.js} +1 -1
  42. package/dist/assets/web/dist/assets/format-BldUwl_R.js +1 -0
  43. package/dist/assets/web/dist/assets/indexRow-B_s5tKyq.js +1 -0
  44. package/dist/assets/web/dist/assets/{main-B2fX10aW.css → main-B4kEF3Sg.css} +1 -1
  45. package/dist/assets/web/dist/assets/{main-D4EA1g6n.js → main-d-w-tIKt.js} +2 -2
  46. package/dist/assets/web/dist/assets/{sseReplay-g7ml86LM.js → sseReplay-C9m_EB8J.js} +4 -4
  47. package/dist/cli.js +2758 -1445
  48. package/package.json +1 -1
  49. package/dist/assets/web/dist/assets/CostsPage-CtmKhhOF.js +0 -2
  50. package/dist/assets/web/dist/assets/HomePage-QtH1EwYF.js +0 -2
  51. package/dist/assets/web/dist/assets/PendingTurnRow-BZA_vQt3.js +0 -1
  52. package/dist/assets/web/dist/assets/ResidentsIndexPage-CaDvXhzJ.js +0 -1
  53. package/dist/assets/web/dist/assets/RunFoldRow-mgyLW0oV.js +0 -1
  54. package/dist/assets/web/dist/assets/RunRoutePage-DypJYMQa.js +0 -6
  55. package/dist/assets/web/dist/assets/RunsIndexPage-DYraPoWD.js +0 -1
  56. package/dist/assets/web/dist/assets/ScheduledPage-DXD2gLJk.js +0 -1
  57. package/dist/assets/web/dist/assets/UnitRoutePage-jhCrWW3i.js +0 -1
  58. package/dist/assets/web/dist/assets/indexRow-BD1VT8o8.js +0 -1
  59. package/dist/assets/web/dist/assets/localIso-L06jV29p.js +0 -1
@@ -1,15 +1,21 @@
1
1
  import type { RunEvent } from "./runEvents.js";
2
2
 
3
- // What a run cost in tokens, and who it belongs to — the data behind "cost by
4
- // user" on the costs page (docs/reference/specs/costs.md). Every provider call a
5
- // run makes is one `model.turn` span with the provider's own token counts as
6
- // attrs (metered by the model proxy or the runner; docs/reference/specs/tracing.md), so a
7
- // run's usage is the sum of those spans, per model. It is computed ONCE, at
8
- // finish, from the events still in memory (`assembleRunRecord`), and rides the
9
- // record — a record's events may be cut to fit the byte budget, so an aggregate
10
- // taken then is more faithful than one re-read later. A record written before
11
- // the field existed has none; the store fills it in from the run's stored
12
- // events on demand (the lazy backfill), and reports how many still wait.
3
+ // What a run cost in tokens, and who it belongs to — the data behind the cost
4
+ // dimensions of the costs page (docs/reference/specs/costs.md items 10–10a) and
5
+ // the dollars on a run's own page. Every provider call a run makes is one
6
+ // `model.turn` span with the provider's own token counts as attrs (metered by
7
+ // the model proxy or the runner; docs/reference/specs/tracing.md), so a run's
8
+ // usage is the sum of those spans, per model. It is computed ONCE, at finish,
9
+ // from the events still in memory (`assembleRunRecord`), and rides the record —
10
+ // a record's events may be cut to fit the byte budget, so an aggregate taken
11
+ // then is more faithful than one re-read later. A record written before the
12
+ // field existed has none; the store fills it in from the run's stored events
13
+ // on demand (the lazy backfill), and reports how many still wait.
14
+ //
15
+ // The store answers one row per run (`RunUsageRows`); the bot folds them into
16
+ // cells (`aggregateUsage`) — one per requester, thread, channel, agent and UTC
17
+ // day of finish — so a snapshot holds the cube every dimension is read from
18
+ // and never a row per run.
13
19
 
14
20
  export interface ModelUsage {
15
21
  turns: number;
@@ -28,6 +34,9 @@ export interface RunUsage {
28
34
 
29
35
  export const UNKNOWN_MODEL = "unknown";
30
36
 
37
+ /** The agent of a cell whose record names none (a record written before the field, a hand-built one). */
38
+ export const UNKNOWN_AGENT = "unknown";
39
+
31
40
  const MODEL_TURN = "model.turn";
32
41
 
33
42
  const num = (v: unknown): number => (typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : 0);
@@ -96,7 +105,7 @@ export function isRunUsage(v: unknown): v is RunUsage {
96
105
  return Object.values(u.byModel).every(isModelUsage);
97
106
  }
98
107
 
99
- // ---- the aggregate: who spent what, per UTC day ---------------------------------------
108
+ // ---- the store's answer: one row per run -------------------------------------------------
100
109
 
101
110
  /** One finished run as the aggregate sees it — the record's identity fields and its usage. */
102
111
  export interface UsageRun {
@@ -105,22 +114,19 @@ export interface UsageRun {
105
114
  userName?: string;
106
115
  /** A child run is billed to whoever started its parent (run-history item 46). */
107
116
  parentRunId?: string;
117
+ /** The thread the run ran in and the channel it belongs to (platform-namespaced, invariant 4). */
118
+ threadKey: string;
119
+ channelId: string;
120
+ /** The agent the run resolved to; absent on a record that names none. */
121
+ agent?: string;
108
122
  startedAt: number;
109
123
  finishedAt: number;
110
124
  /** Absent on a record written before usage existed and not yet backfilled. */
111
125
  usage?: RunUsage;
112
126
  }
113
127
 
114
- export interface UserDayUsage {
115
- userId: string;
116
- userName?: string;
117
- /** The UTC day the run finished, `YYYY-MM-DD`. */
118
- day: string;
119
- runs: number;
120
- /** Summed wall-clock of the runs (finish − start), for allocating shared cloud spend. */
121
- wallMs: number;
122
- usage: RunUsage;
123
- }
128
+ /** Whose run it is, as far as billing goes. */
129
+ export type UsageIdentity = Pick<UsageRun, "userId" | "userName">;
124
130
 
125
131
  export interface RunUsageQuery {
126
132
  /** Runs that finished at or after this epoch ms … */
@@ -129,8 +135,43 @@ export interface RunUsageQuery {
129
135
  untilMs: number;
130
136
  }
131
137
 
138
+ /** What the store answers (`POST /runs/usage`): the runs that finished in the
139
+ * range, oldest finish first, and the identity of every parent a child names
140
+ * that is outside the batch and known to the store — so the bot can bill the
141
+ * child without a second read. */
142
+ export interface RunUsageRows {
143
+ runs: UsageRun[];
144
+ parents: Record<string, UsageIdentity>;
145
+ /** Runs in range whose usage is not known yet (written before the field; backfill outstanding). */
146
+ pending: number;
147
+ /** The oldest finish the store still holds, so a page can bound its range to the data. */
148
+ earliestFinishedAt?: number;
149
+ retentionDays: number;
150
+ }
151
+
152
+ // ---- the aggregate: the usage cube --------------------------------------------------------
153
+
154
+ /** One cell: the runs one requester was billed for in one thread, one channel,
155
+ * on one agent, finishing on one UTC day. Every cost dimension is a sum over
156
+ * these — by user, thread, channel or agent along a key, by model inside `usage`. */
157
+ export interface UsageRow {
158
+ /** The UTC day the runs finished, `YYYY-MM-DD`. */
159
+ day: string;
160
+ /** The requester billed (a child's parent's). */
161
+ userId: string;
162
+ userName?: string;
163
+ threadKey: string;
164
+ channelId: string;
165
+ /** The agent, or `unknown` when the record names none. */
166
+ agent: string;
167
+ runs: number;
168
+ /** Summed wall-clock of the runs (finish − start), for allocating shared cloud spend. */
169
+ wallMs: number;
170
+ usage: RunUsage;
171
+ }
172
+
132
173
  export interface RunUsageReport {
133
- rows: UserDayUsage[];
174
+ rows: UsageRow[];
134
175
  /** Runs in range whose usage is not known yet (written before the field; backfill outstanding). */
135
176
  pending: number;
136
177
  /** The oldest finish the store still holds, so a page can bound its range to the data. */
@@ -140,34 +181,51 @@ export interface RunUsageReport {
140
181
 
141
182
  export const dayOf = (epochMs: number): string => new Date(epochMs).toISOString().slice(0, 10);
142
183
 
184
+ const identityOf = (who: UsageIdentity): UsageIdentity => ({
185
+ userId: who.userId,
186
+ ...(who.userName ? { userName: who.userName } : {}),
187
+ });
188
+
143
189
  /** Who a run is billed to: its parent's requester when it is a child and the
144
190
  * parent is known (in the batch, or through `lookupParent`), else its own. */
145
191
  export function billedTo(
146
192
  run: UsageRun,
147
193
  batch: ReadonlyMap<string, UsageRun>,
148
- lookupParent: (id: string) => Pick<UsageRun, "userId" | "userName"> | undefined,
149
- ): Pick<UsageRun, "userId" | "userName"> {
150
- if (!run.parentRunId) return { userId: run.userId, ...(run.userName ? { userName: run.userName } : {}) };
194
+ lookupParent: (id: string) => UsageIdentity | undefined,
195
+ ): UsageIdentity {
196
+ if (!run.parentRunId) return identityOf(run);
151
197
  const parent = batch.get(run.parentRunId) ?? lookupParent(run.parentRunId);
152
- if (!parent) return { userId: run.userId, ...(run.userName ? { userName: run.userName } : {}) };
153
- return { userId: parent.userId, ...(parent.userName ? { userName: parent.userName } : {}) };
198
+ return identityOf(parent ?? run);
154
199
  }
155
200
 
156
- /** Pure: the runs summed per (billed user, UTC day of finish). A run without
157
- * usage counts as pending and contributes its run and wall-clock only. Rows
158
- * come out oldest day first, then by user id. */
159
- export function aggregateUsageByUser(
201
+ const CELL_ORDER = ["day", "userId", "threadKey", "channelId", "agent"] as const;
202
+
203
+ /** Pure: the runs summed into cells — per (billed user, thread, channel, agent,
204
+ * UTC day of finish). A run without usage counts as pending and contributes its
205
+ * run and wall-clock only. Rows come out oldest day first, then by user id,
206
+ * thread, channel and agent. */
207
+ export function aggregateUsage(
160
208
  runs: readonly UsageRun[],
161
- lookupParent: (id: string) => Pick<UsageRun, "userId" | "userName"> | undefined = () => undefined,
162
- ): { rows: UserDayUsage[]; pending: number } {
209
+ lookupParent: (id: string) => UsageIdentity | undefined = () => undefined,
210
+ ): { rows: UsageRow[]; pending: number } {
163
211
  const batch = new Map(runs.map((r) => [r.id, r]));
164
- const rows = new Map<string, UserDayUsage>();
212
+ const rows = new Map<string, UsageRow>();
165
213
  let pending = 0;
166
214
  for (const run of runs) {
167
215
  const who = billedTo(run, batch, lookupParent);
168
216
  const day = dayOf(run.finishedAt);
169
- const key = `${day} ${who.userId}`;
170
- const row = rows.get(key) ?? { userId: who.userId, day, runs: 0, wallMs: 0, usage: emptyUsage() };
217
+ const agent = run.agent ?? UNKNOWN_AGENT;
218
+ const key = JSON.stringify([day, who.userId, run.threadKey, run.channelId, agent]);
219
+ const row = rows.get(key) ?? {
220
+ day,
221
+ userId: who.userId,
222
+ threadKey: run.threadKey,
223
+ channelId: run.channelId,
224
+ agent,
225
+ runs: 0,
226
+ wallMs: 0,
227
+ usage: emptyUsage(),
228
+ };
171
229
  if (who.userName && !row.userName) row.userName = who.userName;
172
230
  row.runs += 1;
173
231
  row.wallMs += Math.max(0, run.finishedAt - run.startedAt);
@@ -175,25 +233,78 @@ export function aggregateUsageByUser(
175
233
  else pending += 1;
176
234
  rows.set(key, row);
177
235
  }
236
+ const sorted = [...rows.values()].sort((a, b) => {
237
+ for (const k of CELL_ORDER) {
238
+ if (a[k] < b[k]) return -1;
239
+ if (a[k] > b[k]) return 1;
240
+ }
241
+ return 0;
242
+ });
243
+ return { rows: sorted, pending };
244
+ }
245
+
246
+ /** The bot's fold over the store's answer: the cells, the parents outside the batch consulted. */
247
+ export function reportOfUsageRows(rows: RunUsageRows): RunUsageReport {
248
+ const { rows: cells, pending } = aggregateUsage(rows.runs, (id) => rows.parents[id]);
178
249
  return {
179
- rows: [...rows.values()].sort((a, b) => (a.day < b.day ? -1 : a.day > b.day ? 1 : a.userId < b.userId ? -1 : 1)),
180
- pending,
250
+ rows: cells,
251
+ // The store counts what it could not price; the fold sees the same runs without `usage`.
252
+ pending: Math.max(pending, rows.pending),
253
+ ...(rows.earliestFinishedAt !== undefined ? { earliestFinishedAt: rows.earliestFinishedAt } : {}),
254
+ retentionDays: rows.retentionDays,
181
255
  };
182
256
  }
183
257
 
258
+ const isIdentity = (v: unknown): v is UsageIdentity =>
259
+ typeof v === "object" &&
260
+ v !== null &&
261
+ typeof (v as UsageIdentity).userId === "string" &&
262
+ ((v as UsageIdentity).userName === undefined || typeof (v as UsageIdentity).userName === "string");
263
+
264
+ function isUsageRun(v: unknown): v is UsageRun {
265
+ if (!isIdentity(v)) return false;
266
+ const r = v as UsageRun;
267
+ return (
268
+ typeof r.id === "string" &&
269
+ typeof r.threadKey === "string" &&
270
+ typeof r.channelId === "string" &&
271
+ (r.agent === undefined || typeof r.agent === "string") &&
272
+ (r.parentRunId === undefined || typeof r.parentRunId === "string") &&
273
+ typeof r.startedAt === "number" &&
274
+ typeof r.finishedAt === "number" &&
275
+ (r.usage === undefined || isRunUsage(r.usage))
276
+ );
277
+ }
278
+
279
+ function hasReportTail(r: Record<string, unknown>): boolean {
280
+ if (typeof r.pending !== "number" || typeof r.retentionDays !== "number") return false;
281
+ return r.earliestFinishedAt === undefined || typeof r.earliestFinishedAt === "number";
282
+ }
283
+
284
+ /** The store's wire shape, as the Worker client re-validates it. */
285
+ export function isRunUsageRows(v: unknown): v is RunUsageRows {
286
+ if (typeof v !== "object" || v === null) return false;
287
+ const r = v as Record<string, unknown>;
288
+ if (!Array.isArray(r.runs) || !r.runs.every(isUsageRun)) return false;
289
+ if (typeof r.parents !== "object" || r.parents === null || Array.isArray(r.parents)) return false;
290
+ if (!Object.values(r.parents).every(isIdentity)) return false;
291
+ return hasReportTail(r);
292
+ }
293
+
294
+ /** The cells' wire shape, as the snapshot guard re-validates it. */
184
295
  export function isRunUsageReport(v: unknown): v is RunUsageReport {
185
296
  if (typeof v !== "object" || v === null) return false;
186
297
  const r = v as Record<string, unknown>;
187
- if (!Array.isArray(r.rows) || typeof r.pending !== "number" || typeof r.retentionDays !== "number") return false;
188
- if (r.earliestFinishedAt !== undefined && typeof r.earliestFinishedAt !== "number") return false;
298
+ if (!Array.isArray(r.rows) || !hasReportTail(r)) return false;
189
299
  return r.rows.every(
190
300
  (row) =>
191
- typeof row === "object" &&
192
- row !== null &&
193
- typeof (row as UserDayUsage).userId === "string" &&
194
- typeof (row as UserDayUsage).day === "string" &&
195
- typeof (row as UserDayUsage).runs === "number" &&
196
- typeof (row as UserDayUsage).wallMs === "number" &&
197
- isRunUsage((row as UserDayUsage).usage),
301
+ isIdentity(row) &&
302
+ typeof (row as UsageRow).day === "string" &&
303
+ typeof (row as UsageRow).threadKey === "string" &&
304
+ typeof (row as UsageRow).channelId === "string" &&
305
+ typeof (row as UsageRow).agent === "string" &&
306
+ typeof (row as UsageRow).runs === "number" &&
307
+ typeof (row as UsageRow).wallMs === "number" &&
308
+ isRunUsage((row as UsageRow).usage),
198
309
  );
199
310
  }
@@ -7,6 +7,9 @@ import { parseIngressTokenMap, tokenForSubject } from "./ingressTokens.js";
7
7
  // worker), and each Worker's `scheduled()` looks its firing up HERE — so a
8
8
  // schedule can never exist in one place and not the other.
9
9
  //
10
+ // Schedules a person adds at runtime, and the hand-over of a firing to the
11
+ // `schedule:<name>` actor declared below, are docs/decisions/0049-a-stored-schedule-is-a-turn-the-minute-tick-fires.md.
12
+ //
10
13
  // A schedule has three independent facets:
11
14
  // worker — whose wrangler.jsonc carries the cron and whose `scheduled()` fires it
12
15
  // action — what a firing does: `run` (the bot shim POSTs the generic /ingress as
@@ -34,8 +34,9 @@ import type { RunStatus } from "../runRecord.js";
34
34
  import { normalizeHead, sameCommit } from "../reviewedHead.js";
35
35
  import { formatFinding, type Finding, type FindingDisposition, type ReviewVerdictKind } from "../reviewVerdict.js";
36
36
  import { parsePlanUnit, planUnitIds } from "./contract.js";
37
+ import { carve, loopPosition, MINUTE_MS, SHIP_WAIT, type Carve, type Loop } from "../budgets.js";
37
38
 
38
- const MIN = 60_000;
39
+ const MIN = MINUTE_MS;
39
40
 
40
41
  /** What a pipeline runs under: the rounds cap from the `ship` config block, and
41
42
  * the wall clock from the parent's EFFECTIVE profile — the ship preset's
@@ -45,34 +46,10 @@ export interface ShipCaps {
45
46
  maxMinutes: number;
46
47
  }
47
48
 
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;
49
+ /** A round's minutes come from `carve` in `src/core/budgets.ts` (agent-ship
50
+ * item 8, decision 0046): the remainder minus the reserve derived over the
51
+ * rounds that must still follow, capped at the preset's ask, refused under the
52
+ * round's floor. Nothing here holds a reserve of its own. */
76
53
 
77
54
  /** What a ship pipeline's thread and card say when the bot died under it (run-
78
55
  * history item 36): the work it did stands on GitHub with nobody driving it,
@@ -525,7 +502,14 @@ export type UnitEnding =
525
502
  | { kind: "merge_ready"; pr: PrRef; reviewRounds: number }
526
503
  | { kind: "merge_refused"; pr: PrRef; reason: string; reviewRounds: number }
527
504
  | { kind: "round_cap"; maxRounds: number; reviewRounds: number }
528
- | { kind: "wall_clock_cap"; remainingMs: number; reviewRounds: number; spent: ShipBudgetSpent }
505
+ | {
506
+ kind: "wall_clock_cap";
507
+ remainingMs: number;
508
+ reviewRounds: number;
509
+ spent: ShipBudgetSpent;
510
+ /** The round the remainder could not hold: what it would have got and the floor it fell under. */
511
+ refused?: { round: RoundKind; minutes: number; floor: number };
512
+ }
529
513
  /** The wall clock capped AFTER the coding child opened or updated the pull
530
514
  * request: the work stands and only the review is missing, so the ending
531
515
  * names the pull request and "review pending" instead of calling the unit a
@@ -584,9 +568,6 @@ export interface UnitPipelineInput {
584
568
  /** The pull request's base — the branch the unit is created from and rebased onto. */
585
569
  base: string;
586
570
  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
571
  /** Who merges: the instance's `merge` field as the plan route answers it —
591
572
  * `runner` (a seeded plan, under its grant) or `person` (a task, or a
592
573
  * record without the field). */
@@ -614,7 +595,7 @@ type Phase =
614
595
  /** Before anything is created: what already heads the branch — a merged pull request ends the unit here. */
615
596
  | { at: "pre-check" }
616
597
  | { at: "branch" }
617
- | { at: "spawn"; round: RoundRef; busy: number }
598
+ | { at: "spawn"; round: RoundRef; busy: number; minutes: number; holds: number }
618
599
  | { at: "busy-wait"; round: RoundRef; runId?: string; n: number }
619
600
  /** `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
601
  | { at: "wait"; round: RoundRef; runId: string; n: number; until: number }
@@ -630,8 +611,8 @@ type Phase =
630
611
  * request; with nothing pushed the unit ends with the child's own reason. */
631
612
  dead?: "failed" | "interrupted";
632
613
  }
633
- | { at: "merge"; pr: PrRef; headSha: string; n: number; since: number }
634
- | { at: "merge-wait"; pr: PrRef; headSha: string; n: number; since: number }
614
+ | { at: "merge"; pr: PrRef; headSha: string; n: number; since: number; waitMs: number }
615
+ | { at: "merge-wait"; pr: PrRef; headSha: string; n: number; since: number; waitMs: number }
635
616
  | { at: "ended" };
636
617
 
637
618
  export interface UnitPipelineState {
@@ -662,7 +643,7 @@ export interface UnitPipelineState {
662
643
  }
663
644
 
664
645
  /** Past a child's budget, the parent asks the bot instead of waiting on. */
665
- export const WAIT_MARGIN_MS = 5 * MIN;
646
+ export const WAIT_MARGIN_MS = SHIP_WAIT.marginMinutes * MIN;
666
647
  /** One slice of a wait on a child. The child's budget plus the margin is
667
648
  * walked in chunks with a `read-record` between them, so an event the engine
668
649
  * never delivered — refused, lost, sent to an instance that had ended — costs
@@ -670,19 +651,18 @@ export const WAIT_MARGIN_MS = 5 * MIN;
670
651
  * is the machine's one cadence for asking the bot what it cannot be told (the
671
652
  * margin and the merge poll are the same number), and it keeps a round to a
672
653
  * 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;
654
+ export const WAIT_CHUNK_MS = SHIP_WAIT.chunkMinutes * MIN;
655
+ /** The merge wait is a round of its own: its minutes are carved from the
656
+ * pipeline's remainder when the door is first asked (the merge wait's ask and
657
+ * floor are rows of `src/core/budgets.ts`), and the wait below is sliced
658
+ * from that carve. */
679
659
  /** One merge wait's fallback timeout: the old poll's cadence. The event wakes
680
660
  * the machine at once when the intake delivers it; without one (the webhook
681
661
  * not configured, a delivery lost) the door is still re-asked every chunk, so
682
662
  * a merge is never slower than the poll it replaced. */
683
- export const MERGE_WAIT_CHUNK_MS = 5 * MIN;
663
+ export const MERGE_WAIT_CHUNK_MS = SHIP_WAIT.mergeChunkMinutes * MIN;
684
664
  /** 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;
665
+ export const BUSY_RETRY_MS = SHIP_WAIT.busyRetryMinutes * MIN;
686
666
 
687
667
  // ---- the unit pipeline: opening and the next action -----------------------------------------------
688
668
 
@@ -721,28 +701,16 @@ function waitSliceMs(clock: number, until: number): number {
721
701
  return remaining > 0 ? Math.min(WAIT_CHUNK_MS, remaining) : WAIT_CHUNK_MS;
722
702
  }
723
703
 
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
- }
704
+ /** The pipeline's loop as the config allows it. */
705
+ const loopOf = (s: UnitPipelineState): Loop => ({ maxRounds: s.input.caps.maxRounds });
731
706
 
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
- }
707
+ /** A round's carve (agent-ship item 8): the remainder minus the reserve for
708
+ * the rounds after it, capped at its preset's ask, refused under its floor.
709
+ * A review round `n` and the findings step that follows it share `n`; the
710
+ * module's positions are the loop's own. */
711
+ function roundCarve(s: UnitPipelineState, round: RoundRef): Carve {
712
+ const kind = round.kind === "findings" ? "fix" : round.kind;
713
+ return carve(remainingMs(s), { kind, index: loopPosition(loopOf(s), kind, round.index) }, loopOf(s));
746
714
  }
747
715
 
748
716
  const roundStep = (s: UnitPipelineState, round: RoundRef) => `${s.input.unit.id}/${round.index}/${round.kind}`;
@@ -800,7 +768,7 @@ export function nextAction(s: UnitPipelineState): CoordinatorAction {
800
768
  step: roundStep(s, p.round),
801
769
  preset,
802
770
  round: p.round,
803
- budgetMinutes: budgetMinutesFor(s, p.round),
771
+ budgetMinutes: p.minutes,
804
772
  brief: briefFor(s, p.round),
805
773
  };
806
774
  }
@@ -831,7 +799,7 @@ export function nextAction(s: UnitPipelineState): CoordinatorAction {
831
799
  type: "wait-checks",
832
800
  step: `${unit}/merge/wait/${p.n}`,
833
801
  headSha: p.headSha,
834
- timeoutMs: Math.max(MIN, Math.min(MERGE_WAIT_CHUNK_MS, MERGE_WAIT_MAX_MS - (s.clock - p.since))),
802
+ timeoutMs: Math.max(MIN, Math.min(MERGE_WAIT_CHUNK_MS, p.waitMs - (s.clock - p.since))),
835
803
  };
836
804
  case "ended":
837
805
  return { type: "end", step: `${unit}/end`, ending: s.ending! };
@@ -861,7 +829,7 @@ const roundNote = (round: RoundRef, outcome: ShipRoundOutcome): CoordinatorNote
861
829
  /** The cap's ending: `review_pending` when the clock ran out entering a
862
830
  * review round with the child's pull request standing — the work shipped and
863
831
  * only the review is missing — else the wall-clock cap. Both carry the split. */
864
- function capEnding(s: UnitPipelineState, round?: RoundRef): UnitEnding {
832
+ function capEnding(s: UnitPipelineState, round?: RoundRef, refused?: Carve): UnitEnding {
865
833
  if (round?.kind === "review" && s.pr !== undefined)
866
834
  return {
867
835
  kind: "review_pending",
@@ -870,16 +838,28 @@ function capEnding(s: UnitPipelineState, round?: RoundRef): UnitEnding {
870
838
  reviewRounds: s.reviewRounds,
871
839
  spent: s.spentMs,
872
840
  };
873
- return { kind: "wall_clock_cap", remainingMs: remainingMs(s), reviewRounds: s.reviewRounds, spent: s.spentMs };
841
+ return {
842
+ kind: "wall_clock_cap",
843
+ remainingMs: remainingMs(s),
844
+ reviewRounds: s.reviewRounds,
845
+ spent: s.spentMs,
846
+ ...(round !== undefined && refused?.kind === "refused"
847
+ ? { refused: { round: round.kind, minutes: refused.minutes, floor: refused.floor } }
848
+ : {}),
849
+ };
874
850
  }
875
851
 
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). */
852
+ /** Start a round if its carve holds (agent-ship item 8): a round the
853
+ * remainder cannot carve above its floor is not dispatched, and the unit ends
854
+ * at the cap naming the round, what it would have got and the floor. */
878
855
  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);
856
+ const carved = roundCarve(s, round);
857
+ if (carved.kind === "refused") return end(s, capEnding(s, round, carved), notes);
881
858
  const reviewRounds = round.kind === "review" ? round.index : s.reviewRounds;
882
- return { state: { ...s, reviewRounds, phase: { at: "spawn", round, busy: 0 } }, notes };
859
+ return {
860
+ state: { ...s, reviewRounds, phase: { at: "spawn", round, busy: 0, minutes: carved.minutes, holds: carved.holds } },
861
+ notes,
862
+ };
883
863
  }
884
864
 
885
865
  /** The next review round, or the round cap. */
@@ -1042,7 +1022,26 @@ function settleReview(
1042
1022
  { kind: "merge_refused", pr, reason: "no approved head is known to merge at", reviewRounds: next.reviewRounds },
1043
1023
  notes,
1044
1024
  );
1045
- return { state: { ...next, phase: { at: "merge", pr, headSha, n: 1, since: next.clock } }, notes };
1025
+ const mergeWait = carve(
1026
+ remainingMs(next),
1027
+ { kind: "merge", index: loopPosition(loopOf(next), "merge") },
1028
+ loopOf(next),
1029
+ );
1030
+ if (mergeWait.kind === "refused")
1031
+ return end(
1032
+ next,
1033
+ {
1034
+ kind: "merge_refused",
1035
+ pr,
1036
+ reason: `the remaining ${mergeWait.minutes} minutes of the pipeline are under the merge wait's floor of ${mergeWait.floor}`,
1037
+ reviewRounds: next.reviewRounds,
1038
+ },
1039
+ notes,
1040
+ );
1041
+ return {
1042
+ state: { ...next, phase: { at: "merge", pr, headSha, n: 1, since: next.clock, waitMs: mergeWait.minutes * MIN } },
1043
+ notes,
1044
+ };
1046
1045
  }
1047
1046
  // request_changes: the verdict settled (and posted) — now a stop
1048
1047
  // short-circuits the findings step, naming the review standing on the pull request.
@@ -1252,7 +1251,7 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
1252
1251
  case "spawned":
1253
1252
  case "alreadySpawned": {
1254
1253
  // 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;
1254
+ const until = clocked.clock + p.minutes * MIN + WAIT_MARGIN_MS;
1256
1255
  const runs =
1257
1256
  p.round.kind === "review"
1258
1257
  ? { reviewRunByRound: { ...s.reviewRunByRound, [p.round.index]: r.runId } }
@@ -1270,7 +1269,8 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
1270
1269
  case "busy": {
1271
1270
  // Another run holds the unit's thread: wait for its end, then ask
1272
1271
  // 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));
1272
+ const recarved = roundCarve(clocked, p.round);
1273
+ if (recarved.kind === "refused") return end(clocked, capEnding(clocked, p.round, recarved));
1274
1274
  return {
1275
1275
  state: {
1276
1276
  ...clocked,
@@ -1302,8 +1302,19 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
1302
1302
  }
1303
1303
  break;
1304
1304
  }
1305
- case "busy-wait":
1306
- return { state: { ...s, phase: { at: "spawn", round: p.round, busy: p.n } }, notes: [] };
1305
+ case "busy-wait": {
1306
+ // The clock moved while the spawn was busy: the round is carved again
1307
+ // from what remains, and a carve now under the floor ends at the cap.
1308
+ const carved = roundCarve(s, p.round);
1309
+ if (carved.kind === "refused") return end(s, capEnding(s, p.round, carved));
1310
+ return {
1311
+ state: {
1312
+ ...s,
1313
+ phase: { at: "spawn", round: p.round, busy: p.n, minutes: carved.minutes, holds: carved.holds },
1314
+ },
1315
+ notes: [],
1316
+ };
1317
+ }
1307
1318
  case "wait":
1308
1319
  return {
1309
1320
  state: { ...s, phase: { at: "read", round: p.round, runId: p.runId, n: p.n, until: p.until } },
@@ -1356,7 +1367,7 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
1356
1367
  if (r.outcome === "refused")
1357
1368
  return end(clocked, { kind: "merge_refused", pr: p.pr, reason: r.reason, reviewRounds: s.reviewRounds });
1358
1369
  const waited = r.at - p.since;
1359
- if (waited >= MERGE_WAIT_MAX_MS)
1370
+ if (waited >= p.waitMs)
1360
1371
  return end(clocked, {
1361
1372
  kind: "merge_refused",
1362
1373
  pr: p.pr,
@@ -1364,13 +1375,19 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
1364
1375
  reviewRounds: s.reviewRounds,
1365
1376
  });
1366
1377
  return {
1367
- state: { ...clocked, phase: { at: "merge-wait", pr: p.pr, headSha: p.headSha, n: p.n, since: p.since } },
1378
+ state: {
1379
+ ...clocked,
1380
+ phase: { at: "merge-wait", pr: p.pr, headSha: p.headSha, n: p.n, since: p.since, waitMs: p.waitMs },
1381
+ },
1368
1382
  notes: [],
1369
1383
  };
1370
1384
  }
1371
1385
  case "merge-wait":
1372
1386
  return {
1373
- state: { ...s, phase: { at: "merge", pr: p.pr, headSha: p.headSha, n: p.n + 1, since: p.since } },
1387
+ state: {
1388
+ ...s,
1389
+ phase: { at: "merge", pr: p.pr, headSha: p.headSha, n: p.n + 1, since: p.since, waitMs: p.waitMs },
1390
+ },
1374
1391
  notes: [],
1375
1392
  };
1376
1393
  case "ended":
@@ -1505,7 +1522,7 @@ export function renderUnitReport(s: UnitPipelineState, facts?: MergeReadyFacts):
1505
1522
  ]);
1506
1523
  case "wall_clock_cap":
1507
1524
  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}`,
1525
+ `🧢 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
1526
  budgetSplitLine(e.spent, s.input.caps.maxMinutes),
1510
1527
  splitReport(s),
1511
1528
  reissue,
@@ -196,6 +196,15 @@ function entryLines(h: Handoff): Array<{ list: ListKey; bullet: string; row: str
196
196
  ];
197
197
  }
198
198
 
199
+ /** Each entry as one line, list by list — a deviation as `Deviation: from → to
200
+ * — why`, a follow-up as `what — where`, an unproven criterion as `Unproven:
201
+ * criterion — why` — the same words the ledger rows carry, for a reader that
202
+ * wants the handoff as plain lines (the thread's artifacts block). Empty for
203
+ * an empty handoff. */
204
+ export function handoffLines(h: Handoff): string[] {
205
+ return entryLines(h).map((e) => e.row);
206
+ }
207
+
199
208
  function prLink(pr: HandoffRenderContext["pr"]): string | undefined {
200
209
  return pr ? `[#${pr.number}](${pr.url})` : undefined;
201
210
  }