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
@@ -1,12 +1,10 @@
1
- import { errorStorm, errorStormKnob } from "./error-storm.js";
1
+ import { errorStorm, errorStormKnob } from "./team-error-storm.js";
2
2
  import { logEventBestEffort, TUMWATER_SOURCE } from "../events/events.js";
3
3
  import { teamHold, TEAM_OPEN } from "./team-hold.js";
4
- import { claudeWindowUsedUp, windowReserved } from "../agent/claude/claude-account.js";
4
+ import { claudeWindowUsedUp } from "../agent/claude/claude-account.js";
5
5
  import { sortedRoles } from "../failure/failure-cluster.js";
6
- import { FAILURE_SPREAD_WINDOW_MS, failureSpread } from "./failure-spread.js";
6
+ import { FAILURE_SPREAD_WINDOW_MS, failureSpread } from "./team-failure-spread.js";
7
7
  import { groupBy } from "../collections.js";
8
- import { eventsSince } from "../events/event-window.js";
9
- import { DAY_MS } from "../text/datetime.js";
10
8
  /** The HoldObservation list both team-wide failure polls step their reducer with, gathered
11
9
  * from each runner's two episodic fields: lastRateLimit (stamped "rate-limit", carrying the
12
10
  * server's retry-after when present) and lastBackendFailure. One copy of that flatMap so the
@@ -37,14 +35,13 @@ function holdObservations(runners) {
37
35
  * heldProviders() (until non-null), never bare key presence: a lifted hold must never
38
36
  * keep blocking. Events carry the provider when one is configured (an undefined provider
39
37
  * — pi's default — omits it, so an unconfigured team's events render exactly as before).
40
- * `windowTiers` is the budget poll's window move (tier → the pair its work went to), stamped on
41
- * the subscription window's limited event. `observations` is the per-subscription-backend hold
42
- * observations (one per backend in use; part 2e passes them), each stamped by the caller from
43
- * its account via windowObservation — the account's own `claudeWindowReserve`: crossings held
44
- * like a rejected window (but never the director) log pollClaudeWindowReserve's event instead
45
- * of the limited one. Returns the new map for the caller to keep. Exported as a unit-test
46
- * seam, like its sibling polls below. */
47
- export function pollTeamHold(root, prev, runners, now, observations = [], windowTiers = {}) {
38
+ * `observations` is the per-subscription-backend hold
39
+ * observations (one per backend in use), each stamped by the caller from its account via
40
+ * windowObservation. The gate no longer passes them and no doctor row reads them (Subtract
41
+ * parts 5u/6 and 5w/6); the seam stays for the unit tests. Returns the new map for the
42
+ * caller to keep.
43
+ * Exported as a unit-test seam, like its sibling polls below. */
44
+ export function pollTeamHold(root, prev, runners, now, observations = []) {
48
45
  const steps = [...holdObservations(runners), ...observations];
49
46
  const byProvider = groupBy(steps, (o) => o.provider);
50
47
  const next = new Map();
@@ -73,9 +70,6 @@ export function pollTeamHold(root, prev, runners, now, observations = [], window
73
70
  ...providerFields,
74
71
  windowType: subObs?.windowType,
75
72
  resetsAt: pNext.until,
76
- // Where each moved tier's work went (the budget poll's window move); absent when
77
- // nothing moved, so a no-fallback team's event is today's.
78
- ...(Object.keys(windowTiers).length > 0 ? { tiers: windowTiers } : {}),
79
73
  });
80
74
  };
81
75
  if (pPrev.until !== null && pPrev.kind === "subscription-window" && pPrev.reserve === true &&
@@ -85,7 +79,7 @@ export function pollTeamHold(root, prev, runners, now, observations = [], window
85
79
  }
86
80
  else if (pPrev.until === null && pNext.until !== null) {
87
81
  if (pNext.kind === "subscription-window" && pNext.reserve === true) {
88
- // A reserve crossing logs subscription_window_reserved (pollClaudeWindowReserve), not this.
82
+ // A reserve crossing logs nothing here; the reserve event loop was removed (5u/6).
89
83
  }
90
84
  else if (pNext.kind === "subscription-window") {
91
85
  limitedEvent();
@@ -143,107 +137,15 @@ export function windowObservation(backend, account, now, reserve) {
143
137
  ...(furthest.reserve === true ? { reserve: true } : {}),
144
138
  };
145
139
  }
146
- /** A claude usage window is open for at most `seven_day`, so any window still open was logged
147
- * within this span of the event log. */
148
- const CLAUDE_WINDOW_LOOKBACK_MS = 7 * DAY_MS;
149
- /** The `backend:windowType:resetsAt` keys of the still-open windows whose `type` event is already
150
- * in the event log, so a restart does not re-announce a window that is still open. Keyed by the
151
- * event's own `backend` (falling back to `"claude"` for an event written before the window
152
- * events carried one), so two backends' windows of the same type and reset instant keep their
153
- * own keys. Events without a numeric `resetsAt` cannot name their window and are skipped. */
154
- export function loggedWindowKeys(root, type, now) {
155
- const keys = new Set();
156
- for (const e of eventsSince(root, CLAUDE_WINDOW_LOOKBACK_MS, now, (ev) => ev.type === type)) {
157
- if (typeof e.windowType === "string" && typeof e.resetsAt === "number" && e.resetsAt > now) {
158
- keys.add(`${typeof e.backend === "string" ? e.backend : "claude"}:${e.windowType}:${e.resetsAt}`);
159
- }
160
- }
161
- return keys;
162
- }
163
- /** Log each subscription-window event once per window key. The reserve and the warning polls
164
- * (below) both walk every window of the account and log one event for each that crosses its mark
165
- * this poll, deduplicated by the `backend:type:resetsAt` key so many polls across one window log
166
- * once (a later window's crossing is its own key). They differ only in WHICH window marks (the
167
- * reserve fraction vs the `allowed_warning` status) and the event's payload, so the traversal, the
168
- * "reset instant already past" skip, the key build, the key dedup, and the returned-set contract
169
- * live here and each caller supplies the mark-and-payload predicate (`eventFor` returns the event
170
- * to log, or null to skip that window). The returned set drops keys whose reset instant has passed,
171
- * and is `logged` itself when nothing was added or dropped. */
172
- function logWindowEventOnce(root, logged, account, now, backend, eventFor) {
173
- if (account?.billing !== "subscription")
174
- return logged;
175
- let next = null;
176
- for (const key of logged) {
177
- const suffix = key.slice(key.lastIndexOf(":") + 1);
178
- if (suffix !== "" && Number(suffix) <= now)
179
- (next ??= new Set(logged)).delete(key);
180
- }
181
- for (const [type, w] of Object.entries(account.windows)) {
182
- const ev = eventFor(type, w);
183
- if (ev === null)
184
- continue;
185
- // A window whose reset instant is in the past (or unreadable) no longer marks a crossing.
186
- if (typeof w.resetsAt === "number" && w.resetsAt <= now)
187
- continue;
188
- // Keyed by this poll's backend (the `backend:windowType:resetsAt` shape loggedWindowKeys
189
- // seeds), so a different backend's identical window keeps its own key. The backend rides
190
- // the event, stamped here so each caller's event needs no backend of its own.
191
- const key = `${backend}:${type}:${w.resetsAt ?? ""}`;
192
- if ((next ?? logged).has(key))
193
- continue;
194
- logEventBestEffort(root, { ...ev, backend });
195
- (next ??= new Set(logged)).add(key);
196
- }
197
- return next ?? logged;
198
- }
199
- /** One poll of the reserve crossing: each window at or past its windowReserve fraction (read
200
- * through `windowReserveFor(config, "claude")`) logs `subscription_window_reserved` once per
201
- * window key (`backend:type:resetsAt`, kept in `logged`), so several windows crossing in one
202
- * poll each log their own event. Returns the keys to keep. */
203
- export function pollClaudeWindowReserve(root, logged, account, now, backend, reserve) {
204
- if (reserve === undefined)
205
- return logged;
206
- return logWindowEventOnce(root, logged, account, now, backend, (type, w) => {
207
- const fraction = reserve[type];
208
- if (!windowReserved(w, fraction) || typeof w.resetsAt !== "number")
209
- return null;
210
- return {
211
- source: TUMWATER_SOURCE,
212
- type: "subscription_window_reserved",
213
- windowType: type,
214
- utilization: w.utilization,
215
- reserve: fraction,
216
- resetsAt: w.resetsAt,
217
- };
218
- });
219
- }
220
- /** One poll of the subscription-window warning: each `allowed_warning` window logs
221
- * `subscription_window_warning` once per window key (`backend:type:resetsAt`, kept in `logged`;
222
- * carrying the window's utilization when the account reported one), so many polls across one
223
- * window warn once and several windows warning in one poll each log their own event. Returns
224
- * the keys to keep. */
225
- export function pollClaudeWindowWarning(root, logged, account, now, backend) {
226
- return logWindowEventOnce(root, logged, account, now, backend, (type, w) => {
227
- if (w.status !== "allowed_warning")
228
- return null;
229
- return {
230
- source: TUMWATER_SOURCE,
231
- type: "subscription_window_warning",
232
- windowType: type,
233
- ...(typeof w.utilization === "number" ? { utilization: w.utilization } : {}),
234
- ...(typeof w.resetsAt === "number" ? { resetsAt: w.resetsAt } : {}),
235
- };
236
- });
237
- }
238
- /** One poll of the team-wide error-storm warning (src/team/error-storm.ts): gather each runner's
239
- * current error streak (RunnerState.consecutiveErrors/lastError — the same fields the per-role
240
- * "consecutive run failures" warning reads, the director's included, since its failures are
241
- * the team's evidence too), step the pure reducer, and log exactly one warning per episode,
140
+ /** One poll of the team-wide error-storm warning (src/team/team-error-storm.ts): gather each
141
+ * runner's current error streak (RunnerState.consecutiveErrors/lastError — the same fields the
142
+ * per-role "consecutive run failures" warning reads, the director's included, since its failures
143
+ * are the team's evidence too), step the pure reducer, and log exactly one warning per episode,
242
144
  * like the per-role warning's once-per-streak-crossing shape: on the quiet→storm crossing,
243
- * naming the roles, the shared normalized cause, and the config knob when the cause has one
244
- * (a team timing out together means runTimeoutSeconds does not fit the serving model).
245
- * Storms clear silently — the members' own recoveries already tell that story — and the
246
- * reducer's `prev` pass-through keeps a held storm event-free. Returns the new storm for the
145
+ * naming the roles, the shared normalized cause, and the config knob when the cause has one (a
146
+ * team timing out together means runTimeoutSeconds does not fit the serving model). Storms clear
147
+ * silently — the members' own recoveries already tell that story — and the reducer's `prev`
148
+ * pass-through keeps a held storm event-free. Returns the new storm for the
247
149
  * caller to keep. Exported as a unit-test seam, like pollTeamHold above. */
248
150
  export function pollErrorStorm(root, prev, runners) {
249
151
  const observations = runners.map((r) => ({
@@ -267,15 +169,15 @@ export function pollErrorStorm(root, prev, runners) {
267
169
  }
268
170
  return next;
269
171
  }
270
- /** One poll of the team-wide wide-shallow storm alarm (src/team/failure-spread.ts): gather each
271
- * runner's latest provider-failure observations — the same two the team hold reads, the
272
- * director's included, since its failures are the team's evidence too — step the pure
273
- * reducer, and log exactly one warning per episode: on the quiet→active crossing (and again
274
- * when a DIFFERENT kind becomes the episode while one is sounding, the reducer's new-episode
275
- * rule), naming the failure count, the kind, the window, and the roles behind it. The wiring
276
- * keys its log off active/kind, not object identity — `recent` moves almost every poll, so
277
- * unlike the hold's poll there is no prev pass-through to compare. Exported as a unit-test
278
- * seam, like pollTeamHold above. */
172
+ /** One poll of the team-wide wide-shallow storm alarm (src/team/team-failure-spread.ts): gather
173
+ * each runner's latest provider-failure observations — the same two the team hold reads, the
174
+ * director's included, since its failures are the team's evidence too — step the pure reducer,
175
+ * and log exactly one warning per episode: on the quiet→active crossing (and again when a
176
+ * DIFFERENT kind becomes the episode while one is sounding, the reducer's new-episode rule),
177
+ * naming the failure count, the kind, the window, and the roles behind it. The wiring keys its
178
+ * log off active/kind, not object identity — `recent` moves almost every poll, so unlike the
179
+ * hold's poll there is no prev pass-through to compare. Exported as a unit-test seam, like
180
+ * pollTeamHold above. */
279
181
  export function pollFailureSpread(root, prev, runners, now) {
280
182
  const observations = holdObservations(runners);
281
183
  const next = failureSpread(prev, observations, now);
@@ -6,14 +6,14 @@
6
6
  * an interrupted run's uncommitted edits survive.
7
7
  *
8
8
  * The candidate list and the guard that keeps a clean off the primary checkout live here; the
9
- * in-use half lives in src/git/worktree-use.ts and the hold half in src/gates/disk-gate.ts. */
9
+ * in-use half lives in src/git/worktree-use.ts and the hold half in src/gates/disk-gates.ts. */
10
10
  import path from "node:path";
11
11
  import { HOUR_MS } from "../text/datetime.js";
12
12
  import { worktreesDir } from "../paths.js";
13
13
  import { git, gitTry } from "../git/git-run.js";
14
14
  import { logEventBestEffort, TUMWATER_SOURCE } from "../events/events.js";
15
15
  import { loadRunnerState } from "../runner/runner-state-load.js";
16
- import { BYTES_PER_GB, sampleFreeBytes } from "../gates/disk-gate.js";
16
+ import { BYTES_PER_GB, sampleFreeBytes } from "../gates/disk-gates.js";
17
17
  import { subdirectoryNames } from "../files/files.js";
18
18
  import { claimForReclaim, isReclaimInProgress, isWorktreeInUse, readWorktreeUse, releaseReclaim, updateWorktreeUse, } from "../git/worktree-use.js";
19
19
  /** Worktree basenames that pressure reclaim never touches: the self-hosting mirrors the
@@ -126,55 +126,85 @@ async function cleanIgnored(dir) {
126
126
  const out = await git(dir, "clean", "-fdX");
127
127
  return out.split("\n").some((line) => line.startsWith("Removing "));
128
128
  }
129
- /** Clean pressure candidates until free space reaches `reclaimGB`, re-sampling after each, and
130
- * log exactly one `disk_reclaim` when anything was cleaned. `idle` mode ignores the threshold
131
- * and walks every candidate once (part 3/4). Returns null when nothing was reclaimed. */
132
- export async function reclaimPass(root, mode, opts) {
133
- const sample = opts.sample ?? sampleFreeBytes;
134
- const candidates = opts.candidates ?? reclaimCandidates(root);
135
- const startedAt = Date.now();
136
- const before = sample(root);
129
+ /** True when a pressure or manual pass should stop: the sampler reports free space at or
130
+ * above `reclaimGB`. A null sample (sampling failed) is not the floor, so it keeps reclaiming. */
131
+ function pressureFloorReached(free, reclaimGB) {
132
+ return free !== null && reclaimGB > 0 && free >= reclaimGB * BYTES_PER_GB;
133
+ }
134
+ /** Clean one candidate, treating a guard failure as a skip, not a reason to abort the pass. */
135
+ async function reclaimOneCandidate(root, candidate) {
136
+ try {
137
+ return await reclaimWorktree(root, candidate.dir);
138
+ }
139
+ catch {
140
+ // A guard failure (a directory that stopped being a linked worktree) is not a reason to
141
+ // abort the pass; the remaining candidates are still safe to clean.
142
+ return false;
143
+ }
144
+ }
145
+ /** Walk the candidates once, collecting the ones actually cleaned. Idle mode keeps only the
146
+ * `isIdleExpired` candidates and never re-samples; pressure and manual modes re-sample before
147
+ * each and stop once the pressure floor is reached. */
148
+ async function collectReclaimed(root, candidates, mode, idleHours, reclaimGB, sample, startedAt) {
137
149
  const cleaned = [];
138
150
  for (const candidate of candidates) {
139
151
  if (mode === "idle") {
140
- if (!isIdleExpired(candidate, opts.idleHours ?? 0, startedAt))
152
+ if (!isIdleExpired(candidate, idleHours, startedAt))
141
153
  continue;
142
154
  }
143
- else {
144
- const free = sample(root);
145
- if (free !== null && opts.reclaimGB > 0 && free >= opts.reclaimGB * BYTES_PER_GB)
146
- break;
147
- }
148
- try {
149
- if (await reclaimWorktree(root, candidate.dir))
150
- cleaned.push(candidate.name);
151
- }
152
- catch {
153
- // A guard failure (a directory that stopped being a linked worktree) is not a reason to
154
- // abort the pass; the remaining candidates are still safe to clean.
155
+ else if (pressureFloorReached(sample(root), reclaimGB)) {
156
+ break;
155
157
  }
158
+ if (await reclaimOneCandidate(root, candidate))
159
+ cleaned.push(candidate.name);
156
160
  }
157
- if (cleaned.length === 0)
158
- return null;
159
- const after = sample(root);
161
+ return cleaned;
162
+ }
163
+ /** Freed space as the before/after free-space difference, clamped at zero: another writer can
164
+ * consume space during the pass, and a negative "freed" would render as nonsense in the event
165
+ * and the digest. A null sample keeps the zero fallback. */
166
+ function reclaimFreedGB(before, after) {
167
+ return before !== null && after !== null ? Math.max(0, (after - before) / BYTES_PER_GB) : 0;
168
+ }
169
+ /** The pass result: the cleaned worktrees, the clamped freed and current free figures, and the
170
+ * pass duration. `freeGB` falls back to whichever sample we have when the other is null. */
171
+ function buildReclaimResult(cleaned, before, after, startedAt) {
160
172
  const freeBytes = after ?? before ?? 0;
161
- const result = {
173
+ return {
162
174
  worktrees: cleaned,
163
- // Clamped: another writer can consume space during the pass, and a negative "freed"
164
- // would render as nonsense in the event and the digest.
165
- freedGB: before !== null && after !== null ? Math.max(0, (after - before) / BYTES_PER_GB) : 0,
175
+ freedGB: reclaimFreedGB(before, after),
166
176
  freeGB: freeBytes / BYTES_PER_GB,
167
177
  durationMs: Date.now() - startedAt,
168
178
  };
179
+ }
180
+ /** Log exactly one `disk_reclaim` event with the result's figures. */
181
+ function logReclaim(root, mode, result) {
169
182
  logEventBestEffort(root, {
170
183
  source: TUMWATER_SOURCE,
171
184
  type: "disk_reclaim",
172
185
  mode,
173
- worktrees: cleaned,
186
+ worktrees: result.worktrees,
174
187
  freedGB: result.freedGB,
175
188
  freeGB: result.freeGB,
176
189
  durationMs: result.durationMs,
177
190
  });
191
+ }
192
+ /** Clean pressure candidates until free space reaches `reclaimGB`, re-sampling after each, and
193
+ * log exactly one `disk_reclaim` when anything was cleaned. `idle` mode ignores the threshold
194
+ * and walks every candidate once (part 3/4). Returns null when nothing was reclaimed. */
195
+ export async function reclaimPass(root, mode, opts) {
196
+ const sample = opts.sample ?? sampleFreeBytes;
197
+ const candidates = opts.candidates ?? reclaimCandidates(root);
198
+ const startedAt = Date.now();
199
+ const before = sample(root);
200
+ // With no candidates the pass resolves synchronously (no re-sampling or cleaning to await),
201
+ // so a no-live-team Ctrl+E flashes "nothing to reclaim" within the caller's same turn.
202
+ const cleaned = candidates.length === 0 ? [] : await collectReclaimed(root, candidates, mode, opts.idleHours ?? 0, opts.reclaimGB, sample, startedAt);
203
+ if (cleaned.length === 0)
204
+ return null;
205
+ const after = sample(root);
206
+ const result = buildReclaimResult(cleaned, before, after, startedAt);
207
+ logReclaim(root, mode, result);
178
208
  return result;
179
209
  }
180
210
  /** Dry run: every reclaim candidate with its idle age and what `git clean -ndX` would remove,
@@ -1,7 +1,6 @@
1
- import fs from "node:fs";
2
1
  import { readJsonFile, writeJsonAtomic } from "../files/json-files.js";
3
2
  import { finiteNumber, stringList } from "../files/json-object.js";
4
- import { removeQuiet } from "../files/files.js";
3
+ import { removeIfPresent, removeQuiet } from "../files/files.js";
5
4
  import { withStateLock } from "../concurrency/lock.js";
6
5
  import { collapseWhitespace, truncate } from "../text/text.js";
7
6
  import { pausedPath, pausedRolesLockPath, pausedRolesPath, scheduledStopPath } from "../paths.js";
@@ -128,15 +127,11 @@ export function pauseTeam(root, untilMs, reason) {
128
127
  return true;
129
128
  }
130
129
  /** Resume the team by removing its marker (a no-op if absent); returns whether a marker was
131
- * there to lift, mirroring pauseTeam's changed-state contract. Uses removeQuiet's
130
+ * there to lift, mirroring pauseTeam's changed-state contract. Uses removeIfPresent's
132
131
  * never-throws delete: a marker vanishing between the check and the unlink is success, not a
133
132
  * failure worth surfacing from a toggle. */
134
133
  export function resumeTeam(root) {
135
- const marker = pausedPath(root);
136
- if (!fs.existsSync(marker))
137
- return false;
138
- removeQuiet(marker);
139
- return true;
134
+ return removeIfPresent(pausedPath(root));
140
135
  }
141
136
  /** Schedule a graceful team stop at `untilMs` (epoch ms): `tumwater stop --in`. Overwrites the
142
137
  * marker, so the last write wins. */
@@ -156,11 +151,7 @@ export function scheduledStopAt(root) {
156
151
  }
157
152
  /** Drop the scheduled stop; returns whether a marker was there (resumeTeam's contract). */
158
153
  export function cancelScheduledStop(root) {
159
- const marker = scheduledStopPath(root);
160
- if (!fs.existsSync(marker))
161
- return false;
162
- removeQuiet(marker);
163
- return true;
154
+ return removeIfPresent(scheduledStopPath(root));
164
155
  }
165
156
  /** The roles the operator has individually paused (`tumwater pause --role <id>`): a role in
166
157
  * this set starts no new runs — in-flight ones finish, every other role keeps running, the
@@ -22,7 +22,7 @@ export function markdownTable(header, rows, align = []) {
22
22
  * the `map((item, i) => `${i + 1}. ${item}`).join("\n")` step, shared by the review prompt's
23
23
  * prior objections, its stage-fix findings, and the rejected/revision notes
24
24
  * (prompt/gate-notes.ts), `tumwater questions`' open-question list
25
- * (backlog/question-answer.ts) and `tumwater wake --list`'s wake lines
25
+ * (backlog/backlog-question-answer.ts) and `tumwater wake --list`'s wake lines
26
26
  * (operator/operator-intent.ts `renderWakeList`), so the numbering shape cannot drift between
27
27
  * those surfaces (the TUI queued-prompt rows and the merge queue rows number their own
28
28
  * lines). `empty` is returned verbatim when `items` is empty
@@ -25,8 +25,9 @@ export const PREFILL_REASON = "it rides into every run's prefill";
25
25
  * drift across the four surfaces that show just that first reason: the review gate's returned
26
26
  * detail (review.ts), the event feed's one-line reject (event-format.ts), the failure digest's
27
27
  * rejection clusters (failure-data.ts), and the error a role's next run records (run-apply.ts).
28
- * Sites that render the whole reason list, or intentionally omit the fallback (time-spend.ts's
29
- * first-reason example, review.ts's rejection event `reason`), keep their own handling. */
28
+ * Sites that render the whole reason list, or intentionally omit the fallback
29
+ * (failure-time-spend.ts's first-reason example, review.ts's rejection event `reason`), keep
30
+ * their own handling. */
30
31
  export function firstReason(reasons) {
31
32
  const first = reasons?.[0];
32
33
  return typeof first === "string" ? first : NO_REASONS_GIVEN;
@@ -241,6 +242,21 @@ function withoutLeadingCd(command) {
241
242
  rest = rest.replace(LEADING_CD, "");
242
243
  return rest.trim() ? rest : command;
243
244
  }
245
+ /** The first present candidate string in a tool call's args, or "" when none is a string.
246
+ * The read order — path, file_path, command, cmd, pattern, url — is the order INV-AE4 pins. */
247
+ function toolCallCandidate(args) {
248
+ const candidate = args.path ?? args.file_path ?? args.command ?? args.cmd ?? args.pattern ?? args.url;
249
+ return typeof candidate === "string" ? candidate : "";
250
+ }
251
+ /** How one candidate reads as a detail: a path-like key reduces to its file name, a
252
+ * command-like key drops a leading `cd`, and any other key shows verbatim. */
253
+ function toolCallDetail(candidate, args) {
254
+ if (candidate === args.path || candidate === args.file_path)
255
+ return path.basename(candidate);
256
+ if (candidate === args.command || candidate === args.cmd)
257
+ return withoutLeadingCd(candidate);
258
+ return candidate;
259
+ }
244
260
  /** One-line description of a tool call from its name and args — shared by live progress data
245
261
  * collection (LiveProgress.lastTool), transcript rendering, and the harness's stalled-tool-call
246
262
  * warning (src/pi/pi-stream.ts builds the label src/pi/pi-watchdogs.ts renders). Path-like keys
@@ -248,19 +264,8 @@ function withoutLeadingCd(command) {
248
264
  * is dropped from a command, because the run's cwd is implied and the path would use up the
249
265
  * 32-character budget. */
250
266
  export function describeToolCall(toolName, args) {
251
- let detail = "";
252
- if (isJsonObject(args)) {
253
- const candidate = args.path ?? args.file_path ?? args.command ?? args.cmd ?? args.pattern ?? args.url;
254
- if (typeof candidate === "string") {
255
- detail =
256
- candidate === args.path || candidate === args.file_path
257
- ? path.basename(candidate)
258
- : candidate === args.command || candidate === args.cmd
259
- ? withoutLeadingCd(candidate)
260
- : candidate;
261
- }
262
- }
263
- detail = squash(detail, 32);
267
+ const raw = isJsonObject(args) ? toolCallDetail(toolCallCandidate(args), args) : "";
268
+ const detail = squash(raw, 32);
264
269
  // An empty name (pi omits toolName on some start events) yields the bare detail — no
265
270
  // leading space in front of it.
266
271
  return detail ? (toolName ? `${toolName} ${detail}` : detail) : toolName;
@@ -94,9 +94,9 @@ export function gotSuffix(v) {
94
94
  * and its legacy provider, exempt paths, custom-role tasks), pi-event-line.ts's tool-result content
95
95
  * test, quiet-hours.ts's per-role window lookup, and inbox-attachments.ts's image-name check, so
96
96
  * the blank-means-unset rule cannot drift per call site. Callers where a bare empty string means
97
- * unset rather than blank text (notify.ts, version.ts, inbox-cancel.ts) keep their own `=== ""`
98
- * check; typed-string callers (checkStringField's two-branch shape, checkStringArray's blank-index
99
- * report) spell the trim test inline. */
97
+ * unset rather than blank text (event-notify.ts, version.ts, inbox-cancel.ts) keep their own
98
+ * `=== ""` check; typed-string callers (checkStringField's two-branch shape,
99
+ * checkStringArray's blank-index report) spell the trim test inline. */
100
100
  export function isNonBlankString(v) {
101
101
  return typeof v === "string" && v.trim() !== "";
102
102
  }
@@ -1,18 +1,13 @@
1
1
  import { openQuestionEntries, ageInDays } from "../backlog/backlog.js";
2
2
  import { stripEntryStamp } from "../backlog/backlog-structure.js";
3
- import { questionFilingDate } from "../backlog/question-answer.js";
3
+ import { questionFilingDate } from "../backlog/backlog-question-answer.js";
4
4
  import { byDaysThenIndex } from "../collections.js";
5
- import { eventsSince } from "../events/event-window.js";
6
5
  import { STALE_QUESTION_DAYS } from "../doctor/doctor-backlog.js";
7
- import { regressedFixCandidates, matchedRegressedFixes, regressedFixesPhrase } from "../doctor/doctor-backlog-bugs.js";
8
6
  import { planDependencyProblem } from "../doctor/doctor-backlog-plans.js";
9
- import { checkWaitContention, providerConcurrencyProblems, providerWaitContention, slotWaitContention, smallPoolProblems, staleWorkspacePins, CHECK_WAIT_CHECK_WINDOW_MS, PROVIDER_WAIT_CHECK_WINDOW_MS, SLOT_WAIT_CHECK_WINDOW_MS } from "../doctor/doctor-contention-checks.js";
10
- import { readSlotsState } from "../git/slots-state.js";
11
- import { configForStatus } from "../status/status-polls.js";
12
- import { piModelsPath } from "../pi/pi-models.js";
13
- import { piSettingsPath, readPiDefaultModel } from "../pi/pi-settings.js";
7
+ import { findOrphans, orphanFailDetail } from "../doctor/doctor-orphans.js";
8
+ import { systemProcessProbe } from "../process/process-table.js";
14
9
  /** This module is the alert-feed half of src/ui/status-payload.ts: each always-on team alert's
15
- * data-sourcing function (capacityContention, …) lives here, a
10
+ * data-sourcing function lives here, a
16
11
  * sibling of the src/ui/team-alert-*.ts builders that turn them into banners. status-payload.ts
17
12
  * stays the one status-document builder, importing these feeds and folding each feed's result
18
13
  * into the `alerts` field; status-render.ts reads the feeds directly for the TUI. */
@@ -46,70 +41,51 @@ export function staleOpenQuestions(root, now = Date.now()) {
46
41
  export function planDependencyAlert(root) {
47
42
  return planDependencyProblem(root);
48
43
  }
49
- /** @see CapacityBottleneck */
50
- export function capacityContention(root, now = Date.now()) {
51
- const out = [];
52
- /** Resolve one capacity verdict and push a "capacity bottleneck" row when it warns. Shared
53
- * by the three capacity checks (workspace, provider, check slots) so the try/catch that keeps
54
- * each one from crying wolf on an unreadable event log lives in one place, and the row's
55
- * `name`/`detail` shape cannot drift between them. */
56
- const maybeWarn = (name, windowMs, keep, resolve) => {
57
- try {
58
- const verdict = resolve(eventsSince(root, windowMs, now, keep));
59
- if (verdict.warn)
60
- out.push({ name, detail: verdict.detail });
61
- }
62
- catch {
63
- // A read error skips this row, so the alert never cries wolf on an unreadable log.
64
- }
65
- };
66
- maybeWarn("workspace slots", SLOT_WAIT_CHECK_WINDOW_MS, (ev) => ev.type === "slot_wait", (waits) => slotWaitContention(waits, readSlotsState(root).slots.length));
67
- maybeWarn("provider slots", PROVIDER_WAIT_CHECK_WINDOW_MS, (ev) => ev.type === "provider_wait", (waits) => providerWaitContention(waits, configForStatus(root)));
68
- maybeWarn("check slots", CHECK_WAIT_CHECK_WINDOW_MS, (e) => e.type === "check_wait", (waits) => checkWaitContention(waits, configForStatus(root)));
69
- return out;
44
+ /** The orphan scan's alert shape, given a scan it is handed — the pure feed, so a test stages a
45
+ * fake probe's scan and gets exactly the row's message without a real host (the shared
46
+ * findOrphans/orphanFailDetail the doctor row reads, so the two cannot drift). `root` trims this
47
+ * repo's path from the commands, like the doctor row. */
48
+ export function orphanAlertFromScan(scan, root = "") {
49
+ if ("error" in scan || scan.orphans.length === 0)
50
+ return null;
51
+ const detail = orphanFailDetail(root, scan);
52
+ return detail === null ? null : { count: scan.orphans.length, detail };
70
53
  }
71
- /** A `maxConcurrentPerProvider` map that cannot do its job — the always-on twin of the doctor's
72
- * `provider concurrency` row (src/doctor/doctor-contention-checks.ts checkProviderConcurrency):
73
- * a cap key no seam resolves to (a typo, or a provider the team never runs on), a cap at or
74
- * above `maxConcurrent` (it never binds), or a keyless seam in a capped team. The `contention`
75
- * and `provider-hold` alerts fire only once a too-small cap has already parked runs or a storm
76
- * has struck; this standing alert names the static misconfiguration itself, which the doctor
77
- * row is the only other surface that otherwise exposes. Reads the same last-known-good config
78
- * the snapshot uses (configForStatus) and audits it through the row's own
79
- * providerConcurrencyProblems, so the alert and the row cannot drift on the trigger or the
80
- * wording; `[]` when no cap is configured or the map is well-formed. */
81
- export function providerCapProblems(root) {
82
- return providerConcurrencyProblems(configForStatus(root), readPiDefaultModel(piSettingsPath(piModelsPath()), root));
54
+ /** Run the doctor's orphan scan and shape it into the standing alert. Async (it does the real
55
+ * process-table read); the always-on surfaces read it through getOrphanAlert's cache below, so
56
+ * a status frame never blocks on the host scan. */
57
+ export async function refreshOrphanAlert(root, probe = systemProcessProbe) {
58
+ return orphanAlertFromScan(await findOrphans(root, probe), root);
83
59
  }
84
- /** The always-on twin of the doctor `parallel instances` row's pool-mismatch clause
85
- * (doctor-contention-checks.ts smallPoolProblems): an enabled `feature`/`bugfix` running
86
- * more than one instance while slotCount(config) sits below maxConcurrent — the shared pool
87
- * is smaller than the run cap, so those instances contend for the same checkouts. Built from
88
- * the same smallPoolProblems selector the doctor row uses, so the alert and the row cannot
89
- * drift on the trigger, the role list, or the wording. configForStatus never returns
90
- * undefined (an unreadable tumwater.json falls back to defaultConfig(), whose pool is the
91
- * default maxConcurrent + 1 — never below maxConcurrent), so the [] is the only silent
92
- * outcome. */
93
- export function instancePoolProblems(root) {
94
- return smallPoolProblems(configForStatus(root));
95
- }
96
- /** How far back the regressed-fixes alert looks — one day, so a recently Fixed test that failed
97
- * again is a standing amber alert until a day passes (or the failure is filed). The doctor's
98
- * own `regressed fixes` row keeps its since-the-fix read; this is the alert's daily trigger, the
99
- * same trailing day the regressed-fixes alert reads. */
100
- export const REGRESSED_FIX_WINDOW_MS = 24 * 60 * 60_000;
101
- /** The always-on twin of the doctor's `regressed fixes` row (src/doctor/doctor-backlog-bugs.ts
102
- * checkRegressedFixes): the same candidate selection (regressedFixCandidates) and the same
103
- * failureMentions match (matchedRegressedFixes) and sentence (regressedFixesPhrase), read off the
104
- * trailing REGRESSED_FIX_WINDOW_MS of events (the doctor, whose window is since each fix, still
105
- * warns on an older recurrence). An unreadable event log yields none. */
106
- export function regressedFixesAlert(root, now = Date.now()) {
107
- const candidates = regressedFixCandidates(root);
108
- const rows = matchedRegressedFixes(candidates, eventsSince(root, REGRESSED_FIX_WINDOW_MS, now));
109
- const detail = regressedFixesPhrase(rows);
110
- return detail === null ? null : { count: rows.length, detail };
60
+ /** How stale a cached orphan scan may be before the next status read starts a fresh one:
61
+ * orphan leaks grow over days, so a sixty-second refresh is plenty and keeps the host scan
62
+ * (one `ps`, plus cwd reads only for the parentless candidates argv did not settle) off the
63
+ * hot render path. */
64
+ const ORPHAN_ALERT_REFRESH_MS = 60_000;
65
+ let orphanCache = null;
66
+ let orphanLoading = false;
67
+ /** The synchronous orphan feed the always-on surfaces (status-payload, status-render, the TUI)
68
+ * read each frame: the last scan's value while it is fresher than ORPHAN_ALERT_REFRESH_MS, else a
69
+ * background refresh is kicked (one at a time) and the previous value — null on the first ever
70
+ * read — is returned until that scan lands. The refresh runs off the render path, so a slow host
71
+ * scan never stalls a status frame. Per-process: each long-lived surface (the GUI server, the
72
+ * TUI) and the CLI each run their own throttled scan against the same doctor rule. */
73
+ export function getOrphanAlert(root, probe = systemProcessProbe, now = Date.now()) {
74
+ if (orphanCache === null || now - orphanCache.at >= ORPHAN_ALERT_REFRESH_MS) {
75
+ if (!orphanLoading) {
76
+ orphanLoading = true;
77
+ void (async () => {
78
+ try {
79
+ orphanCache = { value: await refreshOrphanAlert(root, probe), at: Date.now() };
80
+ }
81
+ catch {
82
+ orphanCache = { value: null, at: Date.now() };
83
+ }
84
+ finally {
85
+ orphanLoading = false;
86
+ }
87
+ })();
88
+ }
89
+ }
90
+ return orphanCache === null ? null : orphanCache.value;
111
91
  }
112
- /** Re-export so the always-on alert's stale-pin selector stays in its one home
113
- * (src/doctor/doctor-contention-checks.ts) while tui.tsx keeps importing every alert feed from
114
- * this one module. */
115
- export { staleWorkspacePins };