@coreplane/switchboard 1.238.0 → 1.240.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 (155) hide show
  1. package/dist/assets/config/config.example.yaml +6 -0
  2. package/dist/assets/deploy/cloudflare-memory/worker.ts +115 -1
  3. package/dist/assets/deploy/cloudflare-memory/wrangler.template.jsonc +7 -1
  4. package/dist/assets/deploy/cloudflare-resident/worker.ts +223 -65
  5. package/dist/assets/deploy/cloudflare-sandbox/Dockerfile +8 -1
  6. package/dist/assets/deploy/cloudflare-sandbox/worker.ts +337 -44
  7. package/dist/assets/deploy/cloudflare-sandbox/wrangler.template.jsonc +19 -0
  8. package/dist/assets/deploy/secrets.manifest.json +4 -4
  9. package/dist/assets/package-lock.json +207 -3
  10. package/dist/assets/package.json +2 -1
  11. package/dist/assets/source.json +3 -3
  12. package/dist/assets/src/agents/registry.ts +82 -0
  13. package/dist/assets/src/core/authz/actor.ts +33 -2
  14. package/dist/assets/src/core/authz/policy.ts +6 -0
  15. package/dist/assets/src/core/coordinator/contract.ts +12 -1
  16. package/dist/assets/src/core/coordinator/driver.ts +13 -0
  17. package/dist/assets/src/core/costs.ts +1114 -0
  18. package/dist/assets/src/core/costsSnapshotStore.ts +209 -0
  19. package/dist/assets/src/core/delivery.ts +4 -11
  20. package/dist/assets/src/core/deliverySnapshotStore.ts +12 -28
  21. package/dist/assets/src/core/ingressTokens.ts +27 -13
  22. package/dist/assets/src/core/runLedger/types.ts +3 -0
  23. package/dist/assets/src/core/runRecord.ts +10 -3
  24. package/dist/assets/src/core/schedules.ts +2 -1
  25. package/dist/assets/src/core/ship/coordinator.ts +70 -0
  26. package/dist/assets/src/core/snapshotAge.ts +30 -0
  27. package/dist/assets/src/core/stateWorkerRef.ts +41 -0
  28. package/dist/assets/src/execution/residentAutoRebuild.ts +73 -0
  29. package/dist/assets/src/execution/residentRefresh.ts +25 -3
  30. package/dist/assets/src/execution/restoreWaiters.ts +61 -0
  31. package/dist/assets/src/execution/seedPlan.ts +267 -0
  32. package/dist/assets/src/mcp/registry.ts +7 -0
  33. package/dist/assets/web/dist/.vite/manifest.json +507 -427
  34. package/dist/assets/web/dist/assets/AppShell-CM_fh-WW.js +1 -0
  35. package/dist/assets/web/dist/assets/CostsPage-BD3w5j_S.js +2 -0
  36. package/dist/assets/web/dist/assets/DeliveryPage-BtvzKA1m.js +1 -0
  37. package/dist/assets/web/dist/assets/HomePage-BU6aRCSe.js +2 -0
  38. package/dist/assets/web/dist/assets/NotFoundPage-DwOX1HaZ.js +1 -0
  39. package/dist/assets/web/dist/assets/{ResidentDetailPage-CrfCd8fx.js → ResidentDetailPage-CUAOh5d9.js} +1 -1
  40. package/dist/assets/web/dist/assets/ResidentsIndexPage-B3KUlYy2.js +1 -0
  41. package/dist/assets/web/dist/assets/RunFoldRow-Dulcbg25.js +1 -0
  42. package/dist/assets/web/dist/assets/RunRoutePage-DGoGrQZq.js +6 -0
  43. package/dist/assets/web/dist/assets/RunsIndexPage-37W7OZbC.js +1 -0
  44. package/dist/assets/web/dist/assets/{RunsTabs-XpCqeWu4.js → RunsTabs-BnkvCBrM.js} +1 -1
  45. package/dist/assets/web/dist/assets/ScheduledPage-C1y9jgAk.js +1 -0
  46. package/dist/assets/web/dist/assets/SettingsPage-ggfB-Rqr.js +1 -0
  47. package/dist/assets/web/dist/assets/SlackMark-VDNs7Vjh.js +1 -0
  48. package/dist/assets/web/dist/assets/Slideover-CX-LEklF.js +1 -0
  49. package/dist/assets/web/dist/assets/{StatusDot-DkXALvKf.js → StatusDot-CiByEtoR.js} +1 -1
  50. package/dist/assets/web/dist/assets/{Tooltip-DtLIyi5P.js → Tooltip-DyRxk8A8.js} +1 -1
  51. package/dist/assets/web/dist/assets/UnitRoutePage-CnWyOizV.js +1 -0
  52. package/dist/assets/web/dist/assets/{angular-html-DYtV9uIN.js → angular-html-DAQ_1T1v.js} +1 -1
  53. package/dist/assets/web/dist/assets/{angular-ts-DF2P25Zp.js → angular-ts-D4kxRVp1.js} +1 -1
  54. package/dist/assets/web/dist/assets/{apl-Ff96wxko.js → apl-Dy07l64y.js} +1 -1
  55. package/dist/assets/web/dist/assets/{astro-LbBnZna-.js → astro-DZIbTlc7.js} +1 -1
  56. package/dist/assets/web/dist/assets/{blade-DltuxeBW.js → blade-CmSXWX3l.js} +1 -1
  57. package/dist/assets/web/dist/assets/{c-DBg7yuPx.js → c-ggzVdJLK.js} +1 -1
  58. package/dist/assets/web/dist/assets/{chapel-C83hUAds.js → chapel-Bo1wktsG.js} +1 -1
  59. package/dist/assets/web/dist/assets/{cobol-DB0yV_2D.js → cobol-DJUmtKM2.js} +1 -1
  60. package/dist/assets/web/dist/assets/{coffee-Bpih6Kzl.js → coffee-BZWOVz4C.js} +1 -1
  61. package/dist/assets/web/dist/assets/{cpp-CU00ZeRn.js → cpp-Dtk39yMw.js} +1 -1
  62. package/dist/assets/web/dist/assets/{crystal-f8ImJkux.js → crystal-CGf_-Zoj.js} +1 -1
  63. package/dist/assets/web/dist/assets/{css-4VrC4ISo.js → css-CX2fcL4u.js} +1 -1
  64. package/dist/assets/web/dist/assets/delivery-CTdOqpVK.js +1 -0
  65. package/dist/assets/web/dist/assets/{dist-DtA7JiH5.js → dist-D8TaC961.js} +2 -2
  66. package/dist/assets/web/dist/assets/durationTone-DXG-3R7_.js +1 -0
  67. package/dist/assets/web/dist/assets/{edge-BwFDzfS_.js → edge-BYS8FtgQ.js} +1 -1
  68. package/dist/assets/web/dist/assets/{elixir-wwIYIpJm.js → elixir-fhUpi3nO.js} +1 -1
  69. package/dist/assets/web/dist/assets/{elm-DyDuM3v3.js → elm-DAQNiwsN.js} +1 -1
  70. package/dist/assets/web/dist/assets/{erb-DFJBEVrh.js → erb-CWzEys6U.js} +1 -1
  71. package/dist/assets/web/dist/assets/eventSource-tRprhqm7.js +1 -0
  72. package/dist/assets/web/dist/assets/favicon-D1hkyC5j.js +1 -0
  73. package/dist/assets/web/dist/assets/{git-rebase-CuDv-44X.js → git-rebase-9rY3p-Rw.js} +1 -1
  74. package/dist/assets/web/dist/assets/{glimmer-js-ZFnrm4J3.js → glimmer-js-tY7hy0nE.js} +1 -1
  75. package/dist/assets/web/dist/assets/{glimmer-ts-XY4Yqshp.js → glimmer-ts-CTrLv5JF.js} +1 -1
  76. package/dist/assets/web/dist/assets/{glsl-xnR8efx0.js → glsl-BRJbiDfq.js} +1 -1
  77. package/dist/assets/web/dist/assets/{graphql-D-gO5tnP.js → graphql-B-MtVLh2.js} +1 -1
  78. package/dist/assets/web/dist/assets/{hack-CSxbW4ax.js → hack-BtIkOypQ.js} +1 -1
  79. package/dist/assets/web/dist/assets/{haml-D4ar42QT.js → haml-CoOH6gfa.js} +1 -1
  80. package/dist/assets/web/dist/assets/{handlebars-CMLbDE7o.js → handlebars-DgrRu-GP.js} +1 -1
  81. package/dist/assets/web/dist/assets/{html-CVekU4Fs.js → html-DZaaI7ca.js} +1 -1
  82. package/dist/assets/web/dist/assets/{html-derivative-St3HHw_N.js → html-derivative-DgHvT-qZ.js} +1 -1
  83. package/dist/assets/web/dist/assets/{http-Mon0DWHS.js → http-CvAd51yz.js} +1 -1
  84. package/dist/assets/web/dist/assets/{hurl-Cbl5IvBs.js → hurl-CXGqAfzx.js} +1 -1
  85. package/dist/assets/web/dist/assets/{localIso-CNA-bhS-.js → indexFormat-B-pd4r7Y.js} +1 -1
  86. package/dist/assets/web/dist/assets/{indexRow-FLJu5mR9.js → indexRow-Cse3JGb0.js} +1 -1
  87. package/dist/assets/web/dist/assets/{java-Bprck1l-.js → java-CIQStIXF.js} +1 -1
  88. package/dist/assets/web/dist/assets/{javascript-C73G1-UR.js → javascript-WbH8s7E3.js} +1 -1
  89. package/dist/assets/web/dist/assets/{jinja-C4DuRxYJ.js → jinja-DzyWEVKC.js} +1 -1
  90. package/dist/assets/web/dist/assets/{jison-B2U03GEC.js → jison-CrT-zCa-.js} +1 -1
  91. package/dist/assets/web/dist/assets/{json-CvTZUDzx.js → json-lHjpjXMQ.js} +1 -1
  92. package/dist/assets/web/dist/assets/{jsx-DMWKCzwo.js → jsx-WTwYCHW6.js} +1 -1
  93. package/dist/assets/web/dist/assets/{julia-Czn7bV_3.js → julia-Bqpk9H7E.js} +1 -1
  94. package/dist/assets/web/dist/assets/{just-BBxpNfvC.js → just-LdXk1r3m.js} +1 -1
  95. package/dist/assets/web/dist/assets/{latex-Bw9yg5mr.js → latex-h_y2A5KD.js} +1 -1
  96. package/dist/assets/web/dist/assets/{liquid-BRCjaydE.js → liquid-CY7bF2_p.js} +1 -1
  97. package/dist/assets/web/dist/assets/localIso-L06jV29p.js +1 -0
  98. package/dist/assets/web/dist/assets/{lua-DUu2KIC0.js → lua-oQ7XVZOY.js} +1 -1
  99. package/dist/assets/web/dist/assets/main-CGO6fQ8k.js +28 -0
  100. package/dist/assets/web/dist/assets/main-O8UXRuEv.css +1 -0
  101. package/dist/assets/web/dist/assets/{marko-CZJZIlSQ.js → marko-zv_qZ4QV.js} +1 -1
  102. package/dist/assets/web/dist/assets/{mdc-BmClq6fd.js → mdc-Dl4kL_YK.js} +1 -1
  103. package/dist/assets/web/dist/assets/{nginx-Bmt3xSEm.js → nginx-CAHCZj4F.js} +1 -1
  104. package/dist/assets/web/dist/assets/{nim-Da-i0utK.js → nim-BOtmwCJH.js} +1 -1
  105. package/dist/assets/web/dist/assets/{org-BvAre2qP.js → org-DspQk3HQ.js} +1 -1
  106. package/dist/assets/web/dist/assets/{perl-v9WwFFtB.js → perl-CAXmvVl0.js} +1 -1
  107. package/dist/assets/web/dist/assets/{php-DTU4Zyze.js → php-CMUx4VyM.js} +1 -1
  108. package/dist/assets/web/dist/assets/{pug-DhTwEqTO.js → pug-B55xVJTv.js} +1 -1
  109. package/dist/assets/web/dist/assets/{qml-BUD9AC_c.js → qml-DsNKCCRk.js} +1 -1
  110. package/dist/assets/web/dist/assets/{r-CAnu56n7.js → r-DbhP0VvO.js} +1 -1
  111. package/dist/assets/web/dist/assets/{razor-Dn0P4gAq.js → razor-BCpE6P-p.js} +1 -1
  112. package/dist/assets/web/dist/assets/{regexp-CZyBRaVW.js → regexp-BllsjiN4.js} +1 -1
  113. package/dist/assets/web/dist/assets/{rst-Ckyd5UsY.js → rst-CO5Rpq9V.js} +1 -1
  114. package/dist/assets/web/dist/assets/{ruby-BBQ62fsx.js → ruby-C2A4OgQq.js} +1 -1
  115. package/dist/assets/web/dist/assets/{sas-CW35hMxG.js → sas-Butzoyql.js} +1 -1
  116. package/dist/assets/web/dist/assets/{scss-CpV8pF4J.js → scss-PEUwJBpf.js} +1 -1
  117. package/dist/assets/web/dist/assets/settingsApi-priOPiwu.js +1 -0
  118. package/dist/assets/web/dist/assets/{shellscript-CRJvsmE1.js → shellscript-3Yhq0khE.js} +1 -1
  119. package/dist/assets/web/dist/assets/{shellsession-CyriUZxf.js → shellsession-04Hkb7m3.js} +1 -1
  120. package/dist/assets/web/dist/assets/{soy-D5QzYUeg.js → soy-BnId73cP.js} +1 -1
  121. package/dist/assets/web/dist/assets/{sql-CUBf_Mse.js → sql-BKwdc0ch.js} +1 -1
  122. package/dist/assets/web/dist/assets/sseReplay-DutHNghE.js +9 -0
  123. package/dist/assets/web/dist/assets/{stata-B-4n4ITz.js → stata-BPPtNkay.js} +1 -1
  124. package/dist/assets/web/dist/assets/{surrealql-RuWZBLtr.js → surrealql-CDPChcYP.js} +1 -1
  125. package/dist/assets/web/dist/assets/{svelte-CO5YzCsK.js → svelte-B15DIQ-z.js} +1 -1
  126. package/dist/assets/web/dist/assets/{templ-DbkWVaBF.js → templ-D-rp_AiB.js} +1 -1
  127. package/dist/assets/web/dist/assets/{tex-IuxoEcId.js → tex-BnVDCD7p.js} +1 -1
  128. package/dist/assets/web/dist/assets/{ts-tags-g6dGQPjd.js → ts-tags-cpujdyAR.js} +1 -1
  129. package/dist/assets/web/dist/assets/{tsx-WnXwiyH2.js → tsx-D1vO4L72.js} +1 -1
  130. package/dist/assets/web/dist/assets/{twig-Lx1G7PnY.js → twig-oQGnQGRc.js} +1 -1
  131. package/dist/assets/web/dist/assets/{typescript-BQd7biLF.js → typescript-CKSFpXiH.js} +1 -1
  132. package/dist/assets/web/dist/assets/{typst-D2lXPHK7.js → typst-DKHFo8BF.js} +1 -1
  133. package/dist/assets/web/dist/assets/{vue-4wVQD9y9.js → vue-BstokpiS.js} +1 -1
  134. package/dist/assets/web/dist/assets/{vue-html-DZPaRte4.js → vue-html-DpCp0_Sz.js} +1 -1
  135. package/dist/assets/web/dist/assets/{vue-vine-DbmZCmu-.js → vue-vine-y5d5wUb1.js} +1 -1
  136. package/dist/assets/web/dist/assets/{xml-DV16szyo.js → xml-Br2hx5Ax.js} +1 -1
  137. package/dist/assets/web/dist/assets/{xsl-eo5-1sk0.js → xsl-Bc8Cobap.js} +1 -1
  138. package/dist/assets/web/dist/assets/{yaml-BXdX8HZf.js → yaml-LOITy_I1.js} +1 -1
  139. package/dist/cli.js +2586 -1603
  140. package/package.json +1 -1
  141. package/dist/assets/web/dist/assets/AppShell-MYHNxJks.js +0 -1
  142. package/dist/assets/web/dist/assets/CostsPage-DKk9OjB0.js +0 -2
  143. package/dist/assets/web/dist/assets/DeliveryPage-Kz6js49i.js +0 -1
  144. package/dist/assets/web/dist/assets/NotFoundPage-BXLrQQzy.js +0 -1
  145. package/dist/assets/web/dist/assets/ResidentsIndexPage-BdW0k2h3.js +0 -1
  146. package/dist/assets/web/dist/assets/RunFoldRow-CbTd9SiS.js +0 -9
  147. package/dist/assets/web/dist/assets/RunRoutePage-br0H-8Pz.js +0 -6
  148. package/dist/assets/web/dist/assets/RunsIndexPage-Bnzb15zH.js +0 -1
  149. package/dist/assets/web/dist/assets/ScheduledPage-NLhjzHAz.js +0 -1
  150. package/dist/assets/web/dist/assets/SettingsPage-D-i0uVw-.js +0 -1
  151. package/dist/assets/web/dist/assets/UnitRoutePage-DGXukEAH.js +0 -1
  152. package/dist/assets/web/dist/assets/durationTone-CYMux25J.js +0 -1
  153. package/dist/assets/web/dist/assets/favicon-CSt-qDvc.js +0 -1
  154. package/dist/assets/web/dist/assets/main-CFHF2RvQ.css +0 -1
  155. package/dist/assets/web/dist/assets/main-Dht0in4v.js +0 -28
@@ -156,6 +156,7 @@ import {
156
156
  type WatchdogSummary,
157
157
  } from "../../src/core/schedules.js";
158
158
  import type { ResidentLifecycleState } from "../../src/execution/residentState.js";
159
+ import { RestoreWaiters } from "../../src/execution/restoreWaiters.js";
159
160
  import {
160
161
  decisivePull,
161
162
  effectiveLimits,
@@ -195,6 +196,7 @@ import {
195
196
  type RestoreSample,
196
197
  type RefreshFailure,
197
198
  } from "../../src/execution/residentRefresh.js";
199
+ import { autoRebuildDecision, isAutoRebuildEligible } from "../../src/execution/residentAutoRebuild.js";
198
200
  import {
199
201
  lifecycleOf,
200
202
  parseLifecycle,
@@ -347,7 +349,7 @@ const BUILD_ID = buildId(BUILD);
347
349
  // route is a `resident.fetch` root at the edge. Each joins the bot's trace when
348
350
  // the request carried one. The tracer and its log sink are shared.ts's: the
349
351
  // refresh instance's root (refresh.ts) starts from the same pair.
350
- const STREAMED_ROUTES: ReadonlySet<string> = new Set(["/attach", "/exec", "/op"]);
352
+ const STREAMED_ROUTES: ReadonlySet<string> = new Set(["/attach", "/exec", "/op", "/await-restore"]);
351
353
 
352
354
  /** One request as the resident's own root: started at its t0, joining the
353
355
  * bot's trace when `traceparent` parses, the collector's steps grafted as
@@ -591,18 +593,12 @@ function isNonEvidenceReason(reason: string): boolean {
591
593
  * 503 {state, reason: "mirror-busy"} instead of queueing forever. */
592
594
  const ATTACH_MUTEX_WAIT_MS = 60_000;
593
595
 
594
- /** Watchdog auto-rebuild: a resident down with a REHYDRATION-flavored
595
- * reason (bad/unreadable snapshots — states only a rebuild can escape, since
596
- * down chains never retry hydration) accumulates one strike per watchdog
597
- * pass; at N strikes the watchdog triggers the same down→onboarding rebuild
598
- * an admin would, discarding the unusable snapshots and reprovisioning from
599
- * GitHub. Provision-failure downs never auto-rebuild — they would loop
600
- * against the same broken build. With the 10-minute cron, N=3 ≈ 30 minutes
601
- * down before the automatic escape hatch fires. `runtime-unreachable` is the
602
- * ladder's last rung (item 64): a recreated container did not answer either,
603
- * so the rebuild — destroy plus reprovision — is the only exit left. */
604
- const AUTO_REBUILD_AFTER_STRIKES = 3;
605
- const REHYDRATION_FAILURE_RE = /^(r2-restore-failed|snapshot-stamp-mismatch|no-snapshot|runtime-unreachable)/;
596
+ // Auto-rebuild (item 36): a resident `down` on a reason only a rebuild can
597
+ // escape (REHYDRATION_FAILURE_RE, `residentAutoRebuild.ts`) is rebuilt on the
598
+ // `goDown` transition itself, under `AUTO_REBUILD_BUDGET` per window; the
599
+ // watchdog is the backstop for a transition that could not act (no registry
600
+ // record at the time). Provision-failure downs never auto-rebuild — they would
601
+ // loop against the same broken build.
606
602
 
607
603
  /** /exec budget: the shared 5-minute default (`BASH_TIMEOUT_MS`);
608
604
  * a caller may raise it per call via the body's `timeoutMs` up to the shared
@@ -1237,6 +1233,19 @@ interface DepsBackupRecord {
1237
1233
  createdAt: string;
1238
1234
  }
1239
1235
 
1236
+ /** The snapshot as `/status` and `/debug info` publish it: the stamp, both
1237
+ * archive ids, and the deps entry archive for its lockfile key (null until
1238
+ * one has been taken). */
1239
+ interface SnapshotHandle {
1240
+ ref: string;
1241
+ sha: string;
1242
+ lockfileHash: string;
1243
+ createdAt: string;
1244
+ mirrorBackupId: string;
1245
+ checkoutBackupId: string;
1246
+ depsBackupId: string | null;
1247
+ }
1248
+
1240
1249
  /** What the snapshot step answers: the record already at the stamp, a record
1241
1250
  * it committed (with the one it replaced), or a step another writer won. */
1242
1251
  type SnapshotStepResult =
@@ -1447,7 +1456,11 @@ const UPDATED_KEY = "resident:updatedAt";
1447
1456
  const FACTS_KEY = "resident:facts";
1448
1457
  const SNAPSHOT_KEY = "resident:snapshot";
1449
1458
  const DEADLINE_AT_KEY = "resident:provisionDeadlineAt";
1450
- const REBUILD_STRIKES_KEY = "resident:rebuildStrikes"; // watchdog auto-rebuild counter
1459
+ /** ISO instants of this resident's auto-rebuilds (item 36): the budget's window
1460
+ * is judged over it; a manual rebuild or an offboard clears it. */
1461
+ const AUTO_REBUILDS_KEY = "resident:autoRebuilds";
1462
+ /** The strike counter the watchdog kept before the transition rebuilt (item 36); a serving pass deletes a leftover row. */
1463
+ const RETIRED_REBUILD_STRIKES_KEY = "resident:rebuildStrikes";
1451
1464
  /** Consecutive connects the container's control port did not answer (item 64): the ladder's count. */
1452
1465
  const RUNTIME_UNREACHABLE_KEY = "resident:runtimeUnreachable";
1453
1466
 
@@ -1731,6 +1744,12 @@ export class ResidentDO extends Sandbox<Env> {
1731
1744
  private incarnation = mintIncarnationId();
1732
1745
  private leaseSeq = 0;
1733
1746
 
1747
+ /** Item 27: the held /await-restore requests, answered by the lifecycle
1748
+ * transition out of `restoring` (`setResidentState`). In-memory on purpose:
1749
+ * a DO restart drops the held RPCs with the ledger and the bot's own
1750
+ * deadline names the fallback. */
1751
+ private restoreWaiters = new RestoreWaiters();
1752
+
1734
1753
  private nextHolder(): string {
1735
1754
  return `${this.incarnation}:${++this.leaseSeq}`;
1736
1755
  }
@@ -2527,8 +2546,19 @@ export class ResidentDO extends Sandbox<Env> {
2527
2546
  } catch (err) {
2528
2547
  // The typed and cause-chain check first: the SDK's replacement errors
2529
2548
  // (a stale process handle, an inactive runtime identity, an interrupted
2530
- // operation) carry the wording one cause down or not at all.
2531
- const disposition = restoreFailureDisposition(errMsg(err), { runtimeReplaced: isRuntimeReplacement(err) });
2549
+ // operation) carry the wording one cause down or not at all. An extract
2550
+ // the container roll SIGTERMed is different: its exec comes back a
2551
+ // normal `exit 143` result and the SDK's wording only reaches the execs
2552
+ // that follow — so the disposition also reads the kill signature, and
2553
+ // asks the runtime whether the container the extract wrote to is still
2554
+ // there (the incident that named this: a resident went `down` on exactly
2555
+ // this, 10 ms before every exec answered "container is not running").
2556
+ const runtimeReplaced = isRuntimeReplacement(err);
2557
+ const runtimeActive = runtimeReplaced ? false : await this.isRuntimeActive().catch(() => null);
2558
+ const disposition = restoreFailureDisposition(errMsg(err), {
2559
+ runtimeReplaced,
2560
+ runtimeActive: runtimeActive ?? undefined,
2561
+ });
2532
2562
  if (disposition.action === "interrupted") {
2533
2563
  // The runtime was replaced under the restore (a resident Worker deploy
2534
2564
  // rolled the container): the disk the stream wrote to is gone with the
@@ -2544,7 +2574,10 @@ export class ResidentDO extends Sandbox<Env> {
2544
2574
  console.log(`restore: interrupted by a runtime replacement — ${disposition.reason.slice(0, 400)}`);
2545
2575
  await this.recordRefreshError(disposition.reason);
2546
2576
  await this.setResidentState("degraded", disposition.reason);
2547
- throw err;
2577
+ // The reason travels on the error: the instance step's classifier
2578
+ // reads `restore-interrupted:` as an interruption whether the verdict
2579
+ // came from the SDK's wording, the kill signature or the probe.
2580
+ throw new StepError(err instanceof StepError ? err.step : "restore", disposition.reason);
2548
2581
  }
2549
2582
  // A stalled or capped restore is STILL STREAMING (the SDK call cannot be
2550
2583
  // cancelled); `pendingRestores` keeps the next hydrate off its directory,
@@ -2600,9 +2633,62 @@ export class ResidentDO extends Sandbox<Env> {
2600
2633
  * instance: the cron's decision reads the state). */
2601
2634
  private async goDown(reason: string): Promise<ResidentDownError> {
2602
2635
  await this.setResidentState("down", reason);
2636
+ // Item 36: the transition is the trigger — a rehydration down is rebuilt
2637
+ // now (the rebuild arms provisioning's own schedule and returns), not
2638
+ // after three watchdog passes. The error still says `down`: the caller's
2639
+ // step ends the way it always did, and the rebuild's provisioning owns the
2640
+ // disk from its callback on. A failure inside the judgement is logged, not
2641
+ // thrown: the caller's contract is the ResidentDownError, and the state is
2642
+ // already `down`, where the watchdog's backstop pass finds it.
2643
+ await this.autoRebuildFromDown(reason, "transition").catch((err) =>
2644
+ console.log(`auto-rebuild (transition): failed — ${errMsg(err)}; the watchdog's pass is the backstop`),
2645
+ );
2603
2646
  return new ResidentDownError(reason);
2604
2647
  }
2605
2648
 
2649
+ /** Item 36: judge a `down` for the automatic rebuild and act. `rebuilt` when
2650
+ * the rebuild started (state is `onboarding` now); `budget-spent` when the
2651
+ * resident stays down with the budget stamped on its reason; `none` for a
2652
+ * reason a rebuild cannot help, one already stamped, or a registry record
2653
+ * missing (an offboard mid-flight — the watchdog's pass is the backstop).
2654
+ * Both callers — the transition and the watchdog — read and write the one
2655
+ * history row, so they cannot disagree about the budget. */
2656
+ private async autoRebuildFromDown(
2657
+ reason: string,
2658
+ where: "transition" | "watchdog",
2659
+ ): Promise<"rebuilt" | "budget-spent" | "none"> {
2660
+ if (!isAutoRebuildEligible(reason)) return "none";
2661
+ const history = (await this.ctx.storage.get<string[]>(AUTO_REBUILDS_KEY)) ?? [];
2662
+ const decision = autoRebuildDecision({ reason, history, now: systemClock() });
2663
+ if (decision.action === "not-eligible") return "none";
2664
+ if (decision.action === "budget-spent") {
2665
+ console.log(`auto-rebuild (${where}): ${decision.reason.slice(0, 400)}`);
2666
+ await this.ctx.storage.put(AUTO_REBUILDS_KEY, decision.history);
2667
+ await this.setResidentState("down", decision.reason);
2668
+ return "budget-spent";
2669
+ }
2670
+ const resource = (await this.ctx.storage.get<string>(RESOURCE_KEY)) ?? "";
2671
+ const record = await this.registry()
2672
+ .getRecord(resource)
2673
+ .catch(() => null);
2674
+ if (!record) {
2675
+ console.log(`auto-rebuild (${where}): no registry record for the resident — left down (${reason.slice(0, 200)})`);
2676
+ return "none";
2677
+ }
2678
+ console.log(`auto-rebuild (${where}): ${decision.reason.slice(0, 400)}`);
2679
+ const result = await this.rebuild(resource, record.defaultRef, record.provisioningTimeoutMs, false, {
2680
+ auto: true,
2681
+ });
2682
+ if ("error" in result) {
2683
+ console.log(`auto-rebuild (${where}): rebuild refused — ${result.error}`);
2684
+ return "none";
2685
+ }
2686
+ // The instant is recorded once the rebuild has started: a refused rebuild
2687
+ // spends no budget.
2688
+ await this.ctx.storage.put(AUTO_REBUILDS_KEY, decision.history);
2689
+ return "rebuilt";
2690
+ }
2691
+
2606
2692
  private async recordRefreshError(message: string): Promise<void> {
2607
2693
  const facts = await this.ctx.storage.get<RepoFacts>(FACTS_KEY);
2608
2694
  if (facts) await this.ctx.storage.put(FACTS_KEY, { ...facts, lastRefreshError: residentText(message) });
@@ -3383,8 +3469,8 @@ export class ResidentDO extends Sandbox<Env> {
3383
3469
  await this.recreateContainer(reason);
3384
3470
  throw err;
3385
3471
  case "down":
3386
- // A fresh VM did not answer either. Down with a strike-eligible reason
3387
- // (REHYDRATION_FAILURE_RE) — the watchdog rebuilds after its passes —
3472
+ // A fresh VM did not answer either. Down with a rebuild-eligible reason
3473
+ // (REHYDRATION_FAILURE_RE) — goDown rebuilds on the transition, item 36 —
3388
3474
  // and the VM destroyed, so the rebuild's provisioning starts on a new one.
3389
3475
  this.swapIncarnation(); // deliberate incarnation swap
3390
3476
  await this.destroy().catch((destroyErr) =>
@@ -4281,8 +4367,8 @@ export class ResidentDO extends Sandbox<Env> {
4281
4367
 
4282
4368
  /** One watchdog pass over this resident (invoked by the Worker cron): time
4283
4369
  * out an onboarding stuck past its budget → down(provision-timeout) + cap
4284
- * slot release; auto-rebuild a resident stuck down on unusable snapshots
4285
- * (one strike per pass, rebuild at AUTO_REBUILD_AFTER_STRIKES); name a
4370
+ * slot release; the backstop for item 36's auto-rebuild (a rehydration
4371
+ * down the transition could not act on — same decision, same row); name a
4286
4372
  * `refreshing`/`restoring` marker older than STALE_MIDFLIGHT_MS that no
4287
4373
  * live lease and no running instance stands behind —
4288
4374
  * `degraded(stale-mid-flight: …)` carrying the last instance's id and the
@@ -4324,28 +4410,26 @@ export class ResidentDO extends Sandbox<Env> {
4324
4410
  return { resource, ...status, action: "none" };
4325
4411
  }
4326
4412
  if (status.state === "down") {
4327
- // Auto-rebuild escape hatch: only rehydration-flavored downs — the
4328
- // snapshots themselves are the problem, and a down resident runs no
4329
- // cycle, so without this the resident would stay down forever.
4330
- if (REHYDRATION_FAILURE_RE.test(status.reason)) {
4331
- const strikes = ((await this.ctx.storage.get<number>(REBUILD_STRIKES_KEY)) ?? 0) + 1;
4332
- if (strikes >= AUTO_REBUILD_AFTER_STRIKES) {
4333
- const record = await this.registry()
4334
- .getRecord(resource)
4335
- .catch(() => null);
4336
- if (record) {
4337
- const reason = `auto-rebuild: down for ${strikes} watchdog passes (${status.reason})`;
4338
- await this.rebuild(resource, record.defaultRef, record.provisioningTimeoutMs, false);
4339
- return { resource, state: "onboarding", reason, action: "auto-rebuilt" };
4340
- }
4341
- }
4342
- await this.ctx.storage.put(REBUILD_STRIKES_KEY, strikes);
4413
+ // Item 36's backstop: the transition rebuilt (or stamped the budget)
4414
+ // when it fired; a rehydration down still here on a pass is one the
4415
+ // transition could not act on — a registry record missing then, or a
4416
+ // down from before the transition rebuilt. Same decision, same history
4417
+ // row, no strikes to accumulate.
4418
+ if ((await this.autoRebuildFromDown(status.reason, "watchdog")) === "rebuilt") {
4419
+ return {
4420
+ resource,
4421
+ state: "onboarding",
4422
+ reason: `auto-rebuild (watchdog): ${status.reason}`,
4423
+ action: "auto-rebuilt",
4424
+ };
4343
4425
  }
4344
- return { resource, ...status, action: "none" };
4426
+ return { resource, ...(await this.getStatus()), action: "none" };
4345
4427
  }
4346
- // Any serving state clears accumulated strikes (a recovery must reset the
4347
- // counter, or an unrelated later down inherits stale strikes).
4348
- await this.ctx.storage.delete(REBUILD_STRIKES_KEY);
4428
+ // The auto-rebuild history is NOT cleared by a serving state: the budget
4429
+ // counts rebuilds inside a window, so a resident that comes back `warm`
4430
+ // and goes `down` again is judged with its earlier rebuilds in view. The
4431
+ // strike counter the watchdog kept before is a retired row.
4432
+ await this.ctx.storage.delete(RETIRED_REBUILD_STRIKES_KEY);
4349
4433
 
4350
4434
  // A mid-flight state older than STALE_MIDFLIGHT_MS with no cycle or restore
4351
4435
  // actually running is a marker orphaned by a cycle that died — a DO evicted
@@ -6911,6 +6995,29 @@ export class ResidentDO extends Sandbox<Env> {
6911
6995
  [REASON_KEY]: residentText(reason),
6912
6996
  [UPDATED_KEY]: new Date(systemClock()).toISOString(),
6913
6997
  });
6998
+ // Item 27: the transition OUT of `restoring` is the restore event — it
6999
+ // answers every held /await-restore request with the state landed on.
7000
+ if (state !== "restoring") this.restoreWaiters.publish({ state, reason: residentText(reason) });
7001
+ }
7002
+
7003
+ /** POST /await-restore (docs/reference/specs/execution.md item 27): answer at
7004
+ * once when the state is anything but `restoring`; otherwise hold the ONE
7005
+ * request on the waiter ledger until `setResidentState` leaves `restoring`
7006
+ * and publishes. No polling and no retry timer on either side. */
7007
+ async awaitRestore(): Promise<{ state: string; reason: string }> {
7008
+ // The waiter goes on the ledger BEFORE the state is read: the read awaits
7009
+ // storage, and a transition that lands in that gap would publish to a
7010
+ // ledger this request is not on yet and hold it for a transition that may
7011
+ // never come (the bot's deadline would then turn a short wait cold). A
7012
+ // state already out of `restoring` answers at once and withdraws the waiter.
7013
+ const held = this.restoreWaiters.hold();
7014
+ const status = await this.getStatus();
7015
+ if (status.state !== "restoring") {
7016
+ held.withdraw();
7017
+ return { state: status.state, reason: status.reason };
7018
+ }
7019
+ const outcome = await held.outcome;
7020
+ return { state: outcome.state, reason: outcome.reason };
6914
7021
  }
6915
7022
 
6916
7023
  async getStatus(): Promise<ResidentStatus> {
@@ -6925,6 +7032,26 @@ export class ResidentDO extends Sandbox<Env> {
6925
7032
  /** Full admin-facing view (surfaced via GET /residents and /debug info):
6926
7033
  * lifecycle + recorded sha/cache keys/snapshot stamp/refresh telemetry.
6927
7034
  * Backup handles are reduced to ids — never the raw handle internals. */
7035
+ /** The stamped snapshot as a handle (docs/reference/specs/resident-repos.md
7036
+ * item 7): the stamp, both archive ids, and the deps-store entry archive for
7037
+ * the snapshot's own lockfile key when one has been taken (item 61). What
7038
+ * `/status` publishes for the seed (execution.md item 25 — a seeded sandbox
7039
+ * restores the checkout and the deps view from it) and what `/debug info`
7040
+ * shows; null before the first snapshot. */
7041
+ async snapshotHandle(snap?: SnapshotRecord): Promise<SnapshotHandle | null> {
7042
+ const record = snap ?? (await this.ctx.storage.get<SnapshotRecord>(SNAPSHOT_KEY));
7043
+ if (!record) return null;
7044
+ return {
7045
+ ref: record.ref,
7046
+ sha: record.sha,
7047
+ lockfileHash: record.lockfileHash,
7048
+ createdAt: record.createdAt,
7049
+ mirrorBackupId: record.mirror.id,
7050
+ checkoutBackupId: record.checkout.id,
7051
+ depsBackupId: (await this.depsBackupRecord(record.lockfileHash))?.backup.id ?? null,
7052
+ };
7053
+ }
7054
+
6928
7055
  async getResidentInfo(): Promise<Record<string, unknown>> {
6929
7056
  const map = await this.ctx.storage.get<unknown>([
6930
7057
  RESOURCE_KEY,
@@ -7005,16 +7132,7 @@ export class ResidentDO extends Sandbox<Env> {
7005
7132
  lastRefreshError: facts?.lastRefreshError ?? null,
7006
7133
  lastRestore: facts?.lastRestore ?? null,
7007
7134
  idleSince: facts?.idleSince ?? null,
7008
- snapshot: snap
7009
- ? {
7010
- ref: snap.ref,
7011
- sha: snap.sha,
7012
- lockfileHash: snap.lockfileHash,
7013
- createdAt: snap.createdAt,
7014
- mirrorBackupId: snap.mirror.id,
7015
- checkoutBackupId: snap.checkout.id,
7016
- }
7017
- : null,
7135
+ snapshot: await this.snapshotHandle(snap),
7018
7136
  // The provisioning schedules, the one timer a resident has (item 3).
7019
7137
  schedules: {
7020
7138
  provisionRun: provisionRun.length,
@@ -7051,6 +7169,9 @@ export class ResidentDO extends Sandbox<Env> {
7051
7169
  // Item 64: consecutive connects the control port did not answer, with
7052
7170
  // the rung that count is on; null while the port answers.
7053
7171
  runtimeUnreachable: unreachable ? { ...unreachable, rung: runtimeUnreachableRung(unreachable.count) } : null,
7172
+ // Item 36: the auto-rebuilds this resident has had (ISO instants; the
7173
+ // budget's window is judged over them). Empty after a person's rebuild.
7174
+ autoRebuilds: (map.get(AUTO_REBUILDS_KEY) as string[] | undefined) ?? [],
7054
7175
  };
7055
7176
  }
7056
7177
 
@@ -7133,12 +7254,17 @@ export class ResidentDO extends Sandbox<Env> {
7133
7254
  return { recreated: true, restoreStartedAt };
7134
7255
  }
7135
7256
 
7136
- /** Fault injection for the watchdog's auto-rebuild path: persist
7137
- * `down` with a rehydration-flavored reason (what a real goDown does), so
7138
- * repeated watchdog passes can strike it up to the auto-rebuild without
7139
- * corrupting real R2 objects. Test-only semantics; admin scope. */
7140
- async debugForceDown(reason: string): Promise<ResidentStatus> {
7141
- await this.setResidentState("down", reason);
7257
+ /** Fault injection for item 36 without corrupting real R2 objects. Bare: persist
7258
+ * `down` with a rehydration-flavored reason and nothing else, so the next
7259
+ * watchdog pass is the backstop under test. `transition`: go through
7260
+ * `goDown` itself — the rebuild (or the budget stamp) happens in this call,
7261
+ * the way the wake path's own down does. Test-only semantics; admin scope. */
7262
+ async debugForceDown(reason: string, transition: boolean): Promise<ResidentStatus> {
7263
+ if (transition) {
7264
+ await this.goDown(reason);
7265
+ } else {
7266
+ await this.setResidentState("down", reason);
7267
+ }
7142
7268
  return this.getStatus();
7143
7269
  }
7144
7270
 
@@ -7154,6 +7280,8 @@ export class ResidentDO extends Sandbox<Env> {
7154
7280
  defaultRef: string,
7155
7281
  provisioningTimeoutMs: number,
7156
7282
  dryRun: boolean,
7283
+ /** `auto`: item 36's rebuild, which keeps the auto-rebuild history (the budget's count); a person's rebuild clears it. */
7284
+ opts: { auto?: boolean } = {},
7157
7285
  ): Promise<
7158
7286
  | {
7159
7287
  resource: string;
@@ -7220,7 +7348,11 @@ export class ResidentDO extends Sandbox<Env> {
7220
7348
  // best effort — the 1-year R2 TTL is the leak backstop
7221
7349
  }
7222
7350
  }
7223
- await this.ctx.storage.delete(REBUILD_STRIKES_KEY);
7351
+ // A person's rebuild resets the auto-rebuild budget (item 36): the
7352
+ // history is what stops a flapping resident, and a person asking for a
7353
+ // rebuild has seen it. The automatic one keeps the history — it IS the
7354
+ // budget's count.
7355
+ if (!opts.auto) await this.ctx.storage.delete(AUTO_REBUILDS_KEY);
7224
7356
  // From scratch means a fresh container too: the SDK's runtime identity
7225
7357
  // forgotten and the VM destroyed (SIGKILL, a fresh disk) before
7226
7358
  // provisioning clones onto it. A rebuild that reprovisioned onto the
@@ -7536,6 +7668,7 @@ const ROUTES: Record<string, { scope: Scope; method: string }> = {
7536
7668
  "/read": { scope: "operator", method: "POST" },
7537
7669
  "/write": { scope: "operator", method: "POST" },
7538
7670
  "/op": { scope: "operator", method: "POST" },
7671
+ "/await-restore": { scope: "operator", method: "POST" },
7539
7672
  };
7540
7673
 
7541
7674
  /** Per-resource R2 prefix for future resident cache objects; offboard deletes
@@ -7651,6 +7784,8 @@ export default {
7651
7784
  return await handleWrite(env, body);
7652
7785
  case "/op":
7653
7786
  return await handleOp(env, body, traceparent);
7787
+ case "/await-restore":
7788
+ return await handleAwaitRestore(env, body);
7654
7789
  default:
7655
7790
  return json({ error: "unknown route" }, 404);
7656
7791
  }
@@ -8087,8 +8222,9 @@ async function handleStatus(env: Env, url: URL): Promise<Response> {
8087
8222
  const resource = parseResource(url.searchParams.get("resource"));
8088
8223
  if ("error" in resource) return json({ error: resource.error }, 400);
8089
8224
 
8090
- // Body deliberately limited to { state, reason, inFlight } — operator scope
8091
- // sees lifecycle and activity, not config.
8225
+ // Body deliberately limited to lifecycle, activity and the snapshot handle
8226
+ // — operator scope sees what a run needs (the seed's handle, execution.md
8227
+ // item 25: opaque archive ids, useless without the bucket), not config.
8092
8228
  // Three RPCs in one flight, not one atomic snapshot: getStatus() awaits
8093
8229
  // storage, and the DO may run other work in that gap, so `state`/`reason`
8094
8230
  // and `inFlight` can be a hair apart (and differ slightly from a /residents
@@ -8096,21 +8232,24 @@ async function handleStatus(env: Env, url: URL): Promise<Response> {
8096
8232
  // deploy gate reads /residents. The registry check rides in the same flight
8097
8233
  // (its 404 is judged first, the probes' results discarded then).
8098
8234
  const stub = residentStub(env, resource.resource);
8099
- const [record, status, inFlight, refresh] = await Promise.all([
8235
+ const [record, status, inFlight, refresh, snapshot] = await Promise.all([
8100
8236
  registryStub(env).getRecord(resource.resource),
8101
8237
  stub.getStatus(),
8102
8238
  stub.getInFlightCount(),
8103
8239
  stub.getRefreshView(),
8240
+ stub.snapshotHandle(),
8104
8241
  ]);
8105
8242
  if (!record) return json({ error: `${resource.resource} is not onboarded` }, 404);
8106
8243
  // Item 7: which scheduler drives the refresh cycle and, on the Workflow
8107
- // lifecycle, the current instance with its last step and the last skipped bucket.
8244
+ // lifecycle, the current instance with its last step and the last skipped
8245
+ // bucket; and the snapshot handle a seeded sandbox restores from (item 25).
8108
8246
  return json({
8109
8247
  state: status.state,
8110
8248
  reason: status.reason,
8111
8249
  inFlight,
8112
8250
  lifecycle: refresh.lifecycle,
8113
8251
  refresh: { instance: refresh.instance, skipped: refresh.skipped },
8252
+ snapshot,
8114
8253
  });
8115
8254
  }
8116
8255
 
@@ -8205,6 +8344,24 @@ async function handleAttach(env: Env, body: Record<string, unknown>, traceparent
8205
8344
  );
8206
8345
  }
8207
8346
 
8347
+ /** POST /await-restore (docs/reference/specs/execution.md item 27): the bot's
8348
+ * one held request while the resident restores. Held server-side by the DO's
8349
+ * waiter ledger; the answer streams like /attach (heartbeat whitespace then
8350
+ * ONE JSON document over HTTP 200) so a restore lasting minutes cannot lose
8351
+ * the connection. A pre-route Worker answers 404 (unknown route), which the
8352
+ * bot reads as "this Worker predates the route" and falls back cold. */
8353
+ async function handleAwaitRestore(env: Env, body: Record<string, unknown>): Promise<Response> {
8354
+ const resource = parseResource(body.resource);
8355
+ if ("error" in resource) return json({ error: resource.error }, 400);
8356
+ const record = await registryStub(env).getRecord(resource.resource);
8357
+ if (!record) return json({ error: `${resource.resource} is not onboarded` }, 404);
8358
+ return streamHeartbeatJson(
8359
+ residentStub(env, resource.resource).awaitRestore(),
8360
+ (result) => result,
8361
+ (err) => ({ error: errMsg(err), status: 500 }),
8362
+ );
8363
+ }
8364
+
8208
8365
  async function handleDetach(env: Env, body: Record<string, unknown>): Promise<Response> {
8209
8366
  const ctx = await resolveThreadRoute(env, body);
8210
8367
  if (ctx instanceof Response) return ctx;
@@ -8487,11 +8644,12 @@ async function handleDebug(env: Env, body: Record<string, unknown>): Promise<Res
8487
8644
  case "force-onboarding":
8488
8645
  return json(await stub.debugForceOnboarding());
8489
8646
  case "force-down": {
8490
- // Fault injection for the watchdog auto-rebuild path; a
8491
- // rehydration-flavored default reason makes it strike-eligible.
8647
+ // Fault injection for item 36; a rehydration-flavored default reason
8648
+ // makes it eligible. `transition: true` fires the down through goDown
8649
+ // (the rebuild happens now); bare, the watchdog's next pass is the backstop.
8492
8650
  const reason =
8493
8651
  typeof body.reason === "string" && body.reason ? body.reason : "r2-restore-failed: injected (debug force-down)";
8494
- return json(await stub.debugForceDown(reason));
8652
+ return json(await stub.debugForceDown(reason, body.transition === true));
8495
8653
  }
8496
8654
  case "threads":
8497
8655
  return json(await stub.debugThreads());
@@ -37,8 +37,15 @@ ENV COMMAND_TIMEOUT_MS=1240000
37
37
  # call. The sandbox is already root with full capabilities inside its own
38
38
  # microVM, so a container a run starts shares that run's blast radius and
39
39
  # nothing more; no privileged flag or capability is added anywhere.
40
+ # squashfs-tools (`unsquashfs`): the seed (docs/reference/specs/execution.md
41
+ # item 25) restores the resident's snapshot with the SDK's presigned restore,
42
+ # which MOUNTS the archive (squashfuse + fuse-overlayfs) instead of extracting
43
+ # it; the Worker extracts it onto the disk the way the resident does
44
+ # (resident-repos item 61) so the checkout is one plain ext4 tree. The build
45
+ # asserts the binary with the probe the extract script uses (`command -v`).
40
46
  RUN apt-get update \
41
- && apt-get install -y --no-install-recommends git curl ca-certificates docker.io iptables \
47
+ && apt-get install -y --no-install-recommends git curl ca-certificates docker.io iptables squashfs-tools \
48
+ && command -v unsquashfs >/dev/null \
42
49
  && curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
43
50
  -o /usr/share/keyrings/githubcli-archive-keyring.gpg \
44
51
  && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \