tumwater 0.4.0 → 0.4.2

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 (291) hide show
  1. package/README.md +4 -6
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-backend.js +5 -2
  4. package/dist/src/agent/backend-traits.js +21 -0
  5. package/dist/src/agent/claude/claude-backend.js +1 -1
  6. package/dist/src/agent/claude/claude-stream.js +181 -182
  7. package/dist/src/agent/claude/{write-guard-hook.js → claude-write-guard-hook.js} +1 -1
  8. package/dist/src/agent/claude/claude-write-guard.js +5 -4
  9. package/dist/src/agent/codex/codex-backend.js +1 -1
  10. package/dist/src/agent/codex/codex-rollout.js +48 -45
  11. package/dist/src/agent/codex/codex-stream.js +269 -215
  12. package/dist/src/agent/codex/{write-guard-hook.js → codex-write-guard-hook.js} +2 -1
  13. package/dist/src/agent/codex/codex-write-guard.js +3 -3
  14. package/dist/src/agent/image-input.js +18 -87
  15. package/dist/src/agent/notes-shim.js +2 -2
  16. package/dist/src/agent/opencode/opencode-backend.js +2 -2
  17. package/dist/src/agent/opencode/opencode-catalog.js +2 -2
  18. package/dist/src/agent/opencode/opencode-config.js +2 -2
  19. package/dist/src/agent/opencode/opencode-stream.js +101 -92
  20. package/dist/src/agent/run-agent.js +76 -382
  21. package/dist/src/agent/run-settle.js +25 -15
  22. package/dist/src/agent/run-spawn.js +226 -0
  23. package/dist/src/agent/run-stream.js +185 -0
  24. package/dist/src/agent/session-ledger.js +16 -0
  25. package/dist/src/agent/write-guard.js +5 -3
  26. package/dist/src/backlog/backlog-archive.js +86 -170
  27. package/dist/src/backlog/backlog-cli-view.js +89 -70
  28. package/dist/src/backlog/backlog-eligibility.js +51 -59
  29. package/dist/src/backlog/backlog-file-bugs.js +133 -0
  30. package/dist/src/backlog/backlog-index-data.js +159 -0
  31. package/dist/src/backlog/backlog-index.js +7 -5
  32. package/dist/src/backlog/backlog-md.js +39 -28
  33. package/dist/src/backlog/backlog-release.js +21 -36
  34. package/dist/src/backlog/backlog-render.js +1 -1
  35. package/dist/src/backlog/backlog-structure.js +14 -145
  36. package/dist/src/backlog/backlog-write.js +31 -18
  37. package/dist/src/backlog/backlog.js +14 -4
  38. package/dist/src/backlog/question-answer.js +33 -15
  39. package/dist/src/baseline/main-baseline.js +71 -26
  40. package/dist/src/baseline/main-guardrails.js +76 -15
  41. package/dist/src/baseline/main-red.js +45 -94
  42. package/dist/src/baseline/main-watch.js +9 -3
  43. package/dist/src/brief/brief.js +40 -0
  44. package/dist/src/budget/budget-fallback-view.js +50 -8
  45. package/dist/src/budget/budget-wiring.js +15 -11
  46. package/dist/src/budget/budget.js +3 -2
  47. package/dist/src/build/build-check-events.js +4 -2
  48. package/dist/src/build/build-check-report.js +2 -1
  49. package/dist/src/build/build-check-scoped.js +70 -41
  50. package/dist/src/change/change-data.js +4 -2
  51. package/dist/src/cli/cli-args.js +9 -0
  52. package/dist/src/cli/cli-command-args.js +199 -98
  53. package/dist/src/cli/cli-flag-specs.js +6 -4
  54. package/dist/src/cli/cli-marker-commands.js +2 -2
  55. package/dist/src/cli/cli-query-commands.js +41 -6
  56. package/dist/src/cli/cli-review-commands.js +2 -1
  57. package/dist/src/cli/cli-role-command.js +46 -92
  58. package/dist/src/cli/cli-start.js +123 -47
  59. package/dist/src/cli/follow-view.js +3 -1
  60. package/dist/src/cli/help.js +19 -12
  61. package/dist/src/cli/log-commands.js +128 -107
  62. package/dist/src/cli/question-commands.js +82 -63
  63. package/dist/src/cli.js +396 -343
  64. package/dist/src/collections.js +46 -9
  65. package/dist/src/config/config-fallbacks.js +0 -4
  66. package/dist/src/config/config-live.js +179 -147
  67. package/dist/src/config/config-request.js +176 -51
  68. package/dist/src/config/config-validation-roles.js +271 -0
  69. package/dist/src/config/config-validation.js +10 -205
  70. package/dist/src/config/config-views.js +2 -7
  71. package/dist/src/config/config-write.js +205 -175
  72. package/dist/src/config/config.js +2 -1
  73. package/dist/src/doctor/doctor-backlog-bugs.js +24 -122
  74. package/dist/src/doctor/doctor-backlog-plans.js +24 -25
  75. package/dist/src/doctor/doctor-backlog.js +34 -42
  76. package/dist/src/doctor/doctor-check-names.js +4 -2
  77. package/dist/src/doctor/doctor-checks.js +16 -4
  78. package/dist/src/doctor/doctor-claude-checks.js +2 -2
  79. package/dist/src/doctor/doctor-codex-checks.js +9 -20
  80. package/dist/src/doctor/doctor-contention-checks.js +52 -19
  81. package/dist/src/doctor/doctor-holds.js +22 -20
  82. package/dist/src/doctor/doctor-model-checks.js +8 -184
  83. package/dist/src/doctor/doctor-notify.js +2 -5
  84. package/dist/src/doctor/doctor-opencode-checks.js +90 -41
  85. package/dist/src/doctor/doctor-principles.js +26 -0
  86. package/dist/src/doctor/doctor-project-checks.js +69 -19
  87. package/dist/src/doctor/doctor-role-notes.js +33 -0
  88. package/dist/src/doctor/doctor.js +13 -6
  89. package/dist/src/doctor/probe.js +1 -1
  90. package/dist/src/events/event-format.js +307 -162
  91. package/dist/src/events/event-labels.js +0 -1
  92. package/dist/src/events/event-legacy.js +1 -1
  93. package/dist/src/events/event-window.js +1 -1
  94. package/dist/src/events/events.js +15 -2
  95. package/dist/src/events/notify.js +1 -1
  96. package/dist/src/failure/failure-cluster.js +4 -3
  97. package/dist/src/failure/failure-data.js +126 -94
  98. package/dist/src/failure/failure-render.js +58 -17
  99. package/dist/src/failure/failure-state-change.js +308 -27
  100. package/dist/src/failure/time-spend.js +139 -106
  101. package/dist/src/files/files.js +7 -0
  102. package/dist/src/files/json-object.js +15 -2
  103. package/dist/src/gates/budget-gates.js +90 -68
  104. package/dist/src/gates/gate-polls.js +92 -61
  105. package/dist/src/gates/role-cap-gates.js +1 -1
  106. package/dist/src/gates/startup-gate.js +28 -28
  107. package/dist/src/gates/streak-gate.js +1 -1
  108. package/dist/src/git/git.js +1 -1
  109. package/dist/src/git/slot-salvage.js +9 -9
  110. package/dist/src/git/slots-state.js +12 -3
  111. package/dist/src/git/worktree-pool-startup.js +127 -0
  112. package/dist/src/git/worktree-pool.js +1 -120
  113. package/dist/src/gui/gui-admin-endpoints.js +3 -4
  114. package/dist/src/gui/gui-args.js +1 -1
  115. package/dist/src/gui/gui-backlog-commands.js +20 -11
  116. package/dist/src/gui/gui-backlog-endpoints.js +2 -8
  117. package/dist/src/gui/gui-config-commands.js +51 -76
  118. package/dist/src/gui/gui-endpoint-commands.js +4 -2
  119. package/dist/src/gui/gui-endpoint-common.js +4 -3
  120. package/dist/src/gui/gui-history-endpoints.js +36 -27
  121. package/dist/src/gui/gui-operator-commands.js +43 -17
  122. package/dist/src/gui/gui-prompt-commands.js +27 -21
  123. package/dist/src/gui/gui-quarantine-commands.js +30 -0
  124. package/dist/src/gui/gui-report-endpoints.js +13 -9
  125. package/dist/src/gui/gui-server.js +5 -3
  126. package/dist/src/gui/gui-team-commands.js +1 -1
  127. package/dist/src/history/history-data.js +7 -1
  128. package/dist/src/history/history-render.js +15 -10
  129. package/dist/src/history/history.js +103 -68
  130. package/dist/src/inbox/inbox-submit.js +1 -5
  131. package/dist/src/inbox/pending-prompt.js +3 -2
  132. package/dist/src/inbox/prompt-commands.js +194 -161
  133. package/dist/src/init/init.js +104 -25
  134. package/dist/src/merge/merge-core.js +1 -1
  135. package/dist/src/merge/merge-drain.js +1 -1
  136. package/dist/src/merge/merge-git.js +5 -5
  137. package/dist/src/merge/{merge-post-review.js → merge-post-review-drain.js} +1 -1
  138. package/dist/src/merge/merge-queue.js +2 -1
  139. package/dist/src/merge/merge-to-main.js +1 -1
  140. package/dist/src/merge/merge-verify.js +3 -2
  141. package/dist/src/operator/operator-commands.js +84 -52
  142. package/dist/src/operator/operator-intent.js +26 -3
  143. package/dist/src/operator/retire.js +2 -1
  144. package/dist/src/operator/revert.js +18 -12
  145. package/dist/src/orchestrator/orchestrator-poll.js +2 -2
  146. package/dist/src/orchestrator/orchestrator.js +1 -1
  147. package/dist/src/orchestrator/retention.js +6 -5
  148. package/dist/src/paths.js +10 -10
  149. package/dist/src/pi/pi-bin.js +2 -2
  150. package/dist/src/pi/pi-models.js +9 -0
  151. package/dist/src/pi/pi-stream.js +63 -41
  152. package/dist/src/pi/pi-watchdogs.js +84 -49
  153. package/dist/src/pi-extension/context-shake.js +55 -31
  154. package/dist/src/pi-extension/full-output.js +2 -3
  155. package/dist/src/process/process.js +24 -1
  156. package/dist/src/process/run-marker.js +2 -2
  157. package/dist/src/process/supervisor.js +24 -5
  158. package/dist/src/prompt/gate-notes.js +41 -16
  159. package/dist/src/prompt/notes-backlog.js +22 -29
  160. package/dist/src/prompt/principles.js +11 -0
  161. package/dist/src/prompt/prompt-followup.js +4 -12
  162. package/dist/src/prompt/prompt.js +15 -12
  163. package/dist/src/redeploy/redeploy-probes.js +11 -1
  164. package/dist/src/redeploy/redeployer.js +54 -7
  165. package/dist/src/redeploy/self-reload.js +7 -2
  166. package/dist/src/report/report-data.js +224 -320
  167. package/dist/src/report/report-fold.js +23 -231
  168. package/dist/src/report/report-main-red.js +70 -3
  169. package/dist/src/report/report-render.js +181 -48
  170. package/dist/src/report/report-since-data.js +218 -0
  171. package/dist/src/report/report-usage-fold.js +288 -0
  172. package/dist/src/report/report.js +2 -1
  173. package/dist/src/review/known-flakes.js +22 -3
  174. package/dist/src/review/post-merge-review-accept.js +3 -2
  175. package/dist/src/review/post-merge-review.js +16 -14
  176. package/dist/src/review/quarantine-render.js +29 -11
  177. package/dist/src/review/review-precheck.js +2 -1
  178. package/dist/src/roles/role-catalog.js +30 -29
  179. package/dist/src/roles/role-guidance.js +26 -43
  180. package/dist/src/roles/role-render.js +19 -0
  181. package/dist/src/roles/role-view.js +19 -5
  182. package/dist/src/roles/roles.js +2 -22
  183. package/dist/src/run/ci-parity.js +5 -6
  184. package/dist/src/run/feedback-filings.js +10 -1
  185. package/dist/src/run/qa-coverage.js +4 -4
  186. package/dist/src/run/run-apply.js +14 -2
  187. package/dist/src/run/run-detail-data.js +50 -10
  188. package/dist/src/run/run-detail-render.js +32 -1
  189. package/dist/src/run/run-detail.js +44 -22
  190. package/dist/src/run/run-dismiss.js +43 -0
  191. package/dist/src/run/run-finalize.js +75 -46
  192. package/dist/src/run/run-prompt.js +271 -187
  193. package/dist/src/run/run-resume.js +32 -31
  194. package/dist/src/run/run-stage.js +116 -64
  195. package/dist/src/run/run-timing.js +18 -4
  196. package/dist/src/run/run-verdict.js +119 -83
  197. package/dist/src/run/stage-backlog-checks.js +4 -31
  198. package/dist/src/run/stage-check.js +4 -12
  199. package/dist/src/run/stage-checks-shared.js +15 -0
  200. package/dist/src/run/stage-name-checks.js +3 -13
  201. package/dist/src/run/stage-path-checks.js +26 -29
  202. package/dist/src/runner/leftover.js +13 -4
  203. package/dist/src/runner/revision.js +2 -2
  204. package/dist/src/runner/runner-fallback.js +1 -1
  205. package/dist/src/runner/runner-outcome.js +0 -5
  206. package/dist/src/runner/runner-pi.js +72 -49
  207. package/dist/src/runner/runner-run.js +19 -102
  208. package/dist/src/runner/runner-state-load.js +0 -10
  209. package/dist/src/runner/runner.js +1 -32
  210. package/dist/src/scheduling/claims.js +4 -11
  211. package/dist/src/status/status-data.js +140 -91
  212. package/dist/src/status/status-polls.js +41 -5
  213. package/dist/src/status/status-role-notes.js +53 -0
  214. package/dist/src/status/status-roles.js +10 -37
  215. package/dist/src/text/datetime.js +8 -1
  216. package/dist/src/text/format.js +13 -1
  217. package/dist/src/text/phrases.js +16 -2
  218. package/dist/src/text/text.js +12 -0
  219. package/dist/src/ui/alert-feeds.js +115 -0
  220. package/dist/src/ui/badges.js +3 -4
  221. package/dist/src/ui/gui/gui-client-boot.js +78 -57
  222. package/dist/src/ui/gui/gui-client-drawer-config.js +8 -3
  223. package/dist/src/ui/gui/gui-client-drawer.js +18 -2
  224. package/dist/src/ui/gui/gui-client-feed.js +3 -8
  225. package/dist/src/ui/gui/gui-client-history.js +16 -16
  226. package/dist/src/ui/gui/gui-client-model.js +9 -9
  227. package/dist/src/ui/gui/gui-client-operator.js +6 -10
  228. package/dist/src/ui/gui/gui-client-pending.js +4 -4
  229. package/dist/src/ui/gui/gui-client-report-chart.js +111 -10
  230. package/dist/src/ui/gui/gui-client-report.js +70 -11
  231. package/dist/src/ui/gui/gui-client-roles.js +52 -12
  232. package/dist/src/ui/gui/gui-client-settings.js +31 -26
  233. package/dist/src/ui/gui/gui-client-team.js +28 -2
  234. package/dist/src/ui/gui/gui-client.js +33 -4
  235. package/dist/src/ui/gui/gui-icons.js +7 -1
  236. package/dist/src/ui/progress-data.js +2 -2
  237. package/dist/src/ui/run-progress-model.js +2 -2
  238. package/dist/src/ui/status-model.js +26 -16
  239. package/dist/src/ui/status-payload.js +149 -326
  240. package/dist/src/ui/status-render.js +131 -80
  241. package/dist/src/ui/team-alert-backlog.js +236 -0
  242. package/dist/src/ui/team-alert-shared.js +92 -0
  243. package/dist/src/ui/team-alert-status.js +326 -0
  244. package/dist/src/ui/team-alert-tail.js +238 -0
  245. package/dist/src/ui/team-alerts.js +16 -984
  246. package/dist/src/ui/transcript-tail.js +137 -94
  247. package/dist/src/ui/tui/tui-backlog.js +1 -1
  248. package/dist/src/ui/tui/tui-editor-answer.js +54 -44
  249. package/dist/src/ui/tui/tui-editor-attach.js +74 -63
  250. package/dist/src/ui/tui/tui-editor-backends.js +62 -56
  251. package/dist/src/ui/tui/tui-editor-brief.js +64 -54
  252. package/dist/src/ui/tui/tui-editor-budget.js +106 -89
  253. package/dist/src/ui/tui/tui-editor-config.js +2 -4
  254. package/dist/src/ui/tui/tui-editor-ctx.js +12 -1
  255. package/dist/src/ui/tui/tui-editor-entry.js +171 -153
  256. package/dist/src/ui/tui/tui-editor-instances.js +2 -1
  257. package/dist/src/ui/tui/tui-editor-instructions.js +50 -107
  258. package/dist/src/ui/tui/tui-editor-interval.js +39 -112
  259. package/dist/src/ui/tui/tui-editor-notes.js +28 -84
  260. package/dist/src/ui/tui/tui-editor-pause.js +210 -183
  261. package/dist/src/ui/tui/tui-editor-prompt.js +46 -35
  262. package/dist/src/ui/tui/tui-editor-queued.js +74 -64
  263. package/dist/src/ui/tui/tui-editor-quiet.js +122 -108
  264. package/dist/src/ui/tui/tui-editor-release.js +85 -0
  265. package/dist/src/ui/tui/tui-editor-retire.js +57 -46
  266. package/dist/src/ui/tui/tui-editor-role-enabled.js +90 -80
  267. package/dist/src/ui/tui/tui-editor-role-file.js +87 -0
  268. package/dist/src/ui/tui/tui-editor-role-key.js +50 -42
  269. package/dist/src/ui/tui/tui-editor-search.js +70 -58
  270. package/dist/src/ui/tui/tui-editor-session.js +3 -0
  271. package/dist/src/ui/tui/tui-editors.js +4 -1
  272. package/dist/src/ui/tui/tui-frame.js +7 -3
  273. package/dist/src/ui/tui/tui-history.js +12 -10
  274. package/dist/src/ui/tui/tui-keys.js +6 -1
  275. package/dist/src/ui/tui/tui-pane-activity.js +62 -50
  276. package/dist/src/ui/tui/tui-pane-backlog.js +4 -4
  277. package/dist/src/ui/tui/tui-pane-ctx.js +1 -1
  278. package/dist/src/ui/tui/tui-pane-health.js +45 -26
  279. package/dist/src/ui/tui/tui-pane-history.js +296 -264
  280. package/dist/src/ui/tui/tui-pane-report.js +74 -57
  281. package/dist/src/ui/tui/tui-pane-session.js +6 -2
  282. package/dist/src/ui/tui/tui-pane-transcript.js +158 -131
  283. package/dist/src/ui/tui/tui-pane.js +201 -174
  284. package/dist/src/ui/tui/tui-panes.js +211 -148
  285. package/dist/src/ui/tui/tui.js +4 -4
  286. package/dist/src/verdict/fix-claim.js +18 -6
  287. package/dist/src/verdict/refusal.js +32 -7
  288. package/package.json +1 -1
  289. package/dist/src/doctor/image-probe.js +0 -257
  290. /package/dist/src/gui/{http-body.js → gui-http-body.js} +0 -0
  291. /package/dist/src/merge/{backlog-conflicts.js → merge-backlog-conflicts.js} +0 -0
@@ -40,17 +40,45 @@ export function newTeamGateStates(config) {
40
40
  }
41
41
  /** Poll every team-wide gate and alarm, advancing `states` in place. */
42
42
  export function pollTeamGates(states, ctx) {
43
- const { root, runners, liveConfig, modelsPath, now, info } = ctx;
43
+ const { root, now } = ctx;
44
44
  // The per-backend subscription account lookup (part 2e/9): each backend's account is read on
45
45
  // demand — the window hold/reserve/warning loop, the window-move and the handback reads each
46
46
  // call accounts fresh — so a second subscription backend is judged from its own account, never
47
47
  // from claude's.
48
48
  const accounts = (backend) => readAccount(root, backend, now);
49
- // Daily cost budget gate (src/gates/budget-gates.ts owns the reads, the edge-triggered
50
- // budget_* events, and the breaker re-key; the fallback config view it derives lives in
51
- // src/budget/budget-fallback-view.ts): the orchestrator
52
- // owns only the wiring — the demotion publish below and the per-runner assignment at
53
- // the bottom of this block.
49
+ const budget = pollBudgetPhase(states, ctx, accounts);
50
+ const holds = pollHoldGates(states, ctx);
51
+ const quietDisk = pollQuietAndDiskGates(states, ctx);
52
+ pollTeamFailureAlarms(states, ctx, accounts, budget.windowTiers);
53
+ return {
54
+ gate: budget.gate,
55
+ roleConfig: budget.roleConfig,
56
+ directorConfig: budget.directorConfig,
57
+ userPaused: holds.userPaused,
58
+ pausedRoles: holds.pausedRoles,
59
+ capPaused: holds.capPaused,
60
+ maintenanceQuota: holds.maintenanceQuota,
61
+ bootstrapHeld: holds.bootstrapHeld,
62
+ budgetPausedRoles: budget.budgetPausedRoles,
63
+ servingResolved: budget.servingResolved,
64
+ priceResolved: budget.priceResolved,
65
+ budgetActive: budget.budgetActive,
66
+ windowMovedRoles: budget.windowMovedRoles,
67
+ roleQuietHeld: quietDisk.roleQuietHeld,
68
+ quietNow: quietDisk.quietNow,
69
+ diskHeld: quietDisk.diskHeld,
70
+ };
71
+ }
72
+ // Daily cost budget gate (src/gates/budget-gates.ts owns the reads, the edge-triggered
73
+ // budget_* events, and the breaker re-key; the fallback config view it derives lives in
74
+ // src/budget/budget-fallback-view.ts): the orchestrator
75
+ // owns only the wiring — the demotion publish below and the per-runner assignment at
76
+ // the bottom of this block — and the per-tier budget pause (src/budget/budget-wiring.ts,
77
+ // part 5c/8): a role whose model tier resolved to no usable free pair while the cap is
78
+ // reached starts no new runs, and a role on a window-moved tier whose own backend owns
79
+ // the out window is moved off it.
80
+ function pollBudgetPhase(states, ctx, accounts) {
81
+ const { root, runners, liveConfig, modelsPath, info } = ctx;
54
82
  const { gate, roleConfig, spentUsd, capUsd, onFallback, resumed: gateResumed, pairs: fallbackPairs, priceResolved, servingResolved, budgetActive, windowTiers, windowResumedBackends, directorConfig, chain, } = pollBudgetGate(states.budget, {
55
83
  root,
56
84
  states: runners.map((r) => r.state),
@@ -82,9 +110,40 @@ export function pollTeamGates(states, ctx) {
82
110
  capUsd,
83
111
  },
84
112
  });
85
- // The pause gates (src/gates/pause-gates.ts owns the reads, the edge-triggered pause/resume
86
- // events, and the cross-poll bookkeeping): the operator pause's marker and the per-role pause's
87
- // set, both read fresh per cycle so a marker change takes effect on the next poll.
113
+ // The per-tier budget pause (src/budget/budget-wiring.ts, part 5c/8): a role whose model
114
+ // tier resolved to no usable free pair while the cap is reached starts no new runs, and a
115
+ // role on a window-moved tier whose own backend owns the out window is moved off it — the
116
+ // stateless verdict recomputed every poll from the SAME resolution the gate's value came
117
+ // from, beside `capPaused` (the scheduling pass folds the sets; the director is exempt, as
118
+ // under every autonomous gate, and the demoted pair's probe pierces the set for its own
119
+ // tier's roles only — probeRoles in the orchestrator's scheduling pass).
120
+ const { budgetPausedRoles, windowMovedRoles } = budgetPausedSubjects({
121
+ runners,
122
+ liveConfig,
123
+ accounts,
124
+ budgetActive,
125
+ servingResolved,
126
+ windowTiers,
127
+ });
128
+ return {
129
+ gate,
130
+ roleConfig,
131
+ directorConfig,
132
+ budgetPausedRoles,
133
+ windowMovedRoles,
134
+ servingResolved,
135
+ priceResolved,
136
+ budgetActive,
137
+ windowTiers,
138
+ };
139
+ }
140
+ // The pause gates (src/gates/pause-gates.ts owns the reads, the edge-triggered pause/resume
141
+ // events, and the cross-poll bookkeeping): the operator pause's marker and the per-role pause's
142
+ // set, both read fresh per cycle so a marker change takes effect on the next poll — then the
143
+ // error-streak circuit breaker, the per-role daily cost cap, the maintenance allowance and the
144
+ // new-project bootstrap, each a stateless verdict folded into this poll's hold views.
145
+ function pollHoldGates(states, ctx) {
146
+ const { root, runners, liveConfig, now } = ctx;
88
147
  const { userPaused, pausedRoles } = pollPauseGates(root, states.pause);
89
148
  // The error-streak circuit breaker (src/gates/streak-gate.ts): a role past ERROR_STREAK_BREAKER
90
149
  // consecutive failed runs is paused through the same per-role marker the operator's
@@ -101,21 +160,6 @@ export function pollTeamGates(states, ctx) {
101
160
  for (const role of streakPaused)
102
161
  states.pause.prevPausedRoles.add(role);
103
162
  const pausedRolesNow = streakPaused.length > 0 ? new Set([...pausedRoles, ...streakPaused]) : pausedRoles;
104
- // The per-tier budget pause (src/budget/budget-wiring.ts, part 5c/8): a role whose model
105
- // tier resolved to no usable free pair while the cap is reached starts no new runs, and a
106
- // role on a window-moved tier whose own backend owns the out window is moved off it — the
107
- // stateless verdict recomputed every poll from the SAME resolution the gate's value came
108
- // from, beside `capPaused` (the scheduling pass folds the sets; the director is exempt, as
109
- // under every autonomous gate, and the demoted pair's probe pierces the set for its own
110
- // tier's roles only — probeRoles in the orchestrator's scheduling pass).
111
- const { budgetPausedRoles, windowMovedRoles } = budgetPausedSubjects({
112
- runners,
113
- liveConfig,
114
- accounts,
115
- budgetActive,
116
- servingResolved,
117
- windowTiers,
118
- });
119
163
  // The per-role daily cost cap (src/gates/role-cap-gates.ts): a role whose local-day spend has
120
164
  // reached its own maxDailyCostUsdPerRole entry starts no new runs — the stateless verdict
121
165
  // recomputed every poll, no pause marker written (the marker is anonymous; a cap pause must
@@ -143,12 +187,16 @@ export function pollTeamGates(states, ctx) {
143
187
  // pass folds the held set in beside capPaused (with the fresh-wake/queued-prompt override the
144
188
  // need-based deferral honors).
145
189
  const bootstrapHeld = pollBootstrapGate(root, states.bootstrap, runners, liveConfig, now);
146
- // Per-role quiet hours (src/scheduling/quiet-hours.ts): a role whose own quietHoursPerRole window
147
- // covers `now` starts no new runs — the team window's semantics scoped to one role, the
148
- // director exempt like every autonomous gate. Stateless, recomputed per poll exactly like
149
- // capPaused: no crossing events, no state — a live config edit applies on the next poll
150
- // by construction. A role held by BOTH windows is held once: the scheduling pass folds
151
- // both verdicts into one hold condition, nothing counts a role twice.
190
+ return { userPaused, pausedRoles: pausedRolesNow, capPaused, maintenanceQuota, bootstrapHeld };
191
+ }
192
+ // Per-role quiet hours (src/scheduling/quiet-hours.ts): a role whose own quietHoursPerRole window
193
+ // covers `now` starts no new runs — the team window's semantics scoped to one role, the
194
+ // director exempt like every autonomous gate. Stateless, recomputed per poll exactly like
195
+ // capPaused: no crossing events, no state — a live config edit applies on the next poll
196
+ // by construction. A role held by BOTH windows is held once: the scheduling pass folds
197
+ // both verdicts into one hold condition, nothing counts a role twice.
198
+ function pollQuietAndDiskGates(states, ctx) {
199
+ const { root, runners, liveConfig, now, info } = ctx;
152
200
  const roleQuietHeld = new Set();
153
201
  for (const r of runners) {
154
202
  if (r.role === DIRECTOR_ROLE)
@@ -188,19 +236,20 @@ export function pollTeamGates(states, ctx) {
188
236
  ...(ctx.reclaim?.lastReclaim ? { lastReclaim: ctx.reclaim.lastReclaim } : {}),
189
237
  };
190
238
  publishInfoFields(root, info, [["disk", disk]]);
191
- // Each runner as the two provider-failure polls read it (HoldInputs): the role, its two
192
- // episodic fields, and the provider its NEXT run will run on (runProvider — the tier
193
- // fallback pair while a model-fallback episode is active, else the role's resolved config;
194
- // undefined when pi's default is in charge). Built ONCE per poll so the hold and the spread
195
- // read the same list, like holdObservations on the reducer side. Keying on the effective
196
- // provider is what lets a storm on the fallback pair form its own hold while the abandoned
197
- // primary stays clear (PLANS.md "Model failure fallback, part 1/2").
198
- //
199
- // Each stamp (lastRateLimit/lastBackendFailure) carries the hold key of the backend that
200
- // ACTUALLY failed — a reviewer run folded through the author's role stamps `claude`, not
201
- // the pi author's provider — and the polls' observation mapper files each stamp under that
202
- // key, falling back to runProvider here for a stamp without one (PLANS.md "Mixed claude +
203
- // pi fleets, part 2/4"). The spreads below carry the stamps wholesale, provider included.
239
+ return { roleQuietHeld, quietNow, diskHeld };
240
+ }
241
+ // Each runner as the two provider-failure polls read it (HoldInputs) is built ONCE here so
242
+ // the hold and the spread read the same list, like holdObservations on the reducer side.
243
+ // Keying on the effective provider is what lets a storm on the fallback pair form its own
244
+ // hold while the abandoned primary stays clear (PLANS.md "Model failure fallback, part 1/2").
245
+ //
246
+ // Each stamp (lastRateLimit/lastBackendFailure) carries the hold key of the backend that
247
+ // ACTUALLY failed — a reviewer run folded through the author's role stamps `claude`, not
248
+ // the pi author's provider — and the polls' observation mapper files each stamp under that
249
+ // key, falling back to runProvider here for a stamp without one (PLANS.md "Mixed claude +
250
+ // pi fleets, part 2/4"). The spreads below carry the stamps wholesale, provider included.
251
+ function pollTeamFailureAlarms(states, ctx, accounts, windowTiers) {
252
+ const { root, runners, liveConfig, now, info } = ctx;
204
253
  const holdInputs = (rs) => rs.map((r) => ({
205
254
  role: r.role,
206
255
  provider: r.runProvider(now),
@@ -268,22 +317,4 @@ export function pollTeamGates(states, ctx) {
268
317
  // so a degraded backend that fails the team widely and shallowly still names itself.
269
318
  // Observational only, like the error storm: it gates nothing.
270
319
  states.failureSpread = pollFailureSpread(root, states.failureSpread, holdInputs(runners), now);
271
- return {
272
- gate,
273
- roleConfig,
274
- directorConfig,
275
- userPaused,
276
- pausedRoles: pausedRolesNow,
277
- capPaused,
278
- maintenanceQuota,
279
- bootstrapHeld,
280
- budgetPausedRoles,
281
- servingResolved,
282
- priceResolved,
283
- budgetActive,
284
- windowMovedRoles,
285
- roleQuietHeld,
286
- quietNow,
287
- diskHeld,
288
- };
289
320
  }
@@ -16,7 +16,7 @@
16
16
  * set instead, and `resume --role` has no marker to touch (none is needed: the lift is a
17
17
  * config edit or midnight).
18
18
  * - An unknown role id in the caps map is a config-validation error
19
- * (src/config/config-validation.ts, the `roles.<id>` idiom): explicit misconfiguration
19
+ * (src/config/config-validation-roles.ts, the per-role map checks): explicit misconfiguration
20
20
  * never silently no-ops a cap.
21
21
  *
22
22
  * Shape, like every gate in the family (src/gates/gate-polls.ts): the pure verdict and the
@@ -78,42 +78,28 @@ opencodeSeed = {}) {
78
78
  }
79
79
  // Seed the claude account state before the first run (plans/agent-backends.md part 6a):
80
80
  // `claude auth status` decides subscription vs API key, and an existing file is left alone.
81
- // Gated on a claude seam so a pi-only team spawns nothing, and attempted once per root per
82
- // process: runStartupCheck is also the redeploy/supervisor poll's ask (runStartupProblem),
83
- // and a logged-out claude would otherwise spawn a bounded probe on every poll. If the seed
84
- // fails, the first claude run's observation fills the file in.
85
- if (!claudeSeedAttempted.has(root) && backendsInUse(config).includes("claude")) {
86
- claudeSeedAttempted.add(root);
87
- seedClaudeAccount(root, config);
88
- }
81
+ // If the seed fails, the first claude run's observation fills the file in.
82
+ seedOnce(root, config, "claude", claudeSeedAttempted, () => seedClaudeAccount(root, config));
89
83
  // Seed the codex account state before the first codex run (plans/codex-backend.md §7):
90
84
  // `codex login status` decides subscription vs API key, and an existing file is left alone.
91
- // Gated on a codex seam like the claude seed above, and attempted once per root per process.
92
85
  // API-key billing is refused (decision 5): Codex reports no cost, so per-token billing would
93
86
  // spend money the cap cannot see. If the seed fails or reads something else, the first codex
94
87
  // run's observation fills the file in.
95
- if (!codexSeedAttempted.has(root) && backendsInUse(config).includes("codex")) {
96
- codexSeedAttempted.add(root);
97
- const codexAccount = seedCodexAccount(root, config);
98
- if (codexAccount?.billing === "api") {
99
- return {
100
- problem: "Codex is logged in with an API key; tumwater runs Codex on a ChatGPT subscription only — run `CODEX_HOME=<home> codex login --device-auth`",
101
- };
102
- }
88
+ const codexAccount = seedOnce(root, config, "codex", codexSeedAttempted, () => seedCodexAccount(root, config));
89
+ if (codexAccount?.billing === "api") {
90
+ return {
91
+ problem: "Codex is logged in with an API key; tumwater runs Codex on a ChatGPT subscription only — run `CODEX_HOME=<home> codex login --device-auth`",
92
+ };
103
93
  }
104
94
  // Seed the opencode models catalog (part 6b/7) before the first run: the poll surfaces price
105
95
  // opencode seams from the cached `.tumwater/state/opencode-models.json`, and an absent cache
106
- // reads every opencode seam NOT free (the safe direction for the budget gate). Mirroring the
107
- // claude seed above, it runs once per root per process when a model job routes to opencode;
108
- // a failed fetch keeps the last good cache (or an empty one) and never blocks startup; a
109
- // failed fetch or cache write logs one warning event.
110
- if (!opencodeSeedAttempted.has(root) && backendsInUse(config).includes("opencode")) {
111
- opencodeSeedAttempted.add(root);
112
- await seedOpencodeCatalog(root, config, {
113
- ...opencodeSeed,
114
- warn: (message) => warnEventBestEffort(root, TUMWATER_SOURCE, message),
115
- });
116
- }
96
+ // reads every opencode seam NOT free (the safe direction for the budget gate). A failed fetch
97
+ // keeps the last good cache (or an empty one) and never blocks startup; a failed fetch or
98
+ // cache write logs one warning event.
99
+ await seedOnce(root, config, "opencode", opencodeSeedAttempted, () => seedOpencodeCatalog(root, config, {
100
+ ...opencodeSeed,
101
+ warn: (message) => warnEventBestEffort(root, TUMWATER_SOURCE, message),
102
+ }));
117
103
  const branch = await resolveMainBranch(root, config, branchArg);
118
104
  if ("problem" in branch)
119
105
  return branch;
@@ -159,6 +145,20 @@ export function newOpencodeCatalogRefresh(root, seed, warn) {
159
145
  },
160
146
  };
161
147
  }
148
+ /** Run a per-backend seed at most once per root per process, and only when that backend is in
149
+ * use — the guard the claude-account, codex-account and opencode-catalog seeds share
150
+ * (runStartupCheck is also the redeploy/supervisor poll's ask runStartupProblem, and a seed
151
+ * would otherwise spawn a bounded probe on every poll). Returns the seed body's value when it
152
+ * ran, undefined when the backend is unused or was already attempted this process; each caller
153
+ * decides what to do with the value (refuse on a codex API-key billing, ignore for claude and
154
+ * opencode). */
155
+ function seedOnce(root, config, backend, attempted, seed) {
156
+ if (!attempted.has(root) && backendsInUse(config).includes(backend)) {
157
+ attempted.add(root);
158
+ return seed();
159
+ }
160
+ return undefined;
161
+ }
162
162
  /** Roots this process has already attempted a claude-account seed for (see runStartupCheck),
163
163
  * so the supervisor's and redeploy's repeated startup checks do not respawn `claude auth
164
164
  * status` on every poll when the probe fails. */
@@ -29,7 +29,7 @@
29
29
  * ERROR_STREAK_BREAKER failures.
30
30
  *
31
31
  * Accepted behavior, stated here and not fixed in this change: the dashboards' "failing run after
32
- * run" alert (src/ui/team-alerts.ts) reads consecutiveErrors regardless of pause, so it keeps
32
+ * run" alert (src/ui/team-alert-status.ts) reads consecutiveErrors regardless of pause, so it keeps
33
33
  * listing a breaker-paused role until the streak clears; the role cell's paused badge explains why
34
34
  * it is not running, and the role_streak_paused event tells the operator why. */
35
35
  import { ERROR_STREAK_BREAKER } from "../run/run-apply.js";
@@ -463,7 +463,7 @@ export async function commitMessage(cwd, sha) {
463
463
  /** The subject (first line) of one commit, trimmed, or "" when it cannot be read — the
464
464
  * non-throwing sibling of `commitMessage` and the one home of the `log -1 --format=%s`
465
465
  * read, so the path that names a commit in a message (the post-merge reviewer naming the
466
- * fix it landed, src/merge/merge-post-review.ts) cannot drift on its git spelling
466
+ * fix it landed, src/merge/merge-post-review-drain.ts) cannot drift on its git spelling
467
467
  * or its trim. Its call is through gitTry, so an unreadable repo yields a "" subject, never a
468
468
  * throw; it treats that as an honest degradation, not an error. (Distinct from
469
469
  * commit-message.ts's commitSubject, which parses a subject out of a message string instead of
@@ -24,18 +24,18 @@ import fs from "node:fs";
24
24
  import os from "node:os";
25
25
  import path from "node:path";
26
26
  import { warnEventBestEffort, TUMWATER_SOURCE } from "../events/events.js";
27
- import { writeTextAtomic } from "../files/files.js";
27
+ import { ensureDir, writeTextAtomic } from "../files/files.js";
28
28
  import { conflictedFiles } from "../merge/merge-git.js";
29
29
  import { salvageDir, salvagePendingDir } from "../paths.js";
30
30
  import { errorMessage } from "../text/text.js";
31
- import { pad2 } from "../text/datetime.js";
31
+ import { formatDate, pad2 } from "../text/datetime.js";
32
+ import { plural } from "../text/phrases.js";
32
33
  import { git, gitCouldNotStart, gitTry, runGit } from "./git-run.js";
33
34
  import { isUsableWorktree } from "./worktree.js";
34
35
  /** The patch file's name: a sortable local timestamp, the slot's directory name and the role that
35
36
  * last held it, so a listing reads in order and names whose work each file is. */
36
37
  function salvageFileName(now, slotDir, leftBy) {
37
- const stamp = `${now.getFullYear()}-${pad2(now.getMonth() + 1)}-${pad2(now.getDate())}` +
38
- `-${pad2(now.getHours())}${pad2(now.getMinutes())}${pad2(now.getSeconds())}`;
38
+ const stamp = `${formatDate(now)}-${pad2(now.getHours())}${pad2(now.getMinutes())}${pad2(now.getSeconds())}`;
39
39
  return `${stamp}-${path.basename(slotDir)}-${leftBy ?? "unknown"}.patch`;
40
40
  }
41
41
  /** Save the uncommitted changes in `dir` (modified, deleted and untracked files, ignored ones
@@ -76,24 +76,24 @@ export async function salvageSlotLeftovers(root, dir, { leftBy, recoverFor, forR
76
76
  const base = await git(dir, "rev-parse", "HEAD");
77
77
  const home = recoverFor === null ? salvageDir(root) : salvagePendingDir(root, recoverFor);
78
78
  const file = path.join(home, salvageFileName(now, dir, leftBy));
79
- fs.mkdirSync(home, { recursive: true });
79
+ ensureDir(home);
80
80
  // Atomic: the patch is the only copy left once the lease resets the workspace, so a kill
81
81
  // mid-write must never leave a truncated .patch that a later restore reads and partially
82
82
  // applies. Written to a temp name and renamed, a reader (restoreSalvagedWork) sees either
83
83
  // no file or the complete patch, never a prefix.
84
- writeTextAtomic(file, `# ${files} uncommitted file(s) ${by} left in workspace ${slot}, saved before ${forRole} reused it\n` +
84
+ writeTextAtomic(file, `# ${plural(files, "uncommitted file")} ${by} left in workspace ${slot}, saved before ${forRole} reused it\n` +
85
85
  `# base ${base}; apply with: git apply --index <this file> on a checkout of that commit\n` +
86
86
  `${patch}\n`);
87
87
  const saved = path.relative(root, file);
88
88
  warnEventBestEffort(root, TUMWATER_SOURCE, recoverFor === null
89
- ? `workspace ${slot} still held ${files} uncommitted file(s) ${by} left behind; saved them to ${saved} and reset it for ${forRole}`
90
- : `workspace ${slot} still held ${files} uncommitted file(s) from ${recoverFor}'s unfinished run; saved them to ${saved} for its next run and reset it for ${forRole}`);
89
+ ? `workspace ${slot} still held ${plural(files, "uncommitted file")} ${by} left behind; saved them to ${saved} and reset it for ${forRole}`
90
+ : `workspace ${slot} still held ${plural(files, "uncommitted file")} from ${recoverFor}'s unfinished run; saved them to ${saved} for its next run and reset it for ${forRole}`);
91
91
  return file;
92
92
  }
93
93
  catch (err) {
94
94
  if (gitCouldNotStart(err))
95
95
  throw err;
96
- warnEventBestEffort(root, TUMWATER_SOURCE, `workspace ${slot} still held ${files} uncommitted file(s) ${by} left behind, and saving them failed (${errorMessage(err)}); resetting it for ${forRole} discards them`);
96
+ warnEventBestEffort(root, TUMWATER_SOURCE, `workspace ${slot} still held ${plural(files, "uncommitted file")} ${by} left behind, and saving them failed (${errorMessage(err)}); resetting it for ${forRole} discards them`);
97
97
  return null;
98
98
  }
99
99
  finally {
@@ -72,14 +72,23 @@ function readSlotRecord(slot) {
72
72
  ...slot,
73
73
  dir: slot.dir,
74
74
  lease: isSlotLease(slot.lease) ? readSlotLease(slot.lease) : null,
75
- pinnedFor: typeof slot.pinnedFor === "string" ? canonicalRoleId(slot.pinnedFor) : null,
75
+ pinnedFor: roleIdOrNull(slot.pinnedFor),
76
76
  // Absent stays absent (an older writer's shape is preserved); present-but-foreign is null.
77
77
  pinnedAt: slot.pinnedAt === undefined ? undefined : finiteNumber(slot.pinnedAt, null),
78
- lastRole: typeof slot.lastRole === "string" ? canonicalRoleId(slot.lastRole) : null,
78
+ lastRole: roleIdOrNull(slot.lastRole),
79
79
  lastReleasedAt: finiteNumber(slot.lastReleasedAt, null),
80
- unsettledRun: typeof slot.unsettledRun === "string" ? canonicalRoleId(slot.unsettledRun) : null,
80
+ unsettledRun: roleIdOrNull(slot.unsettledRun),
81
81
  };
82
82
  }
83
+ /** A string role id read from a persisted slot record, canonicalized, or null when absent
84
+ * or foreign. The three canonical-id fields of SlotRecord (pinnedFor, lastRole,
85
+ * unsettledRun) share this one read, so they cannot drift on how a non-string or legacy id
86
+ * reads: a non-string rides along as null (a hand edit or older writer that dropped the
87
+ * field must not name a role), and a legacy id resolves through canonicalRoleId like every
88
+ * reader here. */
89
+ function roleIdOrNull(value) {
90
+ return typeof value === "string" ? canonicalRoleId(value) : null;
91
+ }
83
92
  function writeSlotsStateUnlocked(root, state) {
84
93
  writeJsonAtomic(slotsStatePath(root), state);
85
94
  }
@@ -0,0 +1,127 @@
1
+ /** The worktree pool's one-time startup layout migration (plans/worktree-pool.md, parts 4c/5):
2
+ * move the pre-pool `.tumwater/worktrees/<role>` checkouts onto the pooled `_slot-<n>` layout, and
3
+ * pin the slots a previous process died in. Split out of worktree-pool.ts so that module — the
4
+ * runtime lease machinery every run and verify calls — does not also carry the passes the
5
+ * orchestrator runs exactly once on a team's start. These two passes read and rewrite the same
6
+ * persisted slots.json (updateSlotsState) and run after every RoleRunner is constructed but
7
+ * before any run can lease; they are exported only for orchestrator.ts and the worktree-pool
8
+ * tests. */
9
+ import path from "node:path";
10
+ import { worktreesDir, isCanonicalSlotDir } from "../paths.js";
11
+ import { loadRunnerState } from "../runner/runner-state-load.js";
12
+ import { DIRECTOR_ROLE } from "../roles/roles.js";
13
+ import { updateSlotsState } from "./slots-state.js";
14
+ import { removeWorktreeDir } from "./worktree.js";
15
+ import { warnEventBestEffort, TUMWATER_SOURCE } from "../events/events.js";
16
+ import { errorMessage } from "../text/text.js";
17
+ import { subdirectoryNames } from "../files/files.js";
18
+ /** One-time startup migration (plans/worktree-pool.md "Legacy role worktrees", part 4c/5):
19
+ * retire the pre-pool `.tumwater/worktrees/<role>` checkouts now that runs lease pooled slots.
20
+ * A resumable enabled role keeps its legacy dir as a pinned slot so its pending resume runs in
21
+ * the same cwd; every other legacy dir is removed (its branch keeps any commit), and pins for
22
+ * roles outside `configured` are cleared. `configured` is every role id the config's roles
23
+ * enable (`runnerIds`), never a `--once --role` filter: a scoped run must leave the other roles'
24
+ * legacy dirs and pins alone, or it silently loses their pending resumes. It runs after
25
+ * `runOrchestrator` constructs every
26
+ * `RoleRunner` but before any run can call `leaseSlot`: the constructor infers `resumePending`
27
+ * from the on-disk `running` flag only in memory (it does not save it), so `loadRunnerState` here
28
+ * still reports the on-disk flags. *Removing* a dir is best-effort and logged: the slot records
29
+ * are already updated, so a leaked directory is only a leak the next prune collects. */
30
+ export async function retireLegacyRoleWorktrees(root, configured) {
31
+ const dir = worktreesDir(root);
32
+ // A missing worktrees dir (a team that has not started) yields no names to migrate; the pin
33
+ // retirement pass below still runs over whatever slots.json holds.
34
+ const names = subdirectoryNames(dir, (name) => name !== DIRECTOR_ROLE && !name.startsWith("_"));
35
+ const configuredSet = new Set(configured);
36
+ const toRemove = new Set();
37
+ updateSlotsState(root, (state) => {
38
+ for (const role of names) {
39
+ const legacyDir = path.join(dir, role);
40
+ const legacy = loadRunnerState(root, role);
41
+ const resumable = legacy.resumePending === true || legacy.running === true;
42
+ const ownRecord = state.slots.find((slot) => slot.dir === legacyDir);
43
+ if (resumable && configuredSet.has(role)) {
44
+ const owned = state.slots.find((slot) => slot.pinnedFor === role ||
45
+ (slot.lease?.role === role && slot.lease.purpose === "run"));
46
+ if (owned !== undefined) {
47
+ // The resume happens in the owned slot: drop the legacy record and remove its dir.
48
+ if (owned.dir !== legacyDir) {
49
+ state.slots = state.slots.filter((slot) => slot.dir !== legacyDir);
50
+ toRemove.add(legacyDir);
51
+ }
52
+ if (owned.pinnedFor !== role)
53
+ owned.pinnedAt = Date.now();
54
+ owned.pinnedFor = role;
55
+ }
56
+ else if (ownRecord !== undefined) {
57
+ // Already migrated (a restart before the resume ran): keep it pinned and inert,
58
+ // preserving the pin's original age for the doctor's stuck-pin warning.
59
+ ownRecord.lease = null;
60
+ if (ownRecord.pinnedFor !== role)
61
+ ownRecord.pinnedAt = Date.now();
62
+ ownRecord.pinnedFor = role;
63
+ ownRecord.lastRole = null;
64
+ ownRecord.lastReleasedAt = null;
65
+ }
66
+ else {
67
+ state.slots.push({
68
+ dir: legacyDir,
69
+ lease: null,
70
+ pinnedFor: role,
71
+ pinnedAt: Date.now(),
72
+ lastRole: null,
73
+ lastReleasedAt: null,
74
+ });
75
+ }
76
+ }
77
+ else {
78
+ state.slots = state.slots.filter((slot) => slot.dir !== legacyDir);
79
+ toRemove.add(legacyDir);
80
+ }
81
+ }
82
+ // A paused or removed role must not leak a permanently pinned checkout.
83
+ for (const slot of [...state.slots]) {
84
+ if (slot.pinnedFor === null || configuredSet.has(slot.pinnedFor))
85
+ continue;
86
+ slot.pinnedFor = null;
87
+ slot.pinnedAt = null;
88
+ if (!isCanonicalSlotDir(slot.dir)) {
89
+ state.slots = state.slots.filter((s) => s !== slot);
90
+ toRemove.add(slot.dir);
91
+ }
92
+ }
93
+ });
94
+ for (const victim of toRemove) {
95
+ try {
96
+ await removeWorktreeDir(root, victim);
97
+ }
98
+ catch (err) {
99
+ warnEventBestEffort(root, TUMWATER_SOURCE, `could not remove legacy role workspace ${victim}: ${errorMessage(err)}`);
100
+ }
101
+ }
102
+ }
103
+ /** Startup pass for runs the previous process died in (crash, kill -9, power loss): such a run's
104
+ * lease is still in slots.json with the dead pid, and its RoleRunner reads the persisted
105
+ * `running` flag as a resume that must run in the same checkout, the cwd its session used. Pin
106
+ * each such slot for its role, as the graceful shutdown's abort pin would have, unless the role
107
+ * already holds a pin; the next claim then clears the dead lease (marking the run unsettled) and
108
+ * the resume leases the pinned slot with its edits in place. Only configured roles count, as in
109
+ * retireLegacyRoleWorktrees, so a removed role never leaks a pin. Like that pass, it runs before
110
+ * any run can lease, while loadRunnerState still reports the on-disk flags. */
111
+ export function pinCrashedRuns(root, configured) {
112
+ const configuredSet = new Set(configured);
113
+ updateSlotsState(root, (state) => {
114
+ for (const slot of state.slots) {
115
+ const lease = slot.lease;
116
+ if (lease === null || lease.pid === process.pid || lease.purpose !== "run")
117
+ continue;
118
+ if (!configuredSet.has(lease.role) || state.slots.some((s) => s.pinnedFor === lease.role))
119
+ continue;
120
+ const runner = loadRunnerState(root, lease.role);
121
+ if (runner.running !== true && runner.resumePending !== true)
122
+ continue;
123
+ slot.pinnedFor = lease.role;
124
+ slot.pinnedAt = Date.now();
125
+ }
126
+ });
127
+ }