@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,806 +0,0 @@
1
- # Warm-host background-run guard Implementation Plan
2
-
3
- > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
-
5
- **Goal:** 让 warm-host sweeper 不再驱逐正在跑后台 workflow 的宿主,并在驱逐确实发生时/宿主死亡后留下可读痕迹。
6
-
7
- **Architecture:** 新增 `src/core/background-runs.mjs`(纯解析/判定 + 一层 IO shell,读 pi-dynamic-workflows 的 run head 文件);`selectIdleHostsToEvict` 接受可注入的 `isProtected` 谓词保持纯函数;`service.pruneWarmHosts` 两阶段选择(先无保护快跑,有候选才扫描 store)+ 停止前复查;`service.reconcile()` 新增独立 pre-pass,对已死宿主的 paused run 写行级提示(经 `patch_fields`)。
8
-
9
- **Tech Stack:** Node ESM (`.mjs`) + `node:test` + `assert/strict`;无新依赖;JSDoc typedef(`checkJs: false`,类型只作注释)。
10
-
11
- **Spec:** `docs/superpowers/specs/2026-10-24-issue-230-warm-host-background-runs-design.md`
12
-
13
- ## Global Constraints
14
-
15
- - 缩进用 **Tab**;注释与 commit message 用 **英文**;commit subject 用 conventional 格式。
16
- - 测试一律 `node:test` + `node:assert/strict`,文件放 `test/*.test.mjs`;涉及文件系统的用例必须用 `mkdtempSync(tmpdir())` 自建 fixture,**不得**读写宿主仓库或真实 `~/.pi/workflows`。
17
- - 禁止修改 `main`;所有改动在本 worktree(`C:/Users/27499/proj/opensource/pi-agent-board/.pi/worktrees/issue-230-warm-host-background-runs`)内提交,`git add <file>` 逐文件,**禁用 `git add -A`**。
18
- - 生产代码**不得**新增 `writeState` / `writeStatus` import(`test/architecture-writer-boundary.test.mjs` 静态检查)。
19
- - 判定语义固定:保护 ⇔ `status === "running"` 且(`hostStartedAt == null` 或 `updatedAtMs == null` 或 `updatedAtMs >= hostStartedAt - CLOCK_EPSILON_MS`);`CLOCK_EPSILON_MS = 2000`。
20
- - 环境变量名:`AGENT_BOARD_NO_BACKGROUND_RUN_GUARD`(`"1"` = 关闭保护)、`AGENT_BOARD_WORKFLOW_HOME`(覆盖 workflow home)。
21
- - 诊断 code 固定:`warm_prune_probe_failed`、`warm_prune_during_background_run`、`background_run_interrupted`;三者都必须传 `appendDiagnosticOnce` 的 `opts.match`。
22
- - 覆盖率门槛不变:lines 85 / funcs 80 / branches 70(`npm run verify`)。
23
-
24
- ## Review Focus
25
-
26
- 以下几类输入 spec 没有逐条写测试,但最容易在真实使用中出问题;每条都已在对应 Task 里钉了测试:
27
-
28
- 1. **同一 project 下多会话并发**:一个会话在跑 workflow、另一个空闲 → 只有前者被保护(按 `parentSessionFile` 精确匹配,不是按项目目录整体保护)。
29
- 2. **同一路径的不同写法**:盘符大小写、`\` 与 `/` 混用(上游在不同进程里 `path.join` 产物可能不同)→ 仍判同文件。
30
- 3. **上一次宿主遗留的 `running` 记录**(宿主崩溃后没来得及落终态)→ **不保护**,否则 host 泄漏回来了。
31
- 4. **半写坏掉的 head 文件**(原子写落 `.bak` 中途被读)→ 不抛异常、不改变其它记录的判定结果。
32
- 5. **`AGENT_BOARD_NO_BACKGROUND_RUN_GUARD=1`** → 行为与今天逐字节一致(长 run 仍会被驱逐),但 paused 提示链路照常工作(提示不受该开关控制;U2 实测覆盖这个组合)。
33
-
34
- ---
35
-
36
- ### Task 1: 纯判定核心 `src/core/background-runs.mjs`
37
-
38
- **Files:**
39
- - Create: `src/core/background-runs.mjs`
40
- - Test: `test/background-runs.test.mjs`
41
-
42
- **Interfaces:**
43
- - Produces:
44
- - `CLOCK_EPSILON_MS: number` = `2000`
45
- - `resolveWorkflowHome(o?: { env?: Record<string,string|undefined>, home?: string }): string`
46
- - `parseRunHead(raw: any): { runId: string, status: string, parentSessionFile: string|null, startedAtMs: number|null, updatedAtMs: number|null, hasCheckpoint: boolean } | null`
47
- - `isSameSessionFile(a: string, b: string, o?: { platform?: string }): boolean`
48
- - `classifyRunForRow(run: Object, o: { sessionFile: string, hostStartedAt: number|null }): "protecting"|"paused"|"irrelevant"`
49
- - `classifyRunsForRow(runs: Array<Object>, o: same): { state: "protecting"|"paused"|"irrelevant", run: Object|null }`
50
-
51
- - [ ] **Step 1: 先写失败的测试**
52
-
53
- `test/background-runs.test.mjs`(完整文件):
54
-
55
- ```js
56
- import assert from "node:assert/strict";
57
- import { test } from "node:test";
58
- import {
59
- CLOCK_EPSILON_MS,
60
- classifyRunForRow,
61
- classifyRunsForRow,
62
- isSameSessionFile,
63
- parseRunHead,
64
- resolveWorkflowHome,
65
- } from "../src/core/background-runs.mjs";
66
-
67
- const V2 = (o = {}) => ({
68
- format: "pi-workflow-run-v2",
69
- index: { runId: "run-1", status: "running", parentSessionFile: "C:\\r\\sessions\\view_a.jsonl", updatedAt: "2026-10-24T01:00:00.000Z", startedAt: "2026-10-24T00:50:00.000Z", ...o },
70
- summary: { checkpoint: null },
71
- keys: [],
72
- });
73
-
74
- test("A1: v2 head -> status/parentSessionFile/updatedAt", () => {
75
- const h = parseRunHead(V2());
76
- assert.equal(h.runId, "run-1");
77
- assert.equal(h.status, "running");
78
- assert.equal(h.parentSessionFile, "C:\\r\\sessions\\view_a.jsonl");
79
- assert.equal(h.updatedAtMs, Date.parse("2026-10-24T01:00:00.000Z"));
80
- assert.equal(h.hasCheckpoint, false);
81
- });
82
-
83
- test("A1: v2 head without parentSessionFile -> null parent", () => {
84
- const raw = V2();
85
- delete raw.index.parentSessionFile;
86
- assert.equal(parseRunHead(raw).parentSessionFile, null);
87
- });
88
-
89
- test("A1: legacy flat record -> parseable but no parentSessionFile", () => {
90
- const h = parseRunHead({ runId: "old-1", status: "completed", startedAt: "2026-08-30T00:00:00.000Z" });
91
- assert.equal(h.runId, "old-1");
92
- assert.equal(h.status, "completed");
93
- assert.equal(h.parentSessionFile, null);
94
- });
95
-
96
- test("A1: garbage never throws and yields null", () => {
97
- for (const bad of [null, 42, "str", [], {}, { format: "something-else" }]) {
98
- assert.equal(parseRunHead(bad), null);
99
- }
100
- });
101
-
102
- test("A2: running + same session + updatedAt >= hostStartedAt -> protecting", () => {
103
- const h = parseRunHead(V2());
104
- const startedAt = Date.parse("2026-10-24T00:55:00.000Z");
105
- assert.equal(classifyRunForRow(h, { sessionFile: "C:\\r\\sessions\\view_a.jsonl", hostStartedAt: startedAt }), "protecting");
106
- });
107
-
108
- test("A3: stale running (updatedAt < hostStartedAt) -> irrelevant", () => {
109
- const h = parseRunHead(V2());
110
- const startedAt = Date.parse("2026-10-24T02:00:00.000Z");
111
- assert.equal(classifyRunForRow(h, { sessionFile: "C:\\r\\sessions\\view_a.jsonl", hostStartedAt: startedAt }), "irrelevant");
112
- });
113
-
114
- test("A3: stale running within CLOCK_EPSILON_MS still protects", () => {
115
- const h = parseRunHead(V2());
116
- const startedAt = Date.parse("2026-10-24T01:00:00.000Z") + CLOCK_EPSILON_MS - 1;
117
- assert.equal(classifyRunForRow(h, { sessionFile: "C:\\r\\sessions\\view_a.jsonl", hostStartedAt: startedAt }), "protecting");
118
- });
119
-
120
- test("A3: missing hostStartedAt protects; missing updatedAt protects", () => {
121
- const h = parseRunHead(V2());
122
- assert.equal(classifyRunForRow(h, { sessionFile: "C:\\r\\sessions\\view_a.jsonl", hostStartedAt: null }), "protecting");
123
- const raw = V2();
124
- delete raw.index.updatedAt;
125
- assert.equal(classifyRunForRow(parseRunHead(raw), { sessionFile: raw.index.parentSessionFile, hostStartedAt: Date.now() }), "protecting");
126
- });
127
-
128
- test("A4: non-running statuses never protect; paused is classified separately", () => {
129
- const sessionFile = "C:\\r\\sessions\\view_a.jsonl";
130
- for (const status of ["completed", "failed", "aborted", "pending", ""]) {
131
- assert.equal(classifyRunForRow(parseRunHead(V2({ status })), { sessionFile, hostStartedAt: null }), "irrelevant", status);
132
- }
133
- assert.equal(classifyRunForRow(parseRunHead(V2({ status: "paused" })), { sessionFile, hostStartedAt: null }), "paused");
134
- });
135
-
136
- test("A4: other session's run is irrelevant", () => {
137
- const h = parseRunHead(V2());
138
- assert.equal(classifyRunForRow(h, { sessionFile: "C:\\r\\sessions\\view_b.jsonl", hostStartedAt: null }), "irrelevant");
139
- });
140
-
141
- test("A5: win32 path spelling differences compare equal (platform injected)", () => {
142
- assert.equal(isSameSessionFile("C:\\r\\sessions\\view_a.jsonl", "c:/r/sessions/view_a.jsonl", { platform: "win32" }), true);
143
- assert.equal(isSameSessionFile("c:/r/sessions/view_a.jsonl", "C:\\r\\sessions\\view_b.jsonl", { platform: "win32" }), false);
144
- assert.equal(isSameSessionFile("/tmp/x/a.jsonl", "/tmp/x/./a.jsonl", { platform: "linux" }), true);
145
- assert.equal(isSameSessionFile("/tmp/x/A.jsonl", "/tmp/x/a.jsonl", { platform: "linux" }), false);
146
- });
147
-
148
- test("A5: missing operands -> false, never throws", () => {
149
- assert.equal(isSameSessionFile(null, "/a", {}), false);
150
- assert.equal(isSameSessionFile("/a", undefined, {}), false);
151
- });
152
-
153
- test("A16: workflow home precedence and tilde expansion", () => {
154
- const home = "/home/u";
155
- assert.equal(resolveWorkflowHome({ env: {}, home }), "/home/u/.pi/workflows");
156
- assert.equal(resolveWorkflowHome({ env: { PI_CODING_AGENT_DIR: "/custom/agent" }, home }), "/custom/agent/workflows");
157
- assert.equal(resolveWorkflowHome({ env: { PI_CODING_AGENT_DIR: "~/agent" }, home }), "/home/u/agent/workflows");
158
- assert.equal(
159
- resolveWorkflowHome({ env: { PI_CODING_AGENT_DIR: "/custom/agent", AGENT_BOARD_WORKFLOW_HOME: "/explicit/wf" }, home }),
160
- "/explicit/wf",
161
- );
162
- assert.equal(resolveWorkflowHome({ env: { AGENT_BOARD_WORKFLOW_HOME: "~/wf" }, home }), "/home/u/wf");
163
- });
164
-
165
- test("ReviewFocus 1: two runs in one project — only the matching session protects", () => {
166
- const mine = parseRunHead(V2());
167
- const other = parseRunHead(V2({ runId: "run-2", parentSessionFile: "C:\\r\\sessions\\view_b.jsonl" }));
168
- const res = classifyRunsForRow([other, mine], { sessionFile: "C:\\r\\sessions\\view_a.jsonl", hostStartedAt: null });
169
- assert.equal(res.state, "protecting");
170
- assert.equal(res.run.runId, "run-1");
171
- });
172
-
173
- test("ReviewFocus: protecting outranks paused in one scan", () => {
174
- const paused = parseRunHead(V2({ runId: "p", status: "paused" }));
175
- const running = parseRunHead(V2({ runId: "r", status: "running" }));
176
- assert.equal(classifyRunsForRow([paused, running], { sessionFile: "C:\\r\\sessions\\view_a.jsonl", hostStartedAt: null }).state, "protecting");
177
- assert.equal(classifyRunsForRow([paused], { sessionFile: "C:\\r\\sessions\\view_a.jsonl", hostStartedAt: null }).state, "paused");
178
- assert.equal(classifyRunsForRow([], { sessionFile: "C:\\r\\sessions\\view_a.jsonl", hostStartedAt: null }).state, "irrelevant");
179
- });
180
- ```
181
-
182
- - [ ] **Step 2: 运行测试确认失败**
183
-
184
- Run: `node --test test/background-runs.test.mjs`
185
- Expected: FAIL — `Cannot find module '.../src/core/background-runs.mjs'`
186
-
187
- - [ ] **Step 3: 实现 `src/core/background-runs.mjs`(纯函数部分)**
188
-
189
- 导出签名如上。实现要点(算法由 spec 固定,照写):
190
- - `parseRunHead`:`raw.format === "pi-workflow-run-v2"` → 用 `raw.index`;否则顶层有 `string` 型 `runId` → 当 legacy flat(`parentSessionFile: null`);其它一律 `null`。`startedAtMs/updatedAtMs` 用 `Date.parse`,非有限值记 `null`。`hasCheckpoint = Boolean(source.summary?.checkpoint ?? source.checkpoint)`。
191
- - `isSameSessionFile`:`const impl = platform === "win32" ? path.win32 : path.posix;` normalize 用 `impl.resolve`;win32 比较前 `toLowerCase()`。
192
- - `classifyRunForRow`:顺序 —— 无 `run`/无 `parentSessionFile` → `irrelevant`;`!isSameSessionFile(...)` → `irrelevant`;`status === "paused"` → `paused`;`status !== "running"` → `irrelevant`;`hostStartedAt == null || updatedAtMs == null` → `protecting`;`updatedAtMs >= hostStartedAt - CLOCK_EPSILON_MS` → `protecting`;否则 `irrelevant`。
193
- - `classifyRunsForRow`:遍历取最强(`protecting > paused > irrelevant`),返回 `{ state, run }`(`irrelevant` 时 `run: null`)。
194
- - `resolveWorkflowHome`:`AGENT_BOARD_WORKFLOW_HOME` → `PI_CODING_AGENT_DIR/workflows` → `<home>/.pi/workflows`;`~` 前缀替换为 `home`。
195
- - 文件头写英文注释块,说明这是 issue #230 的判定核心,并指向 spec。
196
-
197
- - [ ] **Step 4: 运行测试确认通过**
198
-
199
- Run: `node --test test/background-runs.test.mjs`
200
- Expected: PASS(15 个 test)
201
-
202
- - [ ] **Step 5: Commit**
203
-
204
- ```bash
205
- git add src/core/background-runs.mjs test/background-runs.test.mjs
206
- git commit -m "feat(core): classify background workflow runs for warm-host protection (issue #230)"
207
- ```
208
-
209
- ---
210
-
211
- ### Task 2: IO shell `scanBackgroundRuns` / `probeBackgroundRun`
212
-
213
- **Files:**
214
- - Modify: `src/core/background-runs.mjs`
215
- - Test: `test/background-runs.test.mjs`
216
-
217
- **Interfaces:**
218
- - Consumes: Task 1 的 `parseRunHead` / `classifyRunsForRow` / `CLOCK_EPSILON_MS`
219
- - Produces:
220
- - `scanBackgroundRuns(o: { workflowHome: string, minMtimeMs?: number }): { ok: boolean, heads: Array<RunHeadWithPath>, error: string|null }`,其中 `RunHeadWithPath = RunHead & { path: string, mtimeMs: number }`
221
- - `probeBackgroundRun(o: { sessionFile: string, hostStartedAt: number|null, workflowHome: string }): { ok: boolean, running: RunHeadWithPath|null, paused: RunHeadWithPath|null, error: string|null }`
222
-
223
- - [ ] **Step 1: 先写失败的测试**(追加到 `test/background-runs.test.mjs`)
224
-
225
- ```js
226
- import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
227
- import { tmpdir } from "node:os";
228
- import { join } from "node:path";
229
- import { probeBackgroundRun, scanBackgroundRuns } from "../src/core/background-runs.mjs";
230
-
231
- function fixtureTree() {
232
- const root = mkdtempSync(join(tmpdir(), "agentboard-wf-"));
233
- const runsDir = join(root, "projects", "proj-abc123", "runs");
234
- mkdirSync(runsDir, { recursive: true });
235
- return { root, runsDir, cleanup: () => rmSync(root, { recursive: true, force: true }) };
236
- }
237
-
238
- const writeHead = (runsDir, name, body) => writeFileSync(join(runsDir, name), JSON.stringify(body));
239
-
240
- test("A6: scan enumerates v2 heads and skips .bak/.events.jsonl/.log/.result", () => {
241
- const fx = fixtureTree();
242
- try {
243
- writeHead(fx.runsDir, "run-1.json", V2());
244
- writeHead(fx.runsDir, "run-1.json.bak", V2());
245
- writeFileSync(join(fx.runsDir, "run-1.json.events.jsonl"), "{}\n");
246
- writeFileSync(join(fx.runsDir, "run-1.log"), "log\n");
247
- writeFileSync(join(fx.runsDir, "run-1.json.result-deadbeef"), "{}");
248
- const res = scanBackgroundRuns({ workflowHome: fx.root });
249
- assert.equal(res.ok, true);
250
- assert.equal(res.heads.length, 1);
251
- assert.equal(res.heads[0].runId, "run-1");
252
- assert.ok(typeof res.heads[0].mtimeMs === "number");
253
- } finally {
254
- fx.cleanup();
255
- }
256
- });
257
-
258
- test("A6: minMtimeMs prefilter drops old heads", () => {
259
- const fx = fixtureTree();
260
- try {
261
- writeHead(fx.runsDir, "run-1.json", V2());
262
- const future = Date.now() + 60_000;
263
- assert.deepEqual(scanBackgroundRuns({ workflowHome: fx.root, minMtimeMs: future }), { ok: true, heads: [], error: null });
264
- } finally {
265
- fx.cleanup();
266
- }
267
- });
268
-
269
- test("A6: corrupt JSON does not throw and does not change the verdict", () => {
270
- const fx = fixtureTree();
271
- try {
272
- writeHead(fx.runsDir, "run-1.json", V2());
273
- writeFileSync(join(fx.runsDir, "broken.json"), "{ not json");
274
- const res = scanBackgroundRuns({ workflowHome: fx.root });
275
- assert.equal(res.ok, true);
276
- assert.equal(res.heads.length, 1);
277
- assert.ok(res.error);
278
- } finally {
279
- fx.cleanup();
280
- }
281
- });
282
-
283
- test("A6: missing projects dir -> ok:true with empty heads", () => {
284
- const root = mkdtempSync(join(tmpdir(), "agentboard-wf-none-"));
285
- try {
286
- assert.deepEqual(scanBackgroundRuns({ workflowHome: root }), { ok: true, heads: [], error: null });
287
- } finally {
288
- rmSync(root, { recursive: true, force: true });
289
- }
290
- });
291
-
292
- test("A10(setup): unreadable store -> ok:false (workflowHome is a file, not a dir)", () => {
293
- const root = mkdtempSync(join(tmpdir(), "agentboard-wf-bad-"));
294
- try {
295
- const asFile = join(root, "not-a-dir");
296
- writeFileSync(asFile, "x");
297
- const res = scanBackgroundRuns({ workflowHome: asFile });
298
- assert.equal(res.ok, false);
299
- assert.ok(res.error);
300
- assert.deepEqual(res.heads, []);
301
- } finally {
302
- rmSync(root, { recursive: true, force: true });
303
- }
304
- });
305
-
306
- test("A6/probe: probe splits running vs paused for the matching session", () => {
307
- const fx = fixtureTree();
308
- try {
309
- writeHead(fx.runsDir, "run-2.json", V2({ runId: "run-2" }));
310
- writeHead(fx.runsDir, "run-1.json", V2({ runId: "run-1", status: "paused" }));
311
- const res = probeBackgroundRun({ sessionFile: "C:\\r\\sessions\\view_a.jsonl", hostStartedAt: null, workflowHome: fx.root });
312
- assert.equal(res.ok, true);
313
- assert.equal(res.paused.runId, "run-1");
314
- assert.equal(res.running, null);
315
- } finally {
316
- fx.cleanup();
317
- }
318
- });
319
- ```
320
-
321
- - [ ] **Step 2: 运行确认失败**
322
-
323
- Run: `node --test test/background-runs.test.mjs`
324
- Expected: FAIL — `scanBackgroundRuns is not a function`
325
-
326
- - [ ] **Step 3: 实现 IO shell**
327
-
328
- 要点:
329
- - `scanBackgroundRuns`:读 `<workflowHome>/projects`(`readdirSync`,`ENOENT` → `{ok:true, heads:[], error:null}`;其它错误 → `{ok:false, heads:[], error:message}`);每个 project 目录读 `runs`;只收 `endsWith(".json")` 的**文件**;`statSync().mtimeMs < minMtimeMs` 跳过;`JSON.parse` 与 `parseRunHead` 都包 `try/catch`,失败记首个 `error` 字符串并继续。
330
- - `probeBackgroundRun`:`minMtimeMs = hostStartedAt == null ? 0 : hostStartedAt - CLOCK_EPSILON_MS`;`!res.ok` → 透传 `{ok:false, running:null, paused:null, error}`;否则 `classifyRunsForRow(heads, {sessionFile, hostStartedAt})` 映射成 `{ok:true, running, paused, error}`。
331
-
332
- - [ ] **Step 4: 运行确认通过**
333
-
334
- Run: `node --test test/background-runs.test.mjs`
335
- Expected: PASS(21 个 test)
336
-
337
- - [ ] **Step 5: Commit**
338
-
339
- ```bash
340
- git add src/core/background-runs.mjs test/background-runs.test.mjs
341
- git commit -m "feat(core): read workflow run heads from disk for background-run probes (issue #230)"
342
- ```
343
-
344
- ---
345
-
346
- ### Task 3: sweeper 纯谓词 `isProtected`
347
-
348
- **Files:**
349
- - Modify: `src/core/warm-host-sweeper.mjs:57-88`
350
- - Test: `test/warm-host-sweeper.test.mjs`
351
-
352
- **Interfaces:**
353
- - Consumes: 无(谓词由调用方注入)
354
- - Produces: `selectIdleHostsToEvict(rows, { now, maxWarm, ttlMs, graceMs?, keepViewId?, isProtected?: ((row) => boolean)|null })` —— 签名向后兼容
355
-
356
- - [ ] **Step 1: 先写失败的测试**(追加到 `test/warm-host-sweeper.test.mjs`)
357
-
358
- ```js
359
- test("A7: isProtected 跳过 ttl 与 maxWarm 两个分支", () => {
360
- const now = 1_000_000;
361
- const mk = (id, startedAt) => ({
362
- meta: { id, updatedAt: now - 10_000 },
363
- state: { semanticState: "idle", lastActivityAt: startedAt },
364
- alive: true,
365
- hostAlive: true,
366
- host: { attachedClients: 0, startedAt },
367
- });
368
- const rows = [
369
- mk("a", now - 500_000),
370
- mk("b", now - 500_000),
371
- mk("c", now - 500_000),
372
- mk("d", now - 500_000),
373
- mk("e", now - 500_000),
374
- mk("f", now - 400_000),
375
- ];
376
- const base = { now, maxWarm: 1, ttlMs: 100_000 };
377
-
378
- assert.deepEqual(selectIdleHostsToEvict(rows, base).ttlEvicted, ["a", "b", "c", "d", "e", "f"]);
379
-
380
- const protectedIds = new Set(["a", "b", "c", "d"]);
381
- const withGuard = { ...base, isProtected: (row) => protectedIds.has(row.meta.id) };
382
- assert.deepEqual(selectIdleHostsToEvict(rows, withGuard).ttlEvicted, ["e", "f"]);
383
-
384
- // cap-only 分支:受保护行不进 survivors,也不占 maxWarm 名额
385
- const capOnly = selectIdleHostsToEvict(rows, { now, maxWarm: 1, ttlMs: 0, isProtected: (row) => protectedIds.has(row.meta.id) });
386
- assert.deepEqual(capOnly.ttlEvicted, []);
387
- assert.deepEqual(capOnly.excessEvicted, ["e"]);
388
- });
389
-
390
- test("A7: isProtected 默认不生效(null / undefined 与既有行为一致)", () => {
391
- const now = 1_000_000;
392
- const rows = [{ meta: { id: "a", updatedAt: now }, state: { semanticState: "idle", lastActivityAt: now - 500_000 }, alive: true, hostAlive: true, host: { attachedClients: 0, startedAt: now - 500_000 } }];
393
- assert.deepEqual(selectIdleHostsToEvict(rows, { now, maxWarm: 4, ttlMs: 100_000, isProtected: undefined }), { ttlEvicted: ["a"], excessEvicted: [] });
394
- });
395
- ```
396
-
397
- - [ ] **Step 2: 运行确认失败**
398
-
399
- Run: `node --test test/warm-host-sweeper.test.mjs`
400
- Expected: FAIL — 第一个新用例的 `ttlEvicted` 仍是 5 个(谓词被忽略)
401
-
402
- - [ ] **Step 3: 实现**
403
-
404
- 在 `selectIdleHostsToEvict` 的 JSDoc 参数里补 `isProtected?: ((row: import("./store.mjs").Row) => boolean)|null`,并在候选循环中 `if (isAgentBusy(row)) continue;` 之后加一行:
405
-
406
- ```js
407
- // Background-run protection (issue #230): a host executing a background
408
- // workflow still counts as busy even though the assistant turn ended.
409
- if (isProtected && isProtected(row)) continue;
410
- ```
411
-
412
- JSDoc 上方注释补一句:谓词由调用方注入(纯函数不读盘)。
413
-
414
- - [ ] **Step 4: 运行确认通过**
415
-
416
- Run: `node --test test/warm-host-sweeper.test.mjs`
417
- Expected: PASS(23 个 test,既有 21 个全绿)
418
-
419
- - [ ] **Step 5: Commit**
420
-
421
- ```bash
422
- git add src/core/warm-host-sweeper.mjs test/warm-host-sweeper.test.mjs
423
- git commit -m "feat(host): let the sweep decision exempt background-workflow hosts (issue #230)"
424
- ```
425
-
426
- ---
427
-
428
- ### Task 4: `pruneWarmHosts` 接线(两阶段选择 + 停止前复查 + 逃生门)
429
-
430
- **Files:**
431
- - Modify: `src/runtime/service.mjs:915-939`(`pruneWarmHosts`)、`src/runtime/service.mjs:136-176`(`createService` 注入项)、服务对外暴露区(`pruneWarmHosts` 委托处 `service.mjs:2510` 不改)
432
- - Test: `test/warm-host-sweep.integration.test.mjs`
433
-
434
- **Interfaces:**
435
- - Consumes: Task 1-3 的 `scanBackgroundRuns` / `probeBackgroundRun` / `classifyRunsForRow` / `resolveWorkflowHome` / `CLOCK_EPSILON_MS` / `selectIdleHostsToEvict(..., { isProtected })`;既有 `appendDiagnosticOnce`(`src/core/diagnostics.mjs`)
436
- - Produces: `createService({ ..., workflowHome?: string, backgroundRuns?: { scanBackgroundRuns, probeBackgroundRun } })`;`pruneWarmHosts` 行为契约见 spec §4
437
-
438
- - [ ] **Step 1: 先写失败的测试**(追加到 `test/warm-host-sweep.integration.test.mjs`)
439
-
440
- 新增 helper 与 4 个用例(沿用本文件既有的 `freshRoot` / `makeService` / `idleView` / `captureHostSocket`):
441
-
442
- ```js
443
- function wfFixture() {
444
- const root = mkdtempSync(join(tmpdir(), "agentboard-wf-svc-"));
445
- const runsDir = join(root, "projects", "proj-1", "runs");
446
- mkdirSync(runsDir, { recursive: true });
447
- return { root, runsDir, cleanup: () => rmSync(root, { recursive: true, force: true }) };
448
- }
449
-
450
- function writeRunningRun(runsDir, root, viewId, { runId = "run-1", status = "running", updatedAt = Date.now() } = {}) {
451
- writeFileSync(
452
- join(runsDir, `${runId}.json`),
453
- JSON.stringify({
454
- format: "pi-workflow-run-v2",
455
- index: { runId, status, parentSessionFile: P.sessionFilePath(root, viewId), updatedAt: new Date(updatedAt).toISOString() },
456
- summary: { checkpoint: null },
457
- }),
458
- );
459
- }
460
-
461
- test("A8: 有 running 后台 run 的 idle host 不被淘汰;无 run 的仍被淘汰", async () => {
462
- const root = freshRoot();
463
- const wf = wfFixture();
464
- try {
465
- const protectedId = "view_protected";
466
- const plainId = "view_plain";
467
- idleView(root, protectedId);
468
- idleView(root, plainId);
469
- writeRunningRun(wf.runsDir, root, protectedId);
470
- const a = await captureHostSocket(root, protectedId);
471
- const b = await captureHostSocket(root, plainId);
472
- makeService(root, { workflowHome: wf.root }).pruneWarmHosts();
473
- await waitFor(() => b.received.some((m) => m.type === "terminate"), 3000);
474
- assert.equal(a.received.some((m) => m.type === "terminate"), false);
475
- assert.equal(b.received.some((m) => m.type === "terminate"), true);
476
- await closeServer(a.server);
477
- await closeServer(b.server);
478
- } finally {
479
- wf.cleanup();
480
- rmSync(root, { recursive: true, force: true });
481
- }
482
- });
483
-
484
- test("A9: 选择后被跳过——复查阶段看到 running run 则不驱逐,并留 warm_prune_during_background_run", async () => {
485
- const root = freshRoot();
486
- const id = "view_race";
487
- try {
488
- idleView(root, id);
489
- const sock = await captureHostSocket(root, id);
490
- // 选择阶段扫不到任何 run;停前复查探针看到刚启动的 run(模拟快照→停止的窗口)
491
- const service = makeService(root, {
492
- backgroundRuns: {
493
- scanBackgroundRuns: () => ({ ok: true, heads: [], error: null }),
494
- probeBackgroundRun: () => ({ ok: true, running: { runId: "run-late" }, paused: null, error: null }),
495
- },
496
- });
497
- service.pruneWarmHosts();
498
- await new Promise((r) => setTimeout(r, 150));
499
- assert.equal(sock.received.some((m) => m.type === "terminate"), false);
500
- const diags = readJsonl(P.diagnosticsPath(root, id));
501
- assert.equal(diags.filter((d) => d.code === "warm_prune_during_background_run").length, 1);
502
- assert.equal(diags.find((d) => d.code === "warm_prune_during_background_run").details.runId, "run-late");
503
- await closeServer(sock.server);
504
- } finally {
505
- rmSync(root, { recursive: true, force: true });
506
- }
507
- });
508
-
509
- test("A10: 探测失败 fail-open(照常 terminate)+ 去重 warn 诊断", async () => {
510
- const root = freshRoot();
511
- const id = "view_probe_fail";
512
- try {
513
- idleView(root, id);
514
- const sock = await captureHostSocket(root, id);
515
- const failing = {
516
- scanBackgroundRuns: () => ({ ok: false, heads: [], error: "EACCES" }),
517
- probeBackgroundRun: () => ({ ok: false, running: null, paused: null, error: "EACCES" }),
518
- };
519
- const service = makeService(root, { backgroundRuns: failing });
520
- service.pruneWarmHosts();
521
- await waitFor(() => sock.received.some((m) => m.type === "terminate"), 3000);
522
- assert.equal(sock.received.some((m) => m.type === "terminate"), true);
523
- const diags = readJsonl(P.diagnosticsPath(root, id));
524
- assert.equal(diags.filter((d) => d.code === "warm_prune_probe_failed").length, 1);
525
- await closeServer(sock.server);
526
- } finally {
527
- rmSync(root, { recursive: true, force: true });
528
- }
529
- });
530
-
531
- test("A11: AGENT_BOARD_NO_BACKGROUND_RUN_GUARD=1 回到今天的行为", async () => {
532
- const root = freshRoot();
533
- const wf = wfFixture();
534
- const prev = process.env.AGENT_BOARD_NO_BACKGROUND_RUN_GUARD;
535
- try {
536
- process.env.AGENT_BOARD_NO_BACKGROUND_RUN_GUARD = "1";
537
- const id = "view_guard_off";
538
- idleView(root, id);
539
- writeRunningRun(wf.runsDir, root, id);
540
- const sock = await captureHostSocket(root, id);
541
- makeService(root, { workflowHome: wf.root }).pruneWarmHosts();
542
- await waitFor(() => sock.received.some((m) => m.type === "terminate"), 3000);
543
- assert.equal(sock.received.some((m) => m.type === "terminate"), true);
544
- await closeServer(sock.server);
545
- } finally {
546
- if (prev === undefined) delete process.env.AGENT_BOARD_NO_BACKGROUND_RUN_GUARD;
547
- else process.env.AGENT_BOARD_NO_BACKGROUND_RUN_GUARD = prev;
548
- wf.cleanup();
549
- rmSync(root, { recursive: true, force: true });
550
- }
551
- });
552
- ```
553
-
554
- 同时给既有 `makeService(root)` 加第二参数(默认空对象)并透传注入项:
555
-
556
- ```js
557
- function makeService(root, extra = {}) {
558
- return createService({
559
- root,
560
- /* ...既有参数保持不变... */
561
- ...extra,
562
- });
563
- }
564
- ```
565
-
566
- 需要的 import 增补:`mkdirSync, writeFileSync`(`node:fs`);`readJsonl` 从 `../src/core/atomic.mjs`。
567
-
568
- - [ ] **Step 2: 运行确认失败**
569
-
570
- Run: `node --test test/warm-host-sweep.integration.test.mjs`
571
- Expected: FAIL — A8 里受保护 host 也收到了 terminate(保护未实现)
572
-
573
- - [ ] **Step 3: 实现 service 侧**
574
-
575
- 1. `createService` 顶部注入(与既有 `opts.X ?? default` 同款):
576
-
577
- ```js
578
- const backgroundRunsImpl = opts.backgroundRuns ?? { scanBackgroundRuns, probeBackgroundRun };
579
- const workflowHomeImpl = opts.workflowHome ?? resolveWorkflowHome();
580
- ```
581
-
582
- 2. `pruneWarmHosts` 重写为 spec §4 的流程:`dry` 快跑 → 无候选即返回 → `AGENT_BOARD_NO_BACKGROUND_RUN_GUARD !== "1"` 时做一次 `scanBackgroundRuns`(`minMtimeMs` = 候选行 `host.startedAt` 最小值减 `CLOCK_EPSILON_MS`;任一候选行缺 `startedAt` 时取 `0`)→ `classifyRunsForRow` 生成 `isProtected` → 带谓词再选 → 逐行复查(既有四项 + `isProtected` + 新鲜 `probeBackgroundRun`):`!ok` → `appendDiagnosticOnce(code:"warm_prune_probe_failed", {match:["error"]})` 后**继续**驱逐;`running` → `appendDiagnosticOnce(code:"warm_prune_during_background_run", {match:["runId"]})` 后 `continue`(跳过);否则 `stopHostRow(row, "warm_prune")`。
583
-
584
- 3. 诊断 message 文案固定:
585
- - `warm_prune_probe_failed`: `"Could not read the workflow run store; warm-host eviction proceeded without background-run protection"`
586
- - `warm_prune_during_background_run`: `` `Skipped warm-host eviction: background workflow ${runId} started after selection` ``
587
-
588
- - [ ] **Step 4: 运行确认通过**
589
-
590
- Run: `node --test test/warm-host-sweep.integration.test.mjs`
591
- Expected: PASS(7 个 test:既有 3 + 新 4)
592
-
593
- - [ ] **Step 5: 回归既有 sweep 单测**
594
-
595
- Run: `node --test test/warm-host-sweeper.test.mjs test/host-recovery.test.mjs`
596
- Expected: PASS(既有用例零改动)
597
-
598
- - [ ] **Step 6: Commit**
599
-
600
- ```bash
601
- git add src/runtime/service.mjs test/warm-host-sweep.integration.test.mjs
602
- git commit -m "fix(host): keep warm hosts alive while a background workflow run is active (issue #230)"
603
- ```
604
-
605
- ---
606
-
607
- ### Task 5: 驱逐/宿主死亡的留痕(reconcile pre-pass + `patch_fields` 白名单)
608
-
609
- **Files:**
610
- - Modify: `src/core/state-commands.mjs:113`(`PATCHABLE_FIELDS["service"]`)、`:108-112` 注释
611
- - Modify: `src/runtime/service.mjs:2269-2275`(`reconcile()` 开头加 pre-pass)与 `createService` 闭包内新增 `noticeProbeMemo` / `noticeInterruptedBackgroundRuns`
612
- - Modify: `test/state-commands.test.mjs:118`(白名单断言)
613
- - Modify: `test/architecture-writer-boundary.test.mjs:39`(豁免理由字符串「三处」→「四处」)
614
- - Test: `test/background-run-notice.test.mjs`
615
-
616
- **Interfaces:**
617
- - Consumes: Task 1-4 的 probe 注入项与被测信息;既有 `sendLifecycleCommand` / `readState` / `writeState` / `appendDiagnosticOnce`
618
- - Produces: `reconcile()` 新增 pre-pass 行为(spec §4 第 0 步);`PATCHABLE_FIELDS["service"].state` 含 `"summary"`
619
-
620
- - [ ] **Step 1: 先写失败的测试**
621
-
622
- `test/background-run-notice.test.mjs`(完整文件;沿用 integration 测试里的 root/fixture 构造方式):
623
-
624
- ```js
625
- import assert from "node:assert/strict";
626
- import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
627
- import { tmpdir } from "node:os";
628
- import { join } from "node:path";
629
- import { test } from "node:test";
630
- import { createService } from "../src/runtime/service.mjs";
631
- import { createView, readState, writeHost } from "../src/core/store.mjs";
632
- import { readJsonl } from "../src/core/atomic.mjs";
633
- import * as P from "../src/core/paths.mjs";
634
-
635
- const makeService = (root, extra = {}) =>
636
- createService({ root, runnerScript: "/no/runner.mjs", piCommand: "pi", piArgsPrefix: [], defaultCwd: process.cwd(), ...extra });
637
-
638
- function deadHostRow(root, id, { paused = true } = {}) {
639
- const wf = mkdtempSync(join(tmpdir(), "agentboard-notice-"));
640
- const runsDir = join(wf, "projects", "p", "runs");
641
- mkdirSync(runsDir, { recursive: true });
642
- createView(root, { id, name: id, cwd: "/r" });
643
- const s = readState(root, id);
644
- // 生产真实态:turn 已结束、run 已终态,宿主随后死亡
645
- s.semanticState = "idle";
646
- s.processState = "exited";
647
- s.currentRunId = null;
648
- s.summary = "Turn finished";
649
- writeState(root, s);
650
- writeHost(root, { viewId: id, mode: "pty", state: "exited", runnerPid: null, startedAt: Date.now() - 3_600_000, endedAt: Date.now() - 60_000, attachedClients: 0, lastSeenAt: Date.now() });
651
- writeFileSync(
652
- join(runsDir, "run-9.json"),
653
- JSON.stringify({
654
- format: "pi-workflow-run-v2",
655
- index: {
656
- runId: "run-9",
657
- status: paused ? "paused" : "completed",
658
- parentSessionFile: P.sessionFilePath(root, id),
659
- updatedAt: new Date(Date.now() - 90_000).toISOString(),
660
- },
661
- summary: { checkpoint: { checkpointId: "cp1", kind: "manual", status: "pending" } },
662
- }),
663
- );
664
- return { wf, cleanup: () => rmSync(wf, { recursive: true, force: true }) };
665
- }
666
-
667
- test("A12: 生产真实态的死宿主 + paused run -> summary 写入提示,诊断恰一条", async () => {
668
- const root = mkdtempSync(join(tmpdir(), "agentboard-notice-root-"));
669
- const fx = deadHostRow(root, "view_notice");
670
- try {
671
- const service = makeService(root, { workflowHome: fx.wf, sendStateCommand: async () => ({ status: "rejected", reason: "coordinator_disabled" }) });
672
- await service.reconcile();
673
- const s = readState(root, "view_notice");
674
- assert.match(s.summary, /Background workflow run-9 was interrupted/);
675
- assert.match(s.summary, /resume/);
676
- const diags = readJsonl(P.diagnosticsPath(root, "view_notice"));
677
- assert.equal(diags.filter((d) => d.code === "background_run_interrupted").length, 1);
678
- await service.reconcile();
679
- assert.equal(readState(root, "view_notice").summary, s.summary);
680
- assert.equal(readJsonl(P.diagnosticsPath(root, "view_notice")).filter((d) => d.code === "background_run_interrupted").length, 1);
681
- } finally {
682
- fx.cleanup();
683
- rmSync(root, { recursive: true, force: true });
684
- }
685
- });
686
-
687
- test("A12: completed run(非 paused)不写提示", async () => {
688
- const root = mkdtempSync(join(tmpdir(), "agentboard-notice-root2-"));
689
- const fx = deadHostRow(root, "view_done", { paused: false });
690
- try {
691
- const service = makeService(root, { workflowHome: fx.wf, sendStateCommand: async () => ({ status: "rejected", reason: "coordinator_disabled" }) });
692
- await service.reconcile();
693
- assert.equal(readState(root, "view_done").summary, "Turn finished");
694
- } finally {
695
- fx.cleanup();
696
- rmSync(root, { recursive: true, force: true });
697
- }
698
- });
699
-
700
- test("A12: 探针失败不记 memo —— 恢复后下一个 tick 仍能写提示", async () => {
701
- const root = mkdtempSync(join(tmpdir(), "agentboard-notice-root3-"));
702
- const fx = deadHostRow(root, "view_retry");
703
- try {
704
- let calls = 0;
705
- const flaky = {
706
- scanBackgroundRuns: () => ({ ok: true, heads: [], error: null }),
707
- probeBackgroundRun: () => {
708
- calls += 1;
709
- if (calls === 1) return { ok: false, running: null, paused: null, error: "EACCES" };
710
- return { ok: true, running: null, paused: { runId: "run-9" }, error: null };
711
- },
712
- };
713
- const service = makeService(root, { backgroundRuns: flaky, sendStateCommand: async () => ({ status: "rejected", reason: "coordinator_disabled" }) });
714
- await service.reconcile();
715
- assert.equal(readState(root, "view_retry").summary, "Turn finished");
716
- await service.reconcile();
717
- assert.match(readState(root, "view_retry").summary, /run-9/);
718
- } finally {
719
- fx.cleanup();
720
- rmSync(root, { recursive: true, force: true });
721
- }
722
- });
723
- ```
724
-
725
- `test/state-commands.test.mjs`:把 `PATCHABLE_FIELDS["service"]` 断言(L118)改为 `{ state: ["lastVisitedAt", "summary"], status: [] }`。
726
-
727
- - [ ] **Step 2: 运行确认失败**
728
-
729
- Run: `node --test test/background-run-notice.test.mjs`
730
- Expected: FAIL — summary 仍是 `Turn finished`(pre-pass 未实现)
731
-
732
- - [ ] **Step 3: 实现**
733
-
734
- 1. `src/core/state-commands.mjs`:`"service"` 的 `state` 白名单加 `"summary"`,并把上方注释改为同时说明两条用途(`lastVisitedAt` = 访问时间;`summary` = 宿主死亡后的后台 run 中断提示,issue #230)。
735
- 2. `src/runtime/service.mjs`:
736
- - `createService` 闭包内新增 `const noticeProbeMemo = new Set();`
737
- - 新增 `async function noticeInterruptedBackgroundRuns(now)`,实现 spec §4 第 0 步(`host.state ∈ {exited, failed}` 且 `!row.hostAlive`;key = `` `${viewId}:${host.endedAt ?? 0}` ``;**probe 成功后才** `memo.add(key)`;`!probe.paused` → continue;summary 已以 `"Background workflow "` 开头则跳过写入;写入走 `sendLifecycleCommand({kind:"patch_fields", payload:{state:{summary}}})`,`reason === "coordinator_disabled"` 时 `const fresh = readState(root, id); fresh.summary = summary; fresh.updatedAt = now; writeState(root, fresh);`;随后 `appendDiagnosticOnce(..., {match:["runId"]})`)。
738
- - `reconcile()` 开头(`const now = Date.now();` 之后、`let fixed = 0;` 之前)`await noticeInterruptedBackgroundRuns(now);`
739
- 3. `test/architecture-writer-boundary.test.mjs:39` 的豁免理由字符串改为「…以及 reconcile() 的四处 coordinator_disabled 直写回退(host 探测终态 / project 模式 / runner-exited / paused-run summary notice)」。
740
-
741
- - [ ] **Step 4: 运行确认通过**
742
-
743
- Run: `node --test test/background-run-notice.test.mjs test/state-commands.test.mjs test/architecture-writer-boundary.test.mjs`
744
- Expected: PASS
745
-
746
- - [ ] **Step 5: Commit**
747
-
748
- ```bash
749
- git add src/core/state-commands.mjs src/runtime/service.mjs test/background-run-notice.test.mjs test/state-commands.test.mjs test/architecture-writer-boundary.test.mjs
750
- git commit -m "feat(host): notice interrupted background runs after a warm host dies (issue #230)"
751
- ```
752
-
753
- ---
754
-
755
- ### Task 6: 全仓验收(A13 / A14)
756
-
757
- **Files:** 无代码改动(仅验证)
758
-
759
- **Interfaces:**
760
- - Consumes: Task 1-5 全部
761
- - Produces: 可合并的绿灯
762
-
763
- - [ ] **Step 1: 类型与静态检查**
764
-
765
- Run: `npm run typecheck`
766
- Expected: 退出码 0
767
-
768
- - [ ] **Step 2: 覆盖验收矩阵**
769
-
770
- Run: `node --test test/background-runs.test.mjs test/warm-host-sweeper.test.mjs test/warm-host-sweep.integration.test.mjs test/background-run-notice.test.mjs test/state-commands.test.mjs test/architecture-writer-boundary.test.mjs`
771
- Expected: PASS,对应 A1-A16 全绿
772
-
773
- - [ ] **Step 3: 全仓门禁**
774
-
775
- Run: `npm run verify`
776
- Expected: 退出码 0(typecheck + perf gate + 全量测试 + c8 覆盖率门槛 + pack:dry)
777
-
778
- - [ ] **Step 4: 提交(若前两步需要修 lint/覆盖率,逐个修复后)**
779
-
780
- ```bash
781
- git add <changed files>
782
- git commit -m "test: cover background-run guard end to end (issue #230)"
783
- ```
784
-
785
- ---
786
-
787
- ## Manual verification (post-implementation, U1 / U2)
788
-
789
- 部署前置与步骤见 spec §9(必须让 board 从 PR 分支加载,**不是**本工作副本)。两项在 PR 描述里登记为 `pending` 直到实际执行。
790
-
791
- | ID | 操作 | 通过标准 |
792
- |---|---|---|
793
- | U1 | 压缩 TTL(`AGENT_BOARD_WARM_HOST_TTL_MS=60000`)后跑一个 ≥3 min 的单 step background workflow,关闭 attach 等 2×TTL | `host.json.state` 保持 `alive`;run 保持 `running`;无 `skipped/interrupted` |
794
- | U2 | `AGENT_BOARD_NO_BACKGROUND_RUN_GUARD=1` 让驱逐发生,再打开 dashboard | 行 summary 含 runId 与 "resume";诊断含 `background_run_interrupted`;resume 后 run 完成 |
795
-
796
- ## 验收 → Task 追溯
797
-
798
- | 验收 ID | Task |
799
- |---|---|
800
- | A1, A2, A3, A4, A5, A16 | 1 |
801
- | A6 | 2 |
802
- | A7 | 3 |
803
- | A8, A9, A10, A11 | 4 |
804
- | A12, A15 | 5 |
805
- | A13, A14 | 6 |
806
- | U1, U2 | Manual verification |