@coreplane/switchboard 1.239.0 → 1.241.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 (156) hide show
  1. package/dist/assets/config/config.example.yaml +32 -7
  2. package/dist/assets/deploy/cloudflare/worker.ts +77 -18
  3. package/dist/assets/deploy/cloudflare-memory/worker.ts +115 -1
  4. package/dist/assets/deploy/cloudflare-memory/wrangler.template.jsonc +7 -1
  5. package/dist/assets/deploy/cloudflare-resident/worker.ts +224 -70
  6. package/dist/assets/deploy/secrets.manifest.json +7 -1
  7. package/dist/assets/package-lock.json +207 -3
  8. package/dist/assets/package.json +2 -1
  9. package/dist/assets/source.json +3 -3
  10. package/dist/assets/src/agents/registry.ts +27 -8
  11. package/dist/assets/src/core/authz/actor.ts +38 -2
  12. package/dist/assets/src/core/authz/grants.ts +20 -9
  13. package/dist/assets/src/core/authz/policy.ts +6 -0
  14. package/dist/assets/src/core/coordinator/contract.ts +49 -1
  15. package/dist/assets/src/core/coordinator/driver.ts +30 -1
  16. package/dist/assets/src/core/coordinator/instancesRoute.ts +72 -0
  17. package/dist/assets/src/core/costs.ts +1119 -0
  18. package/dist/assets/src/core/costsSnapshotStore.ts +209 -0
  19. package/dist/assets/src/core/delivery.ts +4 -11
  20. package/dist/assets/src/core/deliverySnapshotStore.ts +12 -28
  21. package/dist/assets/src/core/ingressTokens.ts +27 -13
  22. package/dist/assets/src/core/runLedger/types.ts +3 -0
  23. package/dist/assets/src/core/runRecord.ts +10 -3
  24. package/dist/assets/src/core/schedules.ts +2 -1
  25. package/dist/assets/src/core/ship/coordinator.ts +92 -7
  26. package/dist/assets/src/core/snapshotAge.ts +30 -0
  27. package/dist/assets/src/core/stateWorkerRef.ts +41 -0
  28. package/dist/assets/src/core/trace/attrs.ts +1 -1
  29. package/dist/assets/src/execution/bindingPurge.ts +2 -2
  30. package/dist/assets/src/execution/residentAutoRebuild.ts +73 -0
  31. package/dist/assets/src/execution/residentRefresh.ts +25 -3
  32. package/dist/assets/src/execution/restoreWaiters.ts +61 -0
  33. package/dist/assets/src/mcp/registry.ts +7 -0
  34. package/dist/assets/web/dist/.vite/manifest.json +509 -427
  35. package/dist/assets/web/dist/assets/AppShell-DEy5jRuS.js +1 -0
  36. package/dist/assets/web/dist/assets/CostsPage-BuHD-Bv7.js +2 -0
  37. package/dist/assets/web/dist/assets/DeliveryPage-BGpPr5xr.js +1 -0
  38. package/dist/assets/web/dist/assets/HomePage-dKBvWh-E.js +2 -0
  39. package/dist/assets/web/dist/assets/NotFoundPage-DtmQM2gD.js +1 -0
  40. package/dist/assets/web/dist/assets/PendingTurnRow-CrEAWx5b.js +1 -0
  41. package/dist/assets/web/dist/assets/{ResidentDetailPage-CrfCd8fx.js → ResidentDetailPage-C-48fxat.js} +1 -1
  42. package/dist/assets/web/dist/assets/ResidentsIndexPage-CBBIjPOa.js +1 -0
  43. package/dist/assets/web/dist/assets/RunFoldRow-B6vxjcBI.js +1 -0
  44. package/dist/assets/web/dist/assets/RunRoutePage-CQjd3Cz-.js +6 -0
  45. package/dist/assets/web/dist/assets/RunsIndexPage-Cy9G6D0p.js +1 -0
  46. package/dist/assets/web/dist/assets/{RunsTabs-XpCqeWu4.js → RunsTabs-CtNGNnhN.js} +1 -1
  47. package/dist/assets/web/dist/assets/ScheduledPage-Ci1Ygqnc.js +1 -0
  48. package/dist/assets/web/dist/assets/SettingsPage-DxmJvAmT.js +1 -0
  49. package/dist/assets/web/dist/assets/SlackMark-VDNs7Vjh.js +1 -0
  50. package/dist/assets/web/dist/assets/{StatusDot-DkXALvKf.js → StatusDot-DPWE1JmO.js} +1 -1
  51. package/dist/assets/web/dist/assets/{Tooltip-DtLIyi5P.js → Tooltip-DKhSRH9t.js} +1 -1
  52. package/dist/assets/web/dist/assets/UnitRoutePage-DEN5bSQf.js +1 -0
  53. package/dist/assets/web/dist/assets/{angular-html-DYtV9uIN.js → angular-html-DgSK1qvr.js} +1 -1
  54. package/dist/assets/web/dist/assets/{angular-ts-DF2P25Zp.js → angular-ts-D3gNdiSG.js} +1 -1
  55. package/dist/assets/web/dist/assets/{apl-Ff96wxko.js → apl-CkHCYM8I.js} +1 -1
  56. package/dist/assets/web/dist/assets/{astro-LbBnZna-.js → astro-DLm45axt.js} +1 -1
  57. package/dist/assets/web/dist/assets/{blade-DltuxeBW.js → blade-BKa-VE-c.js} +1 -1
  58. package/dist/assets/web/dist/assets/{c-DBg7yuPx.js → c-C8NCNWai.js} +1 -1
  59. package/dist/assets/web/dist/assets/{chapel-C83hUAds.js → chapel-DqDQ7IKH.js} +1 -1
  60. package/dist/assets/web/dist/assets/{cobol-DB0yV_2D.js → cobol-j4vCD6AJ.js} +1 -1
  61. package/dist/assets/web/dist/assets/{coffee-Bpih6Kzl.js → coffee-C6oDVH-0.js} +1 -1
  62. package/dist/assets/web/dist/assets/{cpp-CU00ZeRn.js → cpp-Bd3A1Rtc.js} +1 -1
  63. package/dist/assets/web/dist/assets/{crystal-f8ImJkux.js → crystal-CUOgOw0d.js} +1 -1
  64. package/dist/assets/web/dist/assets/{css-4VrC4ISo.js → css-RYljyv7G.js} +1 -1
  65. package/dist/assets/web/dist/assets/delivery-CTdOqpVK.js +1 -0
  66. package/dist/assets/web/dist/assets/{dist-DtA7JiH5.js → dist-BnJXKHCY.js} +2 -2
  67. package/dist/assets/web/dist/assets/durationTone-DXG-3R7_.js +1 -0
  68. package/dist/assets/web/dist/assets/{edge-BwFDzfS_.js → edge-BJhNdQDc.js} +1 -1
  69. package/dist/assets/web/dist/assets/{elixir-wwIYIpJm.js → elixir-g1AWOAfT.js} +1 -1
  70. package/dist/assets/web/dist/assets/{elm-DyDuM3v3.js → elm-CER5e4Dz.js} +1 -1
  71. package/dist/assets/web/dist/assets/{erb-DFJBEVrh.js → erb-D5aOmMQf.js} +1 -1
  72. package/dist/assets/web/dist/assets/eventSource-tRprhqm7.js +1 -0
  73. package/dist/assets/web/dist/assets/favicon-D1hkyC5j.js +1 -0
  74. package/dist/assets/web/dist/assets/{git-rebase-CuDv-44X.js → git-rebase-D6rfV8jp.js} +1 -1
  75. package/dist/assets/web/dist/assets/{glimmer-js-ZFnrm4J3.js → glimmer-js-BvDH3-mC.js} +1 -1
  76. package/dist/assets/web/dist/assets/{glimmer-ts-XY4Yqshp.js → glimmer-ts-DChtNs9V.js} +1 -1
  77. package/dist/assets/web/dist/assets/{glsl-xnR8efx0.js → glsl-BHlsWrxf.js} +1 -1
  78. package/dist/assets/web/dist/assets/{graphql-D-gO5tnP.js → graphql-BsaBHb53.js} +1 -1
  79. package/dist/assets/web/dist/assets/{hack-CSxbW4ax.js → hack-0lbDosCd.js} +1 -1
  80. package/dist/assets/web/dist/assets/{haml-D4ar42QT.js → haml-BKtco2vr.js} +1 -1
  81. package/dist/assets/web/dist/assets/{handlebars-CMLbDE7o.js → handlebars-BE_cj04z.js} +1 -1
  82. package/dist/assets/web/dist/assets/{html-CVekU4Fs.js → html-BNcr8EVd.js} +1 -1
  83. package/dist/assets/web/dist/assets/{html-derivative-St3HHw_N.js → html-derivative-BsJt2Kei.js} +1 -1
  84. package/dist/assets/web/dist/assets/{http-Mon0DWHS.js → http-BR5P8ER_.js} +1 -1
  85. package/dist/assets/web/dist/assets/{hurl-Cbl5IvBs.js → hurl-CtHalpWS.js} +1 -1
  86. package/dist/assets/web/dist/assets/{localIso-CNA-bhS-.js → indexFormat-B-pd4r7Y.js} +1 -1
  87. package/dist/assets/web/dist/assets/{indexRow-FLJu5mR9.js → indexRow-BD1VT8o8.js} +1 -1
  88. package/dist/assets/web/dist/assets/{java-Bprck1l-.js → java-BXVdhN11.js} +1 -1
  89. package/dist/assets/web/dist/assets/{javascript-C73G1-UR.js → javascript-CyoShgNQ.js} +1 -1
  90. package/dist/assets/web/dist/assets/{jinja-C4DuRxYJ.js → jinja-BQYZzHex.js} +1 -1
  91. package/dist/assets/web/dist/assets/{jison-B2U03GEC.js → jison-DqrUa1Eq.js} +1 -1
  92. package/dist/assets/web/dist/assets/{json-CvTZUDzx.js → json-CQiTF9Jj.js} +1 -1
  93. package/dist/assets/web/dist/assets/{jsx-DMWKCzwo.js → jsx-Y16MkCYQ.js} +1 -1
  94. package/dist/assets/web/dist/assets/{julia-Czn7bV_3.js → julia-zn16YONe.js} +1 -1
  95. package/dist/assets/web/dist/assets/{just-BBxpNfvC.js → just-DcVeN_MN.js} +1 -1
  96. package/dist/assets/web/dist/assets/{latex-Bw9yg5mr.js → latex-BNNoF-WP.js} +1 -1
  97. package/dist/assets/web/dist/assets/{liquid-BRCjaydE.js → liquid-DAi6qHzn.js} +1 -1
  98. package/dist/assets/web/dist/assets/localIso-L06jV29p.js +1 -0
  99. package/dist/assets/web/dist/assets/{lua-DUu2KIC0.js → lua-BswS0axw.js} +1 -1
  100. package/dist/assets/web/dist/assets/main-B3is4sk6.js +28 -0
  101. package/dist/assets/web/dist/assets/main-DP_zSemY.css +1 -0
  102. package/dist/assets/web/dist/assets/{marko-CZJZIlSQ.js → marko-C-4hkmQZ.js} +1 -1
  103. package/dist/assets/web/dist/assets/{mdc-BmClq6fd.js → mdc-0ZaHUkS7.js} +1 -1
  104. package/dist/assets/web/dist/assets/{nginx-Bmt3xSEm.js → nginx-DBDg5tOR.js} +1 -1
  105. package/dist/assets/web/dist/assets/{nim-Da-i0utK.js → nim-BVMz49Yw.js} +1 -1
  106. package/dist/assets/web/dist/assets/{org-BvAre2qP.js → org-1hO_K6ya.js} +1 -1
  107. package/dist/assets/web/dist/assets/{perl-v9WwFFtB.js → perl-CVRQLvVk.js} +1 -1
  108. package/dist/assets/web/dist/assets/{php-DTU4Zyze.js → php-K9nCrB9n.js} +1 -1
  109. package/dist/assets/web/dist/assets/{pug-DhTwEqTO.js → pug-CTxOcmO3.js} +1 -1
  110. package/dist/assets/web/dist/assets/{qml-BUD9AC_c.js → qml-CmCkfG5q.js} +1 -1
  111. package/dist/assets/web/dist/assets/{r-CAnu56n7.js → r-pP47Xn1X.js} +1 -1
  112. package/dist/assets/web/dist/assets/{razor-Dn0P4gAq.js → razor-BpW6r3tC.js} +1 -1
  113. package/dist/assets/web/dist/assets/{regexp-CZyBRaVW.js → regexp-CINgdY4N.js} +1 -1
  114. package/dist/assets/web/dist/assets/{rst-Ckyd5UsY.js → rst-BZIUEq2Q.js} +1 -1
  115. package/dist/assets/web/dist/assets/{ruby-BBQ62fsx.js → ruby-nzAOxz6r.js} +1 -1
  116. package/dist/assets/web/dist/assets/{sas-CW35hMxG.js → sas-QIS1bFth.js} +1 -1
  117. package/dist/assets/web/dist/assets/{scss-CpV8pF4J.js → scss-BuhBBVNt.js} +1 -1
  118. package/dist/assets/web/dist/assets/settingsApi-priOPiwu.js +1 -0
  119. package/dist/assets/web/dist/assets/{shellscript-CRJvsmE1.js → shellscript-CQc1vXbk.js} +1 -1
  120. package/dist/assets/web/dist/assets/{shellsession-CyriUZxf.js → shellsession-CoubCAUv.js} +1 -1
  121. package/dist/assets/web/dist/assets/{soy-D5QzYUeg.js → soy-CPRzlder.js} +1 -1
  122. package/dist/assets/web/dist/assets/{sql-CUBf_Mse.js → sql-W9krb8-9.js} +1 -1
  123. package/dist/assets/web/dist/assets/sseReplay-yji8a1wM.js +9 -0
  124. package/dist/assets/web/dist/assets/{stata-B-4n4ITz.js → stata-CuISJEC0.js} +1 -1
  125. package/dist/assets/web/dist/assets/{surrealql-RuWZBLtr.js → surrealql-CSet7584.js} +1 -1
  126. package/dist/assets/web/dist/assets/{svelte-CO5YzCsK.js → svelte-ChXTSYwK.js} +1 -1
  127. package/dist/assets/web/dist/assets/{templ-DbkWVaBF.js → templ-Bm55v62k.js} +1 -1
  128. package/dist/assets/web/dist/assets/{tex-IuxoEcId.js → tex-CICMX8Gj.js} +1 -1
  129. package/dist/assets/web/dist/assets/{ts-tags-g6dGQPjd.js → ts-tags-CA1UzWyB.js} +1 -1
  130. package/dist/assets/web/dist/assets/{tsx-WnXwiyH2.js → tsx-Dy04HNbv.js} +1 -1
  131. package/dist/assets/web/dist/assets/{twig-Lx1G7PnY.js → twig-BBHsVnVD.js} +1 -1
  132. package/dist/assets/web/dist/assets/{typescript-BQd7biLF.js → typescript-Dvc-wVBT.js} +1 -1
  133. package/dist/assets/web/dist/assets/{typst-D2lXPHK7.js → typst-Dvlqx3_q.js} +1 -1
  134. package/dist/assets/web/dist/assets/{vue-html-DZPaRte4.js → vue-html-D4YxT3An.js} +1 -1
  135. package/dist/assets/web/dist/assets/{vue-vine-DbmZCmu-.js → vue-vine-0YE7uQlH.js} +1 -1
  136. package/dist/assets/web/dist/assets/{vue-4wVQD9y9.js → vue-zWi1MNU2.js} +1 -1
  137. package/dist/assets/web/dist/assets/{xml-DV16szyo.js → xml-gm-iksZB.js} +1 -1
  138. package/dist/assets/web/dist/assets/{xsl-eo5-1sk0.js → xsl-D_W5BcrY.js} +1 -1
  139. package/dist/assets/web/dist/assets/{yaml-BXdX8HZf.js → yaml-CHmZ21wZ.js} +1 -1
  140. package/dist/cli.js +6014 -2362
  141. package/package.json +1 -1
  142. package/dist/assets/web/dist/assets/AppShell-MYHNxJks.js +0 -1
  143. package/dist/assets/web/dist/assets/CostsPage-DKk9OjB0.js +0 -2
  144. package/dist/assets/web/dist/assets/DeliveryPage-Kz6js49i.js +0 -1
  145. package/dist/assets/web/dist/assets/NotFoundPage-BXLrQQzy.js +0 -1
  146. package/dist/assets/web/dist/assets/ResidentsIndexPage-BdW0k2h3.js +0 -1
  147. package/dist/assets/web/dist/assets/RunFoldRow-CbTd9SiS.js +0 -9
  148. package/dist/assets/web/dist/assets/RunRoutePage-br0H-8Pz.js +0 -6
  149. package/dist/assets/web/dist/assets/RunsIndexPage-Bnzb15zH.js +0 -1
  150. package/dist/assets/web/dist/assets/ScheduledPage-NLhjzHAz.js +0 -1
  151. package/dist/assets/web/dist/assets/SettingsPage-D-i0uVw-.js +0 -1
  152. package/dist/assets/web/dist/assets/UnitRoutePage-DGXukEAH.js +0 -1
  153. package/dist/assets/web/dist/assets/durationTone-CYMux25J.js +0 -1
  154. package/dist/assets/web/dist/assets/favicon-CSt-qDvc.js +0 -1
  155. package/dist/assets/web/dist/assets/main-CFHF2RvQ.css +0 -1
  156. package/dist/assets/web/dist/assets/main-Dht0in4v.js +0 -28
@@ -156,6 +156,7 @@ import {
156
156
  type WatchdogSummary,
157
157
  } from "../../src/core/schedules.js";
158
158
  import type { ResidentLifecycleState } from "../../src/execution/residentState.js";
159
+ import { RestoreWaiters } from "../../src/execution/restoreWaiters.js";
159
160
  import {
160
161
  decisivePull,
161
162
  effectiveLimits,
@@ -195,6 +196,7 @@ import {
195
196
  type RestoreSample,
196
197
  type RefreshFailure,
197
198
  } from "../../src/execution/residentRefresh.js";
199
+ import { autoRebuildDecision, isAutoRebuildEligible } from "../../src/execution/residentAutoRebuild.js";
198
200
  import {
199
201
  lifecycleOf,
200
202
  parseLifecycle,
@@ -347,7 +349,7 @@ const BUILD_ID = buildId(BUILD);
347
349
  // route is a `resident.fetch` root at the edge. Each joins the bot's trace when
348
350
  // the request carried one. The tracer and its log sink are shared.ts's: the
349
351
  // refresh instance's root (refresh.ts) starts from the same pair.
350
- const STREAMED_ROUTES: ReadonlySet<string> = new Set(["/attach", "/exec", "/op"]);
352
+ const STREAMED_ROUTES: ReadonlySet<string> = new Set(["/attach", "/exec", "/op", "/await-restore"]);
351
353
 
352
354
  /** One request as the resident's own root: started at its t0, joining the
353
355
  * bot's trace when `traceparent` parses, the collector's steps grafted as
@@ -591,18 +593,12 @@ function isNonEvidenceReason(reason: string): boolean {
591
593
  * 503 {state, reason: "mirror-busy"} instead of queueing forever. */
592
594
  const ATTACH_MUTEX_WAIT_MS = 60_000;
593
595
 
594
- /** Watchdog auto-rebuild: a resident down with a REHYDRATION-flavored
595
- * reason (bad/unreadable snapshots — states only a rebuild can escape, since
596
- * down chains never retry hydration) accumulates one strike per watchdog
597
- * pass; at N strikes the watchdog triggers the same down→onboarding rebuild
598
- * an admin would, discarding the unusable snapshots and reprovisioning from
599
- * GitHub. Provision-failure downs never auto-rebuild — they would loop
600
- * against the same broken build. With the 10-minute cron, N=3 ≈ 30 minutes
601
- * down before the automatic escape hatch fires. `runtime-unreachable` is the
602
- * ladder's last rung (item 64): a recreated container did not answer either,
603
- * so the rebuild — destroy plus reprovision — is the only exit left. */
604
- const AUTO_REBUILD_AFTER_STRIKES = 3;
605
- const REHYDRATION_FAILURE_RE = /^(r2-restore-failed|snapshot-stamp-mismatch|no-snapshot|runtime-unreachable)/;
596
+ // Auto-rebuild (item 36): a resident `down` on a reason only a rebuild can
597
+ // escape (REHYDRATION_FAILURE_RE, `residentAutoRebuild.ts`) is rebuilt on the
598
+ // `goDown` transition itself, under `AUTO_REBUILD_BUDGET` per window; the
599
+ // watchdog is the backstop for a transition that could not act (no registry
600
+ // record at the time). Provision-failure downs never auto-rebuild — they would
601
+ // loop against the same broken build.
606
602
 
607
603
  /** /exec budget: the shared 5-minute default (`BASH_TIMEOUT_MS`);
608
604
  * a caller may raise it per call via the body's `timeoutMs` up to the shared
@@ -1237,6 +1233,19 @@ interface DepsBackupRecord {
1237
1233
  createdAt: string;
1238
1234
  }
1239
1235
 
1236
+ /** The snapshot as `/status` and `/debug info` publish it: the stamp, both
1237
+ * archive ids, and the deps entry archive for its lockfile key (null until
1238
+ * one has been taken). */
1239
+ interface SnapshotHandle {
1240
+ ref: string;
1241
+ sha: string;
1242
+ lockfileHash: string;
1243
+ createdAt: string;
1244
+ mirrorBackupId: string;
1245
+ checkoutBackupId: string;
1246
+ depsBackupId: string | null;
1247
+ }
1248
+
1240
1249
  /** What the snapshot step answers: the record already at the stamp, a record
1241
1250
  * it committed (with the one it replaced), or a step another writer won. */
1242
1251
  type SnapshotStepResult =
@@ -1447,7 +1456,11 @@ const UPDATED_KEY = "resident:updatedAt";
1447
1456
  const FACTS_KEY = "resident:facts";
1448
1457
  const SNAPSHOT_KEY = "resident:snapshot";
1449
1458
  const DEADLINE_AT_KEY = "resident:provisionDeadlineAt";
1450
- const REBUILD_STRIKES_KEY = "resident:rebuildStrikes"; // watchdog auto-rebuild counter
1459
+ /** ISO instants of this resident's auto-rebuilds (item 36): the budget's window
1460
+ * is judged over it; a manual rebuild or an offboard clears it. */
1461
+ const AUTO_REBUILDS_KEY = "resident:autoRebuilds";
1462
+ /** The strike counter the watchdog kept before the transition rebuilt (item 36); a serving pass deletes a leftover row. */
1463
+ const RETIRED_REBUILD_STRIKES_KEY = "resident:rebuildStrikes";
1451
1464
  /** Consecutive connects the container's control port did not answer (item 64): the ladder's count. */
1452
1465
  const RUNTIME_UNREACHABLE_KEY = "resident:runtimeUnreachable";
1453
1466
 
@@ -1731,6 +1744,12 @@ export class ResidentDO extends Sandbox<Env> {
1731
1744
  private incarnation = mintIncarnationId();
1732
1745
  private leaseSeq = 0;
1733
1746
 
1747
+ /** Item 27: the held /await-restore requests, answered by the lifecycle
1748
+ * transition out of `restoring` (`setResidentState`). In-memory on purpose:
1749
+ * a DO restart drops the held RPCs with the ledger and the bot's own
1750
+ * deadline names the fallback. */
1751
+ private restoreWaiters = new RestoreWaiters();
1752
+
1734
1753
  private nextHolder(): string {
1735
1754
  return `${this.incarnation}:${++this.leaseSeq}`;
1736
1755
  }
@@ -2527,8 +2546,19 @@ export class ResidentDO extends Sandbox<Env> {
2527
2546
  } catch (err) {
2528
2547
  // The typed and cause-chain check first: the SDK's replacement errors
2529
2548
  // (a stale process handle, an inactive runtime identity, an interrupted
2530
- // operation) carry the wording one cause down or not at all.
2531
- const disposition = restoreFailureDisposition(errMsg(err), { runtimeReplaced: isRuntimeReplacement(err) });
2549
+ // operation) carry the wording one cause down or not at all. An extract
2550
+ // the container roll SIGTERMed is different: its exec comes back a
2551
+ // normal `exit 143` result and the SDK's wording only reaches the execs
2552
+ // that follow — so the disposition also reads the kill signature, and
2553
+ // asks the runtime whether the container the extract wrote to is still
2554
+ // there (the incident that named this: a resident went `down` on exactly
2555
+ // this, 10 ms before every exec answered "container is not running").
2556
+ const runtimeReplaced = isRuntimeReplacement(err);
2557
+ const runtimeActive = runtimeReplaced ? false : await this.isRuntimeActive().catch(() => null);
2558
+ const disposition = restoreFailureDisposition(errMsg(err), {
2559
+ runtimeReplaced,
2560
+ runtimeActive: runtimeActive ?? undefined,
2561
+ });
2532
2562
  if (disposition.action === "interrupted") {
2533
2563
  // The runtime was replaced under the restore (a resident Worker deploy
2534
2564
  // rolled the container): the disk the stream wrote to is gone with the
@@ -2544,7 +2574,10 @@ export class ResidentDO extends Sandbox<Env> {
2544
2574
  console.log(`restore: interrupted by a runtime replacement — ${disposition.reason.slice(0, 400)}`);
2545
2575
  await this.recordRefreshError(disposition.reason);
2546
2576
  await this.setResidentState("degraded", disposition.reason);
2547
- throw err;
2577
+ // The reason travels on the error: the instance step's classifier
2578
+ // reads `restore-interrupted:` as an interruption whether the verdict
2579
+ // came from the SDK's wording, the kill signature or the probe.
2580
+ throw new StepError(err instanceof StepError ? err.step : "restore", disposition.reason);
2548
2581
  }
2549
2582
  // A stalled or capped restore is STILL STREAMING (the SDK call cannot be
2550
2583
  // cancelled); `pendingRestores` keeps the next hydrate off its directory,
@@ -2600,9 +2633,62 @@ export class ResidentDO extends Sandbox<Env> {
2600
2633
  * instance: the cron's decision reads the state). */
2601
2634
  private async goDown(reason: string): Promise<ResidentDownError> {
2602
2635
  await this.setResidentState("down", reason);
2636
+ // Item 36: the transition is the trigger — a rehydration down is rebuilt
2637
+ // now (the rebuild arms provisioning's own schedule and returns), not
2638
+ // after three watchdog passes. The error still says `down`: the caller's
2639
+ // step ends the way it always did, and the rebuild's provisioning owns the
2640
+ // disk from its callback on. A failure inside the judgement is logged, not
2641
+ // thrown: the caller's contract is the ResidentDownError, and the state is
2642
+ // already `down`, where the watchdog's backstop pass finds it.
2643
+ await this.autoRebuildFromDown(reason, "transition").catch((err) =>
2644
+ console.log(`auto-rebuild (transition): failed — ${errMsg(err)}; the watchdog's pass is the backstop`),
2645
+ );
2603
2646
  return new ResidentDownError(reason);
2604
2647
  }
2605
2648
 
2649
+ /** Item 36: judge a `down` for the automatic rebuild and act. `rebuilt` when
2650
+ * the rebuild started (state is `onboarding` now); `budget-spent` when the
2651
+ * resident stays down with the budget stamped on its reason; `none` for a
2652
+ * reason a rebuild cannot help, one already stamped, or a registry record
2653
+ * missing (an offboard mid-flight — the watchdog's pass is the backstop).
2654
+ * Both callers — the transition and the watchdog — read and write the one
2655
+ * history row, so they cannot disagree about the budget. */
2656
+ private async autoRebuildFromDown(
2657
+ reason: string,
2658
+ where: "transition" | "watchdog",
2659
+ ): Promise<"rebuilt" | "budget-spent" | "none"> {
2660
+ if (!isAutoRebuildEligible(reason)) return "none";
2661
+ const history = (await this.ctx.storage.get<string[]>(AUTO_REBUILDS_KEY)) ?? [];
2662
+ const decision = autoRebuildDecision({ reason, history, now: systemClock() });
2663
+ if (decision.action === "not-eligible") return "none";
2664
+ if (decision.action === "budget-spent") {
2665
+ console.log(`auto-rebuild (${where}): ${decision.reason.slice(0, 400)}`);
2666
+ await this.ctx.storage.put(AUTO_REBUILDS_KEY, decision.history);
2667
+ await this.setResidentState("down", decision.reason);
2668
+ return "budget-spent";
2669
+ }
2670
+ const resource = (await this.ctx.storage.get<string>(RESOURCE_KEY)) ?? "";
2671
+ const record = await this.registry()
2672
+ .getRecord(resource)
2673
+ .catch(() => null);
2674
+ if (!record) {
2675
+ console.log(`auto-rebuild (${where}): no registry record for the resident — left down (${reason.slice(0, 200)})`);
2676
+ return "none";
2677
+ }
2678
+ console.log(`auto-rebuild (${where}): ${decision.reason.slice(0, 400)}`);
2679
+ const result = await this.rebuild(resource, record.defaultRef, record.provisioningTimeoutMs, false, {
2680
+ auto: true,
2681
+ });
2682
+ if ("error" in result) {
2683
+ console.log(`auto-rebuild (${where}): rebuild refused — ${result.error}`);
2684
+ return "none";
2685
+ }
2686
+ // The instant is recorded once the rebuild has started: a refused rebuild
2687
+ // spends no budget.
2688
+ await this.ctx.storage.put(AUTO_REBUILDS_KEY, decision.history);
2689
+ return "rebuilt";
2690
+ }
2691
+
2606
2692
  private async recordRefreshError(message: string): Promise<void> {
2607
2693
  const facts = await this.ctx.storage.get<RepoFacts>(FACTS_KEY);
2608
2694
  if (facts) await this.ctx.storage.put(FACTS_KEY, { ...facts, lastRefreshError: residentText(message) });
@@ -3383,8 +3469,8 @@ export class ResidentDO extends Sandbox<Env> {
3383
3469
  await this.recreateContainer(reason);
3384
3470
  throw err;
3385
3471
  case "down":
3386
- // A fresh VM did not answer either. Down with a strike-eligible reason
3387
- // (REHYDRATION_FAILURE_RE) — the watchdog rebuilds after its passes —
3472
+ // A fresh VM did not answer either. Down with a rebuild-eligible reason
3473
+ // (REHYDRATION_FAILURE_RE) — goDown rebuilds on the transition, item 36 —
3388
3474
  // and the VM destroyed, so the rebuild's provisioning starts on a new one.
3389
3475
  this.swapIncarnation(); // deliberate incarnation swap
3390
3476
  await this.destroy().catch((destroyErr) =>
@@ -4281,8 +4367,8 @@ export class ResidentDO extends Sandbox<Env> {
4281
4367
 
4282
4368
  /** One watchdog pass over this resident (invoked by the Worker cron): time
4283
4369
  * out an onboarding stuck past its budget → down(provision-timeout) + cap
4284
- * slot release; auto-rebuild a resident stuck down on unusable snapshots
4285
- * (one strike per pass, rebuild at AUTO_REBUILD_AFTER_STRIKES); name a
4370
+ * slot release; the backstop for item 36's auto-rebuild (a rehydration
4371
+ * down the transition could not act on — same decision, same row); name a
4286
4372
  * `refreshing`/`restoring` marker older than STALE_MIDFLIGHT_MS that no
4287
4373
  * live lease and no running instance stands behind —
4288
4374
  * `degraded(stale-mid-flight: …)` carrying the last instance's id and the
@@ -4324,28 +4410,26 @@ export class ResidentDO extends Sandbox<Env> {
4324
4410
  return { resource, ...status, action: "none" };
4325
4411
  }
4326
4412
  if (status.state === "down") {
4327
- // Auto-rebuild escape hatch: only rehydration-flavored downs — the
4328
- // snapshots themselves are the problem, and a down resident runs no
4329
- // cycle, so without this the resident would stay down forever.
4330
- if (REHYDRATION_FAILURE_RE.test(status.reason)) {
4331
- const strikes = ((await this.ctx.storage.get<number>(REBUILD_STRIKES_KEY)) ?? 0) + 1;
4332
- if (strikes >= AUTO_REBUILD_AFTER_STRIKES) {
4333
- const record = await this.registry()
4334
- .getRecord(resource)
4335
- .catch(() => null);
4336
- if (record) {
4337
- const reason = `auto-rebuild: down for ${strikes} watchdog passes (${status.reason})`;
4338
- await this.rebuild(resource, record.defaultRef, record.provisioningTimeoutMs, false);
4339
- return { resource, state: "onboarding", reason, action: "auto-rebuilt" };
4340
- }
4341
- }
4342
- await this.ctx.storage.put(REBUILD_STRIKES_KEY, strikes);
4413
+ // Item 36's backstop: the transition rebuilt (or stamped the budget)
4414
+ // when it fired; a rehydration down still here on a pass is one the
4415
+ // transition could not act on — a registry record missing then, or a
4416
+ // down from before the transition rebuilt. Same decision, same history
4417
+ // row, no strikes to accumulate.
4418
+ if ((await this.autoRebuildFromDown(status.reason, "watchdog")) === "rebuilt") {
4419
+ return {
4420
+ resource,
4421
+ state: "onboarding",
4422
+ reason: `auto-rebuild (watchdog): ${status.reason}`,
4423
+ action: "auto-rebuilt",
4424
+ };
4343
4425
  }
4344
- return { resource, ...status, action: "none" };
4426
+ return { resource, ...(await this.getStatus()), action: "none" };
4345
4427
  }
4346
- // Any serving state clears accumulated strikes (a recovery must reset the
4347
- // counter, or an unrelated later down inherits stale strikes).
4348
- await this.ctx.storage.delete(REBUILD_STRIKES_KEY);
4428
+ // The auto-rebuild history is NOT cleared by a serving state: the budget
4429
+ // counts rebuilds inside a window, so a resident that comes back `warm`
4430
+ // and goes `down` again is judged with its earlier rebuilds in view. The
4431
+ // strike counter the watchdog kept before is a retired row.
4432
+ await this.ctx.storage.delete(RETIRED_REBUILD_STRIKES_KEY);
4349
4433
 
4350
4434
  // A mid-flight state older than STALE_MIDFLIGHT_MS with no cycle or restore
4351
4435
  // actually running is a marker orphaned by a cycle that died — a DO evicted
@@ -6911,6 +6995,29 @@ export class ResidentDO extends Sandbox<Env> {
6911
6995
  [REASON_KEY]: residentText(reason),
6912
6996
  [UPDATED_KEY]: new Date(systemClock()).toISOString(),
6913
6997
  });
6998
+ // Item 27: the transition OUT of `restoring` is the restore event — it
6999
+ // answers every held /await-restore request with the state landed on.
7000
+ if (state !== "restoring") this.restoreWaiters.publish({ state, reason: residentText(reason) });
7001
+ }
7002
+
7003
+ /** POST /await-restore (docs/reference/specs/execution.md item 27): answer at
7004
+ * once when the state is anything but `restoring`; otherwise hold the ONE
7005
+ * request on the waiter ledger until `setResidentState` leaves `restoring`
7006
+ * and publishes. No polling and no retry timer on either side. */
7007
+ async awaitRestore(): Promise<{ state: string; reason: string }> {
7008
+ // The waiter goes on the ledger BEFORE the state is read: the read awaits
7009
+ // storage, and a transition that lands in that gap would publish to a
7010
+ // ledger this request is not on yet and hold it for a transition that may
7011
+ // never come (the bot's deadline would then turn a short wait cold). A
7012
+ // state already out of `restoring` answers at once and withdraws the waiter.
7013
+ const held = this.restoreWaiters.hold();
7014
+ const status = await this.getStatus();
7015
+ if (status.state !== "restoring") {
7016
+ held.withdraw();
7017
+ return { state: status.state, reason: status.reason };
7018
+ }
7019
+ const outcome = await held.outcome;
7020
+ return { state: outcome.state, reason: outcome.reason };
6914
7021
  }
6915
7022
 
6916
7023
  async getStatus(): Promise<ResidentStatus> {
@@ -6925,6 +7032,26 @@ export class ResidentDO extends Sandbox<Env> {
6925
7032
  /** Full admin-facing view (surfaced via GET /residents and /debug info):
6926
7033
  * lifecycle + recorded sha/cache keys/snapshot stamp/refresh telemetry.
6927
7034
  * Backup handles are reduced to ids — never the raw handle internals. */
7035
+ /** The stamped snapshot as a handle (docs/reference/specs/resident-repos.md
7036
+ * item 7): the stamp, both archive ids, and the deps-store entry archive for
7037
+ * the snapshot's own lockfile key when one has been taken (item 61). What
7038
+ * `/status` publishes for the seed (execution.md item 25 — a seeded sandbox
7039
+ * restores the checkout and the deps view from it) and what `/debug info`
7040
+ * shows; null before the first snapshot. */
7041
+ async snapshotHandle(snap?: SnapshotRecord): Promise<SnapshotHandle | null> {
7042
+ const record = snap ?? (await this.ctx.storage.get<SnapshotRecord>(SNAPSHOT_KEY));
7043
+ if (!record) return null;
7044
+ return {
7045
+ ref: record.ref,
7046
+ sha: record.sha,
7047
+ lockfileHash: record.lockfileHash,
7048
+ createdAt: record.createdAt,
7049
+ mirrorBackupId: record.mirror.id,
7050
+ checkoutBackupId: record.checkout.id,
7051
+ depsBackupId: (await this.depsBackupRecord(record.lockfileHash))?.backup.id ?? null,
7052
+ };
7053
+ }
7054
+
6928
7055
  async getResidentInfo(): Promise<Record<string, unknown>> {
6929
7056
  const map = await this.ctx.storage.get<unknown>([
6930
7057
  RESOURCE_KEY,
@@ -6940,6 +7067,7 @@ export class ResidentDO extends Sandbox<Env> {
6940
7067
  LIFECYCLE_KEY,
6941
7068
  REFRESH_INSTANCE_KEY,
6942
7069
  RUNTIME_UNREACHABLE_KEY,
7070
+ AUTO_REBUILDS_KEY,
6943
7071
  ]);
6944
7072
  const facts = map.get(FACTS_KEY) as RepoFacts | undefined;
6945
7073
  const snap = map.get(SNAPSHOT_KEY) as SnapshotRecord | undefined;
@@ -7005,21 +7133,7 @@ export class ResidentDO extends Sandbox<Env> {
7005
7133
  lastRefreshError: facts?.lastRefreshError ?? null,
7006
7134
  lastRestore: facts?.lastRestore ?? null,
7007
7135
  idleSince: facts?.idleSince ?? null,
7008
- snapshot: snap
7009
- ? {
7010
- ref: snap.ref,
7011
- sha: snap.sha,
7012
- lockfileHash: snap.lockfileHash,
7013
- createdAt: snap.createdAt,
7014
- mirrorBackupId: snap.mirror.id,
7015
- checkoutBackupId: snap.checkout.id,
7016
- // The seed handle's other half (docs/reference/specs/execution.md item 25):
7017
- // the deps-store entry archive for the snapshot's own lockfile key,
7018
- // when one has been taken (item 61) — a seeded sandbox restores it
7019
- // beside the checkout and skips the install.
7020
- depsBackupId: (await this.depsBackupRecord(snap.lockfileHash))?.backup.id ?? null,
7021
- }
7022
- : null,
7136
+ snapshot: await this.snapshotHandle(snap),
7023
7137
  // The provisioning schedules, the one timer a resident has (item 3).
7024
7138
  schedules: {
7025
7139
  provisionRun: provisionRun.length,
@@ -7056,6 +7170,9 @@ export class ResidentDO extends Sandbox<Env> {
7056
7170
  // Item 64: consecutive connects the control port did not answer, with
7057
7171
  // the rung that count is on; null while the port answers.
7058
7172
  runtimeUnreachable: unreachable ? { ...unreachable, rung: runtimeUnreachableRung(unreachable.count) } : null,
7173
+ // Item 36: the auto-rebuilds this resident has had (ISO instants; the
7174
+ // budget's window is judged over them). Empty after a person's rebuild.
7175
+ autoRebuilds: (map.get(AUTO_REBUILDS_KEY) as string[] | undefined) ?? [],
7059
7176
  };
7060
7177
  }
7061
7178
 
@@ -7138,12 +7255,17 @@ export class ResidentDO extends Sandbox<Env> {
7138
7255
  return { recreated: true, restoreStartedAt };
7139
7256
  }
7140
7257
 
7141
- /** Fault injection for the watchdog's auto-rebuild path: persist
7142
- * `down` with a rehydration-flavored reason (what a real goDown does), so
7143
- * repeated watchdog passes can strike it up to the auto-rebuild without
7144
- * corrupting real R2 objects. Test-only semantics; admin scope. */
7145
- async debugForceDown(reason: string): Promise<ResidentStatus> {
7146
- await this.setResidentState("down", reason);
7258
+ /** Fault injection for item 36 without corrupting real R2 objects. Bare: persist
7259
+ * `down` with a rehydration-flavored reason and nothing else, so the next
7260
+ * watchdog pass is the backstop under test. `transition`: go through
7261
+ * `goDown` itself — the rebuild (or the budget stamp) happens in this call,
7262
+ * the way the wake path's own down does. Test-only semantics; admin scope. */
7263
+ async debugForceDown(reason: string, transition: boolean): Promise<ResidentStatus> {
7264
+ if (transition) {
7265
+ await this.goDown(reason);
7266
+ } else {
7267
+ await this.setResidentState("down", reason);
7268
+ }
7147
7269
  return this.getStatus();
7148
7270
  }
7149
7271
 
@@ -7159,6 +7281,8 @@ export class ResidentDO extends Sandbox<Env> {
7159
7281
  defaultRef: string,
7160
7282
  provisioningTimeoutMs: number,
7161
7283
  dryRun: boolean,
7284
+ /** `auto`: item 36's rebuild, which keeps the auto-rebuild history (the budget's count); a person's rebuild clears it. */
7285
+ opts: { auto?: boolean } = {},
7162
7286
  ): Promise<
7163
7287
  | {
7164
7288
  resource: string;
@@ -7225,7 +7349,11 @@ export class ResidentDO extends Sandbox<Env> {
7225
7349
  // best effort — the 1-year R2 TTL is the leak backstop
7226
7350
  }
7227
7351
  }
7228
- await this.ctx.storage.delete(REBUILD_STRIKES_KEY);
7352
+ // A person's rebuild resets the auto-rebuild budget (item 36): the
7353
+ // history is what stops a flapping resident, and a person asking for a
7354
+ // rebuild has seen it. The automatic one keeps the history — it IS the
7355
+ // budget's count.
7356
+ if (!opts.auto) await this.ctx.storage.delete(AUTO_REBUILDS_KEY);
7229
7357
  // From scratch means a fresh container too: the SDK's runtime identity
7230
7358
  // forgotten and the VM destroyed (SIGKILL, a fresh disk) before
7231
7359
  // provisioning clones onto it. A rebuild that reprovisioned onto the
@@ -7541,6 +7669,7 @@ const ROUTES: Record<string, { scope: Scope; method: string }> = {
7541
7669
  "/read": { scope: "operator", method: "POST" },
7542
7670
  "/write": { scope: "operator", method: "POST" },
7543
7671
  "/op": { scope: "operator", method: "POST" },
7672
+ "/await-restore": { scope: "operator", method: "POST" },
7544
7673
  };
7545
7674
 
7546
7675
  /** Per-resource R2 prefix for future resident cache objects; offboard deletes
@@ -7656,6 +7785,8 @@ export default {
7656
7785
  return await handleWrite(env, body);
7657
7786
  case "/op":
7658
7787
  return await handleOp(env, body, traceparent);
7788
+ case "/await-restore":
7789
+ return await handleAwaitRestore(env, body);
7659
7790
  default:
7660
7791
  return json({ error: "unknown route" }, 404);
7661
7792
  }
@@ -8092,8 +8223,9 @@ async function handleStatus(env: Env, url: URL): Promise<Response> {
8092
8223
  const resource = parseResource(url.searchParams.get("resource"));
8093
8224
  if ("error" in resource) return json({ error: resource.error }, 400);
8094
8225
 
8095
- // Body deliberately limited to { state, reason, inFlight } — operator scope
8096
- // sees lifecycle and activity, not config.
8226
+ // Body deliberately limited to lifecycle, activity and the snapshot handle
8227
+ // — operator scope sees what a run needs (the seed's handle, execution.md
8228
+ // item 25: opaque archive ids, useless without the bucket), not config.
8097
8229
  // Three RPCs in one flight, not one atomic snapshot: getStatus() awaits
8098
8230
  // storage, and the DO may run other work in that gap, so `state`/`reason`
8099
8231
  // and `inFlight` can be a hair apart (and differ slightly from a /residents
@@ -8101,21 +8233,24 @@ async function handleStatus(env: Env, url: URL): Promise<Response> {
8101
8233
  // deploy gate reads /residents. The registry check rides in the same flight
8102
8234
  // (its 404 is judged first, the probes' results discarded then).
8103
8235
  const stub = residentStub(env, resource.resource);
8104
- const [record, status, inFlight, refresh] = await Promise.all([
8236
+ const [record, status, inFlight, refresh, snapshot] = await Promise.all([
8105
8237
  registryStub(env).getRecord(resource.resource),
8106
8238
  stub.getStatus(),
8107
8239
  stub.getInFlightCount(),
8108
8240
  stub.getRefreshView(),
8241
+ stub.snapshotHandle(),
8109
8242
  ]);
8110
8243
  if (!record) return json({ error: `${resource.resource} is not onboarded` }, 404);
8111
8244
  // Item 7: which scheduler drives the refresh cycle and, on the Workflow
8112
- // lifecycle, the current instance with its last step and the last skipped bucket.
8245
+ // lifecycle, the current instance with its last step and the last skipped
8246
+ // bucket; and the snapshot handle a seeded sandbox restores from (item 25).
8113
8247
  return json({
8114
8248
  state: status.state,
8115
8249
  reason: status.reason,
8116
8250
  inFlight,
8117
8251
  lifecycle: refresh.lifecycle,
8118
8252
  refresh: { instance: refresh.instance, skipped: refresh.skipped },
8253
+ snapshot,
8119
8254
  });
8120
8255
  }
8121
8256
 
@@ -8210,6 +8345,24 @@ async function handleAttach(env: Env, body: Record<string, unknown>, traceparent
8210
8345
  );
8211
8346
  }
8212
8347
 
8348
+ /** POST /await-restore (docs/reference/specs/execution.md item 27): the bot's
8349
+ * one held request while the resident restores. Held server-side by the DO's
8350
+ * waiter ledger; the answer streams like /attach (heartbeat whitespace then
8351
+ * ONE JSON document over HTTP 200) so a restore lasting minutes cannot lose
8352
+ * the connection. A pre-route Worker answers 404 (unknown route), which the
8353
+ * bot reads as "this Worker predates the route" and falls back cold. */
8354
+ async function handleAwaitRestore(env: Env, body: Record<string, unknown>): Promise<Response> {
8355
+ const resource = parseResource(body.resource);
8356
+ if ("error" in resource) return json({ error: resource.error }, 400);
8357
+ const record = await registryStub(env).getRecord(resource.resource);
8358
+ if (!record) return json({ error: `${resource.resource} is not onboarded` }, 404);
8359
+ return streamHeartbeatJson(
8360
+ residentStub(env, resource.resource).awaitRestore(),
8361
+ (result) => result,
8362
+ (err) => ({ error: errMsg(err), status: 500 }),
8363
+ );
8364
+ }
8365
+
8213
8366
  async function handleDetach(env: Env, body: Record<string, unknown>): Promise<Response> {
8214
8367
  const ctx = await resolveThreadRoute(env, body);
8215
8368
  if (ctx instanceof Response) return ctx;
@@ -8492,11 +8645,12 @@ async function handleDebug(env: Env, body: Record<string, unknown>): Promise<Res
8492
8645
  case "force-onboarding":
8493
8646
  return json(await stub.debugForceOnboarding());
8494
8647
  case "force-down": {
8495
- // Fault injection for the watchdog auto-rebuild path; a
8496
- // rehydration-flavored default reason makes it strike-eligible.
8648
+ // Fault injection for item 36; a rehydration-flavored default reason
8649
+ // makes it eligible. `transition: true` fires the down through goDown
8650
+ // (the rebuild happens now); bare, the watchdog's next pass is the backstop.
8497
8651
  const reason =
8498
8652
  typeof body.reason === "string" && body.reason ? body.reason : "r2-restore-failed: injected (debug force-down)";
8499
- return json(await stub.debugForceDown(reason));
8653
+ return json(await stub.debugForceDown(reason, body.transition === true));
8500
8654
  }
8501
8655
  case "threads":
8502
8656
  return json(await stub.debugThreads());
@@ -38,7 +38,13 @@
38
38
  "name": "SWITCHBOARD_INGRESS_TOKENS",
39
39
  "workers": ["bot"],
40
40
  "optional": true,
41
- "note": "JSON bearer→identity map for POST /ingress and POST /mcp. Self-minted (openssl rand -hex 32 per entry). Optional: without it /ingress and /mcp are disabled and `deploy restart` has no bearer. The `cron` entry fires schedules; a `coordinator` entry (granted `coordinator:step`) is the ship coordinator's, rotated apart from cron."
41
+ "note": "JSON bearer→identity map for POST /ingress and POST /mcp: `{ subject, channel?, email? }` per entry. Self-minted (openssl rand -hex 32 per entry). Optional: without it /ingress and /mcp are disabled and `deploy restart` has no bearer. The `cron` entry fires schedules; a `coordinator` entry (granted `coordinator:step`) is the ship coordinator's, rotated apart from cron. An `email` binds the token to a person: their runs, their grants untouched (authorization.md item 15)."
42
+ },
43
+ {
44
+ "name": "GITHUB_WEBHOOK_SECRET",
45
+ "workers": ["bot"],
46
+ "optional": true,
47
+ "note": "GitHub webhook signing secret for POST /webhooks/github (the check-run intake). Set the same value on the repo's webhook. Optional: without it the intake answers 503 disabled and the merge wait falls back to its bounded timeout."
42
48
  },
43
49
  {
44
50
  "name": "SANDBOX_TOKEN",