@huseynsnmz/pi-board 0.15.1 → 0.16.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 (215) hide show
  1. package/CHANGELOG.md +26 -4
  2. package/CONTRIBUTING.md +7 -0
  3. package/README.md +53 -419
  4. package/SECURITY.md +19 -0
  5. package/docs/how-it-works.md +31 -0
  6. package/docs/reference.md +276 -0
  7. package/package.json +18 -19
  8. package/src/index.ts +9 -1
  9. package/src/runtime/service.mjs +12 -0
  10. package/src/ui/dashboard.ts +14 -8
  11. package/src/ui/double-left.ts +5 -1
  12. package/src/ui/focus.ts +7 -1
  13. package/src/ui/waiting.ts +10 -5
  14. package/IMPLEMENTATION_PLAN.md +0 -920
  15. package/PRD.md +0 -484
  16. package/PROGRESS.md +0 -142
  17. package/VERIFY.md +0 -115
  18. package/docs/BATCH_SELECTION_READ_FLOW.md +0 -277
  19. package/docs/EXPLORATION.md +0 -187
  20. package/docs/PTY_ATTACH_IMPLEMENTATION_PLAN.md +0 -581
  21. package/docs/RELEASE.md +0 -148
  22. package/docs/superpowers/plans/2026-08-15-screenlog-gc.md +0 -704
  23. package/docs/superpowers/plans/2026-08-16-attach-double-cursor-jiggle-retry.md +0 -499
  24. package/docs/superpowers/plans/2026-08-21-attach-coldstart-jiggle-rearm.md +0 -797
  25. package/docs/superpowers/plans/2026-08-21-dashboard-keypress-lag.md +0 -366
  26. package/docs/superpowers/plans/2026-08-21-issue-14-needs-input-no-auto-done.md +0 -576
  27. package/docs/superpowers/plans/2026-08-22-jiggle-shrink-and-hold.md +0 -337
  28. package/docs/superpowers/plans/2026-08-22-launch-cwd-favorites.md +0 -916
  29. package/docs/superpowers/plans/2026-08-22-question-tool-grouping.md +0 -211
  30. package/docs/superpowers/plans/2026-08-22-rename-state-labels.md +0 -182
  31. package/docs/superpowers/plans/2026-08-22-stable-list-order.md +0 -51
  32. package/docs/superpowers/plans/2026-08-27-locks-acquirelock-spin.md +0 -614
  33. package/docs/superpowers/plans/2026-08-27-pty-runner-test-flaky.md +0 -32
  34. package/docs/superpowers/plans/2026-08-29-code-refs-badges.md +0 -223
  35. package/docs/superpowers/plans/2026-08-30-circular-navigation.md +0 -308
  36. package/docs/superpowers/plans/2026-08-30-post-exit-timing-fix.md +0 -37
  37. package/docs/superpowers/plans/2026-08-30-pty-attach-quality-debt.md +0 -311
  38. package/docs/superpowers/plans/2026-08-30-readme-v2.md +0 -294
  39. package/docs/superpowers/plans/2026-09-01-attach-detach-gate-cursor-anchor.md +0 -284
  40. package/docs/superpowers/plans/2026-09-02-attach-detach-editor-state.md +0 -722
  41. package/docs/superpowers/plans/2026-09-03-code-refs-pr-backlink-narrow.md +0 -551
  42. package/docs/superpowers/plans/2026-09-03-detach-gate-glyph-fallback.md +0 -146
  43. package/docs/superpowers/plans/2026-09-04-evidence-outputpreview.md +0 -209
  44. package/docs/superpowers/plans/2026-09-04-warm-host-reclaim.md +0 -796
  45. package/docs/superpowers/plans/2026-09-05-issue-13-drainnextfollowup-pty-probe.md +0 -114
  46. package/docs/superpowers/plans/2026-09-05-issue-38-windows-wezterm-ime-cursor.md +0 -73
  47. package/docs/superpowers/plans/2026-09-05-issue-39-truncate-codepoint-boundary.md +0 -143
  48. package/docs/superpowers/plans/2026-09-05-issue-61-mention-fallback-guards.md +0 -226
  49. package/docs/superpowers/plans/2026-09-05-issue-63-flaky-manual-completion.md +0 -87
  50. package/docs/superpowers/plans/2026-09-05-issue-64-changelog-release-helper.md +0 -53
  51. package/docs/superpowers/plans/2026-09-05-pty-host-stacking-sock-race.md +0 -731
  52. package/docs/superpowers/plans/2026-09-08-attach-ctrl-left-detach.md +0 -30
  53. package/docs/superpowers/plans/2026-09-08-dashboard-shrink-repaint.md +0 -68
  54. package/docs/superpowers/plans/2026-09-08-issue-11-attach-runtime-desync-heal.md +0 -917
  55. package/docs/superpowers/plans/2026-09-08-legacy-stale-host-recovery.md +0 -125
  56. package/docs/superpowers/plans/2026-09-08-spawn-async-error-swallow.md +0 -56
  57. package/docs/superpowers/plans/2026-09-08-stale-model-attach-guard.md +0 -96
  58. package/docs/superpowers/plans/2026-09-09-claimpid-blocks-replace.md +0 -267
  59. package/docs/superpowers/plans/2026-09-09-harden-runner-architecture.md +0 -603
  60. package/docs/superpowers/plans/2026-09-09-single-writer-completion.md +0 -252
  61. package/docs/superpowers/plans/2026-09-10-reader-consistency.md +0 -115
  62. package/docs/superpowers/plans/2026-09-14-attach-cursor-dectcem-gate.md +0 -469
  63. package/docs/superpowers/plans/2026-09-14-attach-snapshot.md +0 -92
  64. package/docs/superpowers/plans/2026-09-14-host-meta-orphan-lock.md +0 -771
  65. package/docs/superpowers/plans/2026-09-14-issue-106-terminal-frame-cognition.md +0 -299
  66. package/docs/superpowers/plans/2026-09-14-issue-113-foreground-preview-race.md +0 -609
  67. package/docs/superpowers/plans/2026-09-14-terminal-model.md +0 -145
  68. package/docs/superpowers/plans/2026-09-15-coordinator-pipe-root-normalize.md +0 -224
  69. package/docs/superpowers/plans/2026-09-15-lease-publish-eprem-reclaim.md +0 -341
  70. package/docs/superpowers/plans/2026-09-18-detach-anchor-reporter-endpoint.md +0 -875
  71. package/docs/superpowers/plans/2026-09-20-control-lifecycle.md +0 -116
  72. package/docs/superpowers/plans/2026-09-20-issue-121-perf-gate-out-of-coverage.md +0 -517
  73. package/docs/superpowers/plans/2026-09-23-a5-mid-stream-assertion.md +0 -25
  74. package/docs/superpowers/plans/2026-09-23-cwd-browse-tab-drill.md +0 -393
  75. package/docs/superpowers/plans/2026-09-23-terminal-query-forwarding.md +0 -51
  76. package/docs/superpowers/plans/2026-09-23-unconditional-detach-key.md +0 -58
  77. package/docs/superpowers/plans/2026-09-24-control-reconcile-flake.md +0 -698
  78. package/docs/superpowers/plans/2026-09-24-probe-attached-clients.md +0 -605
  79. package/docs/superpowers/plans/2026-09-29-issue-145-holding-semantic-state.md +0 -1183
  80. package/docs/superpowers/plans/2026-09-29-issue-145-user-verification.md +0 -66
  81. package/docs/superpowers/plans/2026-09-30-board-delete-key-x-y-confirm.md +0 -649
  82. package/docs/superpowers/plans/2026-09-30-issue-153-runner-stop-boot-window.md +0 -618
  83. package/docs/superpowers/plans/2026-09-30-issue-95-test-discipline.md +0 -555
  84. package/docs/superpowers/plans/2026-09-30-publish-dispatch-tag-check.md +0 -259
  85. package/docs/superpowers/plans/2026-10-06-attach-settle-scheme-delivery.md +0 -751
  86. package/docs/superpowers/plans/2026-10-09-issue-167-attach-middle-paste.md +0 -509
  87. package/docs/superpowers/plans/2026-10-09-issue-169-fullscreen-mouse-ownership.md +0 -346
  88. package/docs/superpowers/plans/2026-10-10-attach-delete-in-place.md +0 -832
  89. package/docs/superpowers/plans/2026-10-10-issue-174-selection-mirror-paste-source.md +0 -510
  90. package/docs/superpowers/plans/2026-10-14-issue-170-attach-reply-guard.md +0 -771
  91. package/docs/superpowers/plans/2026-10-14-issue-178-accept-registration-race.md +0 -1257
  92. package/docs/superpowers/plans/2026-10-14-issue-184-snapshot-window-contract.md +0 -657
  93. package/docs/superpowers/plans/2026-10-14-issue-185-attach-wheel-alt-screen.md +0 -47
  94. package/docs/superpowers/plans/2026-10-14-issue-187-motionless-drag.md +0 -931
  95. package/docs/superpowers/plans/2026-10-14-issue-189-mixed-input-segments.md +0 -823
  96. package/docs/superpowers/plans/2026-10-15-issue-188-x10-mouse-release.md +0 -526
  97. package/docs/superpowers/plans/2026-10-16-issue-198-attach-wheel-forward-fallback.md +0 -32
  98. package/docs/superpowers/plans/2026-10-18-attach-wheel-replay-pseudo-scrollback.md +0 -437
  99. package/docs/superpowers/plans/2026-10-19-issue-213-reply-guard-csi-key.md +0 -257
  100. package/docs/superpowers/plans/2026-10-19-issue-214-attach-forward-alt-modifier.md +0 -202
  101. package/docs/superpowers/plans/2026-10-21-issue-221-ask-user-question.md +0 -198
  102. package/docs/superpowers/plans/2026-10-23-issue-222-attach-ready.md +0 -290
  103. package/docs/superpowers/plans/2026-10-23-issue-225-monotonic-foreground-state.md +0 -450
  104. package/docs/superpowers/plans/2026-10-23-issue-229-vim-jkl.md +0 -449
  105. package/docs/superpowers/plans/2026-10-23-issue-232-attach-bg-only-cells.md +0 -153
  106. package/docs/superpowers/plans/2026-10-23-lease-heartbeat-staleness.md +0 -537
  107. package/docs/superpowers/plans/2026-10-23-selected-row-highlight.md +0 -405
  108. package/docs/superpowers/plans/2026-10-24-issue-217-windows-pid-reuse-stopping.md +0 -367
  109. package/docs/superpowers/plans/2026-10-24-issue-230-warm-host-background-runs.md +0 -806
  110. package/docs/superpowers/plans/2026-10-24-issue-238-windows-process-identity-token.md +0 -490
  111. package/docs/superpowers/plans/2026-10-24-issue-241-coordinator-root-exit.md +0 -461
  112. package/docs/superpowers/plans/2026-10-25-issue-239-coordinator-replacement-identity.md +0 -504
  113. package/docs/superpowers/plans/2026-10-26-issue-245-key-hints-doc-sync.md +0 -244
  114. package/docs/superpowers/plans/2026-10-27-issue-172-fullscreen-selection-clear.md +0 -344
  115. package/docs/superpowers/plans/2026-10-27-issue-252-select-key-hints.md +0 -209
  116. package/docs/superpowers/plans/2026-10-28-issue-255-coordinator-endpoint-zombie.md +0 -442
  117. package/docs/superpowers/plans/2026-10-28-issue-257-legacy-pty-runner-endpoint-cleanup.md +0 -256
  118. package/docs/superpowers/plans/2026-11-03-cwd-favorites-full-pool.md +0 -266
  119. package/docs/superpowers/specs/2026-08-15-screenlog-gc-design.md +0 -105
  120. package/docs/superpowers/specs/2026-08-16-attach-double-cursor-jiggle-retry-design.md +0 -142
  121. package/docs/superpowers/specs/2026-08-21-attach-coldstart-jiggle-rearm-design.md +0 -110
  122. package/docs/superpowers/specs/2026-08-21-dashboard-keypress-lag-design.md +0 -59
  123. package/docs/superpowers/specs/2026-08-21-issue-14-needs-input-no-auto-done-design.md +0 -109
  124. package/docs/superpowers/specs/2026-08-22-cwd-favorites-design.md +0 -131
  125. package/docs/superpowers/specs/2026-08-22-jiggle-shrink-and-hold-design.md +0 -111
  126. package/docs/superpowers/specs/2026-08-22-question-tool-grouping-design.md +0 -114
  127. package/docs/superpowers/specs/2026-08-22-rename-state-labels-design.md +0 -80
  128. package/docs/superpowers/specs/2026-08-27-locks-acquirelock-spin-design.md +0 -97
  129. package/docs/superpowers/specs/2026-08-27-pty-runner-test-flaky-design.md +0 -35
  130. package/docs/superpowers/specs/2026-08-29-code-refs-badges-design.md +0 -128
  131. package/docs/superpowers/specs/2026-08-30-circular-navigation-design.md +0 -47
  132. package/docs/superpowers/specs/2026-08-30-eprm-atomicwrite-race-design.md +0 -77
  133. package/docs/superpowers/specs/2026-08-30-post-exit-timing-fix-design.md +0 -80
  134. package/docs/superpowers/specs/2026-08-30-pty-attach-quality-debt-design.md +0 -105
  135. package/docs/superpowers/specs/2026-08-30-readme-v2-design.md +0 -115
  136. package/docs/superpowers/specs/2026-09-01-attach-detach-gate-cursor-anchor-design.md +0 -78
  137. package/docs/superpowers/specs/2026-09-02-attach-detach-editor-state-design.md +0 -120
  138. package/docs/superpowers/specs/2026-09-03-code-refs-pr-backlink-narrow-design.md +0 -92
  139. package/docs/superpowers/specs/2026-09-03-detach-gate-glyph-fallback-design.md +0 -99
  140. package/docs/superpowers/specs/2026-09-04-evidence-outputpreview-design.md +0 -50
  141. package/docs/superpowers/specs/2026-09-04-warm-host-reclaim-design.md +0 -106
  142. package/docs/superpowers/specs/2026-09-05-issue-13-drainnextfollowup-pty-probe-design.md +0 -64
  143. package/docs/superpowers/specs/2026-09-05-issue-38-windows-wezterm-ime-design.md +0 -48
  144. package/docs/superpowers/specs/2026-09-05-issue-39-truncate-codepoint-boundary-design.md +0 -64
  145. package/docs/superpowers/specs/2026-09-05-issue-61-mention-fallback-design.md +0 -71
  146. package/docs/superpowers/specs/2026-09-05-issue-63-flaky-manual-completion-design.md +0 -49
  147. package/docs/superpowers/specs/2026-09-05-issue-64-changelog-helper-design.md +0 -76
  148. package/docs/superpowers/specs/2026-09-05-pty-host-stacking-sock-race-design.md +0 -510
  149. package/docs/superpowers/specs/2026-09-07-issue-11-attach-runtime-desync-heal-design.md +0 -130
  150. package/docs/superpowers/specs/2026-09-08-attach-ctrl-left-detach-design.md +0 -58
  151. package/docs/superpowers/specs/2026-09-08-dashboard-shrink-repaint-design.md +0 -52
  152. package/docs/superpowers/specs/2026-09-08-legacy-stale-host-recovery-design.md +0 -87
  153. package/docs/superpowers/specs/2026-09-08-spawn-async-error-swallow-design.md +0 -56
  154. package/docs/superpowers/specs/2026-09-08-stale-model-attach-guard-design.md +0 -79
  155. package/docs/superpowers/specs/2026-09-09-claimpid-blocks-replace-design.md +0 -147
  156. package/docs/superpowers/specs/2026-09-09-harden-runner-architecture-design.md +0 -304
  157. package/docs/superpowers/specs/2026-09-14-attach-cursor-dectcem-gate-design.md +0 -114
  158. package/docs/superpowers/specs/2026-09-14-host-meta-orphan-lock-design.md +0 -120
  159. package/docs/superpowers/specs/2026-09-14-issue-106-terminal-frame-cognition-design.md +0 -146
  160. package/docs/superpowers/specs/2026-09-14-issue-113-foreground-preview-race-design.md +0 -116
  161. package/docs/superpowers/specs/2026-09-15-coordinator-pipe-root-normalize-design.md +0 -84
  162. package/docs/superpowers/specs/2026-09-15-lease-publish-eprem-reclaim-design.md +0 -92
  163. package/docs/superpowers/specs/2026-09-18-detach-anchor-reporter-endpoint-design.md +0 -123
  164. package/docs/superpowers/specs/2026-09-20-issue-121-perf-gate-out-of-coverage-design.md +0 -204
  165. package/docs/superpowers/specs/2026-09-23-a5-mid-stream-assertion-design.md +0 -43
  166. package/docs/superpowers/specs/2026-09-23-cwd-browse-tab-drill-design.md +0 -95
  167. package/docs/superpowers/specs/2026-09-23-terminal-query-forwarding-design.md +0 -63
  168. package/docs/superpowers/specs/2026-09-23-unconditional-detach-key-design.md +0 -57
  169. package/docs/superpowers/specs/2026-09-24-control-reconcile-flake-design.md +0 -98
  170. package/docs/superpowers/specs/2026-09-24-probe-attached-clients-design.md +0 -190
  171. package/docs/superpowers/specs/2026-09-29-issue-145-holding-semantic-state-design.md +0 -394
  172. package/docs/superpowers/specs/2026-09-30-board-delete-key-x-y-confirm-design.md +0 -86
  173. package/docs/superpowers/specs/2026-09-30-issue-153-runner-stop-boot-window-design.md +0 -75
  174. package/docs/superpowers/specs/2026-09-30-issue-95-test-discipline-design.md +0 -226
  175. package/docs/superpowers/specs/2026-09-30-publish-dispatch-tag-check-design.md +0 -129
  176. package/docs/superpowers/specs/2026-10-06-attach-settle-scheme-delivery-design.md +0 -196
  177. package/docs/superpowers/specs/2026-10-09-issue-167-attach-middle-paste-design.md +0 -125
  178. package/docs/superpowers/specs/2026-10-09-issue-169-fullscreen-mouse-ownership-design.md +0 -130
  179. package/docs/superpowers/specs/2026-10-10-attach-delete-in-place-design.md +0 -224
  180. package/docs/superpowers/specs/2026-10-10-issue-174-selection-mirror-paste-source-design.md +0 -154
  181. package/docs/superpowers/specs/2026-10-14-issue-170-attach-reply-guard-design.md +0 -195
  182. package/docs/superpowers/specs/2026-10-14-issue-178-accept-registration-race-design.md +0 -287
  183. package/docs/superpowers/specs/2026-10-14-issue-184-snapshot-window-contract-design.md +0 -288
  184. package/docs/superpowers/specs/2026-10-14-issue-185-attach-wheel-alt-screen-design.md +0 -126
  185. package/docs/superpowers/specs/2026-10-14-issue-187-motionless-drag-design.md +0 -269
  186. package/docs/superpowers/specs/2026-10-14-issue-189-mixed-input-segments-design.md +0 -369
  187. package/docs/superpowers/specs/2026-10-15-issue-188-x10-mouse-release-design.md +0 -252
  188. package/docs/superpowers/specs/2026-10-16-issue-198-attach-wheel-forward-fallback-design.md +0 -67
  189. package/docs/superpowers/specs/2026-10-18-attach-wheel-replay-pseudo-scrollback-design.md +0 -250
  190. package/docs/superpowers/specs/2026-10-19-issue-213-reply-guard-csi-key-design.md +0 -165
  191. package/docs/superpowers/specs/2026-10-19-issue-214-attach-forward-alt-modifier-design.md +0 -131
  192. package/docs/superpowers/specs/2026-10-20-issue-221-ask-user-question-design.md +0 -270
  193. package/docs/superpowers/specs/2026-10-22-issue-225-monotonic-foreground-state-design.md +0 -225
  194. package/docs/superpowers/specs/2026-10-23-issue-222-attach-ready-design.md +0 -204
  195. package/docs/superpowers/specs/2026-10-23-issue-229-vim-jkl-design.md +0 -90
  196. package/docs/superpowers/specs/2026-10-23-issue-232-attach-bg-only-cells-design.md +0 -146
  197. package/docs/superpowers/specs/2026-10-23-lease-heartbeat-staleness-design.md +0 -271
  198. package/docs/superpowers/specs/2026-10-23-selected-row-highlight-design.md +0 -191
  199. package/docs/superpowers/specs/2026-10-24-issue-217-windows-pid-reuse-stopping-design.md +0 -334
  200. package/docs/superpowers/specs/2026-10-24-issue-230-warm-host-background-runs-design.md +0 -283
  201. package/docs/superpowers/specs/2026-10-24-issue-238-windows-process-identity-token-design.md +0 -286
  202. package/docs/superpowers/specs/2026-10-24-issue-241-coordinator-root-exit-design.md +0 -381
  203. package/docs/superpowers/specs/2026-10-25-issue-239-coordinator-replacement-identity-design.md +0 -331
  204. package/docs/superpowers/specs/2026-10-26-issue-245-key-hints-doc-sync-design.md +0 -72
  205. package/docs/superpowers/specs/2026-10-27-issue-172-fullscreen-selection-clear-design.md +0 -248
  206. package/docs/superpowers/specs/2026-10-27-issue-252-select-key-hints-design.md +0 -95
  207. package/docs/superpowers/specs/2026-10-28-issue-255-coordinator-endpoint-zombie-design.md +0 -380
  208. package/docs/superpowers/specs/2026-10-28-issue-257-legacy-pty-runner-endpoint-cleanup-design.md +0 -197
  209. package/docs/superpowers/specs/2026-11-03-cwd-favorites-full-pool-design.md +0 -106
  210. package/scripts/budget-audit.mjs +0 -135
  211. package/scripts/patch-vulns.mjs +0 -59
  212. package/scripts/release.mjs +0 -446
  213. package/scripts/release_helper.mjs +0 -309
  214. package/scripts/run-perf-gate.mjs +0 -40
  215. package/scripts/run-tests.mjs +0 -375
@@ -1,796 +0,0 @@
1
- # Warm-Host Sweep Reclaim Implementation Plan
2
-
3
- > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
-
5
- **Goal:** 让空闲 PTY host 的回收(TTL 10min / MAX_WARM 4,设计已有但只惰性触发)真正生效:周期 sweep + 宿主启动即 sweep + session_shutdown 清理,消除 detach 后无限期进程泄漏(实测 8.5h)。
6
-
7
- **Architecture:** 把淘汰判定提取为纯函数 `selectIdleHostsToEvict`(新增 graceMs 豁免防 attach 竞态)→ 新增调度器 `createWarmHostSweeper`(周期 + unref)→ `attachWarmHostSweeper` 生命周期接线函数(非 child 宿主启动即 sweep、session_shutdown 清理;isHostedChild no-op)→ service.mjs 暴露 `pruneWarmHosts` 服务方法供接线调用。
8
-
9
- **Tech Stack:** Node ≥20(node:test,mjs),pi 扩展(pi.on 事件 API),无新依赖。
10
-
11
- **Spec:** `docs/superpowers/specs/2026-09-04-warm-host-reclaim-design.md`
12
-
13
- ## Global Constraints
14
-
15
- - 本仓库测试命令:`node --test test/*.test.mjs`(Windows 本机直接可跑;既有基线含 16-17 个已知环境性失败,见知识库笔记,**以无新增失败为准**);typecheck:`npm run typecheck`
16
- - 所有源文件为 .mjs(node:test);`src/index.ts` 是唯一 .ts 改动点(类型不严,import mjs 无类型可容忍)
17
- - 现有环境变量别名必须保留:`AGENT_BOARD_*` 主名 + `AGENT_VIEW_*` legacy 别名(envInt 第五参 legacyName)
18
- - **isHostedChild 必须 no-op**:child pi 内 sweep 会向自己的 runner 发 terminate(自杀链),环境变量 `AGENT_BOARD_CHILD=1` / `AGENT_VIEW_CHILD=1`
19
- - 淘汰判定语义(spec §2 M1,与原 pruneWarmHosts 等价):idle = hostAlive && !isAgentBusy && attachedClients===0 && 非 keepViewId;TTL 淘汰 → survivors 按 idleSince 升序超额淘汰;`maxWarm===0 && ttlMs===0` = 整体禁用
20
- - **Spec 偏差记录**:spec §2 M4(dashboard POLL_MS 轮询顺带 prune)**取消**——POLL_MS=700ms 周期做全量 listRows(每行读 meta/state/host 三文件)磁盘 IO 不划算;60s 周期 sweep 已覆盖 dashboard 打开场景。不引入对应代码与验收。
21
- - 验收矩阵(spec §4):A1-A5 unit / A6-A7 integration / A8 全量回归 / U1 用户实测
22
-
23
- ---
24
-
25
- ### Task 1: 纯判定函数 `selectIdleHostsToEvict` + busy 判定迁入新模块
26
-
27
- **Files:**
28
- - Create: `src/core/warm-host-sweeper.mjs`
29
- - Create: `test/warm-host-sweeper.test.mjs`
30
- - Modify: `src/runtime/service.mjs`(isAgentBusy/hasPendingQuestions 本地定义删除,改为 import;仅此,pruneWarmHosts 重构在 Task 3)
31
-
32
- **Interfaces:**
33
- - Produces: `selectIdleHostsToEvict(rows, opts) -> { ttlEvicted: string[], excessEvicted: string[] }`;`isAgentBusy(row) -> boolean`;`hasPendingQuestions(row) -> boolean`
34
- - Consumes: `listRows(root)` 的 Row 结构(`{ meta:{id,updatedAt}, alive, hostAlive, host:{state,attachedClients,startedAt,runnerPid}|null, state:{semanticState,lastActivityAt,pendingQuestions,processState,currentRunId}|null }`)
35
-
36
- **验收:** A1, A2, A3, A4(unit)
37
-
38
- - [ ] **Step 1: 写 failing 测试** `test/warm-host-sweeper.test.mjs`
39
-
40
- ```js
41
- import assert from "node:assert/strict";
42
- import { test } from "node:test";
43
- import { isAgentBusy, selectIdleHostsToEvict } from "../src/core/warm-host-sweeper.mjs";
44
-
45
- // ---- rows fixture helpers(内存构造,不触盘)----
46
- const NOW = 1_800_000_000_000;
47
- const now = () => NOW;
48
- /** idle 可淘汰 host:hostAlive、非 busy、无客户端、startedAt 很久以前 */
49
- function idleRow(id, overrides = {}) {
50
- return {
51
- meta: { id, updatedAt: NOW - 3600_000 },
52
- alive: false,
53
- hostAlive: true,
54
- host: { state: "alive", attachedClients: 0, startedAt: NOW - 3600_000, runnerPid: 9999 },
55
- state: { semanticState: "completed", processState: "exited", lastActivityAt: NOW - 3600_000, pendingQuestions: [] },
56
- ...overrides,
57
- };
58
- }
59
- const opts = (overrides = {}) => ({ now: now(), maxWarm: 4, ttlMs: 600_000, graceMs: 30_000, keepViewId: null, ...overrides });
60
-
61
- test("A1: ttl 到期的 idle host 进入 ttlEvicted", () => {
62
- const r = selectIdleHostsToEvict([idleRow("v1")], opts());
63
- assert.deepEqual(r.ttlEvicted, ["v1"]);
64
- assert.deepEqual(r.excessEvicted, []);
65
- });
66
-
67
- test("A1: ttl 未到期(lastActivity 距今 < ttl)保留", () => {
68
- const r = selectIdleHostsToEvict([idleRow("v1", { state: { semanticState: "completed", processState: "exited", lastActivityAt: NOW - 60_000, pendingQuestions: [] } })], opts());
69
- assert.deepEqual(r.ttlEvicted, []);
70
- assert.deepEqual(r.excessEvicted, []);
71
- });
72
-
73
- test("A1: keepViewId 豁免", () => {
74
- const r = selectIdleHostsToEvict([idleRow("v1")], opts({ keepViewId: "v1" }));
75
- assert.deepEqual(r.ttlEvicted, []);
76
- });
77
-
78
- test("A2: busy host 永不淘汰(queued/working/pendingQuestions)", () => {
79
- for (const [label, patch] of [
80
- ["working", { alive: true, state: { semanticState: "working", processState: "alive", pendingQuestions: [], lastActivityAt: NOW - 3600_000 } }],
81
- ["queued", { alive: true, state: { semanticState: "queued", processState: "alive", pendingQuestions: [], lastActivityAt: NOW - 3600_000 } }],
82
- ["pendingQuestions", { alive: true, state: { semanticState: "idle", processState: "alive", pendingQuestions: ["q"], lastActivityAt: NOW - 3600_000 } }],
83
- ]) {
84
- const r = selectIdleHostsToEvict([idleRow("v1", patch)], opts());
85
- assert.deepEqual(r.ttlEvicted, [], label);
86
- }
87
- });
88
-
89
- test("A2: attachedClients>0 永不淘汰", () => {
90
- const r = selectIdleHostsToEvict([idleRow("v1", { host: { state: "alive", attachedClients: 1, startedAt: NOW - 3600_000, runnerPid: 9999 } })], opts());
91
- assert.deepEqual(r.ttlEvicted, []);
92
- assert.deepEqual(r.excessEvicted, []);
93
- });
94
-
95
- test("A2: hostAlive=false 不淘汰", () => {
96
- const r = selectIdleHostsToEvict([idleRow("v1", { hostAlive: false })], opts());
97
- assert.deepEqual(r.ttlEvicted, []);
98
- });
99
-
100
- test("A3: graceMs 豁免窗口内(startedAt 距今 < grace)不淘汰;窗口外淘汰", () => {
101
- const fresh = idleRow("v1", { host: { state: "alive", attachedClients: 0, startedAt: NOW - 5_000, runnerPid: 9999 } });
102
- assert.deepEqual(selectIdleHostsToEvict([fresh], opts()).ttlEvicted, []);
103
- const aged = idleRow("v1", { host: { state: "alive", attachedClients: 0, startedAt: NOW - 120_000, runnerPid: 9999 } });
104
- assert.deepEqual(selectIdleHostsToEvict([aged], opts()).ttlEvicted, ["v1"]);
105
- });
106
-
107
- test("A3: startedAt 缺失时不做 grace 豁免(按 ttl 判)", () => {
108
- const noStarted = idleRow("v1", { host: { state: "alive", attachedClients: 0, runnerPid: 9999 } });
109
- assert.deepEqual(selectIdleHostsToEvict([noStarted], opts()).ttlEvicted, ["v1"]);
110
- });
111
-
112
- test("A4: maxWarm 超额淘汰最旧 survivors(idleSince 升序)", () => {
113
- const older = idleRow("old", { state: { semanticState: "completed", processState: "exited", lastActivityAt: NOW - 100_000, pendingQuestions: [] } });
114
- const newer = idleRow("new", { state: { semanticState: "completed", processState: "exited", lastActivityAt: NOW - 50_000, pendingQuestions: [] } });
115
- // ttl 600s 内都不触发 ttl;maxWarm=1 → 淘汰最旧的 old
116
- const r = selectIdleHostsToEvict([newer, older], opts({ maxWarm: 1 }));
117
- assert.deepEqual(r.excessEvicted, ["old"]);
118
- });
119
-
120
- test("A4: maxWarm=0 且 ttlMs=0 返回空(整体禁用语义在调用方;此处按传入参数执行)", () => {
121
- // 纯函数无早退:ttlMs=0 不走 ttl(保持原逻辑 ttlMs>0 条件),maxWarm=0 → 全淘汰
122
- const r = selectIdleHostsToEvict([idleRow("v1")], opts({ maxWarm: 0, ttlMs: 0 }));
123
- assert.deepEqual(r.ttlEvicted, []);
124
- assert.deepEqual(r.excessEvicted, ["v1"]);
125
- });
126
-
127
- test("isAgentBusy 语义保持", () => {
128
- assert.equal(isAgentBusy(idleRow("x")), false);
129
- assert.equal(isAgentBusy(idleRow("x", { alive: true, state: { semanticState: "working", processState: "alive", pendingQuestions: [] } })), true);
130
- assert.equal(isAgentBusy(idleRow("x", { alive: true, state: { semanticState: "idle", processState: "alive", pendingQuestions: ["q"] } })), true);
131
- });
132
- ```
133
-
134
- - [ ] **Step 2: 跑测试确认 fail**
135
-
136
- Run: `node --test test/warm-host-sweeper.test.mjs`
137
- Expected: FAIL(模块不存在,ERR_MODULE_NOT_FOUND)
138
-
139
- - [ ] **Step 3: 实现 `src/core/warm-host-sweeper.mjs`**
140
-
141
- ```js
142
- /**
143
- * Warm-host sweep reclaim (issue #75).
144
- *
145
- * Idle PTY hosts must not live forever: after detach the runner is a detached
146
- * orphan that nothing else will reap (Windows has no parent-death cascade, and
147
- * the dashboard host may exit without a cleanup hook). The design intent
148
- * (AGENT_BOARD_WARM_HOST_TTL_MS / MAX_WARM_HOSTS) already exists in
149
- * service.mjs pruneWarmHosts but only fires lazily on attach/prewarm/dispatch.
150
- * This module extracts the pure eviction decision plus a periodic sweeper so
151
- * the TTL actually runs.
152
- */
153
-
154
- /** @param {import("./store.mjs").Row} row */
155
- export function hasPendingQuestions(row) {
156
- return Array.isArray(row.state?.pendingQuestions) && row.state.pendingQuestions.length > 0;
157
- }
158
-
159
- /**
160
- * Busy = an agent run is active (queued/working) or the session is waiting on
161
- * user questions. Idle/completed/failed/stopped are never busy.
162
- * @param {import("./store.mjs").Row} row
163
- */
164
- export function isAgentBusy(row) {
165
- const st = row.state?.semanticState;
166
- return Boolean(row.alive && (st === "queued" || st === "working" || hasPendingQuestions(row)));
167
- }
168
-
169
- /**
170
- * Pure eviction decision for warm PTY hosts. No IO, no env: every threshold is
171
- * passed in so the logic is directly unit-testable.
172
- *
173
- * idle = host alive && !busy && no attached clients && not keepViewId.
174
- * graceMs exempts freshly started hosts (attach handoff race: ensureHost has
175
- * started a host but the client has not connected yet).
176
- * TTL eviction runs first; survivors over maxWarm are evicted oldest-first by
177
- * idleSince (state.lastActivityAt ?? host.startedAt ?? meta.updatedAt).
178
- *
179
- * @param {Array<import("./store.mjs").Row>} rows
180
- * @param {{ now: number, maxWarm: number, ttlMs: number, graceMs?: number, keepViewId?: string|null }} o
181
- * @returns {{ ttlEvicted: string[], excessEvicted: string[] }} viewIds, ttl group first
182
- */
183
- export function selectIdleHostsToEvict(rows, { now, maxWarm, ttlMs, graceMs = 0, keepViewId = null }) {
184
- const idle = [];
185
- for (const row of rows) {
186
- if (keepViewId != null && row.meta.id === keepViewId) continue;
187
- if (!row.hostAlive) continue;
188
- if (isAgentBusy(row)) continue;
189
- if ((row.host?.attachedClients ?? 0) !== 0) continue;
190
- const startedAt = row.host?.startedAt;
191
- if (graceMs > 0 && startedAt != null && now - startedAt < graceMs) continue;
192
- const idleSince = row.state?.lastActivityAt ?? startedAt ?? row.meta.updatedAt;
193
- idle.push({ id: row.meta.id, idleSince });
194
- }
195
- const ttlEvicted = [];
196
- const survivors = [];
197
- for (const it of idle) {
198
- // Keep the historical semantics: ttlMs === 0 disables the ttl branch
199
- // (only the maxWarm cap applies); both zero disables eviction entirely.
200
- if (ttlMs > 0 && now - it.idleSince > ttlMs) ttlEvicted.push(it.id);
201
- else survivors.push(it);
202
- }
203
- survivors.sort((a, b) => a.idleSince - b.idleSince);
204
- const excess = Math.max(0, survivors.length - maxWarm);
205
- return { ttlEvicted, excessEvicted: survivors.slice(0, excess).map((it) => it.id) };
206
- }
207
- ```
208
-
209
- - [ ] **Step 4: 切换 service.mjs 的 busy 判定为 import(删除本地重复定义)**
210
-
211
- 在 `src/runtime/service.mjs`:
212
- 1. imports 区(约 35 行 `import { normalizeScreenLogMaxBytes...` 之后)加:
213
-
214
- ```js
215
- import { isAgentBusy, selectIdleHostsToEvict } from "../core/warm-host-sweeper.mjs";
216
- ```
217
-
218
- 2. 删除模块尾部两个本地函数(1098 行附近):
219
-
220
- ```js
221
- /** @param {import("../core/store.mjs").Row} row */
222
- function hasPendingQuestions(row) {
223
- return Array.isArray(row.state?.pendingQuestions) && row.state.pendingQuestions.length > 0;
224
- }
225
-
226
- /** @param {import("../core/store.mjs").Row} row */
227
- function isAgentBusy(row) {
228
- const st = row.state?.semanticState;
229
- return Boolean(row.alive && (st === "queued" || st === "working" || hasPendingQuestions(row)));
230
- }
231
- ```
232
-
233
- (hasPendingQuestions 一并删除:`isAgentBusy` 新实现内置同逻辑;先确认 service.mjs 无其他 hasPendingQuestions 引用:`grep -n "hasPendingQuestions" src/runtime/service.mjs` 应只剩删除区两行。)
234
-
235
- - [ ] **Step 5: 全量跑测试确认无回归**
236
-
237
- Run: `node --test test/*.test.mjs`
238
- Expected: 与基线一致(本机基线含已知 Windows 环境性失败 16-17 个;`test/warm-host-sweeper.test.mjs` 全过)
239
-
240
- - [ ] **Step 6: Commit**
241
-
242
- ```bash
243
- git add src/core/warm-host-sweeper.mjs test/warm-host-sweeper.test.mjs src/runtime/service.mjs
244
- git commit -m "feat: pure warm-host eviction selector with grace exemption (issue #75)"
245
- ```
246
-
247
- ---
248
-
249
- ### Task 2: 调度器 `createWarmHostSweeper` + 生命周期接线函数
250
-
251
- **Files:**
252
- - Modify: `src/core/warm-host-sweeper.mjs`(追加两个导出)
253
- - Modify: `test/warm-host-sweeper.test.mjs`(追加用例)
254
-
255
- **Interfaces:**
256
- - Produces: `createWarmHostSweeper({ sweep, intervalMs }) -> { start(), stop(), sweepNow(), active }`;`attachWarmHostSweeper(pi, { isHostedChild, sweep, intervalMs }) -> { dispose(), active }`
257
- - Consumes: Task 1 模块;调用方(Task 4 index.ts)提供 sweep 闭包与 intervalMs
258
-
259
- **验收:** A5(unit),A6 的接线语义部分(child no-op / 启动即 sweep / shutdown 时 sweepNow+stop,用 fake pi 与 fake sweep)
260
-
261
- - [ ] **Step 1: 写 failing 测试(追加到 `test/warm-host-sweeper.test.mjs`)**
262
-
263
- ```js
264
- import { attachWarmHostSweeper, createWarmHostSweeper } from "../src/core/warm-host-sweeper.mjs";
265
-
266
- const sleepMs = (ms) => new Promise((r) => setTimeout(r, ms));
267
-
268
- test("A5: createWarmHostSweeper 周期触发 sweep;stop 后不再触发;sweepNow 立即执行", async () => {
269
- let calls = 0;
270
- const sweeper = createWarmHostSweeper({ sweep: () => { calls += 1; }, intervalMs: 20 });
271
- sweeper.start();
272
- await sleepMs(75); // ≥3 个周期
273
- assert.ok(calls >= 2, `expected >=2 periodic sweeps, got ${calls}`);
274
- sweeper.stop();
275
- const after = calls;
276
- await sleepMs(50);
277
- assert.equal(calls, after, "stop 后不得再触发");
278
- sweeper.sweepNow();
279
- assert.equal(calls, after + 1);
280
- });
281
-
282
- test("A5: intervalMs<=0 时 start 不启周期,sweepNow 仍可用", async () => {
283
- let calls = 0;
284
- const sweeper = createWarmHostSweeper({ sweep: () => { calls += 1; }, intervalMs: 0 });
285
- sweeper.start();
286
- await sleepMs(40);
287
- assert.equal(calls, 0);
288
- sweeper.sweepNow();
289
- assert.equal(calls, 1);
290
- });
291
-
292
- test("A5: start 幂等(重复 start 不叠加定时器)", async () => {
293
- let calls = 0;
294
- const sweeper = createWarmHostSweeper({ sweep: () => { calls += 1; }, intervalMs: 20 });
295
- sweeper.start();
296
- sweeper.start();
297
- await sleepMs(60);
298
- assert.ok(calls >= 1);
299
- sweeper.stop();
300
- });
301
-
302
- test("A6(接线): 非 child:启动即 sweep 一次 + 注册 session_shutdown;shutdown 触发 sweepNow+stop", async () => {
303
- const events = {};
304
- const fakePi = { on: (ev, fn) => { events[ev] = fn; } };
305
- let sweeps = 0;
306
- const attached = attachWarmHostSweeper(fakePi, {
307
- isHostedChild: false,
308
- sweep: () => { sweeps += 1; },
309
- intervalMs: 0, // 关周期,只验启动与 shutdown 语义
310
- });
311
- assert.equal(sweeps, 1, "启动即 sweep 一次");
312
- assert.equal(typeof events.session_shutdown, "function", "注册了 session_shutdown");
313
- events.session_shutdown();
314
- assert.equal(sweeps, 2, "shutdown 时 sweepNow");
315
- attached.dispose();
316
- });
317
-
318
- test("A6(接线): child:完全 no-op(不 sweep、不注册、active=false)", () => {
319
- const events = {};
320
- const fakePi = { on: (ev, fn) => { events[ev] = fn; } };
321
- let sweeps = 0;
322
- const attached = attachWarmHostSweeper(fakePi, {
323
- isHostedChild: true,
324
- sweep: () => { sweeps += 1; },
325
- intervalMs: 1000,
326
- });
327
- assert.equal(attached.active, false);
328
- assert.equal(sweeps, 0);
329
- assert.equal(events.session_shutdown, undefined);
330
- attached.dispose();
331
- });
332
- ```
333
-
334
- - [ ] **Step 2: 跑测试确认 fail**
335
-
336
- Run: `node --test test/warm-host-sweeper.test.mjs`
337
- Expected: FAIL(createWarmHostSweeper / attachWarmHostSweeper 未定义)
338
-
339
- - [ ] **Step 3: 实现(追加到 `src/core/warm-host-sweeper.mjs` 尾部)**
340
-
341
- ```js
342
- /**
343
- * Periodic sweeper for warm hosts. The interval timer is unref'd so it never
344
- * holds the host pi's event loop open on exit. intervalMs <= 0 disables the
345
- * periodic part; sweepNow() always works.
346
- * @param {{ sweep: () => void, intervalMs: number }} o
347
- */
348
- export function createWarmHostSweeper({ sweep, intervalMs }) {
349
- let timer = null;
350
- let stopped = false;
351
- return {
352
- active: true,
353
- start() {
354
- if (stopped || timer !== null) return;
355
- if (intervalMs > 0) {
356
- timer = setInterval(() => {
357
- try { sweep(); } catch { /* best-effort */ }
358
- }, intervalMs);
359
- if (typeof timer.unref === "function") timer.unref();
360
- }
361
- },
362
- sweepNow() {
363
- if (stopped) return;
364
- try { sweep(); } catch { /* best-effort */ }
365
- },
366
- stop() {
367
- stopped = true;
368
- if (timer !== null) {
369
- clearInterval(timer);
370
- timer = null;
371
- }
372
- },
373
- };
374
- }
375
-
376
- /**
377
- * Wire the sweeper to a host pi extension lifetime: sweep once on attach
378
- * (reclaims hosts leaked by a previous host that died without cleanup), run
379
- * periodically, and sweep again on session_shutdown (host pi exiting or the
380
- * extension instance being reloaded for a session switch).
381
- *
382
- * Child pi processes (AGENT_BOARD_CHILD=1 / AGENT_VIEW_CHILD=1) must never
383
- * sweep: they share the same board root and would terminate their own runner
384
- * (suicide chain). They get a strict no-op.
385
- *
386
- * @param {{ on?: (event: string, fn: () => void) => unknown }} pi
387
- * @param {{ isHostedChild: boolean, sweep: () => void, intervalMs: number }} o
388
- */
389
- export function attachWarmHostSweeper(pi, { isHostedChild, sweep, intervalMs }) {
390
- if (isHostedChild) {
391
- return { active: false, dispose() {} };
392
- }
393
- const sweeper = createWarmHostSweeper({ sweep, intervalMs });
394
- const onShutdown = () => {
395
- sweeper.sweepNow();
396
- sweeper.stop();
397
- };
398
- pi.on?.("session_shutdown", onShutdown);
399
- sweeper.start();
400
- sweeper.sweepNow(); // 回收上一个宿主(可能非正常退出)遗留的 warm hosts
401
- return {
402
- active: true,
403
- dispose() {
404
- onShutdown();
405
- },
406
- };
407
- }
408
- ```
409
-
410
- - [ ] **Step 4: 跑测试确认过**
411
-
412
- Run: `node --test test/warm-host-sweeper.test.mjs`
413
- Expected: 全部 PASS(真定时器 20ms 周期测试约需 200ms 实耗)
414
-
415
- - [ ] **Step 5: Commit**
416
-
417
- ```bash
418
- git add src/core/warm-host-sweeper.mjs test/warm-host-sweeper.test.mjs
419
- git commit -m "feat: periodic warm-host sweeper + host-pi lifecycle wiring (issue #75)"
420
- ```
421
-
422
- ---
423
-
424
- ### Task 3: service.mjs 的 pruneWarmHosts 重构(纯函数调用 + grace env)+ 暴露服务方法
425
-
426
- **Files:**
427
- - Modify: `src/runtime/service.mjs`
428
- - `pruneWarmHosts` 函数体(现 404-432 行附近)
429
- - createService 返回对象尾部(`row(viewId)` 方法之后、`};` 之前,约 1090 行)加服务方法
430
- - `envInt` 加 export(1177 行)
431
-
432
- **Interfaces:**
433
- - Consumes: Task 1 `selectIdleHostsToEvict`;现有 `envInt`、`listRows`、`loadRow`、`sendHostMessage`(均 service.mjs 闭包内)
434
- - Produces: createService 返回值新增方法 `pruneWarmHosts(pruneOpts?: { keepViewId?: string|null }) -> void`;`export function envInt`(index.ts Task 4 用)
435
-
436
- **验收:** A3(env 接线),A8(重构等价回归)
437
-
438
- - [ ] **Step 1: 重构 `pruneWarmHosts`(替换现有 404-432 行函数体)**
439
-
440
- 现有函数(含 warm pool 注释与 maxWarm/ttlMs 读取)替换为:
441
-
442
- ```js
443
- /**
444
- * Evict idle warm PTY hosts past TTL / over the warm pool cap.
445
- * Idle = host alive, not agent-busy, no attached clients.
446
- * Called lazily from dispatch/ensureHost (keepViewId = the view being used)
447
- * and periodically / on shutdown by the warm-host sweeper (issue #75).
448
- * @param {{ keepViewId?: string|null }} [pruneOpts]
449
- */
450
- function pruneWarmHosts(pruneOpts = {}) {
451
- const maxWarm = envInt("AGENT_BOARD_MAX_WARM_HOSTS", 4, 0, 50, "AGENT_VIEW_MAX_WARM_HOSTS");
452
- const ttlMs = envInt("AGENT_BOARD_WARM_HOST_TTL_MS", 10 * 60 * 1000, 0, 24 * 60 * 60 * 1000, "AGENT_VIEW_WARM_HOST_TTL_MS");
453
- if (maxWarm === 0 && ttlMs === 0) return;
454
- const graceMs = envInt("AGENT_BOARD_WARM_HOST_GRACE_MS", 30 * 1000, 0, 24 * 60 * 60 * 1000, "AGENT_VIEW_WARM_HOST_GRACE_MS");
455
- const { ttlEvicted, excessEvicted } = selectIdleHostsToEvict(listRows(root), {
456
- now: Date.now(),
457
- maxWarm,
458
- ttlMs,
459
- graceMs,
460
- keepViewId: pruneOpts.keepViewId ?? null,
461
- });
462
- for (const viewId of [...ttlEvicted, ...excessEvicted]) {
463
- const row = loadRow(root, viewId);
464
- if (row?.hostAlive) sendHostMessage(row, { type: "terminate" });
465
- }
466
- }
467
- ```
468
-
469
- 注意:原实现直接对 listRows 结果发 terminate(行即最新);改为淘汰后按 viewId `loadRow` 复查 `hostAlive`——**消除 listRows 与发消息之间的竞态窗口**(原实现同样竞态,此行改动是净改善且行为等价:host 已死则跳过)。
470
-
471
- - [ ] **Step 2: createService 返回对象暴露服务方法**(`row(viewId)` 方法后加)
472
-
473
- ```js
474
- /**
475
- * Evict idle warm PTY hosts (TTL / warm-pool cap). Safe to call anytime:
476
- * busy and attached hosts are never touched.
477
- * @param {{ keepViewId?: string|null }} [pruneOpts]
478
- */
479
- pruneWarmHosts(pruneOpts = {}) {
480
- pruneWarmHosts(pruneOpts);
481
- },
482
- ```
483
-
484
- (方法体内裸标识符 `pruneWarmHosts` 词法解析到闭包函数——方法名不在方法体内自绑定,合法且无歧义。)
485
-
486
- - [ ] **Step 3: envInt 加 export**(1177 行 `function envInt` → `export function envInt`)
487
-
488
- - [ ] **Step 4: 验证**
489
-
490
- Run: `node --test test/*.test.mjs`
491
- Expected: 无新增失败(与基线一致);新用例暂无——Task 4 的 integration 测试覆盖本任务产出
492
-
493
- - [ ] **Step 5: Commit**
494
-
495
- ```bash
496
- git add src/runtime/service.mjs
497
- git commit -m "refactor: pruneWarmHosts via pure selector + grace env, expose service method (issue #75)"
498
- ```
499
-
500
- ---
501
-
502
- ### Task 4: integration 测试——真实行 + net server 捕获 terminate
503
-
504
- **Files:**
505
- - Create: `test/warm-host-sweep.integration.test.mjs`
506
-
507
- **Interfaces:**
508
- - Consumes: Task 1-3(`createService(...).pruneWarmHosts`、store 基建 `createView/writeState/writeHost/writeHostPid`、`P.controlSocketPath`);Task 2 `attachWarmHostSweeper`
509
- - Produces: 无(纯验证)
510
-
511
- **验收:** A6(integration:idle 收 terminate / busy 不收 / 启动即 sweep)、A7(session_shutdown 触发 sweepNow)
512
-
513
- - [ ] **Step 1: 写测试**
514
-
515
- ```js
516
- import assert from "node:assert/strict";
517
- import { createServer } from "node:net";
518
- import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
519
- import { tmpdir } from "node:os";
520
- import { join } from "node:path";
521
- import { test } from "node:test";
522
- import { createService } from "../src/runtime/service.mjs";
523
- import { createView, readState, writeState, writeHost, writeHostPid } from "../src/core/store.mjs";
524
- import * as P from "../src/core/paths.mjs";
525
- import { attachWarmHostSweeper } from "../src/core/warm-host-sweeper.mjs";
526
-
527
- function freshRoot() {
528
- return mkdtempSync(join(tmpdir(), "agentboard-warmhost-"));
529
- }
530
-
531
- function makeService(root) {
532
- return createService({
533
- root,
534
- runnerScript: "/no/runner.mjs",
535
- piCommand: "pi",
536
- piArgsPrefix: [],
537
- defaultCwd: process.cwd(),
538
- launch: () => ({ pid: null, configPath: "/no/config.json" }),
539
- launchHost: () => ({ pid: null, configPath: "/no/host-config.json" }),
540
- launchTitle: () => ({ pid: null, configPath: "/no/title-config.json" }),
541
- });
542
- }
543
-
544
- /** 起一个监听 viewId socketPath 的 net server,返回 { server, received } */
545
- async function captureHostSocket(root, viewId) {
546
- const received = [];
547
- const server = createServer((socket) => {
548
- let buf = "";
549
- socket.on("data", (chunk) => {
550
- buf += chunk.toString("utf8");
551
- for (const line of buf.split("\n")) {
552
- if (!line.trim()) continue;
553
- try { received.push(JSON.parse(line)); } catch { /* ignore */ }
554
- }
555
- });
556
- });
557
- await new Promise((resolve, reject) => {
558
- server.once("error", reject);
559
- server.listen(P.controlSocketPath(root, viewId), resolve);
560
- });
561
- return { server, received };
562
- }
563
-
564
- const waitFor = async (pred, timeoutMs = 3000) => {
565
- const start = Date.now();
566
- while (Date.now() - start < timeoutMs) {
567
- if (pred()) return true;
568
- await new Promise((r) => setTimeout(r, 25));
569
- }
570
- return pred();
571
- };
572
-
573
- function idleView(root, id) {
574
- createView(root, { id, name: id, cwd: "/r" });
575
- const s = readState(root, id);
576
- s.semanticState = "completed";
577
- s.processState = "exited";
578
- s.lastActivityAt = Date.now() - 3600_000;
579
- writeState(root, s);
580
- writeHost(root, {
581
- viewId: id,
582
- mode: "pty",
583
- state: "alive",
584
- runnerPid: process.pid, // hostAlive 判定用(isAlive(process.pid)=true)
585
- socketPath: P.controlSocketPath(root, id),
586
- startedAt: Date.now() - 3600_000,
587
- attachedClients: 0,
588
- lastSeenAt: Date.now(),
589
- });
590
- writeHostPid(root, id, process.pid);
591
- }
592
-
593
- test("A6: 周期/手动 sweep 后 idle host 收到 terminate、busy host 不收", async () => {
594
- const root = freshRoot();
595
- const oldTtl = process.env.AGENT_BOARD_WARM_HOST_TTL_MS;
596
- const oldGrace = process.env.AGENT_BOARD_WARM_HOST_GRACE_MS;
597
- process.env.AGENT_BOARD_WARM_HOST_TTL_MS = "1"; // 立即过期
598
- process.env.AGENT_BOARD_WARM_HOST_GRACE_MS = "0";
599
- try {
600
- idleView(root, "idle1");
601
- const { server, received } = await captureHostSocket(root, "idle1");
602
- const svc = makeService(root);
603
- svc.pruneWarmHosts();
604
- const got = await waitFor(() => received.some((m) => m.type === "terminate"));
605
- assert.equal(got, true, "idle host 应收到 terminate");
606
- server.close();
607
- } finally {
608
- if (oldTtl === undefined) delete process.env.AGENT_BOARD_WARM_HOST_TTL_MS;
609
- else process.env.AGENT_BOARD_WARM_HOST_TTL_MS = oldTtl;
610
- if (oldGrace === undefined) delete process.env.AGENT_BOARD_WARM_HOST_GRACE_MS;
611
- else process.env.AGENT_BOARD_WARM_HOST_GRACE_MS = oldGrace;
612
- rmSync(root, { recursive: true, force: true });
613
- }
614
- });
615
-
616
- test("A6: busy host 不被 sweep 触碰", async () => {
617
- const root = freshRoot();
618
- const oldTtl = process.env.AGENT_BOARD_WARM_HOST_TTL_MS;
619
- process.env.AGENT_BOARD_WARM_HOST_TTL_MS = "1";
620
- try {
621
- createView(root, { id: "busy1", name: "busy1", cwd: "/r" });
622
- const s = readState(root, "busy1");
623
- s.semanticState = "working";
624
- s.processState = "alive";
625
- s.lastActivityAt = Date.now() - 3600_000;
626
- writeState(root, s);
627
- writeHost(root, {
628
- viewId: "busy1", mode: "pty", state: "alive", runnerPid: process.pid,
629
- socketPath: P.controlSocketPath(root, "busy1"),
630
- startedAt: Date.now() - 3600_000, attachedClients: 0, lastSeenAt: Date.now(),
631
- });
632
- writeHostPid(root, "busy1", process.pid);
633
- const { server, received } = await captureHostSocket(root, "busy1");
634
- const svc = makeService(root);
635
- svc.pruneWarmHosts();
636
- await new Promise((r) => setTimeout(r, 200));
637
- assert.equal(received.some((m) => m.type === "terminate"), false, "busy host 不得收 terminate");
638
- server.close();
639
- } finally {
640
- if (oldTtl === undefined) delete process.env.AGENT_BOARD_WARM_HOST_TTL_MS;
641
- else process.env.AGENT_BOARD_WARM_HOST_TTL_MS = oldTtl;
642
- rmSync(root, { recursive: true, force: true });
643
- }
644
- });
645
-
646
- test("A7: session_shutdown 触发 sweepNow(lifecycle 接线端到端)", async () => {
647
- const root = freshRoot();
648
- const oldTtl = process.env.AGENT_BOARD_WARM_HOST_TTL_MS;
649
- const oldGrace = process.env.AGENT_BOARD_WARM_HOST_GRACE_MS;
650
- process.env.AGENT_BOARD_WARM_HOST_TTL_MS = "1";
651
- process.env.AGENT_BOARD_WARM_HOST_GRACE_MS = "0";
652
- try {
653
- idleView(root, "idle1");
654
- const { server, received } = await captureHostSocket(root, "idle1");
655
- const events = {};
656
- const fakePi = { on: (ev, fn) => { events[ev] = fn; } };
657
- const svc = makeService(root);
658
- const attached = attachWarmHostSweeper(fakePi, {
659
- isHostedChild: false,
660
- sweep: () => svc.pruneWarmHosts(),
661
- intervalMs: 0,
662
- });
663
- // 启动即 sweep 已发 terminate(A6);验证 shutdown 再次 sweep(幂等 terminate 无害)
664
- const got1 = await waitFor(() => received.some((m) => m.type === "terminate"));
665
- assert.equal(got1, true);
666
- received.length = 0;
667
- events.session_shutdown();
668
- const got2 = await waitFor(() => received.some((m) => m.type === "terminate"));
669
- assert.equal(got2, true, "shutdown sweep 应再次发 terminate");
670
- attached.dispose();
671
- server.close();
672
- } finally {
673
- if (oldTtl === undefined) delete process.env.AGENT_BOARD_WARM_HOST_TTL_MS;
674
- else process.env.AGENT_BOARD_WARM_HOST_TTL_MS = oldTtl;
675
- if (oldGrace === undefined) delete process.env.AGENT_BOARD_WARM_HOST_GRACE_MS;
676
- else process.env.AGENT_BOARD_WARM_HOST_GRACE_MS = oldGrace;
677
- rmSync(root, { recursive: true, force: true });
678
- }
679
- });
680
- ```
681
-
682
- (Windows 平台注意:`P.controlSocketPath` 为命名管道(`\\.\pipe\pi-agent-board-<viewId>`),`net.createServer().listen(pipePath)` 直接支持;本机即 Windows。)
683
-
684
- - [ ] **Step 2: 跑测试**
685
-
686
- Run: `node --test test/warm-host-sweep.integration.test.mjs`
687
- Expected: 3 个用例全 PASS(若 `writeHostPid` 未被 store.mjs 导出则改由 `writeHost` 的 runnerPid 字段承载——`loadRow` 用 `host?.runnerPid ?? readHostPid`,两者其一即可;测试已两者都写)
688
-
689
- - [ ] **Step 3: Commit**
690
-
691
- ```bash
692
- git add test/warm-host-sweep.integration.test.mjs
693
- git commit -m "test: integration coverage for warm-host sweep terminate path (issue #75)"
694
- ```
695
-
696
- ---
697
-
698
- ### Task 5: index.ts 接线(宿主扩展生命周期挂 sweeper)
699
-
700
- **Files:**
701
- - Modify: `src/index.ts`
702
-
703
- **Interfaces:**
704
- - Consumes: Task 2 `attachWarmHostSweeper`;Task 3 `envInt`(service.mjs export);本文件已有 `isHostedChild`/`root`/`commandOpts`
705
- - Produces: 宿主 pi(非 child)加载扩展即注册周期 sweep + session_shutdown 清理
706
-
707
- **验收:** A6 接线真环境(typecheck + Task 4 lifecycle 测试佐证),A8
708
-
709
- - [ ] **Step 1: imports(index.ts 顶部现有 import 区加两行)**
710
-
711
- ```ts
712
- import { attachWarmHostSweeper } from "./core/warm-host-sweeper.mjs";
713
- import { createService, envInt } from "./runtime/service.mjs";
714
- ```
715
-
716
- (注意替换现有 `import { createService } from "./runtime/service.mjs";` 行——合并成一个 import。)
717
-
718
- - [ ] **Step 2: piAgentBoard(pi) 函数内、`registerAgentBoardCommand` 之前加接线**
719
-
720
- ```ts
721
- // Warm-host reclaim (issue #75): idle PTY hosts must not leak forever after
722
- // detach. Sweep once now (reclaims hosts orphaned by a previous host pi that
723
- // exited without cleanup), then periodically; sweep again on shutdown.
724
- // Child pi processes skip entirely (they share the board root and would
725
- // terminate their own runner).
726
- attachWarmHostSweeper(pi, {
727
- isHostedChild,
728
- sweep: () => {
729
- try {
730
- serviceForContext().pruneWarmHosts();
731
- } catch {
732
- /* best-effort: never break the session over a sweep */
733
- }
734
- },
735
- intervalMs: envInt("AGENT_BOARD_SWEEP_INTERVAL_MS", 60_000, 0, 24 * 60 * 60 * 1000, "AGENT_VIEW_SWEEP_INTERVAL_MS"),
736
- });
737
- ```
738
-
739
- 其中 `serviceForContext` 是模块级工厂,替代现有 `serviceFor(ctx)` 对 ctx 的依赖(sweep 回调在 session_shutdown 时可能无 ctx):
740
-
741
- 在 `serviceFor` 定义附近(现约 53 行)新增并让 `serviceFor` 复用它:
742
-
743
- ```ts
744
- const serviceForContext = () =>
745
- createService({ root, runnerScript: RUNNER_SCRIPT, ptyRunnerScript: PTY_RUNNER_SCRIPT, titleRunnerScript: TITLE_RUNNER_SCRIPT, autoStateRunnerScript: AUTO_STATE_RUNNER_SCRIPT, piCommand, piArgsPrefix, defaultCwd: process.cwd() });
746
- const serviceFor = (ctx: ExtensionContext) =>
747
- createService({ root, runnerScript: RUNNER_SCRIPT, ptyRunnerScript: PTY_RUNNER_SCRIPT, titleRunnerScript: TITLE_RUNNER_SCRIPT, autoStateRunnerScript: AUTO_STATE_RUNNER_SCRIPT, piCommand, piArgsPrefix, defaultCwd: ctx.cwd });
748
- ```
749
-
750
- (保持两行独立亦可——sweep 用 `defaultCwd: process.cwd()` 的版本;**不要**用共享同一 service 实例的方案:piAgentBoard 内 createService 的 defaultCwd 依赖 ctx,session 间不同;sweep 每次临时创建 service 与现有 `serviceFor` 行为一致且无状态泄漏。)
751
-
752
- - [ ] **Step 3: 验证**
753
-
754
- Run: `npm run typecheck`
755
- Expected: 无类型错误
756
- Run: `node --test test/warm-host-sweeper.test.mjs test/warm-host-sweep.integration.test.mjs`
757
- Expected: 全 PASS
758
-
759
- - [ ] **Step 4: Commit**
760
-
761
- ```bash
762
- git add src/index.ts
763
- git commit -m "feat: wire warm-host sweeper into host pi lifecycle (issue #75)"
764
- ```
765
-
766
- ---
767
-
768
- ### Task 6: 全量回归 + U1 用户实测
769
-
770
- **Files:**
771
- - 无代码改动(验证任务)
772
-
773
- **验收:** A8(全量回归无新增失败)、U1(用户实测回收闭环)
774
-
775
- - [ ] **Step 1: 全量回归**
776
-
777
- Run: `npm run typecheck && node --test test/*.test.mjs`
778
- Expected: typecheck 0 错误;测试与基线一致(无新增失败;新增 3 个测试文件全部 PASS)
779
-
780
- - [ ] **Step 2: U1 用户实测清单(提交 PR 前在真实环境执行,结果记录到 PR 描述)**
781
-
782
- 1. 在宿主 pi(本机)加载新扩展代码(worktree 内 `node src/...` 不方便——用安装目录替换或等 PR 合入后实测;临时方案:设 `AGENT_BOARD_WARM_HOST_TTL_MS=10000` 与 `AGENT_BOARD_SWEEP_INTERVAL_MS=5000` 重启 pi 会话)
783
- 2. `/agent-board` → 选一个会话 attach → detach 返回 board → 关掉宿主 pi 窗口
784
- 3. 等待 >10s(TTL 10s)+ >5s(sweep 周期)
785
- 4. 检查:该 view 的 host.json `state: "exited"`;runner 与 child pi 进程已退出(任务管理器 / `Get-Process`)
786
- 5. 通过标准:进程消失、host.json finalize、重启 pi 后 dashboard 中该会话可正常重新 attach(冷启动路径)
787
-
788
- - [ ] **Step 3: 清理 env 痕迹**(若 Step 2 在宿主真实环境设过 env,恢复默认后验证一次默认参数路径)
789
-
790
- ---
791
-
792
- ## Self-Review
793
-
794
- - **Spec coverage**:M1 → Task 1(selectIdleHostsToEvict 纯函数 + graceMs)✓;M2 → Task 2(createWarmHostSweeper)✓;M3 → Task 2(attachWarmHostSweeper)+ Task 5(index.ts 接线)✓;M4 取消(偏差记录于 Global Constraints)✓;Non-goals 未引入代码 ✓;A1-A4 → Task 1;A5 → Task 2;A6 → Task 2(lifecycle 语义)+ Task 4(integration)+ Task 5(真接线);A7 → Task 4;A8 → Task 1/3/6;U1 → Task 6
795
- - **Placeholder scan**:所有代码步骤含完整实现;Task 6 实测步骤为可执行清单
796
- - **Type consistency**:`selectIdleHostsToEvict(rows, {now,maxWarm,ttlMs,graceMs,keepViewId})` 签名在 Task 1/3 一致;`createWarmHostSweeper({sweep,intervalMs})` 与 `attachWarmHostSweeper(pi,{isHostedChild,sweep,intervalMs})` 在 Task 2/4/5 一致;service 方法 `pruneWarmHosts(pruneOpts?)` 在 Task 3/4/5 一致;`envInt(name,fallback,min,max,legacyName)` export 在 Task 3/5 一致