@coreplane/switchboard 1.241.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 (135) hide show
  1. package/dist/assets/config/config.example.yaml +25 -12
  2. package/dist/assets/deploy/cloudflare-resident/worker.ts +197 -15
  3. package/dist/assets/deploy/cloudflare-sandbox/worker.ts +38 -5
  4. package/dist/assets/package-lock.json +3 -3
  5. package/dist/assets/package.json +1 -1
  6. package/dist/assets/project.json +2 -2
  7. package/dist/assets/source.json +3 -3
  8. package/dist/assets/src/agents/registry.ts +31 -14
  9. package/dist/assets/src/core/authz/actor.ts +12 -9
  10. package/dist/assets/src/core/authz/authorize.ts +17 -4
  11. package/dist/assets/src/core/authz/types.ts +12 -0
  12. package/dist/assets/src/core/coordinator/contract.ts +3 -1
  13. package/dist/assets/src/core/costs.ts +12 -4
  14. package/dist/assets/src/core/harness/scope.ts +21 -0
  15. package/dist/assets/src/core/runEvents.ts +20 -1
  16. package/dist/assets/src/core/runLedger/types.ts +2 -0
  17. package/dist/assets/src/core/runRecord.ts +4 -0
  18. package/dist/assets/src/core/ship/contract.ts +4 -2
  19. package/dist/assets/src/execution/residentAutoRebuild.ts +4 -2
  20. package/dist/assets/src/execution/residentHead.ts +23 -10
  21. package/dist/assets/src/execution/residentInfraStreak.ts +89 -0
  22. package/dist/assets/src/execution/residentRefresh.ts +15 -1
  23. package/dist/assets/src/execution/residentSteps.ts +1 -0
  24. package/dist/assets/src/execution/sandboxErrors.ts +6 -0
  25. package/dist/assets/src/execution/seedPlan.ts +17 -0
  26. package/dist/assets/web/dist/.vite/manifest.json +419 -425
  27. package/dist/assets/web/dist/assets/{AppShell-DEy5jRuS.js → AppShell-IDMGG6yi.js} +1 -1
  28. package/dist/assets/web/dist/assets/{CostsPage-BuHD-Bv7.js → CostsPage-CtmKhhOF.js} +1 -1
  29. package/dist/assets/web/dist/assets/{DeliveryPage-BGpPr5xr.js → DeliveryPage-n1tz5I_v.js} +1 -1
  30. package/dist/assets/web/dist/assets/HomePage-QtH1EwYF.js +2 -0
  31. package/dist/assets/web/dist/assets/{NotFoundPage-DtmQM2gD.js → NotFoundPage-C97EhJcQ.js} +1 -1
  32. package/dist/assets/web/dist/assets/{PendingTurnRow-CrEAWx5b.js → PendingTurnRow-BZA_vQt3.js} +1 -1
  33. package/dist/assets/web/dist/assets/{ResidentDetailPage-C-48fxat.js → ResidentDetailPage-DTMBgnIW.js} +1 -1
  34. package/dist/assets/web/dist/assets/{ResidentsIndexPage-CBBIjPOa.js → ResidentsIndexPage-CaDvXhzJ.js} +1 -1
  35. package/dist/assets/web/dist/assets/RunFoldRow-mgyLW0oV.js +1 -0
  36. package/dist/assets/web/dist/assets/RunRoutePage-DypJYMQa.js +6 -0
  37. package/dist/assets/web/dist/assets/RunsIndexPage-DYraPoWD.js +1 -0
  38. package/dist/assets/web/dist/assets/{RunsTabs-CtNGNnhN.js → RunsTabs-BUfdk0lH.js} +1 -1
  39. package/dist/assets/web/dist/assets/{ScheduledPage-Ci1Ygqnc.js → ScheduledPage-DXD2gLJk.js} +1 -1
  40. package/dist/assets/web/dist/assets/SettingsPage-BIGio8Y0.js +1 -0
  41. package/dist/assets/web/dist/assets/{StatusDot-DPWE1JmO.js → StatusDot-ELoXHlFt.js} +1 -1
  42. package/dist/assets/web/dist/assets/{Tooltip-DKhSRH9t.js → Tooltip-BoeFwYP2.js} +1 -1
  43. package/dist/assets/web/dist/assets/UnitRoutePage-jhCrWW3i.js +1 -0
  44. package/dist/assets/web/dist/assets/{angular-html-DgSK1qvr.js → angular-html-oBNfPJR0.js} +1 -1
  45. package/dist/assets/web/dist/assets/{angular-ts-D3gNdiSG.js → angular-ts-BvNwsyWA.js} +1 -1
  46. package/dist/assets/web/dist/assets/{apl-CkHCYM8I.js → apl-CNUdRlYf.js} +1 -1
  47. package/dist/assets/web/dist/assets/{astro-DLm45axt.js → astro-Zb0NriSe.js} +1 -1
  48. package/dist/assets/web/dist/assets/{blade-BKa-VE-c.js → blade-qPRVheqq.js} +1 -1
  49. package/dist/assets/web/dist/assets/{c-C8NCNWai.js → c-D8Awx4YO.js} +1 -1
  50. package/dist/assets/web/dist/assets/{chapel-DqDQ7IKH.js → chapel-Bt72Mhsx.js} +1 -1
  51. package/dist/assets/web/dist/assets/{cobol-j4vCD6AJ.js → cobol-BOBacexg.js} +1 -1
  52. package/dist/assets/web/dist/assets/{coffee-C6oDVH-0.js → coffee-E4u0liHW.js} +1 -1
  53. package/dist/assets/web/dist/assets/{cpp-Bd3A1Rtc.js → cpp-q2sLNlul.js} +1 -1
  54. package/dist/assets/web/dist/assets/{crystal-CUOgOw0d.js → crystal-DyWqUnlb.js} +1 -1
  55. package/dist/assets/web/dist/assets/{css-RYljyv7G.js → css-CQY0hFsD.js} +1 -1
  56. package/dist/assets/web/dist/assets/{dist-BnJXKHCY.js → dist-BU5UivXC.js} +2 -2
  57. package/dist/assets/web/dist/assets/durationTone-BobbycC-.js +1 -0
  58. package/dist/assets/web/dist/assets/{edge-BJhNdQDc.js → edge-C1MwhJkX.js} +1 -1
  59. package/dist/assets/web/dist/assets/{elixir-g1AWOAfT.js → elixir-Bb3YbHfn.js} +1 -1
  60. package/dist/assets/web/dist/assets/{elm-CER5e4Dz.js → elm-DAN9IGQw.js} +1 -1
  61. package/dist/assets/web/dist/assets/{erb-D5aOmMQf.js → erb-BEB8Xlsj.js} +1 -1
  62. package/dist/assets/web/dist/assets/{git-rebase-D6rfV8jp.js → git-rebase-tqpjRfxO.js} +1 -1
  63. package/dist/assets/web/dist/assets/{glimmer-js-BvDH3-mC.js → glimmer-js-Ccbo65zR.js} +1 -1
  64. package/dist/assets/web/dist/assets/{glimmer-ts-DChtNs9V.js → glimmer-ts-B6WBVMpE.js} +1 -1
  65. package/dist/assets/web/dist/assets/{glsl-BHlsWrxf.js → glsl-tRec3Fcu.js} +1 -1
  66. package/dist/assets/web/dist/assets/{graphql-BsaBHb53.js → graphql-P8kbxT4F.js} +1 -1
  67. package/dist/assets/web/dist/assets/{hack-0lbDosCd.js → hack-H9Zhkagy.js} +1 -1
  68. package/dist/assets/web/dist/assets/{haml-BKtco2vr.js → haml-DtEnpn7Z.js} +1 -1
  69. package/dist/assets/web/dist/assets/{handlebars-BE_cj04z.js → handlebars-DkgPfoAz.js} +1 -1
  70. package/dist/assets/web/dist/assets/{html-BNcr8EVd.js → html-D30RXpIs.js} +1 -1
  71. package/dist/assets/web/dist/assets/{html-derivative-BsJt2Kei.js → html-derivative-DwozLrEx.js} +1 -1
  72. package/dist/assets/web/dist/assets/{http-BR5P8ER_.js → http-DXuzBAPm.js} +1 -1
  73. package/dist/assets/web/dist/assets/{hurl-CtHalpWS.js → hurl-d1UUJIt_.js} +1 -1
  74. package/dist/assets/web/dist/assets/{java-BXVdhN11.js → java-DL0gWf34.js} +1 -1
  75. package/dist/assets/web/dist/assets/{javascript-CyoShgNQ.js → javascript-Cl7vavnS.js} +1 -1
  76. package/dist/assets/web/dist/assets/{jinja-BQYZzHex.js → jinja-CfQOWMX9.js} +1 -1
  77. package/dist/assets/web/dist/assets/{jison-DqrUa1Eq.js → jison-KdYirlqm.js} +1 -1
  78. package/dist/assets/web/dist/assets/{json-CQiTF9Jj.js → json-C9cDQ-Qj.js} +1 -1
  79. package/dist/assets/web/dist/assets/{jsx-Y16MkCYQ.js → jsx-CRx5NItd.js} +1 -1
  80. package/dist/assets/web/dist/assets/{julia-zn16YONe.js → julia-CmsQsZQl.js} +1 -1
  81. package/dist/assets/web/dist/assets/{just-DcVeN_MN.js → just-CsM3Q8TE.js} +1 -1
  82. package/dist/assets/web/dist/assets/{latex-BNNoF-WP.js → latex-BXCh5YRX.js} +1 -1
  83. package/dist/assets/web/dist/assets/{liquid-DAi6qHzn.js → liquid-BF2vwK8p.js} +1 -1
  84. package/dist/assets/web/dist/assets/{lua-BswS0axw.js → lua-DcMBATrl.js} +1 -1
  85. package/dist/assets/web/dist/assets/main-B2fX10aW.css +1 -0
  86. package/dist/assets/web/dist/assets/{main-B3is4sk6.js → main-D4EA1g6n.js} +2 -2
  87. package/dist/assets/web/dist/assets/{marko-C-4hkmQZ.js → marko-88MndKvG.js} +1 -1
  88. package/dist/assets/web/dist/assets/{mdc-0ZaHUkS7.js → mdc-DJ4kVAd8.js} +1 -1
  89. package/dist/assets/web/dist/assets/{nginx-DBDg5tOR.js → nginx-CP6mRgtV.js} +1 -1
  90. package/dist/assets/web/dist/assets/{nim-BVMz49Yw.js → nim-QQfj3fpF.js} +1 -1
  91. package/dist/assets/web/dist/assets/{org-1hO_K6ya.js → org-Bke3eBzc.js} +1 -1
  92. package/dist/assets/web/dist/assets/{perl-CVRQLvVk.js → perl-Bkh0N7Kz.js} +1 -1
  93. package/dist/assets/web/dist/assets/{php-K9nCrB9n.js → php-Dnv1Piya.js} +1 -1
  94. package/dist/assets/web/dist/assets/{pug-CTxOcmO3.js → pug-D6peFFI7.js} +1 -1
  95. package/dist/assets/web/dist/assets/{qml-CmCkfG5q.js → qml-D8PEs-C-.js} +1 -1
  96. package/dist/assets/web/dist/assets/{r-pP47Xn1X.js → r-B1EL9b_j.js} +1 -1
  97. package/dist/assets/web/dist/assets/{razor-BpW6r3tC.js → razor-BsMTIh2b.js} +1 -1
  98. package/dist/assets/web/dist/assets/{regexp-CINgdY4N.js → regexp-HhvC8spD.js} +1 -1
  99. package/dist/assets/web/dist/assets/{rst-BZIUEq2Q.js → rst-D5paAxpg.js} +1 -1
  100. package/dist/assets/web/dist/assets/{ruby-nzAOxz6r.js → ruby-BrQwhLrl.js} +1 -1
  101. package/dist/assets/web/dist/assets/{sas-QIS1bFth.js → sas-afot2B1o.js} +1 -1
  102. package/dist/assets/web/dist/assets/{scss-BuhBBVNt.js → scss-Efm-mwuG.js} +1 -1
  103. package/dist/assets/web/dist/assets/{shellscript-CQc1vXbk.js → shellscript-BK0Vv5fT.js} +1 -1
  104. package/dist/assets/web/dist/assets/{shellsession-CoubCAUv.js → shellsession-BGqlMC7N.js} +1 -1
  105. package/dist/assets/web/dist/assets/{soy-CPRzlder.js → soy-M0b4UwGM.js} +1 -1
  106. package/dist/assets/web/dist/assets/{sql-W9krb8-9.js → sql-sxe6IE9j.js} +1 -1
  107. package/dist/assets/web/dist/assets/sseReplay-g7ml86LM.js +9 -0
  108. package/dist/assets/web/dist/assets/{stata-CuISJEC0.js → stata-nPF_ddLP.js} +1 -1
  109. package/dist/assets/web/dist/assets/{surrealql-CSet7584.js → surrealql-D_GrC6u7.js} +1 -1
  110. package/dist/assets/web/dist/assets/{svelte-ChXTSYwK.js → svelte-DwL1AtNP.js} +1 -1
  111. package/dist/assets/web/dist/assets/{templ-Bm55v62k.js → templ-7s7LTDkc.js} +1 -1
  112. package/dist/assets/web/dist/assets/{tex-CICMX8Gj.js → tex-Bx-5fMxe.js} +1 -1
  113. package/dist/assets/web/dist/assets/{ts-tags-CA1UzWyB.js → ts-tags-DUMJnke_.js} +1 -1
  114. package/dist/assets/web/dist/assets/{tsx-Dy04HNbv.js → tsx-BN8biPDe.js} +1 -1
  115. package/dist/assets/web/dist/assets/{twig-BBHsVnVD.js → twig-8nIu84TN.js} +1 -1
  116. package/dist/assets/web/dist/assets/{typescript-Dvc-wVBT.js → typescript-BooSPq_S.js} +1 -1
  117. package/dist/assets/web/dist/assets/{typst-Dvlqx3_q.js → typst-CTBiBsem.js} +1 -1
  118. package/dist/assets/web/dist/assets/{vue-zWi1MNU2.js → vue-C6Ft4Lea.js} +1 -1
  119. package/dist/assets/web/dist/assets/{vue-html-D4YxT3An.js → vue-html-Ba36dD5D.js} +1 -1
  120. package/dist/assets/web/dist/assets/{vue-vine-0YE7uQlH.js → vue-vine-NkFexVo2.js} +1 -1
  121. package/dist/assets/web/dist/assets/{xml-gm-iksZB.js → xml-C_THnHXZ.js} +1 -1
  122. package/dist/assets/web/dist/assets/{xsl-D_W5BcrY.js → xsl-D8G5xqjY.js} +1 -1
  123. package/dist/assets/web/dist/assets/{yaml-CHmZ21wZ.js → yaml-jAMIJzge.js} +1 -1
  124. package/dist/cli.js +1390 -484
  125. package/package.json +1 -1
  126. package/dist/assets/web/dist/assets/HomePage-dKBvWh-E.js +0 -2
  127. package/dist/assets/web/dist/assets/RunFoldRow-B6vxjcBI.js +0 -1
  128. package/dist/assets/web/dist/assets/RunRoutePage-CQjd3Cz-.js +0 -6
  129. package/dist/assets/web/dist/assets/RunsIndexPage-Cy9G6D0p.js +0 -1
  130. package/dist/assets/web/dist/assets/SettingsPage-DxmJvAmT.js +0 -1
  131. package/dist/assets/web/dist/assets/SlackMark-VDNs7Vjh.js +0 -1
  132. package/dist/assets/web/dist/assets/UnitRoutePage-DEN5bSQf.js +0 -1
  133. package/dist/assets/web/dist/assets/durationTone-DXG-3R7_.js +0 -1
  134. package/dist/assets/web/dist/assets/main-DP_zSemY.css +0 -1
  135. package/dist/assets/web/dist/assets/sseReplay-yji8a1wM.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,17 +333,22 @@ 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
- # Which harness each preset's runs are driven by (docs/reference/specs/harness.md
331
- # item 8): a mapping of preset to a harness's name. Two exist — `pi` (the pi
332
- # coding agent, the default for every preset) and `opencode` (OpenCode's server,
333
- # `opencode serve`) — each started inside the run's own execution container (or
334
- # as a child of the bot for a preset without a workspace) with the run's
335
- # model-proxy bearer as its only key, every tool call decided in the bot, every
336
- # event on the run's record. A preset the block does not name runs on pi;
337
- # nothing defaults to OpenCode. A run keeps the harness it started on, so
338
- # changing a word moves the next run, never one in flight. Any other word
339
- # (`native`, the deleted loop; `codex`) fails the load naming the two, as does
340
- # an unknown preset. How-to: docs/how-to/put-a-preset-on-opencode.md.
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.
341
352
  # harness:
342
353
  # coding: opencode # coding runs on OpenCode
343
354
  # review: pi # the default, written out
@@ -411,6 +422,8 @@ workspaceDir: ./workspaces
411
422
  # snapshot: # the page serves a snapshot of both billing sources, never a live read;
412
423
  # everyHours: 24 # how often the bot reads them (1–168, default 24). `costs snapshot`
413
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
414
427
  # groups:
415
428
  # myapp:
416
429
  # label: My App
@@ -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). */
@@ -7068,6 +7200,7 @@ export class ResidentDO extends Sandbox<Env> {
7068
7200
  REFRESH_INSTANCE_KEY,
7069
7201
  RUNTIME_UNREACHABLE_KEY,
7070
7202
  AUTO_REBUILDS_KEY,
7203
+ INFRA_STREAK_KEY,
7071
7204
  ]);
7072
7205
  const facts = map.get(FACTS_KEY) as RepoFacts | undefined;
7073
7206
  const snap = map.get(SNAPSHOT_KEY) as SnapshotRecord | undefined;
@@ -7120,6 +7253,9 @@ export class ResidentDO extends Sandbox<Env> {
7120
7253
  recycledUnmeasured: recycledUnmeasured ?? null,
7121
7254
  }),
7122
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();
7123
7259
  return {
7124
7260
  resource: map.get(RESOURCE_KEY) ?? null,
7125
7261
  state: map.get(STATE_KEY) ?? "down",
@@ -7139,9 +7275,9 @@ export class ResidentDO extends Sandbox<Env> {
7139
7275
  provisionRun: provisionRun.length,
7140
7276
  provisionDeadline: provisionDeadline.length,
7141
7277
  },
7142
- inFlight: this.inFlightCount(),
7278
+ inFlight: this.inFlightCount() + registeredRuns,
7143
7279
  // The runs alone (no refresh cycle): what the deploy preflight refuses on.
7144
- runsInFlight: this.runsInFlightCount(),
7280
+ runsInFlight: this.runsInFlightCount() + registeredRuns,
7145
7281
  // Item 22: who holds what, as the rows say — the mirror mutex and the
7146
7282
  // cycle/hydration leases, each judged against this incarnation.
7147
7283
  incarnation: this.incarnation,
@@ -7173,6 +7309,12 @@ export class ResidentDO extends Sandbox<Env> {
7173
7309
  // Item 36: the auto-rebuilds this resident has had (ISO instants; the
7174
7310
  // budget's window is judged over them). Empty after a person's rebuild.
7175
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
+ })(),
7176
7318
  };
7177
7319
  }
7178
7320
 
@@ -7255,6 +7397,33 @@ export class ResidentDO extends Sandbox<Env> {
7255
7397
  return { recreated: true, restoreStartedAt };
7256
7398
  }
7257
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
+
7258
7427
  /** Fault injection for item 36 without corrupting real R2 objects. Bare: persist
7259
7428
  * `down` with a rehydration-flavored reason and nothing else, so the next
7260
7429
  * watchdog pass is the backstop under test. `transition`: go through
@@ -8644,6 +8813,19 @@ async function handleDebug(env: Env, body: Record<string, unknown>): Promise<Res
8644
8813
  }
8645
8814
  case "force-onboarding":
8646
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());
8647
8829
  case "force-down": {
8648
8830
  // Fault injection for item 36; a rehydration-flavored default reason
8649
8831
  // 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.242.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.242.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.242.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.242.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",
@@ -204,11 +204,11 @@
204
204
  "when": "After a `web/` change."
205
205
  },
206
206
  "screenshots:gen": {
207
- "does": "Renders the dashboard's screenshots from the fixture preview, both themes, and records their inputs' hashes in `docs/public/screenshots/manifest.json`.",
207
+ "does": "Renders the dashboard screenshots whose inputs changed, both themes, recording each surface's input hashes in `docs/public/screenshots/manifest/` (`--force`: all).",
208
208
  "when": "After a `web/` or fixture change, once `screenshots:check` names it; needs `npx playwright-core install chromium`, so it is not part of `fix`."
209
209
  },
210
210
  "screenshots:check": {
211
- "does": "The dashboard's source and fixtures still hash to what the screenshots were rendered from — no browser.",
211
+ "does": "Each surface's inputs still hash to what its screenshots were rendered from — no browser.",
212
212
  "when": "Part of `check:consistency`."
213
213
  },
214
214
  "load": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.241.0",
3
- "commit": "645b2a6d20ac39178acdb7c412a6a72f6013771c",
4
- "builtAt": "2026-09-17T01:34:41.433Z"
2
+ "version": "1.242.0",
3
+ "commit": "c0dc26a1590c854d944c451829e9b21f6ce7d721",
4
+ "builtAt": "2026-09-17T05:42:17.412Z"
5
5
  }