@coreplane/switchboard 1.236.0 → 1.238.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 (123) hide show
  1. package/dist/assets/deploy/cloudflare-resident/worker.ts +210 -74
  2. package/dist/assets/deploy/cloudflare-sandbox/worker.ts +36 -10
  3. package/dist/assets/package-lock.json +3 -3
  4. package/dist/assets/package.json +1 -1
  5. package/dist/assets/source.json +3 -3
  6. package/dist/assets/src/core/authz/authorize.ts +25 -6
  7. package/dist/assets/src/core/authz/policy.ts +9 -0
  8. package/dist/assets/src/core/authz/types.ts +15 -1
  9. package/dist/assets/src/core/coordinator/driver.ts +5 -1
  10. package/dist/assets/src/core/runEvents.ts +10 -6
  11. package/dist/assets/src/core/ship/coordinator.ts +23 -1
  12. package/dist/assets/src/execution/residentDepsStore.ts +14 -2
  13. package/dist/assets/src/execution/residentDiskBudget.ts +45 -11
  14. package/dist/assets/src/execution/residentHead.ts +34 -8
  15. package/dist/assets/src/execution/residentRebind.ts +88 -31
  16. package/dist/assets/src/execution/sandboxLifecycle.ts +33 -0
  17. package/dist/assets/src/execution/snapshotRetention.ts +62 -0
  18. package/dist/assets/src/mcp/registry.ts +9 -1
  19. package/dist/assets/web/dist/.vite/manifest.json +408 -408
  20. package/dist/assets/web/dist/assets/AppShell-MYHNxJks.js +1 -0
  21. package/dist/assets/web/dist/assets/{CostsPage-BfJsol9y.js → CostsPage-DKk9OjB0.js} +1 -1
  22. package/dist/assets/web/dist/assets/{DeliveryPage-CvInfu7y.js → DeliveryPage-Kz6js49i.js} +1 -1
  23. package/dist/assets/web/dist/assets/{NotFoundPage-d37EP8hS.js → NotFoundPage-BXLrQQzy.js} +1 -1
  24. package/dist/assets/web/dist/assets/{ResidentDetailPage-DQSD0SZD.js → ResidentDetailPage-CrfCd8fx.js} +1 -1
  25. package/dist/assets/web/dist/assets/ResidentsIndexPage-BdW0k2h3.js +1 -0
  26. package/dist/assets/web/dist/assets/{RunFoldRow-Bngn4GjF.js → RunFoldRow-CbTd9SiS.js} +1 -1
  27. package/dist/assets/web/dist/assets/{RunRoutePage-a-tMrirB.js → RunRoutePage-br0H-8Pz.js} +3 -3
  28. package/dist/assets/web/dist/assets/RunsIndexPage-Bnzb15zH.js +1 -0
  29. package/dist/assets/web/dist/assets/{RunsTabs-CyCdOhJ2.js → RunsTabs-XpCqeWu4.js} +1 -1
  30. package/dist/assets/web/dist/assets/{ScheduledPage-ByCmyrDs.js → ScheduledPage-NLhjzHAz.js} +1 -1
  31. package/dist/assets/web/dist/assets/SettingsPage-D-i0uVw-.js +1 -0
  32. package/dist/assets/web/dist/assets/{StatusDot-D-BPSPpw.js → StatusDot-DkXALvKf.js} +1 -1
  33. package/dist/assets/web/dist/assets/{Tooltip-DPDx5tXT.js → Tooltip-DtLIyi5P.js} +1 -1
  34. package/dist/assets/web/dist/assets/{UnitRoutePage-0eiekTHQ.js → UnitRoutePage-DGXukEAH.js} +1 -1
  35. package/dist/assets/web/dist/assets/{angular-html-Ni1Xil2G.js → angular-html-DYtV9uIN.js} +1 -1
  36. package/dist/assets/web/dist/assets/{angular-ts-oCgo6p05.js → angular-ts-DF2P25Zp.js} +1 -1
  37. package/dist/assets/web/dist/assets/{apl-CiWYl0gh.js → apl-Ff96wxko.js} +1 -1
  38. package/dist/assets/web/dist/assets/{astro-B-VeKZ_J.js → astro-LbBnZna-.js} +1 -1
  39. package/dist/assets/web/dist/assets/{blade-BIWEISKd.js → blade-DltuxeBW.js} +1 -1
  40. package/dist/assets/web/dist/assets/{c-DHxA5Byp.js → c-DBg7yuPx.js} +1 -1
  41. package/dist/assets/web/dist/assets/{chapel-D4qGLw-Q.js → chapel-C83hUAds.js} +1 -1
  42. package/dist/assets/web/dist/assets/{cobol-BznX5Q-u.js → cobol-DB0yV_2D.js} +1 -1
  43. package/dist/assets/web/dist/assets/{coffee-CVr8Idlj.js → coffee-Bpih6Kzl.js} +1 -1
  44. package/dist/assets/web/dist/assets/{cpp-wrk6NfW4.js → cpp-CU00ZeRn.js} +1 -1
  45. package/dist/assets/web/dist/assets/{crystal-CGYWMeH1.js → crystal-f8ImJkux.js} +1 -1
  46. package/dist/assets/web/dist/assets/{css-BgGytMsy.js → css-4VrC4ISo.js} +1 -1
  47. package/dist/assets/web/dist/assets/{dist-DSkVB16z.js → dist-DtA7JiH5.js} +2 -2
  48. package/dist/assets/web/dist/assets/{durationTone-DYogEPEk.js → durationTone-CYMux25J.js} +1 -1
  49. package/dist/assets/web/dist/assets/{edge-CdR6AU9v.js → edge-BwFDzfS_.js} +1 -1
  50. package/dist/assets/web/dist/assets/{elixir-CQRb3PZx.js → elixir-wwIYIpJm.js} +1 -1
  51. package/dist/assets/web/dist/assets/{elm-CpuA6W8B.js → elm-DyDuM3v3.js} +1 -1
  52. package/dist/assets/web/dist/assets/{erb-CvlrblqH.js → erb-DFJBEVrh.js} +1 -1
  53. package/dist/assets/web/dist/assets/{git-rebase-COnovCVc.js → git-rebase-CuDv-44X.js} +1 -1
  54. package/dist/assets/web/dist/assets/{glimmer-js-C-DS4g_J.js → glimmer-js-ZFnrm4J3.js} +1 -1
  55. package/dist/assets/web/dist/assets/{glimmer-ts-DTkorWRI.js → glimmer-ts-XY4Yqshp.js} +1 -1
  56. package/dist/assets/web/dist/assets/{glsl-CH51-JW7.js → glsl-xnR8efx0.js} +1 -1
  57. package/dist/assets/web/dist/assets/{graphql-BO-XymNv.js → graphql-D-gO5tnP.js} +1 -1
  58. package/dist/assets/web/dist/assets/{hack-C-N0oicf.js → hack-CSxbW4ax.js} +1 -1
  59. package/dist/assets/web/dist/assets/{haml-BfmdtWoP.js → haml-D4ar42QT.js} +1 -1
  60. package/dist/assets/web/dist/assets/{handlebars-BqhO_te-.js → handlebars-CMLbDE7o.js} +1 -1
  61. package/dist/assets/web/dist/assets/{html-DUEZlTKV.js → html-CVekU4Fs.js} +1 -1
  62. package/dist/assets/web/dist/assets/{html-derivative-DOUJTS8A.js → html-derivative-St3HHw_N.js} +1 -1
  63. package/dist/assets/web/dist/assets/{http-j7UH0Cqv.js → http-Mon0DWHS.js} +1 -1
  64. package/dist/assets/web/dist/assets/{hurl-D6ZaBLJk.js → hurl-Cbl5IvBs.js} +1 -1
  65. package/dist/assets/web/dist/assets/{indexRow-BjXBzI5Z.js → indexRow-FLJu5mR9.js} +1 -1
  66. package/dist/assets/web/dist/assets/{java-dugWx3wi.js → java-Bprck1l-.js} +1 -1
  67. package/dist/assets/web/dist/assets/{javascript-CK4GC4JO.js → javascript-C73G1-UR.js} +1 -1
  68. package/dist/assets/web/dist/assets/{jinja-DrLKIyKG.js → jinja-C4DuRxYJ.js} +1 -1
  69. package/dist/assets/web/dist/assets/{jison-CS1Th-TO.js → jison-B2U03GEC.js} +1 -1
  70. package/dist/assets/web/dist/assets/{json-CkBqdVys.js → json-CvTZUDzx.js} +1 -1
  71. package/dist/assets/web/dist/assets/{jsx-CCxdg7n3.js → jsx-DMWKCzwo.js} +1 -1
  72. package/dist/assets/web/dist/assets/{julia-BalKW2hA.js → julia-Czn7bV_3.js} +1 -1
  73. package/dist/assets/web/dist/assets/{just-BKw1G40D.js → just-BBxpNfvC.js} +1 -1
  74. package/dist/assets/web/dist/assets/{latex-Dfad6ZN-.js → latex-Bw9yg5mr.js} +1 -1
  75. package/dist/assets/web/dist/assets/{liquid-f_CMGBvd.js → liquid-BRCjaydE.js} +1 -1
  76. package/dist/assets/web/dist/assets/{lua-BcMK_uKM.js → lua-DUu2KIC0.js} +1 -1
  77. package/dist/assets/web/dist/assets/main-CFHF2RvQ.css +1 -0
  78. package/dist/assets/web/dist/assets/{main-DR8iKpNt.js → main-Dht0in4v.js} +2 -2
  79. package/dist/assets/web/dist/assets/{marko-B0oNLx2i.js → marko-CZJZIlSQ.js} +1 -1
  80. package/dist/assets/web/dist/assets/{mdc-Bc_MmIyq.js → mdc-BmClq6fd.js} +1 -1
  81. package/dist/assets/web/dist/assets/{nginx-Cmxv8RIt.js → nginx-Bmt3xSEm.js} +1 -1
  82. package/dist/assets/web/dist/assets/{nim-BtgRfaY0.js → nim-Da-i0utK.js} +1 -1
  83. package/dist/assets/web/dist/assets/{org-Dl9EzlHh.js → org-BvAre2qP.js} +1 -1
  84. package/dist/assets/web/dist/assets/{perl-BmF7x7BB.js → perl-v9WwFFtB.js} +1 -1
  85. package/dist/assets/web/dist/assets/{php-rH6CVptQ.js → php-DTU4Zyze.js} +1 -1
  86. package/dist/assets/web/dist/assets/{pug-CIN5Ccck.js → pug-DhTwEqTO.js} +1 -1
  87. package/dist/assets/web/dist/assets/{qml-BpLS3RFr.js → qml-BUD9AC_c.js} +1 -1
  88. package/dist/assets/web/dist/assets/{r-BUMF3B-H.js → r-CAnu56n7.js} +1 -1
  89. package/dist/assets/web/dist/assets/{razor-B7sTBPdT.js → razor-Dn0P4gAq.js} +1 -1
  90. package/dist/assets/web/dist/assets/{regexp-h2SvwrsJ.js → regexp-CZyBRaVW.js} +1 -1
  91. package/dist/assets/web/dist/assets/residentsModel-By1fThvk.js +1 -0
  92. package/dist/assets/web/dist/assets/{rst-DLOWEsD2.js → rst-Ckyd5UsY.js} +1 -1
  93. package/dist/assets/web/dist/assets/{ruby-Cl_-I4k3.js → ruby-BBQ62fsx.js} +1 -1
  94. package/dist/assets/web/dist/assets/{sas-Cm4M-BvV.js → sas-CW35hMxG.js} +1 -1
  95. package/dist/assets/web/dist/assets/{scss-CEFCEQUF.js → scss-CpV8pF4J.js} +1 -1
  96. package/dist/assets/web/dist/assets/{shellscript-CE6zb5eS.js → shellscript-CRJvsmE1.js} +1 -1
  97. package/dist/assets/web/dist/assets/{shellsession-B4fDVzrM.js → shellsession-CyriUZxf.js} +1 -1
  98. package/dist/assets/web/dist/assets/{soy-DwXCMpjE.js → soy-D5QzYUeg.js} +1 -1
  99. package/dist/assets/web/dist/assets/{sql-DrnAKnyD.js → sql-CUBf_Mse.js} +1 -1
  100. package/dist/assets/web/dist/assets/{stata-BzlwPWD6.js → stata-B-4n4ITz.js} +1 -1
  101. package/dist/assets/web/dist/assets/{surrealql-CeUieZXd.js → surrealql-RuWZBLtr.js} +1 -1
  102. package/dist/assets/web/dist/assets/{svelte-BERIpIKR.js → svelte-CO5YzCsK.js} +1 -1
  103. package/dist/assets/web/dist/assets/{templ-6FSuUDwg.js → templ-DbkWVaBF.js} +1 -1
  104. package/dist/assets/web/dist/assets/{tex-SWCyMwNX.js → tex-IuxoEcId.js} +1 -1
  105. package/dist/assets/web/dist/assets/{ts-tags-VWj7gWgY.js → ts-tags-g6dGQPjd.js} +1 -1
  106. package/dist/assets/web/dist/assets/{tsx-B6dzfL8L.js → tsx-WnXwiyH2.js} +1 -1
  107. package/dist/assets/web/dist/assets/{twig-BDXsr482.js → twig-Lx1G7PnY.js} +1 -1
  108. package/dist/assets/web/dist/assets/{typescript-s100Dt8y.js → typescript-BQd7biLF.js} +1 -1
  109. package/dist/assets/web/dist/assets/{typst-WfyEAUdt.js → typst-D2lXPHK7.js} +1 -1
  110. package/dist/assets/web/dist/assets/{vue-CkXOOJ-6.js → vue-4wVQD9y9.js} +1 -1
  111. package/dist/assets/web/dist/assets/{vue-html-CEpzdcau.js → vue-html-DZPaRte4.js} +1 -1
  112. package/dist/assets/web/dist/assets/{vue-vine-D9dsfiFA.js → vue-vine-DbmZCmu-.js} +1 -1
  113. package/dist/assets/web/dist/assets/{xml-iBDofbJq.js → xml-DV16szyo.js} +1 -1
  114. package/dist/assets/web/dist/assets/{xsl-CqbJF4hD.js → xsl-eo5-1sk0.js} +1 -1
  115. package/dist/assets/web/dist/assets/{yaml-D9i6OxYe.js → yaml-BXdX8HZf.js} +1 -1
  116. package/dist/cli.js +1143 -542
  117. package/package.json +1 -1
  118. package/dist/assets/web/dist/assets/AppShell-DZdz_sl7.js +0 -1
  119. package/dist/assets/web/dist/assets/ResidentsIndexPage-CZUGLen6.js +0 -1
  120. package/dist/assets/web/dist/assets/RunsIndexPage-s0VKmRda.js +0 -1
  121. package/dist/assets/web/dist/assets/SettingsPage-DhKEokGK.js +0 -1
  122. package/dist/assets/web/dist/assets/main-B77vAZVg.css +0 -1
  123. package/dist/assets/web/dist/assets/residentsModel-VSJEepL1.js +0 -1
@@ -227,6 +227,7 @@ import {
227
227
  type FetchRecord,
228
228
  type SnapshotStamp,
229
229
  } from "../../src/execution/residentStepPlan.js";
230
+ import { backupIdsOf, RETIRED_SNAPSHOT_KEY, rotateSnapshots } from "../../src/execution/snapshotRetention.js";
230
231
  import {
231
232
  DF_FREE_ARGV,
232
233
  DISK_FULL_FREE_KIB,
@@ -252,7 +253,13 @@ import {
252
253
  type DiskSample,
253
254
  type ThreadCostKind,
254
255
  } from "../../src/execution/residentDiskBudget.js";
255
- import { attachTarget, mirrorNeedsFetch, parseWantSha, wantShaForBinding } from "../../src/execution/residentHead.js";
256
+ import {
257
+ attachTarget,
258
+ type FetchReason,
259
+ mirrorFetchReason,
260
+ parseWantSha,
261
+ wantShaForBinding,
262
+ } from "../../src/execution/residentHead.js";
256
263
  import { residentText, sanitizeResidentBody } from "../../src/execution/residentText.js";
257
264
  import {
258
265
  abandonedWaitStepResult,
@@ -297,6 +304,9 @@ import {
297
304
  parseDepsStoreListing,
298
305
  planDepsEviction,
299
306
  planDepsMaterialization,
307
+ DEPS_STORE_MAX_UNREFERENCED_UNDER_PRESSURE,
308
+ type DepsEvictionPlan,
309
+ type DepsStoreListing,
300
310
  } from "../../src/execution/residentDepsStore.js";
301
311
  import { buildId, injectedBuildStamp } from "../../src/deploy/buildStamp.js";
302
312
  import { createRefreshInstance, createRefreshInstanceNow, type RefreshInstanceParams } from "./refresh";
@@ -1002,10 +1012,11 @@ interface ResidentRecord {
1002
1012
  interface ThreadBinding {
1003
1013
  threadKey: string;
1004
1014
  ref: string;
1005
- /** How `ref` was chosen when the binding was made: the repo default for want
1006
- * of a named branch (item 30), or a branch someone named. Absent on a
1007
- * binding made before the field: read as `default` iff `ref` is the default
1008
- * branch. Only a `default` binding may ever move. */
1015
+ /** How `ref` was chosen: the repo default for want of a named branch — when
1016
+ * the binding was made (item 30), or when a return put it there because the
1017
+ * branch it was on is gone (`returned`) — or a branch someone named. Absent
1018
+ * on a binding made before the field: read as `default` iff `ref` is the
1019
+ * default branch. Only a `default` binding may ever move onto a branch. */
1009
1020
  boundBy?: BoundBy;
1010
1021
  /** The move a binding may make (item 16): from the default it was bound to,
1011
1022
  * onto the branch the thread's own run opened a pull request on. Set, the
@@ -1013,6 +1024,12 @@ interface ThreadBinding {
1013
1024
  * branch was gone from the mirror and the binding went back to the
1014
1025
  * default), and then for the next own pull request. */
1015
1026
  rebound?: Rebound;
1027
+ /** The binding's last move back to the default (item 16's second movement):
1028
+ * the branch it was on — one a rebind moved it onto, or one this thread's
1029
+ * own runs pushed (`ownBranches`) and bound by name, a ship unit's — was
1030
+ * gone from the mirror at an attach. From where, to where, which pull
1031
+ * request's branch it was, when. */
1032
+ returned?: Returned;
1016
1033
  /** The branches the thread's own runs pushed and the pull requests they
1017
1034
  * head, as each run's release handed them over (`/detach` `pushed`, item
1018
1035
  * 16a) — written before any eviction decision, so the fact outlives the
@@ -1122,9 +1139,10 @@ interface AttachOk {
1122
1139
  * and why — so the bot can say where the follow-up runs and why. */
1123
1140
  rebindRefused?: RebindRefused;
1124
1141
  /** This attach moved the binding BACK to the default branch (item 16's
1125
- * second movement): the branch a rebind had moved it onto is gone from the
1126
- * mirror, so the tree was provisioned on the default instead of the attach
1127
- * failing `unknown-ref`. From where, to where, which PR's branch it was. */
1142
+ * second movement): the branch it was on — a rebind's, or one this thread
1143
+ * itself pushed — is gone from the mirror, so the tree was provisioned on
1144
+ * the default instead of the attach failing `unknown-ref`. From where, to
1145
+ * where, which PR's branch it was. */
1128
1146
  returned?: Returned;
1129
1147
  }
1130
1148
 
@@ -2094,13 +2112,20 @@ export class ResidentDO extends Sandbox<Env> {
2094
2112
  throw new StepError("cat-file", `git cat-file exited ${r.exitCode}: ${r.stderr.trim() || "(no output)"}`);
2095
2113
  }
2096
2114
 
2097
- /** Attach's fetch decision (item 51) over the mirror's actual state: the ref
2098
- * is missing, or `wantSha` names a commit the ref's tip is not at. The
2099
- * decision itself is the pure, tested `mirrorNeedsFetch`. */
2100
- private async mirrorNeedsFetchFor(ref: string, wantSha: string | null): Promise<boolean> {
2115
+ /** Attach's fetch decision (item 51) over the mirror's actual state — why
2116
+ * the mirror is fetched before the tree is cloned, or null: the ref is
2117
+ * missing, `wantSha` names a commit the ref's tip is not at, or the ref is
2118
+ * one the binding could return from (item 16's second movement) and is
2119
+ * verified against the origin before the mirror's word on it is trusted.
2120
+ * The decision itself is the pure, tested `mirrorFetchReason`. */
2121
+ private async mirrorFetchReasonFor(
2122
+ ref: string,
2123
+ wantSha: string | null,
2124
+ returnable: boolean,
2125
+ ): Promise<FetchReason | null> {
2101
2126
  const refExists = await this.refExists(ref);
2102
2127
  const mirrorSha = refExists && wantSha !== null ? await this.readMirrorSha(ref) : undefined;
2103
- return mirrorNeedsFetch({ refExists, mirrorSha, wantSha });
2128
+ return mirrorFetchReason({ refExists, mirrorSha, wantSha, returnable });
2104
2129
  }
2105
2130
 
2106
2131
  /** Dependency/build cache key: sha256 over the ls-tree lines (mode,
@@ -2597,7 +2622,7 @@ export class ResidentDO extends Sandbox<Env> {
2597
2622
  [UPDATED_KEY]: new Date(systemClock()).toISOString(),
2598
2623
  [DEADLINE_AT_KEY]: systemClock() + provisioningTimeoutMs,
2599
2624
  });
2600
- await this.ctx.storage.delete([FACTS_KEY, SNAPSHOT_KEY]); // defensive: no stale facts from a past life
2625
+ await this.ctx.storage.delete([FACTS_KEY, SNAPSHOT_KEY, RETIRED_SNAPSHOT_KEY]); // defensive: no stale facts from a past life
2601
2626
  try {
2602
2627
  this.deleteSchedules(PROVISIONING_CALLBACK);
2603
2628
  this.deleteSchedules(PROVISION_RUN_CALLBACK);
@@ -3139,18 +3164,40 @@ export class ResidentDO extends Sandbox<Env> {
3139
3164
 
3140
3165
  /** The cycle's snapshot phase: the stamped pair to R2 and, when this cycle's
3141
3166
  * record stands (not superseded by another writer's), the ready marker and
3142
- * the replaced snapshot's objects swept. Answers whether the record committed. */
3167
+ * the rotation — the replaced pair retired, the pair retired before it
3168
+ * swept (docs/reference/specs/resident-repos.md item 7: two generations, so a
3169
+ * handle a seeded restore read from `/status` still resolves for a cycle).
3170
+ * Answers whether the record committed. */
3143
3171
  private async refreshSnapshot(resource: string, stamp: SnapshotStamp): Promise<boolean> {
3144
3172
  const snapped = await this.snapshot({ resource, stamp });
3145
3173
  const committed = snapped.done || !snapped.superseded;
3146
3174
  if (committed) {
3147
3175
  await this.writeDiskMarkers({ ready: stamp.sha });
3148
- const previous = !snapped.done && !snapped.superseded ? snapped.previous : undefined;
3149
- if (previous) await this.deleteBackupObjects([previous.mirror.id, previous.checkout.id]).catch(() => {});
3176
+ const replaced = !snapped.done && !snapped.superseded ? snapped.previous : undefined;
3177
+ if (replaced) await this.retireSnapshot(replaced);
3150
3178
  }
3151
3179
  return committed;
3152
3180
  }
3153
3181
 
3182
+ /** The rotation's bookkeeping: the retired generation recorded (the storage
3183
+ * write first, so a deletion that fails leaves the record true and the
3184
+ * 1-year R2 TTL as the backstop), then the objects the rotation frees. */
3185
+ private async retireSnapshot(replaced: SnapshotRecord): Promise<void> {
3186
+ const rotation = rotateSnapshots({ replaced, retired: await this.retiredSnapshot() });
3187
+ if (rotation.retired) await this.ctx.storage.put(RETIRED_SNAPSHOT_KEY, rotation.retired);
3188
+ if (rotation.deleteIds.length > 0) await this.deleteBackupObjects(rotation.deleteIds).catch(() => {});
3189
+ }
3190
+
3191
+ private retiredSnapshot(): Promise<SnapshotRecord | undefined> {
3192
+ return this.ctx.storage.get<SnapshotRecord>(RETIRED_SNAPSHOT_KEY);
3193
+ }
3194
+
3195
+ /** The backup ids of every recorded generation — the current pair and the
3196
+ * retired one — for the paths that delete or itemize them all. */
3197
+ private async recordedBackupIds(current: SnapshotRecord | undefined): Promise<string[]> {
3198
+ return backupIdsOf([current, await this.retiredSnapshot()]);
3199
+ }
3200
+
3154
3201
  /** The cycle's completion: the facts to the stamp (the snapshot step moved
3155
3202
  * them in the same write as the record — a wake never sees a half-updated
3156
3203
  * pair; this is the cycle's own bookkeeping on a fresh read, and a
@@ -4067,12 +4114,36 @@ export class ResidentDO extends Sandbox<Env> {
4067
4114
  committedKiB: this.diskCommittedKiB,
4068
4115
  diskBudgetMb: input.record.diskBudgetMb,
4069
4116
  kind,
4117
+ // A deps-store entry backup stages its archive from the deps (item
4118
+ // 61); the reserve holds their share only while one runs.
4119
+ depsBackupInFlight: this.depsBackupsInFlight.size > 0,
4070
4120
  });
4071
4121
  let rawFree = df.freeKiB;
4072
4122
  let verdict = decide(rawFree);
4073
4123
  const evicted: Array<{ threadKey: string; freedKiB: number | null }> = [];
4074
4124
  // Item 62: keep decisions travel as tokens; the key stays for the log line.
4075
4125
  const kept: Array<{ threadKey: string; why: DiskKeepWhy }> = [];
4126
+ // The deps store's spares go first (item 55): a warm cache no live tree
4127
+ // references, worth minutes on a future attach, against a tree refused
4128
+ // now. Their bytes come back before any idle tree is considered.
4129
+ let spares: Array<{ key: string; freedKiB: number }> = [];
4130
+ if (!verdict.fits) {
4131
+ spares = await this.evictDepsSparesUnderPressure().catch((err) => {
4132
+ console.log(`disk-pressure: deps spare eviction failed: ${errMsg(err)}`);
4133
+ return [];
4134
+ });
4135
+ if (spares.length > 0) {
4136
+ console.log(
4137
+ `disk-pressure: evicted ${spares.length} deps-store spare(s) (${formatGiB(spares.reduce((a, e) => a + e.freedKiB, 0))} back) to make room for ${input.threadKey}`,
4138
+ );
4139
+ rawFree = rawFreeAfterEviction(
4140
+ rawFree,
4141
+ await this.dfSample(),
4142
+ spares.reduce((a, e) => a + e.freedKiB, 0),
4143
+ );
4144
+ verdict = decide(rawFree);
4145
+ }
4146
+ }
4076
4147
  if (!verdict.fits) {
4077
4148
  const live = await this.liveBindings();
4078
4149
  const candidates: DiskEvictionCandidate[] = live.map((b) => ({
@@ -4119,7 +4190,7 @@ export class ResidentDO extends Sandbox<Env> {
4119
4190
  // truthiness does not narrow a boolean-literal discriminant.
4120
4191
  const final = verdict;
4121
4192
  if (final.fits === false) {
4122
- const reason = diskPressureReason({ verdict: final, evicted, kept });
4193
+ const reason = diskPressureReason({ verdict: final, evicted, kept, spares });
4123
4194
  console.log(`attach ${input.threadKey}: ${reason}`);
4124
4195
  const s = await this.getStatus();
4125
4196
  return { error: reason, status: 503, state: s.state, reason: DISK_PRESSURE_REASON };
@@ -4127,7 +4198,7 @@ export class ResidentDO extends Sandbox<Env> {
4127
4198
  const m = final.math;
4128
4199
  console.log(
4129
4200
  `attach ${input.threadKey}: disk admitted — ${kind} ${formatGiB(m.projectedKiB)} projected, ${formatGiB(m.freeKiB)} free, ` +
4130
- `reserve ${formatGiB(m.reserve.totalKiB)}, headroom ${formatGiB(m.headroomKiB)}${evicted.length > 0 ? `, evicted ${evicted.map((e) => e.threadKey).join(", ")}` : ""}`,
4201
+ `reserve ${formatGiB(m.reserve.totalKiB)}, headroom ${formatGiB(m.headroomKiB)}${spares.length > 0 ? `, evicted ${spares.length} deps spare(s)` : ""}${evicted.length > 0 ? `, evicted ${evicted.map((e) => e.threadKey).join(", ")}` : ""}`,
4131
4202
  );
4132
4203
  const committedKiB = m.projectedKiB ?? 0;
4133
4204
  this.diskCommittedKiB += committedKiB;
@@ -4461,12 +4532,13 @@ export class ResidentDO extends Sandbox<Env> {
4461
4532
  lastAttachAt: now,
4462
4533
  evicted: false,
4463
4534
  // An evicted binding's own history: how its ref was chosen (absent on
4464
- // one made before the field — never overwritten by this attach's flag)
4465
- // and the one move it may have made.
4535
+ // one made before the field — never overwritten by this attach's flag),
4536
+ // the one move it may have made and its last move back.
4466
4537
  ...(existing
4467
4538
  ? {
4468
4539
  ...(existing.boundBy !== undefined ? { boundBy: existing.boundBy } : {}),
4469
4540
  ...(existing.rebound !== undefined ? { rebound: existing.rebound } : {}),
4541
+ ...(existing.returned !== undefined ? { returned: existing.returned } : {}),
4470
4542
  ...(existing.ownBranches !== undefined ? { ownBranches: existing.ownBranches } : {}),
4471
4543
  }
4472
4544
  : { boundBy }),
@@ -4819,27 +4891,27 @@ export class ResidentDO extends Sandbox<Env> {
4819
4891
 
4820
4892
  /** Item 16's second movement, decided where the fact is established — under
4821
4893
  * the mirror mutex, after the attach's fetch found the bound ref gone: a
4822
- * binding a rebind moved onto its own pull request's branch, that branch
4823
- * now deleted (its pull request merged), goes back to the default it was
4824
- * bound to, so the run starts clean there (item 17) instead of the thread
4825
- * failing `unknown-ref` for the rest of its life. The row as it stands is
4826
- * re-read and re-judged (`canReturnToDefault`: a ref a person named never
4827
- * returns — that branch is theirs to sort out), the move stamped returned
4828
- * so the thread may follow its next pull request, and the answer carries
4829
- * the move back for the card. A row that no longer qualifies — another
4830
- * attach moved it meanwhile — is answered as it stands, nothing written. */
4894
+ * binding on a branch that is now deleted (its pull request merged) goes
4895
+ * back to the default, so the run starts clean there (item 17) instead of
4896
+ * the thread failing `unknown-ref` for the rest of its life — when the
4897
+ * branch is one a rebind moved it onto, or one this thread's own runs
4898
+ * pushed as the binding remembers it (`ownBranches`): a ship unit's coding
4899
+ * child binds its unit branch by name and pushes it, and the merge deletes
4900
+ * it. The row as it stands is re-read and re-judged (the pure
4901
+ * `returnToDefault`, which writes nothing for a row `canReturnToDefault`
4902
+ * does not admit: a ref a person named that the thread never pushed never
4903
+ * returns — that branch is theirs to sort out), the move recorded so the
4904
+ * thread may follow its next pull request, and the answer carries the move
4905
+ * back for the card. A row that no longer qualifies — another attach moved
4906
+ * it meanwhile — is answered as it stands, nothing written. */
4831
4907
  private async returnBindingToDefault(
4832
4908
  binding: ThreadBinding,
4833
4909
  defaultRef: string,
4834
4910
  ): Promise<{ binding: ThreadBinding; returned?: Returned }> {
4835
4911
  const key = threadBindingKey(binding.threadKey);
4836
4912
  const current = (await this.ctx.storage.get<ThreadBinding>(key)) ?? binding;
4837
- if (current.rebound === undefined || !canReturnToDefault(current, defaultRef)) return { binding: current };
4838
- const back = returnToDefault(
4839
- { ...current, rebound: current.rebound },
4840
- defaultRef,
4841
- new Date(systemClock()).toISOString(),
4842
- );
4913
+ const back = returnToDefault(current, defaultRef, new Date(systemClock()).toISOString());
4914
+ if (back === undefined) return { binding: current };
4843
4915
  await this.ctx.storage.put(key, back.binding);
4844
4916
  console.log(
4845
4917
  `attach ${binding.threadKey}: ${back.returned.from} is gone from the mirror (the thread's own pull request #${back.returned.pr}) — returned to ${back.returned.to}; the tree is provisioned there`,
@@ -4921,9 +4993,22 @@ export class ResidentDO extends Sandbox<Env> {
4921
4993
  // The expected head applies to the ref it was resolved for; a sticky
4922
4994
  // binding on another branch drops it (item 51) rather than fetching on
4923
4995
  // every attach of a thread that can never be at that commit.
4996
+ // A ref the binding could return from — a branch a rebind moved it onto,
4997
+ // or one its own runs pushed (item 16's second movement) — is fetched for
4998
+ // whether or not the mirror holds it: the branch dies when its pull
4999
+ // request merges, the deletion reaches the mirror only through a
5000
+ // `fetch --prune`, and a mirror trusted for still holding the ref would
5001
+ // provision a tree at the deleted branch's stale tip until the refresh
5002
+ // cycle's prune caught up. One predicate, read once here, drives the mint
5003
+ // pre-check, the fetch under the lock and the return gate alike.
4924
5004
  let want = wantShaForBinding({ boundRef: binding.ref, refHint, wantSha });
5005
+ const returnable = canReturnToDefault(binding, facts.defaultRef);
4925
5006
  let fetchToken: string | null = token;
4926
- if (!fetchToken && githubAppConfigured(this.env) && (await this.mirrorNeedsFetchFor(binding.ref, want))) {
5007
+ if (
5008
+ !fetchToken &&
5009
+ githubAppConfigured(this.env) &&
5010
+ (await this.mirrorFetchReasonFor(binding.ref, want, returnable)) !== null
5011
+ ) {
4927
5012
  fetchToken = (await mintRepoScopedToken(this.env, slug).catch(() => null))?.token ?? null;
4928
5013
  }
4929
5014
 
@@ -4931,14 +5016,27 @@ export class ResidentDO extends Sandbox<Env> {
4931
5016
  try {
4932
5017
  locked = await this.withMirrorLock(async () => {
4933
5018
  await this.ensureGitSetup();
4934
- const fetched = await this.mirrorNeedsFetchFor(binding.ref, want);
4935
- if (fetched) {
4936
- await this.gitWithCred(
4937
- fetchToken,
4938
- ["-C", MIRROR_DIR, "fetch", "--prune", "origin"],
4939
- "fetch",
4940
- GIT_NETWORK_TIMEOUT_MS,
4941
- );
5019
+ const why = await this.mirrorFetchReasonFor(binding.ref, want, returnable);
5020
+ if (why !== null) {
5021
+ try {
5022
+ await this.gitWithCred(
5023
+ fetchToken,
5024
+ ["-C", MIRROR_DIR, "fetch", "--prune", "origin"],
5025
+ "fetch",
5026
+ GIT_NETWORK_TIMEOUT_MS,
5027
+ );
5028
+ } catch (err) {
5029
+ // A fetch for a missing or stale ref fails the attach as it always
5030
+ // did. One that only verifies a returnable ref the mirror holds is
5031
+ // not the attach's failure — the origin unreachable, no token for
5032
+ // a private repo — so the attach goes on with the mirror's ref, as
5033
+ // it did before the verification existed; the return is only
5034
+ // ever decided on a fetch that ran.
5035
+ if (why !== "returnable-ref") throw err;
5036
+ console.log(
5037
+ `attach ${threadKey}: the fetch verifying ${binding.ref} at the origin failed (${errMsg(err)}) — the attach goes on with the mirror's ref`,
5038
+ );
5039
+ }
4942
5040
  }
4943
5041
  // What to check out, now that the mirror is as fresh as it will get
4944
5042
  // (item 51, the pure `attachTarget`): the ref's tip when the ref is
@@ -4947,18 +5045,21 @@ export class ResidentDO extends Sandbox<Env> {
4947
5045
  // `refs/pull/N/head` still names its head, and the caller told us that
4948
5046
  // head — so a review of a merged PR stays on the warm resident instead
4949
5047
  // of falling back to a cold sandbox that clones the same commit itself.
4950
- // No fetch ran ⇒ the ref existed at the check: `mirrorNeedsFetch` fetches
4951
- // for every missing ref, so only a fetched mirror needs the re-read.
4952
- // (Under the mirror mutex — the mirror cannot change in between.)
4953
- const refExists = !fetched || (await this.refExists(binding.ref));
5048
+ // No fetch was due ⇒ the ref existed at the check: `mirrorFetchReason`
5049
+ // fetches for every missing ref, so only a fetched mirror needs the
5050
+ // re-read — and a returnable ref is re-read for real, the fact the
5051
+ // second movement is decided on. (Under the mirror mutex — the mirror
5052
+ // cannot change in between.)
5053
+ const refExists = why === null || (await this.refExists(binding.ref));
4954
5054
  let target = attachTarget({
4955
5055
  refExists,
4956
5056
  wantSha: want,
4957
5057
  commitInMirror: !refExists && want !== null && (await this.commitInMirror(want)),
4958
5058
  });
4959
- if (target.kind === "unknown-ref" && canReturnToDefault(binding, facts.defaultRef)) {
4960
- // The branch a rebind moved this thread onto is gone even after the
4961
- // fetch — deleted once its pull request merged. The binding goes
5059
+ if (target.kind === "unknown-ref" && returnable) {
5060
+ // The thread's branch — one a rebind moved it onto, or one its own
5061
+ // runs pushed and it was bound to by name — is gone even after the
5062
+ // fetch: deleted once its pull request merged. The binding goes
4962
5063
  // back to the default (item 16's second movement) and the attach
4963
5064
  // goes on there: the default is always in the mirror, and the
4964
5065
  // worktree step below provisions the tree at its tip.
@@ -5713,27 +5814,24 @@ export class ResidentDO extends Sandbox<Env> {
5713
5814
  * in flight, and remove what `planDepsEviction` names — debris first, then
5714
5815
  * the coldest spares beyond DEPS_STORE_MAX_UNREFERENCED. Housekeeping:
5715
5816
  * a failure is a log line, never a lifecycle flip. */
5716
- private async sweepDepsStore(): Promise<void> {
5717
- const listed = await this.run(["sh", "-c", depsStoreListScript()], { timeoutMs: DU_TIMEOUT_MS });
5718
- if (listed.exitCode !== 0) return;
5719
- const listing = parseDepsStoreListing(listed.stdout);
5720
- if (listing.entries.length === 0 && listing.leftovers.length === 0) return;
5817
+ /** The store entries no eviction may touch: the checkout's key, every live
5818
+ * binding's, every install and every entry backup in flight. */
5819
+ private async depsProtectedKeys(): Promise<Set<string>> {
5721
5820
  const facts = await this.ctx.storage.get<RepoFacts>(FACTS_KEY);
5722
5821
  const protectedKeys = new Set<string>([...this.depsInFlight.keys(), ...this.depsBackupsInFlight]);
5723
5822
  if (facts?.lockfileHash) protectedKeys.add(facts.lockfileHash);
5724
5823
  for (const b of await this.liveBindings()) if (b.depsKey) protectedKeys.add(b.depsKey);
5725
- // A scratch/staging dir of an install still running in this incarnation
5726
- // is live work, not debris; the listing cannot tell them apart, so
5727
- // leftovers wait for a sweep with nothing in flight.
5728
- const leftovers = this.depsInFlight.size > 0 ? [] : listing.leftovers;
5729
- const plan = planDepsEviction({ entries: listing.entries, leftovers, protectedKeys });
5730
- if (plan.remove.length === 0) return;
5824
+ return protectedKeys;
5825
+ }
5826
+
5827
+ /** Remove the store entries a plan names and drop their entry backups with
5828
+ * them (item 61 PR B: a spare nothing references on disk is a spare nothing
5829
+ * will wake into). Shared by the sweep and the attach's pressure path. */
5830
+ private async applyDepsEviction(plan: DepsEvictionPlan, listing: DepsStoreListing, who: string): Promise<void> {
5731
5831
  await this.runOk(["rm", "-rf", ...plan.remove], "deps-evict", { timeoutMs: DU_TIMEOUT_MS });
5732
5832
  console.log(
5733
- `deps: evicted ${plan.remove.length} path(s) — ${plan.remove.map((p) => p.slice(DEPS_STORE_DIR.length + 1, DEPS_STORE_DIR.length + 9)).join(", ")}; kept ${plan.keep.map((k) => k.slice(0, 8)).join(", ")}`,
5833
+ `deps: ${who} evicted ${plan.remove.length} path(s) — ${plan.remove.map((p) => p.slice(DEPS_STORE_DIR.length + 1, DEPS_STORE_DIR.length + 9)).join(", ")}; kept ${plan.keep.map((k) => k.slice(0, 8)).join(", ")}`,
5734
5834
  );
5735
- // The evicted entries' archives go with them (item 61 PR B): a spare
5736
- // nothing references on disk is a spare nothing will wake into.
5737
5835
  const kept = new Set(plan.keep);
5738
5836
  const evictedKeys = listing.entries.map((e) => e.key).filter((k) => !kept.has(k));
5739
5837
  const drop = depsBackupsToDrop({
@@ -5748,6 +5846,44 @@ export class ResidentDO extends Sandbox<Env> {
5748
5846
  }
5749
5847
  }
5750
5848
 
5849
+ /** Under disk pressure (item 55) the store keeps no spare: every complete
5850
+ * entry no live tree references goes, coldest first, before any idle tree
5851
+ * is considered — a spare is a cache worth minutes on a future attach, a
5852
+ * refused tree is a run falling to a cold sandbox now. Leftovers are the
5853
+ * sweep's business (they may be live installs' scratch). Answers what it
5854
+ * removed with the bytes each measured at; a listing that fails answers
5855
+ * nothing and the tree path decides as before. */
5856
+ private async evictDepsSparesUnderPressure(): Promise<Array<{ key: string; freedKiB: number }>> {
5857
+ const listed = await this.run(["sh", "-c", depsStoreListScript()], { timeoutMs: DU_TIMEOUT_MS });
5858
+ if (listed.exitCode !== 0) return [];
5859
+ const listing = parseDepsStoreListing(listed.stdout);
5860
+ if (listing.entries.length === 0) return [];
5861
+ const plan = planDepsEviction({
5862
+ entries: listing.entries,
5863
+ leftovers: [],
5864
+ protectedKeys: await this.depsProtectedKeys(),
5865
+ maxUnreferenced: DEPS_STORE_MAX_UNREFERENCED_UNDER_PRESSURE,
5866
+ });
5867
+ if (plan.remove.length === 0) return [];
5868
+ await this.applyDepsEviction(plan, listing, "disk-pressure");
5869
+ return plan.evicted.map((e) => ({ key: e.key, freedKiB: e.kib }));
5870
+ }
5871
+
5872
+ private async sweepDepsStore(): Promise<void> {
5873
+ const listed = await this.run(["sh", "-c", depsStoreListScript()], { timeoutMs: DU_TIMEOUT_MS });
5874
+ if (listed.exitCode !== 0) return;
5875
+ const listing = parseDepsStoreListing(listed.stdout);
5876
+ if (listing.entries.length === 0 && listing.leftovers.length === 0) return;
5877
+ const protectedKeys = await this.depsProtectedKeys();
5878
+ // A scratch/staging dir of an install still running in this incarnation
5879
+ // is live work, not debris; the listing cannot tell them apart, so
5880
+ // leftovers wait for a sweep with nothing in flight.
5881
+ const leftovers = this.depsInFlight.size > 0 ? [] : listing.leftovers;
5882
+ const plan = planDepsEviction({ entries: listing.entries, leftovers, protectedKeys });
5883
+ if (plan.remove.length === 0) return;
5884
+ await this.applyDepsEviction(plan, listing, "sweep");
5885
+ }
5886
+
5751
5887
  /** The per-user 700 staging dir (`install -d` is idempotent), memoized per
5752
5888
  * incarnation — the write/credential paths used to fork for it on every
5753
5889
  * call. Cleared with the other memos; a fresh disk simply re-creates it. */
@@ -7049,6 +7185,7 @@ export class ResidentDO extends Sandbox<Env> {
7049
7185
  };
7050
7186
  }
7051
7187
  const snap = await this.ctx.storage.get<SnapshotRecord>(SNAPSHOT_KEY);
7188
+ const recorded = await this.recordedBackupIds(snap);
7052
7189
  const bindings = await this.ctx.storage.list<ThreadBinding>({ prefix: THREAD_KEY_PREFIX });
7053
7190
  const plan = {
7054
7191
  resource,
@@ -7065,7 +7202,7 @@ export class ResidentDO extends Sandbox<Env> {
7065
7202
  checkoutBackupId: snap.checkout.id,
7066
7203
  }
7067
7204
  : null,
7068
- backupObjects: snap ? await this.countBackupObjects([snap.mirror.id, snap.checkout.id]) : 0,
7205
+ backupObjects: await this.countBackupObjects(recorded),
7069
7206
  },
7070
7207
  reprovision: { defaultRef, provisioningTimeoutMs },
7071
7208
  keeps: { registryRecord: true as const, threadBindings: bindings.size },
@@ -7076,9 +7213,9 @@ export class ResidentDO extends Sandbox<Env> {
7076
7213
  // and backups/<id>/ lives outside the resident/<resource>/ prefix — this
7077
7214
  // is the only path that can still reach them (same ordering as teardown).
7078
7215
  let backupObjectsDeleted = 0;
7079
- if (snap) {
7216
+ if (recorded.length > 0) {
7080
7217
  try {
7081
- backupObjectsDeleted = await this.deleteBackupObjects([snap.mirror.id, snap.checkout.id]);
7218
+ backupObjectsDeleted = await this.deleteBackupObjects(recorded);
7082
7219
  } catch {
7083
7220
  // best effort — the 1-year R2 TTL is the leak backstop
7084
7221
  }
@@ -7114,8 +7251,7 @@ export class ResidentDO extends Sandbox<Env> {
7114
7251
  this.listSchedules(PROVISIONING_CALLBACK),
7115
7252
  this.listSchedules(PROVISION_RUN_CALLBACK),
7116
7253
  ]);
7117
- const snap = await this.ctx.storage.get<SnapshotRecord>(SNAPSHOT_KEY);
7118
- const ids = snap ? [snap.mirror.id, snap.checkout.id] : [];
7254
+ const ids = await this.recordedBackupIds(await this.ctx.storage.get<SnapshotRecord>(SNAPSHOT_KEY));
7119
7255
  const bindings = await this.ctx.storage.list<ThreadBinding>({ prefix: THREAD_KEY_PREFIX });
7120
7256
  return {
7121
7257
  ...status,
@@ -7143,10 +7279,10 @@ export class ResidentDO extends Sandbox<Env> {
7143
7279
  let backupObjectsDeleted = 0;
7144
7280
  this.deleteSchedules(PROVISIONING_CALLBACK);
7145
7281
  this.deleteSchedules(PROVISION_RUN_CALLBACK);
7146
- const snap = await this.ctx.storage.get<SnapshotRecord>(SNAPSHOT_KEY);
7147
- if (snap) {
7282
+ const recorded = await this.recordedBackupIds(await this.ctx.storage.get<SnapshotRecord>(SNAPSHOT_KEY));
7283
+ if (recorded.length > 0) {
7148
7284
  try {
7149
- backupObjectsDeleted = await this.deleteBackupObjects([snap.mirror.id, snap.checkout.id]);
7285
+ backupObjectsDeleted = await this.deleteBackupObjects(recorded);
7150
7286
  } catch (err) {
7151
7287
  errors.push(`backup object deletion failed: ${errMsg(err)}`);
7152
7288
  }
@@ -41,6 +41,9 @@ import {
41
41
  SANDBOX_SLEEP_AFTER,
42
42
  isRecycleError,
43
43
  recycledMidCommandMessage,
44
+ DETACH_HINT,
45
+ OUTPUT_AFTER_EXIT_MS,
46
+ heldOutputNote,
44
47
  } from "../../src/execution/sandboxLifecycle.js";
45
48
  import { envFromRequest } from "../../src/execution/sandboxEnv.js";
46
49
  import { IdleGuard, type IdleGuardHost } from "../../src/execution/sandboxIdle.js";
@@ -88,10 +91,17 @@ const EXEC_TIMEOUT_SECS = 280;
88
91
  * the shell-level 124 is always the deadline a command meets first. */
89
92
  const SDK_BACKSTOP_MARGIN_MS = 40_000;
90
93
 
91
- /** How long the Durable Object waits for the process's exit past the SDK's
92
- * own limit before it kills the process itself and reports the timeout: the
93
- * runtime should have ended it at the limit; a starved container ends it late. */
94
- const OUTPUT_WAIT_MARGIN_MS = 30_000;
94
+ /** How long the Durable Object waits for the process's output to END past
95
+ * the command's own deadline before it asks the runtime what became of the
96
+ * process (docs/reference/specs/execution.md item 24). The runtime ends the
97
+ * output stream only when every holder of the command's stdout and stderr is
98
+ * gone; a process that exited while a detached child holds them is answered
99
+ * with its exit code and a note, a process still running is killed and
100
+ * answered as the shell-level timeout. The wait is `OUTPUT_AFTER_EXIT_MS`
101
+ * (20 s), inside the executor's per-send margin, so this answer always
102
+ * reaches the bot before it gives the command up — the SDK's own limit
103
+ * (`SDK_BACKSTOP_MARGIN_MS`) stays above it as the last resort. */
104
+ const OUTPUT_WAIT_AFTER_DEADLINE_MS = OUTPUT_AFTER_EXIT_MS;
95
105
 
96
106
  /** The interruption reasons that mean the container's runtime is not the one
97
107
  * the command started on: the process, if it started, is gone with its
@@ -303,7 +313,10 @@ export class SwitchboardSandbox extends Sandbox<Env> {
303
313
  return this.execFailure(err, startedAt);
304
314
  }
305
315
  try {
306
- const out = await proc.output({ encoding: "utf8", timeout: backstopMs + OUTPUT_WAIT_MARGIN_MS });
316
+ const out = await proc.output({
317
+ encoding: "utf8",
318
+ timeout: execTimeoutSecs * 1000 + OUTPUT_WAIT_AFTER_DEADLINE_MS,
319
+ });
307
320
  // coreutils `timeout` exits 124 when the deadline killed the command
308
321
  // (137 when the follow-up SIGKILL had to); the SDK's own limit, if it
309
322
  // ever wins, reports `timedOut`. All three are the one story.
@@ -321,9 +334,21 @@ export class SwitchboardSandbox extends Sandbox<Env> {
321
334
  };
322
335
  } catch (err) {
323
336
  if (err instanceof ProcessWaitTimeoutError) {
324
- // The runtime should have ended the process at the SDK's limit and
325
- // did not; abandoning a live process would leave it running in the
326
- // workspace. End it, then report the shell-level timeout it exceeded.
337
+ // The output never ended inside the command's deadline plus the
338
+ // margin. Two stories, told apart by asking the runtime (item 24):
339
+ // the process EXITED and a detached child holds its output open —
340
+ // the answer is its exit code and the note, the child runs on; or
341
+ // the process is still RUNNING past its own `timeout` — end it, and
342
+ // report the shell-level timeout it exceeded.
343
+ const status = await proc.status().catch(() => null);
344
+ if (status?.state === "exited") {
345
+ return {
346
+ stdout: "",
347
+ stderr: heldOutputNote(status.exit.code),
348
+ exitCode: status.exit.code,
349
+ durationMs: systemClock() - startedAt,
350
+ };
351
+ }
327
352
  await proc.kill(9).catch(() => {});
328
353
  return {
329
354
  stdout: "",
@@ -449,11 +474,12 @@ export class SwitchboardSandbox extends Sandbox<Env> {
449
474
  /** The stderr line an exit 124 carries: the limit, the knob, and the way to
450
475
  * outlive a command (`setsid -f`: every /exec runs under `timeout … bash -c`,
451
476
  * whose process group is reaped when the command returns, so a plain
452
- * background job dies with it). */
477
+ * background job dies with it — and with the wrapper's stdio redirected, so
478
+ * the command's output ends when the command does, item 24). */
453
479
  function timeoutNote(execTimeoutSecs: number): string {
454
480
  return (
455
481
  `command timed out in the sandbox after ${execTimeoutSecs}s (pass the bash tool's timeoutMs for longer commands, max ${BASH_TIMEOUT_MAX_MS} ms); ` +
456
- "re-run as smaller/faster steps, or start it detached with `setsid -f sh -c '<command> > /tmp/job.log 2>&1'` and poll the log on later calls"
482
+ `re-run as smaller/faster steps, or start it detached with \`${DETACH_HINT}\` and poll the log on later calls`
457
483
  );
458
484
  }
459
485
 
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.236.0",
3
+ "version": "1.238.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "switchboard",
9
- "version": "1.236.0",
9
+ "version": "1.238.0",
10
10
  "license": "Apache-2.0",
11
11
  "workspaces": [
12
12
  "web",
@@ -20241,7 +20241,7 @@
20241
20241
  },
20242
20242
  "packages/switchboard": {
20243
20243
  "name": "@coreplane/switchboard",
20244
- "version": "1.236.0",
20244
+ "version": "1.238.0",
20245
20245
  "license": "Apache-2.0",
20246
20246
  "dependencies": {
20247
20247
  "@earendil-works/pi-ai": "0.85.1",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.236.0",
3
+ "version": "1.238.0",
4
4
  "private": true,
5
5
  "description": "Mention it in Slack and an agent reviews the PR, ships the fix, or answers the question — on the model you choose, with its tools running where you decide.",
6
6
  "license": "Apache-2.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.236.0",
3
- "commit": "cf9308dd5f28ac788804260c2978403ce5b59c81",
4
- "builtAt": "2026-09-16T20:27:06.883Z"
2
+ "version": "1.238.0",
3
+ "commit": "9990b225865690781f1d97b7128228af23e39500",
4
+ "builtAt": "2026-09-16T22:38:02.661Z"
5
5
  }