@coreplane/switchboard 1.235.0 → 1.237.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 (141) 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/worker.ts +290 -118
  6. package/dist/assets/deploy/cloudflare-sandbox/Dockerfile +11 -0
  7. package/dist/assets/deploy/cloudflare-sandbox/worker.ts +104 -17
  8. package/dist/assets/package-lock.json +212 -3
  9. package/dist/assets/package.json +4 -1
  10. package/dist/assets/source.json +3 -3
  11. package/dist/assets/src/core/authz/authorize.ts +25 -6
  12. package/dist/assets/src/core/authz/policy.ts +9 -0
  13. package/dist/assets/src/core/authz/types.ts +15 -1
  14. package/dist/assets/src/core/coordinator/contract.ts +24 -4
  15. package/dist/assets/src/core/coordinator/driver.ts +36 -4
  16. package/dist/assets/src/core/runEvents.ts +46 -7
  17. package/dist/assets/src/core/runFriction.ts +1 -0
  18. package/dist/assets/src/core/ship/contract.ts +3 -1
  19. package/dist/assets/src/core/ship/coordinator.ts +236 -30
  20. package/dist/assets/src/core/trace/types.ts +5 -0
  21. package/dist/assets/src/execution/residentDepsStore.ts +14 -2
  22. package/dist/assets/src/execution/residentDisk.ts +21 -10
  23. package/dist/assets/src/execution/residentDiskBudget.ts +45 -11
  24. package/dist/assets/src/execution/residentHead.ts +34 -8
  25. package/dist/assets/src/execution/residentRebind.ts +88 -31
  26. package/dist/assets/src/execution/sandboxErrors.ts +72 -0
  27. package/dist/assets/src/execution/sandboxLifecycle.ts +33 -0
  28. package/dist/assets/src/execution/sandboxStart.ts +90 -0
  29. package/dist/assets/web/dist/.vite/manifest.json +437 -423
  30. package/dist/assets/web/dist/assets/AppShell-MYHNxJks.js +1 -0
  31. package/dist/assets/web/dist/assets/{CostsPage-D-v8am88.js → CostsPage-DKk9OjB0.js} +1 -1
  32. package/dist/assets/web/dist/assets/{DeliveryPage-CvlWP7Eq.js → DeliveryPage-Kz6js49i.js} +1 -1
  33. package/dist/assets/web/dist/assets/{NotFoundPage-BzWS4Ca1.js → NotFoundPage-BXLrQQzy.js} +1 -1
  34. package/dist/assets/web/dist/assets/ResidentDetailPage-Dc23v_8v.js +1 -0
  35. package/dist/assets/web/dist/assets/ResidentsIndexPage-TR5D1TRR.js +1 -0
  36. package/dist/assets/web/dist/assets/RunFoldRow-mkUj7WEN.js +9 -0
  37. package/dist/assets/web/dist/assets/{RunRoutePage-oN9GkVEA.js → RunRoutePage-CQI17__g.js} +3 -3
  38. package/dist/assets/web/dist/assets/RunsIndexPage-yIP4PlKP.js +1 -0
  39. package/dist/assets/web/dist/assets/{RunsTabs-DEICQ4FY.js → RunsTabs-XpCqeWu4.js} +1 -1
  40. package/dist/assets/web/dist/assets/ScheduledPage-CdCVyNbh.js +1 -0
  41. package/dist/assets/web/dist/assets/SettingsPage-DuA8JpLU.js +1 -0
  42. package/dist/assets/web/dist/assets/{StatusDot-D14iJMy7.js → StatusDot-DmNHX6am.js} +1 -1
  43. package/dist/assets/web/dist/assets/{Tooltip-B0Ob5MQ4.js → Tooltip-qVnJTgSt.js} +1 -1
  44. package/dist/assets/web/dist/assets/UnitRoutePage-BcTeH7m2.js +1 -0
  45. package/dist/assets/web/dist/assets/{angular-html-Cw130Zyi.js → angular-html-DYtV9uIN.js} +1 -1
  46. package/dist/assets/web/dist/assets/{angular-ts-DY1m4C2s.js → angular-ts-DF2P25Zp.js} +1 -1
  47. package/dist/assets/web/dist/assets/{apl-W6Ty7v05.js → apl-Ff96wxko.js} +1 -1
  48. package/dist/assets/web/dist/assets/{astro-DlLVHwVk.js → astro-LbBnZna-.js} +1 -1
  49. package/dist/assets/web/dist/assets/{blade-Bco3tDh4.js → blade-DltuxeBW.js} +1 -1
  50. package/dist/assets/web/dist/assets/{c-1LaFY_cj.js → c-DBg7yuPx.js} +1 -1
  51. package/dist/assets/web/dist/assets/{chapel-B-OvrOL5.js → chapel-C83hUAds.js} +1 -1
  52. package/dist/assets/web/dist/assets/{cobol-Ch3EPlHu.js → cobol-DB0yV_2D.js} +1 -1
  53. package/dist/assets/web/dist/assets/{coffee-hZuITnto.js → coffee-Bpih6Kzl.js} +1 -1
  54. package/dist/assets/web/dist/assets/{cpp-B8iRVo8m.js → cpp-CU00ZeRn.js} +1 -1
  55. package/dist/assets/web/dist/assets/{crystal-DKveI7lr.js → crystal-f8ImJkux.js} +1 -1
  56. package/dist/assets/web/dist/assets/{css-_2_CD-rr.js → css-4VrC4ISo.js} +1 -1
  57. package/dist/assets/web/dist/assets/{dist-pOdUzj6D.js → dist-TAGawIfM.js} +2 -2
  58. package/dist/assets/web/dist/assets/durationTone-CYMux25J.js +1 -0
  59. package/dist/assets/web/dist/assets/{edge-BenMDPct.js → edge-BwFDzfS_.js} +1 -1
  60. package/dist/assets/web/dist/assets/{elixir-CBbdEqG6.js → elixir-wwIYIpJm.js} +1 -1
  61. package/dist/assets/web/dist/assets/{elm-DMub_2lY.js → elm-DyDuM3v3.js} +1 -1
  62. package/dist/assets/web/dist/assets/{erb-CI9He_Up.js → erb-DFJBEVrh.js} +1 -1
  63. package/dist/assets/web/dist/assets/{favicon-BQsePYv5.js → favicon-CSt-qDvc.js} +1 -1
  64. package/dist/assets/web/dist/assets/{git-rebase-BBfhnyF8.js → git-rebase-CuDv-44X.js} +1 -1
  65. package/dist/assets/web/dist/assets/{glimmer-js-CR9Fxrau.js → glimmer-js-ZFnrm4J3.js} +1 -1
  66. package/dist/assets/web/dist/assets/{glimmer-ts-CwFwX_XS.js → glimmer-ts-XY4Yqshp.js} +1 -1
  67. package/dist/assets/web/dist/assets/{glsl-Daiodp1r.js → glsl-xnR8efx0.js} +1 -1
  68. package/dist/assets/web/dist/assets/{graphql-DZvrsEkB.js → graphql-D-gO5tnP.js} +1 -1
  69. package/dist/assets/web/dist/assets/{hack-BaXOzdg7.js → hack-CSxbW4ax.js} +1 -1
  70. package/dist/assets/web/dist/assets/{haml-CMskq_VA.js → haml-D4ar42QT.js} +1 -1
  71. package/dist/assets/web/dist/assets/{handlebars-Dhw_f7jD.js → handlebars-CMLbDE7o.js} +1 -1
  72. package/dist/assets/web/dist/assets/{html-C9k99z0z.js → html-CVekU4Fs.js} +1 -1
  73. package/dist/assets/web/dist/assets/{html-derivative-VckPItXN.js → html-derivative-St3HHw_N.js} +1 -1
  74. package/dist/assets/web/dist/assets/{http-DXU_3h9v.js → http-Mon0DWHS.js} +1 -1
  75. package/dist/assets/web/dist/assets/{hurl-KU24JNdp.js → hurl-Cbl5IvBs.js} +1 -1
  76. package/dist/assets/web/dist/assets/indexRow-FLJu5mR9.js +1 -0
  77. package/dist/assets/web/dist/assets/{java-BbZjTNgf.js → java-Bprck1l-.js} +1 -1
  78. package/dist/assets/web/dist/assets/{javascript-DXCdqcZZ.js → javascript-C73G1-UR.js} +1 -1
  79. package/dist/assets/web/dist/assets/{jinja-pDlsjTVp.js → jinja-C4DuRxYJ.js} +1 -1
  80. package/dist/assets/web/dist/assets/{jison-DsXDk66e.js → jison-B2U03GEC.js} +1 -1
  81. package/dist/assets/web/dist/assets/{json-tCxXfRgG.js → json-CvTZUDzx.js} +1 -1
  82. package/dist/assets/web/dist/assets/{jsx-B0ZYkCmi.js → jsx-DMWKCzwo.js} +1 -1
  83. package/dist/assets/web/dist/assets/{julia-BLzzeI6x.js → julia-Czn7bV_3.js} +1 -1
  84. package/dist/assets/web/dist/assets/{just-DvD2_60c.js → just-BBxpNfvC.js} +1 -1
  85. package/dist/assets/web/dist/assets/{latex-511zn3h7.js → latex-Bw9yg5mr.js} +1 -1
  86. package/dist/assets/web/dist/assets/{liquid-BD0BkMox.js → liquid-BRCjaydE.js} +1 -1
  87. package/dist/assets/web/dist/assets/{indexFormat-B-pd4r7Y.js → localIso-CNA-bhS-.js} +1 -1
  88. package/dist/assets/web/dist/assets/{lua-NFicKm6N.js → lua-DUu2KIC0.js} +1 -1
  89. package/dist/assets/web/dist/assets/main-csihqEj5.css +1 -0
  90. package/dist/assets/web/dist/assets/main-mUd_TJjH.js +28 -0
  91. package/dist/assets/web/dist/assets/{marko-1-G_nEXK.js → marko-CZJZIlSQ.js} +1 -1
  92. package/dist/assets/web/dist/assets/{mdc-DUe3AQLp.js → mdc-BmClq6fd.js} +1 -1
  93. package/dist/assets/web/dist/assets/{nginx-Dg379_bA.js → nginx-Bmt3xSEm.js} +1 -1
  94. package/dist/assets/web/dist/assets/{nim-BnFq97ZO.js → nim-Da-i0utK.js} +1 -1
  95. package/dist/assets/web/dist/assets/{org-F0uiwGvq.js → org-BvAre2qP.js} +1 -1
  96. package/dist/assets/web/dist/assets/{perl-B0euczkl.js → perl-v9WwFFtB.js} +1 -1
  97. package/dist/assets/web/dist/assets/{php-DkL3k_n7.js → php-DTU4Zyze.js} +1 -1
  98. package/dist/assets/web/dist/assets/{pug-L_OjjZP4.js → pug-DhTwEqTO.js} +1 -1
  99. package/dist/assets/web/dist/assets/{qml-BMjB00Zz.js → qml-BUD9AC_c.js} +1 -1
  100. package/dist/assets/web/dist/assets/{r-DoeLdnqR.js → r-CAnu56n7.js} +1 -1
  101. package/dist/assets/web/dist/assets/{razor-BzYNkAWy.js → razor-Dn0P4gAq.js} +1 -1
  102. package/dist/assets/web/dist/assets/{regexp-CPmElMk3.js → regexp-CZyBRaVW.js} +1 -1
  103. package/dist/assets/web/dist/assets/residentsModel-By1fThvk.js +1 -0
  104. package/dist/assets/web/dist/assets/{rst-DrsjgfI7.js → rst-Ckyd5UsY.js} +1 -1
  105. package/dist/assets/web/dist/assets/{ruby-cB42ppvx.js → ruby-BBQ62fsx.js} +1 -1
  106. package/dist/assets/web/dist/assets/{sas-R5Y3NM_I.js → sas-CW35hMxG.js} +1 -1
  107. package/dist/assets/web/dist/assets/{scss-C-zWxV9s.js → scss-CpV8pF4J.js} +1 -1
  108. package/dist/assets/web/dist/assets/{shellscript-oJF96aAU.js → shellscript-CRJvsmE1.js} +1 -1
  109. package/dist/assets/web/dist/assets/{shellsession-CznECKyi.js → shellsession-CyriUZxf.js} +1 -1
  110. package/dist/assets/web/dist/assets/{soy-wjHLSRag.js → soy-D5QzYUeg.js} +1 -1
  111. package/dist/assets/web/dist/assets/{sql-C_BM8IOW.js → sql-CUBf_Mse.js} +1 -1
  112. package/dist/assets/web/dist/assets/{stata-D31HO8aQ.js → stata-B-4n4ITz.js} +1 -1
  113. package/dist/assets/web/dist/assets/{surrealql-CKCLyplA.js → surrealql-RuWZBLtr.js} +1 -1
  114. package/dist/assets/web/dist/assets/{svelte-3geWk2iu.js → svelte-CO5YzCsK.js} +1 -1
  115. package/dist/assets/web/dist/assets/{templ-DiJr_hTD.js → templ-DbkWVaBF.js} +1 -1
  116. package/dist/assets/web/dist/assets/{tex-BEJGWyeF.js → tex-IuxoEcId.js} +1 -1
  117. package/dist/assets/web/dist/assets/{ts-tags-C8Mzdpxx.js → ts-tags-g6dGQPjd.js} +1 -1
  118. package/dist/assets/web/dist/assets/{tsx-UQiSm_p3.js → tsx-WnXwiyH2.js} +1 -1
  119. package/dist/assets/web/dist/assets/{twig-_Pvzkeo9.js → twig-Lx1G7PnY.js} +1 -1
  120. package/dist/assets/web/dist/assets/{typescript-BWMkINKK.js → typescript-BQd7biLF.js} +1 -1
  121. package/dist/assets/web/dist/assets/{typst-Bdg9m-e7.js → typst-D2lXPHK7.js} +1 -1
  122. package/dist/assets/web/dist/assets/{vue-CDZrrAZ8.js → vue-4wVQD9y9.js} +1 -1
  123. package/dist/assets/web/dist/assets/{vue-html-tAlpNhfN.js → vue-html-DZPaRte4.js} +1 -1
  124. package/dist/assets/web/dist/assets/{vue-vine-C27UF_rh.js → vue-vine-DbmZCmu-.js} +1 -1
  125. package/dist/assets/web/dist/assets/{xml-CxeDr9Zh.js → xml-DV16szyo.js} +1 -1
  126. package/dist/assets/web/dist/assets/{xsl-Bpi0uUnM.js → xsl-eo5-1sk0.js} +1 -1
  127. package/dist/assets/web/dist/assets/{yaml-BtUgZ1GN.js → yaml-BXdX8HZf.js} +1 -1
  128. package/dist/cli.js +3329 -1804
  129. package/package.json +1 -1
  130. package/dist/assets/web/dist/assets/AppShell-lYVcj-k7.js +0 -1
  131. package/dist/assets/web/dist/assets/ResidentDetailPage-DSpsye5e.js +0 -1
  132. package/dist/assets/web/dist/assets/ResidentsIndexPage-CT8fI1M3.js +0 -1
  133. package/dist/assets/web/dist/assets/RunFoldRow-Cqh8pTXC.js +0 -9
  134. package/dist/assets/web/dist/assets/RunsIndexPage-sjXr8iCV.js +0 -1
  135. package/dist/assets/web/dist/assets/ScheduledPage-Tz__zLzi.js +0 -1
  136. package/dist/assets/web/dist/assets/UnitRoutePage-CbKCL58v.js +0 -1
  137. package/dist/assets/web/dist/assets/indexRow-Bnj883ii.js +0 -1
  138. package/dist/assets/web/dist/assets/localIso-L06jV29p.js +0 -1
  139. package/dist/assets/web/dist/assets/main-D3lG-yQ9.js +0 -28
  140. package/dist/assets/web/dist/assets/main-Dm11o0hc.css +0 -1
  141. package/dist/assets/web/dist/assets/residentDiskBudget-BMBKlYRH.js +0 -1
@@ -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
@@ -122,6 +145,14 @@ export function unitBranch(planId: string, slug: string): string {
122
145
  return `plan/${planId}/${slug}`;
123
146
  }
124
147
 
148
+ /** Whether `ref` is a unit branch of ship's own — `plan/<plan-id>/<unit-slug>`.
149
+ * A thread keeps the binding its last run opened a pull request on, so after
150
+ * a plan's unit it sits at that unit branch; a fresh task there must not take
151
+ * it as the base (the pull request would target the earlier unit). */
152
+ export function isUnitBranch(ref: string): boolean {
153
+ return /^plan\/[^/]+\/[^/]+$/.test(ref);
154
+ }
155
+
125
156
  /** The plan and unit a head branch names, or undefined for any other branch — the merge grant's line. */
126
157
  export function parsePlanBranch(branch: string): { planId: string; unitSlug: string } | undefined {
127
158
  const m = PLAN_BRANCH.exec(branch);
@@ -407,7 +438,15 @@ export type CoordinatorAction =
407
438
  | { type: "spawn"; step: string; preset: ChildPreset; round: RoundRef; budgetMinutes: number; brief: Brief }
408
439
  | { type: "wait"; step: string; runId: string; timeoutMs: number }
409
440
  | { type: "read-record"; step: string; runId: string }
410
- | { type: "pr-check"; step: string }
441
+ | {
442
+ type: "pr-check";
443
+ step: string;
444
+ /** Set after a coding child died: the bot opens the pull request from the
445
+ * pushed branch itself (title from the unit, body from this run's
446
+ * submitted description when the record holds one) instead of answering
447
+ * `none` over stranded work. */
448
+ recover?: { runId: string };
449
+ }
411
450
  | { type: "merge"; step: string; prNumber: number; headSha: string }
412
451
  | { type: "sleep"; step: string; ms: number }
413
452
  | { type: "end"; step: string; ending: UnitEnding };
@@ -442,7 +481,14 @@ export type ChildFacts =
442
481
  /** What heads the unit's branch on GitHub: nothing, an open pull request, or —
443
482
  * with no open one — a merged one, `sha` the merge commit on the base. */
444
483
  export type PrCheck =
445
- | { state: "none" }
484
+ | {
485
+ state: "none";
486
+ /** After a recover pr-check (a dead coding child): why nothing was
487
+ * recovered — `no_commits` (GitHub refused the create: nothing between
488
+ * the base and the head) or `no_base` (the instance names no base to
489
+ * open against, so no create was tried). Absent on a plain check. */
490
+ unrecovered?: "no_commits" | "no_base";
491
+ }
446
492
  | { state: "open"; prNumber: number; url: string; headSha?: string; autoMergeEnabled?: boolean }
447
493
  | { state: "merged"; prNumber: number; url: string; sha: string; mergedAt: string };
448
494
 
@@ -458,6 +504,9 @@ export type StepReturn =
458
504
  | { type: "read-record"; step: string; run: ChildFacts; at: number }
459
505
  | { type: "pr-check"; step: string; pr: PrCheck; at: number }
460
506
  | { type: "merge"; step: string; outcome: "merged"; sha: string; at: number }
507
+ // The door found the pull request already merged after the approval — auto-merge
508
+ // fired, or a person merged — so the runner merged nothing (`by: other`).
509
+ | { type: "merge"; step: string; outcome: "merged"; by: "other"; sha: string; mergedAt: string; at: number }
461
510
  | { type: "merge"; step: string; outcome: "pending" | "refused"; reason: string; at: number }
462
511
  | { type: "sleep"; step: string };
463
512
 
@@ -473,7 +522,12 @@ export type UnitEnding =
473
522
  | { kind: "merge_ready"; pr: PrRef; reviewRounds: number }
474
523
  | { kind: "merge_refused"; pr: PrRef; reason: string; reviewRounds: number }
475
524
  | { kind: "round_cap"; maxRounds: number; reviewRounds: number }
476
- | { kind: "wall_clock_cap"; remainingMs: number; reviewRounds: number }
525
+ | { kind: "wall_clock_cap"; remainingMs: number; reviewRounds: number; spent: ShipBudgetSpent }
526
+ /** The wall clock capped AFTER the coding child opened or updated the pull
527
+ * request: the work stands and only the review is missing, so the ending
528
+ * names the pull request and "review pending" instead of calling the unit a
529
+ * failure — the re-issued attempt starts at the review round (`lastPush`). */
530
+ | { kind: "review_pending"; pr: PrRef; headSha?: string; reviewRounds: number; spent: ShipBudgetSpent }
477
531
  | {
478
532
  kind: "stopped";
479
533
  mode: "soft" | "hard";
@@ -494,6 +548,12 @@ export type CoordinatorNote =
494
548
  | { type: "round"; index: number; agent: ChildPreset; outcome: ShipRoundOutcome }
495
549
  | { type: "ended"; ending: UnitEnding };
496
550
 
551
+ /** How the pipeline's budget went, in ms: the coding rounds' (round 0 and the
552
+ * findings steps), the review rounds', and everything else (branching,
553
+ * pr-checks, busy waits, merge polls) — reported on the cap endings so a
554
+ * person can see whether the cap or the child is the problem. */
555
+ export type ShipBudgetSpent = Readonly<Record<"coding" | "review" | "waiting", number>>;
556
+
497
557
  export interface UnitPipelineInput {
498
558
  unit: { id: string; branch: string };
499
559
  repo: string;
@@ -513,6 +573,11 @@ export interface UnitPipelineInput {
513
573
  generated: boolean;
514
574
  /** Resume at review: an open pull request of ship's own the requester named. */
515
575
  resume?: { pr: number; headSha?: string; url?: string };
576
+ /** The head the previous attempt's coding child last pushed (a
577
+ * `review_pending` ending's `headSha`, carried on the unit's row): when the
578
+ * pre-check finds the open pull request still at exactly this head, there is
579
+ * nothing to code and the attempt starts at the review round. */
580
+ lastPush?: string;
516
581
  }
517
582
 
518
583
  type Phase =
@@ -524,7 +589,17 @@ type Phase =
524
589
  /** `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
590
  | { at: "wait"; round: RoundRef; runId: string; n: number; until: number }
526
591
  | { at: "read"; round: RoundRef; runId: string; n: number; until: number }
527
- | { at: "pr-check"; round: RoundRef; runId: string; childHead?: string; finalReply?: string }
592
+ | {
593
+ at: "pr-check";
594
+ round: RoundRef;
595
+ runId: string;
596
+ childHead?: string;
597
+ finalReply?: string;
598
+ /** The coding child died (`failed` or `interrupted`) after it may have
599
+ * pushed: the pr-check recovers a pushed branch by opening its pull
600
+ * request; with nothing pushed the unit ends with the child's own reason. */
601
+ dead?: "failed" | "interrupted";
602
+ }
528
603
  | { at: "merge"; pr: PrRef; headSha: string; n: number; since: number }
529
604
  | { at: "merge-sleep"; pr: PrRef; headSha: string; n: number; since: number }
530
605
  | { at: "ended" };
@@ -551,6 +626,8 @@ export interface UnitPipelineState {
551
626
  readonly findingsRunByRound: Readonly<Record<number, string>>;
552
627
  /** The last coding run (round 0's child or a findings step's): its record carries the unit's handoff. */
553
628
  readonly lastCodingRunId?: string;
629
+ /** How the budget went so far, accrued as each answer moves the clock. */
630
+ readonly spentMs: ShipBudgetSpent;
554
631
  readonly ending?: UnitEnding;
555
632
  }
556
633
 
@@ -579,6 +656,7 @@ export function openUnitPipeline(input: UnitPipelineInput, at: number): UnitPipe
579
656
  clock: at,
580
657
  phase: { at: "pre-check" },
581
658
  reviewRounds: 0,
659
+ spentMs: { coding: 0, review: 0, waiting: 0 },
582
660
  findingsByRound: {},
583
661
  dispositionsByRound: {},
584
662
  reviewRunByRound: {},
@@ -609,8 +687,25 @@ function waitSliceMs(clock: number, until: number): number {
609
687
  /** The child's budget: its preset's own, clipped to the pipeline's remaining
610
688
  * wall clock (agent-ship item 8's clip), never under the two minutes a
611
689
  * 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)));
690
+ function budgetMinutesFor(s: UnitPipelineState, round: RoundRef): number {
691
+ const headroom = remainingMs(s) - reserveFor(round.kind);
692
+ return Math.max(2, Math.min(s.input.childMinutes[presetOf(round.kind)], Math.floor(headroom / MIN)));
693
+ }
694
+
695
+ /** What a child of each round kind leaves on the pipeline's clock: round 0's
696
+ * coding child the whole loop (two reviews and the merge poll,
697
+ * SHIP_LOOP_RESERVE_MS), a findings child the re-review and the merge poll
698
+ * (SHIP_FIX_RESERVE_MS), a review child nothing — the review is what the
699
+ * reserve was held for. */
700
+ function reserveFor(kind: RoundKind): number {
701
+ switch (kind) {
702
+ case "coding":
703
+ return SHIP_LOOP_RESERVE_MS;
704
+ case "findings":
705
+ return SHIP_FIX_RESERVE_MS;
706
+ case "review":
707
+ return 0;
708
+ }
614
709
  }
615
710
 
616
711
  const roundStep = (s: UnitPipelineState, round: RoundRef) => `${s.input.unit.id}/${round.index}/${round.kind}`;
@@ -668,7 +763,7 @@ export function nextAction(s: UnitPipelineState): CoordinatorAction {
668
763
  step: roundStep(s, p.round),
669
764
  preset,
670
765
  round: p.round,
671
- budgetMinutes: budgetMinutesFor(s, preset),
766
+ budgetMinutes: budgetMinutesFor(s, p.round),
672
767
  brief: briefFor(s, p.round),
673
768
  };
674
769
  }
@@ -687,7 +782,11 @@ export function nextAction(s: UnitPipelineState): CoordinatorAction {
687
782
  case "read":
688
783
  return { type: "read-record", step: `${roundStep(s, p.round)}/read/${p.n}`, runId: p.runId };
689
784
  case "pr-check":
690
- return { type: "pr-check", step: `${roundStep(s, p.round)}/pr-check` };
785
+ return {
786
+ type: "pr-check",
787
+ step: `${roundStep(s, p.round)}/pr-check`,
788
+ ...(p.dead !== undefined ? { recover: { runId: p.runId } } : {}),
789
+ };
691
790
  case "merge":
692
791
  return { type: "merge", step: `${unit}/merge/${p.n}`, prNumber: p.pr.number, headSha: p.headSha };
693
792
  case "merge-sleep":
@@ -717,12 +816,26 @@ const roundNote = (round: RoundRef, outcome: ShipRoundOutcome): CoordinatorNote
717
816
  outcome,
718
817
  });
719
818
 
819
+ /** The cap's ending: `review_pending` when the clock ran out entering a
820
+ * review round with the child's pull request standing — the work shipped and
821
+ * only the review is missing — else the wall-clock cap. Both carry the split. */
822
+ function capEnding(s: UnitPipelineState, round?: RoundRef): UnitEnding {
823
+ if (round?.kind === "review" && s.pr !== undefined)
824
+ return {
825
+ kind: "review_pending",
826
+ pr: s.pr,
827
+ ...(s.lastReviewHead !== undefined ? { headSha: s.lastReviewHead } : {}),
828
+ reviewRounds: s.reviewRounds,
829
+ spent: s.spentMs,
830
+ };
831
+ return { kind: "wall_clock_cap", remainingMs: remainingMs(s), reviewRounds: s.reviewRounds, spent: s.spentMs };
832
+ }
833
+
720
834
  /** Start a round if the reservation holds (agent-ship item 8's check: a
721
835
  * child clipped under the reserve cannot do useful work). */
722
836
  function enterRound(s: UnitPipelineState, round: RoundRef, notes: CoordinatorNote[] = []): Transition {
723
837
  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);
838
+ if (remaining < SHIP_ROUND_RESERVE_MS) return end(s, capEnding(s, round), notes);
726
839
  const reviewRounds = round.kind === "review" ? round.index : s.reviewRounds;
727
840
  return { state: { ...s, reviewRounds, phase: { at: "spawn", round, busy: 0 } }, notes };
728
841
  }
@@ -773,17 +886,12 @@ function settleCoding(
773
886
  },
774
887
  [roundNote(round, "stopped")],
775
888
  );
889
+ // A failed coding child no longer aborts outright: the pr-check looks at the
890
+ // branch first — a push before the death is recovered as the round's pull
891
+ // request (agent-ship items 10 and 15), and only a branch with
892
+ // nothing on it ends the unit with the child's own reason.
776
893
  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
- );
894
+ return { state: { ...next, phase: { at: "pr-check", round, runId, dead: "failed" } }, notes: [] };
787
895
  next = {
788
896
  ...next,
789
897
  phase: {
@@ -919,6 +1027,32 @@ function settlePrCheck(s: UnitPipelineState, phase: Extract<Phase, { at: "pr-che
919
1027
  // completed without a pull request of its own, and the unit is done.
920
1028
  if (pr.state === "merged") return foundMerged(s, pr, [roundNote(round, "completed")]);
921
1029
  if (pr.state === "none") {
1030
+ // A dead child left nothing on the branch to recover: the unit ends with
1031
+ // the child's own reason — never the budget clip.
1032
+ if (phase.dead === "interrupted")
1033
+ return end(s, { kind: "interrupted", round, runId: phase.runId, reviewRounds: s.reviewRounds }, [
1034
+ roundNote(round, "aborted"),
1035
+ ]);
1036
+ if (phase.dead === "failed") {
1037
+ // The abort repeats the bot's reason for recovering nothing, and claims
1038
+ // no more than the answer carried.
1039
+ const why =
1040
+ pr.unrecovered === "no_commits"
1041
+ ? `nothing heads \`${s.input.unit.branch}\`: no commits were pushed, so there was no work to recover`
1042
+ : pr.unrecovered === "no_base"
1043
+ ? `\`${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`
1044
+ : `the pr-check found no pull request heading \`${s.input.unit.branch}\`, so nothing was recovered`;
1045
+ return end(
1046
+ s,
1047
+ {
1048
+ kind: "aborted",
1049
+ reason: `⚠️ The coding child of round ${round.index} (run ${phase.runId}) ended \`failed\` — its run page has the error — and ${why}.`,
1050
+ round,
1051
+ reviewRounds: s.reviewRounds,
1052
+ },
1053
+ [roundNote(round, "aborted")],
1054
+ );
1055
+ }
922
1056
  const reason =
923
1057
  round.index === 0
924
1058
  ? `⚠️ 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 +1078,25 @@ function settlePrCheck(s: UnitPipelineState, phase: Extract<Phase, { at: "pr-che
944
1078
  const codingHead = normalizeHead(head);
945
1079
  const reviewedAt = normalizeHead(s.lastReviewHead);
946
1080
  if (codingHead !== undefined && reviewedAt !== undefined && sameCommit(codingHead, reviewedAt)) {
1081
+ // A findings child that died before it pushed: the recover pr-check found
1082
+ // the round's own pull request, still at the reviewed head. The unit
1083
+ // ends with the child's own reason — the ship-restart note for a bot
1084
+ // roll, the failure for a failed run — never as the round's inaction.
1085
+ if (phase.dead === "interrupted")
1086
+ return end(next, { kind: "interrupted", round, runId: phase.runId, reviewRounds: next.reviewRounds }, [
1087
+ roundNote(round, "aborted"),
1088
+ ]);
1089
+ if (phase.dead === "failed")
1090
+ return end(
1091
+ next,
1092
+ {
1093
+ kind: "aborted",
1094
+ 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.`,
1095
+ round,
1096
+ reviewRounds: next.reviewRounds,
1097
+ },
1098
+ [roundNote(round, "aborted")],
1099
+ );
947
1100
  const findings = s.findingsByRound[round.index] ?? [];
948
1101
  const dispositions = s.dispositionsByRound[round.index] ?? [];
949
1102
  const allDeclined =
@@ -967,6 +1120,17 @@ function settlePrCheck(s: UnitPipelineState, phase: Extract<Phase, { at: "pr-che
967
1120
  ]);
968
1121
  }
969
1122
 
1123
+ /** Move the clock and charge the elapsed time to the budget's bucket: a phase
1124
+ * inside a coding or findings round is the coding child's time, a review
1125
+ * round's is the review's, everything else — the pre-check, the branch, busy
1126
+ * waits, the merge polls — is waiting. The split rides the cap endings. */
1127
+ function withClock(s: UnitPipelineState, at: number): UnitPipelineState {
1128
+ const delta = Math.max(0, at - s.clock);
1129
+ const round = "round" in s.phase ? s.phase.round : undefined;
1130
+ const bucket = round === undefined ? "waiting" : round.kind === "review" ? "review" : "coding";
1131
+ return { ...s, clock: at, spentMs: { ...s.spentMs, [bucket]: s.spentMs[bucket] + delta } };
1132
+ }
1133
+
970
1134
  /**
971
1135
  * Feed a step's answer to the machine. An answer for any step but the one the
972
1136
  * machine is at — a duplicate `run finished`, a replayed spawn — changes
@@ -976,7 +1140,7 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
976
1140
  const expected = nextAction(s);
977
1141
  if (expected.type === "end" || ret.step !== expected.step || ret.type !== expected.type)
978
1142
  return { state: s, notes: [] };
979
- const clocked: UnitPipelineState = "at" in ret ? { ...s, clock: ret.at } : s;
1143
+ const clocked: UnitPipelineState = "at" in ret ? withClock(s, ret.at) : s;
980
1144
  const p = s.phase;
981
1145
  switch (p.at) {
982
1146
  case "pre-check": {
@@ -987,6 +1151,20 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
987
1151
  // by the coding child and adopted at the round's own pr-check.
988
1152
  const r = ret as Extract<StepReturn, { type: "pr-check" }>;
989
1153
  if (r.pr.state === "merged") return foundMerged(clocked, r.pr);
1154
+ // The open pull request still heads at the child's own last push (the
1155
+ // previous attempt ended `review_pending`): nothing to code, so the
1156
+ // attempt adopts it and starts at the review round — never a fresh
1157
+ // coding round on an already-shipped pull request.
1158
+ if (r.pr.state === "open") {
1159
+ const head = normalizeHead(r.pr.headSha);
1160
+ const lastPush = normalizeHead(s.input.lastPush);
1161
+ if (head !== undefined && lastPush !== undefined && sameCommit(head, lastPush))
1162
+ return nextReview({
1163
+ ...clocked,
1164
+ pr: { number: r.pr.prNumber, url: r.pr.url },
1165
+ lastReviewHead: head,
1166
+ });
1167
+ }
990
1168
  return { state: { ...clocked, phase: { at: "branch" } }, notes: [] };
991
1169
  }
992
1170
  case "branch": {
@@ -1004,7 +1182,7 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
1004
1182
  case "spawned":
1005
1183
  case "alreadySpawned": {
1006
1184
  // 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;
1185
+ const until = clocked.clock + budgetMinutesFor(s, p.round) * MIN + WAIT_MARGIN_MS;
1008
1186
  const runs =
1009
1187
  p.round.kind === "review"
1010
1188
  ? { reviewRunByRound: { ...s.reviewRunByRound, [p.round.index]: r.runId } }
@@ -1022,12 +1200,7 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
1022
1200
  case "busy": {
1023
1201
  // Another run holds the unit's thread: wait for its end, then ask
1024
1202
  // 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
- });
1203
+ if (remainingMs(clocked) < SHIP_ROUND_RESERVE_MS) return end(clocked, capEnding(clocked, p.round));
1031
1204
  return {
1032
1205
  state: {
1033
1206
  ...clocked,
@@ -1076,10 +1249,19 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
1076
1249
  },
1077
1250
  notes: [],
1078
1251
  };
1079
- if (r.run.status === "interrupted")
1252
+ if (r.run.status === "interrupted") {
1253
+ // A dead CODING child may have pushed before the ledger closed it: the
1254
+ // pr-check recovers the branch. A review child has nothing on the
1255
+ // branch to recover, so its interruption still ends the unit at once.
1256
+ if (p.round.kind !== "review")
1257
+ return {
1258
+ state: { ...clocked, phase: { at: "pr-check", round: p.round, runId: p.runId, dead: "interrupted" } },
1259
+ notes: [],
1260
+ };
1080
1261
  return end(clocked, { kind: "interrupted", round: p.round, runId: p.runId, reviewRounds: s.reviewRounds }, [
1081
1262
  roundNote(p.round, "aborted"),
1082
1263
  ]);
1264
+ }
1083
1265
  return p.round.kind === "review"
1084
1266
  ? settleReview(clocked, p.round, r.run)
1085
1267
  : settleCoding(clocked, p.round, p.runId, r.run);
@@ -1089,7 +1271,18 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
1089
1271
  case "merge": {
1090
1272
  const r = ret as Extract<StepReturn, { type: "merge" }>;
1091
1273
  if (r.outcome === "merged")
1092
- return end(clocked, { kind: "merged", by: "runner", pr: p.pr, sha: r.sha, reviewRounds: s.reviewRounds });
1274
+ // Found already merged at the door — auto-merge or a person, after the
1275
+ // approval: the unit is done, the runner merged nothing (`by: other`).
1276
+ return "by" in r
1277
+ ? end(clocked, {
1278
+ kind: "merged",
1279
+ by: "other",
1280
+ pr: p.pr,
1281
+ sha: r.sha,
1282
+ mergedAt: r.mergedAt,
1283
+ reviewRounds: s.reviewRounds,
1284
+ })
1285
+ : end(clocked, { kind: "merged", by: "runner", pr: p.pr, sha: r.sha, reviewRounds: s.reviewRounds });
1093
1286
  if (r.outcome === "refused")
1094
1287
  return end(clocked, { kind: "merge_refused", pr: p.pr, reason: r.reason, reviewRounds: s.reviewRounds });
1095
1288
  const waited = r.at - p.since;
@@ -1135,6 +1328,12 @@ function dispositionFor(s: UnitPipelineState, finding: Finding, round: number):
1135
1328
  return undefined;
1136
1329
  }
1137
1330
 
1331
+ /** How the budget went, in the card's words: coding, review, waiting minutes. */
1332
+ function budgetSplitLine(spent: ShipBudgetSpent, maxMinutes: number): string {
1333
+ const min = (ms: number) => Math.round(ms / MIN);
1334
+ return `Budget split (${maxMinutes} min): coding ${min(spent.coding)} min, review ${min(spent.review)} min, waiting ${min(spent.waiting)} min.`;
1335
+ }
1336
+
1138
1337
  /** The cap report's declined-vs-unaddressed split over the last review round's findings. */
1139
1338
  function splitReport(s: UnitPipelineState): string {
1140
1339
  if (s.reviewRounds === 0) return "No review round ran before the cap — there are no findings to report.";
@@ -1222,9 +1421,16 @@ export function renderUnitReport(s: UnitPipelineState, facts?: MergeReadyFacts):
1222
1421
  case "wall_clock_cap":
1223
1422
  return join([
1224
1423
  `🧢 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}`,
1424
+ budgetSplitLine(e.spent, s.input.caps.maxMinutes),
1225
1425
  splitReport(s),
1226
1426
  reissue,
1227
1427
  ]);
1428
+ case "review_pending":
1429
+ return join([
1430
+ `⏳ 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.`,
1431
+ budgetSplitLine(e.spent, s.input.caps.maxMinutes),
1432
+ reissue,
1433
+ ]);
1228
1434
  case "stopped":
1229
1435
  return join([
1230
1436
  `${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 {
@@ -299,6 +299,14 @@ export function parseDepsStoreListing(stdout: string): DepsStoreListing {
299
299
  * attaches. Raise with the instance, never by feel. */
300
300
  export const DEPS_STORE_MAX_UNREFERENCED = 1;
301
301
 
302
+ /** How many spares the attach's disk-pressure path keeps: none. A spare is a
303
+ * warm cache for a lockfile key no live tree references, worth minutes on
304
+ * the next attach at that key; a tree refused for want of space is a run
305
+ * falling to a cold sandbox now. Under pressure the spare goes first, before
306
+ * any idle tree (docs/reference/specs/resident-repos.md item 55), and its
307
+ * entry backup with it — the next attach at that key restores or installs. */
308
+ export const DEPS_STORE_MAX_UNREFERENCED_UNDER_PRESSURE = 0;
309
+
302
310
  /** Eviction candidates coldest first. Never a protected key (the checkout's,
303
311
  * a live binding's, an install in flight). Debris — an incomplete entry
304
312
  * with nothing in flight for it — is always first: it is half an install
@@ -318,6 +326,9 @@ export interface DepsEvictionPlan {
318
326
  remove: string[];
319
327
  /** Keys that stay, for the log. */
320
328
  keep: string[];
329
+ /** The entries `remove` names (debris first, then the coldest spares), with
330
+ * their measured sizes — what a pressure eviction gives back. */
331
+ evicted: DepsStoreEntry[];
321
332
  }
322
333
 
323
334
  export function planDepsEviction(input: {
@@ -336,7 +347,8 @@ export function planDepsEviction(input: {
336
347
  const evictSpares = spares.slice(0, Math.max(0, spares.length - max));
337
348
  const remove = [...debris, ...evictSpares].map((e) => depsEntryPath(e.key, storeDir));
338
349
  remove.push(...input.leftovers);
339
- const removed = new Set([...debris, ...evictSpares].map((e) => e.key));
350
+ const evicted = [...debris, ...evictSpares];
351
+ const removed = new Set(evicted.map((e) => e.key));
340
352
  const keep = input.entries.filter((e) => !removed.has(e.key)).map((e) => e.key);
341
- return { remove, keep };
353
+ return { remove, keep, evicted };
342
354
  }
@@ -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" };