tumwater 0.4.2 → 0.4.3

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 (305) hide show
  1. package/README.md +6 -6
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-backend.js +6 -6
  4. package/dist/src/agent/{run-agent.js → agent-run.js} +25 -12
  5. package/dist/src/agent/{run-settle.js → agent-settle.js} +48 -24
  6. package/dist/src/agent/{run-spawn.js → agent-spawn.js} +21 -12
  7. package/dist/src/agent/{run-stream.js → agent-stream.js} +4 -4
  8. package/dist/src/agent/backend-traits.js +2 -2
  9. package/dist/src/agent/claude/claude-account.js +1 -8
  10. package/dist/src/agent/claude/claude-backend.js +3 -3
  11. package/dist/src/agent/claude/claude-env.js +27 -31
  12. package/dist/src/agent/claude/claude-stream.js +111 -54
  13. package/dist/src/agent/codex/codex-account.js +9 -0
  14. package/dist/src/agent/codex/codex-backend.js +2 -2
  15. package/dist/src/agent/codex/codex-env.js +28 -15
  16. package/dist/src/agent/codex/codex-hook-trust.js +39 -20
  17. package/dist/src/agent/codex/codex-rollout.js +93 -56
  18. package/dist/src/agent/codex/codex-stream.js +1 -1
  19. package/dist/src/agent/env-scrub.js +18 -0
  20. package/dist/src/agent/image-input.js +68 -11
  21. package/dist/src/agent/notes-shim.js +2 -2
  22. package/dist/src/agent/opencode/opencode-backend.js +2 -2
  23. package/dist/src/agent/opencode/opencode-catalog.js +50 -27
  24. package/dist/src/agent/opencode/opencode-config.js +32 -14
  25. package/dist/src/agent/opencode/opencode-env.js +12 -13
  26. package/dist/src/agent/opencode/opencode-stream.js +47 -25
  27. package/dist/src/agent/session-ledger.js +1 -1
  28. package/dist/src/agent/subscription-account.js +3 -3
  29. package/dist/src/backlog/backlog-cli-view.js +81 -41
  30. package/dist/src/backlog/backlog-cli.js +57 -26
  31. package/dist/src/backlog/backlog-eligibility.js +58 -102
  32. package/dist/src/backlog/backlog-md.js +5 -5
  33. package/dist/src/backlog/backlog-parts.js +65 -27
  34. package/dist/src/backlog/backlog-release.js +12 -12
  35. package/dist/src/backlog/backlog-structure.js +114 -61
  36. package/dist/src/backlog/backlog.js +1 -1
  37. package/dist/src/baseline/main-baseline.js +148 -84
  38. package/dist/src/budget/budget-fallback-view.js +100 -243
  39. package/dist/src/budget/budget-wiring.js +29 -65
  40. package/dist/src/budget/fallback-breaker.js +2 -2
  41. package/dist/src/build/build-check-detect.js +43 -14
  42. package/dist/src/build/build-check-events.js +2 -2
  43. package/dist/src/build/build-check-scoped.js +40 -16
  44. package/dist/src/build/build-check.js +13 -5
  45. package/dist/src/cli/cli-command-args.js +89 -52
  46. package/dist/src/cli/cli-flag-specs.js +42 -22
  47. package/dist/src/cli/cli-init.js +34 -12
  48. package/dist/src/cli/cli-query-commands.js +1 -1
  49. package/dist/src/cli/cli-repo-gate.js +2 -2
  50. package/dist/src/cli/cli-start.js +91 -58
  51. package/dist/src/cli/completion.js +82 -46
  52. package/dist/src/cli/config-commands.js +82 -71
  53. package/dist/src/cli/help.js +4 -6
  54. package/dist/src/cli/log-commands.js +43 -23
  55. package/dist/src/cli/notify-command.js +2 -2
  56. package/dist/src/cli/question-commands.js +86 -60
  57. package/dist/src/cli.js +181 -135
  58. package/dist/src/collections.js +31 -16
  59. package/dist/src/concurrency/check-slots.js +70 -0
  60. package/dist/src/concurrency/lock.js +22 -0
  61. package/dist/src/concurrency/run-permit.js +49 -0
  62. package/dist/src/concurrency/semaphore.js +46 -250
  63. package/dist/src/config/config-editable-keys.js +4 -7
  64. package/dist/src/config/config-fallbacks.js +81 -101
  65. package/dist/src/config/config-field-checks.js +66 -37
  66. package/dist/src/config/config-keys.js +5 -20
  67. package/dist/src/config/config-live.js +4 -28
  68. package/dist/src/config/{model-selector.js → config-model-selector.js} +4 -4
  69. package/dist/src/config/config-request.js +153 -102
  70. package/dist/src/config/config-schema.js +9 -11
  71. package/dist/src/config/config-selector-checks.js +1 -1
  72. package/dist/src/config/config-validation-agents.js +2 -2
  73. package/dist/src/config/config-validation-roles.js +4 -52
  74. package/dist/src/config/config-validation.js +4 -38
  75. package/dist/src/config/config-views.js +9 -34
  76. package/dist/src/config/config-write.js +6 -23
  77. package/dist/src/config/config.js +4 -5
  78. package/dist/src/doctor/doctor-backlog-bugs.js +7 -6
  79. package/dist/src/doctor/doctor-backlog-plans.js +24 -24
  80. package/dist/src/doctor/doctor-backlog.js +7 -5
  81. package/dist/src/doctor/doctor-check-names.js +3 -6
  82. package/dist/src/doctor/doctor-checks.js +5 -5
  83. package/dist/src/doctor/doctor-claude-checks.js +44 -28
  84. package/dist/src/doctor/doctor-codex-checks.js +6 -6
  85. package/dist/src/doctor/doctor-contention-checks.js +44 -112
  86. package/dist/src/doctor/doctor-fallback-checks.js +2 -2
  87. package/dist/src/doctor/doctor-holds.js +6 -45
  88. package/dist/src/doctor/doctor-install.js +1 -1
  89. package/dist/src/doctor/doctor-launch-services.js +1 -1
  90. package/dist/src/doctor/doctor-model-checks.js +178 -94
  91. package/dist/src/doctor/doctor-notify.js +2 -2
  92. package/dist/src/doctor/doctor-opencode-checks.js +5 -4
  93. package/dist/src/doctor/doctor-orphans.js +120 -20
  94. package/dist/src/doctor/{outcome.js → doctor-outcome.js} +7 -0
  95. package/dist/src/doctor/doctor-pi-checks.js +2 -2
  96. package/dist/src/doctor/doctor-principles.js +3 -3
  97. package/dist/src/doctor/doctor-project-checks.js +3 -3
  98. package/dist/src/doctor/doctor-role-notes.js +1 -1
  99. package/dist/src/doctor/doctor.js +8 -11
  100. package/dist/src/events/event-format.js +30 -54
  101. package/dist/src/events/event-labels.js +1 -5
  102. package/dist/src/events/event-legacy.js +1 -3
  103. package/dist/src/events/{notify.js → event-notify.js} +2 -2
  104. package/dist/src/events/event-read.js +1 -1
  105. package/dist/src/failure/failure-cluster.js +6 -6
  106. package/dist/src/failure/failure-data.js +8 -4
  107. package/dist/src/failure/failure-render.js +44 -34
  108. package/dist/src/failure/failure-state-change.js +4 -34
  109. package/dist/src/failure/{time-spend.js → failure-time-spend.js} +117 -84
  110. package/dist/src/files/files.js +30 -0
  111. package/dist/src/files/json-object.js +8 -8
  112. package/dist/src/files/stat-cache.js +12 -8
  113. package/dist/src/gates/budget-gates.js +85 -84
  114. package/dist/src/gates/gate-polls.js +27 -68
  115. package/dist/src/gates/pause-gates.js +16 -2
  116. package/dist/src/gates/readiness.js +1 -1
  117. package/dist/src/gates/role-cap-gates.js +44 -35
  118. package/dist/src/gates/{startup-gate.js → startup-gates.js} +7 -7
  119. package/dist/src/gates/{streak-gate.js → streak-gates.js} +75 -52
  120. package/dist/src/git/git.js +75 -45
  121. package/dist/src/git/worktree-pool.js +3 -6
  122. package/dist/src/gui/gui-admin-endpoints.js +3 -3
  123. package/dist/src/gui/gui-backlog-commands.js +4 -4
  124. package/dist/src/gui/gui-backlog-endpoints.js +61 -41
  125. package/dist/src/gui/gui-command.js +43 -24
  126. package/dist/src/gui/gui-history-endpoints.js +100 -52
  127. package/dist/src/gui/gui-operator-commands.js +22 -23
  128. package/dist/src/gui/gui-prompt-commands.js +23 -23
  129. package/dist/src/gui/gui-report-endpoints.js +1 -1
  130. package/dist/src/history/history-data.js +71 -44
  131. package/dist/src/history/history-queued.js +49 -27
  132. package/dist/src/history/history.js +51 -33
  133. package/dist/src/inbox/prompt-commands.js +59 -42
  134. package/dist/src/init/init.js +88 -40
  135. package/dist/src/merge/merge-backlog-conflicts.js +1 -1
  136. package/dist/src/merge/merge-core.js +1 -1
  137. package/dist/src/merge/merge-drain.js +8 -11
  138. package/dist/src/merge/merge-pipeline.js +6 -7
  139. package/dist/src/merge/merge-post-review-drain.js +10 -13
  140. package/dist/src/merge/merge-requeue.js +1 -1
  141. package/dist/src/merge/merge-slot.js +0 -8
  142. package/dist/src/merge/merge-verify.js +8 -18
  143. package/dist/src/notes-cli.js +52 -42
  144. package/dist/src/operator/operator-commands.js +76 -44
  145. package/dist/src/operator/operator-intent.js +3 -3
  146. package/dist/src/orchestrator/orchestrator-launch.js +31 -63
  147. package/dist/src/orchestrator/orchestrator-poll.js +27 -32
  148. package/dist/src/orchestrator/orchestrator.js +8 -21
  149. package/dist/src/orchestrator/scheduling-admit.js +101 -68
  150. package/dist/src/orchestrator/scheduling-scope.js +1 -1
  151. package/dist/src/orchestrator/scheduling-skip.js +1 -1
  152. package/dist/src/orchestrator/startup-migrations.js +1 -1
  153. package/dist/src/paths.js +8 -7
  154. package/dist/src/pi/pi-args.js +5 -2
  155. package/dist/src/pi/pi-backend.js +2 -2
  156. package/dist/src/pi/pi-bin.js +3 -3
  157. package/dist/src/pi/pi-event-line.js +1 -1
  158. package/dist/src/pi/pi-models.js +43 -29
  159. package/dist/src/pi/pi-settings.js +43 -20
  160. package/dist/src/pi/pi-stream.js +32 -6
  161. package/dist/src/pi/pi-watchdogs.js +12 -5
  162. package/dist/src/pi/pi.js +2 -2
  163. package/dist/src/pi-extension/context-shake.js +32 -20
  164. package/dist/src/pi-extension/kill-guard.js +69 -0
  165. package/dist/src/process/process-group.js +49 -5
  166. package/dist/src/process/process.js +8 -8
  167. package/dist/src/process/run-marker.js +31 -13
  168. package/dist/src/process/supervisor.js +40 -28
  169. package/dist/src/prompt/dialect.js +1 -1
  170. package/dist/src/prompt/gate-notes.js +2 -2
  171. package/dist/src/prompt/notes-backlog.js +66 -37
  172. package/dist/src/prompt/principles.js +6 -4
  173. package/dist/src/prompt/prompt-followup.js +12 -4
  174. package/dist/src/prompt/prompt.js +88 -38
  175. package/dist/src/report/report-data.js +53 -37
  176. package/dist/src/report/report-fold.js +16 -9
  177. package/dist/src/report/report-render.js +21 -194
  178. package/dist/src/report/report-since-data.js +4 -3
  179. package/dist/src/report/report-since-render.js +173 -0
  180. package/dist/src/report/report-usage-fold.js +8 -8
  181. package/dist/src/report/report.js +46 -22
  182. package/dist/src/review/post-merge-review-render.js +9 -3
  183. package/dist/src/review/post-merge-review.js +1 -1
  184. package/dist/src/review/review-precheck.js +11 -1
  185. package/dist/src/review/review.js +40 -153
  186. package/dist/src/review/secret-leaks.js +26 -16
  187. package/dist/src/roles/role-catalog.js +11 -3
  188. package/dist/src/roles/role-guidance.js +16 -12
  189. package/dist/src/roles/role-render.js +1 -14
  190. package/dist/src/roles/role-view.js +9 -21
  191. package/dist/src/run/acceptance-greps.js +88 -45
  192. package/dist/src/run/run-apply.js +12 -6
  193. package/dist/src/run/run-detail-data.js +121 -68
  194. package/dist/src/run/run-detail-render.js +92 -35
  195. package/dist/src/run/run-detail.js +72 -41
  196. package/dist/src/run/run-prompt.js +63 -50
  197. package/dist/src/run/run-resume.js +76 -29
  198. package/dist/src/run/run-stage.js +135 -84
  199. package/dist/src/run/run-timing.js +8 -2
  200. package/dist/src/run/run-usage.js +31 -1
  201. package/dist/src/run/run-verdict.js +119 -50
  202. package/dist/src/run/stage-check.js +49 -32
  203. package/dist/src/run/stage-checks-shared.js +28 -0
  204. package/dist/src/run/stage-name-checks.js +111 -92
  205. package/dist/src/run/stage-path-checks.js +69 -42
  206. package/dist/src/run/stage-prose-checks.js +55 -40
  207. package/dist/src/runner/runner-outcome.js +6 -1
  208. package/dist/src/runner/runner-pi.js +3 -10
  209. package/dist/src/runner/runner-recovery.js +1 -1
  210. package/dist/src/runner/{revision.js → runner-revision.js} +1 -1
  211. package/dist/src/runner/runner-run.js +97 -35
  212. package/dist/src/runner/runner-state-load.js +12 -9
  213. package/dist/src/runner/runner.js +41 -25
  214. package/dist/src/scheduling/claims.js +0 -4
  215. package/dist/src/scheduling/scheduling.js +53 -66
  216. package/dist/src/status/status-data.js +2 -2
  217. package/dist/src/status/status-role-notes.js +2 -2
  218. package/dist/src/status/status-roles.js +9 -17
  219. package/dist/src/team/{error-storm.js → team-error-storm.js} +1 -1
  220. package/dist/src/team/{failure-spread.js → team-failure-spread.js} +5 -5
  221. package/dist/src/team/team-hold.js +98 -65
  222. package/dist/src/team/{orchestrator-info.js → team-orchestrator-info.js} +2 -15
  223. package/dist/src/team/team-polls.js +28 -126
  224. package/dist/src/team/{reclaim.js → team-reclaim.js} +61 -31
  225. package/dist/src/team/team-state.js +4 -13
  226. package/dist/src/text/markdown.js +1 -1
  227. package/dist/src/text/phrases.js +20 -15
  228. package/dist/src/text/text.js +3 -3
  229. package/dist/src/ui/alert-feeds.js +49 -73
  230. package/dist/src/ui/badges.js +3 -13
  231. package/dist/src/ui/gui/gui-client-boot.js +2 -2
  232. package/dist/src/ui/gui/gui-client-composer.js +1 -1
  233. package/dist/src/ui/gui/gui-client-drawer-config.js +7 -17
  234. package/dist/src/ui/gui/gui-client-drawer.js +1 -1
  235. package/dist/src/ui/gui/gui-client-history.js +28 -14
  236. package/dist/src/ui/gui/gui-client-model.js +0 -12
  237. package/dist/src/ui/gui/gui-client-pending.js +7 -6
  238. package/dist/src/ui/gui/gui-client-report-chart.js +5 -5
  239. package/dist/src/ui/gui/gui-client-report.js +1 -1
  240. package/dist/src/ui/gui/gui-client-roles.js +0 -1
  241. package/dist/src/ui/gui/gui-client-settings.js +43 -84
  242. package/dist/src/ui/gui/gui-client-team.js +4 -4
  243. package/dist/src/ui/gui/gui-client.js +7 -1
  244. package/dist/src/ui/progress-data.js +53 -40
  245. package/dist/src/ui/run-progress-model.js +9 -2
  246. package/dist/src/ui/status-model.js +2 -24
  247. package/dist/src/ui/status-payload.js +3 -7
  248. package/dist/src/ui/status-render.js +5 -9
  249. package/dist/src/ui/team-alert-backlog.js +6 -6
  250. package/dist/src/ui/team-alert-status.js +9 -170
  251. package/dist/src/ui/team-alert-tail.js +19 -215
  252. package/dist/src/ui/team-alerts.js +7 -8
  253. package/dist/src/ui/transcript-run.js +27 -16
  254. package/dist/src/ui/tui/tui-editor-answer.js +12 -10
  255. package/dist/src/ui/tui/tui-editor-attach.js +4 -3
  256. package/dist/src/ui/tui/tui-editor-backends.js +4 -3
  257. package/dist/src/ui/tui/tui-editor-brief.js +6 -9
  258. package/dist/src/ui/tui/tui-editor-budget.js +4 -3
  259. package/dist/src/ui/tui/tui-editor-config.js +80 -68
  260. package/dist/src/ui/tui/tui-editor-entry.js +12 -20
  261. package/dist/src/ui/tui/tui-editor-pause.js +51 -57
  262. package/dist/src/ui/tui/tui-editor-prompt.js +9 -13
  263. package/dist/src/ui/tui/tui-editor-queued.js +11 -14
  264. package/dist/src/ui/tui/tui-editor-quiet.js +4 -3
  265. package/dist/src/ui/tui/tui-editor-release.js +4 -3
  266. package/dist/src/ui/tui/tui-editor-retire.js +7 -7
  267. package/dist/src/ui/tui/tui-editor-role-enabled.js +4 -3
  268. package/dist/src/ui/tui/tui-editor-role-file.js +4 -4
  269. package/dist/src/ui/tui/tui-editor-role-key.js +4 -3
  270. package/dist/src/ui/tui/tui-editor-role-model.js +97 -15
  271. package/dist/src/ui/tui/tui-editor-search.js +4 -3
  272. package/dist/src/ui/tui/tui-editor-session.js +17 -4
  273. package/dist/src/ui/tui/tui-editors.js +1 -7
  274. package/dist/src/ui/tui/tui-frame.js +182 -134
  275. package/dist/src/ui/tui/tui-guard.js +20 -0
  276. package/dist/src/ui/tui/tui-input.js +159 -92
  277. package/dist/src/ui/tui/tui-keymap.js +51 -29
  278. package/dist/src/ui/tui/tui-keys.js +1 -1
  279. package/dist/src/ui/tui/tui-pane-activity.js +3 -3
  280. package/dist/src/ui/tui/tui-pane-backlog.js +74 -36
  281. package/dist/src/ui/tui/tui-pane-ctx.js +1 -1
  282. package/dist/src/ui/tui/tui-pane-health.js +252 -228
  283. package/dist/src/ui/tui/tui-pane-history.js +61 -70
  284. package/dist/src/ui/tui/tui-pane-pending.js +284 -247
  285. package/dist/src/ui/tui/tui-pane-queued.js +25 -27
  286. package/dist/src/ui/tui/tui-pane-report.js +2 -1
  287. package/dist/src/ui/tui/tui-pane-session.js +11 -7
  288. package/dist/src/ui/tui/tui-pane-transcript.js +19 -31
  289. package/dist/src/ui/tui/tui-pane.js +14 -11
  290. package/dist/src/ui/tui/tui-panes.js +29 -32
  291. package/dist/src/ui/tui/tui.js +3 -4
  292. package/dist/src/verdict/fix-claim.js +76 -54
  293. package/dist/src/verdict/refusal.js +1 -1
  294. package/package.json +5 -5
  295. package/dist/src/concurrency/provider-lease.js +0 -105
  296. package/dist/src/runner/model-fallback.js +0 -76
  297. package/dist/src/runner/runner-fallback.js +0 -66
  298. package/dist/src/ui/tui/tui-editor-claude-reserve.js +0 -24
  299. package/dist/src/ui/tui/tui-editor-provider-caps.js +0 -27
  300. /package/dist/src/backlog/{question-answer.js → backlog-question-answer.js} +0 -0
  301. /package/dist/src/doctor/{probe.js → doctor-probe.js} +0 -0
  302. /package/dist/src/gates/{disk-gate.js → disk-gates.js} +0 -0
  303. /package/dist/src/operator/{retire.js → operator-retire.js} +0 -0
  304. /package/dist/src/operator/{revert.js → operator-revert.js} +0 -0
  305. /package/dist/src/runner/{leftover.js → runner-leftover.js} +0 -0
@@ -13,91 +13,78 @@ import { BUGFIX_ROLE, DEFERRABLE_ROLES, DIRECTOR_ROLE, roleTier, } from "../role
13
13
  import { baseRoleOf, runnerEnabled } from "../roles/runner-ids.js";
14
14
  import { HOUR_MS } from "../text/datetime.js";
15
15
  import { yieldMultiplierFor } from "./backoff.js";
16
- /** Should this role run now? `inboxCount` is the calling role's own queued-prompt count
17
- * (the director's queue for the director, the per-role queue for everyone else) — see the
18
- * inbox check below. Exported for tests. */
19
- export function isEligible(runner, now, mainHead, inboxCount, opts = {}) {
20
- const s = runner.state;
21
- // A role disabled in tumwater.json stops running immediately (live-reload); re-enabling
22
- // resumes within one poll cycle because the runner and its persisted state survive.
23
- if (!runnerEnabled(runner.config, runner.role))
24
- return { run: false };
25
- if (s.running)
26
- return { run: false };
27
- // The director carries the user's own requests: no min-gap, no backoff — a queued
28
- // prompt runs as soon as the previous one finishes.
29
- if (runner.role === DIRECTOR_ROLE) {
30
- if (inboxCount > 0)
31
- return { run: true, reason: "inbox" };
32
- if (opts.leftoverPending)
33
- return now >= s.nextRunAt ? { run: true, reason: "leftover" } : { run: false };
34
- return { run: false };
35
- }
36
- // An interrupted run (graceful abort or crash) leaves half-finished work in its pi
37
- // session and worktree: resume it promptly on restart instead of holding it for a full
38
- // interval — the min gap below throttles scheduled runs, not recovery. nextRunAt still
39
- // gates cut-off resumes, which deliberately wait one interval from their compacted context.
40
- // A queued prompt never preempts this gate: a resuming run assembles buildResumePrompt
41
- // and does not dequeue the per-role queue, so an early run would only resume sooner than
42
- // its deliberate wait — the prompt is consumed by the fresh run the inbox check below
43
- // makes due the moment the resume finishes.
16
+ /** Can the runner start at all right now? A role disabled in tumwater.json stops running
17
+ * immediately (live-reload; re-enabling resumes within one poll cycle because the runner and
18
+ * its persisted state survive), and an already-running role is never eligible. Either way the
19
+ * runner holds this poll — the single gate behind isEligible's two early returns. */
20
+ function blockedFromRunning(runner, s) {
21
+ return !runnerEnabled(runner.config, runner.role) || s.running === true;
22
+ }
23
+ /** The director carries the user's own requests: no min-gap, no backoff — a queued prompt
24
+ * runs as soon as the previous one finishes. Reaches either arm and always resolves. */
25
+ function directorGate(s, now, inboxCount, opts) {
26
+ if (inboxCount > 0)
27
+ return { run: true, reason: "inbox" };
28
+ if (opts.leftoverPending)
29
+ return now >= s.nextRunAt ? { run: true, reason: "leftover" } : { run: false };
30
+ return { run: false };
31
+ }
32
+ /** An interrupted run (graceful abort or crash) resumes promptly on restart; nextRunAt still
33
+ * gates cut-off resumes, which deliberately wait one interval. A queued prompt never preempts
34
+ * this gate. Resolves only when a resume is pending, else undefined. */
35
+ function resumeGate(s, now) {
44
36
  if (s.resumePending) {
45
37
  return now >= s.nextRunAt ? { run: true, reason: "resume" } : { run: false };
46
38
  }
47
- // A queued per-role prompt is due on its own existence, exactly like the director's inbox:
48
- // no min-gap, no backoff, no scheduled clock. The queue is the durable record of the
49
- // demand, so it survives every race a wake marker cannot — a wake consumed while a run
50
- // is in flight is overwritten by that run's end-save (runner.ts's wake() contract), but the
51
- // prompt file it enqueued is not, and this check picks the demand up on the first poll
52
- // after the run ends.
39
+ return undefined;
40
+ }
41
+ /** A queued per-role prompt is due on its own existence, exactly like the director's inbox: no
42
+ * min-gap, no backoff, no scheduled clock. The queue is the durable record of the demand. */
43
+ function inboxGate(inboxCount) {
53
44
  if (inboxCount > 0)
54
45
  return { run: true, reason: "inbox" };
55
- // The per-role interval (a slow clock, e.g. the steward's ~6 h) gates scheduled runs and
56
- // "main moved" early wakes — resolved here so a live-reloaded config applies. An operator
57
- // wake overrides it (the woken check below): it is an explicit demand, the same class of
58
- // request the director's inbox runs without any gap.
59
- //
60
- // Yield scaling: for a scalable role the gap is
61
- // multiplied by the multiplier its recent results earned — a search role that keeps
62
- // finding nothing slows down, and the multiplication gates the "main moved" wake below
63
- // too, which is where the cost actually comes from (the gap check above runs first, so a
64
- // scaled role inside its stretched gap neither runs on schedule nor on a main move).
65
- // One merge in the last ten counted runs restores the plain gap; error-class results
66
- // never enter the ring (backoff.ts), so a failing backend does not stretch anything;
67
- // `--once`, an operator wake, and a queued prompt all bypass the gap check entirely.
46
+ return undefined;
47
+ }
48
+ /** The per-role interval (a slow clock) gates scheduled runs and "main moved" early wakes.
49
+ * Yield scaling multiplies the gap; an operator wake overrides it. Resolves with the hold when
50
+ * the runner is inside the (possibly stretched) gap, else undefined. */
51
+ function gapGate(runner, s, now, opts) {
68
52
  const mult = yieldMultiplierFor(runner.state);
69
- // The same planBacklogOpen rule (scheduleConfigForRole) runner-run.ts schedules nextRunAt
70
- // from: while PLANS.md has planning room, plan runs on a zero gap; once the target is met it
71
- // drops to its 3600 s default (the plans-full deferral then holds the run). Yield scaling
72
- // still applies to whatever gap is chosen.
73
53
  const minGapSeconds = scheduleConfigForRole(runner.config, runner.role, opts.planBacklogOpen === true).minRunIntervalSeconds;
74
54
  const minGap = opts.once ? 0 : minGapSeconds * 1000 * mult;
75
55
  const sinceLast = now - (s.lastRunEndedAt ?? 0);
76
- // An operator wake newer than the gap window's opening run overrides the interval
77
- // (RunnerState.wokenAt): an explicit "try again now" with an empty queue must not silently
78
- // wait out the remaining slow clock it ran inside — a queued prompt carries its own
79
- // due-ness in the inbox check above, so it never depends on this stamp surviving.
80
56
  const woken = freshlyWoken(s);
81
57
  if (sinceLast < minGap && !woken) {
82
- // Held inside the stretched gap: the runner is not due this poll (a schedule clock with
83
- // the plain gap, or a main move under the multiplier, would otherwise admit it). Report
84
- // the yield-scaled hold so the phase cell reads "waiting out its gap" instead of the
85
- // bare "due — starts on the next poll" of the no-reason fallback (RunnerState.skipReason).
86
58
  return mult > 1 ? { run: false, skip: "yield-gap" } : { run: false };
87
59
  }
88
- // Once mode's clock override, second half: a raised backoffSeconds means nextRunAt is a
89
- // backoff deadline, not the scheduled clock, and is honored; at zero it is the clock a
90
- // productive run scheduled, and the explicit round demand overrides it.
60
+ return undefined;
61
+ }
62
+ /** The scheduled clock, then the "main moved" wake. Once mode overrides the clock when
63
+ * backoffSeconds is 0 (a productive run scheduled it); a raised backoff honors the deadline.
64
+ * Resolves only once a scheduled reason applies, else undefined. */
65
+ function scheduledGate(s, now, mainHead, opts) {
91
66
  if (now >= s.nextRunAt || (opts.once && s.backoffSeconds === 0)) {
92
67
  return { run: true, reason: s.runs === 0 ? "startup" : "scheduled" };
93
68
  }
94
- // The world changed under a sleeping role: main moved since its last run. The stretched
95
- // gap above gates this wake too (the gap check runs first), so a role whose recent runs
96
- // merged nothing does not re-check on every merge — the multiplier's whole point.
97
69
  if (s.lastMainHead && mainHead && mainHead !== s.lastMainHead) {
98
70
  return { run: true, reason: "main moved" };
99
71
  }
100
- return { run: false };
72
+ return undefined;
73
+ }
74
+ /** Should this role run now? `inboxCount` is the calling role's own queued-prompt count
75
+ * (the director's queue for the director, the per-role queue for everyone else) — see the
76
+ * inbox check below. Exported for tests. */
77
+ export function isEligible(runner, now, mainHead, inboxCount, opts = {}) {
78
+ const s = runner.state;
79
+ if (blockedFromRunning(runner, s))
80
+ return { run: false };
81
+ if (runner.role === DIRECTOR_ROLE)
82
+ return directorGate(s, now, inboxCount, opts);
83
+ return (resumeGate(s, now) ??
84
+ inboxGate(inboxCount) ??
85
+ gapGate(runner, s, now, opts) ??
86
+ scheduledGate(s, now, mainHead, opts) ??
87
+ { run: false });
101
88
  }
102
89
  /** Fair scheduling order for one poll's eligible roles: the director always leads (it runs
103
90
  * the user's prompts), then the work tier (feature/bugfix/plan) before maintenance — a long-idle
@@ -6,7 +6,7 @@ import { configForStatus, queueInFlight, mainCheckForPoll, maintenanceQuotaForPo
6
6
  import { quietHoursStatus, roleQuietHold } from "../scheduling/quiet-hours.js";
7
7
  import { DIRECTOR_ROLE } from "../roles/roles.js";
8
8
  import { pausedRoles, pausedRolesUntil, scheduledStopAt, scheduledWakes, standingTeamPause } from "../team/team-state.js";
9
- import { orchestratorAlive, readOrchestratorInfo, } from "../team/orchestrator-info.js";
9
+ import { orchestratorAlive, readOrchestratorInfo, } from "../team/team-orchestrator-info.js";
10
10
  import { bootstrapHoldsRole, bootstrapStatus } from "../gates/bootstrap-gates.js";
11
11
  import { readMergeQueue } from "../merge/merge-queue.js";
12
12
  import { collectRoleRows } from "./status-roles.js";
@@ -175,7 +175,7 @@ export function snapshot(root, modelsPath = piModelsPath(), now = Date.now()) {
175
175
  // run or verification) or is pinned to — threaded into the roles collector so `running`,
176
176
  // `disk` and the rows agree.
177
177
  const slots = readSlotsState(root).slots;
178
- const roleRows = collectRoleRows(root, cfg, now, slots, modelsPath, markerSet);
178
+ const roleRows = collectRoleRows(root, cfg, slots, modelsPath, markerSet);
179
179
  // Every role whose notes file runs past the read bound (status-role-notes.ts): the standing
180
180
  // twin of the doctor `role notes` row, raised as the amber role-notes-truncated alert.
181
181
  const roleNotesOverCap = collectRoleNotesOverCap(root);
@@ -1,6 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { readTextOrNull } from "../files/files.js";
3
+ import { readRegularTextOrNull } from "../files/files.js";
4
4
  import { cachedByStat } from "../files/stat-cache.js";
5
5
  import { roleNotesPath } from "../paths.js";
6
6
  import { ROLE_NOTES_MAX_BYTES } from "../agent/role-notes-write.js";
@@ -34,7 +34,7 @@ export function readRoleNotesOverCap(root) {
34
34
  continue;
35
35
  const file = path.join(notesDir, name);
36
36
  const chars = cachedByStat(noteLengthCache, file, file, () => {
37
- const text = readTextOrNull(file);
37
+ const text = readRegularTextOrNull(file);
38
38
  return text === null ? null : text.length;
39
39
  }, (n) => n);
40
40
  if (chars === null)
@@ -1,12 +1,11 @@
1
1
  import path from "node:path";
2
2
  import { configForRole, modelSelectorField, roleSeamTier, } from "../config/config-views.js";
3
- import { modelFallbackView } from "../config/config-fallbacks.js";
4
3
  import { isCustomRole } from "../config/config.js";
5
4
  import { baseRoleOf, configuredInstances, instanceIndex, runnerIds, runnerPaused } from "../roles/runner-ids.js";
6
5
  import { errorStreakAtWarn, QUIET_KILL_RESUME_LIMIT } from "../run/run-apply.js";
7
6
  import { queuedMergeRoles } from "../merge/merge-queue.js";
8
7
  import { hasOutstandingWork } from "../runner/runner-state.js";
9
- import { formatModelSelector } from "../config/model-selector.js";
8
+ import { formatModelSelector } from "../config/config-model-selector.js";
10
9
  import { piSettingsPath, readPiDefaultModel } from "../pi/pi-settings.js";
11
10
  import { isJsonObject } from "../files/json-object.js";
12
11
  import { runnerStateForPoll } from "./status-polls.js";
@@ -14,14 +13,14 @@ import { roleImageInfo } from "../agent/image-input.js";
14
13
  import { backendDefaultLabel } from "../agent/backend-traits.js";
15
14
  import { readPiListedModels } from "../pi/pi-listed-models.js";
16
15
  import { seamContextWindowForRole } from "../pi/pi-models.js";
17
- /** One row per enabled role id (part 5b/7). `configForRole`, `modelFallbackView`, the leased /
16
+ /** One row per enabled role id (part 5b/7). `configForRole`, the leased /
18
17
  * pinned slot and the pi-default seam all resolve against the values `snapshot()` already
19
18
  * read once for the poll — the info and slots pins (threaded as `slots`) keep rows, `running`
20
- * and `disk` on the same shared reads, and `now` is the snapshot's pinned clock so every row
21
- * agrees on what "active" means. One read of pi's own saved default per poll (docs/backends.md)
22
- * serves the rows whose seam runs pi and resolves no model — its settings.json sits beside the
23
- * models catalog this snapshot reads (piSettingsPath), so one seam serves both. */
24
- export function collectRoleRows(root, cfg, now, slots, modelsPath, paused) {
19
+ * and `disk` on the same shared reads. One read of pi's own saved default per poll
20
+ * (docs/backends.md) serves the rows whose seam runs pi and resolves no model — its
21
+ * settings.json sits beside the models catalog this snapshot reads (piSettingsPath), so one
22
+ * seam serves both. */
23
+ export function collectRoleRows(root, cfg, slots, modelsPath, paused) {
25
24
  // A surplus runner (index above `instances`, from the runner pool of plans/fast-merge.md
26
25
  // "Lever 1") with nothing to show gets no row, so the table does not list idle extras.
27
26
  // The exceptions are exactly the surplus runners an operator most needs to see and resume
@@ -59,19 +58,13 @@ export function collectRoleRows(root, cfg, now, slots, modelsPath, paused) {
59
58
  // only with a tier map declared, where it names the tier a role rides.
60
59
  const eff = configForRole(cfg, r);
61
60
  // The image-input surface (plans/image-input.md "Surfaces", part 5/5): the role's own
62
- // capability and its reroute target, one shared resolver so the status rows, the role
63
- // page, and the submit gate cannot disagree about who reads images or where a blind
64
- // prompt would run.
61
+ // capability, one shared resolver so the status rows, the role page, and the submit gate
62
+ // cannot disagree about who reads images.
65
63
  const image = roleImageInfo(cfg, r, modelsPath, listed);
66
64
  // The peak-ctx share denominator (the role's context window, part 3c): resolved once per
67
65
  // poll here, beside `eff` (the same seam the role page resolves and names) — the same seam
68
66
  // `seamContextWindow` run against, so row, page and drawer cannot disagree.
69
67
  const contextWindow = seamContextWindowForRole(cfg, r, root, modelsPath);
70
- // Model failure fallback, part 2/2: while the role's episode is active its agent runs use
71
- // the tier-resolved fallback pair, so the row names that pair, the episode's start, and
72
- // the failure that tripped it. `now` is the poll's own clock (the snapshot seam), so
73
- // every row agrees on what "active" means.
74
- const episode = modelFallbackView(base.modelFallback, cfg, r, now);
75
68
  // A seam that runs pi and resolves neither a provider nor a model starts on pi's own
76
69
  // saved default — name it in the row (modelFromPi) so the operator sees which model the
77
70
  // role runs. The config's `thinking` wins over the default's because tumwater passes it
@@ -106,7 +99,6 @@ export function collectRoleRows(root, cfg, now, slots, modelsPath, paused) {
106
99
  ? {}
107
100
  : { model: backendDefaultLabel(eff.agent) }),
108
101
  ...(isJsonObject(cfg.model) ? { modelTier: roleSeamTier(cfg, r) } : {}),
109
- ...(episode !== null ? { fallback: episode } : {}),
110
102
  ...(contextWindow !== null ? { contextWindow } : {}),
111
103
  ...(slot ? { slot: path.basename(slot.dir), slotPinned: pinned !== undefined } : {}),
112
104
  ...(baseRoleOf(r) === r ? {} : { instanceOf: baseRoleOf(r) }),
@@ -22,7 +22,7 @@ export const ERROR_STORM_ROLES = 3;
22
22
  export const ERROR_STORM_QUIET = { key: null, roles: [] };
23
23
  /** The config knob a shared cause points at, when one is known. Only the timeout cause is
24
24
  * mapped: `timed out after <dur>` is the reducer's pooled rendering of the run-timeout errors
25
- * (src/agent/run-agent.ts's two `timed out after ${runTimeoutSeconds}s` shapes, pooled into one
25
+ * (src/agent/agent-run.ts's two `timed out after ${runTimeoutSeconds}s` shapes, pooled into one
26
26
  * key by poolTimeoutKey — src/failure/failure-cluster.ts owns the two shapes and their
27
27
  * pooling), and a team-wide
28
28
  * run of it means the run budget does not fit the serving model — the one cause with a knob
@@ -2,12 +2,12 @@
2
2
  * 2026-09-29 connection-storm entry): when many roles each fail a few times on one provider
3
3
  * failure kind, no existing bar trips — the per-role streak alarm needs one role DEEP
4
4
  * (ERROR_STREAK_WARN consecutive, src/run/run-apply.ts), the error-storm warning needs several
5
- * roles deep at once (src/team/error-storm.ts), and the team hold needs failures COALESCED within
6
- * a two-minute window (src/team/team-hold.ts). A degraded backend that fails the team widely and
7
- * shallowly — six runs across as many roles, each failure minutes apart — rides all three
8
- * gaps in silence. This alarm counts raw failures of one kind across roles in a rolling
5
+ * roles deep at once (src/team/team-error-storm.ts), and the team hold needs failures COALESCED
6
+ * within a two-minute window (src/team/team-hold.ts). A degraded backend that fails the team
7
+ * widely and shallowly — six runs across as many roles, each failure minutes apart — rides all
8
+ * three gaps in silence. This alarm counts raw failures of one kind across roles in a rolling
9
9
  * window, independent of any role's streak depth: the fourth bar, shaped for the spread the
10
- * other three cannot see. POLICY, on the shape of its siblings (src/team/error-storm.ts,
10
+ * other three cannot see. POLICY, on the shape of its siblings (src/team/team-error-storm.ts,
11
11
  * src/team/team-hold.ts): a pure reducer over the observations the orchestrator collects each
12
12
  * poll and the previous state, so the rule is unit-testable without a team and the
13
13
  * orchestrator owns only the wiring (the warning event). Like both siblings it has memory —
@@ -94,88 +94,121 @@ export function standingHolds(holds) {
94
94
  out.sort((a, b) => (a.provider ?? "").localeCompare(b.provider ?? ""));
95
95
  return out;
96
96
  }
97
- /** Step the hold by one orchestrator poll: from `prev` and every role's latest failure
98
- * observations, decide what holds at `now`. A held team stays held until `until` and then
99
- * re-opens by itself — nothing can get stuck, the same guarantee the budget gate's stateless
100
- * re-evaluation gives. An open team trips when HOLD_STORM_ROLES distinct roles each
101
- * ended a run on the same failure kind within the storm window (and after the last re-open) —
102
- * the first qualifying kind in observation order wins, since one poll can hold about only one
103
- * kind — for the longest of the escalated base hold and, for a rate-limit storm, the furthest
104
- * Retry-After deadline among those observations (each measured from its own 429, since that is
105
- * when the provider said it), capped at HOLD_CAP_MS. Returns `prev` itself when
106
- * nothing changed, so a caller can compare `until` across the step to find transitions. */
107
- export function teamHold(prev, observations, now) {
108
- if (prev.until !== null) {
109
- // A reserve hold is re-evaluated every poll: the window may since have been rejected (the
110
- // director keeps running) or the reserve removed from the config, and either must show now.
111
- if (now < prev.until && prev.reserve !== true)
112
- return prev;
113
- // The deadline passed: re-open, keeping provider/kind/escalation as the next storm's
114
- // relapse memory, then fall through so a still-rejected subscription window (or a storm
115
- // whose observations survived) holds again on this very poll rather than leaving one poll
116
- // open. kind and provider stay because the next storm's relapse test compares against the
117
- // hold that just ended.
118
- prev = {
119
- provider: prev.provider,
120
- until: null,
121
- kind: prev.kind,
122
- roles: [],
123
- escalation: prev.escalation,
124
- reopenedAt: prev.reserve === true ? prev.reopenedAt : now,
125
- };
126
- }
127
- // A rejected subscription usage window is the account-level throttle: it holds every seam on
128
- // its provider until `resetsAt`, whatever the role count — the one observation kind the
129
- // per-role storm rule below does not gate. Overage is real money and never holds (the run
130
- // continues into paid overflow); a window with no future `resetsAt` holds nothing.
97
+ /** Decide whether the current hold still stands or the deadline re-opens it: return `prev`
98
+ * itself while open or still held (and not a re-evaluated reserve), else the re-opened
99
+ * skeleton. A reserve hold is re-evaluated every poll: the window may since have been
100
+ * rejected (the director keeps running) or the reserve removed from the config, and either
101
+ * must show now. The deadline passed: re-open, keeping provider/kind/escalation as the next
102
+ * storm's relapse memory, then fall through so a still-rejected subscription window (or a
103
+ * storm whose observations survived) holds again on this very poll rather than leaving one
104
+ * poll open. kind and provider stay because the next storm's relapse test compares against
105
+ * the hold that just ended. */
106
+ function reopenTeamHold(prev, now) {
107
+ if (prev.until === null)
108
+ return prev;
109
+ if (now < prev.until && prev.reserve !== true)
110
+ return prev;
111
+ return {
112
+ provider: prev.provider,
113
+ until: null,
114
+ kind: prev.kind,
115
+ roles: [],
116
+ escalation: prev.escalation,
117
+ reopenedAt: prev.reserve === true ? prev.reopenedAt : now,
118
+ };
119
+ }
120
+ /** The account-level subscription window hold, or undefined when none is rejected. A rejected
121
+ * subscription usage window is the account-level throttle: it holds every seam on its
122
+ * provider until `resetsAt`, whatever the role count — the one observation kind the
123
+ * per-role storm rule below does not gate. Overage is real money and never holds (the run
124
+ * continues into paid overflow); a window with no future `resetsAt` holds nothing. */
125
+ function subscriptionWindowHold(observations, now, reopenedAt) {
131
126
  const window = observations.find((o) => o.kind === "subscription-window" && (o.resetsAt ?? 0) > now);
132
- if (window !== undefined) {
133
- return {
134
- provider: window.provider,
135
- until: window.resetsAt,
136
- kind: "subscription-window",
137
- roles: [],
138
- escalation: 0,
139
- reopenedAt: prev.reopenedAt,
140
- ...(window.reserve === true ? { reserve: true } : {}),
141
- };
142
- }
143
- const recent = observations.filter((o) => (prev.reopenedAt === null || o.at > prev.reopenedAt) && now - o.at <= HOLD_STORM_WINDOW_MS);
144
- // Group the recent failures by provider+kind (in first-seen order, so the pick is
145
- // deterministic) and hold about the first pair that has enough distinct roles behind it.
146
- // undefined providers share one key: an unconfigured team is one backend by construction.
127
+ if (window === undefined)
128
+ return undefined;
129
+ return {
130
+ provider: window.provider,
131
+ until: window.resetsAt,
132
+ kind: "subscription-window",
133
+ roles: [],
134
+ escalation: 0,
135
+ reopenedAt,
136
+ ...(window.reserve === true ? { reserve: true } : {}),
137
+ };
138
+ }
139
+ /** The observations that can still count toward the next storm: failures after the last
140
+ * re-open (the storm that hold already answered, and any an in-flight run hit while it
141
+ * held — that run's own retry is already waiting it out) and inside the storm window. */
142
+ function recentStormObservations(prev, observations, now) {
143
+ return observations.filter((o) => (prev.reopenedAt === null || o.at > prev.reopenedAt) && now - o.at <= HOLD_STORM_WINDOW_MS);
144
+ }
145
+ /** The first provider+kind group (in first-seen order, so the pick is deterministic) with
146
+ * HOLD_STORM_ROLES distinct roles behind it, or undefined when none qualifies — the answer
147
+ * to "which storm wins one poll?", since one poll can hold about only one kind. Undefined
148
+ * providers share one key: an unconfigured team is one backend by construction. */
149
+ function firstStormGroup(recent) {
147
150
  const byProviderKind = new Map();
148
151
  for (const o of recent) {
149
152
  const key = `${o.provider ?? ""}\n${o.kind}`;
150
153
  getOrCreate(byProviderKind, key, () => ({ provider: o.provider, kind: o.kind, group: [] })).group.push(o);
151
154
  }
152
- let storm = [];
153
- let kind = null;
154
- let provider;
155
155
  for (const entry of byProviderKind.values()) {
156
156
  const roles = new Set(entry.group.map((o) => o.role));
157
157
  if (roles.size >= HOLD_STORM_ROLES) {
158
- storm = entry.group;
159
- kind = entry.kind;
160
- provider = entry.provider;
161
- break;
158
+ return { provider: entry.provider, kind: entry.kind, storm: entry.group };
162
159
  }
163
160
  }
164
- if (kind === null)
165
- return prev;
166
- const roles = sortedRoles(new Set(storm.map((o) => o.role)));
167
- // A relapse is per provider AND kind: the same storm re-tripping inside the window
168
- // escalates, while a different kind — or the same kind at a DIFFERENT provider — after a
169
- // re-open is a new incident at the base.
161
+ return undefined;
162
+ }
163
+ /** The hold severity this storm earns: +1 on the previous escalation when it is a relapse of
164
+ * the just-ended hold, else 0. A relapse is per provider AND kind: the same storm re-tripping
165
+ * inside the window escalates, while a different kind — or the same kind at a DIFFERENT
166
+ * provider — after a re-open is a new incident at the base. */
167
+ function holdEscalation(prev, provider, kind, now) {
170
168
  const relapse = prev.kind !== null &&
171
169
  prev.kind === kind &&
172
170
  prev.provider === provider &&
173
171
  prev.reopenedAt !== null &&
174
172
  now - prev.reopenedAt <= HOLD_RELAPSE_MS;
175
- const escalation = relapse ? prev.escalation + 1 : 0;
173
+ return relapse ? prev.escalation + 1 : 0;
174
+ }
175
+ /** How long this storm's hold lasts: the longest of the escalated base hold and, for a
176
+ * rate-limit storm, the furthest Retry-After deadline among its observations (each measured
177
+ * from its own 429, since that is when the provider said it), capped at HOLD_CAP_MS. */
178
+ function holdDurationMs(escalation, storm, now) {
176
179
  const retryAfterMs = Math.max(0, ...storm.map((o) => o.at + (o.retryAfterSeconds ?? 0) * 1000 - now));
177
- const holdMs = Math.min(HOLD_CAP_MS, Math.max(HOLD_BASE_MS * 2 ** escalation, retryAfterMs));
178
- return { provider, until: now + holdMs, kind, roles, escalation, reopenedAt: prev.reopenedAt };
180
+ return Math.min(HOLD_CAP_MS, Math.max(HOLD_BASE_MS * 2 ** escalation, retryAfterMs));
181
+ }
182
+ /** Step the hold by one orchestrator poll: from `prev` and every role's latest failure
183
+ * observations, decide what holds at `now`. A held team stays held until `until` and then
184
+ * re-opens by itself — nothing can get stuck, the same guarantee the budget gate's stateless
185
+ * re-evaluation gives. An open team trips when HOLD_STORM_ROLES distinct roles each
186
+ * ended a run on the same failure kind within the storm window (and after the last re-open) —
187
+ * the first qualifying kind in observation order wins, since one poll can hold about only one
188
+ * kind — for the longest of the escalated base hold and, for a rate-limit storm, the furthest
189
+ * Retry-After deadline among those observations (each measured from its own 429, since that is
190
+ * when the provider said it), capped at HOLD_CAP_MS. Returns `prev` itself when
191
+ * nothing changed, so a caller can compare `until` across the step to find transitions. */
192
+ export function teamHold(prev, observations, now) {
193
+ const reopened = reopenTeamHold(prev, now);
194
+ if (reopened.until !== null)
195
+ return reopened;
196
+ const window = subscriptionWindowHold(observations, now, reopened.reopenedAt);
197
+ if (window !== undefined)
198
+ return window;
199
+ const group = firstStormGroup(recentStormObservations(reopened, observations, now));
200
+ if (group === undefined)
201
+ return reopened;
202
+ const escalation = holdEscalation(reopened, group.provider, group.kind, now);
203
+ const roles = sortedRoles(new Set(group.storm.map((o) => o.role)));
204
+ return {
205
+ provider: group.provider,
206
+ until: now + holdDurationMs(escalation, group.storm, now),
207
+ kind: group.kind,
208
+ roles,
209
+ escalation,
210
+ reopenedAt: reopened.reopenedAt,
211
+ };
179
212
  }
180
213
  /** The providers whose standing hold is the account's rejected subscription window (kind
181
214
  * "subscription-window", not a reserve crossing — the director is exempt from the reserve): the
@@ -84,9 +84,7 @@ function parseBuild(v) {
84
84
  build.restartBlocked = v.restartBlocked;
85
85
  return build;
86
86
  }
87
- /** The published permit snapshot, or undefined for any foreign shape. Each `byKey` entry must
88
- * carry its two numbers; `limit` is kept only when it is a number, so a malformed provider
89
- * map is dropped to `undefined` rather than reaching a render that reads it as a valid cap. */
87
+ /** The published permit snapshot, or undefined for any foreign shape. */
90
88
  /** One entry of the published provider-holds list, or undefined for any foreign shape: a
91
89
  * well-formed entry is an object with a finite `until` and a string `kind`; a `provider` that
92
90
  * is present but not a string drops the entry, so a guarded list keeps its well-formed
@@ -106,18 +104,7 @@ function parseCheckPermits(v) {
106
104
  function parsePermits(v) {
107
105
  if (!isJsonObject(v) || !isFiniteNumber(v.inUse) || !isFiniteNumber(v.limit) || !isFiniteNumber(v.reserve))
108
106
  return undefined;
109
- if (!isJsonObject(v.byKey))
110
- return undefined;
111
- const byKey = {};
112
- for (const [key, value] of Object.entries(v.byKey)) {
113
- if (!isJsonObject(value) || !isFiniteNumber(value.inUse) || !isFiniteNumber(value.waiting))
114
- continue;
115
- const entry = { inUse: value.inUse, waiting: value.waiting };
116
- if (isFiniteNumber(value.limit))
117
- entry.limit = value.limit;
118
- byKey[key] = entry;
119
- }
120
- return { inUse: v.inUse, limit: v.limit, reserve: v.reserve, byKey };
107
+ return { inUse: v.inUse, limit: v.limit, reserve: v.reserve };
121
108
  }
122
109
  function parseSlotPool(v) {
123
110
  if (!isJsonObject(v) || !isFiniteNumber(v.leased) || !isFiniteNumber(v.limit) || !isFiniteNumber(v.waiting))