tumwater 0.4.2 → 0.4.4

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 (354) 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} +27 -13
  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 +6 -27
  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 +8 -0
  14. package/dist/src/agent/codex/codex-args.js +5 -5
  15. package/dist/src/agent/codex/codex-backend.js +2 -2
  16. package/dist/src/agent/codex/codex-env.js +28 -15
  17. package/dist/src/agent/codex/codex-hook-trust.js +39 -20
  18. package/dist/src/agent/codex/codex-rollout.js +93 -56
  19. package/dist/src/agent/codex/codex-stream.js +1 -1
  20. package/dist/src/agent/env-scrub.js +18 -0
  21. package/dist/src/agent/image-input.js +68 -11
  22. package/dist/src/agent/notes-shim.js +2 -2
  23. package/dist/src/agent/opencode/opencode-backend.js +26 -24
  24. package/dist/src/agent/opencode/opencode-catalog.js +85 -36
  25. package/dist/src/agent/opencode/opencode-config.js +32 -14
  26. package/dist/src/agent/opencode/opencode-env.js +12 -13
  27. package/dist/src/agent/opencode/opencode-stream.js +47 -25
  28. package/dist/src/agent/session-ledger.js +23 -27
  29. package/dist/src/agent/subscription-account.js +2 -83
  30. package/dist/src/backlog/backlog-archive.js +3 -4
  31. package/dist/src/backlog/backlog-cli-view.js +81 -41
  32. package/dist/src/backlog/backlog-cli.js +57 -26
  33. package/dist/src/backlog/backlog-eligibility.js +56 -122
  34. package/dist/src/backlog/backlog-md.js +5 -5
  35. package/dist/src/backlog/backlog-parts.js +65 -27
  36. package/dist/src/backlog/{question-answer.js → backlog-question-answer.js} +1 -1
  37. package/dist/src/backlog/backlog-release.js +12 -12
  38. package/dist/src/backlog/backlog-structure.js +114 -61
  39. package/dist/src/backlog/backlog.js +3 -2
  40. package/dist/src/baseline/main-baseline.js +148 -84
  41. package/dist/src/baseline/main-guardrails.js +2 -2
  42. package/dist/src/baseline/main-watch.js +84 -50
  43. package/dist/src/brief/brief.js +26 -13
  44. package/dist/src/budget/budget-fallback-view.js +100 -243
  45. package/dist/src/budget/budget-wiring.js +29 -65
  46. package/dist/src/budget/budget.js +4 -5
  47. package/dist/src/budget/fallback-breaker.js +2 -2
  48. package/dist/src/build/build-check-detect.js +43 -14
  49. package/dist/src/build/build-check-events.js +2 -2
  50. package/dist/src/build/build-check-report.js +10 -0
  51. package/dist/src/build/build-check-scoped.js +40 -18
  52. package/dist/src/build/build-check.js +13 -5
  53. package/dist/src/build/build-stage.js +6 -4
  54. package/dist/src/change/change-data.js +1 -4
  55. package/dist/src/cli/cli-command-args.js +89 -52
  56. package/dist/src/cli/cli-completion-command.js +75 -0
  57. package/dist/src/cli/{config-commands.js → cli-config-commands.js} +82 -71
  58. package/dist/src/cli/cli-doctor-command.js +103 -0
  59. package/dist/src/cli/cli-flag-specs.js +42 -22
  60. package/dist/src/cli/cli-init.js +36 -14
  61. package/dist/src/cli/{log-commands.js → cli-log-commands.js} +43 -23
  62. package/dist/src/cli/{notify-command.js → cli-notify-command.js} +2 -2
  63. package/dist/src/cli/cli-query-commands.js +2 -2
  64. package/dist/src/cli/{question-commands.js → cli-question-commands.js} +86 -60
  65. package/dist/src/cli/cli-repo-gate.js +2 -2
  66. package/dist/src/cli/cli-role-command.js +2 -41
  67. package/dist/src/cli/cli-start-summary.js +4 -3
  68. package/dist/src/cli/cli-start-supervisor.js +190 -0
  69. package/dist/src/cli/cli-start.js +94 -244
  70. package/dist/src/cli/completion.js +85 -49
  71. package/dist/src/cli/follow-view.js +20 -2
  72. package/dist/src/cli/help.js +6 -13
  73. package/dist/src/cli.js +70 -176
  74. package/dist/src/collections.js +39 -27
  75. package/dist/src/concurrency/check-slots.js +70 -0
  76. package/dist/src/concurrency/lock.js +23 -1
  77. package/dist/src/concurrency/run-permit.js +49 -0
  78. package/dist/src/concurrency/semaphore.js +46 -250
  79. package/dist/src/config/config-editable-keys.js +4 -7
  80. package/dist/src/config/config-fallbacks.js +81 -101
  81. package/dist/src/config/config-field-checks.js +66 -37
  82. package/dist/src/config/config-keys.js +66 -40
  83. package/dist/src/config/config-live.js +4 -28
  84. package/dist/src/config/{model-selector.js → config-model-selector.js} +4 -4
  85. package/dist/src/config/config-request.js +163 -102
  86. package/dist/src/config/config-schema.js +10 -12
  87. package/dist/src/config/config-selector-checks.js +1 -1
  88. package/dist/src/config/config-validation-agents.js +2 -2
  89. package/dist/src/config/config-validation-roles.js +8 -72
  90. package/dist/src/config/config-validation.js +4 -39
  91. package/dist/src/config/config-views.js +26 -50
  92. package/dist/src/config/config-write.js +128 -102
  93. package/dist/src/config/config.js +5 -14
  94. package/dist/src/doctor/doctor-backlog-bugs.js +7 -6
  95. package/dist/src/doctor/doctor-backlog-plans.js +53 -120
  96. package/dist/src/doctor/doctor-backlog.js +16 -14
  97. package/dist/src/doctor/doctor-check-names.js +3 -6
  98. package/dist/src/doctor/doctor-checks.js +8 -8
  99. package/dist/src/doctor/doctor-claude-checks.js +70 -36
  100. package/dist/src/doctor/doctor-codex-checks.js +6 -6
  101. package/dist/src/doctor/doctor-contention-checks.js +57 -117
  102. package/dist/src/doctor/doctor-fallback-checks.js +48 -26
  103. package/dist/src/doctor/doctor-holds.js +8 -47
  104. package/dist/src/doctor/doctor-install.js +1 -1
  105. package/dist/src/doctor/doctor-launch-services.js +1 -1
  106. package/dist/src/doctor/doctor-model-checks.js +178 -94
  107. package/dist/src/doctor/doctor-notify.js +2 -2
  108. package/dist/src/doctor/doctor-opencode-checks.js +5 -4
  109. package/dist/src/doctor/doctor-orphans.js +122 -21
  110. package/dist/src/doctor/{outcome.js → doctor-outcome.js} +7 -0
  111. package/dist/src/doctor/doctor-pi-checks.js +2 -2
  112. package/dist/src/doctor/doctor-principles.js +3 -3
  113. package/dist/src/doctor/doctor-project-checks.js +24 -11
  114. package/dist/src/doctor/doctor-role-notes.js +1 -1
  115. package/dist/src/doctor/doctor.js +8 -11
  116. package/dist/src/events/event-format.js +44 -64
  117. package/dist/src/events/event-labels.js +1 -5
  118. package/dist/src/events/event-legacy.js +3 -5
  119. package/dist/src/events/{notify.js → event-notify.js} +6 -6
  120. package/dist/src/events/event-read.js +1 -1
  121. package/dist/src/events/event-window.js +1 -1
  122. package/dist/src/events/events.js +8 -0
  123. package/dist/src/failure/failure-cluster.js +9 -18
  124. package/dist/src/failure/failure-data.js +12 -8
  125. package/dist/src/failure/failure-render.js +46 -36
  126. package/dist/src/failure/failure-state-change.js +54 -120
  127. package/dist/src/failure/{time-spend.js → failure-time-spend.js} +117 -84
  128. package/dist/src/files/files.js +30 -0
  129. package/dist/src/files/json-object.js +8 -8
  130. package/dist/src/files/stat-cache.js +12 -8
  131. package/dist/src/gates/budget-gates.js +85 -84
  132. package/dist/src/gates/gate-polls.js +32 -74
  133. package/dist/src/gates/pause-gates.js +16 -2
  134. package/dist/src/gates/readiness.js +1 -1
  135. package/dist/src/gates/role-cap-gates.js +44 -35
  136. package/dist/src/gates/{startup-gate.js → startup-gates.js} +9 -8
  137. package/dist/src/gates/{streak-gate.js → streak-gates.js} +75 -52
  138. package/dist/src/git/git-diff.js +6 -2
  139. package/dist/src/git/git.js +75 -45
  140. package/dist/src/git/worktree-pool.js +3 -6
  141. package/dist/src/gui/gui-admin-endpoints.js +3 -3
  142. package/dist/src/gui/gui-args.js +12 -0
  143. package/dist/src/gui/gui-backlog-commands.js +4 -4
  144. package/dist/src/gui/gui-backlog-endpoints.js +61 -41
  145. package/dist/src/gui/gui-command.js +43 -24
  146. package/dist/src/gui/gui-config-commands.js +2 -64
  147. package/dist/src/gui/gui-endpoint-commands.js +1 -1
  148. package/dist/src/gui/gui-endpoint-common.js +19 -1
  149. package/dist/src/gui/gui-history-endpoints.js +100 -52
  150. package/dist/src/gui/gui-operator-commands.js +43 -48
  151. package/dist/src/gui/gui-prompt-commands.js +25 -25
  152. package/dist/src/gui/gui-report-endpoints.js +1 -1
  153. package/dist/src/gui/gui-server.js +1 -2
  154. package/dist/src/gui/gui-team-commands.js +2 -1
  155. package/dist/src/history/history-data.js +71 -44
  156. package/dist/src/history/history-queued.js +49 -27
  157. package/dist/src/history/history.js +54 -36
  158. package/dist/src/inbox/prompt-commands.js +59 -42
  159. package/dist/src/init/init.js +88 -40
  160. package/dist/src/merge/merge-backlog-conflicts.js +1 -1
  161. package/dist/src/merge/merge-check-failures.js +12 -9
  162. package/dist/src/merge/merge-core.js +7 -32
  163. package/dist/src/merge/merge-drain.js +11 -13
  164. package/dist/src/merge/merge-pipeline.js +6 -7
  165. package/dist/src/merge/merge-post-review-drain.js +11 -14
  166. package/dist/src/merge/merge-queue-render.js +3 -3
  167. package/dist/src/merge/merge-requeue.js +1 -1
  168. package/dist/src/merge/merge-slot.js +0 -8
  169. package/dist/src/merge/merge-stack.js +17 -18
  170. package/dist/src/merge/merge-to-main.js +13 -58
  171. package/dist/src/merge/merge-verify.js +12 -21
  172. package/dist/src/notes-cli.js +52 -42
  173. package/dist/src/operator/operator-commands.js +80 -47
  174. package/dist/src/operator/operator-intent.js +3 -3
  175. package/dist/src/orchestrator/orchestrator-launch.js +31 -63
  176. package/dist/src/orchestrator/orchestrator-poll.js +44 -69
  177. package/dist/src/orchestrator/orchestrator-scheduling.js +5 -9
  178. package/dist/src/orchestrator/orchestrator.js +8 -21
  179. package/dist/src/orchestrator/scheduling-admit.js +101 -68
  180. package/dist/src/orchestrator/scheduling-scope.js +18 -27
  181. package/dist/src/orchestrator/scheduling-skip.js +10 -14
  182. package/dist/src/orchestrator/startup-migrations.js +1 -1
  183. package/dist/src/paths.js +10 -9
  184. package/dist/src/pi/pi-args.js +5 -2
  185. package/dist/src/pi/pi-backend.js +2 -2
  186. package/dist/src/pi/pi-bin.js +3 -3
  187. package/dist/src/pi/pi-event-line.js +28 -1
  188. package/dist/src/pi/pi-listed-models.js +13 -0
  189. package/dist/src/pi/pi-models.js +43 -29
  190. package/dist/src/pi/pi-settings.js +43 -20
  191. package/dist/src/pi/pi-stream.js +41 -8
  192. package/dist/src/pi/pi-watchdogs.js +43 -15
  193. package/dist/src/pi/pi.js +2 -2
  194. package/dist/src/pi-extension/context-shake.js +32 -20
  195. package/dist/src/pi-extension/kill-guard.js +69 -0
  196. package/dist/src/process/process-group.js +51 -26
  197. package/dist/src/process/process.js +20 -12
  198. package/dist/src/process/run-marker.js +33 -15
  199. package/dist/src/process/supervisor.js +40 -28
  200. package/dist/src/prompt/dialect.js +1 -1
  201. package/dist/src/prompt/gate-notes.js +2 -2
  202. package/dist/src/prompt/gate-prompts.js +1 -44
  203. package/dist/src/prompt/notes-backlog.js +66 -37
  204. package/dist/src/prompt/principles.js +6 -4
  205. package/dist/src/prompt/prompt-followup.js +12 -4
  206. package/dist/src/prompt/prompt.js +88 -38
  207. package/dist/src/redeploy/redeployer.js +21 -1
  208. package/dist/src/report/report-data.js +53 -37
  209. package/dist/src/report/report-fold.js +16 -9
  210. package/dist/src/report/report-render.js +21 -194
  211. package/dist/src/report/report-since-data.js +4 -3
  212. package/dist/src/report/report-since-render.js +173 -0
  213. package/dist/src/report/report-usage-fold.js +8 -8
  214. package/dist/src/report/report.js +46 -22
  215. package/dist/src/review/diff-rules.js +35 -21
  216. package/dist/src/review/post-merge-review-render.js +9 -3
  217. package/dist/src/review/post-merge-review.js +48 -41
  218. package/dist/src/review/review-precheck.js +14 -4
  219. package/dist/src/review/review.js +48 -170
  220. package/dist/src/review/secret-leaks.js +26 -16
  221. package/dist/src/roles/legacy-role-migration.js +6 -3
  222. package/dist/src/roles/role-catalog.js +11 -3
  223. package/dist/src/roles/role-guidance.js +16 -12
  224. package/dist/src/roles/role-render.js +7 -46
  225. package/dist/src/roles/role-view.js +10 -33
  226. package/dist/src/run/acceptance-greps.js +88 -45
  227. package/dist/src/run/ci-parity.js +19 -8
  228. package/dist/src/run/run-apply.js +12 -15
  229. package/dist/src/run/run-detail-data.js +121 -68
  230. package/dist/src/run/run-detail-render.js +92 -35
  231. package/dist/src/run/run-detail.js +72 -41
  232. package/dist/src/run/run-prompt.js +63 -50
  233. package/dist/src/run/run-resume.js +76 -29
  234. package/dist/src/run/run-stage.js +141 -90
  235. package/dist/src/run/run-timing.js +8 -2
  236. package/dist/src/run/run-usage.js +31 -1
  237. package/dist/src/run/run-verdict.js +128 -50
  238. package/dist/src/run/stage-check.js +60 -50
  239. package/dist/src/run/stage-checks-shared.js +28 -0
  240. package/dist/src/run/stage-name-checks.js +111 -92
  241. package/dist/src/run/stage-path-checks.js +69 -42
  242. package/dist/src/run/stage-prose-checks.js +55 -40
  243. package/dist/src/runner/{leftover.js → runner-leftover.js} +9 -26
  244. package/dist/src/runner/runner-outcome.js +7 -2
  245. package/dist/src/runner/runner-pi.js +9 -22
  246. package/dist/src/runner/runner-recovery.js +5 -13
  247. package/dist/src/runner/{revision.js → runner-revision.js} +1 -2
  248. package/dist/src/runner/runner-run.js +103 -50
  249. package/dist/src/runner/runner-state-load.js +18 -28
  250. package/dist/src/runner/runner-state.js +0 -14
  251. package/dist/src/runner/runner.js +41 -25
  252. package/dist/src/scheduling/backoff.js +8 -3
  253. package/dist/src/scheduling/claim-entries.js +2 -1
  254. package/dist/src/scheduling/claims.js +0 -4
  255. package/dist/src/scheduling/scheduling.js +53 -66
  256. package/dist/src/status/status-budget.js +4 -43
  257. package/dist/src/status/status-data.js +35 -39
  258. package/dist/src/status/status-role-notes.js +2 -2
  259. package/dist/src/status/status-roles.js +9 -18
  260. package/dist/src/team/{error-storm.js → team-error-storm.js} +1 -1
  261. package/dist/src/team/{failure-spread.js → team-failure-spread.js} +5 -5
  262. package/dist/src/team/team-hold.js +105 -70
  263. package/dist/src/team/{orchestrator-info.js → team-orchestrator-info.js} +55 -36
  264. package/dist/src/team/team-polls.js +53 -201
  265. package/dist/src/team/{reclaim.js → team-reclaim.js} +61 -31
  266. package/dist/src/team/team-state.js +4 -13
  267. package/dist/src/text/datetime.js +16 -7
  268. package/dist/src/text/markdown.js +1 -1
  269. package/dist/src/text/phrases.js +29 -15
  270. package/dist/src/text/suggest.js +1 -1
  271. package/dist/src/text/text.js +6 -5
  272. package/dist/src/ui/alert-feeds.js +46 -111
  273. package/dist/src/ui/badges.js +17 -88
  274. package/dist/src/ui/gui/gui-client-boot.js +2 -8
  275. package/dist/src/ui/gui/gui-client-composer.js +1 -1
  276. package/dist/src/ui/gui/gui-client-drawer-config.js +8 -33
  277. package/dist/src/ui/gui/gui-client-drawer.js +1 -1
  278. package/dist/src/ui/gui/gui-client-feed.js +1 -1
  279. package/dist/src/ui/gui/gui-client-history.js +28 -14
  280. package/dist/src/ui/gui/gui-client-model.js +0 -12
  281. package/dist/src/ui/gui/gui-client-operator.js +7 -5
  282. package/dist/src/ui/gui/gui-client-pending.js +7 -6
  283. package/dist/src/ui/gui/gui-client-report-chart.js +5 -5
  284. package/dist/src/ui/gui/gui-client-report.js +1 -1
  285. package/dist/src/ui/gui/gui-client-roles.js +0 -1
  286. package/dist/src/ui/gui/gui-client-settings.js +43 -84
  287. package/dist/src/ui/gui/gui-client-team.js +12 -6
  288. package/dist/src/ui/gui/gui-client.js +7 -1
  289. package/dist/src/ui/progress-data.js +107 -44
  290. package/dist/src/ui/run-progress-model.js +20 -4
  291. package/dist/src/ui/status-model.js +2 -24
  292. package/dist/src/ui/status-payload.js +11 -30
  293. package/dist/src/ui/status-render.js +13 -24
  294. package/dist/src/ui/team-alert-backlog.js +14 -204
  295. package/dist/src/ui/team-alert-shared.js +6 -29
  296. package/dist/src/ui/team-alert-status.js +9 -170
  297. package/dist/src/ui/team-alert-tail.js +19 -215
  298. package/dist/src/ui/team-alerts.js +8 -15
  299. package/dist/src/ui/transcript-run.js +27 -16
  300. package/dist/src/ui/tui/tui-backlog.js +2 -2
  301. package/dist/src/ui/tui/tui-editor-answer.js +12 -10
  302. package/dist/src/ui/tui/tui-editor-attach.js +4 -3
  303. package/dist/src/ui/tui/tui-editor-backends.js +4 -3
  304. package/dist/src/ui/tui/tui-editor-brief.js +6 -9
  305. package/dist/src/ui/tui/tui-editor-budget.js +4 -3
  306. package/dist/src/ui/tui/tui-editor-config.js +80 -68
  307. package/dist/src/ui/tui/tui-editor-entry.js +12 -20
  308. package/dist/src/ui/tui/tui-editor-pause.js +51 -57
  309. package/dist/src/ui/tui/tui-editor-prompt.js +9 -13
  310. package/dist/src/ui/tui/tui-editor-queued.js +11 -14
  311. package/dist/src/ui/tui/tui-editor-quiet.js +4 -3
  312. package/dist/src/ui/tui/tui-editor-release.js +4 -3
  313. package/dist/src/ui/tui/tui-editor-retire.js +7 -7
  314. package/dist/src/ui/tui/tui-editor-role-enabled.js +4 -3
  315. package/dist/src/ui/tui/tui-editor-role-file.js +4 -4
  316. package/dist/src/ui/tui/tui-editor-role-key.js +4 -3
  317. package/dist/src/ui/tui/tui-editor-role-model.js +97 -15
  318. package/dist/src/ui/tui/tui-editor-search.js +4 -3
  319. package/dist/src/ui/tui/tui-editor-session.js +17 -4
  320. package/dist/src/ui/tui/tui-editors.js +5 -10
  321. package/dist/src/ui/tui/tui-frame.js +182 -134
  322. package/dist/src/ui/tui/tui-guard.js +20 -0
  323. package/dist/src/ui/tui/tui-input.js +167 -98
  324. package/dist/src/ui/tui/tui-keymap.js +51 -29
  325. package/dist/src/ui/tui/tui-keys.js +1 -1
  326. package/dist/src/ui/tui/tui-pane-activity.js +5 -4
  327. package/dist/src/ui/tui/tui-pane-backlog.js +74 -36
  328. package/dist/src/ui/tui/tui-pane-ctx.js +1 -1
  329. package/dist/src/ui/tui/tui-pane-health.js +252 -230
  330. package/dist/src/ui/tui/tui-pane-history.js +67 -74
  331. package/dist/src/ui/tui/tui-pane-pending.js +288 -247
  332. package/dist/src/ui/tui/tui-pane-queued.js +25 -27
  333. package/dist/src/ui/tui/tui-pane-report.js +2 -1
  334. package/dist/src/ui/tui/tui-pane-session.js +15 -10
  335. package/dist/src/ui/tui/tui-pane-transcript.js +19 -31
  336. package/dist/src/ui/tui/tui-pane.js +14 -11
  337. package/dist/src/ui/tui/tui-panes.js +38 -33
  338. package/dist/src/ui/tui/tui.js +4 -7
  339. package/dist/src/verdict/fix-claim.js +107 -56
  340. package/dist/src/verdict/refusal.js +1 -1
  341. package/dist/src/verdict/reply-contract.js +3 -4
  342. package/package.json +5 -5
  343. package/dist/src/concurrency/provider-lease.js +0 -105
  344. package/dist/src/review/review-followup.js +0 -87
  345. package/dist/src/review/review-run.js +0 -395
  346. package/dist/src/runner/model-fallback.js +0 -76
  347. package/dist/src/runner/runner-fallback.js +0 -66
  348. package/dist/src/ui/tui/tui-editor-claude-reserve.js +0 -24
  349. package/dist/src/ui/tui/tui-editor-provider-caps.js +0 -27
  350. /package/dist/src/cli/{brief-command.js → cli-brief-command.js} +0 -0
  351. /package/dist/src/doctor/{probe.js → doctor-probe.js} +0 -0
  352. /package/dist/src/gates/{disk-gate.js → disk-gates.js} +0 -0
  353. /package/dist/src/operator/{retire.js → operator-retire.js} +0 -0
  354. /package/dist/src/operator/{revert.js → operator-revert.js} +0 -0
@@ -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, queued = queuedMergeRoles(root)) {
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
@@ -31,7 +30,6 @@ export function collectRoleRows(root, cfg, now, slots, modelsPath, paused) {
31
30
  // run after run" alert keys on, so a surplus runner the alert would report is never
32
31
  // hidden. Without these rows the role is invisible and unresumable (BUGS.md 2026-10-23),
33
32
  // and the alerts — which read the rows — cannot name it.
34
- const queued = queuedMergeRoles(root);
35
33
  const roles = runnerIds(cfg).filter((r) => {
36
34
  if (instanceIndex(r) <= configuredInstances(cfg, r))
37
35
  return true;
@@ -59,19 +57,13 @@ export function collectRoleRows(root, cfg, now, slots, modelsPath, paused) {
59
57
  // only with a tier map declared, where it names the tier a role rides.
60
58
  const eff = configForRole(cfg, r);
61
59
  // 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.
60
+ // capability, one shared resolver so the status rows, the role page, and the submit gate
61
+ // cannot disagree about who reads images.
65
62
  const image = roleImageInfo(cfg, r, modelsPath, listed);
66
63
  // The peak-ctx share denominator (the role's context window, part 3c): resolved once per
67
64
  // poll here, beside `eff` (the same seam the role page resolves and names) — the same seam
68
65
  // `seamContextWindow` run against, so row, page and drawer cannot disagree.
69
66
  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
67
  // A seam that runs pi and resolves neither a provider nor a model starts on pi's own
76
68
  // saved default — name it in the row (modelFromPi) so the operator sees which model the
77
69
  // role runs. The config's `thinking` wins over the default's because tumwater passes it
@@ -106,7 +98,6 @@ export function collectRoleRows(root, cfg, now, slots, modelsPath, paused) {
106
98
  ? {}
107
99
  : { model: backendDefaultLabel(eff.agent) }),
108
100
  ...(isJsonObject(cfg.model) ? { modelTier: roleSeamTier(cfg, r) } : {}),
109
- ...(episode !== null ? { fallback: episode } : {}),
110
101
  ...(contextWindow !== null ? { contextWindow } : {}),
111
102
  ...(slot ? { slot: path.basename(slot.dir), slotPinned: pinned !== undefined } : {}),
112
103
  ...(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 —
@@ -78,15 +78,17 @@ export function heldProviders(holds) {
78
78
  /** The holds that STAND, as a structured list for observers (the dashboards and `tumwater
79
79
  * status` name them; the event feed and failure digest already do): iterate the map, skip an
80
80
  * entry whose `until` is null (a lifted hold kept only for relapse memory) and one whose kind
81
- * is `"subscription-window"` (that hold already has the snapshot's `claudeWindow` block and
82
- * its header badge), and emit one `StandingHold` each, sorted by provider name so two reads of
83
- * one map agree. An `undefined` provider means pi's default backend, the same meaning
84
- * `heldProviders` gives it, so it is omitted rather than written as null. */
81
+ * is `"subscription-window"` (the account-level window throttle, not a failure-storm hold the
82
+ * standing list names; its display and crossing events are gone — parts 5u/6–5x/6), then
83
+ * emit one `StandingHold` each, sorted by provider name so two reads of one map agree. An
84
+ * `undefined` provider means pi's default backend, the same meaning `heldProviders` gives it,
85
+ * so it is omitted rather than written as null. */
85
86
  export function standingHolds(holds) {
86
87
  const out = [];
87
88
  for (const [provider, hold] of holds) {
88
89
  // A lifted hold (until null) and a null kind (never standing) never publish; a
89
- // subscription-window hold already has the snapshot's claudeWindow block and its badge.
90
+ // subscription-window hold is an account-level throttle, not a storm hold for this list,
91
+ // and its display and crossing events are gone (parts 5u/6–5x/6).
90
92
  if (hold.until === null || hold.kind === null || hold.kind === "subscription-window")
91
93
  continue;
92
94
  out.push({ ...(provider !== undefined ? { provider } : {}), until: hold.until, kind: hold.kind });
@@ -94,88 +96,121 @@ export function standingHolds(holds) {
94
96
  out.sort((a, b) => (a.provider ?? "").localeCompare(b.provider ?? ""));
95
97
  return out;
96
98
  }
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.
99
+ /** Decide whether the current hold still stands or the deadline re-opens it: return `prev`
100
+ * itself while open or still held (and not a re-evaluated reserve), else the re-opened
101
+ * skeleton. A reserve hold is re-evaluated every poll: the window may since have been
102
+ * rejected (the director keeps running) or the reserve removed from the config, and either
103
+ * must show now. The deadline passed: re-open, keeping provider/kind/escalation as the next
104
+ * storm's relapse memory, then fall through so a still-rejected subscription window (or a
105
+ * storm whose observations survived) holds again on this very poll rather than leaving one
106
+ * poll open. kind and provider stay because the next storm's relapse test compares against
107
+ * the hold that just ended. */
108
+ function reopenTeamHold(prev, now) {
109
+ if (prev.until === null)
110
+ return prev;
111
+ if (now < prev.until && prev.reserve !== true)
112
+ return prev;
113
+ return {
114
+ provider: prev.provider,
115
+ until: null,
116
+ kind: prev.kind,
117
+ roles: [],
118
+ escalation: prev.escalation,
119
+ reopenedAt: prev.reserve === true ? prev.reopenedAt : now,
120
+ };
121
+ }
122
+ /** The account-level subscription window hold, or undefined when none is rejected. A rejected
123
+ * subscription usage window is the account-level throttle: it holds every seam on its
124
+ * provider until `resetsAt`, whatever the role count — the one observation kind the
125
+ * per-role storm rule below does not gate. Overage is real money and never holds (the run
126
+ * continues into paid overflow); a window with no future `resetsAt` holds nothing. */
127
+ function subscriptionWindowHold(observations, now, reopenedAt) {
131
128
  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.
129
+ if (window === undefined)
130
+ return undefined;
131
+ return {
132
+ provider: window.provider,
133
+ until: window.resetsAt,
134
+ kind: "subscription-window",
135
+ roles: [],
136
+ escalation: 0,
137
+ reopenedAt,
138
+ ...(window.reserve === true ? { reserve: true } : {}),
139
+ };
140
+ }
141
+ /** The observations that can still count toward the next storm: failures after the last
142
+ * re-open (the storm that hold already answered, and any an in-flight run hit while it
143
+ * held — that run's own retry is already waiting it out) and inside the storm window. */
144
+ function recentStormObservations(prev, observations, now) {
145
+ return observations.filter((o) => (prev.reopenedAt === null || o.at > prev.reopenedAt) && now - o.at <= HOLD_STORM_WINDOW_MS);
146
+ }
147
+ /** The first provider+kind group (in first-seen order, so the pick is deterministic) with
148
+ * HOLD_STORM_ROLES distinct roles behind it, or undefined when none qualifies — the answer
149
+ * to "which storm wins one poll?", since one poll can hold about only one kind. Undefined
150
+ * providers share one key: an unconfigured team is one backend by construction. */
151
+ function firstStormGroup(recent) {
147
152
  const byProviderKind = new Map();
148
153
  for (const o of recent) {
149
154
  const key = `${o.provider ?? ""}\n${o.kind}`;
150
155
  getOrCreate(byProviderKind, key, () => ({ provider: o.provider, kind: o.kind, group: [] })).group.push(o);
151
156
  }
152
- let storm = [];
153
- let kind = null;
154
- let provider;
155
157
  for (const entry of byProviderKind.values()) {
156
158
  const roles = new Set(entry.group.map((o) => o.role));
157
159
  if (roles.size >= HOLD_STORM_ROLES) {
158
- storm = entry.group;
159
- kind = entry.kind;
160
- provider = entry.provider;
161
- break;
160
+ return { provider: entry.provider, kind: entry.kind, storm: entry.group };
162
161
  }
163
162
  }
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.
163
+ return undefined;
164
+ }
165
+ /** The hold severity this storm earns: +1 on the previous escalation when it is a relapse of
166
+ * the just-ended hold, else 0. A relapse is per provider AND kind: the same storm re-tripping
167
+ * inside the window escalates, while a different kind — or the same kind at a DIFFERENT
168
+ * provider — after a re-open is a new incident at the base. */
169
+ function holdEscalation(prev, provider, kind, now) {
170
170
  const relapse = prev.kind !== null &&
171
171
  prev.kind === kind &&
172
172
  prev.provider === provider &&
173
173
  prev.reopenedAt !== null &&
174
174
  now - prev.reopenedAt <= HOLD_RELAPSE_MS;
175
- const escalation = relapse ? prev.escalation + 1 : 0;
175
+ return relapse ? prev.escalation + 1 : 0;
176
+ }
177
+ /** How long this storm's hold lasts: the longest of the escalated base hold and, for a
178
+ * rate-limit storm, the furthest Retry-After deadline among its observations (each measured
179
+ * from its own 429, since that is when the provider said it), capped at HOLD_CAP_MS. */
180
+ function holdDurationMs(escalation, storm, now) {
176
181
  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 };
182
+ return Math.min(HOLD_CAP_MS, Math.max(HOLD_BASE_MS * 2 ** escalation, retryAfterMs));
183
+ }
184
+ /** Step the hold by one orchestrator poll: from `prev` and every role's latest failure
185
+ * observations, decide what holds at `now`. A held team stays held until `until` and then
186
+ * re-opens by itself — nothing can get stuck, the same guarantee the budget gate's stateless
187
+ * re-evaluation gives. An open team trips when HOLD_STORM_ROLES distinct roles each
188
+ * ended a run on the same failure kind within the storm window (and after the last re-open) —
189
+ * the first qualifying kind in observation order wins, since one poll can hold about only one
190
+ * kind — for the longest of the escalated base hold and, for a rate-limit storm, the furthest
191
+ * Retry-After deadline among those observations (each measured from its own 429, since that is
192
+ * when the provider said it), capped at HOLD_CAP_MS. Returns `prev` itself when
193
+ * nothing changed, so a caller can compare `until` across the step to find transitions. */
194
+ export function teamHold(prev, observations, now) {
195
+ const reopened = reopenTeamHold(prev, now);
196
+ if (reopened.until !== null)
197
+ return reopened;
198
+ const window = subscriptionWindowHold(observations, now, reopened.reopenedAt);
199
+ if (window !== undefined)
200
+ return window;
201
+ const group = firstStormGroup(recentStormObservations(reopened, observations, now));
202
+ if (group === undefined)
203
+ return reopened;
204
+ const escalation = holdEscalation(reopened, group.provider, group.kind, now);
205
+ const roles = sortedRoles(new Set(group.storm.map((o) => o.role)));
206
+ return {
207
+ provider: group.provider,
208
+ until: now + holdDurationMs(escalation, group.storm, now),
209
+ kind: group.kind,
210
+ roles,
211
+ escalation,
212
+ reopenedAt: reopened.reopenedAt,
213
+ };
179
214
  }
180
215
  /** The providers whose standing hold is the account's rejected subscription window (kind
181
216
  * "subscription-window", not a reserve crossing — the director is exempt from the reserve): the
@@ -1,4 +1,5 @@
1
1
  import { readJsonFile, writeJsonAtomic } from "../files/json-files.js";
2
+ import { cachedByStat } from "../files/stat-cache.js";
2
3
  import { finiteNumber, isJsonObject } from "../files/json-object.js";
3
4
  import { pidAlive } from "../process/process.js";
4
5
  import { orchestratorStatePath } from "../paths.js";
@@ -41,13 +42,28 @@ export function publishInfoFields(root, info, fields) {
41
42
  * trusted because the `OrchestratorInfo` annotation says so: a foreign shape (a scalar
42
43
  * `fallbackDemotions`, a `disk` missing its numbers) would otherwise throw out of a render —
43
44
  * `modelPairName(p) in publishedDemotions` or `formatGB(undefined)` — on every second's
44
- * poll. An unreadable field is dropped, so observers read it as absent. */
45
+ * poll. An unreadable field is dropped, so observers read it as absent. The value is served
46
+ * from a stat-keyed cache (infoCache): the TUI, GUI and `tumwater status` poll snapshot every
47
+ * second, but the orchestrator writes the info file only at start and whenever a published
48
+ * field (build, budget, disk, permits) changes — between writes it sits unchanged for minutes.
49
+ * An unchanged file costs one stat syscall per poll instead of re-reading, re-parsing, and
50
+ * re-normalizing JSON; any write invalidates via dev/ino/mtime/size (writeJsonAtomic's
51
+ * tmp+rename is the same freshness contract the other cachedByStat consumers rely on). */
52
+ // Per-root info cache (stat-cache.cachedByStat), keyed by state-file path so distinct roots
53
+ // never collide; capped inside cachedByStat so many short-lived roots in tests cannot grow it
54
+ // unbounded. readOrchestratorInfo serves an unchanged file from this cache on a poll, and the
55
+ // orchestrator's own writes (tmp+rename) invalidate it exactly like every other cachedByStat
56
+ // consumer.
57
+ const infoCache = new Map();
45
58
  export function readOrchestratorInfo(root) {
46
- const info = readJsonFile(orchestratorStatePath(root));
47
- if (info === null)
48
- return null;
49
- normalizeInfoFields(info);
50
- return info;
59
+ const file = orchestratorStatePath(root);
60
+ return (cachedByStat(infoCache, file, file, () => {
61
+ const info = readJsonFile(file);
62
+ if (info === null)
63
+ return null;
64
+ normalizeInfoFields(info);
65
+ return info;
66
+ }, (info) => ({ ...info })) ?? null);
51
67
  }
52
68
  /** Number type-guard over the shared `finiteNumber` read (json-object.ts): that helper is the
53
69
  * one home of the "the field is the number or it is nothing" rule, narrowed here so callers
@@ -82,11 +98,12 @@ function parseBuild(v) {
82
98
  build.restartPending = v.restartPending;
83
99
  if (typeof v.restartBlocked === "string")
84
100
  build.restartBlocked = v.restartBlocked;
101
+ const waiting = v.waitingForCheck;
102
+ if (isJsonObject(waiting) && typeof waiting.head === "string" && isFiniteNumber(waiting.aheadCommits))
103
+ build.waitingForCheck = { head: waiting.head, aheadCommits: waiting.aheadCommits };
85
104
  return build;
86
105
  }
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. */
106
+ /** The published permit snapshot, or undefined for any foreign shape. */
90
107
  /** One entry of the published provider-holds list, or undefined for any foreign shape: a
91
108
  * well-formed entry is an object with a finite `until` and a string `kind`; a `provider` that
92
109
  * is present but not a string drops the entry, so a guarded list keeps its well-formed
@@ -106,18 +123,7 @@ function parseCheckPermits(v) {
106
123
  function parsePermits(v) {
107
124
  if (!isJsonObject(v) || !isFiniteNumber(v.inUse) || !isFiniteNumber(v.limit) || !isFiniteNumber(v.reserve))
108
125
  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 };
126
+ return { inUse: v.inUse, limit: v.limit, reserve: v.reserve };
121
127
  }
122
128
  function parseSlotPool(v) {
123
129
  if (!isJsonObject(v) || !isFiniteNumber(v.leased) || !isFiniteNumber(v.limit) || !isFiniteNumber(v.waiting))
@@ -143,14 +149,9 @@ function parseOrDelete(rec, key, parse) {
143
149
  else
144
150
  rec[key] = parsed;
145
151
  }
146
- /** Degrade each known nested field to a usable shape or drop it, mutating the freshly parsed
147
- * object in place so a well-formed file keeps its values unchanged. */
148
- function normalizeInfoFields(rec) {
149
- parseOrDelete(rec, "build", parseBuild);
150
- parseOrDelete(rec, "fallbackDemoted", parseDemotion);
151
- parseOrDelete(rec, "permits", parsePermits);
152
- parseOrDelete(rec, "checkPermits", parseCheckPermits);
153
- parseOrDelete(rec, "slotPool", parseSlotPool);
152
+ /** Drop a non-object `fallbackDemotions` or each entry that does not parse, keeping
153
+ * well-formed entries unchanged. */
154
+ function normalizeFallbackDemotions(rec) {
154
155
  const demotions = rec.fallbackDemotions;
155
156
  if (!isJsonObject(demotions))
156
157
  delete rec.fallbackDemotions;
@@ -163,13 +164,9 @@ function normalizeInfoFields(rec) {
163
164
  demotions[key] = parsed;
164
165
  }
165
166
  }
166
- const budget = rec.budget;
167
- if (!isJsonObject(budget) || !isFiniteNumber(budget.spentUsd))
168
- delete rec.budget;
169
- if (rec.chain !== undefined && !isJsonObject(rec.chain))
170
- delete rec.chain;
171
- if (rec.stopsAt !== undefined && !isFiniteNumber(rec.stopsAt))
172
- delete rec.stopsAt;
167
+ }
168
+ /** Drop a non-array `providerHolds` or keep each well-formed entry, dropping foreign ones. */
169
+ function normalizeProviderHolds(rec) {
173
170
  const providerHolds = rec.providerHolds;
174
171
  if (!Array.isArray(providerHolds))
175
172
  delete rec.providerHolds;
@@ -182,6 +179,9 @@ function normalizeInfoFields(rec) {
182
179
  }
183
180
  rec.providerHolds = kept;
184
181
  }
182
+ }
183
+ /** Drop a `disk` that lacks finite numbers or a boolean `held`, or an invalid `lastReclaim`. */
184
+ function normalizeDisk(rec) {
185
185
  const disk = rec.disk;
186
186
  if (!isJsonObject(disk) ||
187
187
  !isFiniteNumber(disk.freeGB) ||
@@ -194,6 +194,25 @@ function normalizeInfoFields(rec) {
194
194
  delete disk.lastReclaim;
195
195
  }
196
196
  }
197
+ /** Degrade each known nested field to a usable shape or drop it, mutating the freshly parsed
198
+ * object in place so a well-formed file keeps its values unchanged. */
199
+ function normalizeInfoFields(rec) {
200
+ parseOrDelete(rec, "build", parseBuild);
201
+ parseOrDelete(rec, "fallbackDemoted", parseDemotion);
202
+ parseOrDelete(rec, "permits", parsePermits);
203
+ parseOrDelete(rec, "checkPermits", parseCheckPermits);
204
+ parseOrDelete(rec, "slotPool", parseSlotPool);
205
+ normalizeFallbackDemotions(rec);
206
+ const budget = rec.budget;
207
+ if (!isJsonObject(budget) || !isFiniteNumber(budget.spentUsd))
208
+ delete rec.budget;
209
+ if (rec.chain !== undefined && !isJsonObject(rec.chain))
210
+ delete rec.chain;
211
+ if (rec.stopsAt !== undefined && !isFiniteNumber(rec.stopsAt))
212
+ delete rec.stopsAt;
213
+ normalizeProviderHolds(rec);
214
+ normalizeDisk(rec);
215
+ }
197
216
  /** Publish the running orchestrator's info file atomically (writeJsonAtomic's tmp + rename).
198
217
  * The only writer of that path, so a process killed mid-write — the orchestrator's own poll
199
218
  * publishes budget/disk/build changes while `tumwater start` in another terminal reads it to