@coreplane/switchboard 1.235.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 (132) 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 +115 -56
  6. package/dist/assets/deploy/cloudflare-sandbox/Dockerfile +11 -0
  7. package/dist/assets/deploy/cloudflare-sandbox/worker.ts +68 -7
  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/coordinator/contract.ts +24 -4
  12. package/dist/assets/src/core/coordinator/driver.ts +31 -3
  13. package/dist/assets/src/core/runEvents.ts +36 -1
  14. package/dist/assets/src/core/runFriction.ts +1 -0
  15. package/dist/assets/src/core/ship/contract.ts +3 -1
  16. package/dist/assets/src/core/ship/coordinator.ts +213 -29
  17. package/dist/assets/src/core/trace/types.ts +5 -0
  18. package/dist/assets/src/execution/residentDisk.ts +21 -10
  19. package/dist/assets/src/execution/sandboxErrors.ts +72 -0
  20. package/dist/assets/src/execution/sandboxStart.ts +90 -0
  21. package/dist/assets/web/dist/.vite/manifest.json +437 -423
  22. package/dist/assets/web/dist/assets/{AppShell-lYVcj-k7.js → AppShell-DZdz_sl7.js} +1 -1
  23. package/dist/assets/web/dist/assets/{CostsPage-D-v8am88.js → CostsPage-BfJsol9y.js} +1 -1
  24. package/dist/assets/web/dist/assets/{DeliveryPage-CvlWP7Eq.js → DeliveryPage-CvInfu7y.js} +1 -1
  25. package/dist/assets/web/dist/assets/{NotFoundPage-BzWS4Ca1.js → NotFoundPage-d37EP8hS.js} +1 -1
  26. package/dist/assets/web/dist/assets/ResidentDetailPage-DQSD0SZD.js +1 -0
  27. package/dist/assets/web/dist/assets/ResidentsIndexPage-CZUGLen6.js +1 -0
  28. package/dist/assets/web/dist/assets/RunFoldRow-Bngn4GjF.js +9 -0
  29. package/dist/assets/web/dist/assets/{RunRoutePage-oN9GkVEA.js → RunRoutePage-a-tMrirB.js} +3 -3
  30. package/dist/assets/web/dist/assets/RunsIndexPage-s0VKmRda.js +1 -0
  31. package/dist/assets/web/dist/assets/{RunsTabs-DEICQ4FY.js → RunsTabs-CyCdOhJ2.js} +1 -1
  32. package/dist/assets/web/dist/assets/ScheduledPage-ByCmyrDs.js +1 -0
  33. package/dist/assets/web/dist/assets/SettingsPage-DhKEokGK.js +1 -0
  34. package/dist/assets/web/dist/assets/{StatusDot-D14iJMy7.js → StatusDot-D-BPSPpw.js} +1 -1
  35. package/dist/assets/web/dist/assets/{Tooltip-B0Ob5MQ4.js → Tooltip-DPDx5tXT.js} +1 -1
  36. package/dist/assets/web/dist/assets/UnitRoutePage-0eiekTHQ.js +1 -0
  37. package/dist/assets/web/dist/assets/{angular-html-Cw130Zyi.js → angular-html-Ni1Xil2G.js} +1 -1
  38. package/dist/assets/web/dist/assets/{angular-ts-DY1m4C2s.js → angular-ts-oCgo6p05.js} +1 -1
  39. package/dist/assets/web/dist/assets/{apl-W6Ty7v05.js → apl-CiWYl0gh.js} +1 -1
  40. package/dist/assets/web/dist/assets/{astro-DlLVHwVk.js → astro-B-VeKZ_J.js} +1 -1
  41. package/dist/assets/web/dist/assets/{blade-Bco3tDh4.js → blade-BIWEISKd.js} +1 -1
  42. package/dist/assets/web/dist/assets/{c-1LaFY_cj.js → c-DHxA5Byp.js} +1 -1
  43. package/dist/assets/web/dist/assets/{chapel-B-OvrOL5.js → chapel-D4qGLw-Q.js} +1 -1
  44. package/dist/assets/web/dist/assets/{cobol-Ch3EPlHu.js → cobol-BznX5Q-u.js} +1 -1
  45. package/dist/assets/web/dist/assets/{coffee-hZuITnto.js → coffee-CVr8Idlj.js} +1 -1
  46. package/dist/assets/web/dist/assets/{cpp-B8iRVo8m.js → cpp-wrk6NfW4.js} +1 -1
  47. package/dist/assets/web/dist/assets/{crystal-DKveI7lr.js → crystal-CGYWMeH1.js} +1 -1
  48. package/dist/assets/web/dist/assets/{css-_2_CD-rr.js → css-BgGytMsy.js} +1 -1
  49. package/dist/assets/web/dist/assets/{dist-pOdUzj6D.js → dist-DSkVB16z.js} +2 -2
  50. package/dist/assets/web/dist/assets/durationTone-DYogEPEk.js +1 -0
  51. package/dist/assets/web/dist/assets/{edge-BenMDPct.js → edge-CdR6AU9v.js} +1 -1
  52. package/dist/assets/web/dist/assets/{elixir-CBbdEqG6.js → elixir-CQRb3PZx.js} +1 -1
  53. package/dist/assets/web/dist/assets/{elm-DMub_2lY.js → elm-CpuA6W8B.js} +1 -1
  54. package/dist/assets/web/dist/assets/{erb-CI9He_Up.js → erb-CvlrblqH.js} +1 -1
  55. package/dist/assets/web/dist/assets/{favicon-BQsePYv5.js → favicon-CSt-qDvc.js} +1 -1
  56. package/dist/assets/web/dist/assets/{git-rebase-BBfhnyF8.js → git-rebase-COnovCVc.js} +1 -1
  57. package/dist/assets/web/dist/assets/{glimmer-js-CR9Fxrau.js → glimmer-js-C-DS4g_J.js} +1 -1
  58. package/dist/assets/web/dist/assets/{glimmer-ts-CwFwX_XS.js → glimmer-ts-DTkorWRI.js} +1 -1
  59. package/dist/assets/web/dist/assets/{glsl-Daiodp1r.js → glsl-CH51-JW7.js} +1 -1
  60. package/dist/assets/web/dist/assets/{graphql-DZvrsEkB.js → graphql-BO-XymNv.js} +1 -1
  61. package/dist/assets/web/dist/assets/{hack-BaXOzdg7.js → hack-C-N0oicf.js} +1 -1
  62. package/dist/assets/web/dist/assets/{haml-CMskq_VA.js → haml-BfmdtWoP.js} +1 -1
  63. package/dist/assets/web/dist/assets/{handlebars-Dhw_f7jD.js → handlebars-BqhO_te-.js} +1 -1
  64. package/dist/assets/web/dist/assets/{html-C9k99z0z.js → html-DUEZlTKV.js} +1 -1
  65. package/dist/assets/web/dist/assets/{html-derivative-VckPItXN.js → html-derivative-DOUJTS8A.js} +1 -1
  66. package/dist/assets/web/dist/assets/{http-DXU_3h9v.js → http-j7UH0Cqv.js} +1 -1
  67. package/dist/assets/web/dist/assets/{hurl-KU24JNdp.js → hurl-D6ZaBLJk.js} +1 -1
  68. package/dist/assets/web/dist/assets/indexRow-BjXBzI5Z.js +1 -0
  69. package/dist/assets/web/dist/assets/{java-BbZjTNgf.js → java-dugWx3wi.js} +1 -1
  70. package/dist/assets/web/dist/assets/{javascript-DXCdqcZZ.js → javascript-CK4GC4JO.js} +1 -1
  71. package/dist/assets/web/dist/assets/{jinja-pDlsjTVp.js → jinja-DrLKIyKG.js} +1 -1
  72. package/dist/assets/web/dist/assets/{jison-DsXDk66e.js → jison-CS1Th-TO.js} +1 -1
  73. package/dist/assets/web/dist/assets/{json-tCxXfRgG.js → json-CkBqdVys.js} +1 -1
  74. package/dist/assets/web/dist/assets/{jsx-B0ZYkCmi.js → jsx-CCxdg7n3.js} +1 -1
  75. package/dist/assets/web/dist/assets/{julia-BLzzeI6x.js → julia-BalKW2hA.js} +1 -1
  76. package/dist/assets/web/dist/assets/{just-DvD2_60c.js → just-BKw1G40D.js} +1 -1
  77. package/dist/assets/web/dist/assets/{latex-511zn3h7.js → latex-Dfad6ZN-.js} +1 -1
  78. package/dist/assets/web/dist/assets/{liquid-BD0BkMox.js → liquid-f_CMGBvd.js} +1 -1
  79. package/dist/assets/web/dist/assets/{indexFormat-B-pd4r7Y.js → localIso-CNA-bhS-.js} +1 -1
  80. package/dist/assets/web/dist/assets/{lua-NFicKm6N.js → lua-BcMK_uKM.js} +1 -1
  81. package/dist/assets/web/dist/assets/main-B77vAZVg.css +1 -0
  82. package/dist/assets/web/dist/assets/main-DR8iKpNt.js +28 -0
  83. package/dist/assets/web/dist/assets/{marko-1-G_nEXK.js → marko-B0oNLx2i.js} +1 -1
  84. package/dist/assets/web/dist/assets/{mdc-DUe3AQLp.js → mdc-Bc_MmIyq.js} +1 -1
  85. package/dist/assets/web/dist/assets/{nginx-Dg379_bA.js → nginx-Cmxv8RIt.js} +1 -1
  86. package/dist/assets/web/dist/assets/{nim-BnFq97ZO.js → nim-BtgRfaY0.js} +1 -1
  87. package/dist/assets/web/dist/assets/{org-F0uiwGvq.js → org-Dl9EzlHh.js} +1 -1
  88. package/dist/assets/web/dist/assets/{perl-B0euczkl.js → perl-BmF7x7BB.js} +1 -1
  89. package/dist/assets/web/dist/assets/{php-DkL3k_n7.js → php-rH6CVptQ.js} +1 -1
  90. package/dist/assets/web/dist/assets/{pug-L_OjjZP4.js → pug-CIN5Ccck.js} +1 -1
  91. package/dist/assets/web/dist/assets/{qml-BMjB00Zz.js → qml-BpLS3RFr.js} +1 -1
  92. package/dist/assets/web/dist/assets/{r-DoeLdnqR.js → r-BUMF3B-H.js} +1 -1
  93. package/dist/assets/web/dist/assets/{razor-BzYNkAWy.js → razor-B7sTBPdT.js} +1 -1
  94. package/dist/assets/web/dist/assets/{regexp-CPmElMk3.js → regexp-h2SvwrsJ.js} +1 -1
  95. package/dist/assets/web/dist/assets/residentsModel-VSJEepL1.js +1 -0
  96. package/dist/assets/web/dist/assets/{rst-DrsjgfI7.js → rst-DLOWEsD2.js} +1 -1
  97. package/dist/assets/web/dist/assets/{ruby-cB42ppvx.js → ruby-Cl_-I4k3.js} +1 -1
  98. package/dist/assets/web/dist/assets/{sas-R5Y3NM_I.js → sas-Cm4M-BvV.js} +1 -1
  99. package/dist/assets/web/dist/assets/{scss-C-zWxV9s.js → scss-CEFCEQUF.js} +1 -1
  100. package/dist/assets/web/dist/assets/{shellscript-oJF96aAU.js → shellscript-CE6zb5eS.js} +1 -1
  101. package/dist/assets/web/dist/assets/{shellsession-CznECKyi.js → shellsession-B4fDVzrM.js} +1 -1
  102. package/dist/assets/web/dist/assets/{soy-wjHLSRag.js → soy-DwXCMpjE.js} +1 -1
  103. package/dist/assets/web/dist/assets/{sql-C_BM8IOW.js → sql-DrnAKnyD.js} +1 -1
  104. package/dist/assets/web/dist/assets/{stata-D31HO8aQ.js → stata-BzlwPWD6.js} +1 -1
  105. package/dist/assets/web/dist/assets/{surrealql-CKCLyplA.js → surrealql-CeUieZXd.js} +1 -1
  106. package/dist/assets/web/dist/assets/{svelte-3geWk2iu.js → svelte-BERIpIKR.js} +1 -1
  107. package/dist/assets/web/dist/assets/{templ-DiJr_hTD.js → templ-6FSuUDwg.js} +1 -1
  108. package/dist/assets/web/dist/assets/{tex-BEJGWyeF.js → tex-SWCyMwNX.js} +1 -1
  109. package/dist/assets/web/dist/assets/{ts-tags-C8Mzdpxx.js → ts-tags-VWj7gWgY.js} +1 -1
  110. package/dist/assets/web/dist/assets/{tsx-UQiSm_p3.js → tsx-B6dzfL8L.js} +1 -1
  111. package/dist/assets/web/dist/assets/{twig-_Pvzkeo9.js → twig-BDXsr482.js} +1 -1
  112. package/dist/assets/web/dist/assets/{typescript-BWMkINKK.js → typescript-s100Dt8y.js} +1 -1
  113. package/dist/assets/web/dist/assets/{typst-Bdg9m-e7.js → typst-WfyEAUdt.js} +1 -1
  114. package/dist/assets/web/dist/assets/{vue-CDZrrAZ8.js → vue-CkXOOJ-6.js} +1 -1
  115. package/dist/assets/web/dist/assets/{vue-html-tAlpNhfN.js → vue-html-CEpzdcau.js} +1 -1
  116. package/dist/assets/web/dist/assets/{vue-vine-C27UF_rh.js → vue-vine-D9dsfiFA.js} +1 -1
  117. package/dist/assets/web/dist/assets/{xml-CxeDr9Zh.js → xml-iBDofbJq.js} +1 -1
  118. package/dist/assets/web/dist/assets/{xsl-Bpi0uUnM.js → xsl-CqbJF4hD.js} +1 -1
  119. package/dist/assets/web/dist/assets/{yaml-BtUgZ1GN.js → yaml-D9i6OxYe.js} +1 -1
  120. package/dist/cli.js +2255 -1170
  121. package/package.json +1 -1
  122. package/dist/assets/web/dist/assets/ResidentDetailPage-DSpsye5e.js +0 -1
  123. package/dist/assets/web/dist/assets/ResidentsIndexPage-CT8fI1M3.js +0 -1
  124. package/dist/assets/web/dist/assets/RunFoldRow-Cqh8pTXC.js +0 -9
  125. package/dist/assets/web/dist/assets/RunsIndexPage-sjXr8iCV.js +0 -1
  126. package/dist/assets/web/dist/assets/ScheduledPage-Tz__zLzi.js +0 -1
  127. package/dist/assets/web/dist/assets/UnitRoutePage-CbKCL58v.js +0 -1
  128. package/dist/assets/web/dist/assets/indexRow-Bnj883ii.js +0 -1
  129. package/dist/assets/web/dist/assets/localIso-L06jV29p.js +0 -1
  130. package/dist/assets/web/dist/assets/main-D3lG-yQ9.js +0 -28
  131. package/dist/assets/web/dist/assets/main-Dm11o0hc.css +0 -1
  132. package/dist/assets/web/dist/assets/residentDiskBudget-BMBKlYRH.js +0 -1
@@ -87,6 +87,17 @@ RUN npm install -g @earendil-works/pi-coding-agent@0.85.1 \
87
87
  && pi --version | grep -qx '0.85.1' \
88
88
  && pi --help | grep -q -- '--mode <mode>'
89
89
 
90
+ # OpenCode, the second harness (docs/reference/specs/harness.md): `@opencode/cli`
91
+ # at the EXACT pin held by src/deploy/imageOpenCodeHarness.test.ts, beside pi.
92
+ # The wrapper's postinstall hard-links the platform binary into place, so it
93
+ # is the one install script allowed by name; the grep proves the binary on
94
+ # PATH prints the pin's version text (`opencode v2.0.3`, exactly); the npm
95
+ # cache (a 200 MB tarball) is dropped in the same layer. Dark until a
96
+ # deployment names it: nothing here starts it on its own.
97
+ RUN npm install -g --allow-scripts=@opencode/cli @opencode/cli@2.0.3 \
98
+ && npm cache clean --force \
99
+ && opencode --version | grep -qx 'opencode v2.0.3'
100
+
90
101
  # Non-root user; agents run bash with this user's (container-scoped) permissions.
91
102
  RUN useradd -m -u 1001 switchboard
92
103
  WORKDIR /app
@@ -311,10 +311,15 @@ workspaceDir: ./workspaces
311
311
  # them the request is refused naming what is missing. Per unit: at most
312
312
  # `maxRounds` review rounds and `maxMinutes` minutes of wall clock — whichever
313
313
  # hits first ends the unit, and each child round's own budget is clipped to the
314
- # remaining time. `maxMinutes` is the ship preset's declared budget: a channel or
315
- # user `boundary.maxMinutes` and a per-message `budget:` directive clip it like
316
- # any preset's, and the card says so. Defaults shown; both must be integers >= 1;
317
- # any other key under `ship` fails the load by name.
314
+ # remaining time. The pipeline is budgeted for the loop, not one round: the
315
+ # coding child's directive is additionally capped to leave two review rounds and
316
+ # one merge poll (11 minutes) on the clock, so a child that uses its whole
317
+ # budget still hands the review its time — which is why `maxMinutes` must be an
318
+ # integer >= 14 (the loop's reserve plus one round). `maxMinutes` is the ship
319
+ # preset's declared budget: a channel or user `boundary.maxMinutes` and a
320
+ # per-message `budget:` directive clip it like any preset's, and the card says
321
+ # so. Defaults shown; `maxRounds` must be an integer >= 1; any other key under
322
+ # `ship` fails the load by name.
318
323
  # ship:
319
324
  # maxRounds: 3 # review rounds per unit
320
325
  # maxMinutes: 120 # the ship preset's wall-clock budget in minutes (the registry's default)
@@ -1807,6 +1807,18 @@ export class RunHistoryDO extends DurableObject<Env> {
1807
1807
  this.ctx.storage.transactionSync(() => {
1808
1808
  result = this.upsertInTransaction(record, proposal);
1809
1809
  });
1810
+ // A coordinator child closed OUTSIDE the ledger's finish — the run loop or
1811
+ // a reclaim writing an `interrupted` record, the pi harness's typed restart,
1812
+ // a resume abandoning a lost workspace — still wakes its parent's wait at
1813
+ // once (run-history item 47): the same `run-finished-<runId>` event rides
1814
+ // this commit. The tombstone a run writes at its start is excluded (its
1815
+ // `finishedAt` equals `startedAt`); the parent confirms by `read-record`
1816
+ // before it acts, so a duplicate send is harmless.
1817
+ if (result.stored && record.parentInstanceId !== undefined && record.finishedAt > record.startedAt) {
1818
+ const event = await sendRunFinished(this.env.SHIP_COORDINATOR, record);
1819
+ if (event.kind === "failed")
1820
+ console.warn(`[runs/put] ${record.id} → ${event.type} not delivered to ${event.instance}: ${event.reason}`);
1821
+ }
1810
1822
  if ((await this.ctx.storage.getAlarm()) === null)
1811
1823
  await this.ctx.storage.setAlarm(systemClock() + RUN_SWEEP_INTERVAL_MS);
1812
1824
  return result;
@@ -201,6 +201,18 @@ RUN npm install -g @earendil-works/pi-coding-agent@0.85.1 \
201
201
  && pi --version | grep -qx '0.85.1' \
202
202
  && pi --help | grep -q -- '--mode <mode>'
203
203
 
204
+ # OpenCode, the second harness (docs/reference/specs/harness.md): `@opencode/cli`
205
+ # at the EXACT pin held by src/deploy/imageOpenCodeHarness.test.ts, beside pi.
206
+ # The wrapper's postinstall hard-links the platform binary into place, so it
207
+ # is the one install script allowed by name; the grep proves the binary on
208
+ # PATH prints the pin's version text (`opencode v2.0.3`, exactly); the npm
209
+ # cache (a 200 MB tarball) is dropped in the same layer, and the proof runs
210
+ # again as worker1 below. Dark until a deployment names it: nothing here
211
+ # starts it on its own.
212
+ RUN npm install -g --allow-scripts=@opencode/cli @opencode/cli@2.0.3 \
213
+ && npm cache clean --force \
214
+ && opencode --version | grep -qx 'opencode v2.0.3'
215
+
204
216
  # Agent commands never run as root (repo code runs unprivileged, one user per
205
217
  # thread — docs/reference/specs/resident-repos.md). The SDK's exec has no user/uid option
206
218
  # (verified against 0.13.0-next.751.1), so the container server itself runs as
@@ -230,6 +242,12 @@ RUN su -s /bin/bash worker1 -c "playwright screenshot --viewport-size=640,480 'd
230
242
  # own, named by PI_CODING_AGENT_DIR at start).
231
243
  RUN su -s /bin/bash worker1 -c "pi --version | grep -qx '0.85.1'"
232
244
 
245
+ # OpenCode once more, as worker1: a run's server is started as the thread's
246
+ # user through the same `su`, so the BUILD proves an unprivileged user finds
247
+ # the pin on PATH and the binary runs without a writable home (its roots are
248
+ # the run's own, named by the XDG variables at start).
249
+ RUN su -s /bin/bash worker1 -c "opencode --version | grep -qx 'opencode v2.0.3'"
250
+
233
251
  # bun once more, as worker1: a thread's `bun install` runs as the thread's user
234
252
  # through the same `su`, whose PATH is /etc/environment's rather than this
235
253
  # file's — so the BUILD proves that PATH resolves the pin, not the base's bun.
@@ -505,16 +505,18 @@ const FORCE_DETACH_KILL_TIMEOUT_MS = 2_000;
505
505
  * binding record is KEPT so the next attach recreates with the same
506
506
  * ref. Overridable per resident via the onboard-time `worktreeTtlDays`. */
507
507
  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. */
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. */
512
512
  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). */
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. */
518
520
  const IDLE_AFTER_S = 60 * 60;
519
521
  /** LRU eviction floor: an over-cap onboard with `evictColdest:true` may
520
522
  * offboard the coldest eligible warm resident, but never one whose last
@@ -1037,6 +1039,15 @@ interface ThreadBinding {
1037
1039
  /** Why the tree could not be measured before that eviction (git could not
1038
1040
  * read it) — so an unreadable tree is never recorded as clean. */
1039
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;
1040
1051
  /** How deps were last materialized (evidence that the per-branch reconciliation ran). */
1041
1052
  deps?: ThreadDepsMechanism;
1042
1053
  /** Commit the worktree was last attached at (the ref's tip in the mirror
@@ -2945,12 +2956,11 @@ export class ResidentDO extends Sandbox<Env> {
2945
2956
  throw new CycleRestartError("image-stale-restart");
2946
2957
  }
2947
2958
 
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
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
2950
2961
  // 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.
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.
2954
2964
  // Only a SETTLED resident may park: a cycle that finds `refreshing`/
2955
2965
  // `restoring` at entry is looking at a marker left by a cycle that died
2956
2966
  // mid-flight (a deploy evicting the DO: stuck `refreshing` + parked →
@@ -3002,7 +3012,7 @@ export class ResidentDO extends Sandbox<Env> {
3002
3012
  await this.ctx.storage.delete(DEGRADED_STREAK_KEY);
3003
3013
  }
3004
3014
  if (settled && (await this.isIdle())) {
3005
- // isIdle awaited (git status per live tree) — re-read before writing.
3015
+ // isIdle awaited (the bindings listing) — re-read before writing.
3006
3016
  const now = (await this.ctx.storage.get<RepoFacts>(FACTS_KEY)) ?? facts;
3007
3017
  if (!now.idleSince)
3008
3018
  await this.ctx.storage.put(FACTS_KEY, {
@@ -3798,15 +3808,24 @@ export class ResidentDO extends Sandbox<Env> {
3798
3808
  });
3799
3809
  }
3800
3810
 
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();
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 {
3808
3821
  const recent = systemClock() - IDLE_AFTER_S * 1000;
3809
- if (live.some((b) => Date.parse(b.lastAttachAt) >= recent)) return false;
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;
3810
3829
  return this.inFlightCount() === 0;
3811
3830
  }
3812
3831
 
@@ -3816,17 +3835,6 @@ export class ResidentDO extends Sandbox<Env> {
3816
3835
  return [...all.values()].filter((b) => !b.evicted && b.user);
3817
3836
  }
3818
3837
 
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
3838
  // -- disk-full (docs/reference/specs/resident-repos.md item 54) ---------------------------
3831
3839
 
3832
3840
  /** Free space on the workspace mount in KiB; `null` when df cannot answer.
@@ -3852,28 +3860,54 @@ export class ResidentDO extends Sandbox<Env> {
3852
3860
 
3853
3861
  /** The disk is a cache: stop the container so the next cycle attempt
3854
3862
  * 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. */
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. */
3861
3876
  private async recoverFromDiskFull(reason: string, selfInFlight: number): Promise<boolean> {
3862
3877
  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}`);
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}`);
3872
3889
  return false;
3873
- }
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);
3874
3907
  console.log(
3875
3908
  `disk-full: recycling the container — the next cycle restores mirror + checkout from R2 onto an empty disk (${reason})`,
3876
3909
  );
3910
+ for (const [binding, tree] of trees) await this.recordRecycledTree(binding, tree);
3877
3911
  await this.ctx.storage.put(DISK_FULL_RECYCLE_KEY, systemClock());
3878
3912
  await this.recordRefreshError(`${reason} — container recycled; restoring from R2 on the next cycle`);
3879
3913
  this.swapIncarnation(); // deliberate incarnation swap
@@ -3881,6 +3915,27 @@ export class ResidentDO extends Sandbox<Env> {
3881
3915
  return true;
3882
3916
  }
3883
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
+
3884
3939
  // -- disk budget (docs/reference/specs/resident-repos.md item 55) -------------------------
3885
3940
 
3886
3941
  /** The `df` half: total/used/free of the workspace mount; null when df
@@ -6202,11 +6257,10 @@ export class ResidentDO extends Sandbox<Env> {
6202
6257
  * (su), never as root: the worktree is thread-owned, so root git in it
6203
6258
  * would be refused by safe.directory and would be the exact repo-local-
6204
6259
  * 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.
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.
6210
6264
  *
6211
6265
  * ONE spawn: the presence test and both git probes fold
6212
6266
  * into the pure `worktreeCleanlinessScript` (test -d as root, both git
@@ -6222,7 +6276,8 @@ export class ResidentDO extends Sandbox<Env> {
6222
6276
  return parseWorktreeCleanliness(r);
6223
6277
  }
6224
6278
 
6225
- /** What a tree holds right before its eviction, for the record and the log
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
6226
6281
  * (item 17): the one-spawn probe as the thread user, read once into
6227
6282
  * `evictedTreeOf` — never into a keep decision. Callers skip it when the
6228
6283
  * runtime is down: the disk, and the tree with it, is already gone. */
@@ -6782,6 +6837,8 @@ export class ResidentDO extends Sandbox<Env> {
6782
6837
  evictedWhy,
6783
6838
  evictedLeftBehind,
6784
6839
  evictedUnmeasured,
6840
+ recycledLeftBehind,
6841
+ recycledUnmeasured,
6785
6842
  }) => ({
6786
6843
  threadKey,
6787
6844
  ref,
@@ -6795,6 +6852,8 @@ export class ResidentDO extends Sandbox<Env> {
6795
6852
  evictedWhy: evictedWhy ?? null,
6796
6853
  evictedLeftBehind: evictedLeftBehind ?? null,
6797
6854
  evictedUnmeasured: evictedUnmeasured ?? null,
6855
+ recycledLeftBehind: recycledLeftBehind ?? null,
6856
+ recycledUnmeasured: recycledUnmeasured ?? null,
6798
6857
  }),
6799
6858
  );
6800
6859
  return {
@@ -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
@@ -44,6 +44,7 @@ import {
44
44
  } from "../../src/execution/sandboxLifecycle.js";
45
45
  import { envFromRequest } from "../../src/execution/sandboxEnv.js";
46
46
  import { IdleGuard, type IdleGuardHost } from "../../src/execution/sandboxIdle.js";
47
+ import { StartGate, type StartGateHost, type StartingCause } from "../../src/execution/sandboxStart.js";
47
48
  import { RUNTIME_REPLACEMENT_WORDING, isRuntimeUnreachableSignal } from "../../src/execution/residentRefresh.js";
48
49
  import {
49
50
  fleetBusyAnswer,
@@ -53,6 +54,8 @@ import {
53
54
  runtimeUnreachableAnswer,
54
55
  runtimeUnreachableExecAnswer,
55
56
  SandboxRuntimeUnreachableError,
57
+ sandboxStartingAnswer,
58
+ sandboxStartingExecAnswer,
56
59
  thrownShape,
57
60
  thrownText,
58
61
  } from "../../src/execution/sandboxErrors.js";
@@ -167,6 +170,12 @@ interface FileRefusal {
167
170
  reason?: string;
168
171
  }
169
172
 
173
+ /** The SDK's own limit on the warm-up command (`true`): generous, since the
174
+ * SDK's start — an instance grant up to 30 s, the runtime's port up to 90 s
175
+ * by its defaults — happens inside this one call. The executor's wait for
176
+ * the `sandbox-starting` token is bounded on its own side. */
177
+ const WARM_UP_BACKSTOP_MS = 4 * 60_000;
178
+
170
179
  /** Durable Object storage key of the idle ledger's served-time. */
171
180
  const IDLE_LEDGER_KEY = "switchboard.idle.lastServedAt";
172
181
  /** The scheduled-callback name of the idle sweep (a method below). */
@@ -185,10 +194,12 @@ export class SwitchboardSandbox extends Sandbox<Env> {
185
194
  sleepAfter = SANDBOX_SLEEP_AFTER;
186
195
 
187
196
  private readonly idle: IdleGuard;
197
+ private readonly gate: StartGate;
188
198
 
189
199
  constructor(...args: ConstructorParameters<typeof Sandbox<Env>>) {
190
200
  super(...args);
191
201
  this.idle = new IdleGuard(this.idleHost());
202
+ this.gate = new StartGate(this.startHost());
192
203
  // Every wake, including the one the SDK's own alarm causes on a leaked
193
204
  // container: the baseline is read and a sweep armed before any request.
194
205
  this.ctx.blockConcurrencyWhile(() => this.idle.wake());
@@ -215,6 +226,25 @@ export class SwitchboardSandbox extends Sandbox<Env> {
215
226
  };
216
227
  }
217
228
 
229
+ /** The start gate's view of this object (docs/reference/specs/execution.md
230
+ * item 23): the platform's running flag, and a warm-up that is one trivial
231
+ * command through the SDK — which does the start (instance grant, image
232
+ * pull, boot, the runtime's port) and answers when the runtime does. The
233
+ * warm-up runs inside the idle ledger so the guard sees a request in flight
234
+ * for as long as the start takes. */
235
+ private startHost(): StartGateHost {
236
+ return {
237
+ now: systemClock,
238
+ containerRunning: () => this.ctx.container?.running,
239
+ warmUp: () =>
240
+ this.idle.served(async () => {
241
+ const proc = await createExtensionProcessSandbox(this).exec(["true"], { timeout: WARM_UP_BACKSTOP_MS });
242
+ await proc.output({ encoding: "utf8", timeout: WARM_UP_BACKSTOP_MS });
243
+ }),
244
+ log: (event) => console.log(JSON.stringify({ ...event, thread: this.ctx.id.name ?? this.ctx.id.toString() })),
245
+ };
246
+ }
247
+
218
248
  /** The idle sweep, scheduled by the guard through the SDK's schedule table
219
249
  * and re-armed by it while a container is running. */
220
250
  async idleSweep(): Promise<void> {
@@ -240,7 +270,19 @@ export class SwitchboardSandbox extends Sandbox<Env> {
240
270
  execTimeoutSecs: number,
241
271
  envVars: Record<string, string>,
242
272
  ): Promise<ExecAnswer | ExecFailure> {
243
- return this.idle.served(() => this.execute(command, execTimeoutSecs, envVars));
273
+ const startedAt = systemClock();
274
+ return this.idle.served(async () => {
275
+ try {
276
+ return await this.gate.through(
277
+ () => this.execute(command, execTimeoutSecs, envVars),
278
+ (cause) => sandboxStartingExecAnswer(cause),
279
+ );
280
+ } catch (err) {
281
+ // The warm-up's own failure, handed on by the gate: a full fleet or a
282
+ // silent control port keeps its name; anything else propagates.
283
+ return this.execFailure(err, startedAt);
284
+ }
285
+ });
244
286
  }
245
287
 
246
288
  /** `runCommand` without the ledger entry: the body, and the internal caller
@@ -337,9 +379,19 @@ export class SwitchboardSandbox extends Sandbox<Env> {
337
379
  }
338
380
  }
339
381
 
382
+ /** A file route's answer while the container starts: HTTP 503 with the
383
+ * token, like a full fleet's. */
384
+ private startingRefusal(cause: StartingCause): FileRefusal {
385
+ const { error, reason } = sandboxStartingAnswer(cause);
386
+ return { error, status: 503, reason };
387
+ }
388
+
340
389
  async readText(path: string): Promise<{ content: string } | FileRefusal> {
341
390
  return this.idle.served(() =>
342
- this.fileOp(async () => ({ content: (await this.readFile(path, { encoding: "utf-8" })).content })),
391
+ this.gate.through(
392
+ () => this.fileOp(async () => ({ content: (await this.readFile(path, { encoding: "utf-8" })).content })),
393
+ (cause) => this.startingRefusal(cause),
394
+ ),
343
395
  );
344
396
  }
345
397
 
@@ -348,7 +400,12 @@ export class SwitchboardSandbox extends Sandbox<Env> {
348
400
  * decoded bytes to it — an SDK read that came back short would otherwise
349
401
  * pass as the file. A file over the cap is refused by name inside a 200. */
350
402
  async readBase64(path: string): Promise<Base64ReadAnswer | FileRefusal> {
351
- return this.idle.served(() => this.readBase64Now(path));
403
+ return this.idle.served(() =>
404
+ this.gate.through(
405
+ () => this.readBase64Now(path),
406
+ (cause) => this.startingRefusal(cause),
407
+ ),
408
+ );
352
409
  }
353
410
 
354
411
  private async readBase64Now(path: string): Promise<Base64ReadAnswer | FileRefusal> {
@@ -377,10 +434,14 @@ export class SwitchboardSandbox extends Sandbox<Env> {
377
434
 
378
435
  async write(path: string, content: string): Promise<{ ok: true } | FileRefusal> {
379
436
  return this.idle.served(() =>
380
- this.fileOp(async () => {
381
- await this.writeFile(path, content);
382
- return { ok: true as const };
383
- }),
437
+ this.gate.through(
438
+ () =>
439
+ this.fileOp(async () => {
440
+ await this.writeFile(path, content);
441
+ return { ok: true as const };
442
+ }),
443
+ (cause) => this.startingRefusal(cause),
444
+ ),
384
445
  );
385
446
  }
386
447
  }