@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
@@ -252,7 +252,13 @@ import {
252
252
  type DiskSample,
253
253
  type ThreadCostKind,
254
254
  } from "../../src/execution/residentDiskBudget.js";
255
- import { attachTarget, mirrorNeedsFetch, parseWantSha, wantShaForBinding } from "../../src/execution/residentHead.js";
255
+ import {
256
+ attachTarget,
257
+ type FetchReason,
258
+ mirrorFetchReason,
259
+ parseWantSha,
260
+ wantShaForBinding,
261
+ } from "../../src/execution/residentHead.js";
256
262
  import { residentText, sanitizeResidentBody } from "../../src/execution/residentText.js";
257
263
  import {
258
264
  abandonedWaitStepResult,
@@ -297,6 +303,9 @@ import {
297
303
  parseDepsStoreListing,
298
304
  planDepsEviction,
299
305
  planDepsMaterialization,
306
+ DEPS_STORE_MAX_UNREFERENCED_UNDER_PRESSURE,
307
+ type DepsEvictionPlan,
308
+ type DepsStoreListing,
300
309
  } from "../../src/execution/residentDepsStore.js";
301
310
  import { buildId, injectedBuildStamp } from "../../src/deploy/buildStamp.js";
302
311
  import { createRefreshInstance, createRefreshInstanceNow, type RefreshInstanceParams } from "./refresh";
@@ -505,16 +514,18 @@ const FORCE_DETACH_KILL_TIMEOUT_MS = 2_000;
505
514
  * binding record is KEPT so the next attach recreates with the same
506
515
  * ref. Overridable per resident via the onboard-time `worktreeTtlDays`. */
507
516
  const WORKTREE_TTL_DAYS_DEFAULT = 7;
508
- /** A live binding whose last attach is older than this AND whose tree is clean
509
- * (no uncommitted/unpushed work) is released by the sweep step (every refresh
510
- * instance runs one) — runs that ended before /detach existed, or whose
511
- * release call was lost. Dirty trees keep to the TTL. */
517
+ /** A live binding whose last attach is older than this and has no op in
518
+ * flight is released by the sweep step (every refresh instance runs one),
519
+ * whatever its tree holds (item 17) — runs that ended before /detach existed,
520
+ * or whose release call was lost. */
512
521
  const CLEAN_IDLE_RELEASE_S = 60 * 60;
513
- /** Idle sleep: when no thread has attached within this window and no live
514
- * tree is dirty, the refresh cycle skips the fetch and the cron holds the
515
- * next instance to the idle cadence, so the container can actually sleep
516
- * (SLEEP_AFTER); the next attach refreshes first if the mirror is stale
517
- * (refresh-on-attach). */
522
+ /** Idle sleep: when no live binding was attached to or used within this
523
+ * window and nothing is in flight, the refresh cycle skips the fetch and the
524
+ * cron holds the next instance to the idle cadence, so the container can
525
+ * actually sleep (SLEEP_AFTER); the next attach refreshes first if the mirror
526
+ * is stale (refresh-on-attach). The disk-full recycle (item 54) reads the
527
+ * same window through the same predicate (`recentlyUsed`): a sleep and a
528
+ * recycle destroy the same disk, so one rule says when it may go away. */
518
529
  const IDLE_AFTER_S = 60 * 60;
519
530
  /** LRU eviction floor: an over-cap onboard with `evictColdest:true` may
520
531
  * offboard the coldest eligible warm resident, but never one whose last
@@ -1000,10 +1011,11 @@ interface ResidentRecord {
1000
1011
  interface ThreadBinding {
1001
1012
  threadKey: string;
1002
1013
  ref: string;
1003
- /** How `ref` was chosen when the binding was made: the repo default for want
1004
- * of a named branch (item 30), or a branch someone named. Absent on a
1005
- * binding made before the field: read as `default` iff `ref` is the default
1006
- * branch. Only a `default` binding may ever move. */
1014
+ /** How `ref` was chosen: the repo default for want of a named branch — when
1015
+ * the binding was made (item 30), or when a return put it there because the
1016
+ * branch it was on is gone (`returned`) — or a branch someone named. Absent
1017
+ * on a binding made before the field: read as `default` iff `ref` is the
1018
+ * default branch. Only a `default` binding may ever move onto a branch. */
1007
1019
  boundBy?: BoundBy;
1008
1020
  /** The move a binding may make (item 16): from the default it was bound to,
1009
1021
  * onto the branch the thread's own run opened a pull request on. Set, the
@@ -1011,6 +1023,12 @@ interface ThreadBinding {
1011
1023
  * branch was gone from the mirror and the binding went back to the
1012
1024
  * default), and then for the next own pull request. */
1013
1025
  rebound?: Rebound;
1026
+ /** The binding's last move back to the default (item 16's second movement):
1027
+ * the branch it was on — one a rebind moved it onto, or one this thread's
1028
+ * own runs pushed (`ownBranches`) and bound by name, a ship unit's — was
1029
+ * gone from the mirror at an attach. From where, to where, which pull
1030
+ * request's branch it was, when. */
1031
+ returned?: Returned;
1014
1032
  /** The branches the thread's own runs pushed and the pull requests they
1015
1033
  * head, as each run's release handed them over (`/detach` `pushed`, item
1016
1034
  * 16a) — written before any eviction decision, so the fact outlives the
@@ -1037,6 +1055,15 @@ interface ThreadBinding {
1037
1055
  /** Why the tree could not be measured before that eviction (git could not
1038
1056
  * read it) — so an unreadable tree is never recorded as clean. */
1039
1057
  evictedUnmeasured?: string;
1058
+ /** What the tree held when the last disk-full recycle (item 54) discarded
1059
+ * it with the disk — the same measurement as `evictedLeftBehind`, under its
1060
+ * own name because the binding is not evicted: its user is kept and the
1061
+ * next attach recreates the tree (`worktree-missing`). Both rewritten on
1062
+ * every recycle; absent when the tree was clean or already gone. */
1063
+ recycledLeftBehind?: LeftBehind;
1064
+ /** Why the tree could not be measured before that recycle (git could not
1065
+ * read it) — still discarded with the disk, never recorded as clean. */
1066
+ recycledUnmeasured?: string;
1040
1067
  /** How deps were last materialized (evidence that the per-branch reconciliation ran). */
1041
1068
  deps?: ThreadDepsMechanism;
1042
1069
  /** Commit the worktree was last attached at (the ref's tip in the mirror
@@ -1111,9 +1138,10 @@ interface AttachOk {
1111
1138
  * and why — so the bot can say where the follow-up runs and why. */
1112
1139
  rebindRefused?: RebindRefused;
1113
1140
  /** This attach moved the binding BACK to the default branch (item 16's
1114
- * second movement): the branch a rebind had moved it onto is gone from the
1115
- * mirror, so the tree was provisioned on the default instead of the attach
1116
- * failing `unknown-ref`. From where, to where, which PR's branch it was. */
1141
+ * second movement): the branch it was on — a rebind's, or one this thread
1142
+ * itself pushed — is gone from the mirror, so the tree was provisioned on
1143
+ * the default instead of the attach failing `unknown-ref`. From where, to
1144
+ * where, which PR's branch it was. */
1117
1145
  returned?: Returned;
1118
1146
  }
1119
1147
 
@@ -2083,13 +2111,20 @@ export class ResidentDO extends Sandbox<Env> {
2083
2111
  throw new StepError("cat-file", `git cat-file exited ${r.exitCode}: ${r.stderr.trim() || "(no output)"}`);
2084
2112
  }
2085
2113
 
2086
- /** Attach's fetch decision (item 51) over the mirror's actual state: the ref
2087
- * is missing, or `wantSha` names a commit the ref's tip is not at. The
2088
- * decision itself is the pure, tested `mirrorNeedsFetch`. */
2089
- private async mirrorNeedsFetchFor(ref: string, wantSha: string | null): Promise<boolean> {
2114
+ /** Attach's fetch decision (item 51) over the mirror's actual state — why
2115
+ * the mirror is fetched before the tree is cloned, or null: the ref is
2116
+ * missing, `wantSha` names a commit the ref's tip is not at, or the ref is
2117
+ * one the binding could return from (item 16's second movement) and is
2118
+ * verified against the origin before the mirror's word on it is trusted.
2119
+ * The decision itself is the pure, tested `mirrorFetchReason`. */
2120
+ private async mirrorFetchReasonFor(
2121
+ ref: string,
2122
+ wantSha: string | null,
2123
+ returnable: boolean,
2124
+ ): Promise<FetchReason | null> {
2090
2125
  const refExists = await this.refExists(ref);
2091
2126
  const mirrorSha = refExists && wantSha !== null ? await this.readMirrorSha(ref) : undefined;
2092
- return mirrorNeedsFetch({ refExists, mirrorSha, wantSha });
2127
+ return mirrorFetchReason({ refExists, mirrorSha, wantSha, returnable });
2093
2128
  }
2094
2129
 
2095
2130
  /** Dependency/build cache key: sha256 over the ls-tree lines (mode,
@@ -2945,12 +2980,11 @@ export class ResidentDO extends Sandbox<Env> {
2945
2980
  throw new CycleRestartError("image-stale-restart");
2946
2981
  }
2947
2982
 
2948
- // Idle sleep: nobody has attached for IDLE_AFTER_S and no live tree is
2949
- // dirty → skip this fetch; the cron holds the next instance to the idle
2983
+ // Idle sleep: no live binding used within IDLE_AFTER_S and nothing in
2984
+ // flight → skip this fetch; the cron holds the next instance to the idle
2950
2985
  // cadence, so SLEEP_AFTER can elapse. Staleness is repaid at the next
2951
- // attach (refreshIfStale). A
2952
- // dirty live tree pins the container awake: sleep destroys the disk and
2953
- // uncommitted work is not snapshotted.
2986
+ // attach (refreshIfStale). What a live tree holds is not asked (item 17):
2987
+ // sleep destroys the disk, and a tree no run is using protects nothing.
2954
2988
  // Only a SETTLED resident may park: a cycle that finds `refreshing`/
2955
2989
  // `restoring` at entry is looking at a marker left by a cycle that died
2956
2990
  // mid-flight (a deploy evicting the DO: stuck `refreshing` + parked →
@@ -3002,7 +3036,7 @@ export class ResidentDO extends Sandbox<Env> {
3002
3036
  await this.ctx.storage.delete(DEGRADED_STREAK_KEY);
3003
3037
  }
3004
3038
  if (settled && (await this.isIdle())) {
3005
- // isIdle awaited (git status per live tree) — re-read before writing.
3039
+ // isIdle awaited (the bindings listing) — re-read before writing.
3006
3040
  const now = (await this.ctx.storage.get<RepoFacts>(FACTS_KEY)) ?? facts;
3007
3041
  if (!now.idleSince)
3008
3042
  await this.ctx.storage.put(FACTS_KEY, {
@@ -3798,15 +3832,24 @@ export class ResidentDO extends Sandbox<Env> {
3798
3832
  });
3799
3833
  }
3800
3834
 
3801
- /** Idle = no live binding attached within IDLE_AFTER_S and nothing in
3802
- * flight. What a live tree holds is not asked (item 17): a dirty tree no
3803
- * run is using protects nothing — the next attach wipes it — so it is
3804
- * never a reason to keep the container awake; the sweep's release records
3805
- * what it held. Bindings are storage, so this needs no container. */
3806
- private async isIdle(): Promise<boolean> {
3807
- const live = await this.liveBindings();
3835
+ /** Whether a run may be using this disk: a live binding attached to or
3836
+ * used (an exec bumps `lastAttachAt` too, item 21) within IDLE_AFTER_S.
3837
+ * The op counter is 0 between a run's tool calls, so this floor is what
3838
+ * stands for a run mid-flight. The one predicate behind "the disk may go
3839
+ * away": the idle-sleep gate (item 16b — a platform sleep destroys the
3840
+ * disk) and the disk-full recycle (item 54) both read it, and neither asks
3841
+ * what a tree holds (item 17): a dirty tree no run is using protects
3842
+ * nothing — the next attach wipes it. Bindings are storage, so this needs
3843
+ * no container. */
3844
+ private recentlyUsed(live: ThreadBinding[]): boolean {
3808
3845
  const recent = systemClock() - IDLE_AFTER_S * 1000;
3809
- if (live.some((b) => Date.parse(b.lastAttachAt) >= recent)) return false;
3846
+ return live.some((b) => Date.parse(b.lastAttachAt) >= recent);
3847
+ }
3848
+
3849
+ /** Idle = no live binding used within the floor and nothing in flight; the
3850
+ * sweep's release records what an idle tree held. */
3851
+ private async isIdle(): Promise<boolean> {
3852
+ if (this.recentlyUsed(await this.liveBindings())) return false;
3810
3853
  return this.inFlightCount() === 0;
3811
3854
  }
3812
3855
 
@@ -3816,17 +3859,6 @@ export class ResidentDO extends Sandbox<Env> {
3816
3859
  return [...all.values()].filter((b) => !b.evicted && b.user);
3817
3860
  }
3818
3861
 
3819
- /** The disk-full recycle's tree guard (item 54), its one reader: true when
3820
- * every live tree is clean as its thread user, or the runtime is already
3821
- * down — a sleep has destroyed the disk, so there is nothing left to lose. */
3822
- private async liveTreesClean(live: ThreadBinding[]): Promise<boolean> {
3823
- if (!(await this.isRuntimeActive().catch(() => false))) return true;
3824
- // Concurrent: each check touches only its own (disjoint) tree, and one
3825
- // spawn each — no serial 3-probe round-trip per live binding.
3826
- const checks = await Promise.all(live.map((b) => this.worktreeCleanliness(b)));
3827
- return checks.every((c) => c.clean); // any dirty or unknown → not clean
3828
- }
3829
-
3830
3862
  // -- disk-full (docs/reference/specs/resident-repos.md item 54) ---------------------------
3831
3863
 
3832
3864
  /** Free space on the workspace mount in KiB; `null` when df cannot answer.
@@ -3852,28 +3884,54 @@ export class ResidentDO extends Sandbox<Env> {
3852
3884
 
3853
3885
  /** The disk is a cache: stop the container so the next cycle attempt
3854
3886
  * restores mirror + checkout from R2 onto an empty disk — the same wake
3855
- * path as a platform sleep. Only when the pure plan allows it: nothing in
3856
- * flight (`selfInFlight` excludes the calling refresh cycle from the
3857
- * count), every live tree clean, and no recycle within the cooldown. A
3858
- * refused recycle is written to `lastRefreshError` with its why, so
3859
- * `/residents` says what an operator must do; the `degraded` reason stays
3860
- * the clean `disk-full: …`. Answers whether the container was recycled. */
3887
+ * path as a platform sleep. Only when the pure plan allows it: no recycle
3888
+ * within the cooldown, nothing in flight (`selfInFlight` excludes the
3889
+ * calling refresh cycle from the count) and no live binding used within
3890
+ * the idle floor — the idle-sleep gate's own predicate (`recentlyUsed`),
3891
+ * because a sleep and a recycle destroy the same disk: it may go away when
3892
+ * no run is using it, never for what the trees hold (item 17). A recycle
3893
+ * discards every live tree the way an eviction does, so each is measured
3894
+ * first, as its thread user, and its binding records what went
3895
+ * (`recordRecycledTree`); the plan is decided again after those awaits,
3896
+ * right before the stop. A refused recycle is written to
3897
+ * `lastRefreshError` with its why, so `/residents` says what an operator
3898
+ * must do; the `degraded` reason stays the clean `disk-full: …`. Answers
3899
+ * whether the container was recycled. */
3861
3900
  private async recoverFromDiskFull(reason: string, selfInFlight: number): Promise<boolean> {
3862
3901
  const lastRecycleAt = await this.ctx.storage.get<number>(DISK_FULL_RECYCLE_KEY);
3863
- const plan = planDiskFullRecovery({
3864
- now: systemClock(),
3865
- lastRecycleAt,
3866
- inFlight: this.inFlightCount() - selfInFlight,
3867
- treesClean: await this.liveTreesClean(await this.liveBindings()),
3868
- });
3869
- if (plan.action === "wait") {
3870
- console.log(`disk-full: container kept — ${plan.why}`);
3871
- await this.recordRefreshError(`${reason} — container kept: ${plan.why}`);
3902
+ const plan = (live: ThreadBinding[]) =>
3903
+ planDiskFullRecovery({
3904
+ now: systemClock(),
3905
+ lastRecycleAt,
3906
+ inFlight: this.inFlightCount() - selfInFlight,
3907
+ recentlyUsed: this.recentlyUsed(live),
3908
+ idleFloorS: IDLE_AFTER_S,
3909
+ });
3910
+ const kept = async (why: string) => {
3911
+ console.log(`disk-full: container kept — ${why}`);
3912
+ await this.recordRefreshError(`${reason} — container kept: ${why}`);
3872
3913
  return false;
3873
- }
3914
+ };
3915
+ const live = await this.liveBindings();
3916
+ const first = plan(live);
3917
+ if (first.action === "wait") return kept(first.why);
3918
+ // What each live tree holds, for its record — never a reason to keep the
3919
+ // container. Concurrent: each probe touches only its own tree, one spawn
3920
+ // each. Runtime down: the disk, and every tree with it, is already gone.
3921
+ const active = await this.isRuntimeActive().catch(() => false);
3922
+ const trees = active
3923
+ ? await Promise.all(
3924
+ live.map(async (binding) => [binding, await this.measureTreeBeforeEviction(binding)] as const),
3925
+ )
3926
+ : live.map((binding) => [binding, undefined] as const);
3927
+ // The measurement awaited (the DO yields at each await): decide again over
3928
+ // fresh facts — an attach or an op that landed meanwhile keeps the container.
3929
+ const verdict = plan(await this.liveBindings());
3930
+ if (verdict.action === "wait") return kept(verdict.why);
3874
3931
  console.log(
3875
3932
  `disk-full: recycling the container — the next cycle restores mirror + checkout from R2 onto an empty disk (${reason})`,
3876
3933
  );
3934
+ for (const [binding, tree] of trees) await this.recordRecycledTree(binding, tree);
3877
3935
  await this.ctx.storage.put(DISK_FULL_RECYCLE_KEY, systemClock());
3878
3936
  await this.recordRefreshError(`${reason} — container recycled; restoring from R2 on the next cycle`);
3879
3937
  this.swapIncarnation(); // deliberate incarnation swap
@@ -3881,6 +3939,27 @@ export class ResidentDO extends Sandbox<Env> {
3881
3939
  return true;
3882
3940
  }
3883
3941
 
3942
+ /** The disk-full recycle's record for one live tree (item 54), written the
3943
+ * way `evictBinding` writes an eviction's — but the binding stays live, its
3944
+ * pool user kept and its tree recreated on the next attach, so this is not
3945
+ * an eviction and the record sits under its own name beside
3946
+ * `evictedLeftBehind`: the counts when something was there, the probe's
3947
+ * first error line when git could not read the tree, both rewritten on
3948
+ * every recycle so nothing lingers from an earlier one, and one log line
3949
+ * when the tree held something. Re-read first: a binding evicted or
3950
+ * re-attached during the measurement is left as is. */
3951
+ private async recordRecycledTree(binding: ThreadBinding, tree: EvictedTree | undefined): Promise<void> {
3952
+ const now = await this.ctx.storage.get<ThreadBinding>(threadBindingKey(binding.threadKey));
3953
+ if (!now || now.evicted || now.lastAttachAt !== binding.lastAttachAt) return;
3954
+ await this.ctx.storage.put(threadBindingKey(binding.threadKey), {
3955
+ ...now,
3956
+ recycledLeftBehind: tree && "leftBehind" in tree ? tree.leftBehind : undefined,
3957
+ recycledUnmeasured: tree && "unmeasured" in tree ? tree.unmeasured : undefined,
3958
+ } satisfies ThreadBinding);
3959
+ if (tree)
3960
+ console.log(`disk-full: ${binding.threadKey} tree discarded with the disk — ${evictedTreeSentence(tree)}`);
3961
+ }
3962
+
3884
3963
  // -- disk budget (docs/reference/specs/resident-repos.md item 55) -------------------------
3885
3964
 
3886
3965
  /** The `df` half: total/used/free of the workspace mount; null when df
@@ -4012,12 +4091,36 @@ export class ResidentDO extends Sandbox<Env> {
4012
4091
  committedKiB: this.diskCommittedKiB,
4013
4092
  diskBudgetMb: input.record.diskBudgetMb,
4014
4093
  kind,
4094
+ // A deps-store entry backup stages its archive from the deps (item
4095
+ // 61); the reserve holds their share only while one runs.
4096
+ depsBackupInFlight: this.depsBackupsInFlight.size > 0,
4015
4097
  });
4016
4098
  let rawFree = df.freeKiB;
4017
4099
  let verdict = decide(rawFree);
4018
4100
  const evicted: Array<{ threadKey: string; freedKiB: number | null }> = [];
4019
4101
  // Item 62: keep decisions travel as tokens; the key stays for the log line.
4020
4102
  const kept: Array<{ threadKey: string; why: DiskKeepWhy }> = [];
4103
+ // The deps store's spares go first (item 55): a warm cache no live tree
4104
+ // references, worth minutes on a future attach, against a tree refused
4105
+ // now. Their bytes come back before any idle tree is considered.
4106
+ let spares: Array<{ key: string; freedKiB: number }> = [];
4107
+ if (!verdict.fits) {
4108
+ spares = await this.evictDepsSparesUnderPressure().catch((err) => {
4109
+ console.log(`disk-pressure: deps spare eviction failed: ${errMsg(err)}`);
4110
+ return [];
4111
+ });
4112
+ if (spares.length > 0) {
4113
+ console.log(
4114
+ `disk-pressure: evicted ${spares.length} deps-store spare(s) (${formatGiB(spares.reduce((a, e) => a + e.freedKiB, 0))} back) to make room for ${input.threadKey}`,
4115
+ );
4116
+ rawFree = rawFreeAfterEviction(
4117
+ rawFree,
4118
+ await this.dfSample(),
4119
+ spares.reduce((a, e) => a + e.freedKiB, 0),
4120
+ );
4121
+ verdict = decide(rawFree);
4122
+ }
4123
+ }
4021
4124
  if (!verdict.fits) {
4022
4125
  const live = await this.liveBindings();
4023
4126
  const candidates: DiskEvictionCandidate[] = live.map((b) => ({
@@ -4064,7 +4167,7 @@ export class ResidentDO extends Sandbox<Env> {
4064
4167
  // truthiness does not narrow a boolean-literal discriminant.
4065
4168
  const final = verdict;
4066
4169
  if (final.fits === false) {
4067
- const reason = diskPressureReason({ verdict: final, evicted, kept });
4170
+ const reason = diskPressureReason({ verdict: final, evicted, kept, spares });
4068
4171
  console.log(`attach ${input.threadKey}: ${reason}`);
4069
4172
  const s = await this.getStatus();
4070
4173
  return { error: reason, status: 503, state: s.state, reason: DISK_PRESSURE_REASON };
@@ -4072,7 +4175,7 @@ export class ResidentDO extends Sandbox<Env> {
4072
4175
  const m = final.math;
4073
4176
  console.log(
4074
4177
  `attach ${input.threadKey}: disk admitted — ${kind} ${formatGiB(m.projectedKiB)} projected, ${formatGiB(m.freeKiB)} free, ` +
4075
- `reserve ${formatGiB(m.reserve.totalKiB)}, headroom ${formatGiB(m.headroomKiB)}${evicted.length > 0 ? `, evicted ${evicted.map((e) => e.threadKey).join(", ")}` : ""}`,
4178
+ `reserve ${formatGiB(m.reserve.totalKiB)}, headroom ${formatGiB(m.headroomKiB)}${spares.length > 0 ? `, evicted ${spares.length} deps spare(s)` : ""}${evicted.length > 0 ? `, evicted ${evicted.map((e) => e.threadKey).join(", ")}` : ""}`,
4076
4179
  );
4077
4180
  const committedKiB = m.projectedKiB ?? 0;
4078
4181
  this.diskCommittedKiB += committedKiB;
@@ -4406,12 +4509,13 @@ export class ResidentDO extends Sandbox<Env> {
4406
4509
  lastAttachAt: now,
4407
4510
  evicted: false,
4408
4511
  // An evicted binding's own history: how its ref was chosen (absent on
4409
- // one made before the field — never overwritten by this attach's flag)
4410
- // and the one move it may have made.
4512
+ // one made before the field — never overwritten by this attach's flag),
4513
+ // the one move it may have made and its last move back.
4411
4514
  ...(existing
4412
4515
  ? {
4413
4516
  ...(existing.boundBy !== undefined ? { boundBy: existing.boundBy } : {}),
4414
4517
  ...(existing.rebound !== undefined ? { rebound: existing.rebound } : {}),
4518
+ ...(existing.returned !== undefined ? { returned: existing.returned } : {}),
4415
4519
  ...(existing.ownBranches !== undefined ? { ownBranches: existing.ownBranches } : {}),
4416
4520
  }
4417
4521
  : { boundBy }),
@@ -4764,27 +4868,27 @@ export class ResidentDO extends Sandbox<Env> {
4764
4868
 
4765
4869
  /** Item 16's second movement, decided where the fact is established — under
4766
4870
  * the mirror mutex, after the attach's fetch found the bound ref gone: a
4767
- * binding a rebind moved onto its own pull request's branch, that branch
4768
- * now deleted (its pull request merged), goes back to the default it was
4769
- * bound to, so the run starts clean there (item 17) instead of the thread
4770
- * failing `unknown-ref` for the rest of its life. The row as it stands is
4771
- * re-read and re-judged (`canReturnToDefault`: a ref a person named never
4772
- * returns — that branch is theirs to sort out), the move stamped returned
4773
- * so the thread may follow its next pull request, and the answer carries
4774
- * the move back for the card. A row that no longer qualifies — another
4775
- * attach moved it meanwhile — is answered as it stands, nothing written. */
4871
+ * binding on a branch that is now deleted (its pull request merged) goes
4872
+ * back to the default, so the run starts clean there (item 17) instead of
4873
+ * the thread failing `unknown-ref` for the rest of its life — when the
4874
+ * branch is one a rebind moved it onto, or one this thread's own runs
4875
+ * pushed as the binding remembers it (`ownBranches`): a ship unit's coding
4876
+ * child binds its unit branch by name and pushes it, and the merge deletes
4877
+ * it. The row as it stands is re-read and re-judged (the pure
4878
+ * `returnToDefault`, which writes nothing for a row `canReturnToDefault`
4879
+ * does not admit: a ref a person named that the thread never pushed never
4880
+ * returns — that branch is theirs to sort out), the move recorded so the
4881
+ * thread may follow its next pull request, and the answer carries the move
4882
+ * back for the card. A row that no longer qualifies — another attach moved
4883
+ * it meanwhile — is answered as it stands, nothing written. */
4776
4884
  private async returnBindingToDefault(
4777
4885
  binding: ThreadBinding,
4778
4886
  defaultRef: string,
4779
4887
  ): Promise<{ binding: ThreadBinding; returned?: Returned }> {
4780
4888
  const key = threadBindingKey(binding.threadKey);
4781
4889
  const current = (await this.ctx.storage.get<ThreadBinding>(key)) ?? binding;
4782
- if (current.rebound === undefined || !canReturnToDefault(current, defaultRef)) return { binding: current };
4783
- const back = returnToDefault(
4784
- { ...current, rebound: current.rebound },
4785
- defaultRef,
4786
- new Date(systemClock()).toISOString(),
4787
- );
4890
+ const back = returnToDefault(current, defaultRef, new Date(systemClock()).toISOString());
4891
+ if (back === undefined) return { binding: current };
4788
4892
  await this.ctx.storage.put(key, back.binding);
4789
4893
  console.log(
4790
4894
  `attach ${binding.threadKey}: ${back.returned.from} is gone from the mirror (the thread's own pull request #${back.returned.pr}) — returned to ${back.returned.to}; the tree is provisioned there`,
@@ -4866,9 +4970,22 @@ export class ResidentDO extends Sandbox<Env> {
4866
4970
  // The expected head applies to the ref it was resolved for; a sticky
4867
4971
  // binding on another branch drops it (item 51) rather than fetching on
4868
4972
  // every attach of a thread that can never be at that commit.
4973
+ // A ref the binding could return from — a branch a rebind moved it onto,
4974
+ // or one its own runs pushed (item 16's second movement) — is fetched for
4975
+ // whether or not the mirror holds it: the branch dies when its pull
4976
+ // request merges, the deletion reaches the mirror only through a
4977
+ // `fetch --prune`, and a mirror trusted for still holding the ref would
4978
+ // provision a tree at the deleted branch's stale tip until the refresh
4979
+ // cycle's prune caught up. One predicate, read once here, drives the mint
4980
+ // pre-check, the fetch under the lock and the return gate alike.
4869
4981
  let want = wantShaForBinding({ boundRef: binding.ref, refHint, wantSha });
4982
+ const returnable = canReturnToDefault(binding, facts.defaultRef);
4870
4983
  let fetchToken: string | null = token;
4871
- if (!fetchToken && githubAppConfigured(this.env) && (await this.mirrorNeedsFetchFor(binding.ref, want))) {
4984
+ if (
4985
+ !fetchToken &&
4986
+ githubAppConfigured(this.env) &&
4987
+ (await this.mirrorFetchReasonFor(binding.ref, want, returnable)) !== null
4988
+ ) {
4872
4989
  fetchToken = (await mintRepoScopedToken(this.env, slug).catch(() => null))?.token ?? null;
4873
4990
  }
4874
4991
 
@@ -4876,14 +4993,27 @@ export class ResidentDO extends Sandbox<Env> {
4876
4993
  try {
4877
4994
  locked = await this.withMirrorLock(async () => {
4878
4995
  await this.ensureGitSetup();
4879
- const fetched = await this.mirrorNeedsFetchFor(binding.ref, want);
4880
- if (fetched) {
4881
- await this.gitWithCred(
4882
- fetchToken,
4883
- ["-C", MIRROR_DIR, "fetch", "--prune", "origin"],
4884
- "fetch",
4885
- GIT_NETWORK_TIMEOUT_MS,
4886
- );
4996
+ const why = await this.mirrorFetchReasonFor(binding.ref, want, returnable);
4997
+ if (why !== null) {
4998
+ try {
4999
+ await this.gitWithCred(
5000
+ fetchToken,
5001
+ ["-C", MIRROR_DIR, "fetch", "--prune", "origin"],
5002
+ "fetch",
5003
+ GIT_NETWORK_TIMEOUT_MS,
5004
+ );
5005
+ } catch (err) {
5006
+ // A fetch for a missing or stale ref fails the attach as it always
5007
+ // did. One that only verifies a returnable ref the mirror holds is
5008
+ // not the attach's failure — the origin unreachable, no token for
5009
+ // a private repo — so the attach goes on with the mirror's ref, as
5010
+ // it did before the verification existed; the return is only
5011
+ // ever decided on a fetch that ran.
5012
+ if (why !== "returnable-ref") throw err;
5013
+ console.log(
5014
+ `attach ${threadKey}: the fetch verifying ${binding.ref} at the origin failed (${errMsg(err)}) — the attach goes on with the mirror's ref`,
5015
+ );
5016
+ }
4887
5017
  }
4888
5018
  // What to check out, now that the mirror is as fresh as it will get
4889
5019
  // (item 51, the pure `attachTarget`): the ref's tip when the ref is
@@ -4892,18 +5022,21 @@ export class ResidentDO extends Sandbox<Env> {
4892
5022
  // `refs/pull/N/head` still names its head, and the caller told us that
4893
5023
  // head — so a review of a merged PR stays on the warm resident instead
4894
5024
  // of falling back to a cold sandbox that clones the same commit itself.
4895
- // No fetch ran ⇒ the ref existed at the check: `mirrorNeedsFetch` fetches
4896
- // for every missing ref, so only a fetched mirror needs the re-read.
4897
- // (Under the mirror mutex — the mirror cannot change in between.)
4898
- const refExists = !fetched || (await this.refExists(binding.ref));
5025
+ // No fetch was due ⇒ the ref existed at the check: `mirrorFetchReason`
5026
+ // fetches for every missing ref, so only a fetched mirror needs the
5027
+ // re-read — and a returnable ref is re-read for real, the fact the
5028
+ // second movement is decided on. (Under the mirror mutex — the mirror
5029
+ // cannot change in between.)
5030
+ const refExists = why === null || (await this.refExists(binding.ref));
4899
5031
  let target = attachTarget({
4900
5032
  refExists,
4901
5033
  wantSha: want,
4902
5034
  commitInMirror: !refExists && want !== null && (await this.commitInMirror(want)),
4903
5035
  });
4904
- if (target.kind === "unknown-ref" && canReturnToDefault(binding, facts.defaultRef)) {
4905
- // The branch a rebind moved this thread onto is gone even after the
4906
- // fetch — deleted once its pull request merged. The binding goes
5036
+ if (target.kind === "unknown-ref" && returnable) {
5037
+ // The thread's branch — one a rebind moved it onto, or one its own
5038
+ // runs pushed and it was bound to by name — is gone even after the
5039
+ // fetch: deleted once its pull request merged. The binding goes
4907
5040
  // back to the default (item 16's second movement) and the attach
4908
5041
  // goes on there: the default is always in the mirror, and the
4909
5042
  // worktree step below provisions the tree at its tip.
@@ -5658,27 +5791,24 @@ export class ResidentDO extends Sandbox<Env> {
5658
5791
  * in flight, and remove what `planDepsEviction` names — debris first, then
5659
5792
  * the coldest spares beyond DEPS_STORE_MAX_UNREFERENCED. Housekeeping:
5660
5793
  * a failure is a log line, never a lifecycle flip. */
5661
- private async sweepDepsStore(): Promise<void> {
5662
- const listed = await this.run(["sh", "-c", depsStoreListScript()], { timeoutMs: DU_TIMEOUT_MS });
5663
- if (listed.exitCode !== 0) return;
5664
- const listing = parseDepsStoreListing(listed.stdout);
5665
- if (listing.entries.length === 0 && listing.leftovers.length === 0) return;
5794
+ /** The store entries no eviction may touch: the checkout's key, every live
5795
+ * binding's, every install and every entry backup in flight. */
5796
+ private async depsProtectedKeys(): Promise<Set<string>> {
5666
5797
  const facts = await this.ctx.storage.get<RepoFacts>(FACTS_KEY);
5667
5798
  const protectedKeys = new Set<string>([...this.depsInFlight.keys(), ...this.depsBackupsInFlight]);
5668
5799
  if (facts?.lockfileHash) protectedKeys.add(facts.lockfileHash);
5669
5800
  for (const b of await this.liveBindings()) if (b.depsKey) protectedKeys.add(b.depsKey);
5670
- // A scratch/staging dir of an install still running in this incarnation
5671
- // is live work, not debris; the listing cannot tell them apart, so
5672
- // leftovers wait for a sweep with nothing in flight.
5673
- const leftovers = this.depsInFlight.size > 0 ? [] : listing.leftovers;
5674
- const plan = planDepsEviction({ entries: listing.entries, leftovers, protectedKeys });
5675
- if (plan.remove.length === 0) return;
5801
+ return protectedKeys;
5802
+ }
5803
+
5804
+ /** Remove the store entries a plan names and drop their entry backups with
5805
+ * them (item 61 PR B: a spare nothing references on disk is a spare nothing
5806
+ * will wake into). Shared by the sweep and the attach's pressure path. */
5807
+ private async applyDepsEviction(plan: DepsEvictionPlan, listing: DepsStoreListing, who: string): Promise<void> {
5676
5808
  await this.runOk(["rm", "-rf", ...plan.remove], "deps-evict", { timeoutMs: DU_TIMEOUT_MS });
5677
5809
  console.log(
5678
- `deps: evicted ${plan.remove.length} path(s) — ${plan.remove.map((p) => p.slice(DEPS_STORE_DIR.length + 1, DEPS_STORE_DIR.length + 9)).join(", ")}; kept ${plan.keep.map((k) => k.slice(0, 8)).join(", ")}`,
5810
+ `deps: ${who} evicted ${plan.remove.length} path(s) — ${plan.remove.map((p) => p.slice(DEPS_STORE_DIR.length + 1, DEPS_STORE_DIR.length + 9)).join(", ")}; kept ${plan.keep.map((k) => k.slice(0, 8)).join(", ")}`,
5679
5811
  );
5680
- // The evicted entries' archives go with them (item 61 PR B): a spare
5681
- // nothing references on disk is a spare nothing will wake into.
5682
5812
  const kept = new Set(plan.keep);
5683
5813
  const evictedKeys = listing.entries.map((e) => e.key).filter((k) => !kept.has(k));
5684
5814
  const drop = depsBackupsToDrop({
@@ -5693,6 +5823,44 @@ export class ResidentDO extends Sandbox<Env> {
5693
5823
  }
5694
5824
  }
5695
5825
 
5826
+ /** Under disk pressure (item 55) the store keeps no spare: every complete
5827
+ * entry no live tree references goes, coldest first, before any idle tree
5828
+ * is considered — a spare is a cache worth minutes on a future attach, a
5829
+ * refused tree is a run falling to a cold sandbox now. Leftovers are the
5830
+ * sweep's business (they may be live installs' scratch). Answers what it
5831
+ * removed with the bytes each measured at; a listing that fails answers
5832
+ * nothing and the tree path decides as before. */
5833
+ private async evictDepsSparesUnderPressure(): Promise<Array<{ key: string; freedKiB: number }>> {
5834
+ const listed = await this.run(["sh", "-c", depsStoreListScript()], { timeoutMs: DU_TIMEOUT_MS });
5835
+ if (listed.exitCode !== 0) return [];
5836
+ const listing = parseDepsStoreListing(listed.stdout);
5837
+ if (listing.entries.length === 0) return [];
5838
+ const plan = planDepsEviction({
5839
+ entries: listing.entries,
5840
+ leftovers: [],
5841
+ protectedKeys: await this.depsProtectedKeys(),
5842
+ maxUnreferenced: DEPS_STORE_MAX_UNREFERENCED_UNDER_PRESSURE,
5843
+ });
5844
+ if (plan.remove.length === 0) return [];
5845
+ await this.applyDepsEviction(plan, listing, "disk-pressure");
5846
+ return plan.evicted.map((e) => ({ key: e.key, freedKiB: e.kib }));
5847
+ }
5848
+
5849
+ private async sweepDepsStore(): Promise<void> {
5850
+ const listed = await this.run(["sh", "-c", depsStoreListScript()], { timeoutMs: DU_TIMEOUT_MS });
5851
+ if (listed.exitCode !== 0) return;
5852
+ const listing = parseDepsStoreListing(listed.stdout);
5853
+ if (listing.entries.length === 0 && listing.leftovers.length === 0) return;
5854
+ const protectedKeys = await this.depsProtectedKeys();
5855
+ // A scratch/staging dir of an install still running in this incarnation
5856
+ // is live work, not debris; the listing cannot tell them apart, so
5857
+ // leftovers wait for a sweep with nothing in flight.
5858
+ const leftovers = this.depsInFlight.size > 0 ? [] : listing.leftovers;
5859
+ const plan = planDepsEviction({ entries: listing.entries, leftovers, protectedKeys });
5860
+ if (plan.remove.length === 0) return;
5861
+ await this.applyDepsEviction(plan, listing, "sweep");
5862
+ }
5863
+
5696
5864
  /** The per-user 700 staging dir (`install -d` is idempotent), memoized per
5697
5865
  * incarnation — the write/credential paths used to fork for it on every
5698
5866
  * call. Cleared with the other memos; a fresh disk simply re-creates it. */
@@ -6202,11 +6370,10 @@ export class ResidentDO extends Sandbox<Env> {
6202
6370
  * (su), never as root: the worktree is thread-owned, so root git in it
6203
6371
  * would be refused by safe.directory and would be the exact repo-local-
6204
6372
  * config execution vector safe.directory exists to block. Read by
6205
- * `measureTreeBeforeEviction` for the eviction's record — never a reason
6206
- * to keep a tree (item 17) — and by the disk-full recycle's guard (item
6207
- * 54), where unknown (git failed) counts as NOT clean. A tree that no
6208
- * longer exists (disk recycled by a sleep/wake) is clean with nothing
6209
- * measured: there was nothing to discard.
6373
+ * `measureTreeBeforeEviction` alone, for the record of an eviction or of
6374
+ * the disk-full recycle — never a reason to keep a tree or a container
6375
+ * (item 17). A tree that no longer exists (disk recycled by a sleep/wake)
6376
+ * is clean with nothing measured: there was nothing to discard.
6210
6377
  *
6211
6378
  * ONE spawn: the presence test and both git probes fold
6212
6379
  * into the pure `worktreeCleanlinessScript` (test -d as root, both git
@@ -6222,7 +6389,8 @@ export class ResidentDO extends Sandbox<Env> {
6222
6389
  return parseWorktreeCleanliness(r);
6223
6390
  }
6224
6391
 
6225
- /** What a tree holds right before its eviction, for the record and the log
6392
+ /** What a tree holds right before its eviction — or before the disk-full
6393
+ * recycle discards it with the disk (item 54) — for the record and the log
6226
6394
  * (item 17): the one-spawn probe as the thread user, read once into
6227
6395
  * `evictedTreeOf` — never into a keep decision. Callers skip it when the
6228
6396
  * runtime is down: the disk, and the tree with it, is already gone. */
@@ -6782,6 +6950,8 @@ export class ResidentDO extends Sandbox<Env> {
6782
6950
  evictedWhy,
6783
6951
  evictedLeftBehind,
6784
6952
  evictedUnmeasured,
6953
+ recycledLeftBehind,
6954
+ recycledUnmeasured,
6785
6955
  }) => ({
6786
6956
  threadKey,
6787
6957
  ref,
@@ -6795,6 +6965,8 @@ export class ResidentDO extends Sandbox<Env> {
6795
6965
  evictedWhy: evictedWhy ?? null,
6796
6966
  evictedLeftBehind: evictedLeftBehind ?? null,
6797
6967
  evictedUnmeasured: evictedUnmeasured ?? null,
6968
+ recycledLeftBehind: recycledLeftBehind ?? null,
6969
+ recycledUnmeasured: recycledUnmeasured ?? null,
6798
6970
  }),
6799
6971
  );
6800
6972
  return {