@coreplane/switchboard 1.252.0 → 1.254.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 (143) hide show
  1. package/dist/assets/config/config.example.yaml +54 -1
  2. package/dist/assets/deploy/cloudflare-memory/runMetricsSink.ts +31 -0
  3. package/dist/assets/deploy/cloudflare-memory/worker.ts +455 -32
  4. package/dist/assets/deploy/cloudflare-memory/wrangler.template.jsonc +12 -0
  5. package/dist/assets/deploy/profile.example.json +1 -1
  6. package/dist/assets/package-lock.json +3 -3
  7. package/dist/assets/package.json +1 -1
  8. package/dist/assets/source.json +3 -3
  9. package/dist/assets/src/core/authz/grants.ts +6 -0
  10. package/dist/assets/src/core/authz/policy.ts +19 -0
  11. package/dist/assets/src/core/budgets.ts +25 -0
  12. package/dist/assets/src/core/coordinator/contract.ts +51 -1
  13. package/dist/assets/src/core/coordinator/driver.ts +111 -11
  14. package/dist/assets/src/core/costs.ts +221 -2
  15. package/dist/assets/src/core/costsSnapshotStore.ts +22 -1
  16. package/dist/assets/src/core/memory/engine.ts +25 -4
  17. package/dist/assets/src/core/memory/types.ts +6 -2
  18. package/dist/assets/src/core/pipelineStanding.ts +294 -0
  19. package/dist/assets/src/core/plane/decide.ts +203 -0
  20. package/dist/assets/src/core/provider.ts +11 -0
  21. package/dist/assets/src/core/runEvents.ts +57 -1
  22. package/dist/assets/src/core/runFriction.ts +1 -0
  23. package/dist/assets/src/core/runLedger/transcript.ts +23 -3
  24. package/dist/assets/src/core/runMetrics.ts +237 -0
  25. package/dist/assets/src/core/runRecord.ts +61 -0
  26. package/dist/assets/src/core/runUsage.ts +41 -9
  27. package/dist/assets/src/core/ship/contract.ts +8 -15
  28. package/dist/assets/src/core/ship/coordinator.ts +150 -11
  29. package/dist/assets/src/deploy/profile.ts +16 -0
  30. package/dist/assets/web/dist/.vite/manifest.json +461 -427
  31. package/dist/assets/web/dist/assets/{AppShell-DAtHiuI6.js → AppShell-CSXDCV4x.js} +1 -1
  32. package/dist/assets/web/dist/assets/CostChart-DsuPS2g7.js +2 -0
  33. package/dist/assets/web/dist/assets/CostsPage-BuKjw3nv.js +1 -0
  34. package/dist/assets/web/dist/assets/{DeliveryPage-3ELQWM0r.js → DeliveryPage-ngPsO2to.js} +1 -1
  35. package/dist/assets/web/dist/assets/HomePage-DvxTHzPx.js +3 -0
  36. package/dist/assets/web/dist/assets/{InputMenu-C6aPpe30.js → InputMenu-BdZXnibG.js} +1 -1
  37. package/dist/assets/web/dist/assets/MetricsPage-o5fSaCTe.js +1 -0
  38. package/dist/assets/web/dist/assets/{NotFoundPage-DtE-GgTk.js → NotFoundPage-DHuWyjlq.js} +1 -1
  39. package/dist/assets/web/dist/assets/PendingTurnRow-ZYIRCCZ2.js +1 -0
  40. package/dist/assets/web/dist/assets/PlanePage-DpWfiX4C.js +1 -0
  41. package/dist/assets/web/dist/assets/{ResidentDetailPage-C9y3nbo8.js → ResidentDetailPage-DG86v39Y.js} +1 -1
  42. package/dist/assets/web/dist/assets/{ResidentsIndexPage-i1RG9e7g.js → ResidentsIndexPage-x6p689VH.js} +1 -1
  43. package/dist/assets/web/dist/assets/RunFoldRow-DG29LOTs.js +1 -0
  44. package/dist/assets/web/dist/assets/RunRoutePage-ysJBY8xQ.js +9 -0
  45. package/dist/assets/web/dist/assets/RunsIndexPage-CuzFchcn.js +1 -0
  46. package/dist/assets/web/dist/assets/{RunsTabs-YSbUu5py.js → RunsTabs-kHbbxVHx.js} +1 -1
  47. package/dist/assets/web/dist/assets/{ScheduledPage-DvYwM2TE.js → ScheduledPage-BuLmfcbG.js} +1 -1
  48. package/dist/assets/web/dist/assets/{SettingSelect-BIzAsLk1.js → SettingSelect-BfID5nF4.js} +1 -1
  49. package/dist/assets/web/dist/assets/{SettingsPage-Bo6yCyXZ.js → SettingsPage-BujWkdU_.js} +1 -1
  50. package/dist/assets/web/dist/assets/{StatusDot-CAfS1AUi.js → StatusDot-C8Bc0pTX.js} +1 -1
  51. package/dist/assets/web/dist/assets/{Tooltip-tZoum_T-.js → Tooltip-BWwJx27K.js} +1 -1
  52. package/dist/assets/web/dist/assets/{UnitRoutePage-BmdOHwNn.js → UnitRoutePage-B9kjA1AT.js} +1 -1
  53. package/dist/assets/web/dist/assets/{angular-html-BjeQZdCq.js → angular-html-C8b5YTHf.js} +1 -1
  54. package/dist/assets/web/dist/assets/{angular-ts-ZGdORrL2.js → angular-ts-C3f4WYNe.js} +1 -1
  55. package/dist/assets/web/dist/assets/{apl-DhvV_X93.js → apl-DSHxUJc3.js} +1 -1
  56. package/dist/assets/web/dist/assets/{astro-DiRSE4Ug.js → astro-BSGCCBP6.js} +1 -1
  57. package/dist/assets/web/dist/assets/{blade-D8Fr5Wvq.js → blade-DFeYrMKk.js} +1 -1
  58. package/dist/assets/web/dist/assets/{c-BwbC64D4.js → c-L_QOnFVv.js} +1 -1
  59. package/dist/assets/web/dist/assets/{chapel-DgmqRK97.js → chapel-DLQWUwhu.js} +1 -1
  60. package/dist/assets/web/dist/assets/{cobol-DJod2RNK.js → cobol-1DlMmrEO.js} +1 -1
  61. package/dist/assets/web/dist/assets/{coffee-D1Y2CgBY.js → coffee-BTB6os6N.js} +1 -1
  62. package/dist/assets/web/dist/assets/{cpp-CsO97YOM.js → cpp-BvJI2z3u.js} +1 -1
  63. package/dist/assets/web/dist/assets/{crystal-CJ7m2tOc.js → crystal-B11wGW_h.js} +1 -1
  64. package/dist/assets/web/dist/assets/{css-B2M-NKoj.js → css-BiIWx5Pw.js} +1 -1
  65. package/dist/assets/web/dist/assets/{dist-DfbEpHXR.js → dist-CpnyQGOb.js} +2 -2
  66. package/dist/assets/web/dist/assets/{edge-CFVW-m9B.js → edge-nSglEXaD.js} +1 -1
  67. package/dist/assets/web/dist/assets/{elixir-B2mtRgCr.js → elixir-x7o8qMl8.js} +1 -1
  68. package/dist/assets/web/dist/assets/{elm-2teVAwCi.js → elm-DGrRyqTQ.js} +1 -1
  69. package/dist/assets/web/dist/assets/{erb-LAbeNqen.js → erb--_dh58Nw.js} +1 -1
  70. package/dist/assets/web/dist/assets/{git-rebase-DnhzH_sp.js → git-rebase-DbpUXwyg.js} +1 -1
  71. package/dist/assets/web/dist/assets/{glimmer-js-DPKjXf1-.js → glimmer-js-BG0CuZG5.js} +1 -1
  72. package/dist/assets/web/dist/assets/{glimmer-ts-CABXnt5z.js → glimmer-ts-CQIl5qyR.js} +1 -1
  73. package/dist/assets/web/dist/assets/{glsl-Dz4-3-gJ.js → glsl-9PJNQTcw.js} +1 -1
  74. package/dist/assets/web/dist/assets/{graphql-ByWUD1DL.js → graphql-CzVTzjEI.js} +1 -1
  75. package/dist/assets/web/dist/assets/{hack-CdmOK2K-.js → hack-BhCmAz3Q.js} +1 -1
  76. package/dist/assets/web/dist/assets/{haml-DzegMpd2.js → haml-BmI12COR.js} +1 -1
  77. package/dist/assets/web/dist/assets/{handlebars-B5NeiP0e.js → handlebars-Cib-ISTS.js} +1 -1
  78. package/dist/assets/web/dist/assets/{html-Zg_NHLv9.js → html-D3lDapvI.js} +1 -1
  79. package/dist/assets/web/dist/assets/{html-derivative-DZf9JNzh.js → html-derivative-DRPY8vOK.js} +1 -1
  80. package/dist/assets/web/dist/assets/{http-CVFrQZeN.js → http-B9VttK6m.js} +1 -1
  81. package/dist/assets/web/dist/assets/{hurl-L5brWAJe.js → hurl-C0saNJYj.js} +1 -1
  82. package/dist/assets/web/dist/assets/indexRow-DABQtONT.js +1 -0
  83. package/dist/assets/web/dist/assets/{java-hFphyZ4R.js → java-BCFbtYAj.js} +1 -1
  84. package/dist/assets/web/dist/assets/{javascript-CAJO1WKX.js → javascript-nwBjnrhh.js} +1 -1
  85. package/dist/assets/web/dist/assets/{jinja-N4A4srXI.js → jinja-CMYO9byN.js} +1 -1
  86. package/dist/assets/web/dist/assets/{jison-D3BZa_iU.js → jison-Cko8HnE_.js} +1 -1
  87. package/dist/assets/web/dist/assets/{json-DxD1Qh8W.js → json-Dzv__YsX.js} +1 -1
  88. package/dist/assets/web/dist/assets/{jsx-CHhag69S.js → jsx-CTFxagih.js} +1 -1
  89. package/dist/assets/web/dist/assets/{julia-CrhkZ6Tt.js → julia-_Xvten1-.js} +1 -1
  90. package/dist/assets/web/dist/assets/{just-D0LFOuhq.js → just-bLps4qOu.js} +1 -1
  91. package/dist/assets/web/dist/assets/{latex-BHAlwCXK.js → latex-D6UaYsUP.js} +1 -1
  92. package/dist/assets/web/dist/assets/{liquid-CkaLfCWh.js → liquid-CoDIeULa.js} +1 -1
  93. package/dist/assets/web/dist/assets/{lua-B1h4aAzP.js → lua-D1KFRnVf.js} +1 -1
  94. package/dist/assets/web/dist/assets/main-C4QfwybO.css +1 -0
  95. package/dist/assets/web/dist/assets/main-Comxmwi4.js +28 -0
  96. package/dist/assets/web/dist/assets/{marko-D6CG4hvX.js → marko-B5N5XcOw.js} +1 -1
  97. package/dist/assets/web/dist/assets/{mdc-CVQzimAU.js → mdc-uxzjQO3q.js} +1 -1
  98. package/dist/assets/web/dist/assets/{nginx-h-9Ir54h.js → nginx-BMJxs2TK.js} +1 -1
  99. package/dist/assets/web/dist/assets/{nim-DHVI30sp.js → nim-DqQ3-8p1.js} +1 -1
  100. package/dist/assets/web/dist/assets/{org-AasAXfMF.js → org-DsY6LK59.js} +1 -1
  101. package/dist/assets/web/dist/assets/{perl-BVi5aTEy.js → perl--pq9iJFp.js} +1 -1
  102. package/dist/assets/web/dist/assets/{php-qOkCeUKr.js → php-RHCMbsM2.js} +1 -1
  103. package/dist/assets/web/dist/assets/{pug-8TnUGvEX.js → pug-CFNq_FCp.js} +1 -1
  104. package/dist/assets/web/dist/assets/{qml-CpJbhoxn.js → qml-C9Acx9E8.js} +1 -1
  105. package/dist/assets/web/dist/assets/{r-JkUNcAJW.js → r-DxlU8FBd.js} +1 -1
  106. package/dist/assets/web/dist/assets/{razor-CtU4ws5a.js → razor-CLJ2kKih.js} +1 -1
  107. package/dist/assets/web/dist/assets/{regexp-J2zA6ceN.js → regexp-CzmdGoEp.js} +1 -1
  108. package/dist/assets/web/dist/assets/{rst-CMCvf-yZ.js → rst-D9yuxBET.js} +1 -1
  109. package/dist/assets/web/dist/assets/{ruby-ChLBY_iR.js → ruby-CIRcEvnD.js} +1 -1
  110. package/dist/assets/web/dist/assets/{sas-cwn4x2VY.js → sas-LOl-uUPh.js} +1 -1
  111. package/dist/assets/web/dist/assets/{scss-jSrJlzLI.js → scss-BX2_sZHY.js} +1 -1
  112. package/dist/assets/web/dist/assets/{shellscript-D5nAd0gs.js → shellscript-4d-QvKkY.js} +1 -1
  113. package/dist/assets/web/dist/assets/{shellsession-BZY4-Tdh.js → shellsession-DhB16UGy.js} +1 -1
  114. package/dist/assets/web/dist/assets/{soy-COppPOKr.js → soy-Cm1xTUBr.js} +1 -1
  115. package/dist/assets/web/dist/assets/{sql-Blc_6dOA.js → sql-BsfcBJdu.js} +1 -1
  116. package/dist/assets/web/dist/assets/{sseReplay-DmyMXfRC.js → sseReplay-IzTdD4-3.js} +5 -5
  117. package/dist/assets/web/dist/assets/{stata-DRv3OY-h.js → stata-Dwat3BSw.js} +1 -1
  118. package/dist/assets/web/dist/assets/{surrealql-qMv_eeJ6.js → surrealql-P4rtxzAW.js} +1 -1
  119. package/dist/assets/web/dist/assets/{svelte-d1Edb6Rz.js → svelte-DTcsi8S8.js} +1 -1
  120. package/dist/assets/web/dist/assets/{templ-CUMSIOXG.js → templ-D-kP0Tg5.js} +1 -1
  121. package/dist/assets/web/dist/assets/{tex-BGZQA-Ei.js → tex-D-4vuedK.js} +1 -1
  122. package/dist/assets/web/dist/assets/{ts-tags-Z9ulxVsq.js → ts-tags-jEEWYxsu.js} +1 -1
  123. package/dist/assets/web/dist/assets/{tsx-oI4bI3Vf.js → tsx-CFnlrVzT.js} +1 -1
  124. package/dist/assets/web/dist/assets/{twig-D_RXwBIX.js → twig-D5rGrPjs.js} +1 -1
  125. package/dist/assets/web/dist/assets/{typescript-BgqQDYoN.js → typescript-nYIQ35or.js} +1 -1
  126. package/dist/assets/web/dist/assets/{typst-5MW-KRDd.js → typst-3-UX8dna.js} +1 -1
  127. package/dist/assets/web/dist/assets/{vue-CZhHHsPm.js → vue-CLIPIX03.js} +1 -1
  128. package/dist/assets/web/dist/assets/{vue-html-DlYy2d8v.js → vue-html-Ci9CpkHU.js} +1 -1
  129. package/dist/assets/web/dist/assets/{vue-vine-CJDnx-qn.js → vue-vine-DL5pEYYk.js} +1 -1
  130. package/dist/assets/web/dist/assets/{xml-BRBDk65Q.js → xml-bO80qikC.js} +1 -1
  131. package/dist/assets/web/dist/assets/{xsl-BlCykPxo.js → xsl-BPze60Ej.js} +1 -1
  132. package/dist/assets/web/dist/assets/{yaml-BqW48szf.js → yaml-BQUSon_o.js} +1 -1
  133. package/dist/cli.js +36552 -33171
  134. package/package.json +1 -1
  135. package/dist/assets/web/dist/assets/CostsPage-BoKzYa4B.js +0 -2
  136. package/dist/assets/web/dist/assets/HomePage-BG_ok-K2.js +0 -2
  137. package/dist/assets/web/dist/assets/PendingTurnRow-ChCQOLgZ.js +0 -1
  138. package/dist/assets/web/dist/assets/RunFoldRow-D3wVpzBa.js +0 -1
  139. package/dist/assets/web/dist/assets/RunRoutePage-B3IirUVi.js +0 -9
  140. package/dist/assets/web/dist/assets/RunsIndexPage-DiFmtGaJ.js +0 -1
  141. package/dist/assets/web/dist/assets/indexRow-BT0cPVRw.js +0 -1
  142. package/dist/assets/web/dist/assets/main-5Gm_1Gv8.js +0 -28
  143. package/dist/assets/web/dist/assets/main-zbP_dTjR.css +0 -1
@@ -66,6 +66,18 @@
66
66
  "script_name": "{{bot.script}}"
67
67
  }
68
68
  ],
69
+ // {{#if metrics}}
70
+ // The run-metrics dataset (docs/reference/specs/run-metrics.md): where RunHistoryDO
71
+ // writes a turned-final run's point, bound only when the deployment profile names a
72
+ // dataset (`metrics.dataset`) — nothing is created ahead of the deploy; the platform
73
+ // creates the dataset on first write. Without it the Worker deploys without the
74
+ // binding and writes no points.
75
+ "analytics_engine_datasets": [{ "binding": "RUN_METRICS", "dataset": "{{metrics.dataset}}" }],
76
+ // The dataset's name beside its binding (rendered together): /healthz `features`
77
+ // says `runMetrics:<dataset>` from it, so the bot's boot probe can compare the
78
+ // name its own config carries with the one this deploy bound.
79
+ "vars": { "RUN_METRICS_DATASET": "{{metrics.dataset}}" },
80
+ // {{/if}}
69
81
  // SQLite-backed DOs (durable across restarts — AGENTS.md invariant 6).
70
82
  "migrations": [
71
83
  { "tag": "v1", "new_sqlite_classes": ["MemoryDO"] },
@@ -1,5 +1,5 @@
1
1
  {
2
- "$comment": "The deployment profile: where THIS installation runs. Copy to deploy/profile.json and fill it in (or run `switchboard deploy init`). The account is your Cloudflare account id; every hostname must be under `zone`, a zone in that account, unless the Worker names its own `zone` (also in the account); `workers.bot` is the one required Worker — leave `memory`, `resident` or `sandbox` out and `deploy plan` has no step for them (a bot-only profile is a one-step plan; without `memory` the config is not pushed anywhere). The project's docs site is not a Worker of an installation: it is the project's website, deployed by the project's own CI from project.json, so there is no `docs` entry. `configSource` is where `deploy all` reads the bot's runtime config from before building the image — a path, `github://owner/repo/path@ref` (needs CONFIG_REPO_TOKEN), or `op://Vault/Item/field` (needs OP_SERVICE_ACCOUNT_TOKEN); `secretsSource` is where `secrets put` reads values from — a directory of <NAME> files (the default when absent) or `op://Vault/Item`. `images` is where the bot, resident and sandbox container images come from: `registry` — the release's published images, copied once per version into your account registry by `deploy all` itself (or `deploy images` ahead of it) over HTTPS and referenced from there — no Docker anywhere, a Cloudflare API token with Containers Edit in CLOUDFLARE_API_TOKEN for the copy (an installation deploying published images; what `init` writes from the published package); or `build` (the default when absent) — each Worker's Dockerfile, built by wrangler where `deploy all` runs (a checkout; the project's own production). `artifacts` (optional) names the R2 bucket a run's files move through (docs/reference/specs/execution.md item 20): with it the bot Worker binds the bucket and `deploy` creates it before the upload (the credential then needs Workers R2 Storage: Edit); the bot's runtime config `artifacts.r2.bucket` must say the same name. `deploy plan` reads this example when no profile exists; `deploy all` refuses it.",
2
+ "$comment": "The deployment profile: where THIS installation runs. Copy to deploy/profile.json and fill it in (or run `switchboard deploy init`). The account is your Cloudflare account id; every hostname must be under `zone`, a zone in that account, unless the Worker names its own `zone` (also in the account); `workers.bot` is the one required Worker — leave `memory`, `resident` or `sandbox` out and `deploy plan` has no step for them (a bot-only profile is a one-step plan; without `memory` the config is not pushed anywhere). The project's docs site is not a Worker of an installation: it is the project's website, deployed by the project's own CI from project.json, so there is no `docs` entry. `configSource` is where `deploy all` reads the bot's runtime config from before building the image — a path, `github://owner/repo/path@ref` (needs CONFIG_REPO_TOKEN), or `op://Vault/Item/field` (needs OP_SERVICE_ACCOUNT_TOKEN); `secretsSource` is where `secrets put` reads values from — a directory of <NAME> files (the default when absent) or `op://Vault/Item`. `images` is where the bot, resident and sandbox container images come from: `registry` — the release's published images, copied once per version into your account registry by `deploy all` itself (or `deploy images` ahead of it) over HTTPS and referenced from there — no Docker anywhere, a Cloudflare API token with Containers Edit in CLOUDFLARE_API_TOKEN for the copy (an installation deploying published images; what `init` writes from the published package); or `build` (the default when absent) — each Worker's Dockerfile, built by wrangler where `deploy all` runs (a checkout; the project's own production). `artifacts` (optional) names the R2 bucket a run's files move through (docs/reference/specs/execution.md item 20): with it the bot Worker binds the bucket and `deploy` creates it before the upload (the credential then needs Workers R2 Storage: Edit); the bot's runtime config `artifacts.r2.bucket` must say the same name. `metrics` (optional) names the Analytics Engine dataset every finished run's point is written to (docs/reference/specs/run-metrics.md): the state Worker's template then binds it as `RUN_METRICS` with the name beside it in `RUN_METRICS_DATASET` — nothing is created ahead of the deploy, the platform creates the dataset on first write — and the bot's runtime config `metrics.dataset` must say the same name (the bot warns at boot when the two differ). `deploy plan` reads this example when no profile exists; `deploy all` refuses it.",
3
3
  "account": "00000000000000000000000000000000",
4
4
  "zone": "example.com",
5
5
  "workers": {
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.252.0",
3
+ "version": "1.254.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "switchboard",
9
- "version": "1.252.0",
9
+ "version": "1.254.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.252.0",
20448
+ "version": "1.254.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.252.0",
3
+ "version": "1.254.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",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.252.0",
3
- "commit": "0c2a0a9cdd4afd1a0def0d43ed7a14928c32101e",
4
- "builtAt": "2026-09-19T03:40:43.236Z"
2
+ "version": "1.254.0",
3
+ "commit": "c554204338614ad3882f8e59654806c2a5a61aa9",
4
+ "builtAt": "2026-09-19T19:46:00.046Z"
5
5
  }
@@ -60,6 +60,12 @@ export const CHAT_OPEN_ACTIONS: readonly string[] = [
60
60
  "schedule:read",
61
61
  "memory:write",
62
62
  "mcp:write",
63
+ // `steer.run` (chat-only): the door admits every chat user because the real
64
+ // fence is the steer OWNER rule at the wired sender — the named run's
65
+ // requester, an id their identity record links, or a `runs:write` grant
66
+ // (authorization item 16a) — which the command door cannot see. Never held
67
+ // by a token: the machine surfaces have `send_to_run` and the ingress.
68
+ "steer:write",
63
69
  ];
64
70
 
65
71
  /** The two writes a browser session holds beside its reads (record 0043): the
@@ -58,6 +58,11 @@ export const POLICY: readonly Rule[] = [
58
58
  // List-shaped `runs.*`: the grant admits the command; the store predicate narrows the rows.
59
59
  { action: "runs:read", resource: "command", when: [grant("runs:read")] },
60
60
  { action: "runs:write", resource: "command", when: [grant("runs:write")] },
61
+ // `steer.run`: every chat user holds the grant (CHAT_OPEN_ACTIONS) because
62
+ // the fence is the steer owner rule at the wired sender, decided against the
63
+ // NAMED run (`authorizeSteerOwner`, authorization item 16a) — the requester,
64
+ // a linked id, or `runs:write` — which no command-door row can see.
65
+ { action: "steer:write", resource: "command", when: [grant("steer:write")] },
61
66
 
62
67
  // ── conversations ────────────────────────────────────────────────────────
63
68
  // Who may point the bot at another thread (record 0037). Asked for a
@@ -91,6 +96,12 @@ export const POLICY: readonly Rule[] = [
91
96
  // `all` and a named `grants` entry hold it).
92
97
  { action: "costs:write", resource: "command", when: [grant("costs:write")] },
93
98
 
99
+ // ── metrics ──────────────────────────────────────────────────────────────
100
+ // `metrics trend` reads the run-metrics dataset's report: the `costs:read`
101
+ // shape — a browser session's baseline, a grant for a Slack user or a token,
102
+ // never a chat baseline.
103
+ { action: "metrics:read", resource: "command", when: [grant("metrics:read")] },
104
+
94
105
  // ── providers ────────────────────────────────────────────────────────────
95
106
  // `providers check` reads the provider's own endpoints on request: a browser
96
107
  // session's read baseline, a grant everywhere else — an on-request provider
@@ -141,6 +152,14 @@ export const POLICY: readonly Rule[] = [
141
152
  { action: "config:write", resource: "config-scope", resourceKind: "thread", when: [grant("config:write")] },
142
153
  // A user edits only their own scope.
143
154
  { action: "config:write", resource: "config-scope", resourceKind: "user", when: [IS_SELF] },
155
+ // The author binding's one trusted write (record 0062, authorization.md item
156
+ // 18): `config set user --github` binds a person to a GitHub login, so it is
157
+ // its own action — `identity:write`, held by `all` and by a named grants
158
+ // entry, never a baseline — not the channel-config right: a `config:write`
159
+ // holder could otherwise author commits as any unbound person. The is-self
160
+ // row above does not carry it: a person never binds themself (`config set me
161
+ // --github` is refused at the registry door before this table is asked).
162
+ { action: "identity:write", resource: "config-scope", resourceKind: "user", when: [grant("identity:write")] },
144
163
 
145
164
  // ── agents ───────────────────────────────────────────────────────────────
146
165
  // `agent:run:*` covers every agent through wildcard coverage (grants.ts).
@@ -38,6 +38,14 @@ export const WEEK_MS = 7 * DAY_MS;
38
38
  * which stamps the expiry on its own clock. */
39
39
  export const CONFIRMATION_TTL_MS = 10 * MINUTE_MS;
40
40
 
41
+ /** How long a question's Yes stays pending (docs/decisions/0054, as amended;
42
+ * docs/reference/specs/routing-and-config.md item 25): one day. A Yes only
43
+ * re-dispatches the corrected message, which is then routed, authorized and,
44
+ * for a write, offered its own graded button — the write's ten-minute
45
+ * freshness is enforced downstream, so a short window here protects nothing
46
+ * and only loses answers. People answer a question in minutes to hours. */
47
+ export const QUESTION_TTL_MS = DAY_MS;
48
+
41
49
  /** How long a dispatch's FIRST attach to a resident — a fresh run's, or a
42
50
  * resumed run's re-attach to its recorded worktree — waits for the resident
43
51
  * to wake when the Worker typed its refusal as the platform's transient
@@ -175,6 +183,9 @@ export const HOSTED_DEADLINE_MARGIN_MINUTES = 60;
175
183
  * and the pause before a busy spawn is asked again. */
176
184
  export const SHIP_WAIT = { marginMinutes: 5, chunkMinutes: 5, mergeChunkMinutes: 5, busyRetryMinutes: 2 } as const;
177
185
 
186
+ /** The plane's table (docs/decisions/0064): how long a finished run stays on it. */
187
+ export const PLANE = { recentMinutes: 60 } as const;
188
+
178
189
  /** The named amounts a lease holds back, in minutes. Each stands for a step
179
190
  * every run or round pays: `provision` is attach and restore before the
180
191
  * harness's clock starts; `writeUp` is the final answer after the loop ends;
@@ -461,3 +472,17 @@ export const DEFAULT_GRANT: Grant = { renewals: 0 };
461
472
  * hand-over is now `renewals: 5` — the longest problem a person hands over
462
473
  * in one message before a plan should carry it. */
463
474
  export const GRANT_RENEWALS_MAX = 12;
475
+
476
+ // ---- the idle unit (record 0051: a pipeline idles instead of ending) ----
477
+
478
+ /** `ship.idleDays`' default: zero — today's endings byte for byte. It moves to
479
+ * seven only in the unit that lands after the baseline and the billing answer
480
+ * are recorded (record 0051's plan), never before. */
481
+ export const IDLE_DAYS_DEFAULT = 0;
482
+ /** The most days one idle may wait: the platform's own `waitForEvent` ceiling
483
+ * (a year), which the wait must pass explicitly — its default is 24 hours. */
484
+ export const IDLE_DAYS_MAX = 365;
485
+ /** The most wakes one idle answers before it expires: a bound on how long a
486
+ * busy thread can keep an instance parked (read by the wait, this plan's
487
+ * fifth unit). */
488
+ export const IDLE_WAKES_MAX = 100;
@@ -8,9 +8,10 @@
8
8
  // parent record the spawn route reads the requester from, and the names the
9
9
  // routes decide on.
10
10
  //
11
- import type { Grant, GrantSource } from "../budgets.js";
11
+ import { IDLE_DAYS_MAX, type Grant, type GrantSource } from "../budgets.js";
12
12
  import { isVerbosity, type Verbosity } from "../verbosity.js";
13
13
  import { isAddressSeverity, type AddressSeverity, type AddressSeveritySource } from "../ship/coordinator.js";
14
+ import { isHandoffShape, type Handoff } from "../ship/handoff.js";
14
15
 
15
16
  // A coordinator is a Workflow instance in the shim Worker whose children are
16
17
  // ordinary `dispatch()` runs as the requesting user. It holds no credential of
@@ -329,6 +330,11 @@ export interface CoordinatorInstance {
329
330
  * the unit threads it owns — the unit-ending report's asides and the
330
331
  * segment lines are `verbose` material. Absent reads as `quiet`. */
331
332
  verbosity?: Verbosity;
333
+ /** The idle flag (record 0051; agent-ship item 8): `ship.idleDays` as the
334
+ * ship fork resolved it (user > channel > org), written here beside the
335
+ * grant and answered by the plan route — above zero, an idling ending
336
+ * becomes `idle`; absent reads as zero, today's endings. */
337
+ idleDays?: number;
332
338
  /** The pipeline's caps as the profile gate clipped them: the rounds cap and the wall clock per unit. */
333
339
  caps?: { maxRounds: number; maxMinutes: number };
334
340
  /** The status card in the requesting thread, when the channel has one — what
@@ -350,6 +356,24 @@ export interface UnitSegment {
350
356
  at: number;
351
357
  }
352
358
 
359
+ /** The longest `why` an idle carries: an ending kind's name, never prose —
360
+ * the route refuses a longer one at the door, the row validator at the store. */
361
+ export const IDLE_WHY_MAX = 64;
362
+
363
+ /** The idle on a unit's row (record 0051): what a continuation needs, and the
364
+ * wakes spent against `IDLE_WAKES_MAX`. */
365
+ export interface UnitIdle {
366
+ /** The old ending kind the idle stands in for (`wall_clock_cap`, `stopped`, `continued`, …). */
367
+ why: string;
368
+ at: number;
369
+ renewalsLeft: number;
370
+ from?: string;
371
+ runId?: string;
372
+ spendUsd: number | null;
373
+ handoff?: Handoff;
374
+ wakes: number;
375
+ }
376
+
353
377
  /** One unit of the plan an instance runs (a task string is a generated plan of
354
378
  * one unit, `U1`): its branch, the units it waits on, and — as the runner
355
379
  * reaches it — its thread, its pull request, the round boundaries the card
@@ -398,6 +422,15 @@ export interface CoordinatorUnit {
398
422
  * renews the same segment twice. `from` is the sha the segment continues
399
423
  * from, `runId` the coding run whose write-up briefs it. */
400
424
  segments?: UnitSegment[];
425
+ /** The unit idles (record 0051; run-history item 50): written by `unit-end`
426
+ * on an `idle` ending in place of `ending`, so the unit stays unfinished
427
+ * and keeps owning its thread. `why` is the old kind, `renewalsLeft` what
428
+ * the grant still holds (the wake spends one — this plan's fifth unit),
429
+ * `from` the head a continuation opens from, `runId` the last coding
430
+ * child's run (absent when none ran), `spendUsd` the session's dollars
431
+ * (null once any run's cost is unknown), `handoff` that child's lists, and
432
+ * `wakes` how many wakes this idle has answered — zero at the write. */
433
+ idle?: UnitIdle;
401
434
  /** The round boundaries the coordinator reported, oldest first (the `ship_round`
402
435
  * vocabulary). `gate` rides an approve the machine's severity check caught
403
436
  * carrying a finding at or above the level in force ([agent-ship](../../../docs/reference/specs/agent-ship.md)
@@ -450,6 +483,11 @@ export function isCoordinatorInstance(v: unknown): v is CoordinatorInstance {
450
483
  if (r.caps !== undefined && !(isObject(r.caps) && isFinite(r.caps.maxRounds) && isFinite(r.caps.maxMinutes)))
451
484
  return false;
452
485
  if (r.card !== undefined && !(isObject(r.card) && isText(r.card.channel) && isText(r.card.ts))) return false;
486
+ if (
487
+ r.idleDays !== undefined &&
488
+ !(Number.isInteger(r.idleDays) && (r.idleDays as number) >= 0 && (r.idleDays as number) <= IDLE_DAYS_MAX)
489
+ )
490
+ return false;
453
491
  if (!isOptionalText(r.runId) || !isOptionalText(r.label)) return false;
454
492
  if (r.verbosity !== undefined && !isVerbosity(r.verbosity)) return false;
455
493
  if (r.attempt !== undefined && !(Number.isInteger(r.attempt) && (r.attempt as number) >= 2)) return false;
@@ -464,6 +502,17 @@ const isRoundGate = (v: unknown): boolean =>
464
502
  Array.isArray(v.findings) &&
465
503
  v.findings.every((f) => typeof f === "string");
466
504
 
505
+ const isUnitIdle = (v: unknown): boolean =>
506
+ isObject(v) &&
507
+ isText(v.why, IDLE_WHY_MAX) &&
508
+ isFinite(v.at) &&
509
+ isCount(v.renewalsLeft) &&
510
+ (v.from === undefined || isText(v.from)) &&
511
+ (v.runId === undefined || isText(v.runId)) &&
512
+ (v.spendUsd === null || isFinite(v.spendUsd)) &&
513
+ (v.handoff === undefined || isHandoffShape(v.handoff)) &&
514
+ isCount(v.wakes);
515
+
467
516
  const isSegment = (v: unknown): boolean =>
468
517
  isObject(v) &&
469
518
  typeof v.index === "number" &&
@@ -486,6 +535,7 @@ export function isCoordinatorUnit(v: unknown): v is CoordinatorUnit {
486
535
  if (r.resume !== undefined && !isResume(r.resume)) return false;
487
536
  if (r.lastPush !== undefined && !isText(r.lastPush)) return false;
488
537
  if (r.segments !== undefined && (!Array.isArray(r.segments) || !r.segments.every(isSegment))) return false;
538
+ if (r.idle !== undefined && !isUnitIdle(r.idle)) return false;
489
539
  if (
490
540
  !Array.isArray(r.rounds) ||
491
541
  r.rounds.length > MAX_ROUNDS ||
@@ -33,7 +33,7 @@
33
33
  // plan's re-issue. A task string's ship branch waits for a person. Node-free:
34
34
  // the shim Worker imports this by relative path.
35
35
 
36
- import { DEFAULT_GRANT, GRANT_RENEWALS_MAX, type Grant, type GrantSource } from "../budgets.js";
36
+ import { DEFAULT_GRANT, GRANT_RENEWALS_MAX, IDLE_DAYS_MAX, type Grant, type GrantSource } from "../budgets.js";
37
37
  import { DEFAULT_VERBOSITY, isVerbosity, type Verbosity } from "../verbosity.js";
38
38
  import {
39
39
  applyReturn,
@@ -42,6 +42,8 @@ import {
42
42
  openPlanCursor,
43
43
  openUnitPipeline,
44
44
  readyUnits,
45
+ type PlanCursor,
46
+ type UnitStatus,
45
47
  renderUnitReport,
46
48
  settleUnit,
47
49
  startUnit,
@@ -163,8 +165,18 @@ export function readBotAnswer(
163
165
  }
164
166
 
165
167
  /** The answers the bot itself calls a passing condition — the step is asked
166
- * again, under the policy. Everything else, refusals included, is the machine's. */
167
- const TRANSIENT = new Set(["github_unavailable", "no_channel", "thread_failed", "unit_not_started", "not_host"]);
168
+ * again, under the policy. Everything else, refusals included, is the machine's.
169
+ * `queued` is the plane's hold on a spawn (record 0064, "The queue"): a `409`
170
+ * saying the child's admission waits on an event — the step re-asks until the
171
+ * child is admitted, and the instance is never failed over a full plane. */
172
+ const TRANSIENT = new Set([
173
+ "github_unavailable",
174
+ "no_channel",
175
+ "thread_failed",
176
+ "unit_not_started",
177
+ "not_host",
178
+ "queued",
179
+ ]);
168
180
  export function transientRefusal(answer: BotAnswer): string | undefined {
169
181
  const { ok, error, message } = answer.body;
170
182
  if (ok !== false || typeof error !== "string" || !TRANSIENT.has(error)) return undefined;
@@ -191,6 +203,8 @@ interface PlanFacts {
191
203
  grantSource: GrantSource;
192
204
  /** The request's verbosity as the plan route answers it (routing-and-config item 28): what the unit threads hear. */
193
205
  verbosity: Verbosity;
206
+ /** The idle flag beside the grant (record 0051): above zero, an idling ending becomes `idle`. */
207
+ idleDays: number;
194
208
  /** The instance's mark as the plan route answers it: a generated one-unit plan (a `plan` with no `path`). */
195
209
  generated: boolean;
196
210
  /** The runs page base the bot answered: the report links a child's write-up to its run page with it. */
@@ -220,6 +234,12 @@ function readGrant(raw: unknown): Grant {
220
234
  return { renewals: raw.renewals, ...(typeof cap === "number" && cap > 0 ? { costCapUsd: cap } : {}) };
221
235
  }
222
236
 
237
+ /** The idle flag as the plan route answers it (record 0051): an integer from 0
238
+ * to the module's cap; anything unreadable is 0 — nothing idles on a guess. */
239
+ function readIdleDays(raw: unknown): number {
240
+ return typeof raw === "number" && Number.isInteger(raw) && raw >= 0 && raw <= IDLE_DAYS_MAX ? raw : 0;
241
+ }
242
+
223
243
  function readPlan(a: BotAnswer): PlanFacts {
224
244
  const b = a.body;
225
245
  if (b.ok !== true) throw new UnreadableAnswer("plan", a, "not ok");
@@ -240,6 +260,7 @@ function readPlan(a: BotAnswer): PlanFacts {
240
260
  grantSource:
241
261
  b.grantSource === "run" || b.grantSource === "user" || b.grantSource === "channel" ? b.grantSource : "org",
242
262
  verbosity: isVerbosity(b.verbosity) ? b.verbosity : DEFAULT_VERBOSITY,
263
+ idleDays: readIdleDays(b.idleDays),
243
264
  generated: b.generated === true,
244
265
  ...(typeof b.runPageBase === "string" && b.runPageBase.length > 0 ? { runPageBase: b.runPageBase } : {}),
245
266
  repo: b.repo,
@@ -369,6 +390,11 @@ function prCheckReturn(step: string, a: BotAnswer): StepReturn {
369
390
  prNumber,
370
391
  url,
371
392
  ...(typeof headSha === "string" ? { headSha } : {}),
393
+ // The entry facts (issue 1689): the branch's own tip and whether the
394
+ // bot's approval stands at the head, read at the unit-start's pre-check
395
+ // so a re-issued plan resumes at review or at the merge decision.
396
+ ...(typeof a.body.branchHead === "string" ? { branchHead: a.body.branchHead } : {}),
397
+ ...(typeof a.body.approved === "boolean" ? { approved: a.body.approved } : {}),
372
398
  ...(typeof a.body.autoMergeEnabled === "boolean" ? { autoMergeEnabled: a.body.autoMergeEnabled } : {}),
373
399
  ...(isCommitChecks(a.body.checks) ? { checks: a.body.checks } : {}),
374
400
  // The ready-state facts beside the checks (agent-ship item 9): the
@@ -563,6 +589,10 @@ async function perform(
563
589
  await step.do(action.step, STEP_CONFIG, () =>
564
590
  call(bot, "pr-check", {
565
591
  ...tag,
592
+ // `entry` is the unit-start's pre-check (issue 1689): the bot
593
+ // reads the branch's tip, the approval and the checks beside the
594
+ // listing, so a re-issued plan's unit resumes instead of recoding.
595
+ ...(action.entry === true ? { entry: true } : {}),
566
596
  ...(action.recover !== undefined ? { recover: action.recover } : {}),
567
597
  ...(action.pr !== undefined ? { pr: action.pr } : {}),
568
598
  }),
@@ -662,6 +692,7 @@ async function runUnit(
662
692
  grant: plan.grant,
663
693
  grantSource: plan.grantSource,
664
694
  verbosity: plan.verbosity,
695
+ idleDays: plan.idleDays,
665
696
  generated: plan.generated,
666
697
  ...(plan.runPageBase !== undefined ? { runPageBase: plan.runPageBase } : {}),
667
698
  ...(resume !== undefined ? { resume } : {}),
@@ -734,13 +765,28 @@ async function runUnit(
734
765
  kind: note.ending.kind,
735
766
  report: renderUnitReport(state, endFacts),
736
767
  threadReport: renderUnitReport(state, endFacts, state.input.verbosity ?? DEFAULT_VERBOSITY),
768
+ // An idle ending carries its continuation facts (record 0051): the
769
+ // bot writes them on the row's `idle` in place of an ending, with
770
+ // the coding run id it already receives below.
771
+ ...(note.ending.kind === "idle"
772
+ ? {
773
+ why: note.ending.why,
774
+ renewalsLeft: note.ending.renewalsLeft,
775
+ ...(note.ending.from !== undefined ? { from: note.ending.from } : {}),
776
+ spendUsd: note.ending.spendUsd,
777
+ ...(note.ending.handoff !== undefined ? { handoff: note.ending.handoff } : {}),
778
+ }
779
+ : {}),
737
780
  },
738
781
  ...(state.pr !== undefined ? { pr: state.pr } : {}),
739
782
  // A review_pending ending names the child's own last push so the next
740
- // attempt's pre-check can start at the review round (the row's lastPush).
783
+ // attempt's pre-check can start at the review round (the row's lastPush)
784
+ // — an idled one the same, off the idle's `from` (record 0051).
741
785
  ...(note.ending.kind === "review_pending" && note.ending.headSha !== undefined
742
786
  ? { headSha: note.ending.headSha }
743
- : {}),
787
+ : note.ending.kind === "idle" && note.ending.why === "review_pending" && note.ending.from !== undefined
788
+ ? { headSha: note.ending.from }
789
+ : {}),
744
790
  ...(state.lastCodingRunId !== undefined ? { codingRunId: state.lastCodingRunId } : {}),
745
791
  // A continued ending is a segment's end, not the unit's: the bot
746
792
  // writes the renewal as a row keyed by the next segment's index
@@ -768,12 +814,9 @@ function blockedReport(unit: string, dep: string, depEnding: string): string {
768
814
  return `⛔ Blocked: ${unit} waits on ${dep}, which ended ${depEnding}${person ? " — a person's merge" : ""}. Re-issue the plan naming the remaining units once it is ${person ? "merged" : "resolved"}.`;
769
815
  }
770
816
 
771
- /** The plan: its units one at a time in dependency order, then the endings of the units it never reached. */
772
- async function walk(step: StepRunner, bot: CoordinatorBot, instanceId: string): Promise<PlanRunSummary> {
773
- const plan = readPlan(
774
- answerOf("plan", await step.do("plan", STEP_CONFIG, () => call(bot, "plan", { parentInstanceId: instanceId }))),
775
- );
776
- const graph: PlanGraph = {
817
+ /** The graph as one plan answer carries it: the instance's unit rows, in the plan's order. */
818
+ function graphOf(plan: PlanFacts, instanceId: string): PlanGraph {
819
+ return {
777
820
  planId: plan.planId ?? instanceId,
778
821
  units: plan.units.map((u) => ({
779
822
  id: u.unit,
@@ -783,6 +826,54 @@ async function walk(step: StepRunner, bot: CoordinatorBot, instanceId: string):
783
826
  dependsOn: u.dependsOn,
784
827
  })),
785
828
  };
829
+ }
830
+
831
+ /** The cursor over a freshly read selection (the orchestration-plane plan's
832
+ * re-read requirement): a surviving unit keeps its standing, a row appended
833
+ * mid-walk joins pending — walked after the current unit — and a row gone from the selection
834
+ * was merged by hand: it is walked as merged with nothing run and nothing
835
+ * told (`gone`), a dependency on it counted satisfied like any dependency
836
+ * outside the selection. `blocked` is derived state, so it is recomputed over
837
+ * the fresh graph rather than carried — a blocked unit whose failing
838
+ * dependency left the selection is in play again. */
839
+ function rereadCursor(cursor: PlanCursor, fresh: PlanGraph): { cursor: PlanCursor; gone: string[] } {
840
+ const gone = cursor.order.filter((id) => !fresh.units.some((u) => u.id === id));
841
+ const order = fresh.units.map((u) => u.id);
842
+ const status: Record<string, UnitStatus> = {};
843
+ for (const id of order) {
844
+ const prior = cursor.status[id];
845
+ status[id] = prior === undefined || prior === "blocked" ? "pending" : prior;
846
+ }
847
+ let changed = true;
848
+ while (changed) {
849
+ changed = false;
850
+ for (const u of fresh.units) {
851
+ if (status[u.id] !== "pending") continue;
852
+ if (u.dependsOn.some((d) => d in status && (status[d] === "failed" || status[d] === "blocked"))) {
853
+ status[u.id] = "blocked";
854
+ changed = true;
855
+ }
856
+ }
857
+ }
858
+ return { cursor: { order, status }, gone };
859
+ }
860
+
861
+ /** The plan: its units one at a time in dependency order, then the endings of the units it never reached. */
862
+ async function walk(step: StepRunner, bot: CoordinatorBot, instanceId: string): Promise<PlanRunSummary> {
863
+ // The selection is read at every unit boundary (the orchestration-plane plan): the first read opens
864
+ // the cursor, and each boundary's — its own durable step, `plan/<n>`, so a
865
+ // replay meets the same read — rebuilds it, so a later bot can append a unit
866
+ // to a live instance or drop one merged by hand without killing it.
867
+ let reads = 0;
868
+ const readSelection = async (): Promise<PlanFacts> => {
869
+ reads += 1;
870
+ const name = reads === 1 ? "plan" : `plan/${reads}`;
871
+ return readPlan(
872
+ answerOf("plan", await step.do(name, STEP_CONFIG, () => call(bot, "plan", { parentInstanceId: instanceId }))),
873
+ );
874
+ };
875
+ let plan = await readSelection();
876
+ let graph = graphOf(plan, instanceId);
786
877
  let cursor = openPlanCursor(graph);
787
878
  const endings: Record<string, string> = {};
788
879
  for (;;) {
@@ -809,7 +900,16 @@ async function walk(step: StepRunner, bot: CoordinatorBot, instanceId: string):
809
900
  endings[next] = ending.kind;
810
901
  // A unit is done for its dependents when the base carries its scope: the
811
902
  // runner's merge, or a scope that had already landed before the attempt.
903
+ // An `idle` ending settles the unit `failed` for now: nothing waits yet —
904
+ // the indexed wait and the wake land with the fifth unit of record 0051's
905
+ // plan — so the walk is unchanged until then and the flag ships at zero.
812
906
  cursor = settleUnit(graph, cursor, next, isSettledDone(ending.kind) ? "done" : "failed");
907
+ // The unit boundary's re-read (the orchestration-plane plan): the fresh rows are the selection now.
908
+ plan = await readSelection();
909
+ graph = graphOf(plan, instanceId);
910
+ const reread = rereadCursor(cursor, graph);
911
+ cursor = reread.cursor;
912
+ for (const id of reread.gone) endings[id] ??= "merged";
813
913
  }
814
914
  // Blocked units, in the plan's order: each told its own ending, so the rows
815
915
  // and the summary say why it never ran. Every blocked unit's ending is known