@coreplane/switchboard 1.240.0 → 1.242.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 (146) hide show
  1. package/dist/assets/config/config.example.yaml +40 -8
  2. package/dist/assets/deploy/cloudflare/worker.ts +77 -18
  3. package/dist/assets/deploy/cloudflare-resident/worker.ts +198 -15
  4. package/dist/assets/deploy/cloudflare-sandbox/worker.ts +38 -5
  5. package/dist/assets/deploy/secrets.manifest.json +6 -0
  6. package/dist/assets/package-lock.json +3 -3
  7. package/dist/assets/package.json +1 -1
  8. package/dist/assets/project.json +2 -2
  9. package/dist/assets/source.json +3 -3
  10. package/dist/assets/src/agents/registry.ts +58 -22
  11. package/dist/assets/src/core/authz/actor.ts +17 -9
  12. package/dist/assets/src/core/authz/authorize.ts +17 -4
  13. package/dist/assets/src/core/authz/grants.ts +20 -9
  14. package/dist/assets/src/core/authz/types.ts +12 -0
  15. package/dist/assets/src/core/coordinator/contract.ts +40 -1
  16. package/dist/assets/src/core/coordinator/driver.ts +17 -1
  17. package/dist/assets/src/core/coordinator/instancesRoute.ts +72 -0
  18. package/dist/assets/src/core/costs.ts +22 -9
  19. package/dist/assets/src/core/harness/scope.ts +21 -0
  20. package/dist/assets/src/core/runEvents.ts +20 -1
  21. package/dist/assets/src/core/runLedger/types.ts +2 -0
  22. package/dist/assets/src/core/runRecord.ts +4 -0
  23. package/dist/assets/src/core/ship/contract.ts +4 -2
  24. package/dist/assets/src/core/ship/coordinator.ts +22 -7
  25. package/dist/assets/src/core/trace/attrs.ts +1 -1
  26. package/dist/assets/src/execution/bindingPurge.ts +2 -2
  27. package/dist/assets/src/execution/residentAutoRebuild.ts +4 -2
  28. package/dist/assets/src/execution/residentHead.ts +23 -10
  29. package/dist/assets/src/execution/residentInfraStreak.ts +89 -0
  30. package/dist/assets/src/execution/residentRefresh.ts +15 -1
  31. package/dist/assets/src/execution/residentSteps.ts +1 -0
  32. package/dist/assets/src/execution/sandboxErrors.ts +6 -0
  33. package/dist/assets/src/execution/seedPlan.ts +17 -0
  34. package/dist/assets/web/dist/.vite/manifest.json +435 -439
  35. package/dist/assets/web/dist/assets/AppShell-IDMGG6yi.js +1 -0
  36. package/dist/assets/web/dist/assets/{CostsPage-BD3w5j_S.js → CostsPage-CtmKhhOF.js} +2 -2
  37. package/dist/assets/web/dist/assets/{DeliveryPage-BtvzKA1m.js → DeliveryPage-n1tz5I_v.js} +1 -1
  38. package/dist/assets/web/dist/assets/HomePage-QtH1EwYF.js +2 -0
  39. package/dist/assets/web/dist/assets/{NotFoundPage-DwOX1HaZ.js → NotFoundPage-C97EhJcQ.js} +1 -1
  40. package/dist/assets/web/dist/assets/PendingTurnRow-BZA_vQt3.js +1 -0
  41. package/dist/assets/web/dist/assets/{ResidentDetailPage-CUAOh5d9.js → ResidentDetailPage-DTMBgnIW.js} +1 -1
  42. package/dist/assets/web/dist/assets/{ResidentsIndexPage-B3KUlYy2.js → ResidentsIndexPage-CaDvXhzJ.js} +1 -1
  43. package/dist/assets/web/dist/assets/RunFoldRow-mgyLW0oV.js +1 -0
  44. package/dist/assets/web/dist/assets/RunRoutePage-DypJYMQa.js +6 -0
  45. package/dist/assets/web/dist/assets/RunsIndexPage-DYraPoWD.js +1 -0
  46. package/dist/assets/web/dist/assets/{RunsTabs-BnkvCBrM.js → RunsTabs-BUfdk0lH.js} +1 -1
  47. package/dist/assets/web/dist/assets/{ScheduledPage-C1y9jgAk.js → ScheduledPage-DXD2gLJk.js} +1 -1
  48. package/dist/assets/web/dist/assets/SettingsPage-BIGio8Y0.js +1 -0
  49. package/dist/assets/web/dist/assets/{StatusDot-CiByEtoR.js → StatusDot-ELoXHlFt.js} +1 -1
  50. package/dist/assets/web/dist/assets/{Tooltip-DyRxk8A8.js → Tooltip-BoeFwYP2.js} +1 -1
  51. package/dist/assets/web/dist/assets/UnitRoutePage-jhCrWW3i.js +1 -0
  52. package/dist/assets/web/dist/assets/{angular-html-DAQ_1T1v.js → angular-html-oBNfPJR0.js} +1 -1
  53. package/dist/assets/web/dist/assets/{angular-ts-D4kxRVp1.js → angular-ts-BvNwsyWA.js} +1 -1
  54. package/dist/assets/web/dist/assets/{apl-Dy07l64y.js → apl-CNUdRlYf.js} +1 -1
  55. package/dist/assets/web/dist/assets/{astro-DZIbTlc7.js → astro-Zb0NriSe.js} +1 -1
  56. package/dist/assets/web/dist/assets/{blade-CmSXWX3l.js → blade-qPRVheqq.js} +1 -1
  57. package/dist/assets/web/dist/assets/{c-ggzVdJLK.js → c-D8Awx4YO.js} +1 -1
  58. package/dist/assets/web/dist/assets/{chapel-Bo1wktsG.js → chapel-Bt72Mhsx.js} +1 -1
  59. package/dist/assets/web/dist/assets/{cobol-DJUmtKM2.js → cobol-BOBacexg.js} +1 -1
  60. package/dist/assets/web/dist/assets/{coffee-BZWOVz4C.js → coffee-E4u0liHW.js} +1 -1
  61. package/dist/assets/web/dist/assets/{cpp-Dtk39yMw.js → cpp-q2sLNlul.js} +1 -1
  62. package/dist/assets/web/dist/assets/{crystal-CGf_-Zoj.js → crystal-DyWqUnlb.js} +1 -1
  63. package/dist/assets/web/dist/assets/{css-CX2fcL4u.js → css-CQY0hFsD.js} +1 -1
  64. package/dist/assets/web/dist/assets/{dist-D8TaC961.js → dist-BU5UivXC.js} +2 -2
  65. package/dist/assets/web/dist/assets/durationTone-BobbycC-.js +1 -0
  66. package/dist/assets/web/dist/assets/{edge-BYS8FtgQ.js → edge-C1MwhJkX.js} +1 -1
  67. package/dist/assets/web/dist/assets/{elixir-fhUpi3nO.js → elixir-Bb3YbHfn.js} +1 -1
  68. package/dist/assets/web/dist/assets/{elm-DAQNiwsN.js → elm-DAN9IGQw.js} +1 -1
  69. package/dist/assets/web/dist/assets/{erb-CWzEys6U.js → erb-BEB8Xlsj.js} +1 -1
  70. package/dist/assets/web/dist/assets/{git-rebase-9rY3p-Rw.js → git-rebase-tqpjRfxO.js} +1 -1
  71. package/dist/assets/web/dist/assets/{glimmer-js-tY7hy0nE.js → glimmer-js-Ccbo65zR.js} +1 -1
  72. package/dist/assets/web/dist/assets/{glimmer-ts-CTrLv5JF.js → glimmer-ts-B6WBVMpE.js} +1 -1
  73. package/dist/assets/web/dist/assets/{glsl-BRJbiDfq.js → glsl-tRec3Fcu.js} +1 -1
  74. package/dist/assets/web/dist/assets/{graphql-B-MtVLh2.js → graphql-P8kbxT4F.js} +1 -1
  75. package/dist/assets/web/dist/assets/{hack-BtIkOypQ.js → hack-H9Zhkagy.js} +1 -1
  76. package/dist/assets/web/dist/assets/{haml-CoOH6gfa.js → haml-DtEnpn7Z.js} +1 -1
  77. package/dist/assets/web/dist/assets/{handlebars-DgrRu-GP.js → handlebars-DkgPfoAz.js} +1 -1
  78. package/dist/assets/web/dist/assets/{html-DZaaI7ca.js → html-D30RXpIs.js} +1 -1
  79. package/dist/assets/web/dist/assets/{html-derivative-DgHvT-qZ.js → html-derivative-DwozLrEx.js} +1 -1
  80. package/dist/assets/web/dist/assets/{http-CvAd51yz.js → http-DXuzBAPm.js} +1 -1
  81. package/dist/assets/web/dist/assets/{hurl-CXGqAfzx.js → hurl-d1UUJIt_.js} +1 -1
  82. package/dist/assets/web/dist/assets/{indexRow-Cse3JGb0.js → indexRow-BD1VT8o8.js} +1 -1
  83. package/dist/assets/web/dist/assets/{java-CIQStIXF.js → java-DL0gWf34.js} +1 -1
  84. package/dist/assets/web/dist/assets/{javascript-WbH8s7E3.js → javascript-Cl7vavnS.js} +1 -1
  85. package/dist/assets/web/dist/assets/{jinja-DzyWEVKC.js → jinja-CfQOWMX9.js} +1 -1
  86. package/dist/assets/web/dist/assets/{jison-CrT-zCa-.js → jison-KdYirlqm.js} +1 -1
  87. package/dist/assets/web/dist/assets/{json-lHjpjXMQ.js → json-C9cDQ-Qj.js} +1 -1
  88. package/dist/assets/web/dist/assets/{jsx-WTwYCHW6.js → jsx-CRx5NItd.js} +1 -1
  89. package/dist/assets/web/dist/assets/{julia-Bqpk9H7E.js → julia-CmsQsZQl.js} +1 -1
  90. package/dist/assets/web/dist/assets/{just-LdXk1r3m.js → just-CsM3Q8TE.js} +1 -1
  91. package/dist/assets/web/dist/assets/{latex-h_y2A5KD.js → latex-BXCh5YRX.js} +1 -1
  92. package/dist/assets/web/dist/assets/{liquid-CY7bF2_p.js → liquid-BF2vwK8p.js} +1 -1
  93. package/dist/assets/web/dist/assets/{lua-oQ7XVZOY.js → lua-DcMBATrl.js} +1 -1
  94. package/dist/assets/web/dist/assets/main-B2fX10aW.css +1 -0
  95. package/dist/assets/web/dist/assets/{main-CGO6fQ8k.js → main-D4EA1g6n.js} +2 -2
  96. package/dist/assets/web/dist/assets/{marko-zv_qZ4QV.js → marko-88MndKvG.js} +1 -1
  97. package/dist/assets/web/dist/assets/{mdc-Dl4kL_YK.js → mdc-DJ4kVAd8.js} +1 -1
  98. package/dist/assets/web/dist/assets/{nginx-CAHCZj4F.js → nginx-CP6mRgtV.js} +1 -1
  99. package/dist/assets/web/dist/assets/{nim-BOtmwCJH.js → nim-QQfj3fpF.js} +1 -1
  100. package/dist/assets/web/dist/assets/{org-DspQk3HQ.js → org-Bke3eBzc.js} +1 -1
  101. package/dist/assets/web/dist/assets/{perl-CAXmvVl0.js → perl-Bkh0N7Kz.js} +1 -1
  102. package/dist/assets/web/dist/assets/{php-CMUx4VyM.js → php-Dnv1Piya.js} +1 -1
  103. package/dist/assets/web/dist/assets/{pug-B55xVJTv.js → pug-D6peFFI7.js} +1 -1
  104. package/dist/assets/web/dist/assets/{qml-DsNKCCRk.js → qml-D8PEs-C-.js} +1 -1
  105. package/dist/assets/web/dist/assets/{r-DbhP0VvO.js → r-B1EL9b_j.js} +1 -1
  106. package/dist/assets/web/dist/assets/{razor-BCpE6P-p.js → razor-BsMTIh2b.js} +1 -1
  107. package/dist/assets/web/dist/assets/{regexp-BllsjiN4.js → regexp-HhvC8spD.js} +1 -1
  108. package/dist/assets/web/dist/assets/{rst-CO5Rpq9V.js → rst-D5paAxpg.js} +1 -1
  109. package/dist/assets/web/dist/assets/{ruby-C2A4OgQq.js → ruby-BrQwhLrl.js} +1 -1
  110. package/dist/assets/web/dist/assets/{sas-Butzoyql.js → sas-afot2B1o.js} +1 -1
  111. package/dist/assets/web/dist/assets/{scss-PEUwJBpf.js → scss-Efm-mwuG.js} +1 -1
  112. package/dist/assets/web/dist/assets/{shellscript-3Yhq0khE.js → shellscript-BK0Vv5fT.js} +1 -1
  113. package/dist/assets/web/dist/assets/{shellsession-04Hkb7m3.js → shellsession-BGqlMC7N.js} +1 -1
  114. package/dist/assets/web/dist/assets/{soy-BnId73cP.js → soy-M0b4UwGM.js} +1 -1
  115. package/dist/assets/web/dist/assets/{sql-BKwdc0ch.js → sql-sxe6IE9j.js} +1 -1
  116. package/dist/assets/web/dist/assets/sseReplay-g7ml86LM.js +9 -0
  117. package/dist/assets/web/dist/assets/{stata-BPPtNkay.js → stata-nPF_ddLP.js} +1 -1
  118. package/dist/assets/web/dist/assets/{surrealql-CDPChcYP.js → surrealql-D_GrC6u7.js} +1 -1
  119. package/dist/assets/web/dist/assets/{svelte-B15DIQ-z.js → svelte-DwL1AtNP.js} +1 -1
  120. package/dist/assets/web/dist/assets/{templ-D-rp_AiB.js → templ-7s7LTDkc.js} +1 -1
  121. package/dist/assets/web/dist/assets/{tex-BnVDCD7p.js → tex-Bx-5fMxe.js} +1 -1
  122. package/dist/assets/web/dist/assets/{ts-tags-cpujdyAR.js → ts-tags-DUMJnke_.js} +1 -1
  123. package/dist/assets/web/dist/assets/{tsx-D1vO4L72.js → tsx-BN8biPDe.js} +1 -1
  124. package/dist/assets/web/dist/assets/{twig-oQGnQGRc.js → twig-8nIu84TN.js} +1 -1
  125. package/dist/assets/web/dist/assets/{typescript-CKSFpXiH.js → typescript-BooSPq_S.js} +1 -1
  126. package/dist/assets/web/dist/assets/{typst-DKHFo8BF.js → typst-CTBiBsem.js} +1 -1
  127. package/dist/assets/web/dist/assets/{vue-BstokpiS.js → vue-C6Ft4Lea.js} +1 -1
  128. package/dist/assets/web/dist/assets/{vue-html-DpCp0_Sz.js → vue-html-Ba36dD5D.js} +1 -1
  129. package/dist/assets/web/dist/assets/{vue-vine-y5d5wUb1.js → vue-vine-NkFexVo2.js} +1 -1
  130. package/dist/assets/web/dist/assets/{xml-Br2hx5Ax.js → xml-C_THnHXZ.js} +1 -1
  131. package/dist/assets/web/dist/assets/{xsl-Bc8Cobap.js → xsl-D8G5xqjY.js} +1 -1
  132. package/dist/assets/web/dist/assets/{yaml-LOITy_I1.js → yaml-jAMIJzge.js} +1 -1
  133. package/dist/cli.js +4632 -817
  134. package/package.json +1 -1
  135. package/dist/assets/web/dist/assets/AppShell-CM_fh-WW.js +0 -1
  136. package/dist/assets/web/dist/assets/HomePage-BU6aRCSe.js +0 -2
  137. package/dist/assets/web/dist/assets/RunFoldRow-Dulcbg25.js +0 -1
  138. package/dist/assets/web/dist/assets/RunRoutePage-DGoGrQZq.js +0 -6
  139. package/dist/assets/web/dist/assets/RunsIndexPage-37W7OZbC.js +0 -1
  140. package/dist/assets/web/dist/assets/SettingsPage-ggfB-Rqr.js +0 -1
  141. package/dist/assets/web/dist/assets/SlackMark-VDNs7Vjh.js +0 -1
  142. package/dist/assets/web/dist/assets/Slideover-CX-LEklF.js +0 -1
  143. package/dist/assets/web/dist/assets/UnitRoutePage-CnWyOizV.js +0 -1
  144. package/dist/assets/web/dist/assets/durationTone-DXG-3R7_.js +0 -1
  145. package/dist/assets/web/dist/assets/main-O8UXRuEv.css +0 -1
  146. package/dist/assets/web/dist/assets/sseReplay-DutHNghE.js +0 -9
@@ -100,10 +100,16 @@ defaults:
100
100
  # maxMinutes: 45
101
101
  # maxIdentity: read
102
102
 
103
- # Static per-user defaults (user ID -> scope).
103
+ # Static per-user defaults (user ID -> scope). A user's `harness` puts that
104
+ # person's runs of a preset on another harness (docs/reference/specs/harness.md
105
+ # item 8) — every run of it they request, in any channel, and nobody else's; the
106
+ # same words as the top-level `harness` block, which they override (a channel's
107
+ # `harness` sits between). From chat: `config set me --harness.coding opencode`.
104
108
  # users:
105
109
  # slack:U012345:
106
110
  # model: openai/gpt-5
111
+ # harness:
112
+ # coding: opencode
107
113
 
108
114
  # Authorization (docs/reference/specs/authorization.md item 9, docs/reference/authorization.md):
109
115
  # two blocks. `restrict` names what is CLOSED unless granted — an agent listed
@@ -327,13 +333,25 @@ workspaceDir: ./workspaces
327
333
  # # a finding at or above it sends the round into a fix step instead of merge-ready.
328
334
  # # Per channel/user: config set … --ship.addressSeverity; per run: severity:<level>.
329
335
 
330
- # `harness` is a retired key (docs/reference/specs/harness-pi.md item 1). It
331
- # moved the presets onto pi one at a time; every preset runs on pi now — the pi
332
- # coding agent started inside the run's own execution container (or as a child
333
- # of the bot for a preset without a workspace) with the run's model-proxy
334
- # bearer as its only key — and there is no other loop. A block written during
335
- # the series still loads when every value is `pi`; `native` names the deleted
336
- # loop and fails the load by name, as does an unknown preset. Remove the block.
336
+ # Which harness each preset's runs are driven by, deployment-wide
337
+ # (docs/reference/specs/harness.md item 8): a mapping of preset to a harness's
338
+ # name. Two exist — `pi` (the pi coding agent, the default for every preset)
339
+ # and `opencode` (OpenCode's server, `opencode serve`) — each started inside
340
+ # the run's own execution container (or as a child of the bot for a preset
341
+ # without a workspace) with the run's model-proxy bearer as its only key, every
342
+ # tool call decided in the bot, every event on the run's record. This block is
343
+ # the defaults layer of the per-scope `harness` setting: a channel's or a
344
+ # user's `harness` (above, or `config set channel|me --harness.<preset> …`)
345
+ # overrides it for the runs in that scope, user over channel over here. A
346
+ # preset no layer names runs on pi; nothing defaults to OpenCode. A run keeps
347
+ # the harness it started on, so changing a word moves the next run, never one
348
+ # in flight. Any other word (`native`, the deleted loop; `codex`) fails the
349
+ # load naming the two, as does an unknown preset, in every layer; a
350
+ # `defaults.harness` key is refused pointing here. How-to:
351
+ # docs/how-to/put-a-preset-on-opencode.md.
352
+ # harness:
353
+ # coding: opencode # coding runs on OpenCode
354
+ # review: pi # the default, written out
337
355
 
338
356
  # What the harness writes into pi's per-run settings for every run on pi
339
357
  # (docs/reference/specs/harness-pi.md item 4): pi's compaction thresholds, in
@@ -349,6 +367,18 @@ workspaceDir: ./workspaces
349
367
  # reserveTokens: 150000
350
368
  # keepRecentTokens: 8000
351
369
 
370
+ # What the harness writes into OpenCode's per-run configuration for every run
371
+ # on OpenCode (docs/reference/specs/harness.md item 8): OpenCode's compaction
372
+ # thresholds, in tokens, under OpenCode's own names — `buffer`, the tokens kept
373
+ # free of the model's window before a compaction runs; `keepTokens`, how much
374
+ # of the newest turns it keeps. Unset, OpenCode's defaults stand and the file
375
+ # names no compaction. The OpenCode twin of the `pi` block above; positive
376
+ # integers only, any other shape or key fails the load by name.
377
+ # opencode:
378
+ # compaction:
379
+ # buffer: 20000
380
+ # keepTokens: 8000
381
+
352
382
  # The fan-out cap a spawning run meets (docs/reference/specs/agent-conductor.md).
353
383
  # `agent:conductor` starts child runs as the person who asked — each an
354
384
  # ordinary run in a thread of its own, under that person's permissions — and
@@ -392,6 +422,8 @@ workspaceDir: ./workspaces
392
422
  # snapshot: # the page serves a snapshot of both billing sources, never a live read;
393
423
  # everyHours: 24 # how often the bot reads them (1–168, default 24). `costs snapshot`
394
424
  # # takes one now. Kept on the state Worker any `*.worker` block names
425
+ # alertChannel: slack:C0123ABCD # optional: a line there after three failed takes in a row, and one
426
+ # # when a take lands again; otherwise only the page's status line says
395
427
  # groups:
396
428
  # myapp:
397
429
  # label: My App
@@ -17,8 +17,11 @@ import {
17
17
  createInstanceResponse,
18
18
  instanceStatusResponse,
19
19
  isInstanceNotFound,
20
+ parseInstanceEventPath,
20
21
  parseInstanceStatusPath,
21
22
  parseCreateInstanceRequest,
23
+ parseSendEventRequest,
24
+ sendEventResponse,
22
25
  parseSubjectAuthorization,
23
26
  type CreateInstanceOutcome,
24
27
  } from "../../src/core/coordinator/instancesRoute.ts";
@@ -87,6 +90,7 @@ export interface Env {
87
90
  DASHBOARD_TOKEN?: string; // dashboard auth `token` strategy: the bearer (the default env name; config may name another)
88
91
  SWITCHBOARD_INGRESS_TOKENS?: string; // enables HTTP /ingress + MCP /mcp (JSON token→identity map); the `cron` entry is what scheduled runs present; an entry whose `http:<subject>` actor holds `deploy:write` in the bot's config may POST /admin/restart
89
92
  BRAVE_SEARCH_API_KEY?: string; // web_search backend (Brave); web_fetch works without it
93
+ GITHUB_WEBHOOK_SECRET?: string; // check-run intake: signs POST /webhooks/github; absent, the intake answers 503 disabled
90
94
  CF_ANALYTICS_TOKEN?: string; // costs dash: Cloudflare API token, Account Analytics:Read only
91
95
  ANTHROPIC_ADMIN_KEY?: string; // costs dash (optional): Anthropic Admin API key for the LLM cost report
92
96
  MEMORY_TOKEN?: string; // durable memory + friction ledger + schedule firings + MCP registry: bearer for the state Worker
@@ -125,6 +129,7 @@ const FORWARDED_OPTIONAL = [
125
129
  "ANTHROPIC_ADMIN_KEY",
126
130
  "SWITCHBOARD_INGRESS_TOKENS",
127
131
  "BRAVE_SEARCH_API_KEY",
132
+ "GITHUB_WEBHOOK_SECRET",
128
133
  "MEMORY_TOKEN",
129
134
  "MCP_CREDENTIAL_KEY",
130
135
  "MCP_ACCESS_CLIENT_ID",
@@ -407,6 +412,57 @@ async function handleCoordinatorInstanceStatus(request: Request, env: Env, id: s
407
412
  return json(res.status, res.body);
408
413
  }
409
414
 
415
+ /** `POST /admin/coordinator/instances/<id>/events` — the shim's event relay
416
+ * (docs/reference/specs/http-ingress.md item 12): the bot's check-run intake
417
+ * holds no Workflow binding, so its `checks-settled-<head>` send crosses here
418
+ * and this Worker's own `SHIP_COORDINATOR` binding delivers it. The same door
419
+ * as the create and the status: the bearer in the map, the bot's `authorize`
420
+ * answer. The engine's `instance.not_found` is 404; any other refusal (an
421
+ * instance that already ended) is 502 by reason — the sender treats both as a
422
+ * failed send and the driver's bounded wait is the fallback. */
423
+ async function handleCoordinatorInstanceEvent(request: Request, env: Env, id: string): Promise<Response> {
424
+ const json = (status: number, body: Record<string, unknown>) =>
425
+ new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
426
+ if (request.method !== "POST")
427
+ return json(405, {
428
+ ok: false,
429
+ error: `method not allowed: ${request.method} — ${COORDINATOR_INSTANCES_PATH}/<id>/events answers POST only`,
430
+ });
431
+ const authorization = request.headers.get("authorization") ?? undefined;
432
+ const authn = authenticateIngressBearer(authorization, env.SWITCHBOARD_INGRESS_TOKENS, "coordinator");
433
+ if (!authn.ok) {
434
+ console.warn(`[coordinator] instance event ${authn.status} — ${authn.reason}`);
435
+ return json(authn.status, { ok: false, error: authn.reason });
436
+ }
437
+ let answer: Response;
438
+ try {
439
+ answer = await getContainer(env.SWITCHBOARD, INSTANCE).fetch(
440
+ new Request(`${INTERNAL}${COORDINATOR_AUTHORIZE_PATH}`, {
441
+ method: "POST",
442
+ headers: { authorization: authorization ?? "" },
443
+ }),
444
+ );
445
+ } catch (err) {
446
+ const reason = err instanceof Error ? err.message : String(err);
447
+ return json(503, { ok: false, error: `coordinator disabled: the bot could not be asked (${reason})` });
448
+ }
449
+ const auth = parseSubjectAuthorization(answer.status, await answer.text().catch(() => ""));
450
+ if (!auth.ok) return json(auth.status, { ok: false, error: auth.reason });
451
+ const parsed = parseSendEventRequest(await request.text().catch(() => ""));
452
+ if (!parsed.ok) return json(400, { ok: false, error: parsed.reason });
453
+ let outcome: Parameters<typeof sendEventResponse>[0];
454
+ try {
455
+ await (await env.SHIP_COORDINATOR.get(id)).sendEvent({ type: parsed.type, payload: parsed.payload });
456
+ outcome = { kind: "sent", id };
457
+ } catch (err) {
458
+ const reason = err instanceof Error ? err.message : String(err);
459
+ outcome = isInstanceNotFound(reason) ? { kind: "absent", id } : { kind: "failed", id, reason };
460
+ }
461
+ const res = sendEventResponse(outcome);
462
+ console.log(`[coordinator] ${auth.subject} → instance ${id} event ${parsed.type}: ${outcome.kind}`);
463
+ return json(res.status, res.body);
464
+ }
465
+
410
466
  /** Record a firing on the state Worker's ScheduleDO (the /runs Scheduled panel
411
467
  * reads it). Best-effort: a failure here is a log line — the run itself (if
412
468
  * any) already happened and is its own record. */
@@ -440,29 +496,32 @@ export default {
440
496
  // The routes the Worker answers itself — the restart, the coordinator's
441
497
  // instance creation and an instance's status, all over bindings only this
442
498
  // Worker holds; everything else is the container's.
443
- const statusId = parseInstanceStatusPath(pathname);
499
+ const eventsId = parseInstanceEventPath(pathname);
500
+ const statusId = eventsId === undefined ? parseInstanceStatusPath(pathname) : undefined;
444
501
  const res =
445
502
  pathname === "/admin/restart"
446
503
  ? await handleAdminRestart(forwarded, env)
447
504
  : pathname === COORDINATOR_INSTANCES_PATH
448
505
  ? await handleCoordinatorInstances(forwarded, env)
449
- : statusId !== undefined
450
- ? await handleCoordinatorInstanceStatus(forwarded, env, statusId)
451
- : pathname === COPY_PATH
452
- ? // The artifact copy (artifactsCopy.ts): the R2 binding and the Slack
453
- // token are this Worker's; the bot only asks, with its copy bearer.
454
- // `forwarded`, never `inbound`: `withTraceContext` rebuilt the request
455
- // with `new Request(inbound, …)`, which takes the body stream with it —
456
- // `inbound.text()` is empty afterwards and the route read "not JSON" live.
457
- await handleArtifactsCopy(forwarded, {
458
- bucket: env.ARTIFACTS,
459
- bucketName: env.ARTIFACTS_BUCKET_NAME,
460
- copyToken: env.ARTIFACTS_COPY_TOKEN,
461
- slackToken: env.SLACK_BOT_TOKEN,
462
- fetch: (input, init) => fetch(input, init),
463
- lengthPipe: (size) => new FixedLengthStream(size),
464
- })
465
- : withLength(await getContainer(env.SWITCHBOARD, INSTANCE).fetch(forwarded));
506
+ : eventsId !== undefined
507
+ ? await handleCoordinatorInstanceEvent(forwarded, env, eventsId)
508
+ : statusId !== undefined
509
+ ? await handleCoordinatorInstanceStatus(forwarded, env, statusId)
510
+ : pathname === COPY_PATH
511
+ ? // The artifact copy (artifactsCopy.ts): the R2 binding and the Slack
512
+ // token are this Worker's; the bot only asks, with its copy bearer.
513
+ // `forwarded`, never `inbound`: `withTraceContext` rebuilt the request
514
+ // with `new Request(inbound, …)`, which takes the body stream with it —
515
+ // `inbound.text()` is empty afterwards and the route read "not JSON" live.
516
+ await handleArtifactsCopy(forwarded, {
517
+ bucket: env.ARTIFACTS,
518
+ bucketName: env.ARTIFACTS_BUCKET_NAME,
519
+ copyToken: env.ARTIFACTS_COPY_TOKEN,
520
+ slackToken: env.SLACK_BOT_TOKEN,
521
+ fetch: (input, init) => fetch(input, init),
522
+ lengthPipe: (size) => new FixedLengthStream(size),
523
+ })
524
+ : withLength(await getContainer(env.SWITCHBOARD, INSTANCE).fetch(forwarded));
466
525
  root.end(res.status >= 500 ? "error" : "ok", { httpStatus: res.status });
467
526
  return res;
468
527
  } catch (err) {
@@ -197,6 +197,14 @@ import {
197
197
  type RefreshFailure,
198
198
  } from "../../src/execution/residentRefresh.js";
199
199
  import { autoRebuildDecision, isAutoRebuildEligible } from "../../src/execution/residentAutoRebuild.js";
200
+ import {
201
+ INFRA_STREAK_DOWN_AT,
202
+ infraStreakReason,
203
+ infraStreakRung,
204
+ type InfraStreakRow,
205
+ isRepoCommandStep,
206
+ parksOnRepeat,
207
+ } from "../../src/execution/residentInfraStreak.js";
200
208
  import {
201
209
  lifecycleOf,
202
210
  parseLifecycle,
@@ -544,6 +552,8 @@ const GITHUB_API_TIMEOUT_MS = 10_000;
544
552
  * idle-park like a warm one; the next attach still refreshes first. */
545
553
  const DEGRADED_PARK_AFTER_CYCLES = 3;
546
554
  const DEGRADED_STREAK_KEY = "resident:degradedStreak";
555
+ /** Consecutive cycles failed in the resident's OWN steps (item 67): the ladder's count, `InfraStreakRow`. */
556
+ const INFRA_STREAK_KEY = "resident:infraStreak";
547
557
  /** Degraded reasons that are not evidence about the repository — both always
548
558
  * carry a `: detail` suffix: the watchdog's `stale-mid-flight: …` (a marker
549
559
  * a dead cycle left behind; a cycle must run) and the wake path's
@@ -561,7 +571,7 @@ const DEGRADED_STREAK_KEY = "resident:degradedStreak";
561
571
  * re-enters the step. A control port that did not answer
562
572
  * (`runtime-unreachable: …`, item 64) is the third: no command ran, the
563
573
  * ladder over its own persisted count owns the recovery. */
564
- const NON_EVIDENCE_REASON = /^(?:stale-mid-flight|restore-interrupted|runtime-unreachable):/;
574
+ const NON_EVIDENCE_REASON = /^(?:stale-mid-flight|restore-interrupted|runtime-unreachable|infra-streak):/;
565
575
  /** When the disk-full recovery last stopped the container (docs/reference/specs/resident-repos.md item 54):
566
576
  * feeds `planDiskFullRecovery`'s cooldown so a working set that refills the
567
577
  * disk is named, not recycled in a loop. */
@@ -1479,6 +1489,17 @@ const threadBindingKey = (threadKey: string) => `${THREAD_KEY_PREFIX}${threadKey
1479
1489
 
1480
1490
  const parentDir = (p: string): string => p.slice(0, p.lastIndexOf("/"));
1481
1491
 
1492
+ /** A run's registration, held from its attach to its release (item 44): a
1493
+ * harness run's process lives in the container BETWEEN the bot's operator
1494
+ * calls, so the in-memory op counters read 0 while it runs. Durable rows —
1495
+ * a fresh isolate still counts the process that survived in the container. */
1496
+ const RUN_REG_KEY_PREFIX = "runReg:";
1497
+ const runRegKey = (threadKey: string) => `${RUN_REG_KEY_PREFIX}${threadKey}`;
1498
+ interface RunRegistration {
1499
+ threadKey: string;
1500
+ registeredAt: string;
1501
+ }
1502
+
1482
1503
  /** Deterministic per-thread+ref worktree path. Slugs replace anything outside
1483
1504
  * [A-Za-z0-9._-]; a threadKey-derived hash suffix keeps two keys that slug
1484
1505
  * identically (e.g. "a:b" vs "a-b") from colliding on disk. */
@@ -3099,10 +3120,12 @@ export class ResidentDO extends Sandbox<Env> {
3099
3120
  }
3100
3121
  }
3101
3122
  let settled = entry.state === "warm";
3102
- if (entry.state === "degraded" && !isNonEvidenceReason(entry.reason)) {
3123
+ if (entry.state === "degraded" && !isNonEvidenceReason(entry.reason) && parksOnRepeat(entry.reason)) {
3103
3124
  // 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.
3125
+ // reason that is evidence about the repo or GitHub (github-unreachable,
3126
+ // a repo-command step's <step>-failed); a stable streak means retrying
3127
+ // is not going to help and parking is the right cost behavior. A
3128
+ // resident-step failure never parks: item 67's ladder is its escalation.
3106
3129
  // Any other state resets the streak (below).
3107
3130
  const prev = await this.ctx.storage.get<{ reason: string; count: number }>(DEGRADED_STREAK_KEY);
3108
3131
  const streak =
@@ -3317,6 +3340,8 @@ export class ResidentDO extends Sandbox<Env> {
3317
3340
  // "idle since" and every attach would take the wake-fetch path).
3318
3341
  delete updatedFacts.idleSince;
3319
3342
  await this.ctx.storage.put(FACTS_KEY, updatedFacts);
3343
+ // A completed cycle ends any resident-step streak (item 67).
3344
+ await this.ctx.storage.delete(INFRA_STREAK_KEY);
3320
3345
  await this.setResidentState("warm");
3321
3346
  // Event-triggered reclamation: the prune above already told the
3322
3347
  // mirror which branches died; finished refs give their worktree and
@@ -3370,8 +3395,57 @@ export class ResidentDO extends Sandbox<Env> {
3370
3395
  private async refreshFailed(failure: RefreshFailure, selfInFlight: number): Promise<void> {
3371
3396
  console.log(`refresh: cycle failed — ${failure.reason.slice(0, 400)}`);
3372
3397
  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);
3398
+ if (failure.diskFull) {
3399
+ await this.setResidentState("degraded", failure.reason);
3400
+ await this.recoverFromDiskFull(failure.reason, selfInFlight);
3401
+ return;
3402
+ }
3403
+ // Item 67: a failure in the repository's own command is evidence about the
3404
+ // repo — degraded, and the gate's park streak decides; it also ends any
3405
+ // resident-step streak, since our machinery got that far. A failure in
3406
+ // the resident's own steps climbs the ladder over the persisted count.
3407
+ if (isRepoCommandStep(failure.step)) {
3408
+ await this.ctx.storage.delete(INFRA_STREAK_KEY);
3409
+ await this.setResidentState("degraded", failure.reason);
3410
+ return;
3411
+ }
3412
+ const row = await this.noteInfraStreak(failure.step);
3413
+ switch (infraStreakRung(row.count)) {
3414
+ case "count":
3415
+ await this.setResidentState("degraded", failure.reason);
3416
+ return;
3417
+ case "recreate":
3418
+ // The disk, not the repo: destroy the container, snapshots kept; the
3419
+ // next cycle's wake restores mirror, checkout and deps from R2.
3420
+ await this.recreateContainer(infraStreakReason(row, "recreate"));
3421
+ return;
3422
+ case "down": {
3423
+ // A recreated container failed the same way: the snapshot or the
3424
+ // resident is what is wrong, and a rebuild from GitHub is the exit —
3425
+ // item 36's transition, the VM destroyed first so provisioning starts
3426
+ // on a fresh one.
3427
+ const reason = infraStreakReason(row, "down");
3428
+ this.swapIncarnation(); // deliberate incarnation swap
3429
+ await this.forgetRuntimeIdentity();
3430
+ await this.destroy().catch((err) => console.log(`infra-streak: destroy failed: ${errMsg(err)}`));
3431
+ await this.ctx.storage.delete(INFRA_STREAK_KEY);
3432
+ await this.goDown(reason);
3433
+ return;
3434
+ }
3435
+ }
3436
+ }
3437
+
3438
+ /** One more cycle failed in the resident's own steps: the count up by one,
3439
+ * `firstAt` kept, the step and `lastAt` now. */
3440
+ private async noteInfraStreak(step: string): Promise<InfraStreakRow> {
3441
+ const prev = await this.ctx.storage.get<InfraStreakRow>(INFRA_STREAK_KEY);
3442
+ const now = new Date(systemClock()).toISOString();
3443
+ const row: InfraStreakRow = { step, count: (prev?.count ?? 0) + 1, firstAt: prev?.firstAt ?? now, lastAt: now };
3444
+ await this.ctx.storage.put(INFRA_STREAK_KEY, row);
3445
+ console.log(
3446
+ `infra-streak: cycle ${row.count} of ${INFRA_STREAK_DOWN_AT} failed in the resident's own step ${step} (first at ${row.firstAt})`,
3447
+ );
3448
+ return row;
3375
3449
  }
3376
3450
 
3377
3451
  // -- the runtime that never answers (docs/reference/specs/resident-repos.md item 64) ------
@@ -3494,7 +3568,7 @@ export class ResidentDO extends Sandbox<Env> {
3494
3568
  * restore, for the reason `escalateRuntimeUnreachable` gives. */
3495
3569
  private async recreateContainer(reason: string): Promise<void> {
3496
3570
  console.log(
3497
- `runtime-unreachable: destroying the container — snapshots kept; the next exec restores from R2 (${reason.slice(0, 200)})`,
3571
+ `recreate: destroying the container — snapshots kept; the next exec restores from R2 (${reason.slice(0, 200)})`,
3498
3572
  );
3499
3573
  this.swapIncarnation(); // deliberate incarnation swap
3500
3574
  await this.forgetRuntimeIdentity();
@@ -4687,7 +4761,7 @@ export class ResidentDO extends Sandbox<Env> {
4687
4761
  // container under it (and isIdle never parks the cycle mid-attach).
4688
4762
  this.attachesInFlight++;
4689
4763
  try {
4690
- return await this.attachThreadBody(
4764
+ const res = await this.attachThreadBody(
4691
4765
  threadKey,
4692
4766
  refHint,
4693
4767
  readonly,
@@ -4698,6 +4772,10 @@ export class ResidentDO extends Sandbox<Env> {
4698
4772
  record,
4699
4773
  reason,
4700
4774
  );
4775
+ // The run this attach opens is now in flight until its release —
4776
+ // whatever its op counters read between the bot's calls (item 44).
4777
+ if (!("error" in res)) await this.registerRun(threadKey);
4778
+ return res;
4701
4779
  } finally {
4702
4780
  this.attachesInFlight--;
4703
4781
  }
@@ -5135,10 +5213,16 @@ export class ResidentDO extends Sandbox<Env> {
5135
5213
  // second movement is decided on. (Under the mirror mutex — the mirror
5136
5214
  // cannot change in between.)
5137
5215
  const refExists = why === null || (await this.refExists(binding.ref));
5216
+ // The tip is read here, once, and is what the target is judged on and
5217
+ // what the worktree is provisioned at: a caller that named a commit
5218
+ // gets that commit or a `stale-tip` refusal, never whatever the tip
5219
+ // happens to be (item 51).
5220
+ let tipSha = refExists ? await this.readMirrorSha(binding.ref).catch(() => null) : null;
5138
5221
  let target = attachTarget({
5139
5222
  refExists,
5140
5223
  wantSha: want,
5141
5224
  commitInMirror: !refExists && want !== null && (await this.commitInMirror(want)),
5225
+ tipSha,
5142
5226
  });
5143
5227
  if (target.kind === "unknown-ref" && returnable) {
5144
5228
  // The thread's branch — one a rebind moved it onto, or one its own
@@ -5151,10 +5235,13 @@ export class ResidentDO extends Sandbox<Env> {
5151
5235
  binding = back.binding;
5152
5236
  returned = back.returned;
5153
5237
  want = wantShaForBinding({ boundRef: binding.ref, refHint, wantSha });
5238
+ const defaultExists = await this.refExists(binding.ref);
5239
+ tipSha = defaultExists ? await this.readMirrorSha(binding.ref).catch(() => null) : null;
5154
5240
  target = attachTarget({
5155
- refExists: await this.refExists(binding.ref),
5241
+ refExists: defaultExists,
5156
5242
  wantSha: want,
5157
5243
  commitInMirror: false,
5244
+ tipSha,
5158
5245
  });
5159
5246
  }
5160
5247
  if (target.kind === "unknown-ref") {
@@ -5164,7 +5251,19 @@ export class ResidentDO extends Sandbox<Env> {
5164
5251
  (want !== null ? `, and the mirror does not hold the expected commit ${want.slice(0, 7)} either` : ""),
5165
5252
  );
5166
5253
  }
5167
- const sha = target.kind === "sha" ? target.sha : await this.readMirrorSha(binding.ref);
5254
+ if (target.kind === "stale-tip") {
5255
+ throw new StepError(
5256
+ "stale-tip",
5257
+ `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`,
5258
+ );
5259
+ }
5260
+ // A tip the read could not answer is never provisioned at: with no
5261
+ // commit named the target is `ref` without looking at the tip, so the
5262
+ // failed read is named here instead of becoming a null sha.
5263
+ const sha = target.kind === "sha" ? target.sha : tipSha;
5264
+ if (sha === null) {
5265
+ throw new StepError("rev-parse", `the mirror's tip of ${JSON.stringify(binding.ref)} could not be read`);
5266
+ }
5168
5267
  const threadLockKey = await this.lockfileKey(sha);
5169
5268
  const recreated = await this.ensureThreadWorktree(binding, sha, mode.originUrl, mode.modeSwitch, {
5170
5269
  detached: target.kind === "sha",
@@ -5186,6 +5285,13 @@ export class ResidentDO extends Sandbox<Env> {
5186
5285
  if (err instanceof StepError && err.step === "unknown-ref") {
5187
5286
  return { error: `unknown-ref: ${err.message}`, status: 400 };
5188
5287
  }
5288
+ if (err instanceof StepError && err.step === "stale-tip") {
5289
+ // Item 51: not a resident fault and not a caller fault — a fact about
5290
+ // the mirror at this instant. 409 with the state, so the bot's named
5291
+ // fallback runs cold at the commit it asked for.
5292
+ const s = await this.getStatus();
5293
+ return { error: `stale-tip: ${err.message}`, status: 409, state: s.state, reason: "stale-tip" };
5294
+ }
5189
5295
  return this.attachFailed(err);
5190
5296
  }
5191
5297
 
@@ -6361,6 +6467,9 @@ export class ResidentDO extends Sandbox<Env> {
6361
6467
  evictedLeftBehind: tree && "leftBehind" in tree ? tree.leftBehind : undefined,
6362
6468
  evictedUnmeasured: tree && "unmeasured" in tree ? tree.unmeasured : undefined,
6363
6469
  } satisfies ThreadBinding);
6470
+ // The run's registration goes with its binding (item 44): released here,
6471
+ // never on a give-way above — a re-attach has re-registered it anyway.
6472
+ await this.ctx.storage.delete(runRegKey(binding.threadKey));
6364
6473
  if (tree) console.log(`${logCtx}: ${binding.threadKey} evicted (${why}) — ${evictedTreeSentence(tree)}`);
6365
6474
  return true;
6366
6475
  }
@@ -6522,10 +6631,33 @@ export class ResidentDO extends Sandbox<Env> {
6522
6631
  return threadOps + this.opUsersInUse.size + this.attachesInFlight;
6523
6632
  }
6524
6633
  /** 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. */
6634
+ * The in-memory counters (a fresh isolate answers 0 for them — nothing of
6635
+ * THEIRS survived to be interrupted) plus the durable run registrations:
6636
+ * a harness run's process DOES survive an isolate swap in the container,
6637
+ * so its registration must too. */
6527
6638
  async getInFlightCount(): Promise<number> {
6528
- return this.inFlightCount();
6639
+ return this.inFlightCount() + (await this.registeredRunsBeyondOps());
6640
+ }
6641
+ /** A run holds its worktree from `/attach` to its release (`/detach`, the
6642
+ * sweep, disk pressure — every path ends in `evictBinding`, which clears
6643
+ * the row). Written by the attach; idempotent, a re-attach refreshes it. */
6644
+ private async registerRun(threadKey: string): Promise<void> {
6645
+ await this.ctx.storage.put(runRegKey(threadKey), {
6646
+ threadKey,
6647
+ registeredAt: new Date(systemClock()).toISOString(),
6648
+ } satisfies RunRegistration);
6649
+ }
6650
+ /** The registrations the op counters do not already see: a thread with an
6651
+ * op in flight is counted by `runsInFlightCount`, so its registration is
6652
+ * not counted again. Deliberately NOT part of `inFlightCount()`: the
6653
+ * container-lifecycle predicates (isIdle, reconcileImage) must not let a
6654
+ * stale registration pin a container awake — the clean-idle sweep is what
6655
+ * drains a registration whose release never came. */
6656
+ private async registeredRunsBeyondOps(): Promise<number> {
6657
+ const regs = await this.ctx.storage.list<RunRegistration>({ prefix: RUN_REG_KEY_PREFIX });
6658
+ let n = 0;
6659
+ for (const r of regs.values()) if ((this.threadOpsInFlight.get(r.threadKey) ?? 0) === 0) n++;
6660
+ return n;
6529
6661
  }
6530
6662
  private attachesInFlight = 0;
6531
6663
  /** A refresh cycle past its idle/reconcile gates (fetching, rebuilding, snapshotting). */
@@ -7067,6 +7199,8 @@ export class ResidentDO extends Sandbox<Env> {
7067
7199
  LIFECYCLE_KEY,
7068
7200
  REFRESH_INSTANCE_KEY,
7069
7201
  RUNTIME_UNREACHABLE_KEY,
7202
+ AUTO_REBUILDS_KEY,
7203
+ INFRA_STREAK_KEY,
7070
7204
  ]);
7071
7205
  const facts = map.get(FACTS_KEY) as RepoFacts | undefined;
7072
7206
  const snap = map.get(SNAPSHOT_KEY) as SnapshotRecord | undefined;
@@ -7119,6 +7253,9 @@ export class ResidentDO extends Sandbox<Env> {
7119
7253
  recycledUnmeasured: recycledUnmeasured ?? null,
7120
7254
  }),
7121
7255
  );
7256
+ // The runs whose process lives in the container between operator calls
7257
+ // (item 44): what the deploy preflight must see, added to both counts.
7258
+ const registeredRuns = await this.registeredRunsBeyondOps();
7122
7259
  return {
7123
7260
  resource: map.get(RESOURCE_KEY) ?? null,
7124
7261
  state: map.get(STATE_KEY) ?? "down",
@@ -7138,9 +7275,9 @@ export class ResidentDO extends Sandbox<Env> {
7138
7275
  provisionRun: provisionRun.length,
7139
7276
  provisionDeadline: provisionDeadline.length,
7140
7277
  },
7141
- inFlight: this.inFlightCount(),
7278
+ inFlight: this.inFlightCount() + registeredRuns,
7142
7279
  // The runs alone (no refresh cycle): what the deploy preflight refuses on.
7143
- runsInFlight: this.runsInFlightCount(),
7280
+ runsInFlight: this.runsInFlightCount() + registeredRuns,
7144
7281
  // Item 22: who holds what, as the rows say — the mirror mutex and the
7145
7282
  // cycle/hydration leases, each judged against this incarnation.
7146
7283
  incarnation: this.incarnation,
@@ -7172,6 +7309,12 @@ export class ResidentDO extends Sandbox<Env> {
7172
7309
  // Item 36: the auto-rebuilds this resident has had (ISO instants; the
7173
7310
  // budget's window is judged over them). Empty after a person's rebuild.
7174
7311
  autoRebuilds: (map.get(AUTO_REBUILDS_KEY) as string[] | undefined) ?? [],
7312
+ // Item 67: consecutive cycles failed in the resident's own steps, with
7313
+ // the rung that count is on; null while cycles complete.
7314
+ infraStreak: (() => {
7315
+ const row = map.get(INFRA_STREAK_KEY) as InfraStreakRow | undefined;
7316
+ return row ? { ...row, rung: infraStreakRung(row.count) } : null;
7317
+ })(),
7175
7318
  };
7176
7319
  }
7177
7320
 
@@ -7254,6 +7397,33 @@ export class ResidentDO extends Sandbox<Env> {
7254
7397
  return { recreated: true, restoreStartedAt };
7255
7398
  }
7256
7399
 
7400
+ /** Fault injection for item 67: write the resident-step streak row at `count`
7401
+ * (0 deletes it), so the next real failure lands on the rung under test.
7402
+ * Test-only semantics; admin scope. */
7403
+ async debugSetInfraStreak(count: number): Promise<{ infraStreak: (InfraStreakRow & { rung: string }) | null }> {
7404
+ if (count === 0) {
7405
+ await this.ctx.storage.delete(INFRA_STREAK_KEY);
7406
+ return { infraStreak: null };
7407
+ }
7408
+ const now = new Date(systemClock()).toISOString();
7409
+ const row: InfraStreakRow = { step: "injected", count, firstAt: now, lastAt: now };
7410
+ await this.ctx.storage.put(INFRA_STREAK_KEY, row);
7411
+ return { infraStreak: { ...row, rung: infraStreakRung(count) } };
7412
+ }
7413
+
7414
+ /** Fault injection for item 67: corrupt the mirror on disk (its objects
7415
+ * removed), so the next cycle's `fetch` fails in a resident step. The
7416
+ * snapshots in R2 are untouched — the ladder's recreate restores them.
7417
+ * Test-only semantics; admin scope. */
7418
+ async debugBreakMirror(): Promise<{ broken: boolean; error?: string }> {
7419
+ try {
7420
+ await this.runOk(["rm", "-rf", `${MIRROR_DIR}/objects`], "break-mirror");
7421
+ return { broken: true };
7422
+ } catch (err) {
7423
+ return { broken: false, error: errMsg(err) };
7424
+ }
7425
+ }
7426
+
7257
7427
  /** Fault injection for item 36 without corrupting real R2 objects. Bare: persist
7258
7428
  * `down` with a rehydration-flavored reason and nothing else, so the next
7259
7429
  * watchdog pass is the backstop under test. `transition`: go through
@@ -8643,6 +8813,19 @@ async function handleDebug(env: Env, body: Record<string, unknown>): Promise<Res
8643
8813
  }
8644
8814
  case "force-onboarding":
8645
8815
  return json(await stub.debugForceOnboarding());
8816
+ case "infra-streak": {
8817
+ // Fault injection for item 67: set the resident-step streak's count, so
8818
+ // one real failure lands on the rung under test (recreate at 3, down at 5).
8819
+ const count =
8820
+ typeof body.count === "number" && Number.isInteger(body.count) && body.count >= 0 ? body.count : null;
8821
+ if (count === null) return json({ error: "count must be a non-negative integer" }, 400);
8822
+ return json(await stub.debugSetInfraStreak(count));
8823
+ }
8824
+ case "break-mirror":
8825
+ // Fault injection for item 67: remove the mirror's objects, so the next
8826
+ // cycle's fetch fails in a resident step — a corrupt disk, which the
8827
+ // ladder's recreate restores from the snapshot. Test residents only.
8828
+ return json(await stub.debugBreakMirror());
8646
8829
  case "force-down": {
8647
8830
  // Fault injection for item 36; a rehydration-flavored default reason
8648
8831
  // makes it eligible. `transition: true` fires the down through goDown