@coreplane/switchboard 1.241.0 → 1.243.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 (157) hide show
  1. package/dist/assets/config/config.example.yaml +45 -13
  2. package/dist/assets/deploy/cloudflare-memory/worker.ts +86 -29
  3. package/dist/assets/deploy/cloudflare-resident/worker.ts +298 -24
  4. package/dist/assets/deploy/cloudflare-sandbox/worker.ts +38 -5
  5. package/dist/assets/package-lock.json +3 -3
  6. package/dist/assets/package.json +1 -1
  7. package/dist/assets/project.json +5 -5
  8. package/dist/assets/source.json +3 -3
  9. package/dist/assets/src/agents/registry.ts +61 -48
  10. package/dist/assets/src/config/profile.ts +68 -3
  11. package/dist/assets/src/core/authz/actor.ts +12 -9
  12. package/dist/assets/src/core/authz/authorize.ts +17 -4
  13. package/dist/assets/src/core/authz/policy.ts +4 -0
  14. package/dist/assets/src/core/authz/types.ts +12 -0
  15. package/dist/assets/src/core/budgets.ts +313 -0
  16. package/dist/assets/src/core/coordinator/contract.ts +3 -1
  17. package/dist/assets/src/core/coordinator/driver.ts +0 -10
  18. package/dist/assets/src/core/costs.ts +21 -80
  19. package/dist/assets/src/core/harness/scope.ts +21 -0
  20. package/dist/assets/src/core/modelPricing.ts +212 -0
  21. package/dist/assets/src/core/prDescriptionTypes.ts +29 -24
  22. package/dist/assets/src/core/reviewVerdict.ts +7 -0
  23. package/dist/assets/src/core/runEvents.ts +48 -5
  24. package/dist/assets/src/core/runFriction.ts +2 -1
  25. package/dist/assets/src/core/runLedger/types.ts +2 -0
  26. package/dist/assets/src/core/runRecord.ts +51 -0
  27. package/dist/assets/src/core/runUsage.ts +158 -47
  28. package/dist/assets/src/core/schedules.ts +3 -0
  29. package/dist/assets/src/core/ship/contract.ts +4 -2
  30. package/dist/assets/src/core/ship/coordinator.ts +101 -84
  31. package/dist/assets/src/core/ship/handoff.ts +9 -0
  32. package/dist/assets/src/execution/bashTimeout.ts +8 -5
  33. package/dist/assets/src/execution/residentAutoRebuild.ts +4 -2
  34. package/dist/assets/src/execution/residentHead.ts +23 -10
  35. package/dist/assets/src/execution/residentInfraStreak.ts +89 -0
  36. package/dist/assets/src/execution/residentRefresh.ts +43 -4
  37. package/dist/assets/src/execution/residentSteps.ts +1 -0
  38. package/dist/assets/src/execution/sandboxErrors.ts +6 -0
  39. package/dist/assets/src/execution/seedPlan.ts +17 -0
  40. package/dist/assets/web/dist/.vite/manifest.json +443 -446
  41. package/dist/assets/web/dist/assets/{AppShell-DEy5jRuS.js → AppShell-IDMGG6yi.js} +1 -1
  42. package/dist/assets/web/dist/assets/CostsPage-CwXOmkeQ.js +2 -0
  43. package/dist/assets/web/dist/assets/{DeliveryPage-BGpPr5xr.js → DeliveryPage-n1tz5I_v.js} +1 -1
  44. package/dist/assets/web/dist/assets/HomePage-PRxjQiGG.js +2 -0
  45. package/dist/assets/web/dist/assets/{NotFoundPage-DtmQM2gD.js → NotFoundPage-C97EhJcQ.js} +1 -1
  46. package/dist/assets/web/dist/assets/PendingTurnRow-BT9RhFZ7.js +1 -0
  47. package/dist/assets/web/dist/assets/{ResidentDetailPage-C-48fxat.js → ResidentDetailPage-DACalNVF.js} +1 -1
  48. package/dist/assets/web/dist/assets/ResidentsIndexPage-SHPdu6uZ.js +1 -0
  49. package/dist/assets/web/dist/assets/RunFoldRow-0SdOmOr5.js +1 -0
  50. package/dist/assets/web/dist/assets/RunRoutePage-BaFS2p8I.js +9 -0
  51. package/dist/assets/web/dist/assets/RunsIndexPage-DYI-iALj.js +1 -0
  52. package/dist/assets/web/dist/assets/{RunsTabs-CtNGNnhN.js → RunsTabs-BUfdk0lH.js} +1 -1
  53. package/dist/assets/web/dist/assets/ScheduledPage-DJ8HiCPt.js +1 -0
  54. package/dist/assets/web/dist/assets/SettingsPage-DLiN5IgY.js +1 -0
  55. package/dist/assets/web/dist/assets/{StatusDot-DPWE1JmO.js → StatusDot-Dw0T1M-P.js} +1 -1
  56. package/dist/assets/web/dist/assets/{Tooltip-DKhSRH9t.js → Tooltip-BbLuIAiS.js} +1 -1
  57. package/dist/assets/web/dist/assets/UnitRoutePage-DicUG96U.js +1 -0
  58. package/dist/assets/web/dist/assets/{angular-html-DgSK1qvr.js → angular-html-oBNfPJR0.js} +1 -1
  59. package/dist/assets/web/dist/assets/{angular-ts-D3gNdiSG.js → angular-ts-BvNwsyWA.js} +1 -1
  60. package/dist/assets/web/dist/assets/{apl-CkHCYM8I.js → apl-CNUdRlYf.js} +1 -1
  61. package/dist/assets/web/dist/assets/{astro-DLm45axt.js → astro-Zb0NriSe.js} +1 -1
  62. package/dist/assets/web/dist/assets/{blade-BKa-VE-c.js → blade-qPRVheqq.js} +1 -1
  63. package/dist/assets/web/dist/assets/{c-C8NCNWai.js → c-D8Awx4YO.js} +1 -1
  64. package/dist/assets/web/dist/assets/{chapel-DqDQ7IKH.js → chapel-Bt72Mhsx.js} +1 -1
  65. package/dist/assets/web/dist/assets/{cobol-j4vCD6AJ.js → cobol-BOBacexg.js} +1 -1
  66. package/dist/assets/web/dist/assets/{coffee-C6oDVH-0.js → coffee-E4u0liHW.js} +1 -1
  67. package/dist/assets/web/dist/assets/{cpp-Bd3A1Rtc.js → cpp-q2sLNlul.js} +1 -1
  68. package/dist/assets/web/dist/assets/{crystal-CUOgOw0d.js → crystal-DyWqUnlb.js} +1 -1
  69. package/dist/assets/web/dist/assets/{css-RYljyv7G.js → css-CQY0hFsD.js} +1 -1
  70. package/dist/assets/web/dist/assets/{dist-BnJXKHCY.js → dist-twkFmSUY.js} +2 -2
  71. package/dist/assets/web/dist/assets/durationTone-BobbycC-.js +1 -0
  72. package/dist/assets/web/dist/assets/{edge-BJhNdQDc.js → edge-C1MwhJkX.js} +1 -1
  73. package/dist/assets/web/dist/assets/{elixir-g1AWOAfT.js → elixir-Bb3YbHfn.js} +1 -1
  74. package/dist/assets/web/dist/assets/{elm-CER5e4Dz.js → elm-DAN9IGQw.js} +1 -1
  75. package/dist/assets/web/dist/assets/{erb-D5aOmMQf.js → erb-BEB8Xlsj.js} +1 -1
  76. package/dist/assets/web/dist/assets/format-BldUwl_R.js +1 -0
  77. package/dist/assets/web/dist/assets/{git-rebase-D6rfV8jp.js → git-rebase-tqpjRfxO.js} +1 -1
  78. package/dist/assets/web/dist/assets/{glimmer-js-BvDH3-mC.js → glimmer-js-Ccbo65zR.js} +1 -1
  79. package/dist/assets/web/dist/assets/{glimmer-ts-DChtNs9V.js → glimmer-ts-B6WBVMpE.js} +1 -1
  80. package/dist/assets/web/dist/assets/{glsl-BHlsWrxf.js → glsl-tRec3Fcu.js} +1 -1
  81. package/dist/assets/web/dist/assets/{graphql-BsaBHb53.js → graphql-P8kbxT4F.js} +1 -1
  82. package/dist/assets/web/dist/assets/{hack-0lbDosCd.js → hack-H9Zhkagy.js} +1 -1
  83. package/dist/assets/web/dist/assets/{haml-BKtco2vr.js → haml-DtEnpn7Z.js} +1 -1
  84. package/dist/assets/web/dist/assets/{handlebars-BE_cj04z.js → handlebars-DkgPfoAz.js} +1 -1
  85. package/dist/assets/web/dist/assets/{html-BNcr8EVd.js → html-D30RXpIs.js} +1 -1
  86. package/dist/assets/web/dist/assets/{html-derivative-BsJt2Kei.js → html-derivative-DwozLrEx.js} +1 -1
  87. package/dist/assets/web/dist/assets/{http-BR5P8ER_.js → http-DXuzBAPm.js} +1 -1
  88. package/dist/assets/web/dist/assets/{hurl-CtHalpWS.js → hurl-d1UUJIt_.js} +1 -1
  89. package/dist/assets/web/dist/assets/indexRow-B_s5tKyq.js +1 -0
  90. package/dist/assets/web/dist/assets/{java-BXVdhN11.js → java-DL0gWf34.js} +1 -1
  91. package/dist/assets/web/dist/assets/{javascript-CyoShgNQ.js → javascript-Cl7vavnS.js} +1 -1
  92. package/dist/assets/web/dist/assets/{jinja-BQYZzHex.js → jinja-CfQOWMX9.js} +1 -1
  93. package/dist/assets/web/dist/assets/{jison-DqrUa1Eq.js → jison-KdYirlqm.js} +1 -1
  94. package/dist/assets/web/dist/assets/{json-CQiTF9Jj.js → json-C9cDQ-Qj.js} +1 -1
  95. package/dist/assets/web/dist/assets/{jsx-Y16MkCYQ.js → jsx-CRx5NItd.js} +1 -1
  96. package/dist/assets/web/dist/assets/{julia-zn16YONe.js → julia-CmsQsZQl.js} +1 -1
  97. package/dist/assets/web/dist/assets/{just-DcVeN_MN.js → just-CsM3Q8TE.js} +1 -1
  98. package/dist/assets/web/dist/assets/{latex-BNNoF-WP.js → latex-BXCh5YRX.js} +1 -1
  99. package/dist/assets/web/dist/assets/{liquid-DAi6qHzn.js → liquid-BF2vwK8p.js} +1 -1
  100. package/dist/assets/web/dist/assets/{lua-BswS0axw.js → lua-DcMBATrl.js} +1 -1
  101. package/dist/assets/web/dist/assets/main-B4kEF3Sg.css +1 -0
  102. package/dist/assets/web/dist/assets/{main-B3is4sk6.js → main-d-w-tIKt.js} +2 -2
  103. package/dist/assets/web/dist/assets/{marko-C-4hkmQZ.js → marko-88MndKvG.js} +1 -1
  104. package/dist/assets/web/dist/assets/{mdc-0ZaHUkS7.js → mdc-DJ4kVAd8.js} +1 -1
  105. package/dist/assets/web/dist/assets/{nginx-DBDg5tOR.js → nginx-CP6mRgtV.js} +1 -1
  106. package/dist/assets/web/dist/assets/{nim-BVMz49Yw.js → nim-QQfj3fpF.js} +1 -1
  107. package/dist/assets/web/dist/assets/{org-1hO_K6ya.js → org-Bke3eBzc.js} +1 -1
  108. package/dist/assets/web/dist/assets/{perl-CVRQLvVk.js → perl-Bkh0N7Kz.js} +1 -1
  109. package/dist/assets/web/dist/assets/{php-K9nCrB9n.js → php-Dnv1Piya.js} +1 -1
  110. package/dist/assets/web/dist/assets/{pug-CTxOcmO3.js → pug-D6peFFI7.js} +1 -1
  111. package/dist/assets/web/dist/assets/{qml-CmCkfG5q.js → qml-D8PEs-C-.js} +1 -1
  112. package/dist/assets/web/dist/assets/{r-pP47Xn1X.js → r-B1EL9b_j.js} +1 -1
  113. package/dist/assets/web/dist/assets/{razor-BpW6r3tC.js → razor-BsMTIh2b.js} +1 -1
  114. package/dist/assets/web/dist/assets/{regexp-CINgdY4N.js → regexp-HhvC8spD.js} +1 -1
  115. package/dist/assets/web/dist/assets/{rst-BZIUEq2Q.js → rst-D5paAxpg.js} +1 -1
  116. package/dist/assets/web/dist/assets/{ruby-nzAOxz6r.js → ruby-BrQwhLrl.js} +1 -1
  117. package/dist/assets/web/dist/assets/{sas-QIS1bFth.js → sas-afot2B1o.js} +1 -1
  118. package/dist/assets/web/dist/assets/{scss-BuhBBVNt.js → scss-Efm-mwuG.js} +1 -1
  119. package/dist/assets/web/dist/assets/{shellscript-CQc1vXbk.js → shellscript-BK0Vv5fT.js} +1 -1
  120. package/dist/assets/web/dist/assets/{shellsession-CoubCAUv.js → shellsession-BGqlMC7N.js} +1 -1
  121. package/dist/assets/web/dist/assets/{soy-CPRzlder.js → soy-M0b4UwGM.js} +1 -1
  122. package/dist/assets/web/dist/assets/{sql-W9krb8-9.js → sql-sxe6IE9j.js} +1 -1
  123. package/dist/assets/web/dist/assets/sseReplay-C9m_EB8J.js +9 -0
  124. package/dist/assets/web/dist/assets/{stata-CuISJEC0.js → stata-nPF_ddLP.js} +1 -1
  125. package/dist/assets/web/dist/assets/{surrealql-CSet7584.js → surrealql-D_GrC6u7.js} +1 -1
  126. package/dist/assets/web/dist/assets/{svelte-ChXTSYwK.js → svelte-DwL1AtNP.js} +1 -1
  127. package/dist/assets/web/dist/assets/{templ-Bm55v62k.js → templ-7s7LTDkc.js} +1 -1
  128. package/dist/assets/web/dist/assets/{tex-CICMX8Gj.js → tex-Bx-5fMxe.js} +1 -1
  129. package/dist/assets/web/dist/assets/{ts-tags-CA1UzWyB.js → ts-tags-DUMJnke_.js} +1 -1
  130. package/dist/assets/web/dist/assets/{tsx-Dy04HNbv.js → tsx-BN8biPDe.js} +1 -1
  131. package/dist/assets/web/dist/assets/{twig-BBHsVnVD.js → twig-8nIu84TN.js} +1 -1
  132. package/dist/assets/web/dist/assets/{typescript-Dvc-wVBT.js → typescript-BooSPq_S.js} +1 -1
  133. package/dist/assets/web/dist/assets/{typst-Dvlqx3_q.js → typst-CTBiBsem.js} +1 -1
  134. package/dist/assets/web/dist/assets/{vue-zWi1MNU2.js → vue-C6Ft4Lea.js} +1 -1
  135. package/dist/assets/web/dist/assets/{vue-html-D4YxT3An.js → vue-html-Ba36dD5D.js} +1 -1
  136. package/dist/assets/web/dist/assets/{vue-vine-0YE7uQlH.js → vue-vine-NkFexVo2.js} +1 -1
  137. package/dist/assets/web/dist/assets/{xml-gm-iksZB.js → xml-C_THnHXZ.js} +1 -1
  138. package/dist/assets/web/dist/assets/{xsl-D_W5BcrY.js → xsl-D8G5xqjY.js} +1 -1
  139. package/dist/assets/web/dist/assets/{yaml-CHmZ21wZ.js → yaml-jAMIJzge.js} +1 -1
  140. package/dist/cli.js +4121 -1902
  141. package/package.json +1 -1
  142. package/dist/assets/web/dist/assets/CostsPage-BuHD-Bv7.js +0 -2
  143. package/dist/assets/web/dist/assets/HomePage-dKBvWh-E.js +0 -2
  144. package/dist/assets/web/dist/assets/PendingTurnRow-CrEAWx5b.js +0 -1
  145. package/dist/assets/web/dist/assets/ResidentsIndexPage-CBBIjPOa.js +0 -1
  146. package/dist/assets/web/dist/assets/RunFoldRow-B6vxjcBI.js +0 -1
  147. package/dist/assets/web/dist/assets/RunRoutePage-CQjd3Cz-.js +0 -6
  148. package/dist/assets/web/dist/assets/RunsIndexPage-Cy9G6D0p.js +0 -1
  149. package/dist/assets/web/dist/assets/ScheduledPage-Ci1Ygqnc.js +0 -1
  150. package/dist/assets/web/dist/assets/SettingsPage-DxmJvAmT.js +0 -1
  151. package/dist/assets/web/dist/assets/SlackMark-VDNs7Vjh.js +0 -1
  152. package/dist/assets/web/dist/assets/UnitRoutePage-DEN5bSQf.js +0 -1
  153. package/dist/assets/web/dist/assets/durationTone-DXG-3R7_.js +0 -1
  154. package/dist/assets/web/dist/assets/indexRow-BD1VT8o8.js +0 -1
  155. package/dist/assets/web/dist/assets/localIso-L06jV29p.js +0 -1
  156. package/dist/assets/web/dist/assets/main-DP_zSemY.css +0 -1
  157. package/dist/assets/web/dist/assets/sseReplay-yji8a1wM.js +0 -9
@@ -179,6 +179,7 @@ import {
179
179
  isRuntimeUnreachableSignal,
180
180
  killStaleBuildProcessesCommand,
181
181
  planRefresh,
182
+ RUNTIME_MOVED_WORDING,
182
183
  RUNTIME_REPLACEMENT_WORDING,
183
184
  RUNTIME_UNREACHABLE_DOWN_AT,
184
185
  runtimeUnreachableReason,
@@ -197,6 +198,14 @@ import {
197
198
  type RefreshFailure,
198
199
  } from "../../src/execution/residentRefresh.js";
199
200
  import { autoRebuildDecision, isAutoRebuildEligible } from "../../src/execution/residentAutoRebuild.js";
201
+ import {
202
+ INFRA_STREAK_DOWN_AT,
203
+ infraStreakReason,
204
+ infraStreakRung,
205
+ type InfraStreakRow,
206
+ isRepoCommandStep,
207
+ parksOnRepeat,
208
+ } from "../../src/execution/residentInfraStreak.js";
200
209
  import {
201
210
  lifecycleOf,
202
211
  parseLifecycle,
@@ -544,6 +553,8 @@ const GITHUB_API_TIMEOUT_MS = 10_000;
544
553
  * idle-park like a warm one; the next attach still refreshes first. */
545
554
  const DEGRADED_PARK_AFTER_CYCLES = 3;
546
555
  const DEGRADED_STREAK_KEY = "resident:degradedStreak";
556
+ /** Consecutive cycles failed in the resident's OWN steps (item 67): the ladder's count, `InfraStreakRow`. */
557
+ const INFRA_STREAK_KEY = "resident:infraStreak";
547
558
  /** Degraded reasons that are not evidence about the repository — both always
548
559
  * carry a `: detail` suffix: the watchdog's `stale-mid-flight: …` (a marker
549
560
  * a dead cycle left behind; a cycle must run) and the wake path's
@@ -561,7 +572,7 @@ const DEGRADED_STREAK_KEY = "resident:degradedStreak";
561
572
  * re-enters the step. A control port that did not answer
562
573
  * (`runtime-unreachable: …`, item 64) is the third: no command ran, the
563
574
  * ladder over its own persisted count owns the recovery. */
564
- const NON_EVIDENCE_REASON = /^(?:stale-mid-flight|restore-interrupted|runtime-unreachable):/;
575
+ const NON_EVIDENCE_REASON = /^(?:stale-mid-flight|restore-interrupted|runtime-unreachable|infra-streak):/;
565
576
  /** When the disk-full recovery last stopped the container (docs/reference/specs/resident-repos.md item 54):
566
577
  * feeds `planDiskFullRecovery`'s cooldown so a working set that refills the
567
578
  * disk is named, not recycled in a loop. */
@@ -756,9 +767,34 @@ function isRuntimeReplacement(err: unknown): boolean {
756
767
  return false;
757
768
  }
758
769
 
770
+ /** Whether the SDK's failure vouches that the runtime under the command MOVED
771
+ * — a new incarnation serves the thread, so the container the command was
772
+ * aimed at is gone whatever else the resident knows: the typed classes for
773
+ * 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. */
784
+ function sdkVouchesRuntimeMoved(err: unknown): boolean {
785
+ if (err instanceof StaleProcessHandleError) return true;
786
+ if (err instanceof RuntimeIdentityInactiveError) return true;
787
+ if (err instanceof OperationInterruptedError && RUNTIME_REPLACED_REASONS.has(err.reason)) return true;
788
+ if (isDurableObjectCodeUpdateReset(err)) return true;
789
+ for (const link of selfAndCauses(err)) if (RUNTIME_MOVED_WORDING.test(errMsg(link))) return true;
790
+ return false;
791
+ }
792
+
759
793
  /** The named ThreadErr every thread route (exec/read/write) answers for a
760
794
  * runtime replacement, so the client can classify it (409 like the other
761
- * recoverable thread states; `reason` is the discriminator). */
795
+ * 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. */
762
798
  function runtimeReplacedErr(err: RuntimeReplacedError): ThreadErr {
763
799
  return { error: err.message, status: 409, reason: "runtime-replaced" };
764
800
  }
@@ -1479,6 +1515,17 @@ const threadBindingKey = (threadKey: string) => `${THREAD_KEY_PREFIX}${threadKey
1479
1515
 
1480
1516
  const parentDir = (p: string): string => p.slice(0, p.lastIndexOf("/"));
1481
1517
 
1518
+ /** A run's registration, held from its attach to its release (item 44): a
1519
+ * harness run's process lives in the container BETWEEN the bot's operator
1520
+ * calls, so the in-memory op counters read 0 while it runs. Durable rows —
1521
+ * a fresh isolate still counts the process that survived in the container. */
1522
+ const RUN_REG_KEY_PREFIX = "runReg:";
1523
+ const runRegKey = (threadKey: string) => `${RUN_REG_KEY_PREFIX}${threadKey}`;
1524
+ interface RunRegistration {
1525
+ threadKey: string;
1526
+ registeredAt: string;
1527
+ }
1528
+
1482
1529
  /** Deterministic per-thread+ref worktree path. Slugs replace anything outside
1483
1530
  * [A-Za-z0-9._-]; a threadKey-derived hash suffix keeps two keys that slug
1484
1531
  * identically (e.g. "a:b" vs "a-b") from colliding on disk. */
@@ -1571,6 +1618,52 @@ export class ResidentDO extends Sandbox<Env> {
1571
1618
  * survives, so hydration state is always probed from disk. */
1572
1619
  private hydration: Promise<void> | null = null;
1573
1620
 
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();
1641
+ }
1642
+
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";
1653
+ }
1654
+
1655
+ /** The `/exec` answer for a command the SDK failed with a runtime
1656
+ * 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);
1664
+ return { error: errMsg(err.cause), status: 409 };
1665
+ }
1666
+
1574
1667
  /** The step trace of the request in flight (docs/reference/specs/tracing.md item 19):
1575
1668
  * `attachThread` and `runOp` each run inside their own collector, so the
1576
1669
  * commands `runOk` runs and the mirror-lock waits land on the answer that
@@ -1957,7 +2050,9 @@ export class ResidentDO extends Sandbox<Env> {
1957
2050
  proc = await createExtensionProcessSandbox(this).exec(argv as unknown as SandboxCommand, launch);
1958
2051
  }
1959
2052
  // The spawn is the proof the control port answers: a persisted count of
1960
- // unanswered connects ends here, whatever the command goes on to do.
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;
1961
2056
  await this.clearRuntimeUnreachable();
1962
2057
  try {
1963
2058
  const out = await proc.output({ encoding: "utf8", timeout: timeout + 30_000 });
@@ -3099,10 +3194,12 @@ export class ResidentDO extends Sandbox<Env> {
3099
3194
  }
3100
3195
  }
3101
3196
  let settled = entry.state === "warm";
3102
- if (entry.state === "degraded" && !isNonEvidenceReason(entry.reason)) {
3197
+ if (entry.state === "degraded" && !isNonEvidenceReason(entry.reason) && parksOnRepeat(entry.reason)) {
3103
3198
  // Count consecutive cycles that found the same REFRESH-PRODUCED degraded
3104
- // reason (github-unreachable, <step>-failed); a stable streak means
3105
- // retrying is not going to help and parking is the right cost behavior.
3199
+ // reason that is evidence about the repo or GitHub (github-unreachable,
3200
+ // a repo-command step's <step>-failed); a stable streak means retrying
3201
+ // is not going to help and parking is the right cost behavior. A
3202
+ // resident-step failure never parks: item 67's ladder is its escalation.
3106
3203
  // Any other state resets the streak (below).
3107
3204
  const prev = await this.ctx.storage.get<{ reason: string; count: number }>(DEGRADED_STREAK_KEY);
3108
3205
  const streak =
@@ -3317,6 +3414,8 @@ export class ResidentDO extends Sandbox<Env> {
3317
3414
  // "idle since" and every attach would take the wake-fetch path).
3318
3415
  delete updatedFacts.idleSince;
3319
3416
  await this.ctx.storage.put(FACTS_KEY, updatedFacts);
3417
+ // A completed cycle ends any resident-step streak (item 67).
3418
+ await this.ctx.storage.delete(INFRA_STREAK_KEY);
3320
3419
  await this.setResidentState("warm");
3321
3420
  // Event-triggered reclamation: the prune above already told the
3322
3421
  // mirror which branches died; finished refs give their worktree and
@@ -3370,8 +3469,57 @@ export class ResidentDO extends Sandbox<Env> {
3370
3469
  private async refreshFailed(failure: RefreshFailure, selfInFlight: number): Promise<void> {
3371
3470
  console.log(`refresh: cycle failed — ${failure.reason.slice(0, 400)}`);
3372
3471
  await this.recordRefreshError(failure.reason);
3373
- await this.setResidentState("degraded", failure.reason); // last snapshot keeps serving
3374
- if (failure.diskFull) await this.recoverFromDiskFull(failure.reason, selfInFlight);
3472
+ if (failure.diskFull) {
3473
+ await this.setResidentState("degraded", failure.reason);
3474
+ await this.recoverFromDiskFull(failure.reason, selfInFlight);
3475
+ return;
3476
+ }
3477
+ // Item 67: a failure in the repository's own command is evidence about the
3478
+ // repo — degraded, and the gate's park streak decides; it also ends any
3479
+ // resident-step streak, since our machinery got that far. A failure in
3480
+ // the resident's own steps climbs the ladder over the persisted count.
3481
+ if (isRepoCommandStep(failure.step)) {
3482
+ await this.ctx.storage.delete(INFRA_STREAK_KEY);
3483
+ await this.setResidentState("degraded", failure.reason);
3484
+ return;
3485
+ }
3486
+ const row = await this.noteInfraStreak(failure.step);
3487
+ switch (infraStreakRung(row.count)) {
3488
+ case "count":
3489
+ await this.setResidentState("degraded", failure.reason);
3490
+ return;
3491
+ case "recreate":
3492
+ // The disk, not the repo: destroy the container, snapshots kept; the
3493
+ // next cycle's wake restores mirror, checkout and deps from R2.
3494
+ await this.recreateContainer(infraStreakReason(row, "recreate"));
3495
+ return;
3496
+ case "down": {
3497
+ // A recreated container failed the same way: the snapshot or the
3498
+ // resident is what is wrong, and a rebuild from GitHub is the exit —
3499
+ // item 36's transition, the VM destroyed first so provisioning starts
3500
+ // on a fresh one.
3501
+ const reason = infraStreakReason(row, "down");
3502
+ this.swapIncarnation(); // deliberate incarnation swap
3503
+ await this.forgetRuntimeIdentity();
3504
+ await this.destroy().catch((err) => console.log(`infra-streak: destroy failed: ${errMsg(err)}`));
3505
+ await this.ctx.storage.delete(INFRA_STREAK_KEY);
3506
+ await this.goDown(reason);
3507
+ return;
3508
+ }
3509
+ }
3510
+ }
3511
+
3512
+ /** One more cycle failed in the resident's own steps: the count up by one,
3513
+ * `firstAt` kept, the step and `lastAt` now. */
3514
+ private async noteInfraStreak(step: string): Promise<InfraStreakRow> {
3515
+ const prev = await this.ctx.storage.get<InfraStreakRow>(INFRA_STREAK_KEY);
3516
+ const now = new Date(systemClock()).toISOString();
3517
+ const row: InfraStreakRow = { step, count: (prev?.count ?? 0) + 1, firstAt: prev?.firstAt ?? now, lastAt: now };
3518
+ await this.ctx.storage.put(INFRA_STREAK_KEY, row);
3519
+ console.log(
3520
+ `infra-streak: cycle ${row.count} of ${INFRA_STREAK_DOWN_AT} failed in the resident's own step ${step} (first at ${row.firstAt})`,
3521
+ );
3522
+ return row;
3375
3523
  }
3376
3524
 
3377
3525
  // -- the runtime that never answers (docs/reference/specs/resident-repos.md item 64) ------
@@ -3494,7 +3642,7 @@ export class ResidentDO extends Sandbox<Env> {
3494
3642
  * restore, for the reason `escalateRuntimeUnreachable` gives. */
3495
3643
  private async recreateContainer(reason: string): Promise<void> {
3496
3644
  console.log(
3497
- `runtime-unreachable: destroying the container — snapshots kept; the next exec restores from R2 (${reason.slice(0, 200)})`,
3645
+ `recreate: destroying the container — snapshots kept; the next exec restores from R2 (${reason.slice(0, 200)})`,
3498
3646
  );
3499
3647
  this.swapIncarnation(); // deliberate incarnation swap
3500
3648
  await this.forgetRuntimeIdentity();
@@ -4687,7 +4835,7 @@ export class ResidentDO extends Sandbox<Env> {
4687
4835
  // container under it (and isIdle never parks the cycle mid-attach).
4688
4836
  this.attachesInFlight++;
4689
4837
  try {
4690
- return await this.attachThreadBody(
4838
+ const res = await this.attachThreadBody(
4691
4839
  threadKey,
4692
4840
  refHint,
4693
4841
  readonly,
@@ -4698,6 +4846,10 @@ export class ResidentDO extends Sandbox<Env> {
4698
4846
  record,
4699
4847
  reason,
4700
4848
  );
4849
+ // The run this attach opens is now in flight until its release —
4850
+ // whatever its op counters read between the bot's calls (item 44).
4851
+ if (!("error" in res)) await this.registerRun(threadKey);
4852
+ return res;
4701
4853
  } finally {
4702
4854
  this.attachesInFlight--;
4703
4855
  }
@@ -5135,10 +5287,16 @@ export class ResidentDO extends Sandbox<Env> {
5135
5287
  // second movement is decided on. (Under the mirror mutex — the mirror
5136
5288
  // cannot change in between.)
5137
5289
  const refExists = why === null || (await this.refExists(binding.ref));
5290
+ // The tip is read here, once, and is what the target is judged on and
5291
+ // what the worktree is provisioned at: a caller that named a commit
5292
+ // gets that commit or a `stale-tip` refusal, never whatever the tip
5293
+ // happens to be (item 51).
5294
+ let tipSha = refExists ? await this.readMirrorSha(binding.ref).catch(() => null) : null;
5138
5295
  let target = attachTarget({
5139
5296
  refExists,
5140
5297
  wantSha: want,
5141
5298
  commitInMirror: !refExists && want !== null && (await this.commitInMirror(want)),
5299
+ tipSha,
5142
5300
  });
5143
5301
  if (target.kind === "unknown-ref" && returnable) {
5144
5302
  // The thread's branch — one a rebind moved it onto, or one its own
@@ -5151,10 +5309,13 @@ export class ResidentDO extends Sandbox<Env> {
5151
5309
  binding = back.binding;
5152
5310
  returned = back.returned;
5153
5311
  want = wantShaForBinding({ boundRef: binding.ref, refHint, wantSha });
5312
+ const defaultExists = await this.refExists(binding.ref);
5313
+ tipSha = defaultExists ? await this.readMirrorSha(binding.ref).catch(() => null) : null;
5154
5314
  target = attachTarget({
5155
- refExists: await this.refExists(binding.ref),
5315
+ refExists: defaultExists,
5156
5316
  wantSha: want,
5157
5317
  commitInMirror: false,
5318
+ tipSha,
5158
5319
  });
5159
5320
  }
5160
5321
  if (target.kind === "unknown-ref") {
@@ -5164,7 +5325,19 @@ export class ResidentDO extends Sandbox<Env> {
5164
5325
  (want !== null ? `, and the mirror does not hold the expected commit ${want.slice(0, 7)} either` : ""),
5165
5326
  );
5166
5327
  }
5167
- const sha = target.kind === "sha" ? target.sha : await this.readMirrorSha(binding.ref);
5328
+ if (target.kind === "stale-tip") {
5329
+ throw new StepError(
5330
+ "stale-tip",
5331
+ `ref ${JSON.stringify(binding.ref)} is at ${target.tip ? target.tip.slice(0, 7) : "an unreadable tip"} in the mirror${why === null ? " (no fetch was due)" : " even after the fetch"}; the caller expects ${target.want.slice(0, 7)} — the run executes at the commit it asked for, or not on this resident`,
5332
+ );
5333
+ }
5334
+ // A tip the read could not answer is never provisioned at: with no
5335
+ // commit named the target is `ref` without looking at the tip, so the
5336
+ // failed read is named here instead of becoming a null sha.
5337
+ const sha = target.kind === "sha" ? target.sha : tipSha;
5338
+ if (sha === null) {
5339
+ throw new StepError("rev-parse", `the mirror's tip of ${JSON.stringify(binding.ref)} could not be read`);
5340
+ }
5168
5341
  const threadLockKey = await this.lockfileKey(sha);
5169
5342
  const recreated = await this.ensureThreadWorktree(binding, sha, mode.originUrl, mode.modeSwitch, {
5170
5343
  detached: target.kind === "sha",
@@ -5186,6 +5359,13 @@ export class ResidentDO extends Sandbox<Env> {
5186
5359
  if (err instanceof StepError && err.step === "unknown-ref") {
5187
5360
  return { error: `unknown-ref: ${err.message}`, status: 400 };
5188
5361
  }
5362
+ if (err instanceof StepError && err.step === "stale-tip") {
5363
+ // Item 51: not a resident fault and not a caller fault — a fact about
5364
+ // the mirror at this instant. 409 with the state, so the bot's named
5365
+ // fallback runs cold at the commit it asked for.
5366
+ const s = await this.getStatus();
5367
+ return { error: `stale-tip: ${err.message}`, status: 409, state: s.state, reason: "stale-tip" };
5368
+ }
5189
5369
  return this.attachFailed(err);
5190
5370
  }
5191
5371
 
@@ -6127,11 +6307,30 @@ export class ResidentDO extends Sandbox<Env> {
6127
6307
  return res;
6128
6308
  }
6129
6309
 
6310
+ /** The exec route's one gate for a runtime replacement (item 43): every
6311
+ * `RuntimeReplacedError` the route meets — the preflight's own probe of the
6312
+ * worktree, the command itself — is judged by `replacedExecAnswer`, so the
6313
+ * word reaches the harness only when the SDK vouched the runtime moved or
6314
+ * the resident knows the container it held is gone. */
6130
6315
  private async execThreadImpl(
6131
6316
  threadKey: string,
6132
6317
  command: string,
6133
6318
  timeoutMs: number,
6134
6319
  env?: Record<string, string>,
6320
+ ): Promise<{ stdout: string; stderr: string; exitCode: number; truncated: boolean } | ThreadErr> {
6321
+ try {
6322
+ return await this.execThreadBody(threadKey, command, timeoutMs, env);
6323
+ } catch (err) {
6324
+ if (err instanceof RuntimeReplacedError) return this.replacedExecAnswer(err);
6325
+ throw err;
6326
+ }
6327
+ }
6328
+
6329
+ private async execThreadBody(
6330
+ threadKey: string,
6331
+ command: string,
6332
+ timeoutMs: number,
6333
+ env?: Record<string, string>,
6135
6334
  ): Promise<{ stdout: string; stderr: string; exitCode: number; truncated: boolean } | ThreadErr> {
6136
6335
  const pre = await this.threadPreflight(threadKey);
6137
6336
  if ("error" in pre) return pre;
@@ -6147,13 +6346,8 @@ export class ResidentDO extends Sandbox<Env> {
6147
6346
  : {}),
6148
6347
  } satisfies ThreadBinding);
6149
6348
 
6150
- let r: Awaited<ReturnType<ResidentDO["threadRun"]>>;
6151
- try {
6152
- r = await this.threadRunCapped(binding.user, binding.worktreePath, command, timeoutMs, EXEC_OUTPUT_CAP, env);
6153
- } catch (err) {
6154
- if (err instanceof RuntimeReplacedError) return runtimeReplacedErr(err);
6155
- throw err;
6156
- }
6349
+ // A runtime replacement under the command propagates to `execThreadImpl`'s gate.
6350
+ const r = await this.threadRunCapped(binding.user, binding.worktreePath, command, timeoutMs, EXEC_OUTPUT_CAP, env);
6157
6351
  const truncated = r.stdout.length > EXEC_OUTPUT_CAP || r.stderr.length > EXEC_OUTPUT_CAP || r.truncated === true;
6158
6352
  const notes: string[] = [];
6159
6353
  if (r.timedOut)
@@ -6361,6 +6555,9 @@ export class ResidentDO extends Sandbox<Env> {
6361
6555
  evictedLeftBehind: tree && "leftBehind" in tree ? tree.leftBehind : undefined,
6362
6556
  evictedUnmeasured: tree && "unmeasured" in tree ? tree.unmeasured : undefined,
6363
6557
  } satisfies ThreadBinding);
6558
+ // The run's registration goes with its binding (item 44): released here,
6559
+ // never on a give-way above — a re-attach has re-registered it anyway.
6560
+ await this.ctx.storage.delete(runRegKey(binding.threadKey));
6364
6561
  if (tree) console.log(`${logCtx}: ${binding.threadKey} evicted (${why}) — ${evictedTreeSentence(tree)}`);
6365
6562
  return true;
6366
6563
  }
@@ -6522,10 +6719,33 @@ export class ResidentDO extends Sandbox<Env> {
6522
6719
  return threadOps + this.opUsersInUse.size + this.attachesInFlight;
6523
6720
  }
6524
6721
  /** In-flight activity for the deploy preflight (GET /status, GET /residents).
6525
- * In-memory by nature: a fresh isolate answers 0, which is correct — nothing
6526
- * survived to be interrupted. */
6722
+ * The in-memory counters (a fresh isolate answers 0 for them — nothing of
6723
+ * THEIRS survived to be interrupted) plus the durable run registrations:
6724
+ * a harness run's process DOES survive an isolate swap in the container,
6725
+ * so its registration must too. */
6527
6726
  async getInFlightCount(): Promise<number> {
6528
- return this.inFlightCount();
6727
+ return this.inFlightCount() + (await this.registeredRunsBeyondOps());
6728
+ }
6729
+ /** A run holds its worktree from `/attach` to its release (`/detach`, the
6730
+ * sweep, disk pressure — every path ends in `evictBinding`, which clears
6731
+ * the row). Written by the attach; idempotent, a re-attach refreshes it. */
6732
+ private async registerRun(threadKey: string): Promise<void> {
6733
+ await this.ctx.storage.put(runRegKey(threadKey), {
6734
+ threadKey,
6735
+ registeredAt: new Date(systemClock()).toISOString(),
6736
+ } satisfies RunRegistration);
6737
+ }
6738
+ /** The registrations the op counters do not already see: a thread with an
6739
+ * op in flight is counted by `runsInFlightCount`, so its registration is
6740
+ * not counted again. Deliberately NOT part of `inFlightCount()`: the
6741
+ * container-lifecycle predicates (isIdle, reconcileImage) must not let a
6742
+ * stale registration pin a container awake — the clean-idle sweep is what
6743
+ * drains a registration whose release never came. */
6744
+ private async registeredRunsBeyondOps(): Promise<number> {
6745
+ const regs = await this.ctx.storage.list<RunRegistration>({ prefix: RUN_REG_KEY_PREFIX });
6746
+ let n = 0;
6747
+ for (const r of regs.values()) if ((this.threadOpsInFlight.get(r.threadKey) ?? 0) === 0) n++;
6748
+ return n;
6529
6749
  }
6530
6750
  private attachesInFlight = 0;
6531
6751
  /** A refresh cycle past its idle/reconcile gates (fetching, rebuilding, snapshotting). */
@@ -7068,6 +7288,7 @@ export class ResidentDO extends Sandbox<Env> {
7068
7288
  REFRESH_INSTANCE_KEY,
7069
7289
  RUNTIME_UNREACHABLE_KEY,
7070
7290
  AUTO_REBUILDS_KEY,
7291
+ INFRA_STREAK_KEY,
7071
7292
  ]);
7072
7293
  const facts = map.get(FACTS_KEY) as RepoFacts | undefined;
7073
7294
  const snap = map.get(SNAPSHOT_KEY) as SnapshotRecord | undefined;
@@ -7120,6 +7341,9 @@ export class ResidentDO extends Sandbox<Env> {
7120
7341
  recycledUnmeasured: recycledUnmeasured ?? null,
7121
7342
  }),
7122
7343
  );
7344
+ // The runs whose process lives in the container between operator calls
7345
+ // (item 44): what the deploy preflight must see, added to both counts.
7346
+ const registeredRuns = await this.registeredRunsBeyondOps();
7123
7347
  return {
7124
7348
  resource: map.get(RESOURCE_KEY) ?? null,
7125
7349
  state: map.get(STATE_KEY) ?? "down",
@@ -7139,9 +7363,9 @@ export class ResidentDO extends Sandbox<Env> {
7139
7363
  provisionRun: provisionRun.length,
7140
7364
  provisionDeadline: provisionDeadline.length,
7141
7365
  },
7142
- inFlight: this.inFlightCount(),
7366
+ inFlight: this.inFlightCount() + registeredRuns,
7143
7367
  // The runs alone (no refresh cycle): what the deploy preflight refuses on.
7144
- runsInFlight: this.runsInFlightCount(),
7368
+ runsInFlight: this.runsInFlightCount() + registeredRuns,
7145
7369
  // Item 22: who holds what, as the rows say — the mirror mutex and the
7146
7370
  // cycle/hydration leases, each judged against this incarnation.
7147
7371
  incarnation: this.incarnation,
@@ -7173,6 +7397,12 @@ export class ResidentDO extends Sandbox<Env> {
7173
7397
  // Item 36: the auto-rebuilds this resident has had (ISO instants; the
7174
7398
  // budget's window is judged over them). Empty after a person's rebuild.
7175
7399
  autoRebuilds: (map.get(AUTO_REBUILDS_KEY) as string[] | undefined) ?? [],
7400
+ // Item 67: consecutive cycles failed in the resident's own steps, with
7401
+ // the rung that count is on; null while cycles complete.
7402
+ infraStreak: (() => {
7403
+ const row = map.get(INFRA_STREAK_KEY) as InfraStreakRow | undefined;
7404
+ return row ? { ...row, rung: infraStreakRung(row.count) } : null;
7405
+ })(),
7176
7406
  };
7177
7407
  }
7178
7408
 
@@ -7255,6 +7485,37 @@ export class ResidentDO extends Sandbox<Env> {
7255
7485
  return { recreated: true, restoreStartedAt };
7256
7486
  }
7257
7487
 
7488
+ /** Fault injection for item 67: write the resident-step streak row at `count`
7489
+ * (0 deletes it), so the next real failure lands on the rung under test.
7490
+ * Test-only semantics; admin scope. */
7491
+ async debugSetInfraStreak(count: number): Promise<{ infraStreak: (InfraStreakRow & { rung: string }) | null }> {
7492
+ if (count === 0) {
7493
+ await this.ctx.storage.delete(INFRA_STREAK_KEY);
7494
+ return { infraStreak: null };
7495
+ }
7496
+ const now = new Date(systemClock()).toISOString();
7497
+ const row: InfraStreakRow = { step: "injected", count, firstAt: now, lastAt: now };
7498
+ await this.ctx.storage.put(INFRA_STREAK_KEY, row);
7499
+ return { infraStreak: { ...row, rung: infraStreakRung(count) } };
7500
+ }
7501
+
7502
+ /** Fault injection for item 67: corrupt the mirror on disk so the next
7503
+ * cycle's `fetch` fails in a resident step. The mirror's `config` goes (its
7504
+ * `origin` remote with it: `'origin' does not appear to be a git
7505
+ * repository`, exit 128) — NOT its objects: the wake check (`readyStamp`)
7506
+ * tests `objects/`, and a missing directory is a disk to restore, which the
7507
+ * wake did before any step could fail the first time this was tried live.
7508
+ * The snapshots in R2 are untouched — the ladder's recreate restores them.
7509
+ * Test-only semantics; admin scope. */
7510
+ async debugBreakMirror(): Promise<{ broken: boolean; error?: string }> {
7511
+ try {
7512
+ await this.runOk(["rm", "-f", `${MIRROR_DIR}/config`], "break-mirror");
7513
+ return { broken: true };
7514
+ } catch (err) {
7515
+ return { broken: false, error: errMsg(err) };
7516
+ }
7517
+ }
7518
+
7258
7519
  /** Fault injection for item 36 without corrupting real R2 objects. Bare: persist
7259
7520
  * `down` with a rehydration-flavored reason and nothing else, so the next
7260
7521
  * watchdog pass is the backstop under test. `transition`: go through
@@ -8644,6 +8905,19 @@ async function handleDebug(env: Env, body: Record<string, unknown>): Promise<Res
8644
8905
  }
8645
8906
  case "force-onboarding":
8646
8907
  return json(await stub.debugForceOnboarding());
8908
+ case "infra-streak": {
8909
+ // Fault injection for item 67: set the resident-step streak's count, so
8910
+ // one real failure lands on the rung under test (recreate at 3, down at 5).
8911
+ const count =
8912
+ typeof body.count === "number" && Number.isInteger(body.count) && body.count >= 0 ? body.count : null;
8913
+ if (count === null) return json({ error: "count must be a non-negative integer" }, 400);
8914
+ return json(await stub.debugSetInfraStreak(count));
8915
+ }
8916
+ case "break-mirror":
8917
+ // Fault injection for item 67: remove the mirror's objects, so the next
8918
+ // cycle's fetch fails in a resident step — a corrupt disk, which the
8919
+ // ladder's recreate restores from the snapshot. Test residents only.
8920
+ return json(await stub.debugBreakMirror());
8647
8921
  case "force-down": {
8648
8922
  // Fault injection for item 36; a rehydration-flavored default reason
8649
8923
  // makes it eligible. `transition: true` fires the down through goDown
@@ -87,8 +87,10 @@ import {
87
87
  SEED_ABANDONED_RESTORE_WAIT_MS,
88
88
  seedFixupScript,
89
89
  seedMarkerText,
90
+ type RestorePhases,
90
91
  type SandboxSeed,
91
92
  type SeedAnswer,
93
+ type SeedPhases,
92
94
  type SeedStep,
93
95
  } from "../../src/execution/seedPlan.js";
94
96
  import { shellQuote } from "../../src/execution/shellQuote.js";
@@ -450,12 +452,13 @@ export class SwitchboardSandbox extends Sandbox<Env> {
450
452
  // marker for another handle.
451
453
  await this.runRoot(["sh", "-c", `${unmountAllRestoresScript()}\n${this.seedSweep()}`], 60_000);
452
454
  let t = systemClock();
453
- await this.restoreSeedInto(seed.checkoutBackupId, SEED_CHECKOUT_DIR, deadline, "checkout");
455
+ const checkout = await this.restoreSeedInto(seed.checkoutBackupId, SEED_CHECKOUT_DIR, deadline, "checkout");
454
456
  steps.restore = systemClock() - t;
457
+ const phases: SeedPhases = { checkout, deps: null };
455
458
  if (seed.depsBackupId) {
456
459
  step = "deps";
457
460
  t = systemClock();
458
- await this.restoreSeedInto(seed.depsBackupId, SEED_DEPS_STAGING_DIR, deadline, "deps");
461
+ phases.deps = await this.restoreSeedInto(seed.depsBackupId, SEED_DEPS_STAGING_DIR, deadline, "deps");
459
462
  steps.deps = systemClock() - t;
460
463
  }
461
464
  step = "fixup";
@@ -480,9 +483,27 @@ export class SwitchboardSandbox extends Sandbox<Env> {
480
483
  );
481
484
  const ms = systemClock() - t0;
482
485
  console.log(
483
- JSON.stringify({ event: "sandbox.seeded", slug: seed.slug, ref: seed.fetchRef ?? seed.ref, sha, steps, ms }),
486
+ JSON.stringify({
487
+ event: "sandbox.seeded",
488
+ slug: seed.slug,
489
+ ref: seed.fetchRef ?? seed.ref,
490
+ sha,
491
+ steps,
492
+ phases,
493
+ ms,
494
+ }),
484
495
  );
485
- return { seeded: true, cached: false, slug: seed.slug, ref: seed.fetchRef ?? seed.ref, sha, from, steps, ms };
496
+ return {
497
+ seeded: true,
498
+ cached: false,
499
+ slug: seed.slug,
500
+ ref: seed.fetchRef ?? seed.ref,
501
+ sha,
502
+ from,
503
+ steps,
504
+ phases,
505
+ ms,
506
+ };
486
507
  } catch (err) {
487
508
  const shape = thrownShape(err);
488
509
  // A half seed never survives either: the run that follows goes cold
@@ -510,7 +531,12 @@ export class SwitchboardSandbox extends Sandbox<Env> {
510
531
  * a staging sibling, the wait is judged by bytes arriving (the SDK's call
511
532
  * takes no timeout) against the seed's one deadline, then the extract
512
533
  * script puts a real tree in place and the target appears last. */
513
- private async restoreSeedInto(id: string, targetDir: string, deadlineMs: number, what: string): Promise<void> {
534
+ private async restoreSeedInto(
535
+ id: string,
536
+ targetDir: string,
537
+ deadlineMs: number,
538
+ what: string,
539
+ ): Promise<RestorePhases> {
514
540
  const attempt = crypto.randomUUID().slice(0, 8);
515
541
  const mountDir = restoreMountDir(targetDir, attempt);
516
542
  const backup: DirectoryBackup = { id, dir: mountDir };
@@ -535,11 +561,18 @@ export class SwitchboardSandbox extends Sandbox<Env> {
535
561
  const verdict = judgeRestoreProgress({ startedMs, nowMs: systemClock(), samples, deadlineMs });
536
562
  if (verdict.verdict !== "wait") throw new Error(`${what} restore ${verdict.verdict}: ${verdict.detail}`);
537
563
  }
564
+ const download = systemClock() - startedMs;
565
+ const extractStartedMs = systemClock();
538
566
  const r = await this.runRoot(
539
567
  ["sh", "-c", extractRestoreScript({ mountDir, backupId: id, archivePath: restoreArchivePath(id), targetDir })],
540
568
  Math.max(60_000, deadlineMs - systemClock()),
541
569
  );
542
570
  if (r.exitCode !== 0) throw new Error(`${what} extract exited ${r.exitCode}: ${tail(r.stderr || r.stdout)}`);
571
+ const extract = systemClock() - extractStartedMs;
572
+ console.log(
573
+ JSON.stringify({ event: "sandbox.seed-restore", what, id, download, extract, method: r.stdout.trim() }),
574
+ );
575
+ return { download, extract };
543
576
  }
544
577
 
545
578
  /** Restores this object started that have not settled: what a failure
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.241.0",
3
+ "version": "1.243.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "switchboard",
9
- "version": "1.241.0",
9
+ "version": "1.243.0",
10
10
  "license": "Apache-2.0",
11
11
  "workspaces": [
12
12
  "web",
@@ -20445,7 +20445,7 @@
20445
20445
  },
20446
20446
  "packages/switchboard": {
20447
20447
  "name": "@coreplane/switchboard",
20448
- "version": "1.241.0",
20448
+ "version": "1.243.0",
20449
20449
  "license": "Apache-2.0",
20450
20450
  "dependencies": {
20451
20451
  "@earendil-works/pi-ai": "0.85.1",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.241.0",
3
+ "version": "1.243.0",
4
4
  "private": true,
5
5
  "description": "Mention it in Slack and an agent reviews the PR, ships the fix, or answers the question — on the model you choose, with its tools running where you decide.",
6
6
  "license": "Apache-2.0",