@coreplane/switchboard 1.255.0 → 1.257.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/dist/assets/config/config.example.yaml +14 -0
  2. package/dist/assets/deploy/cloudflare-memory/worker.ts +340 -7
  3. package/dist/assets/deploy/cloudflare-resident/drain.ts +100 -1
  4. package/dist/assets/deploy/cloudflare-resident/worker.ts +227 -34
  5. package/dist/assets/package-lock.json +3 -3
  6. package/dist/assets/package.json +5 -3
  7. package/dist/assets/project.json +17 -9
  8. package/dist/assets/source.json +3 -3
  9. package/dist/assets/src/agents/registry.ts +55 -1
  10. package/dist/assets/src/core/authz/grants.ts +12 -0
  11. package/dist/assets/src/core/authz/policy.ts +15 -0
  12. package/dist/assets/src/core/budgets.ts +51 -17
  13. package/dist/assets/src/core/coordinator/contract.ts +45 -5
  14. package/dist/assets/src/core/coordinator/driver.ts +17 -4
  15. package/dist/assets/src/core/delivery.ts +8 -5
  16. package/dist/assets/src/core/pipelineStanding.ts +57 -0
  17. package/dist/assets/src/core/plane/decide.ts +468 -12
  18. package/dist/assets/src/core/plane/findings.ts +120 -0
  19. package/dist/assets/src/core/reviewVerdict.ts +49 -0
  20. package/dist/assets/src/core/runEvents.ts +41 -14
  21. package/dist/assets/src/core/runFriction.ts +16 -0
  22. package/dist/assets/src/core/runLedger/types.ts +16 -3
  23. package/dist/assets/src/core/runRecord.ts +12 -0
  24. package/dist/assets/src/core/ship/contract.ts +34 -32
  25. package/dist/assets/src/core/ship/coordinator.ts +291 -95
  26. package/dist/assets/src/core/ship/renewal.ts +19 -17
  27. package/dist/assets/src/core/trace/attrs.ts +1 -1
  28. package/dist/assets/src/core/untrusted.ts +35 -0
  29. package/dist/assets/src/execution/residentCredentials.ts +7 -4
  30. package/dist/assets/web/dist/.vite/manifest.json +82 -57
  31. package/dist/assets/web/dist/assets/HomePage-DJqJQzrL.js +1 -0
  32. package/dist/assets/web/dist/assets/{PendingTurnRow-DGINv9XT.js → PendingTurnRow-fhKqKUVv.js} +1 -1
  33. package/dist/assets/web/dist/assets/PlanePage-Bood0hNO.js +1 -0
  34. package/dist/assets/web/dist/assets/{ResidentDetailPage-D_RD6wLo.js → ResidentDetailPage-DOe1HtUC.js} +1 -1
  35. package/dist/assets/web/dist/assets/{ResidentsIndexPage-BLkSuCxo.js → ResidentsIndexPage-TyCGjXM4.js} +1 -1
  36. package/dist/assets/web/dist/assets/RunFoldRow-C_4t-sit.js +1 -0
  37. package/dist/assets/web/dist/assets/RunRoutePage-BNepPMRX.js +9 -0
  38. package/dist/assets/web/dist/assets/RunsIndexPage-jZnMw1mM.js +1 -0
  39. package/dist/assets/web/dist/assets/{ScheduledPage-3aYsDf-q.js → ScheduledPage-BkNoudDP.js} +1 -1
  40. package/dist/assets/web/dist/assets/{SettingsPage-DG-p5Xy1.js → SettingsPage-COvWGXl-.js} +1 -1
  41. package/dist/assets/web/dist/assets/SilentTurn-DNAANKgN.js +2 -0
  42. package/dist/assets/web/dist/assets/SlackMark-CtiHrNjr.js +1 -0
  43. package/dist/assets/web/dist/assets/{StatusDot-CEnGlyAL.js → StatusDot-BgmkSV0S.js} +1 -1
  44. package/dist/assets/web/dist/assets/{Tooltip-CiunVowT.js → Tooltip-DEUPuCPW.js} +1 -1
  45. package/dist/assets/web/dist/assets/UnitRoutePage-DNhdzxvY.js +1 -0
  46. package/dist/assets/web/dist/assets/{dist-luhv3YSo.js → dist-CF3jz9LM.js} +1 -1
  47. package/dist/assets/web/dist/assets/durationTone-CVpX_yIk.js +1 -0
  48. package/dist/assets/web/dist/assets/main-7SAujY_s.js +28 -0
  49. package/dist/assets/web/dist/assets/main-DGC6WFqS.css +1 -0
  50. package/dist/assets/web/dist/assets/{sseReplay-DE6wv1Ua.js → sseReplay-BRJEIh83.js} +1 -1
  51. package/dist/cli.js +2941 -1116
  52. package/package.json +1 -1
  53. package/dist/assets/web/dist/assets/HomePage-Be7jLLnU.js +0 -3
  54. package/dist/assets/web/dist/assets/PlanePage-JEj-lqgz.js +0 -1
  55. package/dist/assets/web/dist/assets/RunFoldRow-CfuZqf_O.js +0 -1
  56. package/dist/assets/web/dist/assets/RunRoutePage-CmWYGR36.js +0 -9
  57. package/dist/assets/web/dist/assets/RunsIndexPage-DW-HHuZa.js +0 -1
  58. package/dist/assets/web/dist/assets/UnitRoutePage-iOYnmTcQ.js +0 -1
  59. package/dist/assets/web/dist/assets/durationTone-U_rOqo6r.js +0 -1
  60. package/dist/assets/web/dist/assets/main-C4QfwybO.css +0 -1
  61. package/dist/assets/web/dist/assets/main-mAKx_zo9.js +0 -28
@@ -63,6 +63,12 @@ export const POLICY: readonly Rule[] = [
63
63
  // NAMED run (`authorizeSteerOwner`, authorization item 16a) — the requester,
64
64
  // a linked id, or `runs:write` — which no command-door row can see.
65
65
  { action: "steer:write", resource: "command", when: [grant("steer:write")] },
66
+ // The plane's checkpoint steer (record 0064): the system actor `plane`
67
+ // (`PLANE_ACTOR_ID`, a `service` actor holding `PLANE_GRANTS`' one standing
68
+ // `steer:write`) writes the steer into a write-preset run's inbox on the
69
+ // ledger object itself, sender `plane`; this row records that authority in
70
+ // the one table even though the object, not the bot, executes the write.
71
+ { action: "steer:write", resource: "run", actorKinds: ["service"], when: [grant("steer:write")] },
66
72
 
67
73
  // ── conversations ────────────────────────────────────────────────────────
68
74
  // Who may point the bot at another thread (record 0037). Asked for a
@@ -108,6 +114,11 @@ export const POLICY: readonly Rule[] = [
108
114
  // read is never a chat baseline, like the costs reads.
109
115
  { action: "providers:read", resource: "command", when: [grant("providers:read")] },
110
116
 
117
+ // ── pull requests ──────────────────────────────────────────────────
118
+ // `pulls rebase` force-pushes the pipeline's branches and can start a paid
119
+ // fix round (record 0071): a grant, never a chat baseline.
120
+ { action: "pulls:write", resource: "command", when: [grant("pulls:write")] },
121
+
111
122
  // ── repos ────────────────────────────────────────────────────────────────
112
123
  { action: "repo:read", resource: "command", when: [grant("repo:read")] },
113
124
  { action: "repo:write", resource: "repo", when: [grant("repo:write")] },
@@ -150,6 +161,10 @@ export const POLICY: readonly Rule[] = [
150
161
  // channel-config right — whoever may set the channel may set a thread in it;
151
162
  // never a baseline, so membership alone admits nobody.
152
163
  { action: "config:write", resource: "config-scope", resourceKind: "thread", when: [grant("config:write")] },
164
+ // The ORG scope (`config set org --pulls.…`, record 0071) — and a repository
165
+ // scope, the org setting's per-repository slice — affects every requester:
166
+ // the same `config:write` grant, never a baseline.
167
+ { action: "config:write", resource: "config-scope", resourceKind: "org", when: [grant("config:write")] },
153
168
  // A user edits only their own scope.
154
169
  { action: "config:write", resource: "config-scope", resourceKind: "user", when: [IS_SELF] },
155
170
  // The author binding's one trusted write (record 0062, authorization.md item
@@ -16,6 +16,10 @@
16
16
  // Workflow-driven coordinator and the deploy Workers can bundle it — the
17
17
  // dependency runs registry → budgets, never the reverse.
18
18
 
19
+ export const SECOND_MS = 1_000;
20
+
21
+ export const secondsToMs = (seconds: number): number => seconds * SECOND_MS;
22
+
19
23
  export const MINUTE_MS = 60_000;
20
24
 
21
25
  /** Minutes → milliseconds, for a duration a request names in minutes (a drain's
@@ -109,6 +113,14 @@ export const DRAIN = {
109
113
  marginMinutes: 5,
110
114
  maxMinutes: 90,
111
115
  defaultMinutes: 60,
116
+ /** How long a held drain waits for a container's post-deploy cycle before
117
+ * reopening the fleet anyway with the stale container named (issue 2044):
118
+ * the platform replaces container processes three to ten minutes after a
119
+ * deploy (docs/reference/specs/resident-repos.md item 65), so a cycle that
120
+ * has not landed within this bound is not coming on its own — the gate
121
+ * asked for the cycle, so it owns the outcome and never holds the fleet
122
+ * closed to the drain's whole `until` on a report nothing will send. */
123
+ cycleBoundMinutes: 10,
112
124
  } as const;
113
125
 
114
126
  /** How long an intake receipt row is kept on the run history object
@@ -143,7 +155,7 @@ export type Preset = LoopPreset | "ship";
143
155
  * that pushes at minute 29 and then runs the repo's whole gate (seven to ten
144
156
  * minutes on a resident) was cut at 45 one run in twelve, so the ask is
145
157
  * double the 90th percentile (31.5) with the gate inside it. Ship's holds
146
- * its own loop at the default rounds with a fix round at that ask, not at
158
+ * its own loop at the default rounds with a findings round at that ask, not at
147
159
  * its floor (`fit` proves the floor case; the ask leaves room above it). */
148
160
  export const ASKS: Readonly<Record<Preset, number>> = {
149
161
  general: 5,
@@ -155,17 +167,18 @@ export const ASKS: Readonly<Record<Preset, number>> = {
155
167
  conductor: 120,
156
168
  };
157
169
 
158
- /** The rounds a ship loop is made of. `fix` is a coding child handed the
170
+ /** The rounds a ship loop is made of. `findings` is a coding child handed the
159
171
  * review's findings; `merge` is the runner's wait on the guards. */
160
- export type RoundKind = "coding" | "review" | "fix" | "merge";
172
+ export type RoundKind = "coding" | "review" | "findings" | "merge";
161
173
 
162
174
  /** The least lease in which a round does useful work, in minutes. A carve that
163
175
  * falls under the floor is refused rather than dispatched: a two-minute
164
- * review or fix costs an attach and a model turn and finishes nothing.
165
- * Review's is the ledger's 90th percentile of completed reviews (5.1 min over
166
- * 181); coding's is the least a fix round can run the repo's gate in — the
167
- * gate alone takes seven to ten minutes on a resident, and a fix that cannot
168
- * run it finishes nothing its contract asks; the merge wait's is a guess
176
+ * review or findings round costs an attach and a model turn and finishes
177
+ * nothing. Review's is the ledger's 90th percentile of completed reviews (5.1
178
+ * min over 181); coding's is the least a findings round can run the repo's
179
+ * gate in — the gate alone takes seven to ten minutes on a resident, and a
180
+ * round that cannot run it finishes nothing its contract asks; the merge
181
+ * wait's is a guess
169
182
  * until the ledger says. */
170
183
  export const PRESET_FLOORS: Readonly<Record<LoopPreset, number>> = {
171
184
  general: 2,
@@ -177,7 +190,7 @@ export const PRESET_FLOORS: Readonly<Record<LoopPreset, number>> = {
177
190
  };
178
191
  export const FLOORS: Readonly<Record<RoundKind, number>> = {
179
192
  coding: PRESET_FLOORS.coding,
180
- fix: PRESET_FLOORS.coding,
193
+ findings: PRESET_FLOORS.coding,
181
194
  review: PRESET_FLOORS.review,
182
195
  merge: 10,
183
196
  };
@@ -186,6 +199,12 @@ export const FLOORS: Readonly<Record<RoundKind, number>> = {
186
199
  * when the remainder allows it. */
187
200
  export const MERGE_WAIT_ASK_MINUTES = 60;
188
201
 
202
+ /** The sweep's bounds (record 0071, mechanism two): the short lease of the one
203
+ * model round a conflicting pull request may buy, and the spend that round is
204
+ * capped at per pull request — a round refused at either bound ends the pull
205
+ * request's line with the conflict named, never a retry loop. */
206
+ export const PULL_SWEEP = { leaseMinutes: 15, spendCapUsd: 5 } as const;
207
+
189
208
  /** The provider retry ladder (issue 1932): the backoff before each retry of a
190
209
  * transient model-call failure — a gateway 5xx, a stream cut before
191
210
  * `message_stop`, a gateway timeout. Three attempts with growing waits,
@@ -208,7 +227,15 @@ export const SHIP_WAIT = { marginMinutes: 5, chunkMinutes: 5, mergeChunkMinutes:
208
227
  * it, a reservation's window, and the default re-ask cadence — how often a
209
228
  * silent resident something waits on is probed (`plane.reaskMinutes`
210
229
  * overrides it per deployment). */
211
- export const PLANE = { recentMinutes: 60, reservationMinutes: 2, reaskMinutes: 2 } as const;
230
+ export const PLANE = {
231
+ recentMinutes: 60,
232
+ reservationMinutes: 2,
233
+ reaskMinutes: 2,
234
+ /** A coding round with no pushed head past this is steered once (record 0064, "The backpressure contract"). */
235
+ noPushMinutes: 15,
236
+ /** The line an in-flight call with NO declared bound is judged against for the same steer. */
237
+ noBoundMinutes: 20,
238
+ } as const;
212
239
 
213
240
  /** The named amounts a lease holds back, in minutes. Each stands for a step
214
241
  * every run or round pays: `provision` is attach and restore before the
@@ -328,6 +355,12 @@ export function provisionalBearerExpiresAt(now: number, leaseMinutes: number): n
328
355
  return now + (ALLOWANCES.provision + leaseMinutes + ALLOWANCES.bearerGrace) * MINUTE_MS;
329
356
  }
330
357
 
358
+ /** The e2b credential-write exec's own timeout: the store-file write is one
359
+ * `printf` plus a `git config` reset, so a minute is generous headroom while
360
+ * still failing fast when the micro-VM is wedged
361
+ * (docs/reference/specs/execution.md item 5). */
362
+ export const SANDBOX_CREDENTIAL_WRITE_TIMEOUT_MS = MINUTE_MS;
363
+
331
364
  /** The per-command bash budget's rows: the default when a call names none, the
332
365
  * ceiling a call may raise it to, and the floor under which a number is a
333
366
  * typo (docs/reference/specs/execution.md item 11). */
@@ -361,19 +394,20 @@ export interface RoundPosition {
361
394
  }
362
395
 
363
396
  /** The rounds of a loop in order: the coding round, then a review and, after
364
- * every review but the last, a fix, then the merge wait. */
397
+ * every review but the last, a findings round, then the merge wait. */
365
398
  export function loopRounds(loop: Loop): RoundKind[] {
366
399
  const rounds: RoundKind[] = ["coding"];
367
400
  for (let i = 0; i < loop.maxRounds; i++) {
368
401
  rounds.push("review");
369
- if (i < loop.maxRounds - 1) rounds.push("fix");
402
+ if (i < loop.maxRounds - 1) rounds.push("findings");
370
403
  }
371
404
  rounds.push("merge");
372
405
  return rounds;
373
406
  }
374
407
 
375
408
  /** Where a round sits in `loopRounds`: the coding round first; review round
376
- * `n` (counted from 1) and the fix that follows it at `2n − 1` and `2n`; the
409
+ * `n` (counted from 1) and the findings round that follows it at `2n − 1`
410
+ * and `2n`; the
377
411
  * merge wait last. The ship coordinator numbers its rounds this way, so the
378
412
  * reserve it carves with is the one the fit assumed. */
379
413
  export function loopPosition(loop: Loop, kind: RoundKind, n = 0): number {
@@ -382,7 +416,7 @@ export function loopPosition(loop: Loop, kind: RoundKind, n = 0): number {
382
416
  return 0;
383
417
  case "review":
384
418
  return 2 * n - 1;
385
- case "fix":
419
+ case "findings":
386
420
  return 2 * n;
387
421
  case "merge":
388
422
  return 2 * loop.maxRounds;
@@ -403,7 +437,7 @@ export function carveChildOfParent(
403
437
  }
404
438
 
405
439
  /** The minutes a round holds back for what must follow it in the loop: the
406
- * floor plus provisioning of every later review and fix, and the merge
440
+ * floor plus provisioning of every later review and findings round, and the merge
407
441
  * wait's floor. A merge holds nothing back; a round with no loop (a
408
442
  * conductor's child) holds nothing back either. */
409
443
  export function reserveMinutes(
@@ -419,12 +453,12 @@ export function reserveMinutes(
419
453
  return reserve;
420
454
  }
421
455
 
422
- /** What a round asks for: the preset's ask for a coding or fix round, the
456
+ /** What a round asks for: the preset's ask for a coding or findings round, the
423
457
  * review's for a review, the merge wait's own for the merge. */
424
458
  export function roundAskMinutes(kind: RoundKind): number {
425
459
  switch (kind) {
426
460
  case "coding":
427
- case "fix":
461
+ case "findings":
428
462
  return ASKS.coding;
429
463
  case "review":
430
464
  return ASKS.review;
@@ -233,6 +233,26 @@ export interface ChecksSettledPayload {
233
233
  settledAt: number;
234
234
  }
235
235
 
236
+ /** The event the merge watch sends a waiting unit when its pull request
237
+ * merged, whoever merged (record 0071, mechanism three): the unit ends
238
+ * `merged` by other. The type names the unit within the instance, the
239
+ * nudge's alphabet and clip rule. */
240
+ export const PULL_MERGED_EVENT_PREFIX = "pr-merged-";
241
+ export function pullMergedEventType(key: { instanceId: string; unit: string }): string {
242
+ const suffix = `-${key.unit}`;
243
+ const room = 100 - PULL_MERGED_EVENT_PREFIX.length - suffix.length;
244
+ return `${PULL_MERGED_EVENT_PREFIX}${key.instanceId.slice(0, room)}${suffix}`;
245
+ }
246
+
247
+ /** What the merged event carries — the pull request and the merge's facts;
248
+ * the waiting unit confirms through its own pr-check before it acts. */
249
+ export interface PullMergedPayload {
250
+ repo: string;
251
+ number: number;
252
+ sha: string;
253
+ mergedAt: string;
254
+ }
255
+
236
256
  /** What the event carries — ids, a status and a clock; the parent confirms
237
257
  * through `read-record` before it acts, so nothing more rides here. */
238
258
  export interface RunFinishedPayload {
@@ -379,17 +399,17 @@ export interface UnitIdle {
379
399
  wakes: number;
380
400
  }
381
401
 
382
- /** One unit of the plan an instance runs (a task string is a generated plan of
383
- * one unit, `U1`): its branch, the units it waits on, and — as the runner
384
- * reaches it — its thread, its pull request, the round boundaries the card
385
- * drew and how it ended. One row a person can read for "what happened to this
386
- * unit". */
387
402
  /** What the severity gate caught on a round: the level in force and the gated findings as `id (severity)`. */
388
403
  export interface RoundGate {
389
404
  level: AddressSeverity;
390
405
  findings: string[];
391
406
  }
392
407
 
408
+ /** One unit of the plan an instance runs (a task string is a generated plan of
409
+ * one unit, `U1`): its branch, the units it waits on, and — as the runner
410
+ * reaches it — its thread, its pull request, the round boundaries the card
411
+ * drew and how it ended. One row a person can read for "what happened to this
412
+ * unit". */
393
413
  export interface CoordinatorUnit {
394
414
  instanceId: string;
395
415
  /** `U<n>` as the plan spells it. */
@@ -606,6 +626,26 @@ export async function sendChecksSettled(
606
626
  }
607
627
  }
608
628
 
629
+ /** The one send per merged fact the watch observed (record 0071): best effort
630
+ * like `sendRunFinished` — a refusal is answered, never thrown; the waiting
631
+ * unit's bounded wait re-asks its own pr-check on its next chunk. */
632
+ export async function sendPullMerged(
633
+ workflow: WorkflowSender | undefined,
634
+ key: { instanceId: string; unit: string },
635
+ payload: PullMergedPayload,
636
+ ): Promise<RunFinishedSend> {
637
+ const instance = key.instanceId;
638
+ if (!workflow) return { kind: "no-binding", instance };
639
+ const type = pullMergedEventType(key);
640
+ try {
641
+ const handle = await workflow.get(instance);
642
+ await handle.sendEvent({ type, payload });
643
+ return { kind: "sent", instance, type };
644
+ } catch (err) {
645
+ return { kind: "failed", instance, type, reason: err instanceof Error ? err.message : String(err) };
646
+ }
647
+ }
648
+
609
649
  /** One deploy-roll signal to the child's parent (run-history item 47a): best
610
650
  * effort like `sendRunFinished` — a refusal is answered, never thrown; a lost
611
651
  * send costs the wait a chunk, never the round. */
@@ -61,7 +61,7 @@ import {
61
61
  type UnitEnding,
62
62
  type UnitPipelineState,
63
63
  stepPrefixOf,
64
- type UnitSession,
64
+ type LeaseSegmentProgress,
65
65
  } from "../ship/coordinator.js";
66
66
  import {
67
67
  checksSettledEventType,
@@ -316,7 +316,11 @@ function readRecordReturn(step: string, a: BotAnswer): StepReturn {
316
316
  // The hard stop's mark, as the bot's answer carries it (record 0060; issue
317
317
  // 1924): a finished child's unit ends stopped on it.
318
318
  const stopped = a.body.stopped === true ? { stopped: true as const } : {};
319
- if (!run.finished) return { type: "read-record", step, run: { finished: false }, ...stopped, at: a.body.at };
319
+ // The interrupted child restarted from its request (issue 1903): the bot
320
+ // answers the live successor's id, and the machine keeps the wait on it.
321
+ const restarted = typeof a.body.restartedAs === "string" ? { restartedAs: a.body.restartedAs } : {};
322
+ if (!run.finished)
323
+ return { type: "read-record", step, run: { finished: false }, ...stopped, ...restarted, at: a.body.at };
320
324
  if (typeof run.status !== "string") throw new UnreadableAnswer("read-record", a, "status");
321
325
  // The typed artifacts as the bot's record carries them — shape-checked where
322
326
  // they were written (the run record's validator), read here as they are.
@@ -337,6 +341,7 @@ function readRecordReturn(step: string, a: BotAnswer): StepReturn {
337
341
  costUsd,
338
342
  handoffLists,
339
343
  failure,
344
+ interruption,
340
345
  } = facts;
341
346
  return {
342
347
  type: "read-record",
@@ -363,6 +368,11 @@ function readRecordReturn(step: string, a: BotAnswer): StepReturn {
363
368
  // The failure by name (run-history item 57), shape-checked: a
364
369
  // `provider_transient` drives the round-0 re-run (agent-ship item 9).
365
370
  ...(isRecord(failure) && typeof failure.kind === "string" ? { failure: { kind: failure.kind } } : {}),
371
+ // What ended an interrupted child (issue 1876): the ending's sentence
372
+ // names the cause instead of claiming a bot restart for every one.
373
+ ...(interruption === "bot_restart" || interruption === "container_replaced" || interruption === "sandbox_fault"
374
+ ? { interruption }
375
+ : {}),
366
376
  },
367
377
  at: a.body.at,
368
378
  };
@@ -441,12 +451,15 @@ function prCheckReturn(step: string, a: BotAnswer): StepReturn {
441
451
  * ask's answer carries `retried` instead: whether the re-run was dispatched,
442
452
  * so the machine never waits on a head an undispatched re-run left unchanged. */
443
453
  function checksReturn(step: string, a: BotAnswer): StepReturn {
444
- const { ok, checks, retried, at } = a.body;
454
+ const { ok, checks, draft, retried, at } = a.body;
445
455
  if (ok !== true) throw new UnreadableAnswer("checks", a, "ok");
446
456
  return {
447
457
  type: "checks",
448
458
  step,
449
459
  ...(isRoundChecks(checks) ? { checks } : {}),
460
+ // The pull request is a draft (issue 2063): the machine's table holds the
461
+ // unit for the ready event instead of merging or ending without a cause.
462
+ ...(draft === true ? { draft: true } : {}),
450
463
  ...(typeof retried === "boolean" ? { retried } : {}),
451
464
  at,
452
465
  };
@@ -681,7 +694,7 @@ async function runUnit(
681
694
  instanceId: string,
682
695
  node: PlanUnitNode,
683
696
  plan: PlanFacts,
684
- session?: UnitSession,
697
+ session?: LeaseSegmentProgress,
685
698
  ): Promise<UnitEnding> {
686
699
  const unit = node.id;
687
700
  // A renewal's segment names its steps under the segment (`U10/s2/…`), so
@@ -186,7 +186,10 @@ export interface WeekRow extends DeliveryIndicators {
186
186
  prs: number[];
187
187
  }
188
188
 
189
- export interface UnitRow extends DeliveryIndicators {
189
+ /** One row per board issue the pull requests link — the report's "Issues:"
190
+ * block and the Delivery page's table. The JSON field stays `units` (a served
191
+ * shape older readers hold); only the words are the issue's. */
192
+ export interface IssueRow extends DeliveryIndicators {
190
193
  /** The board issue, or null for the pull requests no issue claims. */
191
194
  issue: number | null;
192
195
  title: string;
@@ -198,7 +201,7 @@ export interface DeliveryReport {
198
201
  repo: string;
199
202
  range: DeliveryRange;
200
203
  weeks: WeekRow[];
201
- units: UnitRow[];
204
+ units: IssueRow[];
202
205
  prs: PullRequestRow[];
203
206
  totals: DeliveryIndicators;
204
207
  identities: DeliveryIdentities;
@@ -448,7 +451,7 @@ export function buildDeliveryReport(input: DeliveryInput): DeliveryReport {
448
451
  const key = p.issue?.number ?? null;
449
452
  byIssue.set(key, [...(byIssue.get(key) ?? []), p]);
450
453
  }
451
- const units: UnitRow[] = [...byIssue.entries()]
454
+ const units: IssueRow[] = [...byIssue.entries()]
452
455
  .sort(([a], [b]) => (a === null ? 1 : b === null ? -1 : a - b))
453
456
  .map(([issue, rows]) => {
454
457
  const first = rows.find((r) => r.issue !== undefined)?.issue;
@@ -572,7 +575,7 @@ function coverage(report: DeliveryReport): string {
572
575
  return `the newest ${report.totals.prsMerged} pull requests only${from}`;
573
576
  }
574
577
 
575
- /** One block per week, then the units — single-spaced lines, so chat carries them as they are. */
578
+ /** One block per week, then the issues — single-spaced lines, so chat carries them as they are. */
576
579
  export function renderDeliveryReport(report: DeliveryReport, nowMs: number = systemClock()): string {
577
580
  const lines: string[] = [
578
581
  [
@@ -597,7 +600,7 @@ export function renderDeliveryReport(report: DeliveryReport, nowMs: number = sys
597
600
  );
598
601
  }
599
602
  if (report.units.length > 0) {
600
- lines.push("", "Units:");
603
+ lines.push("", "Issues:");
601
604
  for (const u of report.units) {
602
605
  const prs = `${u.prs.length === 1 ? "PR" : "PRs"} ${u.prs.join(", ")}`;
603
606
  const who = u.issue === null ? "(no issue)" : `${u.issue} ${u.title}`;
@@ -130,6 +130,63 @@ export const ENDING_STAGE = {
130
130
  blocked: "ended",
131
131
  } as const satisfies Record<UnitEnding["kind"] | "blocked", Stage | "hold">;
132
132
 
133
+ /** The user's words for a unit's ending (record 0066): what a card, a plan
134
+ * summary and the web unit page print instead of the internal token —
135
+ * `merge_ready` reads `merge-ready`, `round_cap` reads `round cap reached`,
136
+ * never the snake_case kind. Pinned to the union plus `blocked` (the driver's
137
+ * never-started status), so a new ending kind fails the build here. */
138
+ export const ENDING_WORDS = {
139
+ merged: "merged",
140
+ already_landed: "merged",
141
+ merge_ready: "merge-ready",
142
+ held: "held",
143
+ merge_refused: "not merged",
144
+ round_cap: "round cap reached",
145
+ wall_clock_cap: "out of budget",
146
+ review_pending: "review pending",
147
+ stopped: "stopped",
148
+ aborted: "aborted",
149
+ transient: "aborted",
150
+ continued: "continuing",
151
+ no_verdict: "no verdict",
152
+ interrupted: "interrupted",
153
+ refused: "refused",
154
+ idle: "idle",
155
+ blocked: "blocked",
156
+ } as const satisfies Record<UnitEnding["kind"] | "blocked", string>;
157
+
158
+ /** The user's words for a round outcome (record 0066): the card's unit line
159
+ * prints these beside the round header, never `checks_failed` or another
160
+ * internal token. Pinned to the union: a new outcome fails the build here. */
161
+ export const ROUND_OUTCOME_WORDS = {
162
+ started: "started",
163
+ pr_opened: "pull request opened",
164
+ completed: "done",
165
+ approve: "approved",
166
+ request_changes: "changes requested",
167
+ no_verdict: "no verdict",
168
+ checks_failed: "checks failed",
169
+ transient: "retried",
170
+ enqueued: "queued to merge",
171
+ dequeued: "removed from the merge queue",
172
+ aborted: "aborted",
173
+ stopped: "stopped",
174
+ continued: "continuing",
175
+ idle: "idle",
176
+ } as const satisfies Record<ShipRoundOutcome, string>;
177
+
178
+ /** The user's word for any ending or unit-status token — total over strings
179
+ * read off a stored row, so an unknown word prints as itself rather than
180
+ * throwing on an older record. */
181
+ export function endingWordOf(kind: string): string {
182
+ return (ENDING_WORDS as Record<string, string>)[kind] ?? kind;
183
+ }
184
+
185
+ /** The user's word for any round-outcome token, total like `endingWordOf`. */
186
+ export function roundOutcomeWordOf(outcome: string): string {
187
+ return (ROUND_OUTCOME_WORDS as Record<string, string>)[outcome] ?? outcome;
188
+ }
189
+
133
190
  type ShipEvent = Extract<RunEvent, { type: "ship_round" } | { type: "ship_unit" }>;
134
191
 
135
192
  function isShipEvent(e: RunEvent): e is ShipEvent {