@coreplane/switchboard 1.234.0 → 1.236.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 (137) hide show
  1. package/dist/assets/Dockerfile +11 -0
  2. package/dist/assets/config/config.example.yaml +9 -4
  3. package/dist/assets/deploy/cloudflare-memory/worker.ts +12 -0
  4. package/dist/assets/deploy/cloudflare-resident/Dockerfile +18 -0
  5. package/dist/assets/deploy/cloudflare-resident/gc.ts +6 -9
  6. package/dist/assets/deploy/cloudflare-resident/worker.ts +225 -119
  7. package/dist/assets/deploy/cloudflare-sandbox/Dockerfile +11 -0
  8. package/dist/assets/deploy/cloudflare-sandbox/worker.ts +143 -13
  9. package/dist/assets/package-lock.json +212 -3
  10. package/dist/assets/package.json +4 -1
  11. package/dist/assets/source.json +3 -3
  12. package/dist/assets/src/core/coordinator/contract.ts +24 -4
  13. package/dist/assets/src/core/coordinator/driver.ts +31 -3
  14. package/dist/assets/src/core/runEvents.ts +43 -1
  15. package/dist/assets/src/core/runFriction.ts +1 -0
  16. package/dist/assets/src/core/ship/contract.ts +3 -1
  17. package/dist/assets/src/core/ship/coordinator.ts +213 -29
  18. package/dist/assets/src/core/trace/types.ts +5 -0
  19. package/dist/assets/src/execution/residentCleanliness.ts +24 -0
  20. package/dist/assets/src/execution/residentDisk.ts +21 -10
  21. package/dist/assets/src/execution/residentDiskBudget.ts +7 -6
  22. package/dist/assets/src/execution/sandboxErrors.ts +77 -0
  23. package/dist/assets/src/execution/sandboxIdle.ts +220 -0
  24. package/dist/assets/src/execution/sandboxStart.ts +90 -0
  25. package/dist/assets/web/dist/.vite/manifest.json +437 -422
  26. package/dist/assets/web/dist/assets/{AppShell-lYVcj-k7.js → AppShell-DZdz_sl7.js} +1 -1
  27. package/dist/assets/web/dist/assets/{CostsPage-D-v8am88.js → CostsPage-BfJsol9y.js} +1 -1
  28. package/dist/assets/web/dist/assets/{DeliveryPage-CvlWP7Eq.js → DeliveryPage-CvInfu7y.js} +1 -1
  29. package/dist/assets/web/dist/assets/{NotFoundPage-BzWS4Ca1.js → NotFoundPage-d37EP8hS.js} +1 -1
  30. package/dist/assets/web/dist/assets/ResidentDetailPage-DQSD0SZD.js +1 -0
  31. package/dist/assets/web/dist/assets/ResidentsIndexPage-CZUGLen6.js +1 -0
  32. package/dist/assets/web/dist/assets/RunFoldRow-Bngn4GjF.js +9 -0
  33. package/dist/assets/web/dist/assets/RunRoutePage-a-tMrirB.js +6 -0
  34. package/dist/assets/web/dist/assets/RunsIndexPage-s0VKmRda.js +1 -0
  35. package/dist/assets/web/dist/assets/{RunsTabs-DEICQ4FY.js → RunsTabs-CyCdOhJ2.js} +1 -1
  36. package/dist/assets/web/dist/assets/ScheduledPage-ByCmyrDs.js +1 -0
  37. package/dist/assets/web/dist/assets/SettingsPage-DhKEokGK.js +1 -0
  38. package/dist/assets/web/dist/assets/{StatusDot-Ct6AtHhS.js → StatusDot-D-BPSPpw.js} +1 -1
  39. package/dist/assets/web/dist/assets/{Tooltip-qol5Boof.js → Tooltip-DPDx5tXT.js} +1 -1
  40. package/dist/assets/web/dist/assets/UnitRoutePage-0eiekTHQ.js +1 -0
  41. package/dist/assets/web/dist/assets/{angular-html-Cw130Zyi.js → angular-html-Ni1Xil2G.js} +1 -1
  42. package/dist/assets/web/dist/assets/{angular-ts-DY1m4C2s.js → angular-ts-oCgo6p05.js} +1 -1
  43. package/dist/assets/web/dist/assets/{apl-W6Ty7v05.js → apl-CiWYl0gh.js} +1 -1
  44. package/dist/assets/web/dist/assets/{astro-DlLVHwVk.js → astro-B-VeKZ_J.js} +1 -1
  45. package/dist/assets/web/dist/assets/{blade-Bco3tDh4.js → blade-BIWEISKd.js} +1 -1
  46. package/dist/assets/web/dist/assets/{c-1LaFY_cj.js → c-DHxA5Byp.js} +1 -1
  47. package/dist/assets/web/dist/assets/{chapel-B-OvrOL5.js → chapel-D4qGLw-Q.js} +1 -1
  48. package/dist/assets/web/dist/assets/{cobol-Ch3EPlHu.js → cobol-BznX5Q-u.js} +1 -1
  49. package/dist/assets/web/dist/assets/{coffee-hZuITnto.js → coffee-CVr8Idlj.js} +1 -1
  50. package/dist/assets/web/dist/assets/{cpp-B8iRVo8m.js → cpp-wrk6NfW4.js} +1 -1
  51. package/dist/assets/web/dist/assets/{crystal-DKveI7lr.js → crystal-CGYWMeH1.js} +1 -1
  52. package/dist/assets/web/dist/assets/{css-_2_CD-rr.js → css-BgGytMsy.js} +1 -1
  53. package/dist/assets/web/dist/assets/{dist-DwtJj-ou.js → dist-DSkVB16z.js} +2 -2
  54. package/dist/assets/web/dist/assets/durationTone-DYogEPEk.js +1 -0
  55. package/dist/assets/web/dist/assets/{edge-BenMDPct.js → edge-CdR6AU9v.js} +1 -1
  56. package/dist/assets/web/dist/assets/{elixir-CBbdEqG6.js → elixir-CQRb3PZx.js} +1 -1
  57. package/dist/assets/web/dist/assets/{elm-DMub_2lY.js → elm-CpuA6W8B.js} +1 -1
  58. package/dist/assets/web/dist/assets/{erb-CI9He_Up.js → erb-CvlrblqH.js} +1 -1
  59. package/dist/assets/web/dist/assets/{favicon-BQsePYv5.js → favicon-CSt-qDvc.js} +1 -1
  60. package/dist/assets/web/dist/assets/{git-rebase-BBfhnyF8.js → git-rebase-COnovCVc.js} +1 -1
  61. package/dist/assets/web/dist/assets/{glimmer-js-CR9Fxrau.js → glimmer-js-C-DS4g_J.js} +1 -1
  62. package/dist/assets/web/dist/assets/{glimmer-ts-CwFwX_XS.js → glimmer-ts-DTkorWRI.js} +1 -1
  63. package/dist/assets/web/dist/assets/{glsl-Daiodp1r.js → glsl-CH51-JW7.js} +1 -1
  64. package/dist/assets/web/dist/assets/{graphql-DZvrsEkB.js → graphql-BO-XymNv.js} +1 -1
  65. package/dist/assets/web/dist/assets/{hack-BaXOzdg7.js → hack-C-N0oicf.js} +1 -1
  66. package/dist/assets/web/dist/assets/{haml-CMskq_VA.js → haml-BfmdtWoP.js} +1 -1
  67. package/dist/assets/web/dist/assets/{handlebars-Dhw_f7jD.js → handlebars-BqhO_te-.js} +1 -1
  68. package/dist/assets/web/dist/assets/{html-C9k99z0z.js → html-DUEZlTKV.js} +1 -1
  69. package/dist/assets/web/dist/assets/{html-derivative-VckPItXN.js → html-derivative-DOUJTS8A.js} +1 -1
  70. package/dist/assets/web/dist/assets/{http-DXU_3h9v.js → http-j7UH0Cqv.js} +1 -1
  71. package/dist/assets/web/dist/assets/{hurl-KU24JNdp.js → hurl-D6ZaBLJk.js} +1 -1
  72. package/dist/assets/web/dist/assets/indexRow-BjXBzI5Z.js +1 -0
  73. package/dist/assets/web/dist/assets/{java-BbZjTNgf.js → java-dugWx3wi.js} +1 -1
  74. package/dist/assets/web/dist/assets/{javascript-DXCdqcZZ.js → javascript-CK4GC4JO.js} +1 -1
  75. package/dist/assets/web/dist/assets/{jinja-pDlsjTVp.js → jinja-DrLKIyKG.js} +1 -1
  76. package/dist/assets/web/dist/assets/{jison-DsXDk66e.js → jison-CS1Th-TO.js} +1 -1
  77. package/dist/assets/web/dist/assets/{json-tCxXfRgG.js → json-CkBqdVys.js} +1 -1
  78. package/dist/assets/web/dist/assets/{jsx-B0ZYkCmi.js → jsx-CCxdg7n3.js} +1 -1
  79. package/dist/assets/web/dist/assets/{julia-BLzzeI6x.js → julia-BalKW2hA.js} +1 -1
  80. package/dist/assets/web/dist/assets/{just-DvD2_60c.js → just-BKw1G40D.js} +1 -1
  81. package/dist/assets/web/dist/assets/{latex-511zn3h7.js → latex-Dfad6ZN-.js} +1 -1
  82. package/dist/assets/web/dist/assets/{liquid-BD0BkMox.js → liquid-f_CMGBvd.js} +1 -1
  83. package/dist/assets/web/dist/assets/{indexFormat-B-pd4r7Y.js → localIso-CNA-bhS-.js} +1 -1
  84. package/dist/assets/web/dist/assets/{lua-NFicKm6N.js → lua-BcMK_uKM.js} +1 -1
  85. package/dist/assets/web/dist/assets/main-B77vAZVg.css +1 -0
  86. package/dist/assets/web/dist/assets/main-DR8iKpNt.js +28 -0
  87. package/dist/assets/web/dist/assets/{marko-1-G_nEXK.js → marko-B0oNLx2i.js} +1 -1
  88. package/dist/assets/web/dist/assets/{mdc-DUe3AQLp.js → mdc-Bc_MmIyq.js} +1 -1
  89. package/dist/assets/web/dist/assets/{nginx-Dg379_bA.js → nginx-Cmxv8RIt.js} +1 -1
  90. package/dist/assets/web/dist/assets/{nim-BnFq97ZO.js → nim-BtgRfaY0.js} +1 -1
  91. package/dist/assets/web/dist/assets/{org-F0uiwGvq.js → org-Dl9EzlHh.js} +1 -1
  92. package/dist/assets/web/dist/assets/{perl-B0euczkl.js → perl-BmF7x7BB.js} +1 -1
  93. package/dist/assets/web/dist/assets/{php-DkL3k_n7.js → php-rH6CVptQ.js} +1 -1
  94. package/dist/assets/web/dist/assets/{pug-L_OjjZP4.js → pug-CIN5Ccck.js} +1 -1
  95. package/dist/assets/web/dist/assets/{qml-BMjB00Zz.js → qml-BpLS3RFr.js} +1 -1
  96. package/dist/assets/web/dist/assets/{r-DoeLdnqR.js → r-BUMF3B-H.js} +1 -1
  97. package/dist/assets/web/dist/assets/{razor-BzYNkAWy.js → razor-B7sTBPdT.js} +1 -1
  98. package/dist/assets/web/dist/assets/{regexp-CPmElMk3.js → regexp-h2SvwrsJ.js} +1 -1
  99. package/dist/assets/web/dist/assets/residentsModel-VSJEepL1.js +1 -0
  100. package/dist/assets/web/dist/assets/{rst-DrsjgfI7.js → rst-DLOWEsD2.js} +1 -1
  101. package/dist/assets/web/dist/assets/{ruby-cB42ppvx.js → ruby-Cl_-I4k3.js} +1 -1
  102. package/dist/assets/web/dist/assets/{sas-R5Y3NM_I.js → sas-Cm4M-BvV.js} +1 -1
  103. package/dist/assets/web/dist/assets/{scss-C-zWxV9s.js → scss-CEFCEQUF.js} +1 -1
  104. package/dist/assets/web/dist/assets/{shellscript-oJF96aAU.js → shellscript-CE6zb5eS.js} +1 -1
  105. package/dist/assets/web/dist/assets/{shellsession-CznECKyi.js → shellsession-B4fDVzrM.js} +1 -1
  106. package/dist/assets/web/dist/assets/{soy-wjHLSRag.js → soy-DwXCMpjE.js} +1 -1
  107. package/dist/assets/web/dist/assets/{sql-C_BM8IOW.js → sql-DrnAKnyD.js} +1 -1
  108. package/dist/assets/web/dist/assets/{stata-D31HO8aQ.js → stata-BzlwPWD6.js} +1 -1
  109. package/dist/assets/web/dist/assets/{surrealql-CKCLyplA.js → surrealql-CeUieZXd.js} +1 -1
  110. package/dist/assets/web/dist/assets/{svelte-3geWk2iu.js → svelte-BERIpIKR.js} +1 -1
  111. package/dist/assets/web/dist/assets/{templ-DiJr_hTD.js → templ-6FSuUDwg.js} +1 -1
  112. package/dist/assets/web/dist/assets/{tex-BEJGWyeF.js → tex-SWCyMwNX.js} +1 -1
  113. package/dist/assets/web/dist/assets/{ts-tags-C8Mzdpxx.js → ts-tags-VWj7gWgY.js} +1 -1
  114. package/dist/assets/web/dist/assets/{tsx-UQiSm_p3.js → tsx-B6dzfL8L.js} +1 -1
  115. package/dist/assets/web/dist/assets/{twig-_Pvzkeo9.js → twig-BDXsr482.js} +1 -1
  116. package/dist/assets/web/dist/assets/{typescript-BWMkINKK.js → typescript-s100Dt8y.js} +1 -1
  117. package/dist/assets/web/dist/assets/{typst-Bdg9m-e7.js → typst-WfyEAUdt.js} +1 -1
  118. package/dist/assets/web/dist/assets/{vue-CDZrrAZ8.js → vue-CkXOOJ-6.js} +1 -1
  119. package/dist/assets/web/dist/assets/{vue-html-tAlpNhfN.js → vue-html-CEpzdcau.js} +1 -1
  120. package/dist/assets/web/dist/assets/{vue-vine-C27UF_rh.js → vue-vine-D9dsfiFA.js} +1 -1
  121. package/dist/assets/web/dist/assets/{xml-CxeDr9Zh.js → xml-iBDofbJq.js} +1 -1
  122. package/dist/assets/web/dist/assets/{xsl-Bpi0uUnM.js → xsl-CqbJF4hD.js} +1 -1
  123. package/dist/assets/web/dist/assets/{yaml-BtUgZ1GN.js → yaml-D9i6OxYe.js} +1 -1
  124. package/dist/cli.js +2336 -1192
  125. package/package.json +1 -1
  126. package/dist/assets/web/dist/assets/ResidentDetailPage-C_WNBUao.js +0 -1
  127. package/dist/assets/web/dist/assets/ResidentsIndexPage-D1sTyaZ_.js +0 -1
  128. package/dist/assets/web/dist/assets/RunFoldRow-C_bcTSjA.js +0 -5
  129. package/dist/assets/web/dist/assets/RunRoutePage-BWTi_6PG.js +0 -10
  130. package/dist/assets/web/dist/assets/RunsIndexPage-H6GkSv0a.js +0 -1
  131. package/dist/assets/web/dist/assets/ScheduledPage-BLcPzm1S.js +0 -1
  132. package/dist/assets/web/dist/assets/UnitRoutePage-CIhTEwN7.js +0 -1
  133. package/dist/assets/web/dist/assets/indexRow-Bnj883ii.js +0 -1
  134. package/dist/assets/web/dist/assets/localIso-L06jV29p.js +0 -1
  135. package/dist/assets/web/dist/assets/main-CRtlGxRm.js +0 -28
  136. package/dist/assets/web/dist/assets/main-Dm11o0hc.css +0 -1
  137. package/dist/assets/web/dist/assets/residentDiskBudget-BMBKlYRH.js +0 -1
@@ -356,6 +356,21 @@ export interface SpanEndEvent {
356
356
  at?: number;
357
357
  }
358
358
 
359
+ /** A value a `route` event may carry in a bound command input: JSON, nested
360
+ * at most three objects deep under `options`, and nothing else. The record
361
+ * crosses the run store's RPC boundary, whose typing walks every field: a
362
+ * field of `unknown` types the whole record `never`, and a recursive alias is
363
+ * "excessively deep" to it, so the nesting is spelled out level by level. The
364
+ * model's tool call is JSON to begin with; the registry's option schemas nest
365
+ * one object deep (`models.coding`); a deeper value is stored as its JSON
366
+ * text, still redacted and capped. */
367
+ export type RouteInputLeaf = string | number | boolean | null;
368
+ export type RouteInputLeafOrList = RouteInputLeaf | ReadonlyArray<RouteInputLeaf>;
369
+ export type RouteInputObject1 = { readonly [key: string]: RouteInputLeafOrList };
370
+ export type RouteInputObject2 = { readonly [key: string]: RouteInputLeafOrList | RouteInputObject1 };
371
+ export type RouteInputObject3 = { readonly [key: string]: RouteInputLeafOrList | RouteInputObject2 };
372
+ export type RouteInputValue = RouteInputLeafOrList | RouteInputObject3;
373
+
359
374
  export type RunEvent =
360
375
  /** `callId` is the provider's tool_use id — the explicit pair key between a
361
376
  * call and its result (live-view item 13); the runner stamps it on both. */
@@ -367,6 +382,11 @@ export type RunEvent =
367
382
  * past the summary cap. The status card and friction analyzer keep reading
368
383
  * `summary`. */
369
384
  /** `spanId` (docs/reference/specs/tracing.md): the `tool.*` span this call ran under, once the runner emits spans. */
385
+ /** `logIndex` (docs/reference/specs/run-history.md item 53): the row of the run's
386
+ * session log holding the assistant turn this call rode in — on a `tool_result`,
387
+ * the user turn its batch's results make. How a search hit's turn finds its
388
+ * step on the page. Absent for a run without a session, a record from before
389
+ * the field, and a call the mirror could not place. */
370
390
  | {
371
391
  type: "tool_call";
372
392
  tool: string;
@@ -374,6 +394,7 @@ export type RunEvent =
374
394
  command?: string;
375
395
  callId?: string;
376
396
  spanId?: string;
397
+ logIndex?: number;
377
398
  seq?: number;
378
399
  at?: number;
379
400
  }
@@ -393,6 +414,7 @@ export type RunEvent =
393
414
  output?: string;
394
415
  infra?: true;
395
416
  spanId?: string;
417
+ logIndex?: number;
396
418
  seq?: number;
397
419
  at?: number;
398
420
  }
@@ -628,6 +650,16 @@ export type RunEvent =
628
650
  seq?: number;
629
651
  at?: number;
630
652
  }
653
+ /** The coordinator tag as a fact of the run (docs/reference/specs/run-history.md
654
+ * item 48a): the instance the run is a child of, the unit its idempotency
655
+ * key named, and the base branch its pull request targets — published by
656
+ * the dispatcher once at dispatch, from `DispatchOptions.coordinator`, so
657
+ * a run re-attached or restarted after a bot roll (whose spawn options are
658
+ * gone with the process) reads the plan's base back off its own events
659
+ * instead of letting the binding ref — the unit branch itself — stand in.
660
+ * `base` is absent when the spawn knew none; the post-step then falls to
661
+ * the coordinator store's `instance.base`. Additive: unknown → ignored. */
662
+ | { type: "coordinator_tag"; parentInstanceId: string; unit?: string; base?: string; seq?: number; at?: number }
631
663
  /** The review post-step's outcome when the verdict landed
632
664
  * (docs/reference/specs/agent-review.md item 18): the pull request it was
633
665
  * posted to, the head it was pinned to (the carried head after a rebase,
@@ -672,7 +704,14 @@ export type RunEvent =
672
704
  * `run_meta.agentSource` stays `default`). Published by the dispatcher
673
705
  * straight to the registry right after `run_meta`, once per run the router
674
706
  * answered; absent on every run a directive, a sticky preset or a scope
675
- * chose. Head material, like `run_meta`. Additive: unknown → ignored. */
707
+ * chose. Head material, like `run_meta`. Additive: unknown → ignored.
708
+ * A command the router bound from prose (record 0036: the door offers every
709
+ * chat command as a tool beside `route`) rides the command run's record
710
+ * instead, right after its `run_meta`: `preset` is the command run's agent
711
+ * (`command`), `command` the id the model called, `input` the bound input
712
+ * (redacted, each value capped) and `receipt` the chat form the reply led
713
+ * with (`routed: <chat form>`, redacted and capped at `ROUTE_RECEIPT_CAP`);
714
+ * how the invoke ended is the run's own status and `answer`. */
676
715
  | {
677
716
  type: "route";
678
717
  preset: string;
@@ -680,6 +719,9 @@ export type RunEvent =
680
719
  model: string;
681
720
  parts?: ReadonlyArray<{ preset: string; text: string }>;
682
721
  collapsed?: { presets: ReadonlyArray<string> };
722
+ command?: string;
723
+ input?: { readonly [key: string]: RouteInputValue };
724
+ receipt?: string;
683
725
  seq?: number;
684
726
  at?: number;
685
727
  }
@@ -421,6 +421,7 @@ export function analyzeRunFriction(events: readonly RunEvent[], opts: FrictionOp
421
421
  ev.type === "review_artifact" ||
422
422
  ev.type === "pr_description" ||
423
423
  ev.type === "pr_opened" ||
424
+ ev.type === "coordinator_tag" ||
424
425
  ev.type === "review_posted" ||
425
426
  ev.type === "ship_round" ||
426
427
  ev.type === "route" ||
@@ -427,7 +427,9 @@ function renderFirstInstruction(rebase: ChildContract["rebase"]): string {
427
427
  const onto = rebase.onto ? `\`${rebase.onto}\`` : "the merged parent";
428
428
  return (
429
429
  `Rebase ${branch} onto ${onto} before any other work — the parent unit has merged and the base has moved; ` +
430
- `the only writes are your own on that branch. A conflict ends the unit: report it as the handoff and stop.`
430
+ `the only writes are your own on that branch. A conflict ends the unit: report it as the handoff and stop. ` +
431
+ `Right before the push, fetch ${onto} again and rebase once more if it moved during verify, ` +
432
+ `so the pull request is not born conflicting.`
431
433
  );
432
434
  }
433
435
 
@@ -51,6 +51,29 @@ export interface ShipCaps {
51
51
  * burning an attach and a model turn on a doomed round. */
52
52
  export const SHIP_ROUND_RESERVE_MS = 3 * MIN;
53
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;
76
+
54
77
  /** What a ship pipeline's thread and card say when the bot died under it (run-
55
78
  * history item 36): the work it did stands on GitHub with nobody driving it,
56
79
  * so the note names the PR when one was opened and the exact re-issue that
@@ -407,7 +430,15 @@ export type CoordinatorAction =
407
430
  | { type: "spawn"; step: string; preset: ChildPreset; round: RoundRef; budgetMinutes: number; brief: Brief }
408
431
  | { type: "wait"; step: string; runId: string; timeoutMs: number }
409
432
  | { type: "read-record"; step: string; runId: string }
410
- | { type: "pr-check"; step: string }
433
+ | {
434
+ type: "pr-check";
435
+ step: string;
436
+ /** Set after a coding child died: the bot opens the pull request from the
437
+ * pushed branch itself (title from the unit, body from this run's
438
+ * submitted description when the record holds one) instead of answering
439
+ * `none` over stranded work. */
440
+ recover?: { runId: string };
441
+ }
411
442
  | { type: "merge"; step: string; prNumber: number; headSha: string }
412
443
  | { type: "sleep"; step: string; ms: number }
413
444
  | { type: "end"; step: string; ending: UnitEnding };
@@ -442,7 +473,14 @@ export type ChildFacts =
442
473
  /** What heads the unit's branch on GitHub: nothing, an open pull request, or —
443
474
  * with no open one — a merged one, `sha` the merge commit on the base. */
444
475
  export type PrCheck =
445
- | { state: "none" }
476
+ | {
477
+ state: "none";
478
+ /** After a recover pr-check (a dead coding child): why nothing was
479
+ * recovered — `no_commits` (GitHub refused the create: nothing between
480
+ * the base and the head) or `no_base` (the instance names no base to
481
+ * open against, so no create was tried). Absent on a plain check. */
482
+ unrecovered?: "no_commits" | "no_base";
483
+ }
446
484
  | { state: "open"; prNumber: number; url: string; headSha?: string; autoMergeEnabled?: boolean }
447
485
  | { state: "merged"; prNumber: number; url: string; sha: string; mergedAt: string };
448
486
 
@@ -473,7 +511,12 @@ export type UnitEnding =
473
511
  | { kind: "merge_ready"; pr: PrRef; reviewRounds: number }
474
512
  | { kind: "merge_refused"; pr: PrRef; reason: string; reviewRounds: number }
475
513
  | { kind: "round_cap"; maxRounds: number; reviewRounds: number }
476
- | { kind: "wall_clock_cap"; remainingMs: number; reviewRounds: number }
514
+ | { kind: "wall_clock_cap"; remainingMs: number; reviewRounds: number; spent: ShipBudgetSpent }
515
+ /** The wall clock capped AFTER the coding child opened or updated the pull
516
+ * request: the work stands and only the review is missing, so the ending
517
+ * names the pull request and "review pending" instead of calling the unit a
518
+ * failure — the re-issued attempt starts at the review round (`lastPush`). */
519
+ | { kind: "review_pending"; pr: PrRef; headSha?: string; reviewRounds: number; spent: ShipBudgetSpent }
477
520
  | {
478
521
  kind: "stopped";
479
522
  mode: "soft" | "hard";
@@ -494,6 +537,12 @@ export type CoordinatorNote =
494
537
  | { type: "round"; index: number; agent: ChildPreset; outcome: ShipRoundOutcome }
495
538
  | { type: "ended"; ending: UnitEnding };
496
539
 
540
+ /** How the pipeline's budget went, in ms: the coding rounds' (round 0 and the
541
+ * findings steps), the review rounds', and everything else (branching,
542
+ * pr-checks, busy waits, merge polls) — reported on the cap endings so a
543
+ * person can see whether the cap or the child is the problem. */
544
+ export type ShipBudgetSpent = Readonly<Record<"coding" | "review" | "waiting", number>>;
545
+
497
546
  export interface UnitPipelineInput {
498
547
  unit: { id: string; branch: string };
499
548
  repo: string;
@@ -513,6 +562,11 @@ export interface UnitPipelineInput {
513
562
  generated: boolean;
514
563
  /** Resume at review: an open pull request of ship's own the requester named. */
515
564
  resume?: { pr: number; headSha?: string; url?: string };
565
+ /** The head the previous attempt's coding child last pushed (a
566
+ * `review_pending` ending's `headSha`, carried on the unit's row): when the
567
+ * pre-check finds the open pull request still at exactly this head, there is
568
+ * nothing to code and the attempt starts at the review round. */
569
+ lastPush?: string;
516
570
  }
517
571
 
518
572
  type Phase =
@@ -524,7 +578,17 @@ type Phase =
524
578
  /** `until`: when the child's budget plus the margin runs out, counted from the spawn's answer — the wait's last slice ends there. */
525
579
  | { at: "wait"; round: RoundRef; runId: string; n: number; until: number }
526
580
  | { at: "read"; round: RoundRef; runId: string; n: number; until: number }
527
- | { at: "pr-check"; round: RoundRef; runId: string; childHead?: string; finalReply?: string }
581
+ | {
582
+ at: "pr-check";
583
+ round: RoundRef;
584
+ runId: string;
585
+ childHead?: string;
586
+ finalReply?: string;
587
+ /** The coding child died (`failed` or `interrupted`) after it may have
588
+ * pushed: the pr-check recovers a pushed branch by opening its pull
589
+ * request; with nothing pushed the unit ends with the child's own reason. */
590
+ dead?: "failed" | "interrupted";
591
+ }
528
592
  | { at: "merge"; pr: PrRef; headSha: string; n: number; since: number }
529
593
  | { at: "merge-sleep"; pr: PrRef; headSha: string; n: number; since: number }
530
594
  | { at: "ended" };
@@ -551,6 +615,8 @@ export interface UnitPipelineState {
551
615
  readonly findingsRunByRound: Readonly<Record<number, string>>;
552
616
  /** The last coding run (round 0's child or a findings step's): its record carries the unit's handoff. */
553
617
  readonly lastCodingRunId?: string;
618
+ /** How the budget went so far, accrued as each answer moves the clock. */
619
+ readonly spentMs: ShipBudgetSpent;
554
620
  readonly ending?: UnitEnding;
555
621
  }
556
622
 
@@ -579,6 +645,7 @@ export function openUnitPipeline(input: UnitPipelineInput, at: number): UnitPipe
579
645
  clock: at,
580
646
  phase: { at: "pre-check" },
581
647
  reviewRounds: 0,
648
+ spentMs: { coding: 0, review: 0, waiting: 0 },
582
649
  findingsByRound: {},
583
650
  dispositionsByRound: {},
584
651
  reviewRunByRound: {},
@@ -609,8 +676,25 @@ function waitSliceMs(clock: number, until: number): number {
609
676
  /** The child's budget: its preset's own, clipped to the pipeline's remaining
610
677
  * wall clock (agent-ship item 8's clip), never under the two minutes a
611
678
  * spawn accepts. */
612
- function budgetMinutesFor(s: UnitPipelineState, preset: ChildPreset): number {
613
- return Math.max(2, Math.min(s.input.childMinutes[preset], Math.floor(remainingMs(s) / MIN)));
679
+ function budgetMinutesFor(s: UnitPipelineState, round: RoundRef): number {
680
+ const headroom = remainingMs(s) - reserveFor(round.kind);
681
+ return Math.max(2, Math.min(s.input.childMinutes[presetOf(round.kind)], Math.floor(headroom / MIN)));
682
+ }
683
+
684
+ /** What a child of each round kind leaves on the pipeline's clock: round 0's
685
+ * coding child the whole loop (two reviews and the merge poll,
686
+ * SHIP_LOOP_RESERVE_MS), a findings child the re-review and the merge poll
687
+ * (SHIP_FIX_RESERVE_MS), a review child nothing — the review is what the
688
+ * reserve was held for. */
689
+ function reserveFor(kind: RoundKind): number {
690
+ switch (kind) {
691
+ case "coding":
692
+ return SHIP_LOOP_RESERVE_MS;
693
+ case "findings":
694
+ return SHIP_FIX_RESERVE_MS;
695
+ case "review":
696
+ return 0;
697
+ }
614
698
  }
615
699
 
616
700
  const roundStep = (s: UnitPipelineState, round: RoundRef) => `${s.input.unit.id}/${round.index}/${round.kind}`;
@@ -668,7 +752,7 @@ export function nextAction(s: UnitPipelineState): CoordinatorAction {
668
752
  step: roundStep(s, p.round),
669
753
  preset,
670
754
  round: p.round,
671
- budgetMinutes: budgetMinutesFor(s, preset),
755
+ budgetMinutes: budgetMinutesFor(s, p.round),
672
756
  brief: briefFor(s, p.round),
673
757
  };
674
758
  }
@@ -687,7 +771,11 @@ export function nextAction(s: UnitPipelineState): CoordinatorAction {
687
771
  case "read":
688
772
  return { type: "read-record", step: `${roundStep(s, p.round)}/read/${p.n}`, runId: p.runId };
689
773
  case "pr-check":
690
- return { type: "pr-check", step: `${roundStep(s, p.round)}/pr-check` };
774
+ return {
775
+ type: "pr-check",
776
+ step: `${roundStep(s, p.round)}/pr-check`,
777
+ ...(p.dead !== undefined ? { recover: { runId: p.runId } } : {}),
778
+ };
691
779
  case "merge":
692
780
  return { type: "merge", step: `${unit}/merge/${p.n}`, prNumber: p.pr.number, headSha: p.headSha };
693
781
  case "merge-sleep":
@@ -717,12 +805,26 @@ const roundNote = (round: RoundRef, outcome: ShipRoundOutcome): CoordinatorNote
717
805
  outcome,
718
806
  });
719
807
 
808
+ /** The cap's ending: `review_pending` when the clock ran out entering a
809
+ * review round with the child's pull request standing — the work shipped and
810
+ * only the review is missing — else the wall-clock cap. Both carry the split. */
811
+ function capEnding(s: UnitPipelineState, round?: RoundRef): UnitEnding {
812
+ if (round?.kind === "review" && s.pr !== undefined)
813
+ return {
814
+ kind: "review_pending",
815
+ pr: s.pr,
816
+ ...(s.lastReviewHead !== undefined ? { headSha: s.lastReviewHead } : {}),
817
+ reviewRounds: s.reviewRounds,
818
+ spent: s.spentMs,
819
+ };
820
+ return { kind: "wall_clock_cap", remainingMs: remainingMs(s), reviewRounds: s.reviewRounds, spent: s.spentMs };
821
+ }
822
+
720
823
  /** Start a round if the reservation holds (agent-ship item 8's check: a
721
824
  * child clipped under the reserve cannot do useful work). */
722
825
  function enterRound(s: UnitPipelineState, round: RoundRef, notes: CoordinatorNote[] = []): Transition {
723
826
  const remaining = remainingMs(s);
724
- if (remaining < SHIP_ROUND_RESERVE_MS)
725
- return end(s, { kind: "wall_clock_cap", remainingMs: remaining, reviewRounds: s.reviewRounds }, notes);
827
+ if (remaining < SHIP_ROUND_RESERVE_MS) return end(s, capEnding(s, round), notes);
726
828
  const reviewRounds = round.kind === "review" ? round.index : s.reviewRounds;
727
829
  return { state: { ...s, reviewRounds, phase: { at: "spawn", round, busy: 0 } }, notes };
728
830
  }
@@ -773,17 +875,12 @@ function settleCoding(
773
875
  },
774
876
  [roundNote(round, "stopped")],
775
877
  );
878
+ // A failed coding child no longer aborts outright: the pr-check looks at the
879
+ // branch first — a push before the death is recovered as the round's pull
880
+ // request (agent-ship items 10 and 15), and only a branch with
881
+ // nothing on it ends the unit with the child's own reason.
776
882
  if (facts.status === "failed")
777
- return end(
778
- next,
779
- {
780
- kind: "aborted",
781
- reason: `⚠️ The coding child of round ${round.index} (run ${runId}) ended \`failed\` — its run page has the error; nothing was opened or edited from it.`,
782
- round,
783
- reviewRounds: next.reviewRounds,
784
- },
785
- [roundNote(round, "aborted")],
786
- );
883
+ return { state: { ...next, phase: { at: "pr-check", round, runId, dead: "failed" } }, notes: [] };
787
884
  next = {
788
885
  ...next,
789
886
  phase: {
@@ -919,6 +1016,32 @@ function settlePrCheck(s: UnitPipelineState, phase: Extract<Phase, { at: "pr-che
919
1016
  // completed without a pull request of its own, and the unit is done.
920
1017
  if (pr.state === "merged") return foundMerged(s, pr, [roundNote(round, "completed")]);
921
1018
  if (pr.state === "none") {
1019
+ // A dead child left nothing on the branch to recover: the unit ends with
1020
+ // the child's own reason — never the budget clip.
1021
+ if (phase.dead === "interrupted")
1022
+ return end(s, { kind: "interrupted", round, runId: phase.runId, reviewRounds: s.reviewRounds }, [
1023
+ roundNote(round, "aborted"),
1024
+ ]);
1025
+ if (phase.dead === "failed") {
1026
+ // The abort repeats the bot's reason for recovering nothing, and claims
1027
+ // no more than the answer carried.
1028
+ const why =
1029
+ pr.unrecovered === "no_commits"
1030
+ ? `nothing heads \`${s.input.unit.branch}\`: no commits were pushed, so there was no work to recover`
1031
+ : pr.unrecovered === "no_base"
1032
+ ? `\`${s.input.unit.branch}\` could not be given a pull request: the instance names no base branch to open it against, so whatever was pushed stays on the branch`
1033
+ : `the pr-check found no pull request heading \`${s.input.unit.branch}\`, so nothing was recovered`;
1034
+ return end(
1035
+ s,
1036
+ {
1037
+ kind: "aborted",
1038
+ reason: `⚠️ The coding child of round ${round.index} (run ${phase.runId}) ended \`failed\` — its run page has the error — and ${why}.`,
1039
+ round,
1040
+ reviewRounds: s.reviewRounds,
1041
+ },
1042
+ [roundNote(round, "aborted")],
1043
+ );
1044
+ }
922
1045
  const reason =
923
1046
  round.index === 0
924
1047
  ? `⚠️ Ship ended at round 0: the coding round ended without opening a pull request (a clarifying question, a budget write-up, an unproven push or a description-less push ends the pipeline here). No review round ran.`
@@ -944,6 +1067,25 @@ function settlePrCheck(s: UnitPipelineState, phase: Extract<Phase, { at: "pr-che
944
1067
  const codingHead = normalizeHead(head);
945
1068
  const reviewedAt = normalizeHead(s.lastReviewHead);
946
1069
  if (codingHead !== undefined && reviewedAt !== undefined && sameCommit(codingHead, reviewedAt)) {
1070
+ // A findings child that died before it pushed: the recover pr-check found
1071
+ // the round's own pull request, still at the reviewed head. The unit
1072
+ // ends with the child's own reason — the ship-restart note for a bot
1073
+ // roll, the failure for a failed run — never as the round's inaction.
1074
+ if (phase.dead === "interrupted")
1075
+ return end(next, { kind: "interrupted", round, runId: phase.runId, reviewRounds: next.reviewRounds }, [
1076
+ roundNote(round, "aborted"),
1077
+ ]);
1078
+ if (phase.dead === "failed")
1079
+ return end(
1080
+ next,
1081
+ {
1082
+ kind: "aborted",
1083
+ reason: `⚠️ The findings child of round ${round.index} (run ${phase.runId}) ended \`failed\` — its run page has the error — and the branch still sits at \`${codingHead.slice(0, 7)}\`, the commit the review already read, so nothing new was pushed to re-review.`,
1084
+ round,
1085
+ reviewRounds: next.reviewRounds,
1086
+ },
1087
+ [roundNote(round, "aborted")],
1088
+ );
947
1089
  const findings = s.findingsByRound[round.index] ?? [];
948
1090
  const dispositions = s.dispositionsByRound[round.index] ?? [];
949
1091
  const allDeclined =
@@ -967,6 +1109,17 @@ function settlePrCheck(s: UnitPipelineState, phase: Extract<Phase, { at: "pr-che
967
1109
  ]);
968
1110
  }
969
1111
 
1112
+ /** Move the clock and charge the elapsed time to the budget's bucket: a phase
1113
+ * inside a coding or findings round is the coding child's time, a review
1114
+ * round's is the review's, everything else — the pre-check, the branch, busy
1115
+ * waits, the merge polls — is waiting. The split rides the cap endings. */
1116
+ function withClock(s: UnitPipelineState, at: number): UnitPipelineState {
1117
+ const delta = Math.max(0, at - s.clock);
1118
+ const round = "round" in s.phase ? s.phase.round : undefined;
1119
+ const bucket = round === undefined ? "waiting" : round.kind === "review" ? "review" : "coding";
1120
+ return { ...s, clock: at, spentMs: { ...s.spentMs, [bucket]: s.spentMs[bucket] + delta } };
1121
+ }
1122
+
970
1123
  /**
971
1124
  * Feed a step's answer to the machine. An answer for any step but the one the
972
1125
  * machine is at — a duplicate `run finished`, a replayed spawn — changes
@@ -976,7 +1129,7 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
976
1129
  const expected = nextAction(s);
977
1130
  if (expected.type === "end" || ret.step !== expected.step || ret.type !== expected.type)
978
1131
  return { state: s, notes: [] };
979
- const clocked: UnitPipelineState = "at" in ret ? { ...s, clock: ret.at } : s;
1132
+ const clocked: UnitPipelineState = "at" in ret ? withClock(s, ret.at) : s;
980
1133
  const p = s.phase;
981
1134
  switch (p.at) {
982
1135
  case "pre-check": {
@@ -987,6 +1140,20 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
987
1140
  // by the coding child and adopted at the round's own pr-check.
988
1141
  const r = ret as Extract<StepReturn, { type: "pr-check" }>;
989
1142
  if (r.pr.state === "merged") return foundMerged(clocked, r.pr);
1143
+ // The open pull request still heads at the child's own last push (the
1144
+ // previous attempt ended `review_pending`): nothing to code, so the
1145
+ // attempt adopts it and starts at the review round — never a fresh
1146
+ // coding round on an already-shipped pull request.
1147
+ if (r.pr.state === "open") {
1148
+ const head = normalizeHead(r.pr.headSha);
1149
+ const lastPush = normalizeHead(s.input.lastPush);
1150
+ if (head !== undefined && lastPush !== undefined && sameCommit(head, lastPush))
1151
+ return nextReview({
1152
+ ...clocked,
1153
+ pr: { number: r.pr.prNumber, url: r.pr.url },
1154
+ lastReviewHead: head,
1155
+ });
1156
+ }
990
1157
  return { state: { ...clocked, phase: { at: "branch" } }, notes: [] };
991
1158
  }
992
1159
  case "branch": {
@@ -1004,7 +1171,7 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
1004
1171
  case "spawned":
1005
1172
  case "alreadySpawned": {
1006
1173
  // The child's budget runs from the spawn's answer; the wait walks it, plus the margin, in chunks.
1007
- const until = clocked.clock + budgetMinutesFor(s, presetOf(p.round.kind)) * MIN + WAIT_MARGIN_MS;
1174
+ const until = clocked.clock + budgetMinutesFor(s, p.round) * MIN + WAIT_MARGIN_MS;
1008
1175
  const runs =
1009
1176
  p.round.kind === "review"
1010
1177
  ? { reviewRunByRound: { ...s.reviewRunByRound, [p.round.index]: r.runId } }
@@ -1022,12 +1189,7 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
1022
1189
  case "busy": {
1023
1190
  // Another run holds the unit's thread: wait for its end, then ask
1024
1191
  // again — unless the pipeline's wall clock ran out meanwhile.
1025
- if (remainingMs(clocked) < SHIP_ROUND_RESERVE_MS)
1026
- return end(clocked, {
1027
- kind: "wall_clock_cap",
1028
- remainingMs: remainingMs(clocked),
1029
- reviewRounds: s.reviewRounds,
1030
- });
1192
+ if (remainingMs(clocked) < SHIP_ROUND_RESERVE_MS) return end(clocked, capEnding(clocked, p.round));
1031
1193
  return {
1032
1194
  state: {
1033
1195
  ...clocked,
@@ -1076,10 +1238,19 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
1076
1238
  },
1077
1239
  notes: [],
1078
1240
  };
1079
- if (r.run.status === "interrupted")
1241
+ if (r.run.status === "interrupted") {
1242
+ // A dead CODING child may have pushed before the ledger closed it: the
1243
+ // pr-check recovers the branch. A review child has nothing on the
1244
+ // branch to recover, so its interruption still ends the unit at once.
1245
+ if (p.round.kind !== "review")
1246
+ return {
1247
+ state: { ...clocked, phase: { at: "pr-check", round: p.round, runId: p.runId, dead: "interrupted" } },
1248
+ notes: [],
1249
+ };
1080
1250
  return end(clocked, { kind: "interrupted", round: p.round, runId: p.runId, reviewRounds: s.reviewRounds }, [
1081
1251
  roundNote(p.round, "aborted"),
1082
1252
  ]);
1253
+ }
1083
1254
  return p.round.kind === "review"
1084
1255
  ? settleReview(clocked, p.round, r.run)
1085
1256
  : settleCoding(clocked, p.round, p.runId, r.run);
@@ -1135,6 +1306,12 @@ function dispositionFor(s: UnitPipelineState, finding: Finding, round: number):
1135
1306
  return undefined;
1136
1307
  }
1137
1308
 
1309
+ /** How the budget went, in the card's words: coding, review, waiting minutes. */
1310
+ function budgetSplitLine(spent: ShipBudgetSpent, maxMinutes: number): string {
1311
+ const min = (ms: number) => Math.round(ms / MIN);
1312
+ return `Budget split (${maxMinutes} min): coding ${min(spent.coding)} min, review ${min(spent.review)} min, waiting ${min(spent.waiting)} min.`;
1313
+ }
1314
+
1138
1315
  /** The cap report's declined-vs-unaddressed split over the last review round's findings. */
1139
1316
  function splitReport(s: UnitPipelineState): string {
1140
1317
  if (s.reviewRounds === 0) return "No review round ran before the cap — there are no findings to report.";
@@ -1222,9 +1399,16 @@ export function renderUnitReport(s: UnitPipelineState, facts?: MergeReadyFacts):
1222
1399
  case "wall_clock_cap":
1223
1400
  return join([
1224
1401
  `🧢 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}`,
1402
+ budgetSplitLine(e.spent, s.input.caps.maxMinutes),
1225
1403
  splitReport(s),
1226
1404
  reissue,
1227
1405
  ]);
1406
+ case "review_pending":
1407
+ return join([
1408
+ `⏳ Review pending: the coding child shipped ${e.pr.url}${e.headSha !== undefined ? ` (head \`${e.headSha.slice(0, 7)}\`)` : ""} but the remaining pipeline time cannot hold the review round — the work stands, only the review is missing. The next attempt starts at the review round while the pull request still heads at the child's own last push.`,
1409
+ budgetSplitLine(e.spent, s.input.caps.maxMinutes),
1410
+ reissue,
1411
+ ]);
1228
1412
  case "stopped":
1229
1413
  return join([
1230
1414
  `${e.mode === "hard" ? "⛔" : "⏹"} Ship stopped by operator (${e.mode} stop) after ${rounds}.${prLine}`,
@@ -68,6 +68,11 @@ export interface SpanOptions {
68
68
  * plain fetch it was. */
69
69
  export interface TraceOptions {
70
70
  span?: Span;
71
+ /** Which door the call came through when it was not the surface's own
72
+ * grammar: `route` for a command the request router bound from prose
73
+ * (record 0036). Copied onto the registry's audit line, never read by a
74
+ * handler. Absent for a typed command. */
75
+ source?: "route";
71
76
  }
72
77
 
73
78
  export interface Span {
@@ -93,6 +93,30 @@ export function leftBehindOf(measured: WorktreeCleanliness): LeftBehind | undefi
93
93
  return { uncommittedChanges: measured.changes, unpushedCommits: measured.unpushed };
94
94
  }
95
95
 
96
+ /** What an eviction records about the tree it removed
97
+ * (docs/reference/specs/resident-repos.md item 17: dirt never keeps a tree,
98
+ * and nothing is discarded silently), written on the binding beside the
99
+ * cause: the counts when the probes ran and found something, or the probe's
100
+ * first error line when git could not read the tree — nothing readable was
101
+ * kept, but the record must not read as clean. */
102
+ export type EvictedTree = { leftBehind: LeftBehind } | { unmeasured: string };
103
+
104
+ /** The measurement as the eviction's record: `undefined` for a clean tree or
105
+ * one already gone with the disk (nothing was discarded). */
106
+ export function evictedTreeOf(measured: WorktreeCleanliness): EvictedTree | undefined {
107
+ const leftBehind = leftBehindOf(measured);
108
+ if (leftBehind) return { leftBehind };
109
+ if (!measured.clean) return { unmeasured: measured.reason ?? "clean-check failed" };
110
+ return undefined;
111
+ }
112
+
113
+ /** The resident's log line for what an eviction discarded. */
114
+ export function evictedTreeSentence(tree: EvictedTree): string {
115
+ if ("leftBehind" in tree)
116
+ return `left behind ${tree.leftBehind.uncommittedChanges} uncommitted change(s) and ${tree.leftBehind.unpushedCommits} unpushed commit(s), discarded with the tree`;
117
+ return `the tree could not be measured before its removal (${tree.unmeasured})`;
118
+ }
119
+
96
120
  /** The bot's word for a discarded tree, in the release log and the run's
97
121
  * record: what was left, why it is gone, what to do instead. */
98
122
  export function leftBehindSentence(left: LeftBehind): string {
@@ -11,7 +11,7 @@
11
11
  // the disk. Two facts fix that: the failure is classified `disk-full` (never
12
12
  // serviceable, so the bot skips the attach and the card names the disk), and
13
13
  // the resident recycles its container — the disk is a cache; the next refresh
14
- // cycle restores mirror + checkout from R2 — once nothing live would be lost.
14
+ // cycle restores mirror + checkout from R2 — once no run is using it.
15
15
 
16
16
  /** The errno wording tools print for ENOSPC: Node's `ENOSPC` code and libc's
17
17
  * strerror text (git, cp, tar, pnpm all pass it through). A message carrying
@@ -76,17 +76,27 @@ export const DISK_FULL_RECYCLE_COOLDOWN_MS = 60 * 60_000;
76
76
  export type DiskFullRecovery = { action: "recycle" } | { action: "wait"; why: string };
77
77
 
78
78
  /** Whether a disk-full resident may stop its container now. The disk is a
79
- * cache, but two things on it are not: work in flight (a recycle kills the
80
- * process) and a live worktree's uncommitted or unpushed changes (a recycle
81
- * destroys the tree; the next attach recreates it from the mirror). Both keep
82
- * the container; so does the cooldown. `treesClean` must be computed as the
83
- * thread users (never root git in a thread tree) and treated as false when a
84
- * check could not run — an unreadable tree is kept, never guessed clean. */
79
+ * cache — the next cycle restores mirror + checkout from R2, and a live
80
+ * binding's tree is recreated on its next attach — so the one question is
81
+ * whether a RUN is using it, never what the trees hold (item 17: a run
82
+ * starts from a clean tree; what it wants kept, it commits and pushes). Two
83
+ * facts say a run may be: an operation in flight (a recycle kills it), and a
84
+ * live binding attached to or used within the idle floor — the op counter is
85
+ * 0 between a run's tool calls, so the recent-use floor is what stands for a
86
+ * run mid-flight. That is the idle-sleep gate's own predicate (item 16b): a
87
+ * platform sleep destroys the disk exactly as a recycle does, so one rule
88
+ * says when the disk may go away. Both keep the container; so does the
89
+ * cooldown. */
85
90
  export function planDiskFullRecovery(input: {
86
91
  now: number;
87
92
  lastRecycleAt?: number;
88
93
  inFlight: number;
89
- treesClean: boolean;
94
+ /** A live binding was attached to or used (an exec bumps `lastAttachAt`
95
+ * too) within `idleFloorS` — computed once by the Worker, for the idle
96
+ * gate and this plan alike. */
97
+ recentlyUsed: boolean;
98
+ /** The floor `recentlyUsed` was measured against, named in the refusal. */
99
+ idleFloorS: number;
90
100
  }): DiskFullRecovery {
91
101
  if (input.lastRecycleAt !== undefined && input.now - input.lastRecycleAt < DISK_FULL_RECYCLE_COOLDOWN_MS) {
92
102
  const min = Math.round((input.now - input.lastRecycleAt) / 60_000);
@@ -97,10 +107,11 @@ export function planDiskFullRecovery(input: {
97
107
  }
98
108
  if (input.inFlight > 0)
99
109
  return { action: "wait", why: `${input.inFlight} operation(s) in flight — a recycle would kill them` };
100
- if (!input.treesClean) {
110
+ if (input.recentlyUsed) {
111
+ const min = Math.round(input.idleFloorS / 60);
101
112
  return {
102
113
  action: "wait",
103
- why: "a live worktree has (or could not prove it has no) uncommitted or unpushed work — a recycle would destroy it",
114
+ why: `a live worktree was attached to or used within the last ${min} min — a run may be mid-flight between two tool calls, and a recycle would destroy its tree`,
104
115
  };
105
116
  }
106
117
  return { action: "recycle" };
@@ -311,17 +311,18 @@ export interface DiskEvictionCandidate {
311
311
 
312
312
  /** Why a tree was kept under disk pressure. `other` is the caller's fallback
313
313
  * for a keep decided outside `orderEvictionCandidates` (a binding that moved
314
- * during the check); free text never becomes a token. */
315
- export type DiskKeepWhy = "busy" | "default-ref" | "recent" | "dirty" | "requesting" | "other";
314
+ * during the check); free text never becomes a token. No token names dirt:
315
+ * a tree is never kept for what it holds (a run starts from a clean tree),
316
+ * the Worker records what it removes instead. */
317
+ export type DiskKeepWhy = "busy" | "default-ref" | "recent" | "requesting" | "other";
316
318
 
317
319
  /** Order the live trees for eviction under pressure and name every one that is
318
320
  * kept: the requesting thread itself, a busy tree, the default branch (the
319
321
  * one most likely re-attached — the same rule as `reclaimDecision`), and a
320
322
  * tree attached within `DISK_EVICT_MIN_IDLE_MS` are never candidates; the
321
- * rest are ordered coldest first (oldest `lastAttachAt`, ties on key).
322
- * Cleanliness is NOT decided here — it needs the container (as the thread
323
- * user), so the Worker checks each candidate in this order and keeps a dirty
324
- * or unreadable one (`dirty`), exactly like the sweep. */
323
+ * rest are ordered coldest first (oldest `lastAttachAt`, ties on key). What
324
+ * each tree holds is not asked — the Worker measures it as the thread user
325
+ * right before the eviction, for the record only. */
325
326
  export function orderEvictionCandidates(input: {
326
327
  candidates: readonly DiskEvictionCandidate[];
327
328
  now: number;