@coreplane/switchboard 1.243.0 → 1.244.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 (133) hide show
  1. package/dist/assets/deploy/cloudflare-memory/worker.ts +148 -0
  2. package/dist/assets/deploy/cloudflare-resident/gc.ts +8 -1
  3. package/dist/assets/deploy/cloudflare-resident/worker.ts +433 -117
  4. package/dist/assets/deploy/cloudflare-sandbox/worker.ts +13 -2
  5. package/dist/assets/package-lock.json +3 -3
  6. package/dist/assets/package.json +1 -1
  7. package/dist/assets/source.json +3 -3
  8. package/dist/assets/src/agents/registry.ts +1 -1
  9. package/dist/assets/src/core/authz/types.ts +10 -0
  10. package/dist/assets/src/core/budgets.ts +36 -0
  11. package/dist/assets/src/core/coordinator/contract.ts +32 -0
  12. package/dist/assets/src/core/coordinator/driver.ts +75 -12
  13. package/dist/assets/src/core/delivery.ts +1 -1
  14. package/dist/assets/src/core/runEvents.ts +50 -7
  15. package/dist/assets/src/core/runFriction.ts +2 -1
  16. package/dist/assets/src/core/runRecord.ts +31 -0
  17. package/dist/assets/src/core/ship/contract.ts +2 -1
  18. package/dist/assets/src/core/ship/coordinator.ts +211 -13
  19. package/dist/assets/src/core/ship/renewal.ts +155 -0
  20. package/dist/assets/src/core/trace/attrs.ts +11 -0
  21. package/dist/assets/src/core/trace/types.ts +4 -3
  22. package/dist/assets/src/execution/residentRefresh.ts +24 -4
  23. package/dist/assets/src/execution/residentText.ts +8 -3
  24. package/dist/assets/src/mcp/registry.ts +3 -0
  25. package/dist/assets/web/dist/.vite/manifest.json +417 -417
  26. package/dist/assets/web/dist/assets/{AppShell-IDMGG6yi.js → AppShell-Ck4hSZDi.js} +1 -1
  27. package/dist/assets/web/dist/assets/CostsPage-DvhjDMka.js +2 -0
  28. package/dist/assets/web/dist/assets/DeliveryPage-DBlI_Yq2.js +1 -0
  29. package/dist/assets/web/dist/assets/HomePage-XFWPxDiJ.js +2 -0
  30. package/dist/assets/web/dist/assets/{NotFoundPage-C97EhJcQ.js → NotFoundPage-CiGrVOyv.js} +1 -1
  31. package/dist/assets/web/dist/assets/{PendingTurnRow-BT9RhFZ7.js → PendingTurnRow-BHCSpsPf.js} +1 -1
  32. package/dist/assets/web/dist/assets/{ResidentDetailPage-DACalNVF.js → ResidentDetailPage-C5USV_8P.js} +1 -1
  33. package/dist/assets/web/dist/assets/{ResidentsIndexPage-SHPdu6uZ.js → ResidentsIndexPage-CSKisWnX.js} +1 -1
  34. package/dist/assets/web/dist/assets/RunFoldRow-BPMQxTcl.js +1 -0
  35. package/dist/assets/web/dist/assets/RunRoutePage-CZe9Yodw.js +9 -0
  36. package/dist/assets/web/dist/assets/RunsIndexPage-BfA333Qy.js +1 -0
  37. package/dist/assets/web/dist/assets/{RunsTabs-BUfdk0lH.js → RunsTabs-BWRHzhtT.js} +1 -1
  38. package/dist/assets/web/dist/assets/{ScheduledPage-DJ8HiCPt.js → ScheduledPage-DBaChcJZ.js} +1 -1
  39. package/dist/assets/web/dist/assets/SettingsPage-CqVi9-Ac.js +1 -0
  40. package/dist/assets/web/dist/assets/{StatusDot-Dw0T1M-P.js → StatusDot-_Q_bmReh.js} +1 -1
  41. package/dist/assets/web/dist/assets/{Tooltip-BbLuIAiS.js → Tooltip-Cafsgd4m.js} +1 -1
  42. package/dist/assets/web/dist/assets/{UnitRoutePage-DicUG96U.js → UnitRoutePage-CdoBZwtf.js} +1 -1
  43. package/dist/assets/web/dist/assets/{angular-html-oBNfPJR0.js → angular-html-Dail07rr.js} +1 -1
  44. package/dist/assets/web/dist/assets/{angular-ts-BvNwsyWA.js → angular-ts-BReExio8.js} +1 -1
  45. package/dist/assets/web/dist/assets/{apl-CNUdRlYf.js → apl-DhNO6Yyx.js} +1 -1
  46. package/dist/assets/web/dist/assets/{astro-Zb0NriSe.js → astro-BYotTY4C.js} +1 -1
  47. package/dist/assets/web/dist/assets/{blade-qPRVheqq.js → blade-mUxTtD1X.js} +1 -1
  48. package/dist/assets/web/dist/assets/{c-D8Awx4YO.js → c-DhuiwcSQ.js} +1 -1
  49. package/dist/assets/web/dist/assets/{chapel-Bt72Mhsx.js → chapel-GD2vFtRi.js} +1 -1
  50. package/dist/assets/web/dist/assets/{cobol-BOBacexg.js → cobol-BhFDwkes.js} +1 -1
  51. package/dist/assets/web/dist/assets/{coffee-E4u0liHW.js → coffee-DWUgbE0r.js} +1 -1
  52. package/dist/assets/web/dist/assets/{cpp-q2sLNlul.js → cpp-CO9XFjGW.js} +1 -1
  53. package/dist/assets/web/dist/assets/{crystal-DyWqUnlb.js → crystal-B9pVurpn.js} +1 -1
  54. package/dist/assets/web/dist/assets/{css-CQY0hFsD.js → css-CFVVV6lI.js} +1 -1
  55. package/dist/assets/web/dist/assets/{dist-twkFmSUY.js → dist-BtPs2AkQ.js} +2 -2
  56. package/dist/assets/web/dist/assets/{edge-C1MwhJkX.js → edge-BDhIDQIb.js} +1 -1
  57. package/dist/assets/web/dist/assets/{elixir-Bb3YbHfn.js → elixir-DG3J8WoD.js} +1 -1
  58. package/dist/assets/web/dist/assets/{elm-DAN9IGQw.js → elm-FiOdiaOE.js} +1 -1
  59. package/dist/assets/web/dist/assets/{erb-BEB8Xlsj.js → erb-vxbtW503.js} +1 -1
  60. package/dist/assets/web/dist/assets/{git-rebase-tqpjRfxO.js → git-rebase-DINz8M3H.js} +1 -1
  61. package/dist/assets/web/dist/assets/{glimmer-js-Ccbo65zR.js → glimmer-js-yann6r9i.js} +1 -1
  62. package/dist/assets/web/dist/assets/{glimmer-ts-B6WBVMpE.js → glimmer-ts-CXTbrZn_.js} +1 -1
  63. package/dist/assets/web/dist/assets/{glsl-tRec3Fcu.js → glsl-DNYC-hq0.js} +1 -1
  64. package/dist/assets/web/dist/assets/{graphql-P8kbxT4F.js → graphql-CwzhHzi9.js} +1 -1
  65. package/dist/assets/web/dist/assets/{hack-H9Zhkagy.js → hack-BBXF1cwR.js} +1 -1
  66. package/dist/assets/web/dist/assets/{haml-DtEnpn7Z.js → haml-oEqBcsTy.js} +1 -1
  67. package/dist/assets/web/dist/assets/{handlebars-DkgPfoAz.js → handlebars-YE5thkT1.js} +1 -1
  68. package/dist/assets/web/dist/assets/{html-D30RXpIs.js → html-Dp4jRQ1j.js} +1 -1
  69. package/dist/assets/web/dist/assets/{html-derivative-DwozLrEx.js → html-derivative-DCT1GYtQ.js} +1 -1
  70. package/dist/assets/web/dist/assets/{http-DXuzBAPm.js → http-BPcGbmzJ.js} +1 -1
  71. package/dist/assets/web/dist/assets/{hurl-d1UUJIt_.js → hurl-CQnNHwyd.js} +1 -1
  72. package/dist/assets/web/dist/assets/{indexRow-B_s5tKyq.js → indexRow-BmK74Vp1.js} +1 -1
  73. package/dist/assets/web/dist/assets/{java-DL0gWf34.js → java-Dm8Ovj6W.js} +1 -1
  74. package/dist/assets/web/dist/assets/{javascript-Cl7vavnS.js → javascript-Btm9oJK9.js} +1 -1
  75. package/dist/assets/web/dist/assets/{jinja-CfQOWMX9.js → jinja-VZYjWF_U.js} +1 -1
  76. package/dist/assets/web/dist/assets/{jison-KdYirlqm.js → jison-Dcmjxa9x.js} +1 -1
  77. package/dist/assets/web/dist/assets/{json-C9cDQ-Qj.js → json-DoWiOGZ8.js} +1 -1
  78. package/dist/assets/web/dist/assets/{jsx-CRx5NItd.js → jsx-Dvmo55ma.js} +1 -1
  79. package/dist/assets/web/dist/assets/{julia-CmsQsZQl.js → julia-d8AtfV26.js} +1 -1
  80. package/dist/assets/web/dist/assets/{just-CsM3Q8TE.js → just-Cc5EStA-.js} +1 -1
  81. package/dist/assets/web/dist/assets/{latex-BXCh5YRX.js → latex-B5fKKIH7.js} +1 -1
  82. package/dist/assets/web/dist/assets/{liquid-BF2vwK8p.js → liquid-DUYI9JND.js} +1 -1
  83. package/dist/assets/web/dist/assets/{lua-DcMBATrl.js → lua-BhMwKWpw.js} +1 -1
  84. package/dist/assets/web/dist/assets/main-C4366PZF.css +1 -0
  85. package/dist/assets/web/dist/assets/main-DlTNAC6c.js +28 -0
  86. package/dist/assets/web/dist/assets/{marko-88MndKvG.js → marko-Cnf2cPAi.js} +1 -1
  87. package/dist/assets/web/dist/assets/{mdc-DJ4kVAd8.js → mdc-FDDXoir9.js} +1 -1
  88. package/dist/assets/web/dist/assets/{nginx-CP6mRgtV.js → nginx-q-8U_1jR.js} +1 -1
  89. package/dist/assets/web/dist/assets/{nim-QQfj3fpF.js → nim-DIA0TUWs.js} +1 -1
  90. package/dist/assets/web/dist/assets/{org-Bke3eBzc.js → org-CR88hy7S.js} +1 -1
  91. package/dist/assets/web/dist/assets/{perl-Bkh0N7Kz.js → perl-Bkd8N7ja.js} +1 -1
  92. package/dist/assets/web/dist/assets/{php-Dnv1Piya.js → php-BOs84mvy.js} +1 -1
  93. package/dist/assets/web/dist/assets/{pug-D6peFFI7.js → pug-BbpZBb3w.js} +1 -1
  94. package/dist/assets/web/dist/assets/{qml-D8PEs-C-.js → qml-D2jQpjFE.js} +1 -1
  95. package/dist/assets/web/dist/assets/{r-B1EL9b_j.js → r-DKzcAQ22.js} +1 -1
  96. package/dist/assets/web/dist/assets/{razor-BsMTIh2b.js → razor-SOQMWcC9.js} +1 -1
  97. package/dist/assets/web/dist/assets/{regexp-HhvC8spD.js → regexp-B6KK4W8d.js} +1 -1
  98. package/dist/assets/web/dist/assets/{rst-D5paAxpg.js → rst-kPSg-NPf.js} +1 -1
  99. package/dist/assets/web/dist/assets/{ruby-BrQwhLrl.js → ruby-cKS-JYLH.js} +1 -1
  100. package/dist/assets/web/dist/assets/{sas-afot2B1o.js → sas-DNxzE5gs.js} +1 -1
  101. package/dist/assets/web/dist/assets/{scss-Efm-mwuG.js → scss-DGcHQ5OO.js} +1 -1
  102. package/dist/assets/web/dist/assets/{shellscript-BK0Vv5fT.js → shellscript-CRZeZBa5.js} +1 -1
  103. package/dist/assets/web/dist/assets/{shellsession-BGqlMC7N.js → shellsession-DSNEL0HX.js} +1 -1
  104. package/dist/assets/web/dist/assets/{soy-M0b4UwGM.js → soy-zisNbkDO.js} +1 -1
  105. package/dist/assets/web/dist/assets/{sql-sxe6IE9j.js → sql-MG06KMMb.js} +1 -1
  106. package/dist/assets/web/dist/assets/{sseReplay-C9m_EB8J.js → sseReplay-DaRtc7nI.js} +4 -4
  107. package/dist/assets/web/dist/assets/{stata-nPF_ddLP.js → stata-CB_wKwv9.js} +1 -1
  108. package/dist/assets/web/dist/assets/{surrealql-D_GrC6u7.js → surrealql-DYdSt7re.js} +1 -1
  109. package/dist/assets/web/dist/assets/{svelte-DwL1AtNP.js → svelte-DHO-F7c7.js} +1 -1
  110. package/dist/assets/web/dist/assets/{templ-7s7LTDkc.js → templ-CUL5L4Lr.js} +1 -1
  111. package/dist/assets/web/dist/assets/{tex-Bx-5fMxe.js → tex-_JNEKjVW.js} +1 -1
  112. package/dist/assets/web/dist/assets/{ts-tags-DUMJnke_.js → ts-tags--9EpEV_8.js} +1 -1
  113. package/dist/assets/web/dist/assets/{tsx-BN8biPDe.js → tsx-BcNq2x2u.js} +1 -1
  114. package/dist/assets/web/dist/assets/{twig-8nIu84TN.js → twig-EsY5Dk_D.js} +1 -1
  115. package/dist/assets/web/dist/assets/{typescript-BooSPq_S.js → typescript-CyA_HiUH.js} +1 -1
  116. package/dist/assets/web/dist/assets/{typst-CTBiBsem.js → typst-SzaHlDt4.js} +1 -1
  117. package/dist/assets/web/dist/assets/{vue-C6Ft4Lea.js → vue-D3cn5acV.js} +1 -1
  118. package/dist/assets/web/dist/assets/{vue-html-Ba36dD5D.js → vue-html-6MxhUxOa.js} +1 -1
  119. package/dist/assets/web/dist/assets/{vue-vine-NkFexVo2.js → vue-vine-qJnqhgo9.js} +1 -1
  120. package/dist/assets/web/dist/assets/{xml-C_THnHXZ.js → xml-DyRpC7xv.js} +1 -1
  121. package/dist/assets/web/dist/assets/{xsl-D8G5xqjY.js → xsl-BXMRPAsg.js} +1 -1
  122. package/dist/assets/web/dist/assets/{yaml-jAMIJzge.js → yaml-BNmPCtAN.js} +1 -1
  123. package/dist/cli.js +2990 -706
  124. package/package.json +1 -1
  125. package/dist/assets/web/dist/assets/CostsPage-CwXOmkeQ.js +0 -2
  126. package/dist/assets/web/dist/assets/DeliveryPage-n1tz5I_v.js +0 -1
  127. package/dist/assets/web/dist/assets/HomePage-PRxjQiGG.js +0 -2
  128. package/dist/assets/web/dist/assets/RunFoldRow-0SdOmOr5.js +0 -1
  129. package/dist/assets/web/dist/assets/RunRoutePage-BaFS2p8I.js +0 -9
  130. package/dist/assets/web/dist/assets/RunsIndexPage-DYI-iALj.js +0 -1
  131. package/dist/assets/web/dist/assets/SettingsPage-DLiN5IgY.js +0 -1
  132. package/dist/assets/web/dist/assets/main-B4kEF3Sg.css +0 -1
  133. package/dist/assets/web/dist/assets/main-d-w-tIKt.js +0 -28
@@ -69,6 +69,7 @@
69
69
  // (docs/decisions/0009-residents-second-credential-domain.md).
70
70
  import {
71
71
  isDurableObjectCodeUpdateReset,
72
+ isPlatformTransientError,
72
73
  OperationInterruptedError,
73
74
  ProcessWaitTimeoutError,
74
75
  RPCTransportError,
@@ -196,6 +197,7 @@ import {
196
197
  type RefreshPlan,
197
198
  type RestoreSample,
198
199
  type RefreshFailure,
200
+ fetchFailureIsMirrors,
199
201
  } from "../../src/execution/residentRefresh.js";
200
202
  import { autoRebuildDecision, isAutoRebuildEligible } from "../../src/execution/residentAutoRebuild.js";
201
203
  import {
@@ -683,18 +685,60 @@ export function validateEnvNames(vars: Record<string, string>): void {
683
685
  * decides (idempotent read/write retry; exec is handed to the model). */
684
686
  class RuntimeReplacedError extends Error {
685
687
  constructor(
686
- readonly phase: "spawn" | "collect",
688
+ /** Where the replacement was met: the command's spawn, its collect, or
689
+ * the Worker's call into the Durable Object itself (`threadRejectionErr`),
690
+ * before the method could answer. */
691
+ readonly phase: "spawn" | "collect" | "call",
687
692
  readonly cause: unknown,
693
+ /** Whether the replacement was KNOWN where the failure was classified
694
+ * (`replacementKnown`): the SDK vouched the runtime moved, or a restore is
695
+ * under way for this resident. One decision, made once at the exec choke
696
+ * point, and it gates the word on `/exec` alone (`replacedExecAnswer`) —
697
+ * the incarnation swap is unconditional there. Unknown, the failure only
698
+ * says the container is down or the transport was lost — a merely asleep
699
+ * or starting container, or a network blip, say the same — so the answer
700
+ * is the SDK's words and the harness's one more command decides. At the
701
+ * Worker's call nothing vouched, so it is `false` there. */
702
+ readonly known: boolean,
688
703
  ) {
689
704
  super(
690
705
  `runtime-replaced: the resident runtime was replaced (a deploy) while this command was ${
691
- phase === "spawn" ? "starting" : "running"
706
+ phase === "spawn" ? "starting" : phase === "collect" ? "running" : "pending at the Worker"
692
707
  }; its output is lost (${errMsg(cause)})`,
693
708
  );
694
709
  this.name = "RuntimeReplacedError";
695
710
  }
696
711
  }
697
712
 
713
+ /** The resident's own Durable Object was reset while a command was in flight —
714
+ * a `wrangler deploy` of the Worker code (not the container image) supersedes
715
+ * this DO's isolate, so the SDK's control session to the container is lost
716
+ * mid-command (`isDurableObjectCodeUpdateReset`). This is NOT a runtime
717
+ * replacement: the container and every process the run holds in it are exactly
718
+ * as they were — only the DO that was driving them reset. The command's
719
+ * outcome is unknown (the reset may have raced its start or its finish), so
720
+ * the resident never re-issues it here; the thread routes answer the NAMED
721
+ * `control-reset` error and the client re-sends an idempotent op or resolves a
722
+ * write by pi's echo (docs/reference/specs/harness-pi.md item 16). Distinct
723
+ * from `RuntimeReplacedError` precisely so the harness never mistakes a DO
724
+ * reset over a live pi for a replaced container and orphans that pi. */
725
+ class ControlResetError extends Error {
726
+ constructor(
727
+ /** Where the reset was met: the command's spawn, its collect, or the
728
+ * Worker's call into the Durable Object itself (`threadRejectionErr`),
729
+ * before the method could answer. */
730
+ readonly phase: "spawn" | "collect" | "call",
731
+ readonly cause: unknown,
732
+ ) {
733
+ super(
734
+ `control-reset: the resident's Durable Object was reset (a deploy) while this command was ${
735
+ phase === "spawn" ? "starting" : phase === "collect" ? "running" : "pending at the Worker"
736
+ }; the container and its processes are as they were; the command's outcome is unknown (${errMsg(cause)})`,
737
+ );
738
+ this.name = "ControlResetError";
739
+ }
740
+ }
741
+
698
742
  /** Every wording the pinned SDK (@cloudflare/sandbox@0.13.0-next.751.1) uses
699
743
  * when the runtime incarnation changed under a call, for the message-based
700
744
  * fallback below. Two of these come from classes the SDK does NOT export
@@ -753,16 +797,25 @@ const RPC_TRANSPORT_LOSS_KINDS = new Set(["peer_closed", "connection_failed", "u
753
797
  /** Does this SDK error mean the runtime incarnation changed under us? Typed
754
798
  * checks first (`StaleProcessHandleError`, `RuntimeIdentityInactiveError`, an
755
799
  * `OperationInterruptedError` with one of `RUNTIME_REPLACED_REASONS`, an
756
- * `RPCTransportError` with one of `RPC_TRANSPORT_LOSS_KINDS`, the platform's
757
- * superseded-isolate reset), then the SDK's message wording — on the error AND
758
- * its cause chain — as a belt-and-braces fallback. Anything else (a real spawn
759
- * failure, a timeout, a wire-format error) is NOT a runtime replacement. */
800
+ * `RPCTransportError` with one of `RPC_TRANSPORT_LOSS_KINDS`), then the SDK's
801
+ * message wording — on the error AND its cause chain — as a belt-and-braces
802
+ * fallback. Anything else (a real spawn failure, a timeout, a wire-format
803
+ * error) is NOT a runtime replacement.
804
+ *
805
+ * Deliberately IMMUNE to the platform's superseded-isolate reset (a DO
806
+ * code-update reset): that is this Durable Object resetting over a container it
807
+ * did not touch, the container unchanged, so it is never a runtime replacement
808
+ * and never swaps the incarnation on the `/exec` path. `run()` answers it as a
809
+ * `control-reset` (`isControlReset`), and the restore path folds it in
810
+ * explicitly at its own site (`isRuntimeReplacement(err) || isControlReset(err)`)
811
+ * — the ONE place a DO reset shares the replacement disposition, since a reset
812
+ * under a live restore leaves nothing to land and re-restores onto the container
813
+ * that comes back. Keeping it out of this predicate makes that use the only one. */
760
814
  function isRuntimeReplacement(err: unknown): boolean {
761
815
  if (err instanceof StaleProcessHandleError) return true;
762
816
  if (err instanceof RuntimeIdentityInactiveError) return true;
763
817
  if (err instanceof OperationInterruptedError && RUNTIME_REPLACED_REASONS.has(err.reason)) return true;
764
818
  if (err instanceof RPCTransportError && RPC_TRANSPORT_LOSS_KINDS.has(err.kind)) return true;
765
- if (isDurableObjectCodeUpdateReset(err)) return true;
766
819
  for (const link of selfAndCauses(err)) if (RUNTIME_REPLACEMENT_WORDING.test(errMsg(link))) return true;
767
820
  return false;
768
821
  }
@@ -771,34 +824,167 @@ function isRuntimeReplacement(err: unknown): boolean {
771
824
  * — a new incarnation serves the thread, so the container the command was
772
825
  * aimed at is gone whatever else the resident knows: the typed classes for
773
826
  * a stale process handle and an inactive runtime identity, an interruption
774
- * whose reason is a replaced runtime, the platform's superseded-isolate
775
- * reset, and the SDK's wordings for the same (`RUNTIME_MOVED_WORDING`, on the
776
- * error and its cause chain). NOT the words for a container that is merely
777
- * down (`STOPPED_CONTAINER_WORDING`: "The container is not running", "Process
778
- * supervisor is closed") and NOT a transport lost under the call
779
- * (`RPCTransportError`): an asleep or starting container and a network blip
780
- * answer those too, so on `/exec` they are the word only when the resident
781
- * itself knows the container it held is gone (`replacedExecAnswer`). The
782
- * resident's own steps keep the union (`isRuntimeReplacement`): a step
783
- * interrupted either way is retried onto the container that comes back. */
827
+ * whose reason is a replaced runtime, and the SDK's wordings for the same
828
+ * (`RUNTIME_MOVED_WORDING`, on the error and its cause chain). NOT the words
829
+ * for a container that is merely down (`STOPPED_CONTAINER_WORDING`: "The
830
+ * container is not running", "Process supervisor is closed") and NOT a
831
+ * transport lost under the call (`RPCTransportError`): an asleep or starting
832
+ * container and a network blip answer those too, so on `/exec` they are the
833
+ * word only when the resident itself knows the container it held is gone
834
+ * (`replacedExecAnswer`). NOT the platform's superseded-isolate reset either:
835
+ * that is this Durable Object resetting over a container it did not touch —
836
+ * `run()` intercepts it first, in both phases, as a `control-reset`
837
+ * (`isControlReset`), so a DO-reset cause never reaches this predicate, and a
838
+ * vouch for it here would say the opposite of that word. The restore path
839
+ * keeps the union by naming both explicitly at its site
840
+ * (`isRuntimeReplacement(err) || isControlReset(err)`), so a step interrupted
841
+ * either way is retried onto the container that comes back — while this
842
+ * predicate and `isRuntimeReplacement` stay immune to the reset. */
784
843
  function sdkVouchesRuntimeMoved(err: unknown): boolean {
785
844
  if (err instanceof StaleProcessHandleError) return true;
786
845
  if (err instanceof RuntimeIdentityInactiveError) return true;
787
846
  if (err instanceof OperationInterruptedError && RUNTIME_REPLACED_REASONS.has(err.reason)) return true;
788
- if (isDurableObjectCodeUpdateReset(err)) return true;
789
847
  for (const link of selfAndCauses(err)) if (RUNTIME_MOVED_WORDING.test(errMsg(link))) return true;
790
848
  return false;
791
849
  }
792
850
 
851
+ /** The platform's transient sentences the pinned SDK's own predicate does not
852
+ * name — `isPlatformTransientError` covers the code-update reset, a lost
853
+ * connection, the storage-startup reset and the typed `retryable` flag, so
854
+ * those are read from the SDK, never from a string table of ours. Two remain:
855
+ * the Durable Object reset by a storage operation that did not complete (the
856
+ * platform's `Durable Object storage operation exceeded timeout which caused
857
+ * object to be reset.` — an assumption about the platform's text, carried
858
+ * from the first typing of the catch-all, not in the SDK's source), and the
859
+ * Durable Object overloaded. The SDK's retry predicate EXCLUDES the overloaded
860
+ * sentence (`isErrorRetryable`: an in-process retry only adds to a queue that
861
+ * is full); it is typed transient here because the client's
862
+ * `worker-unavailable` is a re-probe after the harness's bounded wait
863
+ * (`replacedVerdict`, `PROBE_WAIT_MAX_MS`), never a tight retry, and a full
864
+ * queue drains. The platform's sentences alone — a bare `internal error` or
865
+ * `overloaded` is a word a git or GitHub failure carries too, and a throw
866
+ * typed transient by it would be re-probed for a restore window that never
867
+ * clears it. */
868
+ const TRANSIENT_PLATFORM_WORDING = /storage operation|durable object is overloaded/i;
869
+
870
+ /** The platform's own transient that no route word names — what is left once
871
+ * `threadRejectionErr` has answered a reset and a replacement by their words:
872
+ * the container's runtime unreachable (`isRuntimeUnreachable`: the SDK's
873
+ * connect abort by its `AbortError` name or its exact sentence —
874
+ * `RUNTIME_UNREACHABLE_WORDING` is anchored to the whole message, so a
875
+ * `TimeoutError`'s `The operation was aborted due to timeout`, the text a
876
+ * route's own `AbortSignal.timeout` on a slow GitHub raises, never matches),
877
+ * the pinned SDK's own platform-transient predicate (`isPlatformTransientError`:
878
+ * the code-update reset, a lost connection, the storage-startup reset, the
879
+ * typed `retryable` flag — the SDK's signal, read as it reads it), and the
880
+ * remainder sentences above, anywhere in the cause chain. */
881
+ function isUnnamedPlatformTransient(err: unknown): boolean {
882
+ if (isRuntimeUnreachable(err) || isPlatformTransientError(err)) return true;
883
+ for (const link of selfAndCauses(err)) {
884
+ if (link instanceof Error && TRANSIENT_PLATFORM_WORDING.test(link.message)) return true;
885
+ }
886
+ return false;
887
+ }
888
+
889
+ /** Whether a throw no route named is the platform's own transient — the
890
+ * Durable Object reset by a deploy (`isControlReset`), the runtime replaced
891
+ * under the call (`isRuntimeReplacement`), or the transient no word names
892
+ * (`isUnnamedPlatformTransient`) — against a deterministic throw in the route
893
+ * itself (a bug, a bad argument). The client reads the answer's `transient`
894
+ * field to re-probe the first and judge the second at once (execution.md item
895
+ * 9), never the words. */
896
+ function isTransientPlatformThrow(err: unknown): boolean {
897
+ return isControlReset(err) || isRuntimeReplacement(err) || isUnnamedPlatformTransient(err);
898
+ }
899
+
900
+ /** The 500 for a throw no route named, at whichever catch met it — the fetch
901
+ * handler's catch-all, a streamed route's rejection mapper, a route's own
902
+ * catch around its body (`prefix`: `attach-failed`, `op-failed`): the words,
903
+ * and whether the throw was the platform's transient (`transient`), typed
904
+ * here so the client decides by a field and never by which catch met the
905
+ * throw. A failure a route DID name — a step that failed, a mirror held — is
906
+ * that route's own answer and never comes here. */
907
+ function catchAllErr(err: unknown, prefix?: string): ThreadErr {
908
+ return unnamedThrowErr(err, isTransientPlatformThrow(err), prefix);
909
+ }
910
+
911
+ /** The one shape of that 500: the words, behind `prefix` where the route names
912
+ * itself, and the verdict `transient`, decided once by the caller — so a
913
+ * caller that has already settled the typed predicates (`threadRejectionErr`)
914
+ * hands its verdict in instead of walking the cause chain again. */
915
+ function unnamedThrowErr(err: unknown, transient: boolean, prefix?: string): ThreadErr {
916
+ const words = errMsg(err);
917
+ return { error: prefix ? `${prefix}: ${words}` : words, status: 500, transient };
918
+ }
919
+
920
+ type ThreadDataRoute = "/exec" | "/read" | "/write";
921
+
922
+ /** A thread data-plane route's pending Durable Object call that REJECTED —
923
+ * the stub, not the method: the DO reset by a code update before or while the
924
+ * method ran, a storage operation that did not complete, the runtime
925
+ * unreachable — answered as the DO answers the same fact when it catches it
926
+ * inside (`execThreadImpl`, the file methods). A control reset is the DO's
927
+ * own word, `control-reset` on a 409: the container and its processes are as
928
+ * they were and the command's outcome is unknown, so the client resolves it by
929
+ * its own rule and never waits on it as the resident unavailable. A runtime
930
+ * replacement is answered by route, as the methods answer it, with the DO's
931
+ * own gate (`replacementKnown`) applied as far as it reaches: its first half,
932
+ * the SDK vouching the runtime moved (`sdkVouchesRuntimeMoved`), judges the
933
+ * moved sentences that survive the stub boundary as text, so it is made here
934
+ * by the same rule; its other half, a restore under way (`knowsContainerGone`),
935
+ * is the DO's alone and out of reach. So on `/exec` the word is said where
936
+ * the SDK vouched and WITHHELD where only the DO could have — that gate's
937
+ * unknown branch, the SDK's words on a 409 with no word, for the harness's one
938
+ * more command to judge; on `/read` and `/write` the word is said either way
939
+ * (`runtimeReplacedErr`, unconditional there on purpose), since it drives the
940
+ * client's one re-attach-and-retry and never a verdict. Anything else is the
941
+ * typed 500, its `transient` decided here once the typed predicates are
942
+ * settled. */
943
+ function threadRejectionErr(err: unknown, route: ThreadDataRoute): ThreadErr {
944
+ if (isControlReset(err)) return controlResetErr(new ControlResetError("call", err));
945
+ if (isRuntimeReplacement(err)) {
946
+ const known = sdkVouchesRuntimeMoved(err);
947
+ if (route === "/exec" && !known) return { error: errMsg(err), status: 409 };
948
+ return runtimeReplacedErr(new RuntimeReplacedError("call", err, known));
949
+ }
950
+ return unnamedThrowErr(err, isUnnamedPlatformTransient(err));
951
+ }
952
+
953
+ /** Whether a command's failure is the resident's own Durable Object resetting
954
+ * under it (a Worker-code deploy) rather than the container runtime being
955
+ * replaced. Checked BEFORE `isRuntimeReplacement` on the thread `run()` path,
956
+ * so a DO reset over a still-running container answers `control-reset`, never
957
+ * the replaced word. The SDK's predicate already walks the cause
958
+ * chain (`selfAndCauses`), so it is called directly on the caught error. */
959
+ function isControlReset(err: unknown): boolean {
960
+ return isDurableObjectCodeUpdateReset(err);
961
+ }
962
+
793
963
  /** The named ThreadErr every thread route (exec/read/write) answers for a
794
964
  * runtime replacement, so the client can classify it (409 like the other
795
965
  * recoverable thread states; `reason` is the discriminator). On `/exec` the
796
- * answer goes through `replacedExecAnswer` first: the word only when the SDK
797
- * vouched the runtime moved or the resident knows the container is gone. */
966
+ * answer goes through `replacedExecAnswer` first: the word only when the
967
+ * replacement was known where `run()` classified the failure. On
968
+ * `/read` and `/write` the word stays unconditional on purpose: those routes
969
+ * reach the same `run()` and can meet the same down wordings, but there the
970
+ * word drives the client's re-attach-and-retry (the attach is what wakes a
971
+ * container that is asleep or starting), never a verdict — nothing relaunches
972
+ * on it, and the harness never reads or writes through them (its seam runs
973
+ * every operation as an `/exec` script). Gating them would trade a spare
974
+ * re-attach for a read that fails outright while the container starts. */
798
975
  function runtimeReplacedErr(err: RuntimeReplacedError): ThreadErr {
799
976
  return { error: err.message, status: 409, reason: "runtime-replaced" };
800
977
  }
801
978
 
979
+ /** The named ThreadErr a DO code-update reset answers with — its own `reason`
980
+ * the client keys on, distinct from `runtime-replaced`: the container is
981
+ * unchanged and the command's outcome is unknown, so the client re-sends an
982
+ * idempotent op or resolves a write by echo (harness-pi item 16), never the
983
+ * replaced verdict. */
984
+ function controlResetErr(err: ControlResetError): ThreadErr {
985
+ return { error: err.message, status: 409, reason: "control-reset" };
986
+ }
987
+
802
988
  /** The container's control port never answered: `exec` rejected with the
803
989
  * DOMException of the SDK's connect abort (`DEFAULT_CONNECT_TIMEOUT_MS`,
804
990
  * 30 s), raised inside its wake path — `RuntimeBootstrapProbe.probe` →
@@ -1128,6 +1314,20 @@ interface ThreadBinding {
1128
1314
  interface ThreadErr {
1129
1315
  error: string;
1130
1316
  status: number;
1317
+ /** Beside `state` on a 503 that carries the lifecycle state: the lifecycle
1318
+ * REASON (`getStatus().reason`), kept apart from `reason`, which is the
1319
+ * answer's own word (`mirror-busy`, `disk-pressure`, `image-stale`). The
1320
+ * client reads the pair to decide whether the resident is coming back
1321
+ * (execution.md item 9); reading `reason` there would take a busy mirror on
1322
+ * a degraded-but-serviceable resident for a repo failure. */
1323
+ stateReason?: string;
1324
+ /** On the 500 for a throw no route named (`unnamedThrowErr`, through
1325
+ * `catchAllErr` or `threadRejectionErr`: the fetch handler's catch-all, a
1326
+ * streamed route's rejection, a route's own catch around its body): whether
1327
+ * the throw it wrapped was the platform's own transient
1328
+ * (`isTransientPlatformThrow`) — a re-probe clears it — or a deterministic
1329
+ * throw in the route, judged at once. */
1330
+ transient?: boolean;
1131
1331
  /** The steps the request ran before it failed (docs/reference/specs/tracing.md item 19):
1132
1332
  * a failed attach's trace is the one that says which step blew the budget. */
1133
1333
  trace?: ResidentStep[];
@@ -1618,49 +1818,49 @@ export class ResidentDO extends Sandbox<Env> {
1618
1818
  * survives, so hydration state is always probed from disk. */
1619
1819
  private hydration: Promise<void> | null = null;
1620
1820
 
1621
- /** The container stop the platform delivered to `onStop` and no command has
1622
- * answered since: the resident's own knowledge that the container it held
1623
- * is gone (docs/reference/specs/resident-repos.md item 43). In memory on
1624
- * purpose — a Durable Object that restarts learns the same fact from the
1625
- * restore its next command's wake path starts (`restoring`), and a stop
1626
- * recorded in storage would outlive the container that answers now. */
1627
- private containerStop: { at: number; exitCode: number; reason: string } | undefined;
1628
-
1629
- /** The platform's signal that the container stopped — the rollout's
1630
- * `runtime_signal`, or an exit of its own — recorded before the SDK's
1631
- * reconciliation runs, so an `/exec` that meets the stopped container from
1632
- * here on answers `runtime-replaced` (`replacedExecAnswer`) rather than the
1633
- * binding's bare "The container is not running". The Sandbox base declares
1634
- * the hook without parameters; the Container base beneath it passes them. */
1635
- async onStop(params?: { exitCode: number; reason: string }): Promise<void> {
1636
- this.containerStop = { at: systemClock(), exitCode: params?.exitCode ?? 0, reason: params?.reason ?? "unknown" };
1637
- console.log(
1638
- `container: stopped (exit ${this.containerStop.exitCode}, ${this.containerStop.reason}) — the container this resident held is gone; /exec answers runtime-replaced until a command answers again`,
1639
- );
1640
- await super.onStop();
1821
+ /** Whether this resident knows the container it held is gone
1822
+ * (docs/reference/specs/resident-repos.md item 43): a restore under way for
1823
+ * this resident (`restoring`, persisted before any restore work) — its own
1824
+ * knowledge, reliable — and nothing else.
1825
+ *
1826
+ * Not the Container base's exit state, not a replayed `onStop`, not the
1827
+ * platform's `running` flag: each was tried or weighed and separates
1828
+ * nothing — a roll and an idle sleep leave the same traces, in both
1829
+ * directions — and the why, with the pinned library's lines, is the
1830
+ * record's alone (docs/reference/specs/resident-repos.md item 43), so a
1831
+ * dependency bump rots one place, not three. The harness's own probe
1832
+ * (`identity`, `alive`) stays the arbiter, so under-saying the word here is
1833
+ * safe and over-saying it is what orphans a process.
1834
+ *
1835
+ * A read that fails (the storage under a reset or a restore) is no
1836
+ * knowledge: the answer falls back to the SDK's words and the harness
1837
+ * probes, never an unhandled throw where a 409 was due. */
1838
+ private async knowsContainerGone(): Promise<boolean> {
1839
+ try {
1840
+ return (await this.getStatus()).state === "restoring";
1841
+ } catch {
1842
+ return false;
1843
+ }
1641
1844
  }
1642
1845
 
1643
- /** Whether this resident knows the container it held is gone: it saw the
1644
- * container stop and no command has answered since, or a restore is under
1645
- * way (`restoring`, persisted before any restore work). Never the
1646
- * platform's `running` flag — an asleep or starting container answers
1647
- * false to it too, and an `/exec` that meets one must say what the SDK
1648
- * said so the harness probes rather than relaunch into a container that
1649
- * was never replaced. */
1650
- private async knowsContainerGone(): Promise<boolean> {
1651
- if (this.containerStop !== undefined) return true;
1652
- return (await this.getStatus()).state === "restoring";
1846
+ /** The one decision on a failure the SDK classified as a runtime
1847
+ * replacement, made where it is classified (`run()`): the SDK vouched the
1848
+ * runtime moved, or a restore is under way for this resident. It gates the
1849
+ * word on `/exec` and the incarnation mint alike; the per-incarnation memos
1850
+ * clear before it, unconditionally. */
1851
+ private async replacementKnown(err: unknown): Promise<boolean> {
1852
+ return sdkVouchesRuntimeMoved(err) || (await this.knowsContainerGone());
1653
1853
  }
1654
1854
 
1655
1855
  /** The `/exec` answer for a command the SDK failed with a runtime
1656
1856
  * replacement (item 43; harness.md item 6): the word — `reason:
1657
- * "runtime-replaced"`, the resident's sentence — when the SDK vouched the
1658
- * runtime moved or the resident knows the container it held is gone;
1659
- * otherwise what the SDK said, with no word and no `reason`, so the
1660
- * harness's one more command decides. The command is never re-issued
1661
- * either way: it may have started. */
1662
- private async replacedExecAnswer(err: RuntimeReplacedError): Promise<ThreadErr> {
1663
- if (sdkVouchesRuntimeMoved(err.cause) || (await this.knowsContainerGone())) return runtimeReplacedErr(err);
1857
+ * "runtime-replaced"`, the resident's sentence — when the replacement was
1858
+ * known at the choke point (`RuntimeReplacedError.known`); otherwise what
1859
+ * the SDK said, with no word and no `reason`, so the harness's one more
1860
+ * command decides. The command is never re-issued either way: it may have
1861
+ * started. */
1862
+ private replacedExecAnswer(err: RuntimeReplacedError): ThreadErr {
1863
+ if (err.known) return runtimeReplacedErr(err);
1664
1864
  return { error: errMsg(err.cause), status: 409 };
1665
1865
  }
1666
1866
 
@@ -2024,6 +2224,12 @@ export class ResidentDO extends Sandbox<Env> {
2024
2224
  try {
2025
2225
  proc = await createExtensionProcessSandbox(this).exec(argv as unknown as SandboxCommand, launch);
2026
2226
  } catch (err) {
2227
+ // A DO code-update reset (our own Worker deploy) BEFORE a runtime
2228
+ // replacement: the container is unchanged, so this is `control-reset`, not
2229
+ // the replaced word — never `swapIncarnation`, and the command's outcome
2230
+ // is unknown. Checked first so a DO reset over a live pi is never
2231
+ // read as a replaced container.
2232
+ if (isControlReset(err)) throw new ControlResetError("spawn", err);
2027
2233
  if (!isRuntimeReplacement(err)) {
2028
2234
  // The control port never answered the SDK's connect (its 30 s abort,
2029
2235
  // raised inside the wake path): no process started and nothing about
@@ -2041,8 +2247,25 @@ export class ResidentDO extends Sandbox<Env> {
2041
2247
  // takes the throw below. It exists so that if a future SDK vouches "never
2042
2248
  // started" we retry then — and only then — without a change here.
2043
2249
  if (!(err instanceof OperationInterruptedError && err.retryable === true)) {
2044
- this.swapIncarnation(); // the container this incarnation's memos described is gone
2045
- throw new RuntimeReplacedError("spawn", err);
2250
+ // Two consequences with different safety, split here. The memos
2251
+ // (`hydratedVerdictAt`, `gitSetupDone`, `stageDirsReady`, the deps
2252
+ // flags — the fact that was stale) clear on EVERY replacement the SDK
2253
+ // classified: cheap to re-derive, and the per-incarnation memo block's
2254
+ // invariant is that this one choke point clears them, whatever the
2255
+ // word will say. The incarnation — the lease fencing token, one
2256
+ // isolate paired with one container runtime — is minted only when the
2257
+ // replacement is KNOWN: `isRuntimeReplacement` is the union, and a
2258
+ // transport blip on one request that minted an id would make
2259
+ // `takeMutex` read the live holder's row as `holder-incarnation-gone`
2260
+ // and hand the mirror mutex to a concurrent `/attach` over a running
2261
+ // fetch. (A swap gated whole on `known` was tried and left the memos
2262
+ // stale on a real roll, where `known` is false while the wake rewrites
2263
+ // the base's state — resident-repos item 43.) Only the word is gated,
2264
+ // on the same `known`.
2265
+ this.clearIncarnationMemos();
2266
+ const known = await this.replacementKnown(err);
2267
+ if (known) this.swapIncarnation();
2268
+ throw new RuntimeReplacedError("spawn", err, known);
2046
2269
  }
2047
2270
  console.log(
2048
2271
  `exec: runtime replaced before the process started (SDK says retryable) — retrying once: ${errMsg(err)}`,
@@ -2050,9 +2273,7 @@ export class ResidentDO extends Sandbox<Env> {
2050
2273
  proc = await createExtensionProcessSandbox(this).exec(argv as unknown as SandboxCommand, launch);
2051
2274
  }
2052
2275
  // The spawn is the proof the control port answers: a persisted count of
2053
- // unanswered connects ends here, whatever the command goes on to do — and
2054
- // a container stop seen before it is history (`knowsContainerGone`).
2055
- this.containerStop = undefined;
2276
+ // unanswered connects ends here, whatever the command goes on to do.
2056
2277
  await this.clearRuntimeUnreachable();
2057
2278
  try {
2058
2279
  const out = await proc.output({ encoding: "utf8", timeout: timeout + 30_000 });
@@ -2066,9 +2287,18 @@ export class ResidentDO extends Sandbox<Env> {
2066
2287
  truncated: out.truncated,
2067
2288
  };
2068
2289
  } catch (err) {
2290
+ // A DO code-update reset during collect: the container and the process are
2291
+ // unchanged (only this DO's isolate reset), so the outcome is unknown but
2292
+ // never a replacement — `control-reset`, no `swapIncarnation`.
2293
+ // Checked before the replacement branch below.
2294
+ if (isControlReset(err)) throw new ControlResetError("collect", err);
2069
2295
  if (isRuntimeReplacement(err)) {
2070
- this.swapIncarnation(); // the container this incarnation's memos described is gone
2071
- throw new RuntimeReplacedError("collect", err);
2296
+ // The same split as the spawn site above: the memos unconditionally,
2297
+ // the incarnation only when the replacement is known, the word gated.
2298
+ this.clearIncarnationMemos();
2299
+ const known = await this.replacementKnown(err);
2300
+ if (known) this.swapIncarnation();
2301
+ throw new RuntimeReplacedError("collect", err, known);
2072
2302
  }
2073
2303
  if (err instanceof ProcessWaitTimeoutError) {
2074
2304
  // The supervisor should have killed the process at `timeout`; 30 s
@@ -2648,7 +2878,13 @@ export class ResidentDO extends Sandbox<Env> {
2648
2878
  // asks the runtime whether the container the extract wrote to is still
2649
2879
  // there (the incident that named this: a resident went `down` on exactly
2650
2880
  // this, 10 ms before every exec answered "container is not running").
2651
- const runtimeReplaced = isRuntimeReplacement(err);
2881
+ // A DO code-update reset under a live restore leaves nothing to land (the
2882
+ // restore's SDK call is gone with this isolate), so it shares the
2883
+ // replacement disposition — interrupted, retried onto the container that
2884
+ // comes back. This is the ONLY site that folds the DO reset in;
2885
+ // `isRuntimeReplacement` itself stays immune to it, so `/exec` never
2886
+ // answers the replaced word for a reset over a live container.
2887
+ const runtimeReplaced = isRuntimeReplacement(err) || isControlReset(err);
2652
2888
  const runtimeActive = runtimeReplaced ? false : await this.isRuntimeActive().catch(() => null);
2653
2889
  const disposition = restoreFailureDisposition(errMsg(err), {
2654
2890
  runtimeReplaced,
@@ -3301,6 +3537,14 @@ export class ResidentDO extends Sandbox<Env> {
3301
3537
  await this.recoverFromDiskFull(failure.reason, selfInFlight);
3302
3538
  return { ok: false, reason: failure.reason };
3303
3539
  }
3540
+ // Item 67: a fetch that failed on the MIRROR (its remote gone, its
3541
+ // object store broken) is the resident's own step failing — `fetch-failed`,
3542
+ // counted by the ladder, never serviceable — not GitHub being unreachable.
3543
+ // The first live row found a broken mirror parked as `github-unreachable`.
3544
+ if (fetchFailureIsMirrors(message)) {
3545
+ await this.refreshFailed(failure, selfInFlight);
3546
+ return { ok: false, reason: failure.reason };
3547
+ }
3304
3548
  const reason = `github-unreachable: ${message}`;
3305
3549
  await this.setResidentState("degraded", reason);
3306
3550
  return { ok: false, reason };
@@ -4427,7 +4671,7 @@ export class ResidentDO extends Sandbox<Env> {
4427
4671
  const reason = diskPressureReason({ verdict: final, evicted, kept, spares });
4428
4672
  console.log(`attach ${input.threadKey}: ${reason}`);
4429
4673
  const s = await this.getStatus();
4430
- return { error: reason, status: 503, state: s.state, reason: DISK_PRESSURE_REASON };
4674
+ return { error: reason, status: 503, state: s.state, stateReason: s.reason, reason: DISK_PRESSURE_REASON };
4431
4675
  }
4432
4676
  const m = final.math;
4433
4677
  console.log(
@@ -4827,6 +5071,7 @@ export class ResidentDO extends Sandbox<Env> {
4827
5071
  error: "image-stale: the container predates the current pool and is restarting; retry shortly",
4828
5072
  status: 503,
4829
5073
  state: "restoring",
5074
+ stateReason: "",
4830
5075
  reason: "image-stale",
4831
5076
  };
4832
5077
  }
@@ -4854,7 +5099,7 @@ export class ResidentDO extends Sandbox<Env> {
4854
5099
  this.attachesInFlight--;
4855
5100
  }
4856
5101
  } catch (err) {
4857
- return { error: `attach-failed: ${errMsg(err)}`, status: 500 };
5102
+ return catchAllErr(err, "attach-failed");
4858
5103
  }
4859
5104
  }
4860
5105
 
@@ -4866,7 +5111,7 @@ export class ResidentDO extends Sandbox<Env> {
4866
5111
  * instance's entry gate (the cron's, within one bucket) — an attach never
4867
5112
  * stops the container itself: it is in flight. */
4868
5113
  private async attachFailed(err: unknown): Promise<ThreadErr> {
4869
- if (!(err instanceof StepError)) return { error: `attach-failed: ${errMsg(err)}`, status: 500 };
5114
+ if (!(err instanceof StepError)) return catchAllErr(err, "attach-failed");
4870
5115
  const failure = await this.classifyFailure(err.step, err.message);
4871
5116
  if (!failure.diskFull) return { error: `attach-failed at ${err.step}: ${err.message}`, status: 500 };
4872
5117
  console.log(`attach: ${failure.reason}`);
@@ -4890,7 +5135,13 @@ export class ResidentDO extends Sandbox<Env> {
4890
5135
  await this.refreshIfStale(resourceId);
4891
5136
  } catch (err) {
4892
5137
  const s = await this.getStatus();
4893
- return { error: `not-serviceable: ${errMsg(err)}`, status: 503, state: s.state, reason: s.reason };
5138
+ return {
5139
+ error: `not-serviceable: ${errMsg(err)}`,
5140
+ status: 503,
5141
+ state: s.state,
5142
+ stateReason: s.reason,
5143
+ reason: s.reason,
5144
+ };
4894
5145
  }
4895
5146
  // `resourceId` was read by the caller a moment ago (item 15 of the audit:
4896
5147
  // this used to re-read the same key), the registry record rides in from
@@ -4902,7 +5153,10 @@ export class ResidentDO extends Sandbox<Env> {
4902
5153
  const stored = await this.ctx.storage.get<RepoFacts | ThreadBinding>([FACTS_KEY, threadBindingKey(threadKey)]);
4903
5154
  const facts = stored.get(FACTS_KEY) as RepoFacts | undefined;
4904
5155
  const record = recordFromRoute ?? (await this.registry().getRecord(resource));
4905
- if (!record || !facts) return { error: "not-serviceable: registry record or repo facts missing", status: 503 };
5156
+ // Typed `reason` beside the words: the client reads the field — a refusal no
5157
+ // wait clears, unlike the restore window's 503s — never the sentence.
5158
+ if (!record || !facts)
5159
+ return { error: "not-serviceable: registry record or repo facts missing", status: 503, reason: "unregistered" };
4906
5160
 
4907
5161
  // The binding's ref wins for the thread's whole life, with one exception
4908
5162
  // (item 16): a thread bound to the repo default for want of a named branch
@@ -5052,9 +5306,9 @@ export class ResidentDO extends Sandbox<Env> {
5052
5306
  } catch (err) {
5053
5307
  if (err instanceof MirrorBusyError) {
5054
5308
  const s = await this.getStatus();
5055
- return { error: errMsg(err), status: 503, state: s.state, reason: "mirror-busy" };
5309
+ return { error: errMsg(err), status: 503, state: s.state, stateReason: s.reason, reason: "mirror-busy" };
5056
5310
  }
5057
- return { error: `attach-failed: ${errMsg(err)}`, status: 500 };
5311
+ return catchAllErr(err, "attach-failed");
5058
5312
  }
5059
5313
  if (outcome.kind === "none") return { binding: outcome.binding };
5060
5314
  if (outcome.kind === "refuse") {
@@ -5354,7 +5608,7 @@ export class ResidentDO extends Sandbox<Env> {
5354
5608
  return { error: `reuse-refused: ${err.why}`, status: 409, needs: "recreate" };
5355
5609
  if (err instanceof MirrorBusyError) {
5356
5610
  const s = await this.getStatus();
5357
- return { error: errMsg(err), status: 503, state: s.state, reason: "mirror-busy" };
5611
+ return { error: errMsg(err), status: 503, state: s.state, stateReason: s.reason, reason: "mirror-busy" };
5358
5612
  }
5359
5613
  if (err instanceof StepError && err.step === "unknown-ref") {
5360
5614
  return { error: `unknown-ref: ${err.message}`, status: 400 };
@@ -5399,7 +5653,7 @@ export class ResidentDO extends Sandbox<Env> {
5399
5653
  // worktree lock above, so the bot-side fallback can retry.
5400
5654
  if (err instanceof MirrorBusyError) {
5401
5655
  const s = await this.getStatus();
5402
- return { error: errMsg(err), status: 503, state: s.state, reason: "mirror-busy" };
5656
+ return { error: errMsg(err), status: 503, state: s.state, stateReason: s.reason, reason: "mirror-busy" };
5403
5657
  }
5404
5658
  return this.attachFailed(err);
5405
5659
  }
@@ -6237,7 +6491,13 @@ export class ResidentDO extends Sandbox<Env> {
6237
6491
  await this.ensureHydrated();
6238
6492
  } catch (err) {
6239
6493
  const s = await this.getStatus();
6240
- return { error: `not-serviceable: ${errMsg(err)}`, status: 503, state: s.state, reason: s.reason };
6494
+ return {
6495
+ error: `not-serviceable: ${errMsg(err)}`,
6496
+ status: 503,
6497
+ state: s.state,
6498
+ stateReason: s.reason,
6499
+ reason: s.reason,
6500
+ };
6241
6501
  }
6242
6502
  const binding = await this.ctx.storage.get<ThreadBinding>(threadBindingKey(threadKey));
6243
6503
  if (!binding) {
@@ -6321,6 +6581,9 @@ export class ResidentDO extends Sandbox<Env> {
6321
6581
  try {
6322
6582
  return await this.execThreadBody(threadKey, command, timeoutMs, env);
6323
6583
  } catch (err) {
6584
+ // A DO reset over a live container answers its own word at once — the
6585
+ // container is unchanged, so no `replacedExecAnswer` gate applies.
6586
+ if (err instanceof ControlResetError) return controlResetErr(err);
6324
6587
  if (err instanceof RuntimeReplacedError) return this.replacedExecAnswer(err);
6325
6588
  throw err;
6326
6589
  }
@@ -6346,7 +6609,8 @@ export class ResidentDO extends Sandbox<Env> {
6346
6609
  : {}),
6347
6610
  } satisfies ThreadBinding);
6348
6611
 
6349
- // A runtime replacement under the command propagates to `execThreadImpl`'s gate.
6612
+ // A runtime replacement or a control reset under the command propagates to
6613
+ // `execThread`'s gate (`replacedExecAnswer` / `controlResetErr`).
6350
6614
  const r = await this.threadRunCapped(binding.user, binding.worktreePath, command, timeoutMs, EXEC_OUTPUT_CAP, env);
6351
6615
  const truncated = r.stdout.length > EXEC_OUTPUT_CAP || r.stderr.length > EXEC_OUTPUT_CAP || r.truncated === true;
6352
6616
  const notes: string[] = [];
@@ -6399,6 +6663,7 @@ export class ResidentDO extends Sandbox<Env> {
6399
6663
  capBytesFor(READ_CONTENT_CAP),
6400
6664
  );
6401
6665
  } catch (err) {
6666
+ if (err instanceof ControlResetError) return controlResetErr(err);
6402
6667
  if (err instanceof RuntimeReplacedError) return runtimeReplacedErr(err);
6403
6668
  throw err;
6404
6669
  }
@@ -6447,6 +6712,7 @@ export class ResidentDO extends Sandbox<Env> {
6447
6712
  }
6448
6713
  return { encoding: "base64", content: parts.join(""), size };
6449
6714
  } catch (err) {
6715
+ if (err instanceof ControlResetError) return controlResetErr(err);
6450
6716
  if (err instanceof RuntimeReplacedError) return runtimeReplacedErr(err);
6451
6717
  throw err;
6452
6718
  }
@@ -6493,6 +6759,7 @@ export class ResidentDO extends Sandbox<Env> {
6493
6759
  DEFAULT_EXEC_TIMEOUT_MS,
6494
6760
  );
6495
6761
  } catch (err) {
6762
+ if (err instanceof ControlResetError) return controlResetErr(err);
6496
6763
  if (err instanceof RuntimeReplacedError) return runtimeReplacedErr(err);
6497
6764
  const step = err instanceof StepError ? ` at ${err.step}` : "";
6498
6765
  return { error: `write-failed${step}: ${errMsg(err)}`, status: 400 };
@@ -6859,7 +7126,13 @@ export class ResidentDO extends Sandbox<Env> {
6859
7126
  await this.ensureHydrated();
6860
7127
  } catch (err) {
6861
7128
  const s = await this.getStatus();
6862
- return { error: `not-serviceable: ${errMsg(err)}`, status: 503, state: s.state, reason: s.reason };
7129
+ return {
7130
+ error: `not-serviceable: ${errMsg(err)}`,
7131
+ status: 503,
7132
+ state: s.state,
7133
+ stateReason: s.reason,
7134
+ reason: s.reason,
7135
+ };
6863
7136
  }
6864
7137
  // One storage round trip for the two facts; the registry lookup stays (an
6865
7138
  // op resolves ONLY through the onboard-time command table).
@@ -6867,7 +7140,10 @@ export class ResidentDO extends Sandbox<Env> {
6867
7140
  const resource = (stored.get(RESOURCE_KEY) as string | undefined) ?? "";
6868
7141
  const facts = stored.get(FACTS_KEY) as RepoFacts | undefined;
6869
7142
  const record = await this.registry().getRecord(resource);
6870
- if (!record || !facts) return { error: "not-serviceable: registry record or repo facts missing", status: 503 };
7143
+ // Typed `reason` beside the words: the client reads the field — a refusal no
7144
+ // wait clears, unlike the restore window's 503s — never the sentence.
7145
+ if (!record || !facts)
7146
+ return { error: "not-serviceable: registry record or repo facts missing", status: 503, reason: "unregistered" };
6871
7147
  const command = record.commands[op];
6872
7148
  if (!command) return { error: `op-unavailable: the command table has no "${op}" entry`, status: 400 };
6873
7149
 
@@ -6963,13 +7239,15 @@ export class ResidentDO extends Sandbox<Env> {
6963
7239
  } catch (err) {
6964
7240
  if (err instanceof MirrorBusyError) {
6965
7241
  const s = await this.getStatus();
6966
- return { error: errMsg(err), status: 503, state: s.state, reason: "mirror-busy" };
7242
+ return { error: errMsg(err), status: 503, state: s.state, stateReason: s.reason, reason: "mirror-busy" };
6967
7243
  }
6968
7244
  if (err instanceof StepError && err.step === "unknown-ref") {
6969
7245
  return { error: `unknown-ref: ${err.message}`, status: 400 };
6970
7246
  }
6971
- const step = err instanceof StepError ? ` at ${err.step}` : "";
6972
- return { error: `op-failed${step}: ${errMsg(err)}`, status: 500 };
7247
+ // A step that failed is named and deterministic; a throw no step named is
7248
+ // typed by the one builder every such 500 goes through.
7249
+ if (err instanceof StepError) return { error: `op-failed at ${err.step}: ${errMsg(err)}`, status: 500 };
7250
+ return catchAllErr(err, "op-failed");
6973
7251
  } finally {
6974
7252
  // Disposable means disposable: the checkout dies with the op, pass or
6975
7253
  // fail (best effort — a slept container already destroyed it anyway).
@@ -7123,13 +7401,22 @@ export class ResidentDO extends Sandbox<Env> {
7123
7401
  fates.set(ref, { fate: looked.fate, detail });
7124
7402
  }),
7125
7403
  );
7404
+ // The runs registered on this resident (item 44): a run holds its tree
7405
+ // from its attach to its release, and its process lives in the container
7406
+ // between the bot's calls — the op counters read 0 while its model thinks.
7407
+ const registered = new Set(
7408
+ [...(await this.ctx.storage.list<RunRegistration>({ prefix: RUN_REG_KEY_PREFIX })).values()].map(
7409
+ (r) => r.threadKey,
7410
+ ),
7411
+ );
7126
7412
  for (const binding of live) {
7127
7413
  const isDefaultRef = binding.ref === defaultRef;
7128
7414
  // The default branch is never a finished ref (reclaimDecision keeps it
7129
7415
  // by name), so its fate is never looked up.
7130
7416
  const { fate, detail } = (!isDefaultRef && fates.get(binding.ref)) || { fate: "unknown" as RefFate, detail: "" };
7131
7417
  const busy = this.threadOpsInFlight.get(binding.threadKey) ?? 0;
7132
- const decision = reclaimDecision({ fate, isDefaultRef, busy });
7418
+ const held = registered.has(binding.threadKey);
7419
+ const decision = reclaimDecision({ fate, isDefaultRef, busy, held });
7133
7420
  if (!decision.reclaim) {
7134
7421
  kept.push({ threadKey: binding.threadKey, ref: binding.ref, why: decision.why });
7135
7422
  continue;
@@ -7146,6 +7433,11 @@ export class ResidentDO extends Sandbox<Env> {
7146
7433
  kept.push({ threadKey: binding.threadKey, ref: binding.ref, why: "busy" });
7147
7434
  continue;
7148
7435
  }
7436
+ // A run attached while the tree was measured holds it now (item 44).
7437
+ if ((await this.ctx.storage.get<RunRegistration>(runRegKey(binding.threadKey))) !== undefined) {
7438
+ kept.push({ threadKey: binding.threadKey, ref: binding.ref, why: "run-held" });
7439
+ continue;
7440
+ }
7149
7441
  if (!current || current.evicted || current.lastAttachAt !== binding.lastAttachAt) {
7150
7442
  kept.push({ threadKey: binding.threadKey, ref: binding.ref, why: "re-attached" });
7151
7443
  continue;
@@ -8052,7 +8344,7 @@ export default {
8052
8344
  return json({ error: "unknown route" }, 404);
8053
8345
  }
8054
8346
  } catch (err) {
8055
- return json({ error: errMsg(err) }, 500);
8347
+ return json(catchAllErr(err), 500);
8056
8348
  }
8057
8349
  })();
8058
8350
  root?.end(res.status >= 500 ? "error" : "ok", { httpStatus: res.status });
@@ -8602,7 +8894,7 @@ async function handleAttach(env: Env, body: Record<string, unknown>, traceparent
8602
8894
  reason,
8603
8895
  ),
8604
8896
  (result) => result,
8605
- (err) => ({ error: errMsg(err), status: 500 }),
8897
+ (err) => catchAllErr(err),
8606
8898
  );
8607
8899
  }
8608
8900
 
@@ -8620,7 +8912,7 @@ async function handleAwaitRestore(env: Env, body: Record<string, unknown>): Prom
8620
8912
  return streamHeartbeatJson(
8621
8913
  residentStub(env, resource.resource).awaitRestore(),
8622
8914
  (result) => result,
8623
- (err) => ({ error: errMsg(err), status: 500 }),
8915
+ (err) => catchAllErr(err),
8624
8916
  );
8625
8917
  }
8626
8918
 
@@ -8698,33 +8990,50 @@ function streamHeartbeatJson<T>(
8698
8990
  return new Response(stream, { headers: { "content-type": "application/json" } });
8699
8991
  }
8700
8992
 
8701
- /** /exec's payload mapping: a result as {stdout, stderr, exitCode, truncated},
8702
- * a named failure as {error, needs?, reason?, stdout:"", stderr:error, exitCode:127}
8703
- * (`reason:"runtime-replaced"` is how the client tells a deploy from a dead
8704
- * exec transport). */
8993
+ /** /exec's failure document, in the item-3 dual shape (`error` beside
8994
+ * `stdout: ""`, `stderr`, `exitCode: 127`) so old and new executors both
8995
+ * render it. The ThreadErr's fields ride beside the words, as the JSON routes
8996
+ * carry them: `needs`; the lifecycle pair (`state`, `stateReason`); the
8997
+ * answer's own word (`reason`, kept independent of `state` — `runtimeReplacedErr()`
8998
+ * sets `reason: "runtime-replaced"` with NO state, and the client's
8999
+ * deploy-vs-dead-transport check reads it); the answer's `status`; the
9000
+ * catch-all's `transient`. The client types a streamed failure by these fields
9001
+ * (execution.md item 9), and a document that dropped them would make every
9002
+ * /exec failure a deterministic answer over HTTP 200. One builder for both of
9003
+ * the stream's paths: a failure the Durable Object named, and a pending
9004
+ * result that rejected. */
9005
+ function execFailureDocument(failure: ThreadErr): object {
9006
+ return {
9007
+ error: failure.error,
9008
+ ...(failure.needs ? { needs: failure.needs } : {}),
9009
+ ...(failure.state ? { state: failure.state } : {}),
9010
+ ...(typeof failure.stateReason === "string" ? { stateReason: failure.stateReason } : {}),
9011
+ ...(failure.reason ? { reason: failure.reason } : {}),
9012
+ status: failure.status,
9013
+ ...(typeof failure.transient === "boolean" ? { transient: failure.transient } : {}),
9014
+ stdout: "",
9015
+ stderr: failure.error,
9016
+ exitCode: 127,
9017
+ };
9018
+ }
9019
+
9020
+ /** /exec's payload mapping: a result as {stdout, stderr, exitCode, truncated};
9021
+ * a failure as `execFailureDocument` — the one the Durable Object named, or
9022
+ * the pending result's rejection answered as the DO would have
9023
+ * (`threadRejectionErr`: a code-update reset the DO's own `control-reset`
9024
+ * word, a replacement the SDK's words on a bare 409 — the word withheld on
9025
+ * /exec alone —, anything else the typed 500), so a rejection streams its
9026
+ * `status`, its word and its `transient`,
9027
+ * and the client reads it like the JSON routes' answer, never as a
9028
+ * deterministic answer over HTTP 200. */
8705
9029
  function streamThreadExec(pending: Promise<Awaited<ReturnType<ResidentDO["execThread"]>>>): Response {
8706
9030
  return streamHeartbeatJson(
8707
9031
  pending,
8708
9032
  (result) =>
8709
9033
  "error" in result
8710
- ? {
8711
- error: result.error,
8712
- ...(result.needs ? { needs: result.needs } : {}),
8713
- // `reason` must stay independent of `state`: runtimeReplacedErr()
8714
- // sets reason:"runtime-replaced" with NO state, and the client's
8715
- // deploy-vs-dead-transport check reads it. Folding these two spreads
8716
- // back into one silently drops it (no test covers this Worker).
8717
- ...(result.state ? { state: result.state } : {}),
8718
- ...(result.reason ? { reason: result.reason } : {}),
8719
- stdout: "",
8720
- stderr: result.error,
8721
- exitCode: 127,
8722
- }
9034
+ ? execFailureDocument(result)
8723
9035
  : { stdout: result.stdout, stderr: result.stderr, exitCode: result.exitCode, truncated: result.truncated },
8724
- (err) => {
8725
- const msg = errMsg(err);
8726
- return { error: msg, stdout: "", stderr: msg, exitCode: 127 };
8727
- },
9036
+ (err) => execFailureDocument(threadRejectionErr(err, "/exec")),
8728
9037
  );
8729
9038
  }
8730
9039
 
@@ -8735,7 +9044,12 @@ async function handleRead(env: Env, body: Record<string, unknown>): Promise<Resp
8735
9044
  return json({ error: "path must be a string relative to the thread worktree" }, 400);
8736
9045
  const encoding = readEncodingOf(body);
8737
9046
  if (typeof encoding !== "string") return json({ error: encoding.error }, 400);
8738
- const result = await ctx.stub.readThreadFile(ctx.threadKey, body.path, encoding);
9047
+ // A stub that rejects (the DO reset under the call, a storage operation that
9048
+ // did not complete) is answered as the DO answers the same fact inside, never
9049
+ // left to the fetch handler's catch-all.
9050
+ const result = await ctx.stub
9051
+ .readThreadFile(ctx.threadKey, body.path, encoding)
9052
+ .catch((err: unknown) => threadRejectionErr(err, "/read"));
8739
9053
  if ("error" in result) return threadErrResponse(result);
8740
9054
  return json(result);
8741
9055
  }
@@ -8748,7 +9062,9 @@ async function handleWrite(env: Env, body: Record<string, unknown>): Promise<Res
8748
9062
  if (typeof body.content !== "string" || body.content.length > MAX_WRITE_CONTENT) {
8749
9063
  return json({ error: `content must be a string of at most ${MAX_WRITE_CONTENT} chars` }, 400);
8750
9064
  }
8751
- const result = await ctx.stub.writeThreadFile(ctx.threadKey, body.path, body.content);
9065
+ const result = await ctx.stub
9066
+ .writeThreadFile(ctx.threadKey, body.path, body.content)
9067
+ .catch((err: unknown) => threadRejectionErr(err, "/write"));
8752
9068
  if ("error" in result) return threadErrResponse(result);
8753
9069
  return json(result);
8754
9070
  }
@@ -8822,18 +9138,18 @@ async function handleOp(env: Env, body: Record<string, unknown>, traceparent?: s
8822
9138
  }
8823
9139
 
8824
9140
  /** /op's payload mapping: results pass through; a named error sheds its
8825
- * transport-only `status` field (the body is the contract, never the code). */
9141
+ * transport-only `status` field (the body is the contract, never the code); a
9142
+ * pending op that rejected is the typed 500 (`catchAllErr`, so `transient`
9143
+ * rides beside the words), its status shed the same way. */
8826
9144
  function streamOp(pending: Promise<Awaited<ReturnType<ResidentDO["runOp"]>>>): Response {
9145
+ const shedStatus = (failure: ThreadErr): object => {
9146
+ const { status: _status, ...rest } = failure;
9147
+ return rest;
9148
+ };
8827
9149
  return streamHeartbeatJson(
8828
9150
  pending,
8829
- (result) => {
8830
- if ("error" in result) {
8831
- const { status: _status, ...rest } = result;
8832
- return rest;
8833
- }
8834
- return result;
8835
- },
8836
- (err) => ({ error: errMsg(err) }),
9151
+ (result) => ("error" in result ? shedStatus(result) : result),
9152
+ (err) => shedStatus(catchAllErr(err, "op-failed")),
8837
9153
  );
8838
9154
  }
8839
9155