@coreplane/switchboard 1.235.0 → 1.237.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/dist/assets/Dockerfile +11 -0
  2. package/dist/assets/config/config.example.yaml +9 -4
  3. package/dist/assets/deploy/cloudflare-memory/worker.ts +12 -0
  4. package/dist/assets/deploy/cloudflare-resident/Dockerfile +18 -0
  5. package/dist/assets/deploy/cloudflare-resident/worker.ts +290 -118
  6. package/dist/assets/deploy/cloudflare-sandbox/Dockerfile +11 -0
  7. package/dist/assets/deploy/cloudflare-sandbox/worker.ts +104 -17
  8. package/dist/assets/package-lock.json +212 -3
  9. package/dist/assets/package.json +4 -1
  10. package/dist/assets/source.json +3 -3
  11. package/dist/assets/src/core/authz/authorize.ts +25 -6
  12. package/dist/assets/src/core/authz/policy.ts +9 -0
  13. package/dist/assets/src/core/authz/types.ts +15 -1
  14. package/dist/assets/src/core/coordinator/contract.ts +24 -4
  15. package/dist/assets/src/core/coordinator/driver.ts +36 -4
  16. package/dist/assets/src/core/runEvents.ts +46 -7
  17. package/dist/assets/src/core/runFriction.ts +1 -0
  18. package/dist/assets/src/core/ship/contract.ts +3 -1
  19. package/dist/assets/src/core/ship/coordinator.ts +236 -30
  20. package/dist/assets/src/core/trace/types.ts +5 -0
  21. package/dist/assets/src/execution/residentDepsStore.ts +14 -2
  22. package/dist/assets/src/execution/residentDisk.ts +21 -10
  23. package/dist/assets/src/execution/residentDiskBudget.ts +45 -11
  24. package/dist/assets/src/execution/residentHead.ts +34 -8
  25. package/dist/assets/src/execution/residentRebind.ts +88 -31
  26. package/dist/assets/src/execution/sandboxErrors.ts +72 -0
  27. package/dist/assets/src/execution/sandboxLifecycle.ts +33 -0
  28. package/dist/assets/src/execution/sandboxStart.ts +90 -0
  29. package/dist/assets/web/dist/.vite/manifest.json +437 -423
  30. package/dist/assets/web/dist/assets/AppShell-MYHNxJks.js +1 -0
  31. package/dist/assets/web/dist/assets/{CostsPage-D-v8am88.js → CostsPage-DKk9OjB0.js} +1 -1
  32. package/dist/assets/web/dist/assets/{DeliveryPage-CvlWP7Eq.js → DeliveryPage-Kz6js49i.js} +1 -1
  33. package/dist/assets/web/dist/assets/{NotFoundPage-BzWS4Ca1.js → NotFoundPage-BXLrQQzy.js} +1 -1
  34. package/dist/assets/web/dist/assets/ResidentDetailPage-Dc23v_8v.js +1 -0
  35. package/dist/assets/web/dist/assets/ResidentsIndexPage-TR5D1TRR.js +1 -0
  36. package/dist/assets/web/dist/assets/RunFoldRow-mkUj7WEN.js +9 -0
  37. package/dist/assets/web/dist/assets/{RunRoutePage-oN9GkVEA.js → RunRoutePage-CQI17__g.js} +3 -3
  38. package/dist/assets/web/dist/assets/RunsIndexPage-yIP4PlKP.js +1 -0
  39. package/dist/assets/web/dist/assets/{RunsTabs-DEICQ4FY.js → RunsTabs-XpCqeWu4.js} +1 -1
  40. package/dist/assets/web/dist/assets/ScheduledPage-CdCVyNbh.js +1 -0
  41. package/dist/assets/web/dist/assets/SettingsPage-DuA8JpLU.js +1 -0
  42. package/dist/assets/web/dist/assets/{StatusDot-D14iJMy7.js → StatusDot-DmNHX6am.js} +1 -1
  43. package/dist/assets/web/dist/assets/{Tooltip-B0Ob5MQ4.js → Tooltip-qVnJTgSt.js} +1 -1
  44. package/dist/assets/web/dist/assets/UnitRoutePage-BcTeH7m2.js +1 -0
  45. package/dist/assets/web/dist/assets/{angular-html-Cw130Zyi.js → angular-html-DYtV9uIN.js} +1 -1
  46. package/dist/assets/web/dist/assets/{angular-ts-DY1m4C2s.js → angular-ts-DF2P25Zp.js} +1 -1
  47. package/dist/assets/web/dist/assets/{apl-W6Ty7v05.js → apl-Ff96wxko.js} +1 -1
  48. package/dist/assets/web/dist/assets/{astro-DlLVHwVk.js → astro-LbBnZna-.js} +1 -1
  49. package/dist/assets/web/dist/assets/{blade-Bco3tDh4.js → blade-DltuxeBW.js} +1 -1
  50. package/dist/assets/web/dist/assets/{c-1LaFY_cj.js → c-DBg7yuPx.js} +1 -1
  51. package/dist/assets/web/dist/assets/{chapel-B-OvrOL5.js → chapel-C83hUAds.js} +1 -1
  52. package/dist/assets/web/dist/assets/{cobol-Ch3EPlHu.js → cobol-DB0yV_2D.js} +1 -1
  53. package/dist/assets/web/dist/assets/{coffee-hZuITnto.js → coffee-Bpih6Kzl.js} +1 -1
  54. package/dist/assets/web/dist/assets/{cpp-B8iRVo8m.js → cpp-CU00ZeRn.js} +1 -1
  55. package/dist/assets/web/dist/assets/{crystal-DKveI7lr.js → crystal-f8ImJkux.js} +1 -1
  56. package/dist/assets/web/dist/assets/{css-_2_CD-rr.js → css-4VrC4ISo.js} +1 -1
  57. package/dist/assets/web/dist/assets/{dist-pOdUzj6D.js → dist-TAGawIfM.js} +2 -2
  58. package/dist/assets/web/dist/assets/durationTone-CYMux25J.js +1 -0
  59. package/dist/assets/web/dist/assets/{edge-BenMDPct.js → edge-BwFDzfS_.js} +1 -1
  60. package/dist/assets/web/dist/assets/{elixir-CBbdEqG6.js → elixir-wwIYIpJm.js} +1 -1
  61. package/dist/assets/web/dist/assets/{elm-DMub_2lY.js → elm-DyDuM3v3.js} +1 -1
  62. package/dist/assets/web/dist/assets/{erb-CI9He_Up.js → erb-DFJBEVrh.js} +1 -1
  63. package/dist/assets/web/dist/assets/{favicon-BQsePYv5.js → favicon-CSt-qDvc.js} +1 -1
  64. package/dist/assets/web/dist/assets/{git-rebase-BBfhnyF8.js → git-rebase-CuDv-44X.js} +1 -1
  65. package/dist/assets/web/dist/assets/{glimmer-js-CR9Fxrau.js → glimmer-js-ZFnrm4J3.js} +1 -1
  66. package/dist/assets/web/dist/assets/{glimmer-ts-CwFwX_XS.js → glimmer-ts-XY4Yqshp.js} +1 -1
  67. package/dist/assets/web/dist/assets/{glsl-Daiodp1r.js → glsl-xnR8efx0.js} +1 -1
  68. package/dist/assets/web/dist/assets/{graphql-DZvrsEkB.js → graphql-D-gO5tnP.js} +1 -1
  69. package/dist/assets/web/dist/assets/{hack-BaXOzdg7.js → hack-CSxbW4ax.js} +1 -1
  70. package/dist/assets/web/dist/assets/{haml-CMskq_VA.js → haml-D4ar42QT.js} +1 -1
  71. package/dist/assets/web/dist/assets/{handlebars-Dhw_f7jD.js → handlebars-CMLbDE7o.js} +1 -1
  72. package/dist/assets/web/dist/assets/{html-C9k99z0z.js → html-CVekU4Fs.js} +1 -1
  73. package/dist/assets/web/dist/assets/{html-derivative-VckPItXN.js → html-derivative-St3HHw_N.js} +1 -1
  74. package/dist/assets/web/dist/assets/{http-DXU_3h9v.js → http-Mon0DWHS.js} +1 -1
  75. package/dist/assets/web/dist/assets/{hurl-KU24JNdp.js → hurl-Cbl5IvBs.js} +1 -1
  76. package/dist/assets/web/dist/assets/indexRow-FLJu5mR9.js +1 -0
  77. package/dist/assets/web/dist/assets/{java-BbZjTNgf.js → java-Bprck1l-.js} +1 -1
  78. package/dist/assets/web/dist/assets/{javascript-DXCdqcZZ.js → javascript-C73G1-UR.js} +1 -1
  79. package/dist/assets/web/dist/assets/{jinja-pDlsjTVp.js → jinja-C4DuRxYJ.js} +1 -1
  80. package/dist/assets/web/dist/assets/{jison-DsXDk66e.js → jison-B2U03GEC.js} +1 -1
  81. package/dist/assets/web/dist/assets/{json-tCxXfRgG.js → json-CvTZUDzx.js} +1 -1
  82. package/dist/assets/web/dist/assets/{jsx-B0ZYkCmi.js → jsx-DMWKCzwo.js} +1 -1
  83. package/dist/assets/web/dist/assets/{julia-BLzzeI6x.js → julia-Czn7bV_3.js} +1 -1
  84. package/dist/assets/web/dist/assets/{just-DvD2_60c.js → just-BBxpNfvC.js} +1 -1
  85. package/dist/assets/web/dist/assets/{latex-511zn3h7.js → latex-Bw9yg5mr.js} +1 -1
  86. package/dist/assets/web/dist/assets/{liquid-BD0BkMox.js → liquid-BRCjaydE.js} +1 -1
  87. package/dist/assets/web/dist/assets/{indexFormat-B-pd4r7Y.js → localIso-CNA-bhS-.js} +1 -1
  88. package/dist/assets/web/dist/assets/{lua-NFicKm6N.js → lua-DUu2KIC0.js} +1 -1
  89. package/dist/assets/web/dist/assets/main-csihqEj5.css +1 -0
  90. package/dist/assets/web/dist/assets/main-mUd_TJjH.js +28 -0
  91. package/dist/assets/web/dist/assets/{marko-1-G_nEXK.js → marko-CZJZIlSQ.js} +1 -1
  92. package/dist/assets/web/dist/assets/{mdc-DUe3AQLp.js → mdc-BmClq6fd.js} +1 -1
  93. package/dist/assets/web/dist/assets/{nginx-Dg379_bA.js → nginx-Bmt3xSEm.js} +1 -1
  94. package/dist/assets/web/dist/assets/{nim-BnFq97ZO.js → nim-Da-i0utK.js} +1 -1
  95. package/dist/assets/web/dist/assets/{org-F0uiwGvq.js → org-BvAre2qP.js} +1 -1
  96. package/dist/assets/web/dist/assets/{perl-B0euczkl.js → perl-v9WwFFtB.js} +1 -1
  97. package/dist/assets/web/dist/assets/{php-DkL3k_n7.js → php-DTU4Zyze.js} +1 -1
  98. package/dist/assets/web/dist/assets/{pug-L_OjjZP4.js → pug-DhTwEqTO.js} +1 -1
  99. package/dist/assets/web/dist/assets/{qml-BMjB00Zz.js → qml-BUD9AC_c.js} +1 -1
  100. package/dist/assets/web/dist/assets/{r-DoeLdnqR.js → r-CAnu56n7.js} +1 -1
  101. package/dist/assets/web/dist/assets/{razor-BzYNkAWy.js → razor-Dn0P4gAq.js} +1 -1
  102. package/dist/assets/web/dist/assets/{regexp-CPmElMk3.js → regexp-CZyBRaVW.js} +1 -1
  103. package/dist/assets/web/dist/assets/residentsModel-By1fThvk.js +1 -0
  104. package/dist/assets/web/dist/assets/{rst-DrsjgfI7.js → rst-Ckyd5UsY.js} +1 -1
  105. package/dist/assets/web/dist/assets/{ruby-cB42ppvx.js → ruby-BBQ62fsx.js} +1 -1
  106. package/dist/assets/web/dist/assets/{sas-R5Y3NM_I.js → sas-CW35hMxG.js} +1 -1
  107. package/dist/assets/web/dist/assets/{scss-C-zWxV9s.js → scss-CpV8pF4J.js} +1 -1
  108. package/dist/assets/web/dist/assets/{shellscript-oJF96aAU.js → shellscript-CRJvsmE1.js} +1 -1
  109. package/dist/assets/web/dist/assets/{shellsession-CznECKyi.js → shellsession-CyriUZxf.js} +1 -1
  110. package/dist/assets/web/dist/assets/{soy-wjHLSRag.js → soy-D5QzYUeg.js} +1 -1
  111. package/dist/assets/web/dist/assets/{sql-C_BM8IOW.js → sql-CUBf_Mse.js} +1 -1
  112. package/dist/assets/web/dist/assets/{stata-D31HO8aQ.js → stata-B-4n4ITz.js} +1 -1
  113. package/dist/assets/web/dist/assets/{surrealql-CKCLyplA.js → surrealql-RuWZBLtr.js} +1 -1
  114. package/dist/assets/web/dist/assets/{svelte-3geWk2iu.js → svelte-CO5YzCsK.js} +1 -1
  115. package/dist/assets/web/dist/assets/{templ-DiJr_hTD.js → templ-DbkWVaBF.js} +1 -1
  116. package/dist/assets/web/dist/assets/{tex-BEJGWyeF.js → tex-IuxoEcId.js} +1 -1
  117. package/dist/assets/web/dist/assets/{ts-tags-C8Mzdpxx.js → ts-tags-g6dGQPjd.js} +1 -1
  118. package/dist/assets/web/dist/assets/{tsx-UQiSm_p3.js → tsx-WnXwiyH2.js} +1 -1
  119. package/dist/assets/web/dist/assets/{twig-_Pvzkeo9.js → twig-Lx1G7PnY.js} +1 -1
  120. package/dist/assets/web/dist/assets/{typescript-BWMkINKK.js → typescript-BQd7biLF.js} +1 -1
  121. package/dist/assets/web/dist/assets/{typst-Bdg9m-e7.js → typst-D2lXPHK7.js} +1 -1
  122. package/dist/assets/web/dist/assets/{vue-CDZrrAZ8.js → vue-4wVQD9y9.js} +1 -1
  123. package/dist/assets/web/dist/assets/{vue-html-tAlpNhfN.js → vue-html-DZPaRte4.js} +1 -1
  124. package/dist/assets/web/dist/assets/{vue-vine-C27UF_rh.js → vue-vine-DbmZCmu-.js} +1 -1
  125. package/dist/assets/web/dist/assets/{xml-CxeDr9Zh.js → xml-DV16szyo.js} +1 -1
  126. package/dist/assets/web/dist/assets/{xsl-Bpi0uUnM.js → xsl-eo5-1sk0.js} +1 -1
  127. package/dist/assets/web/dist/assets/{yaml-BtUgZ1GN.js → yaml-BXdX8HZf.js} +1 -1
  128. package/dist/cli.js +3329 -1804
  129. package/package.json +1 -1
  130. package/dist/assets/web/dist/assets/AppShell-lYVcj-k7.js +0 -1
  131. package/dist/assets/web/dist/assets/ResidentDetailPage-DSpsye5e.js +0 -1
  132. package/dist/assets/web/dist/assets/ResidentsIndexPage-CT8fI1M3.js +0 -1
  133. package/dist/assets/web/dist/assets/RunFoldRow-Cqh8pTXC.js +0 -9
  134. package/dist/assets/web/dist/assets/RunsIndexPage-sjXr8iCV.js +0 -1
  135. package/dist/assets/web/dist/assets/ScheduledPage-Tz__zLzi.js +0 -1
  136. package/dist/assets/web/dist/assets/UnitRoutePage-CbKCL58v.js +0 -1
  137. package/dist/assets/web/dist/assets/indexRow-Bnj883ii.js +0 -1
  138. package/dist/assets/web/dist/assets/localIso-L06jV29p.js +0 -1
  139. package/dist/assets/web/dist/assets/main-D3lG-yQ9.js +0 -28
  140. package/dist/assets/web/dist/assets/main-Dm11o0hc.css +0 -1
  141. package/dist/assets/web/dist/assets/residentDiskBudget-BMBKlYRH.js +0 -1
@@ -151,9 +151,19 @@ export { parseDfKiB };
151
151
  // ---------------------------------------------------------------------------
152
152
 
153
153
  /** The refresh cycle snapshots mirror + checkout to R2 through the Sandbox
154
- * SDK (`createBackup`); whether it stages a tarball on local disk is
155
- * SDK-internal, so the budget holds room for one compressed copy of what it
156
- * archives — the same ratio `instanceSizing.test.ts` sizes the instance with. */
154
+ * SDK (`createBackup`), which stages each archive on the container's own disk
155
+ * (`/var/backups/<id>.sqsh`, lz4 squashfs) before the upload and removes it
156
+ * after, the pair concurrently — so the budget holds room for one compressed
157
+ * copy of what the cycle archives, at the ratio `instanceSizing.test.ts` sizes
158
+ * the instance with. Since item 61 PR B the checkout archive EXCLUDES
159
+ * node_modules (`CHECKOUT_SNAPSHOT_EXCLUDES`): the deps are not in the
160
+ * cycle's staging. The deps-store entry has its own archive, taken once per
161
+ * lockfile key right after the entry is committed, and that one IS staged
162
+ * from the deps — the reserve counts it only while such a backup is in
163
+ * flight (`depsBackupInFlight`), never as a standing charge. Before this,
164
+ * the standing term multiplied the deps too: on a resident whose deps are
165
+ * 6.9 GiB of an 8.3 GiB archived set, five gigabytes were held for an archive
166
+ * of about one, and every attach was refused with 6.7 GiB free. */
157
167
  export const SNAPSHOT_STAGING_RATIO = 0.6;
158
168
 
159
169
  /** The fixed floor under the staging term: at least 1 GiB, or 5 % of the disk
@@ -173,13 +183,26 @@ export interface DiskReserve {
173
183
  totalKiB: number;
174
184
  }
175
185
 
186
+ /** What the reserve knows beyond the sample: whether a deps-store entry
187
+ * backup is being taken right now (item 61), whose archive is staged from
188
+ * the deps and so needs their share of the ratio while it runs. */
189
+ export interface DiskReserveInput {
190
+ depsBackupInFlight?: boolean;
191
+ }
192
+
176
193
  /** `staging + floor`. Staging is computed from the MEASURED mirror and
177
- * checkout (deps + rest); an unmeasured part counts as 0 there — the floor
178
- * still stands, and `projectThreadCostKiB` refuses to project from missing
179
- * parts, so an unmeasured resident never admits on a guess. */
180
- export function diskReserveKiB(sample: Pick<DiskSample, "totalKiB" | "parts">): DiskReserve {
181
- const archived = (sample.parts.mirror ?? 0) + (sample.parts.deps ?? 0) + (sample.parts.checkout ?? 0);
182
- const stagingKiB = Math.round(archived * SNAPSHOT_STAGING_RATIO);
194
+ * checkout (the checkout's own bytes: history + tree + build output, never
195
+ * the deps, which the checkout archive excludes), plus the deps while a
196
+ * deps-store entry backup is in flight; an unmeasured part counts as 0 there
197
+ * — the floor still stands, and `projectThreadCostKiB` refuses to project
198
+ * from missing parts, so an unmeasured resident never admits on a guess. */
199
+ export function diskReserveKiB(
200
+ sample: Pick<DiskSample, "totalKiB" | "parts">,
201
+ input: DiskReserveInput = {},
202
+ ): DiskReserve {
203
+ const staged = (sample.parts.mirror ?? 0) + (sample.parts.checkout ?? 0);
204
+ const depsStaged = input.depsBackupInFlight ? (sample.parts.deps ?? 0) : 0;
205
+ const stagingKiB = Math.round((staged + depsStaged) * SNAPSHOT_STAGING_RATIO);
183
206
  const floorKiB = Math.max(DISK_FLOOR_MIN_KIB, Math.round(sample.totalKiB * DISK_FLOOR_FRACTION));
184
207
  return { stagingKiB, floorKiB, totalKiB: stagingKiB + floorKiB };
185
208
  }
@@ -261,11 +284,14 @@ export function checkDiskAdmission(input: {
261
284
  committedKiB?: number;
262
285
  diskBudgetMb?: number;
263
286
  kind: ThreadCostKind;
287
+ /** A deps-store entry backup is in flight: its archive is staged from the
288
+ * deps, so the reserve holds their share for as long as it runs. */
289
+ depsBackupInFlight?: boolean;
264
290
  }): AdmissionVerdict {
265
291
  const rawFree = Math.max(0, (input.freeKiB ?? input.sample.freeKiB) - (input.committedKiB ?? 0));
266
292
  const live = { ...input.sample, freeKiB: rawFree, usedKiB: input.sample.totalKiB - rawFree };
267
293
  const { capacityKiB, freeKiB, capped } = effectiveFreeKiB(live, input.diskBudgetMb);
268
- const reserve = diskReserveKiB(input.sample);
294
+ const reserve = diskReserveKiB(input.sample, { depsBackupInFlight: input.depsBackupInFlight });
269
295
  const projectedKiB = projectThreadCostKiB(input.sample.parts, input.kind);
270
296
  const headroomKiB = freeKiB - reserve.totalKiB - (projectedKiB ?? 0);
271
297
  const math: AdmissionMath = { projectedKiB, kind: input.kind, freeKiB, capacityKiB, capped, reserve, headroomKiB };
@@ -401,6 +427,9 @@ export function diskPressureReason(input: {
401
427
  verdict: Extract<AdmissionVerdict, { fits: false }>;
402
428
  evicted: ReadonlyArray<{ freedKiB: number | null }>;
403
429
  kept: ReadonlyArray<{ why: DiskKeepWhy }>;
430
+ /** Deps-store spares removed first under the pressure (item 55), with the
431
+ * bytes each gave back. */
432
+ spares?: ReadonlyArray<{ freedKiB: number }>;
404
433
  }): string {
405
434
  const { math } = input.verdict;
406
435
  const need =
@@ -412,10 +441,15 @@ export function diskPressureReason(input: {
412
441
  `${DISK_PRESSURE_REASON}: need ${need}, but ${formatGiB(math.freeKiB)} free${cap} minus the ${formatGiB(math.reserve.totalKiB)} reserve ` +
413
442
  `(snapshot staging ${formatGiB(math.reserve.stagingKiB)} + floor ${formatGiB(math.reserve.floorKiB)}) leaves ${formatGiB(Math.max(0, math.freeKiB - math.reserve.totalKiB))} — short by ${formatGiB(input.verdict.shortfallKiB)}`,
414
443
  ];
444
+ const spares = input.spares ?? [];
445
+ if (spares.length > 0) {
446
+ const freed = spares.reduce((a, e) => a + e.freedKiB, 0);
447
+ parts.push(`evicted ${spares.length} deps-store spare(s) (${formatGiB(freed)} back)`);
448
+ }
415
449
  if (input.evicted.length > 0) {
416
450
  const freed = input.evicted.reduce((a, e) => a + (e.freedKiB ?? 0), 0);
417
451
  parts.push(`evicted ${input.evicted.length} idle tree(s) (${formatGiB(freed)} back)`);
418
- } else parts.push("evicted nothing");
452
+ } else parts.push(spares.length > 0 ? "evicted no idle tree" : "evicted nothing");
419
453
  if (input.kept.length > 0) {
420
454
  const counts = new Map<DiskKeepWhy, number>();
421
455
  for (const k of input.kept) counts.set(k.why, (counts.get(k.why) ?? 0) + 1);
@@ -42,19 +42,45 @@ export function wantShaForBinding(input: {
42
42
  return input.wantSha;
43
43
  }
44
44
 
45
- /** Whether the attach must fetch the mirror before cloning the thread tree.
46
- * - the ref is not in the mirror → fetch (the only pre-existing rule);
45
+ /** Why the attach fetches the mirror before cloning the thread tree. The
46
+ * Worker treats the three differently when the fetch fails: a `missing-ref`
47
+ * or `stale-tip` fetch that fails fails the attach, as it always did; a
48
+ * `returnable-ref` fetch only verifies a ref the mirror holds, so its
49
+ * failure is one log line and the attach goes on with the mirror's ref. */
50
+ export type FetchReason = "missing-ref" | "stale-tip" | "returnable-ref";
51
+
52
+ /** Whether — and why — the attach must fetch the mirror before cloning the
53
+ * thread tree; null when the mirror is good enough as it stands.
54
+ * - the ref is not in the mirror → `missing-ref` (the oldest rule);
47
55
  * - a `wantSha` was named and the mirror's tip of the ref is not that
48
- * commit → fetch;
49
- * - otherwise the mirror is good enough as it stands.
56
+ * commit → `stale-tip`;
57
+ * - the bound ref is one the binding could return from — a branch a rebind
58
+ * moved it onto, or one the thread's own runs pushed (`canReturnToDefault`,
59
+ * resident-repos.md item 16) → `returnable-ref`: such a branch dies when
60
+ * its pull request merges, and the deletion reaches the mirror only through
61
+ * a `fetch --prune`. Trusting the mirror because it still holds the ref
62
+ * would provision a tree at the deleted branch's stale tip until the
63
+ * refresh cycle's prune caught up — whether the thread returned to the
64
+ * default would depend on where in the cycle its follow-up landed. So the
65
+ * ref is verified against the origin at every attach, and the return is
66
+ * decided at the first attach after the deletion. The default cannot be
67
+ * lost and a ref a person named that the thread never pushed has no way
68
+ * back, so neither pays this fetch: the refresh cycle is their freshness.
69
+ * - otherwise null.
50
70
  * A fetch that STILL leaves the tip elsewhere (a push racing this attach, or
51
71
  * a force-push) is not this function's concern: the attach proceeds on the
52
72
  * fetched tip and reports it, and the reviewed-head guard decides what a
53
73
  * review of it may do. */
54
- export function mirrorNeedsFetch(input: { refExists: boolean; mirrorSha?: string; wantSha: string | null }): boolean {
55
- if (!input.refExists) return true;
56
- if (input.wantSha === null) return false;
57
- return input.mirrorSha !== input.wantSha;
74
+ export function mirrorFetchReason(input: {
75
+ refExists: boolean;
76
+ mirrorSha?: string;
77
+ wantSha: string | null;
78
+ returnable: boolean;
79
+ }): FetchReason | null {
80
+ if (!input.refExists) return "missing-ref";
81
+ if (input.wantSha !== null && input.mirrorSha !== input.wantSha) return "stale-tip";
82
+ if (input.returnable) return "returnable-ref";
83
+ return null;
58
84
  }
59
85
 
60
86
  /** What the attach checks out, once the mirror is as fresh as it will get. */
@@ -35,15 +35,21 @@
35
35
  * Every refusal is named in the attach answer so the bot can say why the
36
36
  * follow-up runs where it does.
37
37
  *
38
- * The second movement: a rebound binding names a branch that can die — the
39
- * pull request merges and the branch is deleted. A binding left on it would
40
- * fail every later attach (`unknown-ref`) for the thread's whole life. So a
41
- * binding a rebind moved, whose branch the mirror no longer holds after a
42
- * fetch, goes back to the default it was bound to (`canReturnToDefault`,
43
- * `returnToDefault`); the attach provisions the tree there, clean, and the
44
- * thread is default-bound again, so a later own pull request may move it once
45
- * more. A ref a person named that vanished keeps the `unknown-ref` refusal:
46
- * that branch is the person's to sort out. */
38
+ * The second movement: a binding can sit on a branch that dies — the pull
39
+ * request merges and the branch is deleted. A binding left on it would fail
40
+ * every later attach (`unknown-ref`) for the thread's whole life. So a
41
+ * binding whose ref the mirror no longer holds after a fetch goes back to the
42
+ * default (`canReturnToDefault`, `returnToDefault`) when the gone ref is
43
+ * EITHER the branch a rebind moved it onto OR one of the thread's own
44
+ * branches — a branch this thread's runs pushed (`ownBranches`), whatever
45
+ * `boundBy` says: a ship unit's coding child binds its unit branch by name,
46
+ * pushes it and opens the pull request, and the unit's merge deletes it. The
47
+ * attach provisions the tree at the default, clean, and the thread is
48
+ * default-bound again (`boundBy: default` — the default was chosen for want
49
+ * of the branch it was on), so a later own pull request may move it once
50
+ * more. A ref a person named that the thread never pushed keeps the
51
+ * `unknown-ref` refusal: a person's branch that is gone is not this thread's
52
+ * finished work, and the resident never swaps it for the default unasked. */
47
53
 
48
54
  /** The pull request the thread's own run opened, and its head branch — the
49
55
  * reason a caller's refHint is that branch. */
@@ -136,9 +142,15 @@ export function rememberOwnBranches(
136
142
  return [...kept, ...added].slice(-OWN_BRANCHES_MAX);
137
143
  }
138
144
 
145
+ /** The binding's memory of `ref` as a branch the thread's own runs pushed —
146
+ * the pull request it heads and when it was told — or none. */
147
+ export function ownBranchOf(binding: { ownBranches?: readonly OwnBranch[] }, ref: string): OwnBranch | undefined {
148
+ return (binding.ownBranches ?? []).find((b) => b.ref === ref);
149
+ }
150
+
139
151
  /** Whether the thread's own runs pushed `ref`, as the binding remembers it. */
140
152
  export function isOwnBranch(binding: { ownBranches?: readonly OwnBranch[] }, ref: string): boolean {
141
- return (binding.ownBranches ?? []).some((b) => b.ref === ref);
153
+ return ownBranchOf(binding, ref) !== undefined;
142
154
  }
143
155
 
144
156
  /** How a binding's ref was chosen: the repo default for want of a named
@@ -171,8 +183,12 @@ export interface Rebound {
171
183
  returnedAt?: string;
172
184
  }
173
185
 
174
- /** The move back, in the attach answer: from the branch that is gone, to the
175
- * default, for the pull request whose branch it was, when. */
186
+ /** The move back (the second movement), on the binding and in the attach
187
+ * answer: from the branch that is gone, to the default, for the pull request
188
+ * whose branch it was — the rebind's, or the one the binding remembers the
189
+ * branch heading — when. On the binding it is the LAST move back: a rebind's
190
+ * own record keeps its `returnedAt` beside it, and a binding never rebound
191
+ * (its unit branch bound by name and pushed) has only this. */
176
192
  export interface Returned {
177
193
  from: string;
178
194
  to: string;
@@ -199,6 +215,8 @@ export interface RebindableBinding {
199
215
  evicted?: boolean;
200
216
  boundBy?: BoundBy;
201
217
  rebound?: Rebound;
218
+ /** The binding's last move back to the default (the second movement). */
219
+ returned?: Returned;
202
220
  /** The branches the thread's own runs pushed, handed over at each release. */
203
221
  ownBranches?: OwnBranch[];
204
222
  }
@@ -320,29 +338,68 @@ export function rebindVerdict(plan: { to: string; pr: number; own?: boolean }, t
320
338
  return { kind: "rebind" };
321
339
  }
322
340
 
323
- /** Whether a binding whose ref the mirror no longer holds goes back to the
324
- * default branch (the second movement): only a binding a rebind moved onto
325
- * its own pull request's branch — bound by default in the first place, still
326
- * on that branch, the move not yet returned. Anything else keeps the
327
- * attach's `unknown-ref` refusal: a ref a person named is that person's to
328
- * sort out, and a binding on the default cannot lose its ref. */
329
- export function canReturnToDefault(
330
- binding: { ref: string; boundBy?: BoundBy; rebound?: Rebound },
341
+ /** What the second movement reads off a binding. */
342
+ export type ReturnableBinding = Pick<RebindableBinding, "ref" | "boundBy" | "rebound" | "ownBranches">;
343
+
344
+ /** The way back a binding whose ref is gone may take, if any — the one place
345
+ * the second movement is decided, so the predicate and the move cannot
346
+ * disagree: `rebind` — a rebind moved the binding onto the branch (bound by
347
+ * default in the first place, still on that branch, the move not yet
348
+ * returned); `own` — the branch is one this thread's own runs pushed, as the
349
+ * binding remembers it, whatever `boundBy` says. A binding on the default
350
+ * cannot lose its ref, and a ref a person named that the thread never pushed
351
+ * has no way back: that branch is the person's to sort out. */
352
+ function wayBack(
353
+ binding: ReturnableBinding,
331
354
  defaultRef: string,
332
- ): boolean {
355
+ ): { kind: "rebind"; rebound: Rebound } | { kind: "own"; branch: OwnBranch } | undefined {
356
+ if (binding.ref === defaultRef) return undefined;
333
357
  const r = binding.rebound;
334
- if (r === undefined || r.returnedAt !== undefined || binding.ref !== r.to) return false;
335
- return binding.ref !== defaultRef && boundByOf(binding, defaultRef) === "default";
358
+ if (
359
+ r !== undefined &&
360
+ r.returnedAt === undefined &&
361
+ binding.ref === r.to &&
362
+ boundByOf(binding, defaultRef) === "default"
363
+ ) {
364
+ return { kind: "rebind", rebound: r };
365
+ }
366
+ const branch = ownBranchOf(binding, binding.ref);
367
+ return branch === undefined ? undefined : { kind: "own", branch };
368
+ }
369
+
370
+ /** Whether a binding whose ref the mirror no longer holds goes back to the
371
+ * default branch (the second movement): a binding a rebind moved onto its
372
+ * own pull request's branch, or one on a branch this thread itself pushed.
373
+ * Anything else keeps the attach's `unknown-ref` refusal. */
374
+ export function canReturnToDefault(binding: ReturnableBinding, defaultRef: string): boolean {
375
+ return wayBack(binding, defaultRef) !== undefined;
336
376
  }
337
377
 
338
- /** The move back: the binding's ref becomes the default and the move that
339
- * brought it here is stamped returned, so the thread may move again. Only
340
- * ever applied to a binding `canReturnToDefault` admitted. */
341
- export function returnToDefault<B extends { ref: string; rebound?: Rebound }>(
342
- binding: B & { rebound: Rebound },
378
+ /** The move back: the binding's ref becomes the default, the binding reads as
379
+ * bound by default (the default was chosen for want of the branch it was on,
380
+ * so its next own pull request may move it — a binding that named its unit
381
+ * branch included), a rebind's move is stamped returned, and the last move
382
+ * back is recorded. The pull request named is the rebind's, else the one the
383
+ * binding remembers the gone branch heading. Undefined for a binding
384
+ * `canReturnToDefault` does not admit: nothing to write. */
385
+ export function returnToDefault<B extends ReturnableBinding>(
386
+ binding: B,
343
387
  defaultRef: string,
344
388
  at: string,
345
- ): { binding: B; returned: Returned } {
346
- const returned: Returned = { from: binding.ref, to: defaultRef, pr: binding.rebound.pr, at };
347
- return { binding: { ...binding, ref: defaultRef, rebound: { ...binding.rebound, returnedAt: at } }, returned };
389
+ ): { binding: B; returned: Returned } | undefined {
390
+ const way = wayBack(binding, defaultRef);
391
+ if (way === undefined) return undefined;
392
+ const pr = way.kind === "rebind" ? way.rebound.pr : way.branch.pr;
393
+ const returned: Returned = { from: binding.ref, to: defaultRef, pr, at };
394
+ const rebound = way.kind === "rebind" ? { ...way.rebound, returnedAt: at } : binding.rebound;
395
+ return {
396
+ binding: {
397
+ ...binding,
398
+ ref: defaultRef,
399
+ boundBy: "default",
400
+ ...(rebound !== undefined ? { rebound } : {}),
401
+ returned,
402
+ },
403
+ returned,
404
+ };
348
405
  }
@@ -125,6 +125,78 @@ export function fleetBusyExhaustedMessage(waitedMs: number): string {
125
125
  );
126
126
  }
127
127
 
128
+ // ---------------------------------------------------------------------------
129
+ // A container that is still starting (docs/reference/specs/execution.md item 23).
130
+ //
131
+ // Why this exists: a thread's first request finds no running container, and
132
+ // the SDK's first exec then carries the whole start — the platform's instance
133
+ // grant (its default wait 30 s), the image pull on a machine that has not seen
134
+ // this image, the microVM boot and the runtime's port (90 s) — before the
135
+ // command runs. The executor's per-send deadline for a 60 s command is 90 s,
136
+ // so every fresh-sandbox run died with "gave no answer within 90s" while its
137
+ // container came up a minute later and sat idle. The Worker now names the
138
+ // condition instead: it starts the container in the background and answers
139
+ // `sandbox-starting` at once, the executor waits on the token exactly as it
140
+ // waits on `fleet-busy` — the identical request re-sent, nothing ran — under a
141
+ // start budget of its own, and the command runs once the container is up.
142
+
143
+ /** The machine token the executor waits on, beside `fleet-busy`. */
144
+ export const SANDBOX_STARTING_REASON = "sandbox-starting" as const;
145
+
146
+ /** What the token means, in the words the model and the operator see. */
147
+ export const SANDBOX_STARTING_EXPLANATION =
148
+ "the thread's sandbox container is starting (image pull, boot, runtime) — nothing ran yet; the request is re-sent once it is up";
149
+
150
+ /** The executor waits at most this long for a container to start, whatever
151
+ * the command's own budget: the platform's own start allowances (30 s for an
152
+ * instance, 90 s for the port) plus a slow image pull fit inside it, and a
153
+ * 60 s command is never killed by a two-minute start it did not cause. */
154
+ export const SANDBOX_START_WAIT_MAX_MS = 5 * 60_000;
155
+
156
+ /** Backoff between re-sends while a container starts: 5 s, 10 s, then 15 s.
157
+ * A start takes tens of seconds, not minutes, so the poll is denser than the
158
+ * fleet wait's and a ready container is used within 15 s of coming up. */
159
+ export const SANDBOX_START_BACKOFF_MS: readonly number[] = [5_000, 10_000, 15_000];
160
+
161
+ /** The reasons whose answers the executor re-sends after a wait. Every other
162
+ * `reason` — or none — is an ordinary failure after one send. */
163
+ export type WaitReason = typeof FLEET_BUSY_REASON | typeof SANDBOX_STARTING_REASON;
164
+
165
+ export function isWaitReason(reason: unknown): reason is WaitReason {
166
+ return reason === FLEET_BUSY_REASON || reason === SANDBOX_STARTING_REASON;
167
+ }
168
+
169
+ /** The Worker's answer on /read and /write (sent as HTTP 503) while the
170
+ * container starts: the token, and the start's own phase as the cause. */
171
+ export function sandboxStartingAnswer(cause: string): { error: string; reason: typeof SANDBOX_STARTING_REASON } {
172
+ return {
173
+ error: `${SANDBOX_STARTING_REASON}: ${SANDBOX_STARTING_EXPLANATION} (${cause})`,
174
+ reason: SANDBOX_STARTING_REASON,
175
+ };
176
+ }
177
+
178
+ /** The Worker's answer on /exec, in-body under the streamed HTTP 200 in the
179
+ * item-3 dual shape, like `fleetBusyExecAnswer`. */
180
+ export function sandboxStartingExecAnswer(cause: string): {
181
+ error: string;
182
+ reason: typeof SANDBOX_STARTING_REASON;
183
+ stdout: "";
184
+ stderr: string;
185
+ exitCode: 127;
186
+ } {
187
+ const { error, reason } = sandboxStartingAnswer(cause);
188
+ return { error, reason, stdout: "", stderr: error, exitCode: 127 };
189
+ }
190
+
191
+ /** The message `ExecCapacityError` carries when a container did not start
192
+ * inside the start budget: the wait, and what to do. */
193
+ export function startWaitExhaustedMessage(waitedMs: number): string {
194
+ return (
195
+ `sandbox not ready — the thread's container did not finish starting within ${Math.round(waitedMs / 1000)}s; ` +
196
+ "try again in a few minutes"
197
+ );
198
+ }
199
+
128
200
  /** The text the Worker carries in-body for a thrown value: the SDK's own
129
201
  * message when it has one, else a sentence that says the SDK gave none —
130
202
  * naming the error's name and code, and the one condition known to produce
@@ -76,3 +76,36 @@ export function recycledMidCommandMessage(elapsedMs: number, msg: string, certai
76
76
  `check /workspace before continuing: it is empty if the container was replaced (re-clone), intact if only its runtime restarted (${msg})`
77
77
  );
78
78
  }
79
+
80
+ /** The redirects a detached job's wrapper must carry so the command that
81
+ * forked it owns none of its descriptors — the same string the harness seam
82
+ * uses for its own start (`DETACHED_STDIO` in src/core/harness/container.ts;
83
+ * a test holds the two equal). Without them the runtime waits for an end of
84
+ * output the detached child never gives (item 24). */
85
+ export const DETACH_REDIRECTS = "</dev/null >/dev/null 2>&1";
86
+
87
+ /** The way a command detaches a job that must outlive it, as the exit-124
88
+ * hint and the docs spell it: the job's own output into a file, the
89
+ * wrapper's stdio to /dev/null. */
90
+ export const DETACH_HINT = `setsid -f sh -c '<command> > /tmp/job.log 2>&1' ${DETACH_REDIRECTS}`;
91
+
92
+ /** How long past the process's exit the Durable Object waits for its output
93
+ * stream to end before it answers with the exit code alone. The runtime
94
+ * reports the stream's end only when every holder of the command's stdout
95
+ * and stderr is gone; a detached child that inherited them holds it open
96
+ * for its own lifetime. Twenty seconds covers coreutils `timeout -k 10`'s
97
+ * SIGKILL follow-up and the runtime's teardown, and stays inside the
98
+ * executor's per-send margin (EXEC_CALL_MARGIN_MS, 30 s) so this answer
99
+ * reaches the bot before it gives the command up. */
100
+ export const OUTPUT_AFTER_EXIT_MS = 20_000;
101
+
102
+ /** The stderr line an exec answers with when its process exited but its
103
+ * output never ended: what happened, why the output is missing, and how to
104
+ * detach a job so it does not happen again. */
105
+ export function heldOutputNote(exitCode: number): string {
106
+ return (
107
+ `the command exited (code ${exitCode}) but its output could not be collected: a process it started still holds its stdout or stderr open ` +
108
+ `(a background job started without redirecting the wrapper's own stdio); its output is lost to this call, the job itself is still running — ` +
109
+ `detach with \`${DETACH_HINT}\` so the command's descriptors close when it does`
110
+ );
111
+ }
@@ -0,0 +1,90 @@
1
+ // The start gate of a thread's sandbox (docs/reference/specs/execution.md
2
+ // item 23): the Durable Object's decision, on every request, between running
3
+ // the operation and answering `sandbox-starting`. Deliberately free of node:
4
+ // imports so wrangler can bundle it into the sandbox Worker, like
5
+ // sandboxErrors.ts and sandboxIdle.ts.
6
+ //
7
+ // Why: a thread's first request finds no running container, and the SDK's
8
+ // first exec then carries the whole start — the platform's instance grant,
9
+ // the image pull, the microVM boot, the runtime's port — before the command
10
+ // runs; the executor's per-send deadline for a 60 s command is 90 s, so every
11
+ // fresh-sandbox run died with "gave no answer within 90s" while its container
12
+ // came up a minute later. The gate starts the container in the background
13
+ // through a warm-up the host provides, answers the named token at once, and
14
+ // lets the operation through only once the container is up. A warm-up that
15
+ // fails hands its error to the next request, so a full fleet or a silent
16
+ // runtime keeps its own name (item 14, item 9) — the gate never swallows it.
17
+
18
+ /** What the gate needs from the Durable Object. */
19
+ export interface StartGateHost {
20
+ /** `ctx.container?.running`: true once the platform runs the container,
21
+ * false when it is stopped, undefined when there is no container binding
22
+ * (treated as not running: the warm-up will say what is wrong). */
23
+ containerRunning(): boolean | undefined;
24
+ /** Start the container and wait for its runtime: one trivial command
25
+ * through the SDK, which does the start itself. Resolves when the runtime
26
+ * answered; rejects with the SDK's own error otherwise. */
27
+ warmUp(): Promise<void>;
28
+ now(): number;
29
+ log(event: Record<string, unknown>): void;
30
+ }
31
+
32
+ /** The gate's answer when the operation cannot run yet: the phase the start
33
+ * is in, in words, for the answer's cause. */
34
+ export type StartingCause = "container not running; starting it" | "container starting";
35
+
36
+ export class StartGate {
37
+ private starting: Promise<void> | null = null;
38
+ private startedAt = 0;
39
+ private failure: { error: unknown } | null = null;
40
+
41
+ constructor(private readonly host: StartGateHost) {}
42
+
43
+ /** Run `op` when the container is up; otherwise answer `starting(cause)`
44
+ * at once — beginning the warm-up on the first such request — and let the
45
+ * executor's wait re-send. A warm-up that failed is thrown here once, on
46
+ * the next request, so the caller classifies it as it would any SDK error
47
+ * (a full fleet, a silent control port); the request after that starts a
48
+ * fresh warm-up if the container is still not running. */
49
+ async through<T, S>(op: () => Promise<T>, starting: (cause: StartingCause) => S): Promise<T | S> {
50
+ if (this.failure) {
51
+ const { error } = this.failure;
52
+ this.failure = null;
53
+ throw error;
54
+ }
55
+ if (this.starting) return starting("container starting");
56
+ if (this.host.containerRunning() !== true) {
57
+ this.begin();
58
+ return starting("container not running; starting it");
59
+ }
60
+ return op();
61
+ }
62
+
63
+ /** Whether a warm-up is in flight — for the wiring test and the card. */
64
+ get isStarting(): boolean {
65
+ return this.starting !== null;
66
+ }
67
+
68
+ private begin(): void {
69
+ this.startedAt = this.host.now();
70
+ this.host.log({ event: "sandbox.starting" });
71
+ this.starting = this.host
72
+ .warmUp()
73
+ .then(
74
+ () => {
75
+ this.host.log({ event: "sandbox.started", durationMs: this.host.now() - this.startedAt });
76
+ },
77
+ (error: unknown) => {
78
+ this.host.log({
79
+ event: "sandbox.start-failed",
80
+ durationMs: this.host.now() - this.startedAt,
81
+ error: String(error),
82
+ });
83
+ this.failure = { error };
84
+ },
85
+ )
86
+ .finally(() => {
87
+ this.starting = null;
88
+ });
89
+ }
90
+ }