@coreplane/switchboard 1.234.0 → 1.236.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/dist/assets/Dockerfile +11 -0
  2. package/dist/assets/config/config.example.yaml +9 -4
  3. package/dist/assets/deploy/cloudflare-memory/worker.ts +12 -0
  4. package/dist/assets/deploy/cloudflare-resident/Dockerfile +18 -0
  5. package/dist/assets/deploy/cloudflare-resident/gc.ts +6 -9
  6. package/dist/assets/deploy/cloudflare-resident/worker.ts +225 -119
  7. package/dist/assets/deploy/cloudflare-sandbox/Dockerfile +11 -0
  8. package/dist/assets/deploy/cloudflare-sandbox/worker.ts +143 -13
  9. package/dist/assets/package-lock.json +212 -3
  10. package/dist/assets/package.json +4 -1
  11. package/dist/assets/source.json +3 -3
  12. package/dist/assets/src/core/coordinator/contract.ts +24 -4
  13. package/dist/assets/src/core/coordinator/driver.ts +31 -3
  14. package/dist/assets/src/core/runEvents.ts +43 -1
  15. package/dist/assets/src/core/runFriction.ts +1 -0
  16. package/dist/assets/src/core/ship/contract.ts +3 -1
  17. package/dist/assets/src/core/ship/coordinator.ts +213 -29
  18. package/dist/assets/src/core/trace/types.ts +5 -0
  19. package/dist/assets/src/execution/residentCleanliness.ts +24 -0
  20. package/dist/assets/src/execution/residentDisk.ts +21 -10
  21. package/dist/assets/src/execution/residentDiskBudget.ts +7 -6
  22. package/dist/assets/src/execution/sandboxErrors.ts +77 -0
  23. package/dist/assets/src/execution/sandboxIdle.ts +220 -0
  24. package/dist/assets/src/execution/sandboxStart.ts +90 -0
  25. package/dist/assets/web/dist/.vite/manifest.json +437 -422
  26. package/dist/assets/web/dist/assets/{AppShell-lYVcj-k7.js → AppShell-DZdz_sl7.js} +1 -1
  27. package/dist/assets/web/dist/assets/{CostsPage-D-v8am88.js → CostsPage-BfJsol9y.js} +1 -1
  28. package/dist/assets/web/dist/assets/{DeliveryPage-CvlWP7Eq.js → DeliveryPage-CvInfu7y.js} +1 -1
  29. package/dist/assets/web/dist/assets/{NotFoundPage-BzWS4Ca1.js → NotFoundPage-d37EP8hS.js} +1 -1
  30. package/dist/assets/web/dist/assets/ResidentDetailPage-DQSD0SZD.js +1 -0
  31. package/dist/assets/web/dist/assets/ResidentsIndexPage-CZUGLen6.js +1 -0
  32. package/dist/assets/web/dist/assets/RunFoldRow-Bngn4GjF.js +9 -0
  33. package/dist/assets/web/dist/assets/RunRoutePage-a-tMrirB.js +6 -0
  34. package/dist/assets/web/dist/assets/RunsIndexPage-s0VKmRda.js +1 -0
  35. package/dist/assets/web/dist/assets/{RunsTabs-DEICQ4FY.js → RunsTabs-CyCdOhJ2.js} +1 -1
  36. package/dist/assets/web/dist/assets/ScheduledPage-ByCmyrDs.js +1 -0
  37. package/dist/assets/web/dist/assets/SettingsPage-DhKEokGK.js +1 -0
  38. package/dist/assets/web/dist/assets/{StatusDot-Ct6AtHhS.js → StatusDot-D-BPSPpw.js} +1 -1
  39. package/dist/assets/web/dist/assets/{Tooltip-qol5Boof.js → Tooltip-DPDx5tXT.js} +1 -1
  40. package/dist/assets/web/dist/assets/UnitRoutePage-0eiekTHQ.js +1 -0
  41. package/dist/assets/web/dist/assets/{angular-html-Cw130Zyi.js → angular-html-Ni1Xil2G.js} +1 -1
  42. package/dist/assets/web/dist/assets/{angular-ts-DY1m4C2s.js → angular-ts-oCgo6p05.js} +1 -1
  43. package/dist/assets/web/dist/assets/{apl-W6Ty7v05.js → apl-CiWYl0gh.js} +1 -1
  44. package/dist/assets/web/dist/assets/{astro-DlLVHwVk.js → astro-B-VeKZ_J.js} +1 -1
  45. package/dist/assets/web/dist/assets/{blade-Bco3tDh4.js → blade-BIWEISKd.js} +1 -1
  46. package/dist/assets/web/dist/assets/{c-1LaFY_cj.js → c-DHxA5Byp.js} +1 -1
  47. package/dist/assets/web/dist/assets/{chapel-B-OvrOL5.js → chapel-D4qGLw-Q.js} +1 -1
  48. package/dist/assets/web/dist/assets/{cobol-Ch3EPlHu.js → cobol-BznX5Q-u.js} +1 -1
  49. package/dist/assets/web/dist/assets/{coffee-hZuITnto.js → coffee-CVr8Idlj.js} +1 -1
  50. package/dist/assets/web/dist/assets/{cpp-B8iRVo8m.js → cpp-wrk6NfW4.js} +1 -1
  51. package/dist/assets/web/dist/assets/{crystal-DKveI7lr.js → crystal-CGYWMeH1.js} +1 -1
  52. package/dist/assets/web/dist/assets/{css-_2_CD-rr.js → css-BgGytMsy.js} +1 -1
  53. package/dist/assets/web/dist/assets/{dist-DwtJj-ou.js → dist-DSkVB16z.js} +2 -2
  54. package/dist/assets/web/dist/assets/durationTone-DYogEPEk.js +1 -0
  55. package/dist/assets/web/dist/assets/{edge-BenMDPct.js → edge-CdR6AU9v.js} +1 -1
  56. package/dist/assets/web/dist/assets/{elixir-CBbdEqG6.js → elixir-CQRb3PZx.js} +1 -1
  57. package/dist/assets/web/dist/assets/{elm-DMub_2lY.js → elm-CpuA6W8B.js} +1 -1
  58. package/dist/assets/web/dist/assets/{erb-CI9He_Up.js → erb-CvlrblqH.js} +1 -1
  59. package/dist/assets/web/dist/assets/{favicon-BQsePYv5.js → favicon-CSt-qDvc.js} +1 -1
  60. package/dist/assets/web/dist/assets/{git-rebase-BBfhnyF8.js → git-rebase-COnovCVc.js} +1 -1
  61. package/dist/assets/web/dist/assets/{glimmer-js-CR9Fxrau.js → glimmer-js-C-DS4g_J.js} +1 -1
  62. package/dist/assets/web/dist/assets/{glimmer-ts-CwFwX_XS.js → glimmer-ts-DTkorWRI.js} +1 -1
  63. package/dist/assets/web/dist/assets/{glsl-Daiodp1r.js → glsl-CH51-JW7.js} +1 -1
  64. package/dist/assets/web/dist/assets/{graphql-DZvrsEkB.js → graphql-BO-XymNv.js} +1 -1
  65. package/dist/assets/web/dist/assets/{hack-BaXOzdg7.js → hack-C-N0oicf.js} +1 -1
  66. package/dist/assets/web/dist/assets/{haml-CMskq_VA.js → haml-BfmdtWoP.js} +1 -1
  67. package/dist/assets/web/dist/assets/{handlebars-Dhw_f7jD.js → handlebars-BqhO_te-.js} +1 -1
  68. package/dist/assets/web/dist/assets/{html-C9k99z0z.js → html-DUEZlTKV.js} +1 -1
  69. package/dist/assets/web/dist/assets/{html-derivative-VckPItXN.js → html-derivative-DOUJTS8A.js} +1 -1
  70. package/dist/assets/web/dist/assets/{http-DXU_3h9v.js → http-j7UH0Cqv.js} +1 -1
  71. package/dist/assets/web/dist/assets/{hurl-KU24JNdp.js → hurl-D6ZaBLJk.js} +1 -1
  72. package/dist/assets/web/dist/assets/indexRow-BjXBzI5Z.js +1 -0
  73. package/dist/assets/web/dist/assets/{java-BbZjTNgf.js → java-dugWx3wi.js} +1 -1
  74. package/dist/assets/web/dist/assets/{javascript-DXCdqcZZ.js → javascript-CK4GC4JO.js} +1 -1
  75. package/dist/assets/web/dist/assets/{jinja-pDlsjTVp.js → jinja-DrLKIyKG.js} +1 -1
  76. package/dist/assets/web/dist/assets/{jison-DsXDk66e.js → jison-CS1Th-TO.js} +1 -1
  77. package/dist/assets/web/dist/assets/{json-tCxXfRgG.js → json-CkBqdVys.js} +1 -1
  78. package/dist/assets/web/dist/assets/{jsx-B0ZYkCmi.js → jsx-CCxdg7n3.js} +1 -1
  79. package/dist/assets/web/dist/assets/{julia-BLzzeI6x.js → julia-BalKW2hA.js} +1 -1
  80. package/dist/assets/web/dist/assets/{just-DvD2_60c.js → just-BKw1G40D.js} +1 -1
  81. package/dist/assets/web/dist/assets/{latex-511zn3h7.js → latex-Dfad6ZN-.js} +1 -1
  82. package/dist/assets/web/dist/assets/{liquid-BD0BkMox.js → liquid-f_CMGBvd.js} +1 -1
  83. package/dist/assets/web/dist/assets/{indexFormat-B-pd4r7Y.js → localIso-CNA-bhS-.js} +1 -1
  84. package/dist/assets/web/dist/assets/{lua-NFicKm6N.js → lua-BcMK_uKM.js} +1 -1
  85. package/dist/assets/web/dist/assets/main-B77vAZVg.css +1 -0
  86. package/dist/assets/web/dist/assets/main-DR8iKpNt.js +28 -0
  87. package/dist/assets/web/dist/assets/{marko-1-G_nEXK.js → marko-B0oNLx2i.js} +1 -1
  88. package/dist/assets/web/dist/assets/{mdc-DUe3AQLp.js → mdc-Bc_MmIyq.js} +1 -1
  89. package/dist/assets/web/dist/assets/{nginx-Dg379_bA.js → nginx-Cmxv8RIt.js} +1 -1
  90. package/dist/assets/web/dist/assets/{nim-BnFq97ZO.js → nim-BtgRfaY0.js} +1 -1
  91. package/dist/assets/web/dist/assets/{org-F0uiwGvq.js → org-Dl9EzlHh.js} +1 -1
  92. package/dist/assets/web/dist/assets/{perl-B0euczkl.js → perl-BmF7x7BB.js} +1 -1
  93. package/dist/assets/web/dist/assets/{php-DkL3k_n7.js → php-rH6CVptQ.js} +1 -1
  94. package/dist/assets/web/dist/assets/{pug-L_OjjZP4.js → pug-CIN5Ccck.js} +1 -1
  95. package/dist/assets/web/dist/assets/{qml-BMjB00Zz.js → qml-BpLS3RFr.js} +1 -1
  96. package/dist/assets/web/dist/assets/{r-DoeLdnqR.js → r-BUMF3B-H.js} +1 -1
  97. package/dist/assets/web/dist/assets/{razor-BzYNkAWy.js → razor-B7sTBPdT.js} +1 -1
  98. package/dist/assets/web/dist/assets/{regexp-CPmElMk3.js → regexp-h2SvwrsJ.js} +1 -1
  99. package/dist/assets/web/dist/assets/residentsModel-VSJEepL1.js +1 -0
  100. package/dist/assets/web/dist/assets/{rst-DrsjgfI7.js → rst-DLOWEsD2.js} +1 -1
  101. package/dist/assets/web/dist/assets/{ruby-cB42ppvx.js → ruby-Cl_-I4k3.js} +1 -1
  102. package/dist/assets/web/dist/assets/{sas-R5Y3NM_I.js → sas-Cm4M-BvV.js} +1 -1
  103. package/dist/assets/web/dist/assets/{scss-C-zWxV9s.js → scss-CEFCEQUF.js} +1 -1
  104. package/dist/assets/web/dist/assets/{shellscript-oJF96aAU.js → shellscript-CE6zb5eS.js} +1 -1
  105. package/dist/assets/web/dist/assets/{shellsession-CznECKyi.js → shellsession-B4fDVzrM.js} +1 -1
  106. package/dist/assets/web/dist/assets/{soy-wjHLSRag.js → soy-DwXCMpjE.js} +1 -1
  107. package/dist/assets/web/dist/assets/{sql-C_BM8IOW.js → sql-DrnAKnyD.js} +1 -1
  108. package/dist/assets/web/dist/assets/{stata-D31HO8aQ.js → stata-BzlwPWD6.js} +1 -1
  109. package/dist/assets/web/dist/assets/{surrealql-CKCLyplA.js → surrealql-CeUieZXd.js} +1 -1
  110. package/dist/assets/web/dist/assets/{svelte-3geWk2iu.js → svelte-BERIpIKR.js} +1 -1
  111. package/dist/assets/web/dist/assets/{templ-DiJr_hTD.js → templ-6FSuUDwg.js} +1 -1
  112. package/dist/assets/web/dist/assets/{tex-BEJGWyeF.js → tex-SWCyMwNX.js} +1 -1
  113. package/dist/assets/web/dist/assets/{ts-tags-C8Mzdpxx.js → ts-tags-VWj7gWgY.js} +1 -1
  114. package/dist/assets/web/dist/assets/{tsx-UQiSm_p3.js → tsx-B6dzfL8L.js} +1 -1
  115. package/dist/assets/web/dist/assets/{twig-_Pvzkeo9.js → twig-BDXsr482.js} +1 -1
  116. package/dist/assets/web/dist/assets/{typescript-BWMkINKK.js → typescript-s100Dt8y.js} +1 -1
  117. package/dist/assets/web/dist/assets/{typst-Bdg9m-e7.js → typst-WfyEAUdt.js} +1 -1
  118. package/dist/assets/web/dist/assets/{vue-CDZrrAZ8.js → vue-CkXOOJ-6.js} +1 -1
  119. package/dist/assets/web/dist/assets/{vue-html-tAlpNhfN.js → vue-html-CEpzdcau.js} +1 -1
  120. package/dist/assets/web/dist/assets/{vue-vine-C27UF_rh.js → vue-vine-D9dsfiFA.js} +1 -1
  121. package/dist/assets/web/dist/assets/{xml-CxeDr9Zh.js → xml-iBDofbJq.js} +1 -1
  122. package/dist/assets/web/dist/assets/{xsl-Bpi0uUnM.js → xsl-CqbJF4hD.js} +1 -1
  123. package/dist/assets/web/dist/assets/{yaml-BtUgZ1GN.js → yaml-D9i6OxYe.js} +1 -1
  124. package/dist/cli.js +2336 -1192
  125. package/package.json +1 -1
  126. package/dist/assets/web/dist/assets/ResidentDetailPage-C_WNBUao.js +0 -1
  127. package/dist/assets/web/dist/assets/ResidentsIndexPage-D1sTyaZ_.js +0 -1
  128. package/dist/assets/web/dist/assets/RunFoldRow-C_bcTSjA.js +0 -5
  129. package/dist/assets/web/dist/assets/RunRoutePage-BWTi_6PG.js +0 -10
  130. package/dist/assets/web/dist/assets/RunsIndexPage-H6GkSv0a.js +0 -1
  131. package/dist/assets/web/dist/assets/ScheduledPage-BLcPzm1S.js +0 -1
  132. package/dist/assets/web/dist/assets/UnitRoutePage-CIhTEwN7.js +0 -1
  133. package/dist/assets/web/dist/assets/indexRow-Bnj883ii.js +0 -1
  134. package/dist/assets/web/dist/assets/localIso-L06jV29p.js +0 -1
  135. package/dist/assets/web/dist/assets/main-CRtlGxRm.js +0 -28
  136. package/dist/assets/web/dist/assets/main-Dm11o0hc.css +0 -1
  137. package/dist/assets/web/dist/assets/residentDiskBudget-BMBKlYRH.js +0 -1
@@ -116,10 +116,13 @@ import {
116
116
  type ThreadDepsMechanism,
117
117
  } from "../../src/execution/residentDepCache.js";
118
118
  import {
119
- leftBehindOf,
119
+ evictedTreeOf,
120
+ evictedTreeSentence,
120
121
  parseWorktreeCleanliness,
121
122
  worktreeCleanlinessScript,
123
+ type EvictedTree,
122
124
  type LeftBehind,
125
+ type WorktreeCleanliness,
123
126
  } from "../../src/execution/residentCleanliness.js";
124
127
  import {
125
128
  base64LengthOf,
@@ -502,16 +505,18 @@ const FORCE_DETACH_KILL_TIMEOUT_MS = 2_000;
502
505
  * binding record is KEPT so the next attach recreates with the same
503
506
  * ref. Overridable per resident via the onboard-time `worktreeTtlDays`. */
504
507
  const WORKTREE_TTL_DAYS_DEFAULT = 7;
505
- /** A live binding whose last attach is older than this AND whose tree is clean
506
- * (no uncommitted/unpushed work) is released by the sweep step (every refresh
507
- * instance runs one) — runs that ended before /detach existed, or whose
508
- * release call was lost. Dirty trees keep to the TTL. */
508
+ /** A live binding whose last attach is older than this and has no op in
509
+ * flight is released by the sweep step (every refresh instance runs one),
510
+ * whatever its tree holds (item 17) — runs that ended before /detach existed,
511
+ * or whose release call was lost. */
509
512
  const CLEAN_IDLE_RELEASE_S = 60 * 60;
510
- /** Idle sleep: when no thread has attached within this window and no live
511
- * tree is dirty, the refresh cycle skips the fetch and the cron holds the
512
- * next instance to the idle cadence, so the container can actually sleep
513
- * (SLEEP_AFTER); the next attach refreshes first if the mirror is stale
514
- * (refresh-on-attach). */
513
+ /** Idle sleep: when no live binding was attached to or used within this
514
+ * window and nothing is in flight, the refresh cycle skips the fetch and the
515
+ * cron holds the next instance to the idle cadence, so the container can
516
+ * actually sleep (SLEEP_AFTER); the next attach refreshes first if the mirror
517
+ * is stale (refresh-on-attach). The disk-full recycle (item 54) reads the
518
+ * same window through the same predicate (`recentlyUsed`): a sleep and a
519
+ * recycle destroy the same disk, so one rule says when it may go away. */
515
520
  const IDLE_AFTER_S = 60 * 60;
516
521
  /** LRU eviction floor: an over-cap onboard with `evictColdest:true` may
517
522
  * offboard the coldest eligible warm resident, but never one whose last
@@ -1023,8 +1028,26 @@ interface ThreadBinding {
1023
1028
  evicted?: boolean;
1024
1029
  evictedAt?: string;
1025
1030
  /** Why the last eviction happened (the audit trail): `ttl`, `clean-idle`,
1026
- * `detach`, or a reclamation fate — `merged #N` / `closed #N` / `gone`. */
1031
+ * `detach`, `disk-pressure`, or a reclamation fate — `merged #N` /
1032
+ * `closed #N` / `gone`. */
1027
1033
  evictedWhy?: string;
1034
+ /** What the tree held when that eviction removed it (item 17: never a
1035
+ * reason to keep the tree, never discarded silently): the tracked changes
1036
+ * and unpushed commits measured as the thread user. Absent when the tree
1037
+ * was clean, already gone with the disk, or not measured (a force detach). */
1038
+ evictedLeftBehind?: LeftBehind;
1039
+ /** Why the tree could not be measured before that eviction (git could not
1040
+ * read it) — so an unreadable tree is never recorded as clean. */
1041
+ evictedUnmeasured?: string;
1042
+ /** What the tree held when the last disk-full recycle (item 54) discarded
1043
+ * it with the disk — the same measurement as `evictedLeftBehind`, under its
1044
+ * own name because the binding is not evicted: its user is kept and the
1045
+ * next attach recreates the tree (`worktree-missing`). Both rewritten on
1046
+ * every recycle; absent when the tree was clean or already gone. */
1047
+ recycledLeftBehind?: LeftBehind;
1048
+ /** Why the tree could not be measured before that recycle (git could not
1049
+ * read it) — still discarded with the disk, never recorded as clean. */
1050
+ recycledUnmeasured?: string;
1028
1051
  /** How deps were last materialized (evidence that the per-branch reconciliation ran). */
1029
1052
  deps?: ThreadDepsMechanism;
1030
1053
  /** Commit the worktree was last attached at (the ref's tip in the mirror
@@ -2933,12 +2956,11 @@ export class ResidentDO extends Sandbox<Env> {
2933
2956
  throw new CycleRestartError("image-stale-restart");
2934
2957
  }
2935
2958
 
2936
- // Idle sleep: nobody has attached for IDLE_AFTER_S and no live tree is
2937
- // dirty → skip this fetch; the cron holds the next instance to the idle
2959
+ // Idle sleep: no live binding used within IDLE_AFTER_S and nothing in
2960
+ // flight → skip this fetch; the cron holds the next instance to the idle
2938
2961
  // cadence, so SLEEP_AFTER can elapse. Staleness is repaid at the next
2939
- // attach (refreshIfStale). A
2940
- // dirty live tree pins the container awake: sleep destroys the disk and
2941
- // uncommitted work is not snapshotted.
2962
+ // attach (refreshIfStale). What a live tree holds is not asked (item 17):
2963
+ // sleep destroys the disk, and a tree no run is using protects nothing.
2942
2964
  // Only a SETTLED resident may park: a cycle that finds `refreshing`/
2943
2965
  // `restoring` at entry is looking at a marker left by a cycle that died
2944
2966
  // mid-flight (a deploy evicting the DO: stuck `refreshing` + parked →
@@ -2990,7 +3012,7 @@ export class ResidentDO extends Sandbox<Env> {
2990
3012
  await this.ctx.storage.delete(DEGRADED_STREAK_KEY);
2991
3013
  }
2992
3014
  if (settled && (await this.isIdle())) {
2993
- // isIdle awaited (git status per live tree) — re-read before writing.
3015
+ // isIdle awaited (the bindings listing) — re-read before writing.
2994
3016
  const now = (await this.ctx.storage.get<RepoFacts>(FACTS_KEY)) ?? facts;
2995
3017
  if (!now.idleSince)
2996
3018
  await this.ctx.storage.put(FACTS_KEY, {
@@ -3786,15 +3808,25 @@ export class ResidentDO extends Sandbox<Env> {
3786
3808
  });
3787
3809
  }
3788
3810
 
3789
- /** Idle = no live binding attached within IDLE_AFTER_S AND (when the
3790
- * runtime is up) no live tree is dirty. Bindings are storage; dirtiness
3791
- * needs the container — if it is already asleep there is nothing to lose. */
3792
- private async isIdle(): Promise<boolean> {
3793
- const live = await this.liveBindings();
3811
+ /** Whether a run may be using this disk: a live binding attached to or
3812
+ * used (an exec bumps `lastAttachAt` too, item 21) within IDLE_AFTER_S.
3813
+ * The op counter is 0 between a run's tool calls, so this floor is what
3814
+ * stands for a run mid-flight. The one predicate behind "the disk may go
3815
+ * away": the idle-sleep gate (item 16b — a platform sleep destroys the
3816
+ * disk) and the disk-full recycle (item 54) both read it, and neither asks
3817
+ * what a tree holds (item 17): a dirty tree no run is using protects
3818
+ * nothing — the next attach wipes it. Bindings are storage, so this needs
3819
+ * no container. */
3820
+ private recentlyUsed(live: ThreadBinding[]): boolean {
3794
3821
  const recent = systemClock() - IDLE_AFTER_S * 1000;
3795
- if (live.some((b) => Date.parse(b.lastAttachAt) >= recent)) return false;
3796
- if (this.inFlightCount() > 0) return false;
3797
- return this.liveTreesClean(live);
3822
+ return live.some((b) => Date.parse(b.lastAttachAt) >= recent);
3823
+ }
3824
+
3825
+ /** Idle = no live binding used within the floor and nothing in flight; the
3826
+ * sweep's release records what an idle tree held. */
3827
+ private async isIdle(): Promise<boolean> {
3828
+ if (this.recentlyUsed(await this.liveBindings())) return false;
3829
+ return this.inFlightCount() === 0;
3798
3830
  }
3799
3831
 
3800
3832
  /** Bindings that hold a pool user and a tree on disk (not evicted). */
@@ -3803,18 +3835,6 @@ export class ResidentDO extends Sandbox<Env> {
3803
3835
  return [...all.values()].filter((b) => !b.evicted && b.user);
3804
3836
  }
3805
3837
 
3806
- /** True when no live tree holds work a lost disk would destroy: every one
3807
- * is clean (as its thread user), or the runtime is already down — a sleep
3808
- * has destroyed the disk, so there is nothing left to lose. */
3809
- private async liveTreesClean(live: ThreadBinding[]): Promise<boolean> {
3810
- if (!(await this.isRuntimeActive().catch(() => false))) return true;
3811
- // Concurrent: each check touches only its own (disjoint) tree, and one
3812
- // spawn each — the idle gate does not pay a serial
3813
- // 3-probe round-trip per live binding.
3814
- const checks = await Promise.all(live.map((b) => this.worktreeCleanliness(b)));
3815
- return checks.every((c) => c.clean); // any dirty or unknown → not clean
3816
- }
3817
-
3818
3838
  // -- disk-full (docs/reference/specs/resident-repos.md item 54) ---------------------------
3819
3839
 
3820
3840
  /** Free space on the workspace mount in KiB; `null` when df cannot answer.
@@ -3840,28 +3860,54 @@ export class ResidentDO extends Sandbox<Env> {
3840
3860
 
3841
3861
  /** The disk is a cache: stop the container so the next cycle attempt
3842
3862
  * restores mirror + checkout from R2 onto an empty disk — the same wake
3843
- * path as a platform sleep. Only when the pure plan allows it: nothing in
3844
- * flight (`selfInFlight` excludes the calling refresh cycle from the
3845
- * count), every live tree clean, and no recycle within the cooldown. A
3846
- * refused recycle is written to `lastRefreshError` with its why, so
3847
- * `/residents` says what an operator must do; the `degraded` reason stays
3848
- * the clean `disk-full: …`. Answers whether the container was recycled. */
3863
+ * path as a platform sleep. Only when the pure plan allows it: no recycle
3864
+ * within the cooldown, nothing in flight (`selfInFlight` excludes the
3865
+ * calling refresh cycle from the count) and no live binding used within
3866
+ * the idle floor — the idle-sleep gate's own predicate (`recentlyUsed`),
3867
+ * because a sleep and a recycle destroy the same disk: it may go away when
3868
+ * no run is using it, never for what the trees hold (item 17). A recycle
3869
+ * discards every live tree the way an eviction does, so each is measured
3870
+ * first, as its thread user, and its binding records what went
3871
+ * (`recordRecycledTree`); the plan is decided again after those awaits,
3872
+ * right before the stop. A refused recycle is written to
3873
+ * `lastRefreshError` with its why, so `/residents` says what an operator
3874
+ * must do; the `degraded` reason stays the clean `disk-full: …`. Answers
3875
+ * whether the container was recycled. */
3849
3876
  private async recoverFromDiskFull(reason: string, selfInFlight: number): Promise<boolean> {
3850
3877
  const lastRecycleAt = await this.ctx.storage.get<number>(DISK_FULL_RECYCLE_KEY);
3851
- const plan = planDiskFullRecovery({
3852
- now: systemClock(),
3853
- lastRecycleAt,
3854
- inFlight: this.inFlightCount() - selfInFlight,
3855
- treesClean: await this.liveTreesClean(await this.liveBindings()),
3856
- });
3857
- if (plan.action === "wait") {
3858
- console.log(`disk-full: container kept — ${plan.why}`);
3859
- await this.recordRefreshError(`${reason} — container kept: ${plan.why}`);
3878
+ const plan = (live: ThreadBinding[]) =>
3879
+ planDiskFullRecovery({
3880
+ now: systemClock(),
3881
+ lastRecycleAt,
3882
+ inFlight: this.inFlightCount() - selfInFlight,
3883
+ recentlyUsed: this.recentlyUsed(live),
3884
+ idleFloorS: IDLE_AFTER_S,
3885
+ });
3886
+ const kept = async (why: string) => {
3887
+ console.log(`disk-full: container kept — ${why}`);
3888
+ await this.recordRefreshError(`${reason} — container kept: ${why}`);
3860
3889
  return false;
3861
- }
3890
+ };
3891
+ const live = await this.liveBindings();
3892
+ const first = plan(live);
3893
+ if (first.action === "wait") return kept(first.why);
3894
+ // What each live tree holds, for its record — never a reason to keep the
3895
+ // container. Concurrent: each probe touches only its own tree, one spawn
3896
+ // each. Runtime down: the disk, and every tree with it, is already gone.
3897
+ const active = await this.isRuntimeActive().catch(() => false);
3898
+ const trees = active
3899
+ ? await Promise.all(
3900
+ live.map(async (binding) => [binding, await this.measureTreeBeforeEviction(binding)] as const),
3901
+ )
3902
+ : live.map((binding) => [binding, undefined] as const);
3903
+ // The measurement awaited (the DO yields at each await): decide again over
3904
+ // fresh facts — an attach or an op that landed meanwhile keeps the container.
3905
+ const verdict = plan(await this.liveBindings());
3906
+ if (verdict.action === "wait") return kept(verdict.why);
3862
3907
  console.log(
3863
3908
  `disk-full: recycling the container — the next cycle restores mirror + checkout from R2 onto an empty disk (${reason})`,
3864
3909
  );
3910
+ for (const [binding, tree] of trees) await this.recordRecycledTree(binding, tree);
3865
3911
  await this.ctx.storage.put(DISK_FULL_RECYCLE_KEY, systemClock());
3866
3912
  await this.recordRefreshError(`${reason} — container recycled; restoring from R2 on the next cycle`);
3867
3913
  this.swapIncarnation(); // deliberate incarnation swap
@@ -3869,6 +3915,27 @@ export class ResidentDO extends Sandbox<Env> {
3869
3915
  return true;
3870
3916
  }
3871
3917
 
3918
+ /** The disk-full recycle's record for one live tree (item 54), written the
3919
+ * way `evictBinding` writes an eviction's — but the binding stays live, its
3920
+ * pool user kept and its tree recreated on the next attach, so this is not
3921
+ * an eviction and the record sits under its own name beside
3922
+ * `evictedLeftBehind`: the counts when something was there, the probe's
3923
+ * first error line when git could not read the tree, both rewritten on
3924
+ * every recycle so nothing lingers from an earlier one, and one log line
3925
+ * when the tree held something. Re-read first: a binding evicted or
3926
+ * re-attached during the measurement is left as is. */
3927
+ private async recordRecycledTree(binding: ThreadBinding, tree: EvictedTree | undefined): Promise<void> {
3928
+ const now = await this.ctx.storage.get<ThreadBinding>(threadBindingKey(binding.threadKey));
3929
+ if (!now || now.evicted || now.lastAttachAt !== binding.lastAttachAt) return;
3930
+ await this.ctx.storage.put(threadBindingKey(binding.threadKey), {
3931
+ ...now,
3932
+ recycledLeftBehind: tree && "leftBehind" in tree ? tree.leftBehind : undefined,
3933
+ recycledUnmeasured: tree && "unmeasured" in tree ? tree.unmeasured : undefined,
3934
+ } satisfies ThreadBinding);
3935
+ if (tree)
3936
+ console.log(`disk-full: ${binding.threadKey} tree discarded with the disk — ${evictedTreeSentence(tree)}`);
3937
+ }
3938
+
3872
3939
  // -- disk budget (docs/reference/specs/resident-repos.md item 55) -------------------------
3873
3940
 
3874
3941
  /** The `df` half: total/used/free of the workspace mount; null when df
@@ -3948,10 +4015,11 @@ export class ResidentDO extends Sandbox<Env> {
3948
4015
  * already on disk is `reuse` (0 — a dirty/stale recreate frees the old one
3949
4016
  * first). A ref not yet in the mirror (fetched under the lock, moments later)
3950
4017
  * is projected as `hardlink`, the common case. When it does not fit, the
3951
- * coldest clean idle trees go first (`orderEvictionCandidates`: never the
4018
+ * coldest idle trees go first (`orderEvictionCandidates`: never the
3952
4019
  * requesting thread, a busy tree, the default branch, or one attached within
3953
- * DISK_EVICT_MIN_IDLE_MS; cleanliness checked as the thread user, dirty or
3954
- * unreadable kept — the sweep's rules), `df` re-probed after each; still
4020
+ * DISK_EVICT_MIN_IDLE_MS), whatever they hold — item 17: dirt never keeps a
4021
+ * tree; each is measured as its thread user for the eviction's record —
4022
+ * `df` re-probed after each; still
3955
4023
  * short → `503 {reason:"disk-pressure"}` with the whole math in `error`, the
3956
4024
  * same shape as `mirror-busy`, so the bot falls back cold legibly. Never a
3957
4025
  * lifecycle flip: the checkout is intact and every existing tree keeps
@@ -4021,12 +4089,10 @@ export class ResidentDO extends Sandbox<Env> {
4021
4089
  if (verdict.fits) break;
4022
4090
  const binding = live.find((b) => b.threadKey === c.threadKey);
4023
4091
  if (!binding) continue;
4024
- const clean = await this.worktreeCleanliness(binding);
4025
- if (!clean.clean) {
4026
- kept.push({ threadKey: c.threadKey, why: "dirty" });
4027
- continue;
4028
- }
4029
- // Same re-read guards as the sweep: the clean check awaited.
4092
+ // What the tree holds goes on the eviction's record (item 17), never
4093
+ // into a keep; the runtime is up — this attach is running on it.
4094
+ const tree = await this.measureTreeBeforeEviction(binding);
4095
+ // Same re-read guards as the sweep: the measurement awaited.
4030
4096
  const current = await this.ctx.storage.get<ThreadBinding>(threadBindingKey(c.threadKey));
4031
4097
  if (
4032
4098
  !current ||
@@ -4037,7 +4103,7 @@ export class ResidentDO extends Sandbox<Env> {
4037
4103
  kept.push({ threadKey: c.threadKey, why: "busy" });
4038
4104
  continue;
4039
4105
  }
4040
- if (!(await this.evictBinding(current, true, "disk-pressure", DISK_PRESSURE_REASON))) {
4106
+ if (!(await this.evictBinding(current, true, "disk-pressure", DISK_PRESSURE_REASON, tree))) {
4041
4107
  kept.push({ threadKey: c.threadKey, why: "other" });
4042
4108
  continue;
4043
4109
  }
@@ -6022,13 +6088,21 @@ export class ResidentDO extends Sandbox<Env> {
6022
6088
 
6023
6089
  /** Remove a thread's worktree (when the runtime is up — a slept container
6024
6090
  * already lost it) and release its pool user; the binding is KEPT, marked
6025
- * evicted, so the ref stays sticky and the next attach recreates the tree
6026
- * with it. Shared by the inactivity sweep and /detach. */
6091
+ * evicted with the cause (`evictedWhy`) and with what the tree held
6092
+ * (`evictedLeftBehind`, or `evictedUnmeasured` when git could not read it
6093
+ * — item 17: never a reason to keep the tree, never discarded silently), so
6094
+ * the ref stays sticky and the next attach recreates the tree with it.
6095
+ * Shared by the inactivity sweep, the reclamation pass, the disk-pressure
6096
+ * path and /detach. */
6027
6097
  private async evictBinding(
6028
6098
  binding: ThreadBinding,
6029
6099
  runtimeActive: boolean,
6030
6100
  logCtx: string,
6031
6101
  why: string,
6102
+ /** What the caller measured in the tree first (`measureTreeBeforeEviction`,
6103
+ * as the thread user with the runtime up); absent when the tree was clean,
6104
+ * already gone with the disk, or not measured (a force detach). */
6105
+ tree?: EvictedTree,
6032
6106
  ): Promise<boolean> {
6033
6107
  const threadDir = parentDir(binding.worktreePath);
6034
6108
  if (runtimeActive && threadDir.startsWith(`${THREADS_DIR}/`)) {
@@ -6064,7 +6138,10 @@ export class ResidentDO extends Sandbox<Env> {
6064
6138
  evicted: true,
6065
6139
  evictedAt: new Date(systemClock()).toISOString(),
6066
6140
  evictedWhy: why,
6141
+ evictedLeftBehind: tree && "leftBehind" in tree ? tree.leftBehind : undefined,
6142
+ evictedUnmeasured: tree && "unmeasured" in tree ? tree.unmeasured : undefined,
6067
6143
  } satisfies ThreadBinding);
6144
+ if (tree) console.log(`${logCtx}: ${binding.threadKey} evicted (${why}) — ${evictedTreeSentence(tree)}`);
6068
6145
  return true;
6069
6146
  }
6070
6147
 
@@ -6105,17 +6182,13 @@ export class ResidentDO extends Sandbox<Env> {
6105
6182
  if (left > 0) return { released: false, reason: busyAfterKillReason(left), user: binding.user };
6106
6183
  }
6107
6184
  const active = await this.isRuntimeActive().catch(() => false);
6108
- // What the tree still holds, for the answer — never a reason to keep it.
6109
- // Not measured on a force release: a read-only tree holds nothing, and a
6110
- // hard stop's tree is whatever the killed command left. A probe that
6111
- // fails names nothing (never a guess) and is a log line.
6112
- let leftBehind: LeftBehind | undefined;
6113
- if (!force && active) {
6114
- const measured = await this.worktreeCleanliness(binding);
6115
- leftBehind = leftBehindOf(measured);
6116
- if (!measured.clean && leftBehind === undefined)
6117
- console.log(`detach: ${threadKey}: the tree could not be measured before its release (${measured.reason})`);
6118
- }
6185
+ // What the tree still holds, for the answer and the eviction's record —
6186
+ // never a reason to keep it. Not measured on a force release: a read-only
6187
+ // tree holds nothing, and a hard stop's tree is whatever the killed
6188
+ // command left. A probe that fails names nothing in the answer (never a
6189
+ // guess); the record and the log say it could not be measured.
6190
+ let tree: EvictedTree | undefined;
6191
+ if (!force && active) tree = await this.measureTreeBeforeEviction(binding);
6119
6192
  // Re-check right before removal: the measurement above awaited (the DO
6120
6193
  // yields at each await), so an exec that arrived mid-detach would otherwise
6121
6194
  // have its tree removed under it.
@@ -6136,16 +6209,12 @@ export class ResidentDO extends Sandbox<Env> {
6136
6209
  // Same as the sweep: `active` was read before the measurement's awaits; a
6137
6210
  // container that woke meanwhile must get the rm, not an orphaned tree.
6138
6211
  const activeNow = await this.isRuntimeActive().catch(() => false);
6139
- if (!(await this.evictBinding(current, activeNow, `detach`, "detach"))) {
6212
+ if (!(await this.evictBinding(current, activeNow, `detach`, "detach", tree))) {
6140
6213
  return { released: false, reason: "re-attached during eviction — kept", user };
6141
6214
  }
6142
- if (leftBehind) {
6143
- console.log(
6144
- `detach: ${threadKey} released — left behind ${leftBehind.uncommittedChanges} uncommitted change(s) and ${leftBehind.unpushedCommits} unpushed commit(s), discarded with the tree`,
6145
- );
6146
- }
6147
6215
  // Item 55: the tree is gone; the gauge catches up at the next refresh
6148
6216
  // instance's `measure` step, and the admission's `df` sees the space now.
6217
+ const leftBehind = tree && "leftBehind" in tree ? tree.leftBehind : undefined;
6149
6218
  return { released: true, user, ...(leftBehind !== undefined ? { leftBehind } : {}) };
6150
6219
  }
6151
6220
 
@@ -6183,20 +6252,21 @@ export class ResidentDO extends Sandbox<Env> {
6183
6252
  }
6184
6253
  }
6185
6254
 
6186
- /** Is this thread's tree safe to destroy? The git probes run AS THE THREAD
6187
- * USER (su), never as root: the worktree is thread-owned, so root git in
6188
- * it would be refused by safe.directory and would be the exact repo-local-
6189
- * config execution vector safe.directory exists to block. Unknown (git
6190
- * failed) counts as NOT clean — never destroy work on a guess. A tree
6191
- * that no longer exists (disk recycled by a sleep/wake) has nothing to
6192
- * preserve: releasable, so a post-wake binding does not hold a pool user
6193
- * for 7 days on behalf of files that are already gone.
6255
+ /** What this thread's tree holds: the tracked changes and unpushed commits,
6256
+ * or that git could not read it. The git probes run AS THE THREAD USER
6257
+ * (su), never as root: the worktree is thread-owned, so root git in it
6258
+ * would be refused by safe.directory and would be the exact repo-local-
6259
+ * config execution vector safe.directory exists to block. Read by
6260
+ * `measureTreeBeforeEviction` alone, for the record of an eviction or of
6261
+ * the disk-full recycle — never a reason to keep a tree or a container
6262
+ * (item 17). A tree that no longer exists (disk recycled by a sleep/wake)
6263
+ * is clean with nothing measured: there was nothing to discard.
6194
6264
  *
6195
6265
  * ONE spawn: the presence test and both git probes fold
6196
6266
  * into the pure `worktreeCleanlinessScript` (test -d as root, both git
6197
6267
  * commands inside a single privilege-dropped `su`, tagged lines out);
6198
6268
  * `parseWorktreeCleanliness` encodes the exact decision table above. */
6199
- private async worktreeCleanliness(binding: ThreadBinding): Promise<{ clean: boolean; reason?: string }> {
6269
+ private async worktreeCleanliness(binding: ThreadBinding): Promise<WorktreeCleanliness> {
6200
6270
  const injected = { GIT_TERMINAL_PROMPT: "0" }; // same injection as threadRun — fail fast, never prompt
6201
6271
  validateEnvNames(injected);
6202
6272
  const r = await this.run(["sh", "-c", worktreeCleanlinessScript(binding.worktreePath, binding.user)], {
@@ -6206,6 +6276,15 @@ export class ResidentDO extends Sandbox<Env> {
6206
6276
  return parseWorktreeCleanliness(r);
6207
6277
  }
6208
6278
 
6279
+ /** What a tree holds right before its eviction — or before the disk-full
6280
+ * recycle discards it with the disk (item 54) — for the record and the log
6281
+ * (item 17): the one-spawn probe as the thread user, read once into
6282
+ * `evictedTreeOf` — never into a keep decision. Callers skip it when the
6283
+ * runtime is down: the disk, and the tree with it, is already gone. */
6284
+ private async measureTreeBeforeEviction(binding: ThreadBinding): Promise<EvictedTree | undefined> {
6285
+ return evictedTreeOf(await this.worktreeCleanliness(binding));
6286
+ }
6287
+
6209
6288
  /** Anything that must not be interrupted by a container stop or counted
6210
6289
  * as idle: thread exec/read/write, disposable /op runs, and attaches past
6211
6290
  * their own image check (mid clone/install under the mirror lock). */
@@ -6278,8 +6357,17 @@ export class ResidentDO extends Sandbox<Env> {
6278
6357
  // attach), and an eviction decided on a stale "inactive" would skip the
6279
6358
  // rm and orphan a real tree.
6280
6359
  const activeNow = await this.isRuntimeActive().catch(() => false);
6360
+ // What the tree holds goes on the eviction's record (item 17), measured
6361
+ // while the runtime is up; a slept container has no tree to measure.
6362
+ const tree = activeNow ? await this.measureTreeBeforeEviction(current) : undefined;
6281
6363
  if (
6282
- await this.evictBinding(current, activeNow, `worktree-sweep ${resource}`, last >= cutoff ? "clean-idle" : "ttl")
6364
+ await this.evictBinding(
6365
+ current,
6366
+ activeNow,
6367
+ `worktree-sweep ${resource}`,
6368
+ last >= cutoff ? "clean-idle" : "ttl",
6369
+ tree,
6370
+ )
6283
6371
  )
6284
6372
  evicted.push(binding.threadKey);
6285
6373
  else kept++;
@@ -6537,9 +6625,11 @@ export class ResidentDO extends Sandbox<Env> {
6537
6625
  * mirror (the refresh cycle's `fetch --prune` just ran) or its PR was
6538
6626
  * merged/closed. Runs inside the refresh cycle — a poll on the existing
6539
6627
  * cadence, since the GitHub App has webhooks off — and via /debug
6540
- * reclaim-now. Never touches the default branch, a busy thread, or a dirty
6541
- * tree (reclaimDecision); every keep is named. The eviction itself is the
6542
- * sweep's `evictBinding` with the same re-read guards. */
6628
+ * reclaim-now. Never touches the default branch or a busy thread
6629
+ * (reclaimDecision); every keep is named. What the tree holds is never a
6630
+ * reason to keep it (item 17) — it is measured once as the thread user and
6631
+ * recorded by the eviction, the sweep's `evictBinding` with the same
6632
+ * re-read guards. */
6543
6633
  async reclaimFinishedRefs(
6544
6634
  resource: string,
6545
6635
  defaultRef: string,
@@ -6599,21 +6689,16 @@ export class ResidentDO extends Sandbox<Env> {
6599
6689
  // by name), so its fate is never looked up.
6600
6690
  const { fate, detail } = (!isDefaultRef && fates.get(binding.ref)) || { fate: "unknown" as RefFate, detail: "" };
6601
6691
  const busy = this.threadOpsInFlight.get(binding.threadKey) ?? 0;
6602
- // The clean check runs as the thread user and only when it can decide
6603
- // anything: a finished ref, nothing running, runtime up (down → the tree
6604
- // is already gone with the disk → null).
6605
- const finished = fate === "gone" || fate === "merged" || fate === "closed";
6606
- const clean = !active
6607
- ? null
6608
- : finished && !isDefaultRef && busy === 0
6609
- ? (await this.worktreeCleanliness(binding)).clean
6610
- : null;
6611
- const decision = reclaimDecision({ fate, isDefaultRef, busy, clean });
6692
+ const decision = reclaimDecision({ fate, isDefaultRef, busy });
6612
6693
  if (!decision.reclaim) {
6613
6694
  kept.push({ threadKey: binding.threadKey, ref: binding.ref, why: decision.why });
6614
6695
  continue;
6615
6696
  }
6616
- // Same guards as the sweep: the clean check awaited, so re-read the
6697
+ // What the tree holds goes on the eviction's record (item 17), never
6698
+ // into the decision: measured as the thread user while the runtime is
6699
+ // up; down → the tree is already gone with the disk.
6700
+ const tree = active ? await this.measureTreeBeforeEviction(binding) : undefined;
6701
+ // Same guards as the sweep: the measurement awaited, so re-read the
6617
6702
  // binding (a re-attach means a fresh tree) and the op counter.
6618
6703
  const busyNow = this.threadOpsInFlight.get(binding.threadKey) ?? 0;
6619
6704
  const current = await this.ctx.storage.get<ThreadBinding>(threadBindingKey(binding.threadKey));
@@ -6627,7 +6712,7 @@ export class ResidentDO extends Sandbox<Env> {
6627
6712
  }
6628
6713
  const activeNow = await this.isRuntimeActive().catch(() => false);
6629
6714
  const why = `${decision.why}${detail}`;
6630
- if (await this.evictBinding(current, activeNow, `reclaim ${resource}`, why)) {
6715
+ if (await this.evictBinding(current, activeNow, `reclaim ${resource}`, why, tree)) {
6631
6716
  reclaimed.push({ threadKey: binding.threadKey, ref: binding.ref, why });
6632
6717
  console.log(`reclaim ${resource}: evicted ${binding.threadKey} on ${binding.ref} — ${why}`);
6633
6718
  } else kept.push({ threadKey: binding.threadKey, ref: binding.ref, why: "re-attached" });
@@ -6738,18 +6823,39 @@ export class ResidentDO extends Sandbox<Env> {
6738
6823
  // left out; nothing here is secret (credential files are never persisted).
6739
6824
  const threads = [...bindings.values()]
6740
6825
  .sort((a, b) => b.lastAttachAt.localeCompare(a.lastAttachAt))
6741
- .map(({ threadKey, ref, sha, user, deps, boundAt, lastAttachAt, evicted, evictedAt, evictedWhy }) => ({
6742
- threadKey,
6743
- ref,
6744
- sha: sha ?? null,
6745
- user,
6746
- deps: deps ?? null,
6747
- boundAt,
6748
- lastAttachAt,
6749
- evicted: evicted ?? false,
6750
- evictedAt: evictedAt ?? null,
6751
- evictedWhy: evictedWhy ?? null,
6752
- }));
6826
+ .map(
6827
+ ({
6828
+ threadKey,
6829
+ ref,
6830
+ sha,
6831
+ user,
6832
+ deps,
6833
+ boundAt,
6834
+ lastAttachAt,
6835
+ evicted,
6836
+ evictedAt,
6837
+ evictedWhy,
6838
+ evictedLeftBehind,
6839
+ evictedUnmeasured,
6840
+ recycledLeftBehind,
6841
+ recycledUnmeasured,
6842
+ }) => ({
6843
+ threadKey,
6844
+ ref,
6845
+ sha: sha ?? null,
6846
+ user,
6847
+ deps: deps ?? null,
6848
+ boundAt,
6849
+ lastAttachAt,
6850
+ evicted: evicted ?? false,
6851
+ evictedAt: evictedAt ?? null,
6852
+ evictedWhy: evictedWhy ?? null,
6853
+ evictedLeftBehind: evictedLeftBehind ?? null,
6854
+ evictedUnmeasured: evictedUnmeasured ?? null,
6855
+ recycledLeftBehind: recycledLeftBehind ?? null,
6856
+ recycledUnmeasured: recycledUnmeasured ?? null,
6857
+ }),
6858
+ );
6753
6859
  return {
6754
6860
  resource: map.get(RESOURCE_KEY) ?? null,
6755
6861
  state: map.get(STATE_KEY) ?? "down",
@@ -176,6 +176,17 @@ RUN npm install -g @earendil-works/pi-coding-agent@0.85.1 \
176
176
  && pi --version | grep -qx '0.85.1' \
177
177
  && pi --help | grep -q -- '--mode <mode>'
178
178
 
179
+ # OpenCode, the second harness (docs/reference/specs/harness.md): `@opencode/cli`
180
+ # at the EXACT pin held by src/deploy/imageOpenCodeHarness.test.ts, beside pi.
181
+ # The wrapper's postinstall hard-links the platform binary into place, so it
182
+ # is the one install script allowed by name; the grep proves the binary on
183
+ # PATH prints the pin's version text (`opencode v2.0.3`, exactly); the npm
184
+ # cache (a 200 MB tarball) is dropped in the same layer. Dark until a
185
+ # deployment names it: nothing here starts it on its own.
186
+ RUN npm install -g --allow-scripts=@opencode/cli @opencode/cli@2.0.3 \
187
+ && npm cache clean --force \
188
+ && opencode --version | grep -qx 'opencode v2.0.3'
189
+
179
190
  # The container outlives a crash of the SDK's container server (the
180
191
  # supervisor's header says why). The base image's ENTRYPOINT is tini running
181
192
  # the server, so one uncaught exception there ended the container; here tini