tumwater 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (361) hide show
  1. package/README.md +74 -108
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-args.js +24 -0
  4. package/dist/src/agent/agent-backend.js +2 -2
  5. package/dist/src/agent/backend-traits.js +60 -0
  6. package/dist/src/agent/claude/claude-account.js +61 -8
  7. package/dist/src/agent/claude/claude-args.js +12 -15
  8. package/dist/src/agent/claude/claude-backend.js +51 -41
  9. package/dist/src/agent/claude/claude-env.js +7 -3
  10. package/dist/src/agent/claude/claude-stream.js +7 -16
  11. package/dist/src/agent/image-input.js +107 -0
  12. package/dist/src/agent/notes-shim.js +41 -0
  13. package/dist/src/agent/opencode/opencode-args.js +61 -0
  14. package/dist/src/agent/opencode/opencode-backend.js +255 -0
  15. package/dist/src/agent/opencode/opencode-catalog.js +184 -0
  16. package/dist/src/agent/opencode/opencode-config.js +188 -0
  17. package/dist/src/agent/opencode/opencode-env.js +36 -0
  18. package/dist/src/agent/opencode/opencode-session-id.js +37 -0
  19. package/dist/src/agent/opencode/opencode-stream.js +278 -0
  20. package/dist/src/agent/role-notes-write.js +16 -32
  21. package/dist/src/agent/run-agent.js +95 -18
  22. package/dist/src/agent/session-ledger.js +57 -1
  23. package/dist/src/backlog/backlog-archive.js +296 -0
  24. package/dist/src/backlog/backlog-cli.js +573 -0
  25. package/dist/src/backlog/backlog-commit.js +31 -0
  26. package/dist/src/backlog/backlog-eligibility.js +357 -33
  27. package/dist/src/backlog/backlog-md.js +98 -18
  28. package/dist/src/backlog/backlog-render.js +62 -11
  29. package/dist/src/backlog/backlog-structure.js +261 -58
  30. package/dist/src/backlog/backlog-write.js +197 -319
  31. package/dist/src/backlog/backlog.js +113 -16
  32. package/dist/src/backlog/question-answer.js +339 -0
  33. package/dist/src/baseline/main-baseline.js +75 -88
  34. package/dist/src/baseline/main-red.js +38 -30
  35. package/dist/src/brief/brief-edit.js +43 -0
  36. package/dist/src/{brief.js → brief/brief.js} +66 -10
  37. package/dist/src/budget/budget.js +32 -15
  38. package/dist/src/budget/fallback-breaker.js +8 -7
  39. package/dist/src/build/build-check-detect.js +8 -7
  40. package/dist/src/build/build-check-events.js +68 -22
  41. package/dist/src/build/build-check-report.js +22 -23
  42. package/dist/src/build/build-check-scoped.js +51 -62
  43. package/dist/src/build/build-check.js +15 -14
  44. package/dist/src/build/build-info.js +2 -1
  45. package/dist/src/build/build-stage.js +23 -22
  46. package/dist/src/build/dep-install.js +22 -21
  47. package/dist/src/build/host-sleep.js +1 -1
  48. package/dist/src/change/change-data.js +25 -14
  49. package/dist/src/change/change-render.js +11 -10
  50. package/dist/src/cli/brief-command.js +58 -0
  51. package/dist/src/cli/cli-args.js +25 -5
  52. package/dist/src/cli/cli-command-args.js +46 -14
  53. package/dist/src/cli/cli-editor.js +64 -0
  54. package/dist/src/cli/cli-flag-specs.js +54 -16
  55. package/dist/src/cli/cli-marker-commands.js +13 -10
  56. package/dist/src/cli/cli-output.js +9 -0
  57. package/dist/src/cli/cli-query-commands.js +458 -49
  58. package/dist/src/cli/cli-repo-gate.js +1 -1
  59. package/dist/src/cli/cli-run.js +208 -59
  60. package/dist/src/cli/completion.js +207 -0
  61. package/dist/src/cli/config-commands.js +14 -11
  62. package/dist/src/cli/help.js +305 -110
  63. package/dist/src/cli/log-commands.js +127 -21
  64. package/dist/src/cli/notify-command.js +33 -18
  65. package/dist/src/cli/question-commands.js +29 -224
  66. package/dist/src/cli.js +121 -40
  67. package/dist/src/collections.js +66 -16
  68. package/dist/src/concurrency/check-permit.js +16 -17
  69. package/dist/src/concurrency/lock.js +6 -5
  70. package/dist/src/concurrency/provider-lease.js +69 -0
  71. package/dist/src/concurrency/semaphore.js +258 -36
  72. package/dist/src/config/config-aliases.js +106 -0
  73. package/dist/src/config/config-editable-keys.js +3 -3
  74. package/dist/src/config/config-fallbacks.js +379 -0
  75. package/dist/src/config/config-field-checks.js +19 -10
  76. package/dist/src/config/config-keys.js +160 -0
  77. package/dist/src/config/config-live.js +64 -12
  78. package/dist/src/config/config-schema.js +34 -22
  79. package/dist/src/config/config-validation.js +132 -48
  80. package/dist/src/config/config-views.js +119 -337
  81. package/dist/src/config/config-write.js +138 -177
  82. package/dist/src/config/config.js +60 -49
  83. package/dist/src/config/model-selector.js +28 -2
  84. package/dist/src/doctor/doctor-backlog.js +546 -49
  85. package/dist/src/doctor/doctor-checks.js +534 -56
  86. package/dist/src/doctor/doctor-holds.js +193 -53
  87. package/dist/src/doctor/doctor-install.js +30 -0
  88. package/dist/src/doctor/doctor-launch-services.js +2 -2
  89. package/dist/src/doctor/doctor-model-checks.js +549 -157
  90. package/dist/src/doctor/doctor-notify.js +27 -0
  91. package/dist/src/doctor/doctor-orphans.js +10 -9
  92. package/dist/src/doctor/doctor-render.js +36 -9
  93. package/dist/src/doctor/doctor.js +80 -22
  94. package/dist/src/doctor/image-probe.js +244 -0
  95. package/dist/src/doctor/probe.js +64 -0
  96. package/dist/src/events/event-format.js +226 -102
  97. package/dist/src/events/event-labels.js +116 -0
  98. package/dist/src/events/event-legacy.js +140 -0
  99. package/dist/src/events/event-read.js +58 -22
  100. package/dist/src/events/event-window.js +27 -7
  101. package/dist/src/events/events.js +18 -14
  102. package/dist/src/events/notify.js +137 -38
  103. package/dist/src/failure/failure-cluster.js +12 -9
  104. package/dist/src/failure/failure-data.js +96 -48
  105. package/dist/src/failure/failure-render.js +69 -58
  106. package/dist/src/failure/failure-state-change.js +33 -26
  107. package/dist/src/failure/time-spend.js +79 -59
  108. package/dist/src/files/file-queue.js +16 -4
  109. package/dist/src/files/files.js +18 -6
  110. package/dist/src/files/json-object.js +18 -3
  111. package/dist/src/files/tail.js +1 -1
  112. package/dist/src/gates/bootstrap-gates.js +5 -4
  113. package/dist/src/gates/budget-gates.js +250 -34
  114. package/dist/src/gates/disk-gate.js +18 -18
  115. package/dist/src/gates/gate-polls.js +117 -69
  116. package/dist/src/gates/maintenance-quota.js +29 -29
  117. package/dist/src/gates/pause-gates.js +11 -11
  118. package/dist/src/gates/readiness.js +2 -1
  119. package/dist/src/gates/role-cap-gates.js +16 -21
  120. package/dist/src/gates/startup-gate.js +75 -31
  121. package/dist/src/gates/streak-gate.js +18 -16
  122. package/dist/src/git/commit-message.js +12 -5
  123. package/dist/src/git/git-diff.js +16 -16
  124. package/dist/src/git/git-run.js +8 -8
  125. package/dist/src/git/git.js +86 -55
  126. package/dist/src/git/slots-state.js +14 -5
  127. package/dist/src/git/worktree-pool.js +25 -18
  128. package/dist/src/git/worktree-use.js +15 -14
  129. package/dist/src/git/worktree.js +18 -18
  130. package/dist/src/gui/gui-args.js +10 -14
  131. package/dist/src/gui/gui-backlog-commands.js +273 -0
  132. package/dist/src/gui/gui-command.js +1 -1
  133. package/dist/src/gui/gui-config-commands.js +440 -0
  134. package/dist/src/gui/gui-endpoint-commands.js +16 -669
  135. package/dist/src/gui/gui-endpoint-common.js +49 -0
  136. package/dist/src/gui/gui-endpoints.js +116 -56
  137. package/dist/src/gui/gui-operator-commands.js +145 -0
  138. package/dist/src/gui/gui-prompt-commands.js +234 -0
  139. package/dist/src/gui/gui-server.js +28 -8
  140. package/dist/src/gui/gui-team-commands.js +164 -0
  141. package/dist/src/gui/http-body.js +17 -1
  142. package/dist/src/history/history-data.js +194 -98
  143. package/dist/src/history/history-render.js +76 -0
  144. package/dist/src/history/history.js +37 -58
  145. package/dist/src/inbox/inbox-attachments.js +75 -14
  146. package/dist/src/inbox/inbox-cancel.js +7 -5
  147. package/dist/src/inbox/inbox-edit.js +1 -1
  148. package/dist/src/inbox/inbox-submit.js +44 -4
  149. package/dist/src/inbox/inbox-top.js +83 -0
  150. package/dist/src/inbox/inbox.js +8 -2
  151. package/dist/src/inbox/pending-prompt.js +1 -1
  152. package/dist/src/inbox/prompt-commands.js +170 -80
  153. package/dist/src/init/init-templates.js +16 -14
  154. package/dist/src/init/init.js +12 -11
  155. package/dist/src/loop/leftover.js +28 -27
  156. package/dist/src/loop/loop-fallback.js +2 -2
  157. package/dist/src/loop/loop-pi.js +106 -52
  158. package/dist/src/loop/loop-recovery.js +15 -15
  159. package/dist/src/loop/loop-state.js +139 -13
  160. package/dist/src/loop/loop-tick.js +194 -86
  161. package/dist/src/loop/loop.js +72 -36
  162. package/dist/src/loop/model-fallback.js +3 -2
  163. package/dist/src/loop/revision.js +16 -7
  164. package/dist/src/merge/backlog-conflicts.js +155 -0
  165. package/dist/src/{landing/landing-batch.js → merge/merge-batch.js} +90 -89
  166. package/dist/src/{landing/landing-check-failures.js → merge/merge-check-failures.js} +87 -41
  167. package/dist/src/{landing/landing-core.js → merge/merge-core.js} +119 -95
  168. package/dist/src/{landing/landing-diff.js → merge/merge-diff.js} +1 -1
  169. package/dist/src/merge/merge-drain.js +212 -0
  170. package/dist/src/{landing/landing-git.js → merge/merge-git.js} +94 -21
  171. package/dist/src/merge/merge-pipeline.js +99 -0
  172. package/dist/src/{landing/landing-questions.js → merge/merge-questions.js} +9 -6
  173. package/dist/src/merge/merge-queue-render.js +75 -0
  174. package/dist/src/{landing/landing-queue.js → merge/merge-queue.js} +95 -36
  175. package/dist/src/merge/merge-slot.js +340 -0
  176. package/dist/src/{landing/landing-stack.js → merge/merge-stack.js} +66 -62
  177. package/dist/src/{landing/landing-merge.js → merge/merge-to-main.js} +72 -62
  178. package/dist/src/merge/merge-verify.js +212 -0
  179. package/dist/src/notes-cli.js +97 -0
  180. package/dist/src/operator/operator-commands.js +154 -47
  181. package/dist/src/operator/operator-intent.js +87 -49
  182. package/dist/src/operator/operator-requests.js +52 -52
  183. package/dist/src/operator/retire.js +62 -27
  184. package/dist/src/operator/revert.js +71 -0
  185. package/dist/src/orchestrator/orchestrator-launch.js +74 -14
  186. package/dist/src/orchestrator/orchestrator-scheduling.js +129 -44
  187. package/dist/src/orchestrator/orchestrator.js +158 -100
  188. package/dist/src/orchestrator/retention.js +29 -12
  189. package/dist/src/orchestrator/startup-migration-steps.js +51 -0
  190. package/dist/src/orchestrator/startup-migrations.js +40 -0
  191. package/dist/src/paths.js +128 -37
  192. package/dist/src/pi/pi-args.js +26 -3
  193. package/dist/src/pi/pi-backend.js +15 -23
  194. package/dist/src/pi/pi-bin.js +18 -12
  195. package/dist/src/pi/pi-event-line.js +17 -1
  196. package/dist/src/pi/pi-failure.js +87 -0
  197. package/dist/src/pi/pi-models.js +126 -38
  198. package/dist/src/pi/pi-run-result.js +1 -1
  199. package/dist/src/pi/pi-settings.js +86 -0
  200. package/dist/src/pi/pi-stream.js +131 -107
  201. package/dist/src/pi/pi-watchdogs.js +47 -43
  202. package/dist/src/pi-extension/bounded-output.js +20 -5
  203. package/dist/src/pi-extension/context-budget.js +3 -1
  204. package/dist/src/pi-extension/context-shake.js +54 -21
  205. package/dist/src/pi-extension/context-window.js +37 -0
  206. package/dist/src/pi-extension/full-output.js +2 -2
  207. package/dist/src/pi-extension/strip-thinking.js +80 -0
  208. package/dist/src/pi-extension/trim-system-prompt.js +38 -0
  209. package/dist/src/process/launch-services.js +11 -10
  210. package/dist/src/process/process-table.js +1 -1
  211. package/dist/src/process/process.js +7 -7
  212. package/dist/src/process/run-marker.js +29 -6
  213. package/dist/src/process/supervisor.js +47 -25
  214. package/dist/src/prompt/dialect.js +30 -10
  215. package/dist/src/prompt/gate-prompts.js +170 -67
  216. package/dist/src/prompt/principles.js +1 -0
  217. package/dist/src/prompt/prompt-followup.js +35 -20
  218. package/dist/src/prompt/prompt.js +249 -91
  219. package/dist/src/redeploy/redeploy-policy.js +10 -10
  220. package/dist/src/redeploy/redeploy.js +6 -4
  221. package/dist/src/redeploy/redeployer.js +63 -55
  222. package/dist/src/redeploy/self-reload.js +1 -1
  223. package/dist/src/report/report-data.js +264 -255
  224. package/dist/src/report/report-fold.js +353 -0
  225. package/dist/src/report/report-render.js +270 -33
  226. package/dist/src/request-timeouts.js +1 -1
  227. package/dist/src/review/known-flakes.js +34 -14
  228. package/dist/src/review/protected-edits.js +33 -0
  229. package/dist/src/review/review-followup.js +16 -4
  230. package/dist/src/review/review-precheck.js +14 -13
  231. package/dist/src/review/review-verdict.js +14 -0
  232. package/dist/src/review/review.js +81 -39
  233. package/dist/src/review/secret-leaks.js +127 -0
  234. package/dist/src/review/suite-rerun.js +4 -3
  235. package/dist/src/roles/legacy-role-ids.js +21 -0
  236. package/dist/src/roles/legacy-role-migration.js +62 -0
  237. package/dist/src/roles/loop-ids.js +19 -2
  238. package/dist/src/roles/role-catalog.js +125 -135
  239. package/dist/src/roles/role-guidance.js +64 -31
  240. package/dist/src/roles/role-labels.js +10 -0
  241. package/dist/src/roles/role-render.js +194 -18
  242. package/dist/src/roles/role-view.js +216 -17
  243. package/dist/src/roles/roles.js +18 -15
  244. package/dist/src/scheduling/backoff.js +29 -19
  245. package/dist/src/scheduling/claims.js +25 -9
  246. package/dist/src/scheduling/once-round.js +11 -11
  247. package/dist/src/scheduling/quiet-hours.js +25 -32
  248. package/dist/src/scheduling/scheduling.js +43 -24
  249. package/dist/src/scheduling/{work-landed-cache.js → work-merged-cache.js} +14 -14
  250. package/dist/src/status/status-budget.js +115 -0
  251. package/dist/src/status/status-data.js +77 -184
  252. package/dist/src/status/status-inbox.js +47 -0
  253. package/dist/src/status/status-install.js +27 -0
  254. package/dist/src/status/status-polls.js +68 -33
  255. package/dist/src/status/status-roles.js +105 -0
  256. package/dist/src/{fleet → team}/error-storm.js +10 -10
  257. package/dist/src/{fleet → team}/failure-spread.js +8 -8
  258. package/dist/src/{fleet → team}/orchestrator-info.js +86 -18
  259. package/dist/src/{fleet → team}/reclaim.js +5 -4
  260. package/dist/src/{fleet/fleet-hold.js → team/team-hold.js} +55 -21
  261. package/dist/src/{fleet/fleet-polls.js → team/team-polls.js} +116 -65
  262. package/dist/src/{fleet/fleet-state.js → team/team-state.js} +62 -37
  263. package/dist/src/text/datetime.js +42 -2
  264. package/dist/src/text/format.js +10 -1
  265. package/dist/src/text/markdown.js +1 -1
  266. package/dist/src/text/phrases.js +35 -34
  267. package/dist/src/text/suggest.js +3 -2
  268. package/dist/src/text/text.js +2 -2
  269. package/dist/src/tick/qa-coverage.js +2 -1
  270. package/dist/src/tick/stage-check.js +147 -26
  271. package/dist/src/tick/telemetry-digest.js +10 -9
  272. package/dist/src/tick/tick-apply.js +101 -72
  273. package/dist/src/tick/tick-detail-data.js +32 -18
  274. package/dist/src/tick/tick-detail.js +62 -21
  275. package/dist/src/tick/tick-finalize.js +29 -22
  276. package/dist/src/tick/tick-outcome.js +2 -1
  277. package/dist/src/tick/tick-prompt.js +94 -26
  278. package/dist/src/tick/tick-resume.js +29 -13
  279. package/dist/src/tick/tick-stage.js +91 -47
  280. package/dist/src/tick/tick-timing.js +60 -59
  281. package/dist/src/tick/tick-usage.js +53 -23
  282. package/dist/src/tick/tick-verdict.js +76 -10
  283. package/dist/src/ui/badges.js +118 -67
  284. package/dist/src/ui/gui/gui-client-boot.js +209 -30
  285. package/dist/src/ui/gui/gui-client-composer.js +102 -23
  286. package/dist/src/ui/gui/gui-client-drawer.js +330 -75
  287. package/dist/src/ui/gui/gui-client-history.js +96 -38
  288. package/dist/src/ui/gui/gui-client-labels.js +24 -0
  289. package/dist/src/ui/gui/gui-client-loops.js +94 -52
  290. package/dist/src/ui/gui/gui-client-markdown.js +1 -0
  291. package/dist/src/ui/gui/gui-client-model.js +63 -43
  292. package/dist/src/ui/gui/gui-client-operator.js +129 -39
  293. package/dist/src/ui/gui/gui-client-pending.js +36 -32
  294. package/dist/src/ui/gui/gui-client-report.js +339 -46
  295. package/dist/src/ui/gui/gui-client-settings.js +148 -14
  296. package/dist/src/ui/gui/gui-client-sound.js +2 -1
  297. package/dist/src/ui/gui/{gui-client-fleet.js → gui-client-team.js} +196 -72
  298. package/dist/src/ui/gui/gui-client.js +26 -20
  299. package/dist/src/ui/gui/gui-page.js +19 -19
  300. package/dist/src/ui/gui/gui-styles.js +12 -4
  301. package/dist/src/ui/progress-data.js +5 -3
  302. package/dist/src/ui/status-model.js +184 -122
  303. package/dist/src/ui/status-payload.js +119 -32
  304. package/dist/src/ui/status-render.js +122 -52
  305. package/dist/src/ui/team-alerts.js +341 -0
  306. package/dist/src/ui/tick-progress-model.js +14 -14
  307. package/dist/src/ui/tone.js +11 -8
  308. package/dist/src/ui/transcript-tail.js +1 -0
  309. package/dist/src/ui/transcript.js +23 -16
  310. package/dist/src/ui/tui/tui-app.js +1 -0
  311. package/dist/src/ui/tui/tui-backlog.js +85 -11
  312. package/dist/src/ui/tui/tui-editor-answer.js +97 -0
  313. package/dist/src/ui/tui/tui-editor-attach.js +107 -0
  314. package/dist/src/ui/tui/tui-editor-brief.js +107 -0
  315. package/dist/src/ui/tui/tui-editor-budget.js +155 -0
  316. package/dist/src/ui/tui/tui-editor-ctx.js +1 -0
  317. package/dist/src/ui/tui/tui-editor-entry.js +230 -0
  318. package/dist/src/ui/tui/tui-editor-interval.js +122 -0
  319. package/dist/src/ui/tui/tui-editor-notes.js +114 -0
  320. package/dist/src/ui/tui/tui-editor-pause.js +266 -0
  321. package/dist/src/ui/tui/tui-editor-prompt.js +95 -0
  322. package/dist/src/ui/tui/tui-editor-queued.js +98 -0
  323. package/dist/src/ui/tui/tui-editor-quiet.js +106 -0
  324. package/dist/src/ui/tui/tui-editor-retire.js +83 -0
  325. package/dist/src/ui/tui/tui-editor-search.js +73 -0
  326. package/dist/src/ui/tui/tui-editor-session.js +97 -0
  327. package/dist/src/ui/tui/tui-editors.js +152 -0
  328. package/dist/src/ui/tui/tui-frame.js +67 -55
  329. package/dist/src/ui/tui/tui-history.js +12 -7
  330. package/dist/src/ui/tui/tui-input.js +49 -44
  331. package/dist/src/ui/tui/tui-keymap.js +1 -0
  332. package/dist/src/ui/tui/tui-keys.js +91 -1704
  333. package/dist/src/ui/tui/tui-pane-activity.js +99 -0
  334. package/dist/src/ui/tui/tui-pane-backlog.js +224 -0
  335. package/dist/src/ui/tui/tui-pane-brief.js +30 -0
  336. package/dist/src/ui/tui/tui-pane-ctx.js +11 -0
  337. package/dist/src/ui/tui/tui-pane-health.js +124 -0
  338. package/dist/src/ui/tui/tui-pane-history.js +316 -0
  339. package/dist/src/ui/tui/tui-pane-pending.js +74 -0
  340. package/dist/src/ui/tui/tui-pane-queued.js +143 -0
  341. package/dist/src/ui/tui/tui-pane-report.js +122 -0
  342. package/dist/src/ui/tui/tui-pane-session.js +101 -0
  343. package/dist/src/ui/tui/tui-pane-transcript.js +150 -0
  344. package/dist/src/ui/tui/tui-pane.js +66 -28
  345. package/dist/src/ui/tui/tui-panes.js +294 -0
  346. package/dist/src/ui/tui/tui.js +45 -25
  347. package/dist/src/verdict/fix-claim.js +141 -38
  348. package/dist/src/verdict/no-change.js +19 -0
  349. package/dist/src/verdict/refusal.js +92 -8
  350. package/dist/src/verdict/reply-contract.js +17 -5
  351. package/dist/src/version.js +3 -2
  352. package/package.json +4 -2
  353. package/dist/src/agent/mcp/role-notes-server.js +0 -160
  354. package/dist/src/landing/backlog-conflicts.js +0 -116
  355. package/dist/src/landing/landing-drain.js +0 -204
  356. package/dist/src/landing/landing-pipeline.js +0 -96
  357. package/dist/src/landing/landing-queue-render.js +0 -31
  358. package/dist/src/landing/landing-slot.js +0 -246
  359. package/dist/src/landing/landing-vetting.js +0 -196
  360. package/dist/src/pi-extension/role-notes.js +0 -40
  361. package/dist/src/ui/fleet-alerts.js +0 -174
@@ -1,17 +1,37 @@
1
1
  /** In-process counting semaphore bounding concurrent work (the orchestrator uses it to
2
2
  * cap simultaneous pi runs). The cross-PROCESS mutex lives in lock.ts; this one only
3
- * coordinates within a single harness process. */
3
+ * coordinates within a single harness process.
4
+ *
5
+ * Since 2026-10-15 it also bounds a model provider (a "key") independently, so a full
6
+ * provider never blocks a waiter on another (plans/provider-concurrency.md §3). The keyless
7
+ * entry points — acquire/tryAcquire/release — are unchanged; only acquireLease, forceLease
8
+ * and setKeyCaps touch the keyed path. */
4
9
  export class Semaphore {
5
10
  capacity;
6
- /** A parked waiter: its scheduling tier plus the resolver that hands it the permit. */
11
+ /** A parked WAITING (admission) waiter: its scheduling tier, optional provider key, and the
12
+ * resolver that hands it the permit. Keyless waiters (the legacy path) carry no key and are
13
+ * never held by a key cap. */
7
14
  waiters = [];
15
+ /** A parked re-key waiter: it already holds a team permit, so it waits only for its key's
16
+ * headroom, ahead of every admission waiter (which would otherwise queue a team permit uselessly
17
+ * behind another provider's runs). `granted` flips when drive() hands the waiter the target key's
18
+ * slot, so the lease's release can tell a delivered grant (give it back) from a still-parked one
19
+ * (cancel it) when the two interleave. */
20
+ rekeyWaiters = [];
8
21
  /** Permits currently held, tracked separately from capacity (rather than as an "available"
9
22
  * count): setCapacity can shrink below the current in-use, and a free-permit count cannot
10
23
  * represent that state — it would over-admit new work after a shrink. */
11
24
  inUse = 0;
25
+ /** The per-key permits currently held: a lease or forced lease charges its key here, and a
26
+ * key at its cap withholds further grants on it. Grows and shrinks only through release and
27
+ * rekey — never preempted. */
28
+ keyInUse = new Map();
29
+ /** The live per-key caps, from `maxConcurrentPerProvider` (plans/provider-concurrency.md
30
+ * §1). An absent key is uncapped — bounded by `capacity` alone. */
31
+ keyCaps = new Map();
12
32
  /** Permits withheld from tier ≥ 1 acquirers so a work loop that has work to take can claim
13
33
  * one without waiting for a maintenance tick to release (plans/parallel-work-instances.md
14
- * "Priority headroom", part 7/7). Tiers ≤ 0 (work, LANDING_TIER, MERGE_TIER) ignore it. */
34
+ * "Priority headroom", part 7/7). Tiers ≤ 0 (work, VERIFY_TIER, MERGE_TIER) ignore it. */
15
35
  reserve = 0;
16
36
  constructor(capacity) {
17
37
  this.capacity = capacity;
@@ -20,12 +40,13 @@ export class Semaphore {
20
40
  get limit() {
21
41
  return this.capacity;
22
42
  }
23
- /** How many acquirers are parked for a permit right now. */
43
+ /** How many acquirers are parked for a permit right now, admission and re-key waiters both.
44
+ * With no keys there are never re-key waiters, so this is exactly today's count. */
24
45
  get waiting() {
25
- return this.waiters.length;
46
+ return this.waiters.length + this.rekeyWaiters.length;
26
47
  }
27
48
  /** Take a free permit without parking: true when one was granted (the same fast path
28
- * acquire takes), false when acquire would have to wait. */
49
+ * acquire takes), false when acquire would have to wait. Unkeyed, as before. */
29
50
  tryAcquire() {
30
51
  if (this.inUse >= this.capacity)
31
52
  return false;
@@ -36,7 +57,7 @@ export class Semaphore {
36
57
  * requests only: on arrival a waiter inserts ahead of every parked waiter with a strictly
37
58
  * greater tier (the orchestrator passes roleTier so work roles beat maintenance across
38
59
  * polls), keeping stable FIFO within a tier. In-flight holders are never preempted — they
39
- * run to completion and their release is what hands out the permit. */
60
+ * run to completion and their release is what hands out the permit. Unkeyed, as before. */
40
61
  async acquire(tier) {
41
62
  if (this.inUse < this.capacity - this.reserveFor(tier)) {
42
63
  this.inUse += 1;
@@ -54,54 +75,255 @@ export class Semaphore {
54
75
  this.waiters.splice(i, 0, waiter);
55
76
  });
56
77
  }
57
- /** Hand the next parked waiter a permit when in-use is under the cap, returning whether one
58
- * was woken. The one home of the permit hand-off: release() calls it once, setCapacity()
59
- * loops it to fill new headroom. It never admits past the cap, so a shrink wakes nothing
60
- * until releases drain in-use under it. */
61
- grantNextWaiter() {
62
- // Peek before admitting: a tier ≥ 1 waiter at the head is the highest-priority one (the
63
- // queue is tier-ascending), so when the reserve withholds a permit from it, it withholds
64
- // one from every waiter behind it too.
65
- const next = this.waiters[0];
66
- if (!next)
67
- return false;
68
- if (this.inUse >= this.capacity - this.reserveFor(next.tier))
69
- return false;
70
- this.waiters.shift();
71
- this.inUse += 1;
72
- next.resolve();
73
- return true;
78
+ /** Admit by both the team total and an optional provider key at once, waiting until both
79
+ * hold, and return a lease handle. `tier` orders the wait exactly as acquire does; `key`
80
+ * bounds the permit by its provider cap: granted when `inUse < capacity − reserveFor(tier)`
81
+ * and the key is under its cap (an absent or uncapped key always passes). FIFO holds within
82
+ * a key, and a full key never delays a waiter on another. */
83
+ async acquireLease(tier, key) {
84
+ let currentKey = key;
85
+ // True once release() has run: a released lease owns no slot and must not undo one again —
86
+ // a second release (or a parked rekey's late grant) must be a no-op, never a negative
87
+ // count. The orchestrator releases each lease exactly once, but the rekey cancel path
88
+ // relies on this guard so a cancelled lease never re-decrements its target key.
89
+ let closed = false;
90
+ // The lease's parked re-key, while it waits on a full target key. At that point the lease
91
+ // holds NO key slot (the old one was returned at the rekey's start), so release() must
92
+ // CANCEL the pending grant rather than undo a slot this lease never took. Null when no
93
+ // rekey is parked.
94
+ let parkedRekey = null;
95
+ const release = () => {
96
+ if (closed)
97
+ return;
98
+ closed = true;
99
+ if (parkedRekey !== null) {
100
+ // A re-key is parked waiting on its new key. Cancel it: pull it off the queue (so drive
101
+ // can't grant it) and, if the grant already arrived this microtask, give the new key's slot
102
+ // back. The lease holds no slot either way, so the team permit is the only thing to free.
103
+ const w = parkedRekey;
104
+ const idx = this.rekeyWaiters.indexOf(w);
105
+ if (idx >= 0)
106
+ this.rekeyWaiters.splice(idx, 1);
107
+ if (w.granted) {
108
+ this.bumpKeyInUse(w.key, -1);
109
+ }
110
+ w.resolve();
111
+ parkedRekey = null;
112
+ }
113
+ else if (currentKey !== undefined) {
114
+ this.bumpKeyInUse(currentKey, -1);
115
+ }
116
+ this.inUse -= 1;
117
+ this.drive();
118
+ };
119
+ const rekey = async (to) => {
120
+ if (to === currentKey || closed)
121
+ return;
122
+ // Give the old key back first, waking that key's waiters.
123
+ if (currentKey !== undefined) {
124
+ this.bumpKeyInUse(currentKey, -1);
125
+ }
126
+ currentKey = to;
127
+ lease.key = to;
128
+ this.drive();
129
+ if (to !== undefined && this.keyAtCap(to)) {
130
+ // The new key is full: wait only for its headroom, ahead of every admission waiter.
131
+ const w = { key: to, resolve: () => { }, granted: false };
132
+ parkedRekey = w;
133
+ this.rekeyWaiters.push(w);
134
+ await new Promise((resolve) => {
135
+ w.resolve = resolve;
136
+ });
137
+ parkedRekey = null;
138
+ if (w.granted)
139
+ this.drive();
140
+ return;
141
+ }
142
+ if (to !== undefined)
143
+ this.bumpKeyInUse(to, 1);
144
+ this.drive();
145
+ };
146
+ const lease = { key: currentKey, release, rekey };
147
+ if (this.inUse < this.capacity - this.reserveFor(tier) && (key === undefined || !this.keyAtCap(key))) {
148
+ this.inUse += 1;
149
+ if (key !== undefined)
150
+ this.bumpKeyInUse(key, 1);
151
+ return lease;
152
+ }
153
+ await new Promise((resolve) => {
154
+ const waiter = { tier, key, resolve };
155
+ let i = this.waiters.length;
156
+ while (i > 0) {
157
+ const prev = this.waiters[i - 1];
158
+ if (!prev || prev.tier <= tier)
159
+ break;
160
+ i -= 1;
161
+ }
162
+ this.waiters.splice(i, 0, waiter);
163
+ });
164
+ return lease;
165
+ }
166
+ /** Synchronously report whether acquireLease(tier, key) would return at once — both the
167
+ * team total and the key (when set) have headroom right now, and the tier's reserve is
168
+ * satisfied. Mirrors the exact fast-path condition acquireLease's first branch uses, so a
169
+ * caller can decide whether to show a "waiting for a slot" state before awaiting a lease
170
+ * that would actually park (merge-drain.ts's resolver wrapper). */
171
+ canLeaseAtOnce(tier, key) {
172
+ return this.inUse < this.capacity - this.reserveFor(tier) && (key === undefined || !this.keyAtCap(key));
74
173
  }
75
- /** The permits withheld from `tier`: `reserve` for tier ≥ 1 (maintenance), 0 for every tier
76
- * that ships work or lands a change (work, LANDING_TIER, MERGE_TIER). */
174
+ /** The director's bypass: take a key's slot at once, never waiting, and never counting toward
175
+ * the team cap. It DOES count toward the key's in-use, so a forced lease can overshoot a
176
+ * full key by one holder; no grant on that key happens until a release drains it back under
177
+ * the cap. Its re-key also never waits. */
178
+ forceLease(key) {
179
+ let currentKey = key;
180
+ if (currentKey !== undefined) {
181
+ this.bumpKeyInUse(currentKey, 1);
182
+ }
183
+ const release = () => {
184
+ if (currentKey !== undefined) {
185
+ this.bumpKeyInUse(currentKey, -1);
186
+ }
187
+ this.drive();
188
+ };
189
+ const rekey = async (to) => {
190
+ if (to === currentKey)
191
+ return;
192
+ if (currentKey !== undefined) {
193
+ this.bumpKeyInUse(currentKey, -1);
194
+ }
195
+ currentKey = to;
196
+ lease.key = to;
197
+ if (to !== undefined)
198
+ this.bumpKeyInUse(to, 1);
199
+ this.drive();
200
+ };
201
+ const lease = { key: currentKey, release, rekey };
202
+ return lease;
203
+ }
204
+ /** Hand every parked waiter a permit it can take — both team and key headroom — in one
205
+ * pass. The one home of the permit hand-off: release, setCapacity, setReserve and setKeyCaps
206
+ * all drive it, and growth loops it to fill new headroom. It never admits past the team cap
207
+ * or a key cap. Re-key waiters run first (they already hold a team permit, so they wait only
208
+ * on their key). Admission waiters are scanned in queue (tier, then FIFO) order: a waiter
209
+ * whose key is full is skipped and keeps its place, so a later waiter on a free key is never
210
+ * stuck behind it — but the reserve stops the scan at the first tier ≥ 1 waiter it withholds
211
+ * from, since the tier-ascending queue means everything behind is withheld too. */
212
+ drive() {
213
+ for (;;) {
214
+ // Re-key waiters first: no team check — they already hold a team permit.
215
+ let granted = false;
216
+ for (let i = 0; i < this.rekeyWaiters.length; i++) {
217
+ const w = this.rekeyWaiters[i];
218
+ if (this.keyAtCap(w.key))
219
+ continue;
220
+ w.granted = true;
221
+ this.rekeyWaiters.splice(i, 1);
222
+ this.bumpKeyInUse(w.key, 1);
223
+ w.resolve();
224
+ granted = true;
225
+ break;
226
+ }
227
+ if (granted)
228
+ continue;
229
+ for (let i = 0; i < this.waiters.length; i++) {
230
+ const w = this.waiters[i];
231
+ if (this.inUse >= this.capacity - this.reserveFor(w.tier))
232
+ break; // reserve/full stops the scan
233
+ if (w.key !== undefined && this.keyAtCap(w.key))
234
+ continue; // skip a full key, keep its place
235
+ this.waiters.splice(i, 1);
236
+ this.inUse += 1;
237
+ if (w.key !== undefined)
238
+ this.bumpKeyInUse(w.key, 1);
239
+ w.resolve();
240
+ granted = true;
241
+ break;
242
+ }
243
+ if (!granted)
244
+ break;
245
+ }
246
+ }
247
+ /** The permits withheld from `tier`: `reserve` for tier ≥ 1 (maintenance), 0 for every tier that
248
+ * ships work or merges a change (work, VERIFY_TIER, MERGE_TIER). */
77
249
  reserveFor(tier) {
78
250
  return tier >= 1 ? this.reserve : 0;
79
251
  }
252
+ /** True when `key` is at its cap — a configured cap the in-use already meets or exceeds. An
253
+ * uncapped key (no cap set) is never "full". */
254
+ keyAtCap(key) {
255
+ const cap = this.keyCaps.get(key);
256
+ return cap !== undefined && (this.keyInUse.get(key) ?? 0) >= cap;
257
+ }
258
+ /** Add `delta` to `key`'s held permits, initializing an absent key at zero first — the
259
+ * single home of the get-or-init bump that release, rekey, force and drive all repeated
260
+ * (an absent key implicitly holds zero, so the first bump must not read `undefined`). */
261
+ bumpKeyInUse(key, delta) {
262
+ this.keyInUse.set(key, (this.keyInUse.get(key) ?? 0) + delta);
263
+ }
80
264
  /** Release a held permit. The finishing work gives back its permit first, and a queued waiter
81
- * takes it over only if there is headroom (grantNextWaiter): after a shrink, inUse can sit at
82
- * or above the cap for a while (the in-flight work admitted before it), and no new grant may
83
- * proceed until releases bring in-use under the cap. In the normal case this is exactly the
84
- * classic hand-off — one release wakes one waiter, net in-use unchanged. */
265
+ * takes it over only if there is headroom (drive): after a shrink, inUse can sit at or above
266
+ * the cap for a while (the in-flight work admitted before it), and no new grant may proceed
267
+ * until releases bring in-use under the cap. In the normal case this is exactly the classic
268
+ * hand-off — one release wakes one waiter, net in-use unchanged. */
85
269
  release() {
86
270
  this.inUse -= 1;
87
- this.grantNextWaiter();
271
+ this.drive();
88
272
  }
89
273
  /** Live-resize the cap (a mid-run tumwater.json edit). Growing admits queued waiters up to
90
274
  * the new headroom — one permit per woken waiter. Shrinking never preempts in-flight work:
91
275
  * it only caps future grants until releases bring in-use under the new cap. */
92
276
  setCapacity(n) {
93
277
  this.capacity = n;
94
- while (this.grantNextWaiter()) {
95
- // One permit per woken waiter, up to the new headroom.
96
- }
278
+ this.drive();
97
279
  }
98
280
  /** Live-resize the permits withheld from tier ≥ 1 acquirers. Lowering it re-runs the hand-off
99
281
  * so a parked maintenance waiter can take a permit the reserve had held; raising it never
100
282
  * preempts an in-flight holder — it only withholds future grants. */
101
283
  setReserve(n) {
102
284
  this.reserve = Math.max(0, n);
103
- while (this.grantNextWaiter()) {
104
- // One permit per woken waiter, up to the headroom the new reserve leaves.
285
+ this.drive();
286
+ }
287
+ /** Live-resize the per-key caps (a mid-run `maxConcurrentPerProvider` edit). Growing admits
288
+ * parked waiters for that key up to the new headroom; shrinking never preempts an in-flight
289
+ * holder — it only withholds future grants on that key until releases drain it under the new
290
+ * cap, the same contract as setCapacity. Non-positive and non-integer entries are dropped. */
291
+ setKeyCaps(caps) {
292
+ this.keyCaps = new Map(Object.entries(caps).filter(([, v]) => Number.isInteger(v) && v > 0));
293
+ this.drive();
294
+ }
295
+ /** The pool's live state for observability (plans/provider-concurrency.md §8): the team
296
+ * in-use/cap, the reserve, and each referenced key's in-use, cap (undefined when uncapped)
297
+ * and waiting count. */
298
+ snapshot() {
299
+ const byKey = {};
300
+ const addKey = (key) => {
301
+ if (key === undefined)
302
+ return;
303
+ if (byKey[key] === undefined) {
304
+ const cap = this.keyCaps.get(key);
305
+ const entry = {
306
+ inUse: this.keyInUse.get(key) ?? 0,
307
+ waiting: 0,
308
+ };
309
+ if (cap !== undefined)
310
+ entry.limit = cap;
311
+ byKey[key] = entry;
312
+ }
313
+ };
314
+ for (const k of this.keyCaps.keys())
315
+ addKey(k);
316
+ for (const k of this.keyInUse.keys())
317
+ addKey(k);
318
+ for (const w of this.waiters) {
319
+ addKey(w.key);
320
+ if (w.key !== undefined)
321
+ byKey[w.key].waiting += 1;
322
+ }
323
+ for (const w of this.rekeyWaiters) {
324
+ addKey(w.key);
325
+ byKey[w.key].waiting += 1;
105
326
  }
327
+ return { inUse: this.inUse, limit: this.capacity, reserve: this.reserve, byKey };
106
328
  }
107
329
  }
@@ -0,0 +1,106 @@
1
+ import { isJsonObject } from "../files/json-object.js";
2
+ import { LEGACY_ROLE_IDS } from "../roles/legacy-role-ids.js";
3
+ /** The config maps keyed by role id, whose old-id keys move to the new id. */
4
+ const ROLE_KEYED_MAPS = ["roles", "maxDailyCostUsdPerRole", "quietHoursPerRole"];
5
+ /** Move each retired role id keyed in `map` to its new id. Returns `map` itself when none
6
+ * moved. */
7
+ function renameRoleIds(map, name, legacy, renamed, problems) {
8
+ let out = map;
9
+ for (const [old, next] of Object.entries(legacy)) {
10
+ if (!Object.hasOwn(map, old))
11
+ continue;
12
+ if (Object.hasOwn(map, next)) {
13
+ problems.push(`sets both ${name}.${old} and ${name}.${next} — keep ${name}.${next} (${old} is its old id)`);
14
+ continue;
15
+ }
16
+ if (out === map)
17
+ out = { ...map };
18
+ out[next] = out[old];
19
+ delete out[old];
20
+ renamed.push(`${name}.${old} → ${name}.${next}`);
21
+ }
22
+ return out;
23
+ }
24
+ /** The one table of renamed config keys: tumwater.json may spell either name, load reads both,
25
+ * and every writer writes only `to`. */
26
+ const CONFIG_KEY_RENAMES = [
27
+ { from: "landBatchMax", to: "mergeBatchMax", scope: "top" },
28
+ { from: "maintenancePerWorkLanding", to: "maintenancePerWorkMerge", scope: "top" },
29
+ { from: "tickTimeoutSeconds", to: "runTimeoutSeconds", scope: "top" },
30
+ { from: "minTickIntervalSeconds", to: "minRunIntervalSeconds", scope: "top" },
31
+ { from: "minTickIntervalSeconds", to: "minRunIntervalSeconds", scope: "role" },
32
+ { from: "customLoops", to: "customRoles", scope: "top" },
33
+ { from: "agent", to: "backend", scope: "top" },
34
+ { from: "agents", to: "backends", scope: "top" },
35
+ ];
36
+ /** Move every old key of one object to its new name. Returns a copy when anything moved. */
37
+ function renameIn(obj, scope, prefix, renamed, problems) {
38
+ let out = obj;
39
+ for (const r of CONFIG_KEY_RENAMES) {
40
+ if (r.scope !== scope || !Object.hasOwn(obj, r.from))
41
+ continue;
42
+ if (Object.hasOwn(obj, r.to)) {
43
+ problems.push(`${prefix}${r.from} and ${prefix}${r.to} are both set — keep ${prefix}${r.to} (${r.from} is its old name)`);
44
+ continue;
45
+ }
46
+ if (out === obj)
47
+ out = { ...obj };
48
+ out[r.to] = out[r.from];
49
+ delete out[r.from];
50
+ renamed.push(`${prefix}${r.from} → ${prefix}${r.to}`);
51
+ }
52
+ return out;
53
+ }
54
+ /** Rewrite a parsed config (or director request) so it carries only current key names. Pure:
55
+ * the input is never mutated, and a non-object passes through unchanged. `renamed` lists each
56
+ * `<from> → <to>` applied; `problems` names each pair the file set both ways. */
57
+ export function normalizeConfigKeys(raw, legacy = LEGACY_ROLE_IDS) {
58
+ const renamed = [];
59
+ const problems = [];
60
+ if (!isJsonObject(raw))
61
+ return { raw, renamed, problems };
62
+ let out = renameIn(raw, "top", "", renamed, problems);
63
+ for (const name of ROLE_KEYED_MAPS) {
64
+ const map = out[name];
65
+ if (!isJsonObject(map))
66
+ continue;
67
+ const next = renameRoleIds(map, name, legacy, renamed, problems);
68
+ if (next !== map)
69
+ out = { ...out, [name]: next };
70
+ }
71
+ if (isJsonObject(out.roles)) {
72
+ let roles = out.roles;
73
+ for (const [id, entry] of Object.entries(out.roles)) {
74
+ if (!isJsonObject(entry))
75
+ continue;
76
+ const next = renameIn(entry, "role", `roles.${id}.`, renamed, problems);
77
+ if (next !== entry) {
78
+ if (roles === out.roles)
79
+ roles = { ...out.roles };
80
+ roles[id] = next;
81
+ }
82
+ }
83
+ if (roles !== out.roles)
84
+ out = { ...out, roles };
85
+ }
86
+ return { raw: out, renamed, problems };
87
+ }
88
+ /** Map a key a person typed to its current spelling: a top-level old name, a dotted key whose
89
+ * first segment is an old top-level name, or `roles.<id>.<old field>`. Anything else passes
90
+ * through unchanged. */
91
+ export function canonicalConfigKey(key, legacy = LEGACY_ROLE_IDS) {
92
+ const parts = key.split(".");
93
+ const first = parts[0] ?? "";
94
+ const top = CONFIG_KEY_RENAMES.find((r) => r.scope === "top" && r.from === first);
95
+ if (top)
96
+ return [top.to, ...parts.slice(1)].join(".");
97
+ if (parts.length >= 2 && ROLE_KEYED_MAPS.includes(first) && Object.hasOwn(legacy, parts[1])) {
98
+ return canonicalConfigKey([first, legacy[parts[1]], ...parts.slice(2)].join("."), legacy);
99
+ }
100
+ if (parts.length === 3 && first === "roles") {
101
+ const role = CONFIG_KEY_RENAMES.find((r) => r.scope === "role" && r.from === parts[2]);
102
+ if (role)
103
+ return `roles.${parts[1]}.${role.to}`;
104
+ }
105
+ return key;
106
+ }
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * The config keys the dashboard's Settings view may show and edit: the top-level settings
3
- * an operator changes often from the browser. Everything else — customLoops, the per-role
3
+ * an operator changes often from the browser. Everything else — customRoles, the per-role
4
4
  * maps — stays CLI/director territory (the plan's curation decision, 2026-10-02). One
5
5
  * constant so handleConfig (GET /api/config) and handleConfigSet (POST /api/config-set)
6
6
  * cannot drift apart on what is editable.
@@ -21,6 +21,6 @@ export const EDITABLE_CONFIG_KEYS = [
21
21
  "quietHours",
22
22
  "notify",
23
23
  // The per-backend section (plans/agent-backends.md, part 4a/7): the whole `agents` object is
24
- // edited here as JSON. The CLI's dotted `agents.<id>.bin`/`.args` merge one field at a time.
25
- "agents",
24
+ // edited here as JSON. The CLI's dotted `backends.<id>.bin`/`.args` merge one field at a time.
25
+ "backends",
26
26
  ];