@coreplane/switchboard 1.251.0 → 1.253.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 (167) hide show
  1. package/dist/assets/config/config.example.yaml +28 -2
  2. package/dist/assets/deploy/cloudflare/preflight.mjs +19 -21
  3. package/dist/assets/deploy/cloudflare/worker.ts +6 -3
  4. package/dist/assets/deploy/cloudflare-memory/runMetricsSink.ts +31 -0
  5. package/dist/assets/deploy/cloudflare-memory/worker.ts +223 -38
  6. package/dist/assets/deploy/cloudflare-memory/wrangler.template.jsonc +12 -0
  7. package/dist/assets/deploy/cloudflare-resident/memoryGuard.ts +212 -0
  8. package/dist/assets/deploy/cloudflare-resident/refresh.ts +1 -1
  9. package/dist/assets/deploy/cloudflare-resident/worker.ts +317 -56
  10. package/dist/assets/deploy/cloudflare-sandbox/Dockerfile +4 -2
  11. package/dist/assets/deploy/cloudflare-sandbox/runtime-supervisor.d.mts +31 -0
  12. package/dist/assets/deploy/cloudflare-sandbox/runtime-supervisor.mjs +119 -0
  13. package/dist/assets/deploy/profile.example.json +1 -1
  14. package/dist/assets/package-lock.json +3 -3
  15. package/dist/assets/package.json +3 -2
  16. package/dist/assets/project.json +13 -9
  17. package/dist/assets/source.json +3 -3
  18. package/dist/assets/src/agents/registry.ts +5 -5
  19. package/dist/assets/src/core/authz/policy.ts +8 -0
  20. package/dist/assets/src/core/budgets.ts +39 -0
  21. package/dist/assets/src/core/coordinator/contract.ts +93 -1
  22. package/dist/assets/src/core/coordinator/driver.ts +236 -21
  23. package/dist/assets/src/core/drain.ts +50 -0
  24. package/dist/assets/src/core/memory/engine.ts +123 -4
  25. package/dist/assets/src/core/memory/scorer.ts +12 -4
  26. package/dist/assets/src/core/memory/types.ts +73 -12
  27. package/dist/assets/src/core/modelCard.ts +32 -4
  28. package/dist/assets/src/core/modelPricing.ts +111 -1
  29. package/dist/assets/src/core/modelProxy/usage.ts +88 -0
  30. package/dist/assets/src/core/modelRegistry.ts +15 -1
  31. package/dist/assets/src/core/refusal.ts +4 -7
  32. package/dist/assets/src/core/reviewVerdict.ts +4 -0
  33. package/dist/assets/src/core/runEvents.ts +82 -3
  34. package/dist/assets/src/core/runFriction.ts +8 -2
  35. package/dist/assets/src/core/runLedger/types.ts +11 -0
  36. package/dist/assets/src/core/runMetrics.ts +237 -0
  37. package/dist/assets/src/core/runRecord.ts +46 -0
  38. package/dist/assets/src/core/runUsage.ts +67 -13
  39. package/dist/assets/src/core/schedules.ts +3 -0
  40. package/dist/assets/src/core/ship/contract.ts +34 -14
  41. package/dist/assets/src/core/ship/coordinator.ts +485 -55
  42. package/dist/assets/src/core/ship/renewal.ts +10 -5
  43. package/dist/assets/src/core/trace/attrs.ts +24 -0
  44. package/dist/assets/src/core/types.ts +5 -5
  45. package/dist/assets/src/core/verbosity.ts +48 -0
  46. package/dist/assets/src/deploy/liveGate.ts +40 -13
  47. package/dist/assets/src/deploy/profile.ts +16 -0
  48. package/dist/assets/src/deploy/restart.ts +11 -12
  49. package/dist/assets/src/execution/residentDepCache.ts +50 -1
  50. package/dist/assets/src/execution/residentDepsStore.ts +40 -2
  51. package/dist/assets/src/execution/residentRefresh.ts +55 -3
  52. package/dist/assets/src/execution/residentSteps.ts +4 -0
  53. package/dist/assets/src/execution/sandboxErrors.ts +8 -0
  54. package/dist/assets/web/dist/.vite/manifest.json +444 -430
  55. package/dist/assets/web/dist/assets/{AppShell-DAtHiuI6.js → AppShell-Bmz8nzz3.js} +1 -1
  56. package/dist/assets/web/dist/assets/{CostsPage-BoKzYa4B.js → CostsPage-CxTKdpzU.js} +1 -1
  57. package/dist/assets/web/dist/assets/{DeliveryPage-DUXd-Sl-.js → DeliveryPage-C7jaEJip.js} +1 -1
  58. package/dist/assets/web/dist/assets/HomePage-cWbupQzy.js +3 -0
  59. package/dist/assets/web/dist/assets/{InputMenu-C6aPpe30.js → InputMenu-C9E7YoII.js} +1 -1
  60. package/dist/assets/web/dist/assets/{NotFoundPage-DtE-GgTk.js → NotFoundPage-4nlrq7DP.js} +1 -1
  61. package/dist/assets/web/dist/assets/{PendingTurnRow-DDhMhrI7.js → PendingTurnRow-C-pUgyyX.js} +1 -1
  62. package/dist/assets/web/dist/assets/PlanePage-BNy4tSgJ.js +1 -0
  63. package/dist/assets/web/dist/assets/{ResidentDetailPage-BnEoOnGQ.js → ResidentDetailPage-Dbtiq6_J.js} +1 -1
  64. package/dist/assets/web/dist/assets/{ResidentsIndexPage-Dxpgf-l-.js → ResidentsIndexPage-CKtz9H1o.js} +1 -1
  65. package/dist/assets/web/dist/assets/RunFoldRow-sYFtuK2G.js +1 -0
  66. package/dist/assets/web/dist/assets/RunRoutePage-Clh68nw9.js +9 -0
  67. package/dist/assets/web/dist/assets/RunsIndexPage-DPDbcOA_.js +1 -0
  68. package/dist/assets/web/dist/assets/{RunsTabs-YSbUu5py.js → RunsTabs-DotpuCSP.js} +1 -1
  69. package/dist/assets/web/dist/assets/{ScheduledPage-B_GgeJrb.js → ScheduledPage-C8FYoZy5.js} +1 -1
  70. package/dist/assets/web/dist/assets/{SettingSelect-BIzAsLk1.js → SettingSelect-CSiBQCSX.js} +1 -1
  71. package/dist/assets/web/dist/assets/{SettingsPage-BXX4R113.js → SettingsPage-CkYIhLUl.js} +1 -1
  72. package/dist/assets/web/dist/assets/{StatusDot-BOaw8le9.js → StatusDot-C0TUgnC1.js} +1 -1
  73. package/dist/assets/web/dist/assets/{Tooltip-DYZZ4l4V.js → Tooltip-CN5cZdl3.js} +1 -1
  74. package/dist/assets/web/dist/assets/{UnitRoutePage-BaSW5Odq.js → UnitRoutePage-D_EtAl8r.js} +1 -1
  75. package/dist/assets/web/dist/assets/{angular-html-BjeQZdCq.js → angular-html-qW40ZL9x.js} +1 -1
  76. package/dist/assets/web/dist/assets/{angular-ts-ZGdORrL2.js → angular-ts-PUtZU8Cq.js} +1 -1
  77. package/dist/assets/web/dist/assets/{apl-DhvV_X93.js → apl-D3fC7Qjd.js} +1 -1
  78. package/dist/assets/web/dist/assets/{astro-DiRSE4Ug.js → astro-Bi3JFHhh.js} +1 -1
  79. package/dist/assets/web/dist/assets/{blade-D8Fr5Wvq.js → blade-CSpsf9Z9.js} +1 -1
  80. package/dist/assets/web/dist/assets/budgets-CbIyPAER.js +1 -0
  81. package/dist/assets/web/dist/assets/{c-BwbC64D4.js → c-DE_b1oWP.js} +1 -1
  82. package/dist/assets/web/dist/assets/{chapel-DgmqRK97.js → chapel-CWXd9giL.js} +1 -1
  83. package/dist/assets/web/dist/assets/{cobol-DJod2RNK.js → cobol-CnMOmWNF.js} +1 -1
  84. package/dist/assets/web/dist/assets/{coffee-D1Y2CgBY.js → coffee-B9WvURu8.js} +1 -1
  85. package/dist/assets/web/dist/assets/{cpp-CsO97YOM.js → cpp-BZAJXlWI.js} +1 -1
  86. package/dist/assets/web/dist/assets/{crystal-CJ7m2tOc.js → crystal-Bm48wAlb.js} +1 -1
  87. package/dist/assets/web/dist/assets/{css-B2M-NKoj.js → css-WtMeC3ab.js} +1 -1
  88. package/dist/assets/web/dist/assets/{dist-BCVXeBJ9.js → dist-B1Mf2JbM.js} +2 -2
  89. package/dist/assets/web/dist/assets/{edge-CFVW-m9B.js → edge-BmwNa6Ss.js} +1 -1
  90. package/dist/assets/web/dist/assets/{elixir-B2mtRgCr.js → elixir-BotfP-Mn.js} +1 -1
  91. package/dist/assets/web/dist/assets/{elm-2teVAwCi.js → elm-DAFc6laN.js} +1 -1
  92. package/dist/assets/web/dist/assets/{erb-LAbeNqen.js → erb-QkOON3bB.js} +1 -1
  93. package/dist/assets/web/dist/assets/{git-rebase-DnhzH_sp.js → git-rebase-BPs-dd73.js} +1 -1
  94. package/dist/assets/web/dist/assets/{glimmer-js-DPKjXf1-.js → glimmer-js-D8eooepk.js} +1 -1
  95. package/dist/assets/web/dist/assets/{glimmer-ts-CABXnt5z.js → glimmer-ts-B0bRiDVg.js} +1 -1
  96. package/dist/assets/web/dist/assets/{glsl-Dz4-3-gJ.js → glsl-Bv6bMe1n.js} +1 -1
  97. package/dist/assets/web/dist/assets/{graphql-ByWUD1DL.js → graphql-Bh9y-n9o.js} +1 -1
  98. package/dist/assets/web/dist/assets/{hack-CdmOK2K-.js → hack-DSEbn7Ir.js} +1 -1
  99. package/dist/assets/web/dist/assets/{haml-DzegMpd2.js → haml-0NW1-z1f.js} +1 -1
  100. package/dist/assets/web/dist/assets/{handlebars-B5NeiP0e.js → handlebars-BfgCHFbK.js} +1 -1
  101. package/dist/assets/web/dist/assets/{html-Zg_NHLv9.js → html-CQHc0gQ5.js} +1 -1
  102. package/dist/assets/web/dist/assets/{html-derivative-DZf9JNzh.js → html-derivative-B0Tm7wLP.js} +1 -1
  103. package/dist/assets/web/dist/assets/{http-CVFrQZeN.js → http-GAuU3aOF.js} +1 -1
  104. package/dist/assets/web/dist/assets/{hurl-L5brWAJe.js → hurl-CrDFbF5B.js} +1 -1
  105. package/dist/assets/web/dist/assets/indexRow-BxKswdlK.js +1 -0
  106. package/dist/assets/web/dist/assets/{java-hFphyZ4R.js → java-DYv5KhUe.js} +1 -1
  107. package/dist/assets/web/dist/assets/{javascript-CAJO1WKX.js → javascript-32SiLGg0.js} +1 -1
  108. package/dist/assets/web/dist/assets/{jinja-N4A4srXI.js → jinja--gJDUGYa.js} +1 -1
  109. package/dist/assets/web/dist/assets/{jison-D3BZa_iU.js → jison-BlKLGzZH.js} +1 -1
  110. package/dist/assets/web/dist/assets/{json-DxD1Qh8W.js → json-BICanD1N.js} +1 -1
  111. package/dist/assets/web/dist/assets/{jsx-CHhag69S.js → jsx-CgW3-uFT.js} +1 -1
  112. package/dist/assets/web/dist/assets/{julia-CrhkZ6Tt.js → julia-BErRpwwL.js} +1 -1
  113. package/dist/assets/web/dist/assets/{just-D0LFOuhq.js → just-CordHv0h.js} +1 -1
  114. package/dist/assets/web/dist/assets/{latex-BHAlwCXK.js → latex-D7LVPLbi.js} +1 -1
  115. package/dist/assets/web/dist/assets/{liquid-CkaLfCWh.js → liquid-3ehz2i4U.js} +1 -1
  116. package/dist/assets/web/dist/assets/{lua-B1h4aAzP.js → lua-C9ppSVrm.js} +1 -1
  117. package/dist/assets/web/dist/assets/main-Cv0q9y56.js +28 -0
  118. package/dist/assets/web/dist/assets/main-DKFCM-sy.css +1 -0
  119. package/dist/assets/web/dist/assets/{marko-D6CG4hvX.js → marko-xBZLUBEJ.js} +1 -1
  120. package/dist/assets/web/dist/assets/{mdc-CVQzimAU.js → mdc-BJxGoVgD.js} +1 -1
  121. package/dist/assets/web/dist/assets/{nginx-h-9Ir54h.js → nginx-B-sZmpsb.js} +1 -1
  122. package/dist/assets/web/dist/assets/{nim-DHVI30sp.js → nim--s9wRABK.js} +1 -1
  123. package/dist/assets/web/dist/assets/{org-AasAXfMF.js → org-DoUvssoI.js} +1 -1
  124. package/dist/assets/web/dist/assets/{perl-BVi5aTEy.js → perl-B-vGHKOy.js} +1 -1
  125. package/dist/assets/web/dist/assets/{php-qOkCeUKr.js → php-CerWx_Xb.js} +1 -1
  126. package/dist/assets/web/dist/assets/{pug-8TnUGvEX.js → pug-CzKrBMb1.js} +1 -1
  127. package/dist/assets/web/dist/assets/{qml-CpJbhoxn.js → qml-Bs8L8I6k.js} +1 -1
  128. package/dist/assets/web/dist/assets/{r-JkUNcAJW.js → r-w6uG6mob.js} +1 -1
  129. package/dist/assets/web/dist/assets/{razor-CtU4ws5a.js → razor-dz2oj2bd.js} +1 -1
  130. package/dist/assets/web/dist/assets/{regexp-J2zA6ceN.js → regexp-CfQdprnY.js} +1 -1
  131. package/dist/assets/web/dist/assets/{rst-CMCvf-yZ.js → rst-DAOaX2tb.js} +1 -1
  132. package/dist/assets/web/dist/assets/{ruby-ChLBY_iR.js → ruby-QcCe4udB.js} +1 -1
  133. package/dist/assets/web/dist/assets/{sas-cwn4x2VY.js → sas-BrM4lAAc.js} +1 -1
  134. package/dist/assets/web/dist/assets/{scss-jSrJlzLI.js → scss-DVbJXoUb.js} +1 -1
  135. package/dist/assets/web/dist/assets/{shellscript-D5nAd0gs.js → shellscript-09fP2hZf.js} +1 -1
  136. package/dist/assets/web/dist/assets/{shellsession-BZY4-Tdh.js → shellsession-Qgr8xjZF.js} +1 -1
  137. package/dist/assets/web/dist/assets/{soy-COppPOKr.js → soy-DIYeNTuz.js} +1 -1
  138. package/dist/assets/web/dist/assets/{sql-Blc_6dOA.js → sql-CzLR9msV.js} +1 -1
  139. package/dist/assets/web/dist/assets/sseReplay-BYo2EZKy.js +11 -0
  140. package/dist/assets/web/dist/assets/{stata-DRv3OY-h.js → stata-zCY1ZzH0.js} +1 -1
  141. package/dist/assets/web/dist/assets/{surrealql-qMv_eeJ6.js → surrealql-CJIjTRV4.js} +1 -1
  142. package/dist/assets/web/dist/assets/{svelte-d1Edb6Rz.js → svelte-C5OTPFZg.js} +1 -1
  143. package/dist/assets/web/dist/assets/{templ-CUMSIOXG.js → templ-DYj-UICF.js} +1 -1
  144. package/dist/assets/web/dist/assets/{tex-BGZQA-Ei.js → tex-xcie-3Gd.js} +1 -1
  145. package/dist/assets/web/dist/assets/{ts-tags-Z9ulxVsq.js → ts-tags-CoN86VTt.js} +1 -1
  146. package/dist/assets/web/dist/assets/{tsx-oI4bI3Vf.js → tsx-n3-No_Br.js} +1 -1
  147. package/dist/assets/web/dist/assets/{twig-D_RXwBIX.js → twig-DPiJ7Vp1.js} +1 -1
  148. package/dist/assets/web/dist/assets/{typescript-BgqQDYoN.js → typescript-BP6HBaOp.js} +1 -1
  149. package/dist/assets/web/dist/assets/{typst-5MW-KRDd.js → typst-Do9ZjsVP.js} +1 -1
  150. package/dist/assets/web/dist/assets/{vue-CZhHHsPm.js → vue-CzAeXNRY.js} +1 -1
  151. package/dist/assets/web/dist/assets/{vue-html-DlYy2d8v.js → vue-html-D7iYmYjd.js} +1 -1
  152. package/dist/assets/web/dist/assets/{vue-vine-CJDnx-qn.js → vue-vine-DtrMRsJx.js} +1 -1
  153. package/dist/assets/web/dist/assets/{xml-BRBDk65Q.js → xml-DGJHo1c0.js} +1 -1
  154. package/dist/assets/web/dist/assets/{xsl-BlCykPxo.js → xsl-Q00kvNgW.js} +1 -1
  155. package/dist/assets/web/dist/assets/{yaml-BqW48szf.js → yaml-F-UGKaiO.js} +1 -1
  156. package/dist/cli.js +5623 -2455
  157. package/package.json +1 -1
  158. package/dist/assets/deploy/cloudflare-sandbox/runtime-supervisor.sh +0 -68
  159. package/dist/assets/web/dist/assets/HomePage-mSiqEEcN.js +0 -2
  160. package/dist/assets/web/dist/assets/RunFoldRow-CSo4-vld.js +0 -1
  161. package/dist/assets/web/dist/assets/RunRoutePage-9klVWhSF.js +0 -9
  162. package/dist/assets/web/dist/assets/RunsIndexPage-BplMIgaw.js +0 -1
  163. package/dist/assets/web/dist/assets/budgets-BvWYKPsY.js +0 -1
  164. package/dist/assets/web/dist/assets/indexRow-Bde9OZxG.js +0 -1
  165. package/dist/assets/web/dist/assets/main-Dkcbtu3u.js +0 -28
  166. package/dist/assets/web/dist/assets/main-zbP_dTjR.css +0 -1
  167. package/dist/assets/web/dist/assets/sseReplay-DPwdsaok.js +0 -9
@@ -180,8 +180,11 @@ import {
180
180
  type StoredTestOverrides,
181
181
  } from "./gc";
182
182
  import {
183
+ CHECKOUT_FETCH_COMMAND,
183
184
  checkoutUpdateCommand,
184
185
  classifyRefreshFailure,
186
+ stageCheckoutScript,
187
+ swapCheckoutScript,
185
188
  restoreFailureDisposition,
186
189
  isRuntimeUnreachableSignal,
187
190
  killStaleBuildProcessesCommand,
@@ -304,6 +307,7 @@ import {
304
307
  CHECKOUT_SNAPSHOT_EXCLUDES,
305
308
  DEPS_BACKUP_KEY_PREFIX,
306
309
  DEPS_BACKUP_TTL_S,
310
+ DEPS_ENTRY_BACKUP_EXCLUDES,
307
311
  DEPS_STORE_DIR,
308
312
  depsBackupStorageKey,
309
313
  depsBackupsToDrop,
@@ -330,6 +334,14 @@ import {
330
334
  import { buildId, injectedBuildStamp } from "../../src/deploy/buildStamp.js";
331
335
  import { createRefreshInstance, createRefreshInstanceNow, type RefreshInstanceParams } from "./refresh";
332
336
  import { drainRefusal, liveDrain, parseDrainRequest, type DrainRecord } from "./drain";
337
+ import {
338
+ CGROUP_READ_ARGV,
339
+ MEMORY_PRESSURE_REASON,
340
+ MemoryGuard,
341
+ MemorySampleUnavailable,
342
+ type MemoryGateRoute,
343
+ type MemoryReading,
344
+ } from "./memoryGuard";
333
345
  import {
334
346
  ControlResetError,
335
347
  RuntimeReplacedError,
@@ -485,6 +497,18 @@ const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve,
485
497
  * DO storage is). Thread worktrees hang off the same mirror; keep these paths stable. */
486
498
  const MIRROR_DIR = "/workspace/mirror"; // bare mirror, owned by root
487
499
  const CHECKOUT_DIR = "/workspace/checkout"; // default-branch working tree + deps + build, owned by BUILD_USER
500
+ /** The staged rebuild's side trees (resident-repos.md item 47): the refresh
501
+ * builds into `staging` OUTSIDE the mirror lock and swaps it into
502
+ * CHECKOUT_DIR under it (two renames); `retired` holds the replaced checkout
503
+ * between the swap and its off-lock removal. Same filesystem as the
504
+ * checkout, so both moves are renames and `cp -al` hardlinks. */
505
+ const STAGING_CHECKOUT_DIR = "/workspace/checkout.staging";
506
+ const RETIRED_CHECKOUT_DIR = "/workspace/checkout.retired";
507
+ const CHECKOUT_SWAP_DIRS = {
508
+ checkout: CHECKOUT_DIR,
509
+ staging: STAGING_CHECKOUT_DIR,
510
+ retired: RETIRED_CHECKOUT_DIR,
511
+ } as const;
488
512
  const RESIDENT_STATE_DIR = "/workspace/.resident"; // mode 700 root:root — worker users cannot traverse
489
513
  const CRED_FILE = `${RESIDENT_STATE_DIR}/git-credentials`; // one-shot token file, deleted after each git command
490
514
  const READY_MARKER = `${RESIDENT_STATE_DIR}/ready`; // holds the sha the disk was hydrated to
@@ -607,6 +631,11 @@ const DISK_FULL_RECYCLE_KEY = "resident:diskFullRecycleAt";
607
631
  * admission projects a new tree's cost from its parts (its free-space term
608
632
  * is a live `df` of its own). */
609
633
  const DISK_KEY = "resident:disk";
634
+ /** The last memory reading (docs/reference/specs/resident-repos.md item 70; `memoryGuard.ts`):
635
+ * one cgroup v2 read at every /exec and /attach start and on the measure
636
+ * tick, persisted so the gauges (`/status`, `/residents`, the watchdog line)
637
+ * read storage only — the watchdog never touches the container. */
638
+ const MEMORY_KEY = "resident:memory";
610
639
  /** The lifecycle row (docs/reference/specs/resident-repos.md item 7): `workflow`,
611
640
  * the one scheduler. Kept from the flagged rollout so `/status` and `/debug
612
641
  * info` can say so; an `alarm` value a flip left behind reads `workflow`
@@ -1893,6 +1922,12 @@ export class ResidentDO extends Sandbox<Env> {
1893
1922
  * deadline names the fallback. */
1894
1923
  private restoreWaiters = new RestoreWaiters();
1895
1924
 
1925
+ /** Item 70: the memory guard — one cgroup reading per /exec and /attach
1926
+ * start (and per measure tick), the two-threshold gate over the last one.
1927
+ * In-memory like the waiter ledger: a fresh isolate re-samples at the next
1928
+ * route start, and the persisted copy (MEMORY_KEY) serves the gauges. */
1929
+ private memoryGuard = new MemoryGuard({ read: () => this.readCgroup() }, (line) => console.log(line));
1930
+
1896
1931
  private nextHolder(): string {
1897
1932
  return `${this.incarnation}:${++this.leaseSeq}`;
1898
1933
  }
@@ -2209,18 +2244,24 @@ export class ResidentDO extends Sandbox<Env> {
2209
2244
  }
2210
2245
 
2211
2246
  /** Run one command-table entry as the unprivileged build user in the warm
2212
- * checkout. Never root. Never a GitHub token — the env
2213
- * is whatever `su` grants the target user, nothing injected. */
2214
- private async buildUserRun(command: string, step: ResidentStepLabelKey, timeoutMs: number): Promise<string> {
2215
- // Steps on the checkout are sequential, so a live build-user process
2216
- // INSIDE it here is a leftover (a step whose wait was abandoned, or one
2247
+ * checkout (or the staged rebuild's tree, item 47). Never root. Never a
2248
+ * GitHub token — the env is whatever `su` grants the target user, nothing
2249
+ * injected. */
2250
+ private async buildUserRun(
2251
+ command: string,
2252
+ step: ResidentStepLabelKey,
2253
+ timeoutMs: number,
2254
+ dir: string = CHECKOUT_DIR,
2255
+ ): Promise<string> {
2256
+ // Steps on one tree are sequential, so a live build-user process INSIDE
2257
+ // it here is a leftover (a step whose wait was abandoned, or one
2217
2258
  // orphaned by a Worker-only deploy resetting the DO) — and it is writing
2218
- // into the tree this step is about to clean or build. Scoped to the
2219
- // checkout: the same user's deps-store installs run in their own scratch
2259
+ // into the tree this step is about to clean or build. Scoped to that
2260
+ // tree: the same user's deps-store installs run in their own scratch
2220
2261
  // trees, in parallel, and are live work (killStaleBuildProcessesCommand).
2221
- const swept = await this.runOk(killStaleBuildProcessesCommand(BUILD_USER, CHECKOUT_DIR), `${step}-stale-sweep`);
2262
+ const swept = await this.runOk(killStaleBuildProcessesCommand(BUILD_USER, dir), `${step}-stale-sweep`);
2222
2263
  if (swept.trim()) console.log(`${step}: ${swept.trim()}`);
2223
- return this.runOk(["su", "-s", "/bin/bash", BUILD_USER, "-c", `cd ${CHECKOUT_DIR} && ${command}`], step, {
2264
+ return this.runOk(["su", "-s", "/bin/bash", BUILD_USER, "-c", `cd ${dir} && ${command}`], step, {
2224
2265
  timeoutMs,
2225
2266
  });
2226
2267
  }
@@ -2563,9 +2604,10 @@ export class ResidentDO extends Sandbox<Env> {
2563
2604
  }
2564
2605
  }
2565
2606
 
2566
- /** Bring the checkout to `sha` and build it, under the mirror mutex. The
2567
- * disk markers decide (planBuild over the refresh planner): a checkout
2568
- * whose HEAD, deps and build markers all name the target is done. */
2607
+ /** Bring the checkout to `sha` and build it — staged outside the mirror
2608
+ * mutex, swapped in under it (item 47). The disk markers decide (planBuild
2609
+ * over the refresh planner): a checkout whose HEAD, deps and build markers
2610
+ * all name the target is done. */
2569
2611
  async runBuild(input: {
2570
2612
  sha: string;
2571
2613
  factsSha: string;
@@ -2577,47 +2619,116 @@ export class ResidentDO extends Sandbox<Env> {
2577
2619
  const { sha, lockfileKey } = input;
2578
2620
  const plan = planBuild({ sha, factsSha: input.factsSha, lockfileKey, disk: await this.readRefreshDisk() });
2579
2621
  if (plan.action === "done") return { done: true, why: plan.why };
2580
- // Serialize the CHECKOUT_DIR mutation on the mirror mutex:
2581
- // materializeThreadDeps reads CHECKOUT_DIR via `cp -al` under the same
2582
- // lock, so an attach/op dep-copy can never hardlink a half-rebuilt
2583
- // checkout into a thread tree (torn cache → false ❌ from `repo test`).
2584
- // No wait timeout, exactly like the fetch lock: the background refresh
2585
- // queues behind an in-flight attach instead of flipping to degraded on
2622
+ // The mirror mutex is held only to STAGE and later to SWAP — never for
2623
+ // the build (item 47): a heavy build under the lock pushed concurrent
2624
+ // attaches past ATTACH_MUTEX_WAIT_MS into mirror-busy 503s and cold
2625
+ // fallbacks. The stage lock covers the two reads the mutex actually
2626
+ // protects: a consistent hardlink copy of the warm checkout (exactly
2627
+ // materializeThreadDeps' torn-cache guarantee, which the `cp -al` under
2628
+ // the same lock keeps — CHECKOUT_DIR now changes only by rename under
2629
+ // this mutex) and the fetch from the mirror into the staging tree (an
2630
+ // attach's `fetch --prune` could delete a ref under it). No wait
2631
+ // timeout, exactly like the fetch lock: the background refresh queues
2632
+ // behind an in-flight attach instead of flipping to degraded on
2586
2633
  // transient lock contention. Token-free: repo code runs during the build.
2634
+ // Off the lock, before the stage lease: a failed or interrupted off-lock
2635
+ // build leaves a full staging tree — deps view plus partial build output,
2636
+ // hundreds of thousands of inodes — and the retry 30 s later would
2637
+ // otherwise hold the mirror lock through its removal, the contention
2638
+ // class the staged rebuild exists to remove. The tree is private to this
2639
+ // rebuild, so its stale writers are killed and the tree dropped without
2640
+ // the lock; the retired tree's heal-then-remove stays in the staged
2641
+ // script under the lease, where the checkout path's consistency needs it.
2642
+ const swept = await this.runOk(
2643
+ killStaleBuildProcessesCommand(BUILD_USER, STAGING_CHECKOUT_DIR),
2644
+ "checkout-stage-stale-sweep",
2645
+ );
2646
+ if (swept.trim()) console.log(`checkout-stage: ${swept.trim()}`);
2647
+ await this.runOk(["rm", "-rf", STAGING_CHECKOUT_DIR], "checkout-stage-clear", {
2648
+ timeoutMs: GIT_NETWORK_TIMEOUT_MS,
2649
+ });
2650
+ await this.withMirrorLock(
2651
+ async () => {
2652
+ await this.runOk(["sh", "-c", stageCheckoutScript(CHECKOUT_SWAP_DIRS)], "checkout-stage", {
2653
+ timeoutMs: GIT_NETWORK_TIMEOUT_MS,
2654
+ });
2655
+ await this.buildUserRun(
2656
+ CHECKOUT_FETCH_COMMAND,
2657
+ "checkout-update",
2658
+ GIT_NETWORK_TIMEOUT_MS,
2659
+ STAGING_CHECKOUT_DIR,
2660
+ );
2661
+ },
2662
+ 0,
2663
+ // The lease is the section's own budget (resident-repos.md item 22) —
2664
+ // the backstop by which a taker judges this holder dead. The section
2665
+ // runs two commands each budgeted GIT_NETWORK_TIMEOUT_MS (the stage
2666
+ // script and the mirror fetch), so the lease is their sum: a lease of
2667
+ // one budget let a taker steal the lock mid-stage and race its
2668
+ // `fetch --prune` against the staging fetch's negotiation.
2669
+ { step: "checkout-stage", budgetMs: 2 * GIT_NETWORK_TIMEOUT_MS },
2670
+ );
2671
+ // Outside the lock, in the staging tree. Isolation invariant (review 1b):
2672
+ // attached, sha-pinned thread worktrees hold hardlinks to the store
2673
+ // entry's FILE inodes, and so does the staged copy. A build that writes
2674
+ // THROUGH an existing inode — many bundlers do (e.g. .next incremental
2675
+ // manifests open+truncate rather than recreate) — would mutate every
2676
+ // consumer's pinned artifacts. The `-x` clean removes the staged copy's
2677
+ // build output so the build allocates FRESH inodes; the entry's own files
2678
+ // are owner-read-only (deps-harden), so a write through them fails loudly
2679
+ // instead of silently reaching the store; the tool caches inside
2680
+ // node_modules are the checkout's private copies (item 18).
2681
+ //
2682
+ // Install gate: when the committed lockfile key is unchanged, node_modules
2683
+ // (the view) is excluded from the clean and no deps work happens; a
2684
+ // changed key re-links the view to the new entry — which is also what
2685
+ // drops deps the new lockfile no longer has.
2686
+ await this.buildUserRun(
2687
+ checkoutUpdateCommand(sha, plan.clean),
2688
+ "checkout-update",
2689
+ GIT_NETWORK_TIMEOUT_MS,
2690
+ STAGING_CHECKOUT_DIR,
2691
+ );
2692
+ if (plan.install && input.depsEntry) {
2693
+ // The old view (a resumed install's keep-deps clean leaves it in
2694
+ // place, item 57) makes way for the new entry's: hardlinks only,
2695
+ // the entry's inodes are untouched.
2696
+ await this.runOk(["rm", "-rf", `${STAGING_CHECKOUT_DIR}/node_modules`], "unlink-deps-view");
2697
+ await this.linkDepsView(
2698
+ `${input.depsEntry}/node_modules`,
2699
+ STAGING_CHECKOUT_DIR,
2700
+ BUILD_USER,
2701
+ STAGING_CHECKOUT_DIR,
2702
+ );
2703
+ }
2704
+ await this.buildUserRun(input.buildCmd, "build", REFRESH_BUILD_TIMEOUT_MS, STAGING_CHECKOUT_DIR);
2705
+ // The swap: two renames — milliseconds — and the markers written with the
2706
+ // tree they vouch for, under one short lease.
2587
2707
  await this.withMirrorLock(
2588
2708
  async () => {
2589
- // Isolation invariant (review 1b): attached, sha-pinned thread
2590
- // worktrees hold hardlinks to the store entry's FILE inodes, and so
2591
- // does the checkout. A build that writes THROUGH an existing inode —
2592
- // many bundlers do (e.g. .next incremental manifests open+truncate
2593
- // rather than recreate) — would mutate every consumer's pinned
2594
- // artifacts. The `-x` clean removes the checkout's build output so
2595
- // the build allocates FRESH inodes; the entry's own files are
2596
- // owner-read-only (deps-harden), so a write through them fails
2597
- // loudly instead of silently reaching the store; the tool caches
2598
- // inside node_modules are the checkout's private copies (item 18).
2599
- //
2600
- // Install gate: when the committed lockfile key is unchanged,
2601
- // node_modules (the view) is excluded from the clean and no deps
2602
- // work happens; a changed key re-links the view to the new entry —
2603
- // which is also what drops deps the new lockfile no longer has.
2604
- await this.buildUserRun(checkoutUpdateCommand(sha, plan.clean), "checkout-update", GIT_NETWORK_TIMEOUT_MS);
2709
+ // A leftover writer inside the old checkout would keep writing into
2710
+ // the retired tree while it is removed.
2711
+ const swept = await this.runOk(
2712
+ killStaleBuildProcessesCommand(BUILD_USER, CHECKOUT_DIR),
2713
+ "checkout-swap-stale-sweep",
2714
+ );
2715
+ if (swept.trim()) console.log(`checkout-swap: ${swept.trim()}`);
2716
+ await this.runOk(["sh", "-c", swapCheckoutScript(CHECKOUT_SWAP_DIRS)], "checkout-swap", {
2717
+ timeoutMs: GIT_NETWORK_TIMEOUT_MS,
2718
+ });
2605
2719
  if (plan.install) {
2606
- // The old view (a resumed install's keep-deps clean leaves it in
2607
- // place, item 57) makes way for the new entry's: hardlinks only,
2608
- // the entry's inodes are untouched.
2609
- if (input.depsEntry) {
2610
- await this.runOk(["rm", "-rf", `${CHECKOUT_DIR}/node_modules`], "unlink-deps-view");
2611
- await this.linkDepsView(`${input.depsEntry}/node_modules`, CHECKOUT_DIR, BUILD_USER);
2612
- }
2613
2720
  await this.writeDiskMarkers({ depsKey: lockfileKey });
2614
2721
  await this.runOk(["rm", "-f", INSTALLING_MARKER], "clear-installing-marker");
2615
2722
  }
2616
- await this.buildUserRun(input.buildCmd, "build", REFRESH_BUILD_TIMEOUT_MS);
2617
2723
  await this.writeDiskMarkers({ builtSha: sha });
2618
2724
  },
2619
2725
  0,
2620
- { step: "build", budgetMs: GIT_NETWORK_TIMEOUT_MS + REFRESH_BUILD_TIMEOUT_MS },
2726
+ { step: "checkout-swap", budgetMs: GIT_NETWORK_TIMEOUT_MS },
2727
+ );
2728
+ // Off the lock: the replaced tree's removal (hardlinks — mostly link
2729
+ // drops). A failure here is a log line; the next stage's seed sweeps it.
2730
+ await this.runOk(["rm", "-rf", RETIRED_CHECKOUT_DIR], "checkout-retire").catch((err: unknown) =>
2731
+ console.log(`checkout-retire: ${errMsg(err)} — the next staged rebuild sweeps it`),
2621
2732
  );
2622
2733
  return { done: false, why: plan.why };
2623
2734
  }
@@ -2709,7 +2820,10 @@ export class ResidentDO extends Sandbox<Env> {
2709
2820
  // (item 61: the SDK's presigned restore mounts) — `rm -rf` on a mount
2710
2821
  // point is "Device or resource busy". Unmount first, every time.
2711
2822
  await this.runOk(["sh", "-c", unmountAllRestoresScript()], "unmount-restores");
2712
- await this.runOk(["rm", "-rf", MIRROR_DIR, CHECKOUT_DIR, ...DISK_MARKERS], "clean-before-restore");
2823
+ await this.runOk(
2824
+ ["rm", "-rf", MIRROR_DIR, CHECKOUT_DIR, STAGING_CHECKOUT_DIR, RETIRED_CHECKOUT_DIR, ...DISK_MARKERS],
2825
+ "clean-before-restore",
2826
+ );
2713
2827
  try {
2714
2828
  // The restore pair IS the cold-wake critical path. Sequential on purpose:
2715
2829
  // the SDK serializes backup operations anyway (one queue), so a
@@ -2962,7 +3076,10 @@ export class ResidentDO extends Sandbox<Env> {
2962
3076
  });
2963
3077
  }
2964
3078
  await this.runOk(["sh", "-c", unmountAllRestoresScript()], "unmount-restores");
2965
- await this.runOk(["rm", "-rf", MIRROR_DIR, CHECKOUT_DIR, ...DISK_MARKERS], "clean-workspace");
3079
+ await this.runOk(
3080
+ ["rm", "-rf", MIRROR_DIR, CHECKOUT_DIR, STAGING_CHECKOUT_DIR, RETIRED_CHECKOUT_DIR, ...DISK_MARKERS],
3081
+ "clean-workspace",
3082
+ );
2966
3083
  await this.ensureGitSetup();
2967
3084
  await this.withMirrorLock(() =>
2968
3085
  this.gitWithCred(
@@ -4234,7 +4351,13 @@ export class ResidentDO extends Sandbox<Env> {
4234
4351
  cycle.count();
4235
4352
  // Same gate as the sweep: a silent control port cannot answer a `df`.
4236
4353
  if (await this.runtimeUnreachableRow()) return { status: "stopped", why: "runtime-unreachable" };
4237
- return this.housekeeping("measure", async () => ({ measured: (await this.measureDisk()) !== null }));
4354
+ return this.housekeeping("measure", async () => {
4355
+ // Item 70: the tick's memory line rides the measure step's cadence —
4356
+ // the instance the watchdog cron creates, never the watchdog's own
4357
+ // pass (which must not touch the container). No polling loop.
4358
+ await this.sampleMemory();
4359
+ return { measured: (await this.measureDisk()) !== null };
4360
+ });
4238
4361
  });
4239
4362
  }
4240
4363
 
@@ -4424,6 +4547,76 @@ export class ResidentDO extends Sandbox<Env> {
4424
4547
  return sample;
4425
4548
  }
4426
4549
 
4550
+ // -- memory guard (docs/reference/specs/resident-repos.md item 70) ------------
4551
+
4552
+ /** The one real `CgroupReader`: `CGROUP_READ_ARGV` through the exec choke
4553
+ * point (`run()`), one exec per sample. A throw there (busy, replaced,
4554
+ * reset — the container's moment, not the cgroup) is the transient
4555
+ * `MemorySampleUnavailable`, which never disables the gate — and a typed
4556
+ * runtime replacement `invalidates` the last reading: the container it
4557
+ * measured is gone, and gating on its numbers would answer
4558
+ * `memory-pressure` where the route's own gate owes `runtime-replaced`.
4559
+ * A non-zero exit is the unreadable cgroup that logs once and disables it. */
4560
+ private async readCgroup(): Promise<string> {
4561
+ let r: { stdout: string; stderr: string; exitCode: number };
4562
+ try {
4563
+ r = await this.run([...CGROUP_READ_ARGV]);
4564
+ } catch (err) {
4565
+ throw new MemorySampleUnavailable(errMsg(err), err instanceof RuntimeReplacedError);
4566
+ }
4567
+ if (r.exitCode !== 0) throw new Error(`cgroup read exit ${r.exitCode}: ${(r.stderr || r.stdout).trim()}`);
4568
+ return r.stdout;
4569
+ }
4570
+
4571
+ /** One sample, one structured log line (the guard's). Never on an inactive
4572
+ * runtime — a sleeping container is not woken to be measured (item 55's
4573
+ * rule) — and persisted for the gauges when taken. */
4574
+ private async sampleMemory(): Promise<MemoryReading | null> {
4575
+ if (!(await this.isRuntimeActive().catch(() => false))) {
4576
+ // An inactive container holds no memory: whatever the guard last read
4577
+ // came from a container that no longer runs, and a route hitting the
4578
+ // gate before anything wakes the runtime (a fresh hydration memo skips
4579
+ // the restore) must not be refused on a dead container's percent.
4580
+ this.memoryGuard.invalidate();
4581
+ return null;
4582
+ }
4583
+ const reading = await this.memoryGuard.sample(new Date(systemClock()).toISOString());
4584
+ if (reading) await this.ctx.storage.put(MEMORY_KEY, reading);
4585
+ return reading;
4586
+ }
4587
+
4588
+ /** The last reading, for `/status`, `/residents` and the watchdog line —
4589
+ * this incarnation's when it has one, else the persisted one (storage
4590
+ * only: the watchdog never touches the container). */
4591
+ async memoryGauge(): Promise<MemoryReading | null> {
4592
+ return this.memoryGuard.lastReading ?? (await this.ctx.storage.get<MemoryReading>(MEMORY_KEY)) ?? null;
4593
+ }
4594
+
4595
+ /** The route gate (item 70): one fresh sample, then the pure verdict over
4596
+ * the last reading. A refusal is the same 503 shape as `mirror-busy`, so
4597
+ * the bot falls back or waits legibly — and a command already running is
4598
+ * never touched: the gate sits at the route's start and kills nothing. */
4599
+ private async memoryGate(route: MemoryGateRoute, exemptRegisteredRun = false): Promise<ThreadErr | null> {
4600
+ await this.sampleMemory();
4601
+ // A run already in flight — registered from attach to release (item 44) —
4602
+ // is what the gate holds the resident open FOR: its re-attach (a rolled
4603
+ // container, an evicted worktree, a resume) passes as it passes the drain,
4604
+ // sampled but never refused — refusing it would strand the warm worktree
4605
+ // and lose the very work the gate protects.
4606
+ if (exemptRegisteredRun) return null;
4607
+ const refusal = this.memoryGuard.gate(route);
4608
+ if (!refusal) return null;
4609
+ const s = await this.getStatus();
4610
+ return {
4611
+ error: refusal.message,
4612
+ status: 503,
4613
+ state: s.state,
4614
+ stateReason: s.reason,
4615
+ reason: MEMORY_PRESSURE_REASON,
4616
+ cause: "system",
4617
+ };
4618
+ }
4619
+
4427
4620
  /** `{usedKiB, totalKiB, at}` of the last sample for the watchdog line (storage
4428
4621
  * only — the watchdog never touches the container). */
4429
4622
  private async diskGauge(): Promise<{ usedKiB: number; totalKiB: number; freeKiB: number; at: string } | null> {
@@ -4683,11 +4876,17 @@ export class ResidentDO extends Sandbox<Env> {
4683
4876
  action: "none" | "provision-timed-out" | "auto-rebuilt";
4684
4877
  /** Item 55: the last disk sample's gauge, for the watchdog's status line. */
4685
4878
  disk: { usedKiB: number; totalKiB: number; freeKiB: number; at: string } | null;
4879
+ /** Item 70: the last memory reading, storage only — the tick's gauge. */
4880
+ memory: MemoryReading | null;
4686
4881
  /** Item 7: what the cron's instance-creation decision reads, after the check above settled the state. */
4687
4882
  refresh: RefreshRow;
4688
4883
  }> {
4689
- const [check, disk] = await Promise.all([this.watchdogCheckLifecycle(), this.diskGauge()]);
4690
- return { ...check, disk, refresh: await this.refreshRow() };
4884
+ const [check, disk, memory] = await Promise.all([
4885
+ this.watchdogCheckLifecycle(),
4886
+ this.diskGauge(),
4887
+ this.memoryGauge(),
4888
+ ]);
4889
+ return { ...check, disk, memory, refresh: await this.refreshRow() };
4691
4890
  }
4692
4891
 
4693
4892
  private async watchdogCheckLifecycle(): Promise<{
@@ -4981,10 +5180,18 @@ export class ResidentDO extends Sandbox<Env> {
4981
5180
  // closed on the run it is closed for. Read before the image reconcile so
4982
5181
  // a refused attach never restarts a container.
4983
5182
  const drain = await this.fleetDrain();
4984
- if (drain && !(await this.ctx.storage.get(runRegKey(threadKey)))) {
5183
+ const registered = (await this.ctx.storage.get(runRegKey(threadKey))) !== undefined;
5184
+ if (drain && !registered) {
4985
5185
  const refusal: ThreadErr & { draining: DrainRecord } = drainRefusal(drain);
4986
5186
  return refusal;
4987
5187
  }
5188
+ // Item 70: above the soft memory threshold a NEW attach is refused like
5189
+ // `mirror-busy` (the bot falls back or waits, the card says why) — after
5190
+ // the drain (storage only, cheaper) and before the image reconcile, so a
5191
+ // refused attach never restarts a container. A registered run's
5192
+ // re-attach passes for the same reason it passes the drain above.
5193
+ const memory = await this.memoryGate("attach", registered);
5194
+ if (memory) return memory;
4988
5195
  const resourceId = (await this.ctx.storage.get<string>(RESOURCE_KEY)) ?? "";
4989
5196
  if (await this.reconcileImage("attach")) {
4990
5197
  return {
@@ -5975,10 +6182,14 @@ export class ResidentDO extends Sandbox<Env> {
5975
6182
  const resource = (await this.ctx.storage.get<string>(RESOURCE_KEY)) ?? "";
5976
6183
  const backup = await withTimeout(
5977
6184
  this.createBackup({
5978
- dir: `${depsEntryPath(key)}/node_modules`,
6185
+ // The entry dir itself, not only its top-level node_modules: nested
6186
+ // workspace node_modules are entry content too. The markers are
6187
+ // excluded so a restore's own commit writes `.complete` LAST.
6188
+ dir: depsEntryPath(key),
5979
6189
  localBucket: transfer.localBucket,
5980
6190
  ttl: DEPS_BACKUP_TTL_S,
5981
6191
  name: `${resource} deps ${key.slice(0, 8)}`,
6192
+ excludes: [...DEPS_ENTRY_BACKUP_EXCLUDES],
5982
6193
  }),
5983
6194
  R2_TRANSFER_TIMEOUT_MS,
5984
6195
  "deps backup",
@@ -6020,9 +6231,16 @@ export class ResidentDO extends Sandbox<Env> {
6020
6231
  await this.ensureDepsStoreDir();
6021
6232
  console.log(`deps: restoring ${key.slice(0, 8)} from backup ${record.backup.id.slice(0, 8)} into ${scratch}`);
6022
6233
  await this.runOk(["mkdir", "-p", scratch], "deps-restore-scratch");
6234
+ // The archive is the entry dir (top-level node_modules PLUS nested
6235
+ // workspace ones at their tree-relative paths), so it extracts into the
6236
+ // scratch root and the commit script moves each node_modules like an
6237
+ // install's. An archive from before nested coverage held only the
6238
+ // node_modules CONTENTS: its extract leaves no scratch/node_modules, the
6239
+ // commit fails, the record is dropped and the installer runs — the
6240
+ // documented fall-through, never a stranded key.
6023
6241
  await this.restoreExtracted(
6024
6242
  record.backup,
6025
- `${scratch}/node_modules`,
6243
+ scratch,
6026
6244
  `deps restore ${key.slice(0, 8)}`,
6027
6245
  "deps-restore-extract",
6028
6246
  deadlineMs,
@@ -6104,6 +6322,15 @@ export class ResidentDO extends Sandbox<Env> {
6104
6322
  parsed.mutableListing,
6105
6323
  "deps-seed-swap",
6106
6324
  );
6325
+ for (const rel of parsed.nested) {
6326
+ await this.swapMutableCaches(
6327
+ `${depsEntryPath(seed)}/${rel}`,
6328
+ `${scratch}/${rel}`,
6329
+ BUILD_USER,
6330
+ parsed.mutableListing,
6331
+ "deps-seed-swap",
6332
+ );
6333
+ }
6107
6334
  console.log(`deps: ${key.slice(0, 8)} seeded from ${seed.slice(0, 8)} (${parsed.mech})`);
6108
6335
  }
6109
6336
  // Unprivileged and token-free, in a tree only this attempt
@@ -6194,25 +6421,46 @@ export class ResidentDO extends Sandbox<Env> {
6194
6421
  return mech;
6195
6422
  }
6196
6423
 
6197
- /** The view itself, for callers already holding the mirror lock (the
6198
- * refresh rebuild and provisioning link the CHECKOUT this way). */
6424
+ /** The view itself, lock-agnostic: provisioning links the CHECKOUT under
6425
+ * the mirror lock it already holds; the staged rebuild links its staging
6426
+ * tree OFF the lock — safe because the tree is private to the rebuild and
6427
+ * the store entry's inodes are immutable (deps-harden). */
6199
6428
  private async linkDepsView(
6200
6429
  entryNodeModules: string | null,
6201
6430
  tree: string,
6202
6431
  user: string,
6432
+ /** The build dirs' source. The staged rebuild passes its own tree (its
6433
+ * clean just removed them — nothing to copy; the WARM checkout's build
6434
+ * output must never seed the tree that is about to rebuild it). */
6435
+ from: string = CHECKOUT_DIR,
6203
6436
  ): Promise<DepCacheMaterialization | "none"> {
6204
6437
  const parsed = await this.runDepScript(
6205
- depCacheScript(CHECKOUT_DIR, tree, user, entryNodeModules ? { nodeModulesSrc: entryNodeModules } : {}),
6438
+ depCacheScript(from, tree, user, entryNodeModules ? { nodeModulesSrc: entryNodeModules } : {}),
6206
6439
  "deps-materialize",
6207
6440
  REFRESH_BUILD_TIMEOUT_MS,
6208
6441
  );
6209
6442
  await this.swapMutableCaches(
6210
- entryNodeModules ?? `${CHECKOUT_DIR}/node_modules`,
6443
+ entryNodeModules ?? `${from}/node_modules`,
6211
6444
  `${tree}/node_modules`,
6212
6445
  user,
6213
6446
  parsed.mutableListing,
6214
6447
  "deps-mutable-swap",
6215
6448
  );
6449
+ // Nested workspace node_modules the script hardlinked from the entry get
6450
+ // the same tool-cache swap, each scoped to its own root (mutableCachePaths
6451
+ // keeps only the listing under that root).
6452
+ if (entryNodeModules) {
6453
+ const entryRoot = entryNodeModules.replace(/\/node_modules$/, "");
6454
+ for (const rel of parsed.nested) {
6455
+ await this.swapMutableCaches(
6456
+ `${entryRoot}/${rel}`,
6457
+ `${tree}/${rel}`,
6458
+ user,
6459
+ parsed.mutableListing,
6460
+ "deps-mutable-swap",
6461
+ );
6462
+ }
6463
+ }
6216
6464
  return parsed.mech;
6217
6465
  }
6218
6466
 
@@ -6553,6 +6801,10 @@ export class ResidentDO extends Sandbox<Env> {
6553
6801
  timeoutMs: number,
6554
6802
  env?: Record<string, string>,
6555
6803
  ): Promise<{ stdout: string; stderr: string; exitCode: number; truncated: boolean } | ThreadErr> {
6804
+ // Item 70: a new command is refused by name above the hard memory
6805
+ // threshold — before the preflight, so nothing of it starts.
6806
+ const memory = await this.memoryGate("exec");
6807
+ if (memory) return memory;
6556
6808
  const pre = await this.threadPreflight(threadKey);
6557
6809
  if ("error" in pre) return pre;
6558
6810
  const { binding } = pre;
@@ -7568,6 +7820,7 @@ export class ResidentDO extends Sandbox<Env> {
7568
7820
  RUNTIME_UNREACHABLE_KEY,
7569
7821
  AUTO_REBUILDS_KEY,
7570
7822
  INFRA_STREAK_KEY,
7823
+ MEMORY_KEY,
7571
7824
  ]);
7572
7825
  const facts = map.get(FACTS_KEY) as RepoFacts | undefined;
7573
7826
  const snap = map.get(SNAPSHOT_KEY) as SnapshotRecord | undefined;
@@ -7670,6 +7923,9 @@ export class ResidentDO extends Sandbox<Env> {
7670
7923
  // Item 55: the last disk sample (`residentDiskBudget.ts` DiskSample), or
7671
7924
  // null before the first measurement of this incarnation.
7672
7925
  disk,
7926
+ // Item 70: the last memory reading (used/cap bytes, percent), or null
7927
+ // before the first sample of this incarnation reaches storage.
7928
+ memory: (map.get(MEMORY_KEY) as MemoryReading | undefined) ?? null,
7673
7929
  // Item 64: consecutive connects the control port did not answer, with
7674
7930
  // the rung that count is on; null while the port answers.
7675
7931
  runtimeUnreachable: unreachable ? { ...unreachable, rung: runtimeUnreachableRung(unreachable.count) } : null,
@@ -8804,12 +9060,13 @@ async function handleStatus(env: Env, url: URL): Promise<Response> {
8804
9060
  // deploy gate reads /residents. The registry check rides in the same flight
8805
9061
  // (its 404 is judged first, the probes' results discarded then).
8806
9062
  const stub = residentStub(env, resource.resource);
8807
- const [record, status, inFlight, refresh, snapshot] = await Promise.all([
9063
+ const [record, status, inFlight, refresh, snapshot, memory] = await Promise.all([
8808
9064
  registryStub(env).getRecord(resource.resource),
8809
9065
  stub.getStatus(),
8810
9066
  stub.getInFlightCount(),
8811
9067
  stub.getRefreshView(),
8812
9068
  stub.snapshotHandle(),
9069
+ stub.memoryGauge(),
8813
9070
  ]);
8814
9071
  if (!record) return json({ error: `${resource.resource} is not onboarded` }, 404);
8815
9072
  // Item 7: which scheduler drives the refresh cycle and, on the Workflow
@@ -8822,6 +9079,9 @@ async function handleStatus(env: Env, url: URL): Promise<Response> {
8822
9079
  lifecycle: refresh.lifecycle,
8823
9080
  refresh: { instance: refresh.instance, skipped: refresh.skipped },
8824
9081
  snapshot,
9082
+ // Item 70: the last memory reading, so the bot and the residents page can
9083
+ // show what the resident's own gate is reading.
9084
+ memory,
8825
9085
  });
8826
9086
  }
8827
9087
 
@@ -9312,6 +9572,7 @@ async function runWatchdog(env: Env, parent?: TraceSpan): Promise<WatchdogSummar
9312
9572
  reason: s.value.reason,
9313
9573
  action: s.value.action,
9314
9574
  disk: s.value.disk,
9575
+ memory: s.value.memory,
9315
9576
  instance: s.value.instance,
9316
9577
  }
9317
9578
  : { resource: record.resource, error: errMsg(s.reason) };
@@ -207,5 +207,7 @@ RUN npm install -g --allow-scripts=@opencode/cli @opencode/cli@2.0.3 \
207
207
  # stays PID 1 — it reaps orphans and forwards the platform's signals — and
208
208
  # runs the supervisor, which starts the server again when it exits.
209
209
  # src/deploy/sandboxRuntimeSupervisor.test.ts holds this pair of lines.
210
- COPY --chmod=0755 runtime-supervisor.sh /usr/local/bin/sandbox-runtime-supervisor
211
- ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/sandbox-runtime-supervisor"]
210
+ # The .mjs extension keeps Node reading it as ESM; its `#!/usr/bin/env node`
211
+ # resolves to the Node copied in above.
212
+ COPY --chmod=0755 runtime-supervisor.mjs /usr/local/bin/sandbox-runtime-supervisor.mjs
213
+ ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/sandbox-runtime-supervisor.mjs"]
@@ -0,0 +1,31 @@
1
+ // Types for runtime-supervisor.mjs (plain-Node ESM: the sandbox image copies
2
+ // the file as-is, no build step). The seams exist so the tests can drive the
3
+ // loop with a fake child, a fake clock and a fake signal source.
4
+ export const DEFAULT_RUNTIME: "/container-server/sandbox";
5
+ export const QUICK_EXIT_SECS: 10;
6
+ export const QUICK_EXIT_LIMIT: 5;
7
+ export const RESTART_PAUSE_MS: 1000;
8
+
9
+ /** The least a spawned child must offer the loop. */
10
+ export type SupervisedChild = {
11
+ kill(signal: "SIGTERM"): unknown;
12
+ once(event: "exit", listener: (code: number | null, signal: string | null) => void): unknown;
13
+ once(event: "error", listener: (err: unknown) => void): unknown;
14
+ };
15
+
16
+ export type SignalSource = {
17
+ on(event: "SIGTERM" | "SIGINT", listener: () => void): unknown;
18
+ removeListener(event: "SIGTERM" | "SIGINT", listener: () => void): unknown;
19
+ };
20
+
21
+ export function endCode(code: number | null, signal: string | null): number;
22
+
23
+ export function supervise(deps: {
24
+ runtime: string;
25
+ args: string[];
26
+ spawn: (runtime: string, args: string[]) => SupervisedChild;
27
+ now: () => number;
28
+ sleep: (ms: number) => Promise<unknown>;
29
+ log: (line: string) => void;
30
+ signals: SignalSource;
31
+ }): Promise<number>;