@huseynsnmz/pi-board 0.15.3 → 0.17.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 (216) hide show
  1. package/CHANGELOG.md +24 -4
  2. package/CONTRIBUTING.md +7 -0
  3. package/README.md +55 -419
  4. package/SECURITY.md +19 -0
  5. package/docs/how-it-works.md +31 -0
  6. package/docs/reference.md +277 -0
  7. package/package.json +5 -11
  8. package/src/commands/agent-board.ts +26 -2
  9. package/src/commands/attach-flow.ts +4 -0
  10. package/src/core/double-press.mjs +6 -2
  11. package/src/core/slash-commands.mjs +52 -0
  12. package/src/index.ts +1 -1
  13. package/src/runtime/service.mjs +12 -0
  14. package/src/ui/dashboard.ts +59 -25
  15. package/src/ui/pty-attach.ts +8 -3
  16. package/IMPLEMENTATION_PLAN.md +0 -920
  17. package/PRD.md +0 -484
  18. package/PROGRESS.md +0 -142
  19. package/VERIFY.md +0 -115
  20. package/docs/BATCH_SELECTION_READ_FLOW.md +0 -277
  21. package/docs/EXPLORATION.md +0 -187
  22. package/docs/PTY_ATTACH_IMPLEMENTATION_PLAN.md +0 -581
  23. package/docs/RELEASE.md +0 -148
  24. package/docs/superpowers/plans/2026-08-15-screenlog-gc.md +0 -704
  25. package/docs/superpowers/plans/2026-08-16-attach-double-cursor-jiggle-retry.md +0 -499
  26. package/docs/superpowers/plans/2026-08-21-attach-coldstart-jiggle-rearm.md +0 -797
  27. package/docs/superpowers/plans/2026-08-21-dashboard-keypress-lag.md +0 -366
  28. package/docs/superpowers/plans/2026-08-21-issue-14-needs-input-no-auto-done.md +0 -576
  29. package/docs/superpowers/plans/2026-08-22-jiggle-shrink-and-hold.md +0 -337
  30. package/docs/superpowers/plans/2026-08-22-launch-cwd-favorites.md +0 -916
  31. package/docs/superpowers/plans/2026-08-22-question-tool-grouping.md +0 -211
  32. package/docs/superpowers/plans/2026-08-22-rename-state-labels.md +0 -182
  33. package/docs/superpowers/plans/2026-08-22-stable-list-order.md +0 -51
  34. package/docs/superpowers/plans/2026-08-27-locks-acquirelock-spin.md +0 -614
  35. package/docs/superpowers/plans/2026-08-27-pty-runner-test-flaky.md +0 -32
  36. package/docs/superpowers/plans/2026-08-29-code-refs-badges.md +0 -223
  37. package/docs/superpowers/plans/2026-08-30-circular-navigation.md +0 -308
  38. package/docs/superpowers/plans/2026-08-30-post-exit-timing-fix.md +0 -37
  39. package/docs/superpowers/plans/2026-08-30-pty-attach-quality-debt.md +0 -311
  40. package/docs/superpowers/plans/2026-08-30-readme-v2.md +0 -294
  41. package/docs/superpowers/plans/2026-09-01-attach-detach-gate-cursor-anchor.md +0 -284
  42. package/docs/superpowers/plans/2026-09-02-attach-detach-editor-state.md +0 -722
  43. package/docs/superpowers/plans/2026-09-03-code-refs-pr-backlink-narrow.md +0 -551
  44. package/docs/superpowers/plans/2026-09-03-detach-gate-glyph-fallback.md +0 -146
  45. package/docs/superpowers/plans/2026-09-04-evidence-outputpreview.md +0 -209
  46. package/docs/superpowers/plans/2026-09-04-warm-host-reclaim.md +0 -796
  47. package/docs/superpowers/plans/2026-09-05-issue-13-drainnextfollowup-pty-probe.md +0 -114
  48. package/docs/superpowers/plans/2026-09-05-issue-38-windows-wezterm-ime-cursor.md +0 -73
  49. package/docs/superpowers/plans/2026-09-05-issue-39-truncate-codepoint-boundary.md +0 -143
  50. package/docs/superpowers/plans/2026-09-05-issue-61-mention-fallback-guards.md +0 -226
  51. package/docs/superpowers/plans/2026-09-05-issue-63-flaky-manual-completion.md +0 -87
  52. package/docs/superpowers/plans/2026-09-05-issue-64-changelog-release-helper.md +0 -53
  53. package/docs/superpowers/plans/2026-09-05-pty-host-stacking-sock-race.md +0 -731
  54. package/docs/superpowers/plans/2026-09-08-attach-ctrl-left-detach.md +0 -30
  55. package/docs/superpowers/plans/2026-09-08-dashboard-shrink-repaint.md +0 -68
  56. package/docs/superpowers/plans/2026-09-08-issue-11-attach-runtime-desync-heal.md +0 -917
  57. package/docs/superpowers/plans/2026-09-08-legacy-stale-host-recovery.md +0 -125
  58. package/docs/superpowers/plans/2026-09-08-spawn-async-error-swallow.md +0 -56
  59. package/docs/superpowers/plans/2026-09-08-stale-model-attach-guard.md +0 -96
  60. package/docs/superpowers/plans/2026-09-09-claimpid-blocks-replace.md +0 -267
  61. package/docs/superpowers/plans/2026-09-09-harden-runner-architecture.md +0 -603
  62. package/docs/superpowers/plans/2026-09-09-single-writer-completion.md +0 -252
  63. package/docs/superpowers/plans/2026-09-10-reader-consistency.md +0 -115
  64. package/docs/superpowers/plans/2026-09-14-attach-cursor-dectcem-gate.md +0 -469
  65. package/docs/superpowers/plans/2026-09-14-attach-snapshot.md +0 -92
  66. package/docs/superpowers/plans/2026-09-14-host-meta-orphan-lock.md +0 -771
  67. package/docs/superpowers/plans/2026-09-14-issue-106-terminal-frame-cognition.md +0 -299
  68. package/docs/superpowers/plans/2026-09-14-issue-113-foreground-preview-race.md +0 -609
  69. package/docs/superpowers/plans/2026-09-14-terminal-model.md +0 -145
  70. package/docs/superpowers/plans/2026-09-15-coordinator-pipe-root-normalize.md +0 -224
  71. package/docs/superpowers/plans/2026-09-15-lease-publish-eprem-reclaim.md +0 -341
  72. package/docs/superpowers/plans/2026-09-18-detach-anchor-reporter-endpoint.md +0 -875
  73. package/docs/superpowers/plans/2026-09-20-control-lifecycle.md +0 -116
  74. package/docs/superpowers/plans/2026-09-20-issue-121-perf-gate-out-of-coverage.md +0 -517
  75. package/docs/superpowers/plans/2026-09-23-a5-mid-stream-assertion.md +0 -25
  76. package/docs/superpowers/plans/2026-09-23-cwd-browse-tab-drill.md +0 -393
  77. package/docs/superpowers/plans/2026-09-23-terminal-query-forwarding.md +0 -51
  78. package/docs/superpowers/plans/2026-09-23-unconditional-detach-key.md +0 -58
  79. package/docs/superpowers/plans/2026-09-24-control-reconcile-flake.md +0 -698
  80. package/docs/superpowers/plans/2026-09-24-probe-attached-clients.md +0 -605
  81. package/docs/superpowers/plans/2026-09-29-issue-145-holding-semantic-state.md +0 -1183
  82. package/docs/superpowers/plans/2026-09-29-issue-145-user-verification.md +0 -66
  83. package/docs/superpowers/plans/2026-09-30-board-delete-key-x-y-confirm.md +0 -649
  84. package/docs/superpowers/plans/2026-09-30-issue-153-runner-stop-boot-window.md +0 -618
  85. package/docs/superpowers/plans/2026-09-30-issue-95-test-discipline.md +0 -555
  86. package/docs/superpowers/plans/2026-09-30-publish-dispatch-tag-check.md +0 -259
  87. package/docs/superpowers/plans/2026-10-06-attach-settle-scheme-delivery.md +0 -751
  88. package/docs/superpowers/plans/2026-10-09-issue-167-attach-middle-paste.md +0 -509
  89. package/docs/superpowers/plans/2026-10-09-issue-169-fullscreen-mouse-ownership.md +0 -346
  90. package/docs/superpowers/plans/2026-10-10-attach-delete-in-place.md +0 -832
  91. package/docs/superpowers/plans/2026-10-10-issue-174-selection-mirror-paste-source.md +0 -510
  92. package/docs/superpowers/plans/2026-10-14-issue-170-attach-reply-guard.md +0 -771
  93. package/docs/superpowers/plans/2026-10-14-issue-178-accept-registration-race.md +0 -1257
  94. package/docs/superpowers/plans/2026-10-14-issue-184-snapshot-window-contract.md +0 -657
  95. package/docs/superpowers/plans/2026-10-14-issue-185-attach-wheel-alt-screen.md +0 -47
  96. package/docs/superpowers/plans/2026-10-14-issue-187-motionless-drag.md +0 -931
  97. package/docs/superpowers/plans/2026-10-14-issue-189-mixed-input-segments.md +0 -823
  98. package/docs/superpowers/plans/2026-10-15-issue-188-x10-mouse-release.md +0 -526
  99. package/docs/superpowers/plans/2026-10-16-issue-198-attach-wheel-forward-fallback.md +0 -32
  100. package/docs/superpowers/plans/2026-10-18-attach-wheel-replay-pseudo-scrollback.md +0 -437
  101. package/docs/superpowers/plans/2026-10-19-issue-213-reply-guard-csi-key.md +0 -257
  102. package/docs/superpowers/plans/2026-10-19-issue-214-attach-forward-alt-modifier.md +0 -202
  103. package/docs/superpowers/plans/2026-10-21-issue-221-ask-user-question.md +0 -198
  104. package/docs/superpowers/plans/2026-10-23-issue-222-attach-ready.md +0 -290
  105. package/docs/superpowers/plans/2026-10-23-issue-225-monotonic-foreground-state.md +0 -450
  106. package/docs/superpowers/plans/2026-10-23-issue-229-vim-jkl.md +0 -449
  107. package/docs/superpowers/plans/2026-10-23-issue-232-attach-bg-only-cells.md +0 -153
  108. package/docs/superpowers/plans/2026-10-23-lease-heartbeat-staleness.md +0 -537
  109. package/docs/superpowers/plans/2026-10-23-selected-row-highlight.md +0 -405
  110. package/docs/superpowers/plans/2026-10-24-issue-217-windows-pid-reuse-stopping.md +0 -367
  111. package/docs/superpowers/plans/2026-10-24-issue-230-warm-host-background-runs.md +0 -806
  112. package/docs/superpowers/plans/2026-10-24-issue-238-windows-process-identity-token.md +0 -490
  113. package/docs/superpowers/plans/2026-10-24-issue-241-coordinator-root-exit.md +0 -461
  114. package/docs/superpowers/plans/2026-10-25-issue-239-coordinator-replacement-identity.md +0 -504
  115. package/docs/superpowers/plans/2026-10-26-issue-245-key-hints-doc-sync.md +0 -244
  116. package/docs/superpowers/plans/2026-10-27-issue-172-fullscreen-selection-clear.md +0 -344
  117. package/docs/superpowers/plans/2026-10-27-issue-252-select-key-hints.md +0 -209
  118. package/docs/superpowers/plans/2026-10-28-issue-255-coordinator-endpoint-zombie.md +0 -442
  119. package/docs/superpowers/plans/2026-10-28-issue-257-legacy-pty-runner-endpoint-cleanup.md +0 -256
  120. package/docs/superpowers/plans/2026-11-03-cwd-favorites-full-pool.md +0 -266
  121. package/docs/superpowers/specs/2026-08-15-screenlog-gc-design.md +0 -105
  122. package/docs/superpowers/specs/2026-08-16-attach-double-cursor-jiggle-retry-design.md +0 -142
  123. package/docs/superpowers/specs/2026-08-21-attach-coldstart-jiggle-rearm-design.md +0 -110
  124. package/docs/superpowers/specs/2026-08-21-dashboard-keypress-lag-design.md +0 -59
  125. package/docs/superpowers/specs/2026-08-21-issue-14-needs-input-no-auto-done-design.md +0 -109
  126. package/docs/superpowers/specs/2026-08-22-cwd-favorites-design.md +0 -131
  127. package/docs/superpowers/specs/2026-08-22-jiggle-shrink-and-hold-design.md +0 -111
  128. package/docs/superpowers/specs/2026-08-22-question-tool-grouping-design.md +0 -114
  129. package/docs/superpowers/specs/2026-08-22-rename-state-labels-design.md +0 -80
  130. package/docs/superpowers/specs/2026-08-27-locks-acquirelock-spin-design.md +0 -97
  131. package/docs/superpowers/specs/2026-08-27-pty-runner-test-flaky-design.md +0 -35
  132. package/docs/superpowers/specs/2026-08-29-code-refs-badges-design.md +0 -128
  133. package/docs/superpowers/specs/2026-08-30-circular-navigation-design.md +0 -47
  134. package/docs/superpowers/specs/2026-08-30-eprm-atomicwrite-race-design.md +0 -77
  135. package/docs/superpowers/specs/2026-08-30-post-exit-timing-fix-design.md +0 -80
  136. package/docs/superpowers/specs/2026-08-30-pty-attach-quality-debt-design.md +0 -105
  137. package/docs/superpowers/specs/2026-08-30-readme-v2-design.md +0 -115
  138. package/docs/superpowers/specs/2026-09-01-attach-detach-gate-cursor-anchor-design.md +0 -78
  139. package/docs/superpowers/specs/2026-09-02-attach-detach-editor-state-design.md +0 -120
  140. package/docs/superpowers/specs/2026-09-03-code-refs-pr-backlink-narrow-design.md +0 -92
  141. package/docs/superpowers/specs/2026-09-03-detach-gate-glyph-fallback-design.md +0 -99
  142. package/docs/superpowers/specs/2026-09-04-evidence-outputpreview-design.md +0 -50
  143. package/docs/superpowers/specs/2026-09-04-warm-host-reclaim-design.md +0 -106
  144. package/docs/superpowers/specs/2026-09-05-issue-13-drainnextfollowup-pty-probe-design.md +0 -64
  145. package/docs/superpowers/specs/2026-09-05-issue-38-windows-wezterm-ime-design.md +0 -48
  146. package/docs/superpowers/specs/2026-09-05-issue-39-truncate-codepoint-boundary-design.md +0 -64
  147. package/docs/superpowers/specs/2026-09-05-issue-61-mention-fallback-design.md +0 -71
  148. package/docs/superpowers/specs/2026-09-05-issue-63-flaky-manual-completion-design.md +0 -49
  149. package/docs/superpowers/specs/2026-09-05-issue-64-changelog-helper-design.md +0 -76
  150. package/docs/superpowers/specs/2026-09-05-pty-host-stacking-sock-race-design.md +0 -510
  151. package/docs/superpowers/specs/2026-09-07-issue-11-attach-runtime-desync-heal-design.md +0 -130
  152. package/docs/superpowers/specs/2026-09-08-attach-ctrl-left-detach-design.md +0 -58
  153. package/docs/superpowers/specs/2026-09-08-dashboard-shrink-repaint-design.md +0 -52
  154. package/docs/superpowers/specs/2026-09-08-legacy-stale-host-recovery-design.md +0 -87
  155. package/docs/superpowers/specs/2026-09-08-spawn-async-error-swallow-design.md +0 -56
  156. package/docs/superpowers/specs/2026-09-08-stale-model-attach-guard-design.md +0 -79
  157. package/docs/superpowers/specs/2026-09-09-claimpid-blocks-replace-design.md +0 -147
  158. package/docs/superpowers/specs/2026-09-09-harden-runner-architecture-design.md +0 -304
  159. package/docs/superpowers/specs/2026-09-14-attach-cursor-dectcem-gate-design.md +0 -114
  160. package/docs/superpowers/specs/2026-09-14-host-meta-orphan-lock-design.md +0 -120
  161. package/docs/superpowers/specs/2026-09-14-issue-106-terminal-frame-cognition-design.md +0 -146
  162. package/docs/superpowers/specs/2026-09-14-issue-113-foreground-preview-race-design.md +0 -116
  163. package/docs/superpowers/specs/2026-09-15-coordinator-pipe-root-normalize-design.md +0 -84
  164. package/docs/superpowers/specs/2026-09-15-lease-publish-eprem-reclaim-design.md +0 -92
  165. package/docs/superpowers/specs/2026-09-18-detach-anchor-reporter-endpoint-design.md +0 -123
  166. package/docs/superpowers/specs/2026-09-20-issue-121-perf-gate-out-of-coverage-design.md +0 -204
  167. package/docs/superpowers/specs/2026-09-23-a5-mid-stream-assertion-design.md +0 -43
  168. package/docs/superpowers/specs/2026-09-23-cwd-browse-tab-drill-design.md +0 -95
  169. package/docs/superpowers/specs/2026-09-23-terminal-query-forwarding-design.md +0 -63
  170. package/docs/superpowers/specs/2026-09-23-unconditional-detach-key-design.md +0 -57
  171. package/docs/superpowers/specs/2026-09-24-control-reconcile-flake-design.md +0 -98
  172. package/docs/superpowers/specs/2026-09-24-probe-attached-clients-design.md +0 -190
  173. package/docs/superpowers/specs/2026-09-29-issue-145-holding-semantic-state-design.md +0 -394
  174. package/docs/superpowers/specs/2026-09-30-board-delete-key-x-y-confirm-design.md +0 -86
  175. package/docs/superpowers/specs/2026-09-30-issue-153-runner-stop-boot-window-design.md +0 -75
  176. package/docs/superpowers/specs/2026-09-30-issue-95-test-discipline-design.md +0 -226
  177. package/docs/superpowers/specs/2026-09-30-publish-dispatch-tag-check-design.md +0 -129
  178. package/docs/superpowers/specs/2026-10-06-attach-settle-scheme-delivery-design.md +0 -196
  179. package/docs/superpowers/specs/2026-10-09-issue-167-attach-middle-paste-design.md +0 -125
  180. package/docs/superpowers/specs/2026-10-09-issue-169-fullscreen-mouse-ownership-design.md +0 -130
  181. package/docs/superpowers/specs/2026-10-10-attach-delete-in-place-design.md +0 -224
  182. package/docs/superpowers/specs/2026-10-10-issue-174-selection-mirror-paste-source-design.md +0 -154
  183. package/docs/superpowers/specs/2026-10-14-issue-170-attach-reply-guard-design.md +0 -195
  184. package/docs/superpowers/specs/2026-10-14-issue-178-accept-registration-race-design.md +0 -287
  185. package/docs/superpowers/specs/2026-10-14-issue-184-snapshot-window-contract-design.md +0 -288
  186. package/docs/superpowers/specs/2026-10-14-issue-185-attach-wheel-alt-screen-design.md +0 -126
  187. package/docs/superpowers/specs/2026-10-14-issue-187-motionless-drag-design.md +0 -269
  188. package/docs/superpowers/specs/2026-10-14-issue-189-mixed-input-segments-design.md +0 -369
  189. package/docs/superpowers/specs/2026-10-15-issue-188-x10-mouse-release-design.md +0 -252
  190. package/docs/superpowers/specs/2026-10-16-issue-198-attach-wheel-forward-fallback-design.md +0 -67
  191. package/docs/superpowers/specs/2026-10-18-attach-wheel-replay-pseudo-scrollback-design.md +0 -250
  192. package/docs/superpowers/specs/2026-10-19-issue-213-reply-guard-csi-key-design.md +0 -165
  193. package/docs/superpowers/specs/2026-10-19-issue-214-attach-forward-alt-modifier-design.md +0 -131
  194. package/docs/superpowers/specs/2026-10-20-issue-221-ask-user-question-design.md +0 -270
  195. package/docs/superpowers/specs/2026-10-22-issue-225-monotonic-foreground-state-design.md +0 -225
  196. package/docs/superpowers/specs/2026-10-23-issue-222-attach-ready-design.md +0 -204
  197. package/docs/superpowers/specs/2026-10-23-issue-229-vim-jkl-design.md +0 -90
  198. package/docs/superpowers/specs/2026-10-23-issue-232-attach-bg-only-cells-design.md +0 -146
  199. package/docs/superpowers/specs/2026-10-23-lease-heartbeat-staleness-design.md +0 -271
  200. package/docs/superpowers/specs/2026-10-23-selected-row-highlight-design.md +0 -191
  201. package/docs/superpowers/specs/2026-10-24-issue-217-windows-pid-reuse-stopping-design.md +0 -334
  202. package/docs/superpowers/specs/2026-10-24-issue-230-warm-host-background-runs-design.md +0 -283
  203. package/docs/superpowers/specs/2026-10-24-issue-238-windows-process-identity-token-design.md +0 -286
  204. package/docs/superpowers/specs/2026-10-24-issue-241-coordinator-root-exit-design.md +0 -381
  205. package/docs/superpowers/specs/2026-10-25-issue-239-coordinator-replacement-identity-design.md +0 -331
  206. package/docs/superpowers/specs/2026-10-26-issue-245-key-hints-doc-sync-design.md +0 -72
  207. package/docs/superpowers/specs/2026-10-27-issue-172-fullscreen-selection-clear-design.md +0 -248
  208. package/docs/superpowers/specs/2026-10-27-issue-252-select-key-hints-design.md +0 -95
  209. package/docs/superpowers/specs/2026-10-28-issue-255-coordinator-endpoint-zombie-design.md +0 -380
  210. package/docs/superpowers/specs/2026-10-28-issue-257-legacy-pty-runner-endpoint-cleanup-design.md +0 -197
  211. package/docs/superpowers/specs/2026-11-03-cwd-favorites-full-pool-design.md +0 -106
  212. package/scripts/budget-audit.mjs +0 -135
  213. package/scripts/release.mjs +0 -446
  214. package/scripts/release_helper.mjs +0 -309
  215. package/scripts/run-perf-gate.mjs +0 -40
  216. package/scripts/run-tests.mjs +0 -375
@@ -1,334 +0,0 @@
1
- # issue #217 — 设计:把「身份不可验证」精确化,消除 Windows pid 复用导致的永久 stopping
2
-
3
- Drafted: deepseek/deepseek-flash (selected) · deepseek/deepseek-flash (physical) @ 2026-10-24T01:52:00+08:00
4
- Revised: deepseek/deepseek-flash (selected) · deepseek/deepseek-flash (physical) @ 2026-10-24T02:18:00+08:00 · 依据 R1 复核(G1–G2 + D1–D7)
5
- Revised: deepseek/deepseek-flash (selected) · deepseek/deepseek-flash (physical) @ 2026-10-24T02:37:00+08:00 · 依据 R2 复核(G1 + D1–D4)
6
- Revised: deepseek/deepseek-flash (selected) · deepseek/deepseek-flash (physical) @ 2026-10-24T02:55:00+08:00 · 依据 R3 复核(G1 + D1–D3)
7
- Revised: deepseek/deepseek-flash (selected) · deepseek/deepseek-flash (physical) @ 2026-10-24T03:12:00+08:00 · 依据 R4 复核(0 真缺口 + D1–D3,收敛轮)
8
- Revised: deepseek/deepseek-flash (selected) · deepseek/deepseek-flash (physical) @ 2026-10-24T03:30:00+08:00 · 依据 PR #240 CR:probe 派生的 owned 在发信号前重验(防 mid-recovery pid 复用)
9
-
10
- 仓库:zhuxixi/pi-agent-board · issue:#217 · 起草轮次:R0(待交叉复核)
11
-
12
- ---
13
-
14
- ## 1. 问题与根因(已复现 / 已核实)
15
-
16
- **现象**:Windows(以及任何 `captureStartToken()` 返回 `null` 的平台)上,一条新协议 host 记录在 `stopping` 状态下,若 child pid 已被无关进程复用,attach 永远返回
17
- `recovery_pending: host identity unverifiable`,`diagnostics.jsonl` 零记录,记录永久停在 `stopping`。
18
-
19
- **根因链**(行号以当前 `main` @ 20b0323 为准):
20
-
21
- | 环节 | 位置 | 事实 |
22
- |---|---|---|
23
- | 记录侧 token | `runner/pty-runner.mjs:1613` 私有 `captureStartToken` | `platform !== "linux"` → `null`;写入 `runnerIdentity` / `childIdentity` |
24
- | 观测侧 token | `src/runtime/service.mjs:2433` 私有 `readProcStartToken` | 同样 `/proc` only;被 `defaultObserveProcess`(:2511)使用 |
25
- | 判定 | `service.mjs:2511` `defaultObserveProcess` | 活但 token 缺失 → `unknown` |
26
- | 死锁 A | `service.mjs:1018` | `unknown` → `recovery_pending`;但记录已在 `:1002` 调用的 `requestHostStop`(定义 `:947`)被翻成 `stopping` |
27
- | 死锁 B | `service.mjs:1307-1308` | resolver 的 `stopping` 分支遇 `unknown` 直接 `pending` |
28
- | 释放侧 | `service.mjs:980` `hostRoleEnded` | 只接受 `not_started` / `dead` / `foreign`;`unknown` 永不放行 |
29
- | 诊断 | 无 | `recovery_pending` 两条返回路径都不写 `diagnostics.jsonl` |
30
-
31
- **不对称性**:`dead` 在 token 比对之前判定,所以「进程已死」的残留行都能自愈;只有「活但无法验证」的复用 pid 会死锁。
32
-
33
- **关键调研结论(决定修复落点)**:
34
-
35
- 1. 运行时**没有**走 `src/core/host-coordination.mjs:49` 的 `processIdentityState`(该函数仅被单测引用)。
36
- 因此只改 `src/core/pid.mjs` 的 `captureStartToken` 或 `processIdentityState` **修不好本 issue**。
37
- 2. `observeHostRole`(`service.mjs:961`)绕过了 `processIdentityState` 的 `spawnedAt == null → not_started`
38
- 语义,自己实现了一份平行判定 —— 这是第三份漂移点,本次收敛。
39
- 3. #70 的保守契约(unknown 绝不猜测)必须保留;本设计只把 `unknown` **精确化**,
40
- 不新增「无证据也释放」的路径。**显式修订声明**:
41
- `docs/superpowers/specs/2026-09-05-pty-host-stacking-sock-race-design.md:144-145` 把
42
- 「无稳定 token → unknown → recovery_pending」钉成契约;本设计将其修订为
43
- 「无 token → 探测 → owned/foreign/unknown」,探测不可用(`probe == null`)时仍严格保持原契约。
44
- 4. #224 spec 把本 issue 划为「另一层,独立修」;其中「Windows 创建时间修不好 #224」的结论针对
45
- 租约判定(从不比对 token),**不适用于**本 issue。
46
-
47
- **本机实况证据**(2026-10-24):18 条 `stopping`/`alive` 记录中,所有新协议记录的
48
- `runnerIdentity/childIdentity.startToken` 均为 `null`;`runnerSpawnedAt → childSpawnedAt`
49
- 间隔 89–118 ms;现存 `view_8eb60f4ff4`(legacy)的 `childPid 3180` 活着,实为
50
- `SOGOUSmartAssistant.exe`(创建于 2026-10-23T01:21:40Z,远晚于记录的 `startedAt`)。
51
-
52
- **平台能力验证**(本机 Windows 11):
53
-
54
- ```
55
- powershell -NoProfile -NonInteractive -Command
56
- '$p = Get-CimInstance Win32_Process -Filter "ProcessId=3180" -ErrorAction SilentlyContinue;
57
- if($p){ [pscustomobject]@{creation=[long]([datetimeoffset]$p.CreationDate).ToUnixTimeMilliseconds(); cmd=$p.CommandLine} | ConvertTo-Json -Compress }'
58
- → {"creation":1792718500139,"cmd":"\"E:\\...\\SOGOUSmartAssistant.exe\" --panel=auto"} # ≈ 470 ms
59
- ```
60
-
61
- 一次查询同时拿到**创建时间**与**命令行**,不依赖 `wmic`(Win11 24H2 起移除)或 `Get-Process` 的两次往返。
62
- child 的真实命令行形如 `node.exe <cli.js> --session <sessionFile>`(`runner/pty-runner.mjs:288`)。
63
-
64
- ## 2. 范围
65
-
66
- **做**:
67
-
68
- 1. 新增 process-identity 探测层(Windows CIM / macOS `ps`),只在 token 缺失且 observation 为 `unknown`
69
- 时调用(Linux 稳态零成本)。
70
- 2. 新增纯判定 `classifyUnverifiableProcess`:用**角色专属路径参数的命令行匹配**(child → `sessionFile`,
71
- runner → `configPath`;确定性信号)判 `owned` / `foreign`,命令行不可读时退回**创建时间 vs `*SpawnedAt`**
72
- 区间比对。
73
- 3. `observeHostRole` 异步化 + 探测 memo:`recoverHost` 单次调用内 ≤2 次;resolver stopping 分支用
74
- 自己的局部 memo(2 次);单次 attach 最坏 4 次(探测只发生在 token 缺失的恢复路径)。
75
- 4. `recovery_pending` 落诊断(去重),消除 #112 同款静默。
76
- 5. 把**细化层**判定收敛回 `host-coordination.mjs` 纯函数。保证边界:`observeHostRole` 仍保留 I/O 接线与
77
- 基础判定(`identity → observeProcess` / `not_started` / `unknown`),`processIdentityState` 维持现状
78
- (运行时仍未引用),本次不承诺「判定全部集中到 core」。
79
-
80
- **不做**(明确非目标):
81
-
82
- 1. `captureStartToken` 四份拷贝收敛、Windows 记录侧 token 实现(#224 spec 已划为共享底座,另立)。
83
- 2. 「强制重置 host 记录」UI 行操作 —— 本修复后不再是唯一出路;独立 UI 功能面。
84
- 3. `recoverHost` 先翻 `stopping` 再验证的顺序调整 —— 只改变污染面,不改变可达性;不混入本修复。
85
- 4. legacy(`instanceId == null`)记录 —— 已由 `canFinalizeLegacyHost` 覆盖,不受本 bug 影响。
86
- 5. 命令行之外的佐证(文件句柄 / 会话文件锁)—— 收益不抵复杂度。
87
-
88
- ## 3. 设计决策
89
-
90
- ### D1 判定函数:命令行优先,创建时间兜底(纯函数)
91
-
92
- `src/core/host-coordination.mjs`:
93
-
94
- ```js
95
- /**
96
- * @param {{ spawnedAt: number|null, probe: {creationTimeMs:number|null, commandLine:string|null}|null,
97
- * expectedCommandFragment: string|null, toleranceMs: number }} input
98
- * @returns {"owned"|"foreign"|"unknown"}
99
- */
100
- export function classifyUnverifiableProcess({ spawnedAt, probe, expectedCommandFragment, toleranceMs }) {
101
- if (!probe) return "unknown"; // 探测不可用:保持 #70 保守契约
102
- if (probe.commandLine && expectedCommandFragment) {
103
- return commandLineMentions(probe.commandLine, expectedCommandFragment) ? "owned" : "foreign";
104
- }
105
- if (spawnedAt == null || probe.creationTimeMs == null) return "unknown";
106
- return Math.abs(probe.creationTimeMs - spawnedAt) <= toleranceMs ? "owned" : "foreign";
107
- }
108
-
109
- /** 路径比较:反斜杠归一 + 大小写不敏感(Windows 语义);只匹配路径,不要求 `--session` 前缀。 */
110
- export function commandLineMentions(commandLine, fragment) { /* ... */ }
111
- ```
112
-
113
- **角色不对称(R1-G1)**:runner 与 child 的命令行**不同构**,确定性匹配串必须按角色给:
114
-
115
- | 角色 | 确定性匹配串 | 依据 |
116
- |---|---|---|
117
- | child | `meta.sessionFile` | `runner/pty-runner.mjs:955` `[...piArgsPrefix, "--session", config.sessionFile]` |
118
- | runner | host 记录的 `configPath` | `src/core/launch.mjs:83` `node <runnerScript> <configPath>` —— **没有** `--session` |
119
-
120
- 把 `sessionFile` 统一套到 runner 会把「活着的 owned runner」误判 `foreign`,进而在旧 runner 存活时 claim
121
- 新 host(双写 session 文件)。匹配串缺失(如 legacy 记录无 `configPath`)时该角色不走命令行分支,退回创建时间。
122
-
123
- **为什么命令行优先**:进程命令行是确定性证据 —— 我们 spawn 的每个角色都带自己的唯一路径参数
124
- (child 带 session 文件,runner 带 instance 专属 config 路径),被复用的 pid 命令行不可能同时命中。
125
- 创建时间受系统时钟 step 调整影响(长期存活进程的 `creationTimeMs - spawnedAt` 会漂移),只能做兜底。
126
-
127
- **不变量**:
128
-
129
- - `probe == null` → `unknown`(探测失败/权限不足),绝不 foreign。
130
- - `expectedCommandFragment` 缺失 → 不进入命令行分支(无法比对),退回创建时间。
131
- - `spawnedAt` 缺失 → 创建时间分支返回 `unknown`。
132
-
133
- ### D2 探测层:一次查询拿两信号(新模块 `src/core/process-identity-probe.mjs`)
134
-
135
- ```js
136
- // platform/execFileFn/timeoutMs 均有默认值:默认注入点按单参调用 probeProcessIdentityImpl(pid),
137
- // 不会对 undefined 解构(R3-G1)。execFileFn 默认取 node:child_process 的异步 execFile(本仓首次引入)。
138
- export async function readProcessIdentityProbe(
139
- pid,
140
- { platform = process.platform, execFileFn = execFile, timeoutMs = PROBE_TIMEOUT_MS, nowMs = Date.now } = {},
141
- ) -> {creationTimeMs:number|null, commandLine:string|null} | null
142
- ```
143
-
144
- | 平台 | 命令 | 解析 |
145
- |---|---|---|
146
- | `win32` | `powershell.exe -NoProfile -NonInteractive -Command <CIM 片段>` | `JSON.parse` → `{creation, cmd}`;`creation` 用 `parseUnixMillis` |
147
- | `darwin` | `ps -p <pid> -o etime=,command=` | 首列 `etime`(`DD-HH:MM:SS` / `HH:MM:SS` / `MM:SS`)→ `now - elapsed`;其余为 command |
148
- | 其他 | 不探测,返回 `null` | Linux 已有 `/proc` token |
149
-
150
- 约束:
151
-
152
- - `execFile` **异步**(不阻塞 dashboard 主线程),`timeout: 3000`,`windowsHide: true`
153
- (出处:`src/core/launch.mjs:40` 的 `spawnDetached` 注释与 issue #49 —— detached spawn 缺此参数会在
154
- Win11 25H2 触发 WindowsTerminal 闪现)。
155
- - pid 必须是正整数,否则直接返回 `null`(无注入面)。
156
- - macOS 的 `creationTimeMs = nowMs - elapsedMs`,`nowMs` 可注入(与 `createService` 的 `nowImpl` 惯例一致),
157
- A5 用固定 `nowMs` 断言确定性(R4-D2)。
158
- - 输出解析失败 / 命令非零退出 / 超时 → `null`。`null` 在判定层是 `unknown`,方向安全。
159
- - 纯函数导出(`parseUnixMillis` / `parsePsElapsedMs` / `parseProbeJson` / `parsePsProbeLine` /
160
- `resolveProbeCommand`)供单测枚举,I/O 只有 `readProcessIdentityProbe` 一层。
161
-
162
- ### D3 `observeHostRole`:unknown 时才探测,单次恢复内 memo
163
-
164
- `service.mjs`:
165
-
166
- ```js
167
- // sessionFile 来源:recoverHost 新增可选第三参(默认从 row.meta 读取),不破既有测试的
168
- // svc.recoverHost("v1", "i1") 调用;resolver 调用点把自己已有的 sessionFile 传进去。
169
- async function recoverHost(viewId, expectedInstanceId, sessionFile = loadRow(root, viewId)?.meta.sessionFile ?? null) {
170
- const probeMemo = new Map(); // 单次 recoverHost 内共享(循环 + final 复检)
171
- // ...
172
- const runnerObservation = await observeHostRole(host, "runner", { sessionFile, memo: probeMemo });
173
- const childObservation = await observeHostRole(host, "child", { sessionFile, memo: probeMemo });
174
- }
175
- // final 复检(对应 :1046)同样必须带第三参,否则 undefined 解构抛 TypeError(R3-D2):
176
- // const finalRunner = await observeHostRole(finalHost, "runner", { sessionFile, memo: probeMemo });
177
-
178
- async function observeHostRole(host, role, { sessionFile, memo }) {
179
- const identity = host?.[`${role}Identity`] ?? null;
180
- const pid = identity?.pid ?? host?.[`${role}Pid`] ?? null;
181
- const spawnedAt = host?.[`${role}SpawnedAt`] ?? null;
182
- const observation = identity ? observeProcessImpl(identity) : spawnedAt == null ? "not_started" : "unknown";
183
- if (observation !== "unknown" || pid == null) return observation;
184
-
185
- if (!memo.has(pid)) memo.set(pid, await probeProcessIdentityImpl(pid));
186
- const expectedCommandFragment = role === "child" ? sessionFile : host?.configPath ?? null;
187
- return classifyUnverifiableProcess({
188
- spawnedAt, probe: memo.get(pid), expectedCommandFragment,
189
- toleranceMs: resolveIdentityCreationToleranceMs(),
190
- });
191
- }
192
- ```
193
-
194
- - **memo 必须由调用方显式创建并下传**(R1-D1):默认参数 `= new Map()` 会在每次调用时新建,
195
- `recoverHost` 循环(`:1014-1015`)与 final 复检(`:1046`)就各自重探测。
196
- `HOST_RECOVERY_POLL_MS = 150`、`HOST_RECOVERY_GRACE_MS = 5_000`,最坏 33 轮 × 2 角色 → 无 memo 时
197
- 66 次 × ~470 ms 远超 grace。memo 是**分类缓存**:共享 memo 后单次观测的探测 ≤2 次
198
- (runner + child 各一次);但 **probe 派生的 `owned` 必须在每次发信号前重新探测并重新分类**
199
- (`revalidateProbeOwned`,PR #240 CR)——若 pid 在 recovery 中被复用,重验得到 `foreign`,
200
- 不发信号且不置 `*Sent`,下一轮 `hostRoleEnded` 按 ended 放行。token 路径(Linux)不重探测:
201
- `observeProcessImpl` 读取实时 `/proc`,且探测层本就不支持 Linux。
202
- - **resolver stopping 分支的接线(R2-G1)**:该分支(`service.mjs:1304-1305`)不是循环,用自己的局部 memo,
203
- 并把分支内已有的 `sessionFile`(`:1260`)传入,不得依赖未定义的解构:
204
-
205
- ```js
206
- const probeMemo = new Map();
207
- const runnerObs = await observeHostRole(host, "runner", { sessionFile, memo: probeMemo });
208
- const childObs = await observeHostRole(host, "child", { sessionFile, memo: probeMemo });
209
- ```
210
- - **probe 为 `null` 时保持 `unknown`,不做 `isAlive` 二次判定**(R1-G2):`isAlive` 在 `service.mjs:28`
211
- 直接 import、不可注入,加这条分支会让既有「unknown → pending」用例(死占位 pid 4242 / 999999)
212
- 翻成 `dead`,与 A11 自相矛盾。代价仅是一次轮询(≤150 ms)后由 `defaultObserveProcess` 判 `dead`。
213
- - 调用点全部在 async 上下文(`recoverHost` 循环、resolver stopping 分支、finalize 前复检),
214
- 逐一补 `await`。
215
- - `probeProcessIdentityImpl` 新注入点(`opts.probeProcessIdentity`),默认 `readProcessIdentityProbe`。
216
-
217
- ### D4 诊断:`recovery_pending` 必须留痕且去重
218
-
219
- - 新增 `src/core/diagnostics.mjs#appendDiagnosticOnce(root, viewId, patch, { match, limit })`:
220
- 读 tail(默认 20 条),若已有同 `code` 且 `match` 各 `details` 键值相同 → 跳过。
221
- - `recovery_pending` 共 5 个返回面(R1-D3):`service.mjs:1018`(recoverHost 循环)、`:1047`(recoverHost
222
- final 复检)、`:1308`(resolver stopping 分支自主判定)、`:1315` 与 `:1383`(转述 `recoverHost` 的
223
- `recovery_pending`)。**只有产生方**(1018 / 1047 / 1308)写诊断;转述方不重复写(去重也会拦,但不必依赖)。
224
-
225
- ```js
226
- { source:"service", level:"warn", code:"host_recovery_pending",
227
- message:"Host recovery is pending: process identity unverifiable",
228
- details:{ instanceId, role, observation, probe: probe ? "available" : "unavailable", path: "recover"|"attach" } }
229
- ```
230
-
231
- - 去重键:`code + details.instanceId + details.role`。同一 instance 的 runner/child 各留一条,反复点击不刷屏。
232
-
233
- ### D5 常量与测试旋钮
234
-
235
- ```js
236
- export const HOST_IDENTITY_CREATION_TOLERANCE_MS = 5_000;
237
- export function resolveIdentityCreationToleranceMs(env = process.env) {
238
- return resolveTestMs(env, "AGENT_BOARD_TEST_IDENTITY_CREATION_TOLERANCE_MS", HOST_IDENTITY_CREATION_TOLERANCE_MS);
239
- }
240
- ```
241
-
242
- 容差取 5 s:覆盖 `spawn → childSpawnedAt 写盘` 的实测 89–118 ms 与常见时钟调整;
243
- 命令行分支命中时不依赖该容差。env 旋钮的覆盖行为由 **A15** 验证。
244
-
245
- **模块归属(R3-D1)**:`HOST_IDENTITY_CREATION_TOLERANCE_MS` 与 `resolveIdentityCreationToleranceMs`
246
- 落在 `src/runtime/service.mjs`(与 `resolveHostStartGraceMs:80` / `resolveHostRecoveryGraceMs:85` /
247
- `resolveHostRecoveryPollMs:90` 并列),**不**放进 `host-coordination.mjs`——该模块文件头明确
248
- 「no fs/net/process I/O」,且 `resolveTestMs` 对非法值会写 stderr。A15 在 `test/test-knobs.test.mjs` 覆盖。
249
-
250
- ## 4. 数据流(修复后)
251
-
252
- ```
253
- attach 点击
254
- └─ resolveAttachTargetInner: host.state === "stopping" 且超 grace
255
- └─ observeHostRole(runner/child) [async]
256
- ├─ isAlive=false → dead ✓(现状)
257
- ├─ token 可用且不匹配 → foreign ✓(现状,Linux)
258
- ├─ token 缺失 → probe(pid)
259
- │ ├─ 命令行含 sessionFile → owned → SIGTERM/SIGKILL(既有升级逻辑)
260
- │ ├─ 命令行不含 sessionFile → foreign → 不发信号,释放并 claim 新 host
261
- │ └─ 探测失败 → unknown → pending + 诊断(现状行为 + 留痕)
262
- └─ observed 都 ended → recoverHost finalize + 新 claim → 下一次 attach 冷启动
263
- ```
264
-
265
- ## 5. 可测性拆分设计
266
-
267
- | 单元 | 位置 | 性质 | 测法 |
268
- |---|---|---|---|
269
- | `commandLineMentions` | `host-coordination.mjs` | 纯函数 | 绝对路径 `\`/`/`、大小写、引号包裹、路径前缀陷阱(`session-2.jsonl` 不得匹配 `session.jsonl` 之外的假阳性需专门用例) |
270
- | `classifyUnverifiableProcess` | `host-coordination.mjs` | 纯函数 | 真值表:probe null / 命令行匹配(含 `spawnedAt:null` 时仍 owned)/ 命令行不匹配 / 命令行空 + 时间窗内 / 窗外 / 命令行空且 `spawnedAt:null` → unknown(R4-D3) |
271
- | `parseUnixMillis` / `parseProbeJson` | `process-identity-probe.mjs` | 纯函数 | 正常、空串、非数字、负值、缺字段 |
272
- | `parsePsElapsedMs` / `parsePsProbeLine` | 同上 | 纯函数 | `05:03` / `01:02:03` / `2-01:02:03` / 非法输入;command 含空格 |
273
- | `resolveProbeCommand` | 同上 | 纯函数 | win32 / darwin / linux 返回;pid 非法 → null |
274
- | `readProcessIdentityProbe` | 同上 | I/O 外壳(注入 `execFileFn`) | 成功 stdout、非零退出、超时、空 stdout、`windowsHide` 参数断言 |
275
- | 探测次数上限 | `service.mjs` | 集成(注入计数桩) | 单次 `recoverHost` 内探测桩调用 ≤2 次(memo 共享) |
276
- | `appendDiagnosticOnce` | `diagnostics.mjs` | 文件 I/O(临时目录) | 首写、重复跳过、键不同再写、无 match 时退化为 append |
277
- | `recoverHost` 判定接线 | `service.mjs` | 集成(注入探测桩) | A6/A7/A8 |
278
- | resolver stopping 分支 | `service.mjs` | 集成(注入探测桩) | A9 |
279
-
280
- **测试边界**:判定与解析全部在纯函数层可枚举;平台差异被压缩到 `resolveProbeCommand` +
281
- `readProcessIdentityProbe` 两个函数;`service.mjs` 只做「何时调用」的接线,不承载解析逻辑。
282
-
283
- **既有测试的确定性**:`test/host-recovery.test.mjs:179`、`test/host-resolver.test.mjs:350` 等
284
- 使用 `observeProcess: () => "unknown"` 的用例必须显式注入 `probeProcessIdentity: () => null`,
285
- 否则默认探测会查询真实 pid 表(测试 pid 恰好存在时结果依赖宿主机),破坏确定性。
286
- `probeProcessIdentity: () => null` 时 `classifyUnverifiableProcess` 必返回 `unknown`,原断言(pending)成立——
287
- 前提是**不**在 `probe == null` 时额外做 `isAlive` 二次判定(见 D3)。
288
-
289
- **签名变更清单**:`recoverHost(viewId, expectedInstanceId, sessionFile?)` 增加可选第三参;
290
- 既有测试的两参调用保持兼容。
291
-
292
- ## 6. 验收矩阵
293
-
294
- | ID | 功能点 | 验收方式 | 具体验证 | 通过标准 |
295
- |---|---|---|---|---|
296
- | A1 | 命令行匹配语义 | 自动化验证(unit) | `node --test test/host-coordination.test.mjs` | 大小写/斜杠/引号/前缀陷阱用例全通过 |
297
- | A2 | 不可验证进程分类真值表 | 自动化验证(unit) | 同上 | probe null → unknown;命令行匹配 → owned;不匹配 → foreign;时间窗边界 ±tolerance |
298
- | A3 | 平台探测命令构造 | 自动化验证(unit) | `node --test test/process-identity-probe.test.mjs` | win32/darwin 命令形态;其他平台 null;非法 pid null |
299
- | A4 | 探测输出解析(Windows / macOS) | 自动化验证(unit) | 同上 | 三种 etime 形态、JSON 形态、非法输入 → null |
300
- | A5 | 探测执行层 | 自动化验证(unit) | 同上(注入 `execFileFn`) | 成功/非零退出/超时/空输出;参数含 `windowsHide: true`、`timeout` |
301
- | A6 | recoverHost:unknown + 命令行不匹配 → 释放重建 | 自动化验证(integration) | `node --test test/host-recovery.test.mjs` | 不发任何信号;claim 新 instance;记录 `starting` |
302
- | A7 | recoverHost:unknown + 命令行匹配 → 发信号重建 | 自动化验证(integration) | 同上 | 恰好一次 SIGTERM 指向该 identity |
303
- | A8 | recoverHost:探测不可用 → 保持 pending(#70 不回归) | 自动化验证(integration) | 同上 | `recovery_pending`,记录仍 `stopping` |
304
- | A9 | resolver stopping 分支:foreign 可推进 | 自动化验证(integration) | `node --test test/host-resolver.test.mjs` | 不再返回 `recovery_pending`,最终 `pty` 或进入恢复 |
305
- | A10 | `recovery_pending` 诊断 + 去重 | 自动化验证(integration) | host-recovery / host-resolver 测试 | JSONL 出现 `host_recovery_pending`;同键第二次点击不新增 |
306
- | A11 | 既有 unknown 用例仍 pending | 自动化验证(integration) | `node --test test/host-recovery.test.mjs test/host-resolver.test.mjs`(用例显式注入 `probeProcessIdentity: () => null` 桩) | 原断言不变 |
307
- | A12 | 类型检查 | 自动化验证(static) | `npm run typecheck` | 0 error |
308
- | A13 | 单次恢复探测次数 ≤2(memo 共享) | 自动化验证(integration) | `node --test test/host-recovery.test.mjs` | 注入计数桩,`recoverHost` 一次调用内 ≤2 次探测 |
309
- | A14 | 相关测试套件 | 自动化验证(integration) | `node --test test/host-recovery.test.mjs test/host-resolver.test.mjs test/host-coordination.test.mjs test/process-identity-probe.test.mjs test/diagnostics.test.mjs` | 全通过 |
310
- | A15 | 容差 env 旋钮(D5) | 自动化验证(unit) | `node --test test/test-knobs.test.mjs` | `resolveIdentityCreationToleranceMs` 已加入 reader 断言;`AGENT_BOARD_TEST_IDENTITY_CREATION_TOLERANCE_MS` 覆盖生效;未设时回退 5000 |
311
- | U1 | Windows 实机:真实 stranded 记录 attach 恢复 | 用户实测 | **前置**:修复 PR 已合并并 `git pull`、重载 Pi/dashboard、确认加载到 `src/core/process-identity-probe.mjs`。**步骤**:手改 `views/<viewId>/host.json` 造 stranded 记录(`state=stopping`、`stopRequestedAt` 早于 recovery grace、`runnerPid` 填已死 pid、`runnerIdentity.startToken=null`、`childPid` 填一个存活的无关进程 pid、`childIdentity.startToken=null`、`childSpawnedAt` 填与该 pid 创建时间相距超过容差的值),点击行 | 进入 attach,host.json 出现新 instance 且 `state` 推进到 `starting`/`alive`;diagnostics 无静默 |
312
- | U2 | Windows 实机:真孤儿 child 被识别为 owned 并回收 | 用户实测 | **前置**:同 U1。**步骤**:启动 host 后 `kill -9` runner(保留 child),点击 attach | child 进程结束,新 host 启动,会话续接 |
313
-
314
- U1/U2 需要真实 Windows 交互环境;本机可构造,但 U 级结果以实际执行记录为准,无法执行时标记 `pending`。
315
-
316
- ## 7. 风险与回滚
317
-
318
- | 风险 | 缓解 |
319
- |---|---|
320
- | 误判 `foreign` → 释放后新 host 与真孤儿同时写 session 文件 | 命令行匹配是确定性证据;只有命令行不可读时才依赖时间窗;探测失败一律 `unknown` |
321
- | 误判 `owned` → 向复用 pid 发信号(杀无辜) | owned 仅在命令行匹配(确定性)或时间窗内成立;时间窗 5 s 远小于实测 pid 复用跨度(天级);且每次 probe 派生的升级信号发送前都重验 pid(PR #240 CR),mid-recovery 复用的 pid 会被判 `foreign` 而不发信号 |
322
- | 异步化改变 recovery 时序 / 竞态 | memo 把探测压到 ≤2 次;所有调用点已在 async 上下文;`recoverHost` 的 owner 复检逻辑不变 |
323
- | Windows 杀软/组策略禁用 PowerShell | 探测返回 `null` → 维持现状 `unknown` + 诊断,不回归 |
324
- | 测试依赖宿主机 pid 表 | 所有 unknown 用例显式注入探测桩 |
325
-
326
- **回滚**:改动集中在 `host-coordination.mjs`、`process-identity-probe.mjs`(新)、`service.mjs`、
327
- `diagnostics.mjs` 与对应测试;`git revert` 单 commit 即恢复原行为。
328
-
329
- ## 8. 测试命令与已知环境限制
330
-
331
- - 本机(Windows)验证:`npm run typecheck` + 上表列出的单测/集成测试文件。
332
- - `npm run verify`(含 perf gate + coverage)在无控制台环境可能因 node-pty 的
333
- `AttachConsole failed`(已知本地限制,见 JFox permanent `202609051440498495`)出现既有失败;
334
- CI(Linux)为准。若本机 verify 失败,需区分「本改动引入」与「既有环境失败」并在 PR 中列明。
@@ -1,283 +0,0 @@
1
- # Spec — issue #230: warm-host sweeper must not evict hosts running a background workflow
2
-
3
- Drafted: deepseek/deepseek-flash (selected) · deepseek/deepseek-flash (physical) @ 2026-10-24T09:27:39+08:00
4
- Revised: deepseek/deepseek-flash (selected) · deepseek/deepseek-flash (physical) @ 2026-10-24T09:33:48+08:00(round 1 复核后修订)
5
- Revised: deepseek/deepseek-flash (selected) · deepseek/deepseek-flash (physical) @ 2026-10-24T09:41:14+08:00(round 2 复核后细化修订:R2-D1..D6)
6
-
7
- - Repo: `zhuxixi/pi-agent-board`
8
- - Issue: https://github.com/zhuxixi/pi-agent-board/issues/230
9
- - Research: `~/.github-issue-driven/zhuxixi/pi-agent-board/issue-230/research/2026-10-24-root-cause-and-signal.md`(跨 harness 留档,不进仓)
10
- - Status: **design approved**(spec cross-review 两轮收敛,2026-10-24;轮次报告见 issue #230 评论)
11
-
12
- ---
13
-
14
- ## 1. 问题与现状
15
-
16
- ### 1.1 现象(issue 复述,已验证)
17
-
18
- 后台 workflow(`workflow({background:true})`)的 agent step 若长于 `AGENT_BOARD_WARM_HOST_TTL_MS`(默认 10 min),未 attach 的宿主会被周期 sweeper 在 TTL 或 `MAX_WARM_HOSTS=4` 分支上驱逐;宿主被 revoke → child pi 收 SIGTERM → in-flight agent 落为 `skipped/interrupted` → run 落 `paused`,且**无任何通知**。用户重新 attach + resume 后再被驱逐,形成 evict/re-run 死循环(重复付费)。
19
-
20
- ### 1.2 根因(代码级,已定位)
21
-
22
- | 位置 | 现状 | 问题 |
23
- |---|---|---|
24
- | `src/core/warm-host-sweeper.mjs:23-27` | `isAgentBusy(row)` = `alive && semanticState ∈ {queued,working}` 或有 `pendingQuestions` | 后台 run 不持有 turn:`workflow()` 返回 `started` 后 turn 结束、`semanticState` 回 `idle` → 判定为"空闲" |
25
- | `src/core/warm-host-sweeper.mjs:57-88` | `selectIdleHostsToEvict` 只看 `hostAlive` / `isAgentBusy` / `attachedClients` / `graceMs` / `keepViewId` | 两个淘汰分支(TTL、maxWarm)都会选中带 running run 的 host |
26
- | `src/runtime/service.mjs:915-939` | `pruneWarmHosts` 的复查循环复用 `isAgentBusy` | 同一盲区,且是真正下发 `stopHostRow` 的地方 |
27
- | `src/index.ts:54-70` | 每个非 child 宿主 pi 都跑 60s sweeper;shutdown 时再 `sweepNow()` | 多个 board 进程并发 sweep,放大误杀概率 |
28
-
29
- 驱逐后归因也丢失:`requestHostStop` 写 `host.json.stopReason="warm_prune"`,但 pty-runner 收尾时用 `stopReason:"revoked"` 覆盖(`runner/pty-runner.mjs:710-724`),事后无法区分。
30
-
31
- ### 1.3 可用信号(已验证)
32
-
33
- pi-dynamic-workflows 的 v2 run head `<workflowHome>/projects/<slug>-<sha12>/runs/<runId>.json`(~1.6 KB)里 `index` 含:
34
-
35
- ```json
36
- { "runId": "...", "status": "running|paused|completed|failed|aborted|pending",
37
- "parentSessionFile": "<绝对路径>", "startedAt": "...", "updatedAt": "..." }
38
- ```
39
-
40
- `parentSessionFile` 与 board 的 `row.meta.sessionFile` 是同一路径(实测 `…\agent-board\sessions\view_<id>.jsonl`)。`workflowHome` = `PI_CODING_AGENT_DIR ? <agentDir>/workflows : ~/.pi/workflows`。
41
-
42
- ---
43
-
44
- ## 2. 目标 / 非目标
45
-
46
- **目标**
47
-
48
- - G1:带 `status === "running"` 后台 run 的宿主,不被 TTL 分支、也不被 maxWarm 分支驱逐;选择阶段与停止前复查阶段都生效(覆盖所有调用点:60s sweep、`ensureHost`/dispatch 惰性、shutdown `sweepNow`、公开 `service.pruneWarmHosts`)。
49
- - G2:当驱逐**仍然发生**(探针失败 / guard 被关闭 / 复查后竞态)时,board 侧留下可追溯痕迹(探针失败 → `warm_prune_probe_failed`;其余 → 事后 `background_run_interrupted`),不让 run 静默变 paused。
50
- - G3:宿主因**任意原因**消失后,若该会话留下 `paused` run,行上出现可读提示(含 runId 与"可 resume";写入 `state.summary` + `warn` 诊断),不新增 UI 组件、**不依赖** reconcile 的 `looksActive` 门禁与 `reconcile_finalize` 的 `processState === "alive"` 前置(目标场景两者都不成立,见 D9/D12)。
51
- - G4:探测失败一律 fail-open(退回今天的行为),绝不因为外部文件格式变化而让 warm-host 回收整体失效。
52
-
53
- **非目标**
54
-
55
- - 不修 pi-dynamic-workflows 的"非 usage_limit 暂停不通知"上游缺口(本 issue 只在 board 侧补痕迹)。
56
- - 不改 TTL / maxWarm / grace / sweep 间隔默认值。
57
- - 不让 coordinator / state 命令层感知 workflow;不新增 ViewState 字段。
58
- - 不为 legacy(< v2)run record 做按会话匹配(它们没有 `parentSessionFile`)。
59
- - 不修 `stopReason` 被 runner 覆盖成 `revoked` 的问题(记录为后续 issue 候选,本 spec 的判定不依赖它)。
60
- - 不提供"自动 resume paused run"能力。
61
-
62
- ---
63
-
64
- ## 3. 决策表
65
-
66
- | ID | 决策 | 备选(否决理由) | 验收 |
67
- |---|---|---|---|
68
- | D1 | 保护条件 = `status === "running"` **且** `run.updatedAt >= host.startedAt`(`host.startedAt` 缺失时只按 status 保护) | ① 文件新鲜度窗口:长 step 期间不落盘,短窗口复现 bug、长窗口让陈旧记录长期豁免(host 泄漏回归);② 只看 status:上一次宿主崩溃遗留的 running 记录会永久豁免该行 | A2, A3, A4 |
69
- | D2 | `paused` 不保护(paused 无需 host 即可 resume),仅用于 G3 的提示判定 | 连 paused 一起保护:paused run 可能永不被 resume,host 永久不回收 | A4, A13 |
70
- | D3 | 探测方式:遍历 `<workflowHome>/projects/*/runs/*.json`,按 `parentSessionFile` 全量匹配 | 复刻上游 `projectKey = slug + sha256(cwd)[:12]`:上游改算法即静默失效,且需读 `row.meta.cwd` 之外的隐式约定 | A1, A6 |
71
- | D4 | mtime 预筛 `mtimeMs >= min(候选行 host.startedAt) - 2s`;任一候选行缺失 `startedAt` 时禁用预筛(实现传 `0 - CLOCK_EPSILON_MS`,对真实 mtime 等价),因为按时间兜底会丢掉分类规则(`hostStartedAt == null → protecting`)本应保护的 head(预筛与分类不一致的 bug);预筛只是性能优化,判定仍走 D1 | 无预筛:每次 sweep 全量读 head(可接受但没必要);用 `updatedAt` 预筛:逻辑重复 | A6 |
72
- | D5 | 纯判定与 IO 分离:`selectIdleHostsToEvict` 接受可选 `isProtected(row)` 谓词(默认不保护,行为与今天一致),service 注入真实探测结果 | 在纯函数里读盘:破坏既有 17 个单测的可测性,且违背仓内 pure-core/IO-shell 约定(AGENTS.md) | A7 |
73
- | D6 | 探测失败(目录缺失 / 坏 JSON / 读错误)→ `ok:false` → 按未保护处理(fail-open)+ `appendDiagnosticOnce(code:"warm_prune_probe_failed", level:"warn")` | fail-closed:一次格式变更就会让 warm host 永不回收 | A10 |
74
- | D7 | 逃生门 `AGENT_BOARD_NO_BACKGROUND_RUN_GUARD=1` 关闭保护(与 `AGENT_BOARD_NO_SWEEP` 同款命名),默认开启 | 无开关:出问题只能回滚版本 | A11 |
75
- | D8 | 复查阶段的新鲜探测若发现 running run:**跳过回收**(不 stop),并留痕 `appendDiagnosticOnce(code:"warm_prune_during_background_run", level:"warn", details:{runId, hostStartedAt}, {match:["runId"]})`(记录“保护拦下一次误杀”) | 只保护不留痕:排障时分不清“没有候选”与“候选被保护挡下”,看不到保护是否生效 | A9 |
76
- | D9 | G3 提示通道:`patch_fields`(source=`service` 的 state 白名单新增 `summary`)+ 同步追加 `warn` 诊断 | ① 直接 `writeState`:违反 `test/architecture-writer-boundary.test.mjs`;② **`reconcile_finalize`**:`state-commands.mjs:502` 在 `currentState.processState !== "alive"` 时 `reject("no_change")`,而目标场景 processState 恒为 `exited`(实测真实 view:`semanticState=idle / processState=exited / host=alive`)→ 不可达;③ 新增 `notice` 字段 + badge:设计面过大;④ 只写诊断不写 summary:issue 要的是行级可见提示 | A12, A15 |
77
- | D12 | G3 的触发与节流:放在 `service.reconcile()` 的**独立 pre-pass**(不受 `looksActive` 约束),只对 `row.host.state ∈ {exited, failed}` 且 `!row.hostAlive` 的行探测;按 `(viewId, host.endedAt)` 记忆,`host.endedAt` 变化才重探;summary 已含提示前缀则跳过写入 | ① 放进 `reconcile` 既有循环内:被 `looksActive` 拦住(R1-G1);② 放进 sweeper:每个宿主 pi 每 60s 做一次全量行扫描 + 探测,职责外溢且多进程重复;③ 每 tick 无记忆探测:dashboard 的 reconcile 间隔约 700ms,会放大 IO | A12 |
78
- | D10 | 工作流根目录解析:`AGENT_BOARD_WORKFLOW_HOME`(显式覆盖)→ `PI_CODING_AGENT_DIR/workflows`(`~` 展开)→ `~/.pi/workflows` | 只用默认路径:自定义 agent dir 的用户保护失效 | A6, A16 |
79
- | D11 | 纯扫描一次、复查停止前再扫一次(每次 sweep 最多 `#stops + 1` 次扫描;`#stops` 实测 0-2) | 只为省 IO 引入自建缓存/增量:与 D9 相比收益小、失效模式多 | A8, A9 |
80
-
81
- ---
82
-
83
- ## 4. 数据流
84
-
85
- ```
86
- service.pruneWarmHosts(pruneOpts) [src/runtime/service.mjs:915]
87
- 1. dry = selectIdleHostsToEvict(listRows(root), {ttl,maxWarm,grace,keepViewId}) // 纯,无保护
88
- 2. dry 两列表都空 → return // 无 IO,保持今天的快路径
89
- 3. scan = scanBackgroundRuns({ workflowHome, minMtimeMs }) // IO,一次;scan.ok === false 仅表示覆盖不完整,不取消保护:已读 head 仍参与选择(run 可读 → 该宿主受保护),读不到的 run 无证据、照旧淘汰;覆盖不完整在停前 fresh 探测失败时经 warm_prune_probe_failed 报给用户
90
- 4. isProtected = (row) => classifyRunsForRow(scan.heads, { sessionFile: row.meta.sessionFile, hostStartedAt: row.host?.startedAt }).state === "protecting" // 纯
91
- 5. {ttlEvicted, excessEvicted} = selectIdleHostsToEvict(..., {isProtected}) // 纯
92
- 6. for viewId in [...ttl, ...excess]:
93
- row = loadRow(root, viewId) // 新鲜快照
94
- re-verify: hostAlive / isAgentBusy / attachedClients / grace // 既有
95
- fresh = probeBackgroundRun({ sessionFile, hostStartedAt, workflowHome }) // 复查,IO
96
- if fresh.running → appendDiagnosticOnce(warm_prune_during_background_run) + continue // D8
97
- if !fresh.ok → appendDiagnosticOnce(warm_prune_probe_failed) → 继续(fail-open)
98
- stopHostRow(row, "warm_prune")
99
-
100
- service.reconcile() [src/runtime/service.mjs:2269]
101
- 0. 新增 pre-pass(在 looksActive 门禁之前,独立执行):
102
- for row of listRows(root):
103
- if (!row.host || row.hostAlive) continue
104
- if (row.host.state !== "exited" && row.host.state !== "failed") continue
105
- key = `${row.meta.id}:${row.host.endedAt ?? 0}`; if (noticeProbeMemo.has(key)) continue
106
- res = probeBackgroundRun({ sessionFile: row.meta.sessionFile, hostStartedAt: row.host.startedAt, workflowHome })
107
- if (!res.ok) continue // 探针失败不记 memo:下一 tick 重试(D12)
108
- if (res.paused || (res.running == null && res.paused == null)) // 仅结论明确时记忆(paused 或 running/paused 皆空)
109
- noticeProbeMemo.add(key) // host.endedAt 变化才重探
110
- if (!res.paused) continue // running 非空 → 不记 memo,下一 tick 重探(running 可能翻成 paused)
111
- paused = res.paused
112
- summary = `Background workflow ${paused.runId} was interrupted — resume it from this session`
113
- if (!row.state?.summary?.startsWith("Background workflow "))
114
- sendLifecycleCommand({kind:"patch_fields", source:"service", payload:{state:{summary}}}) // coordinator_disabled → writeState 回退(现有逃生门)
115
- appendDiagnosticOnce(code:"background_run_interrupted", level:"warn", details:{runId, hostState, exitCode}, {match:["runId"]})
116
- 1..n. 既有循环(looksActive 门禁与 host 消失分支**保持不变**,本 issue 不改其语义)
117
- ```
118
-
119
- 时序要点(复核视角②):判定所需的 `row.host.startedAt` 与 `row.meta.sessionFile` 都在 `loadRow` 快照里(`src/core/types.mjs:139`);`run.updatedAt` 在探测时刻从磁盘读;两者都在**触发时刻**可得,无"上一轮值"依赖。
120
-
121
- `minMtimeMs` 计算:`Math.min(...candidates.map(r => r.host?.startedAt)) - CLOCK_EPSILON_MS`;任一候选行缺失 `host.startedAt` 时禁用预筛(实现传 `0 - CLOCK_EPSILON_MS`,对真实 mtime 等价),`CLOCK_EPSILON_MS = 2000`(FS mtime 粒度容差)。
122
-
123
- ---
124
-
125
- ## 5. 组件契约
126
-
127
- ### 5.1 新模块 `src/core/background-runs.mjs`
128
-
129
- 模块 = 决策核心 + 一层薄 IO shell(`scanBackgroundRuns`/`probeBackgroundRun`,见下;模块头已相应更新)。
130
-
131
- 纯函数(无 IO,直接单测):
132
-
133
- | 导出 | 签名 | 语义 |
134
- |---|---|---|
135
- | `resolveWorkflowHome({ env, home })` | `(o?) => string` | 按 D10 解析,`~` 展开;纯(不读 `process.env` 之外的隐式状态,参数可注入) |
136
- | `parseRunHead(raw)` | `(any) => RunHead \| null` | v2(`format === "pi-workflow-run-v2"` → `index`)与 legacy flat(顶层字段,`parentSessionFile` 视作 null);结构不合法返回 `null`,**不抛** |
137
- | `isSameSessionFile(a, b, { platform })` | `(string, string, o?) => boolean` | 按 `platform`(默认 `process.platform`,**可注入**)选 `path.win32` / `path.posix` 归一;win32 下大小写不敏感。平台注入是必需的:CI 只跑 Linux(`.github/workflows/ci.yml:14`),否则 win32 分支在 CI 零覆盖 |
138
- | `classifyRunForRow(run, { sessionFile, hostStartedAt })` | `=> "protecting" \| "paused" \| "irrelevant"` | D1/D2 |
139
- | `classifyRunsForRow(runs, { sessionFile, hostStartedAt })` | `=> { state, run }` | 遍历 runs,取"最强"结论(protecting > paused > irrelevant);`run` 为产生结论的 head(`irrelevant` 时为 null) |
140
-
141
- `RunHead`(内部结构,非导出契约):
142
-
143
- ```js
144
- { path, mtimeMs, runId, status, parentSessionFile, updatedAtMs, startedAtMs, hasCheckpoint }
145
- ```
146
-
147
- IO shell(可用 temp dir 直测):
148
-
149
- | 导出 | 签名 | 语义 |
150
- |---|---|---|
151
- | `scanBackgroundRuns({ workflowHome, minMtimeMs })` | `=> { ok, heads, error }` | 遍历 `<home>/projects/*/runs/`,只收 `*.json`(排除 `.bak`、`.events.jsonl`、`.log`、`.result-*`);`stat.mtimeMs < minMtimeMs` 跳过。`ok === (error === null)`:目录不可读 / runs 目录不可读 / 文件不可读 / 坏 JSON → 记 `error` 并 `ok:false`(已读到的 head 仍返回并继续参与保护判定;`ok:false` 仅表示覆盖不完整);store 缺失 → `ok:true, heads:[]`。`workflowHome` 先 `stat`:缺失 → 视为无 store(`ok:true`);存在但不是目录 → `ok:false` |
152
- | `probeBackgroundRun({ sessionFile, hostStartedAt, workflowHome })` | `=> { ok, running: RunHead\|null, paused: RunHead\|null, error }` | 同一份 scan 上**独立**计算 `running` 与 `paused`(同一会话可同时持有一个旧 paused run 与当前 running run);`paused` 取 `updatedAtMs` 最大者(并列按 `path` 排序);`ok:false` 仅表示读失败(D6) |
153
-
154
- ### 5.2 `src/core/warm-host-sweeper.mjs`
155
-
156
- ```diff
157
- -export function selectIdleHostsToEvict(rows, { now, maxWarm, ttlMs, graceMs = 0, keepViewId = null })
158
- +export function selectIdleHostsToEvict(rows, { now, maxWarm, ttlMs, graceMs = 0, keepViewId = null, isProtected = null })
159
- ```
160
-
161
- 在候选循环中,`isAgentBusy` 之后加:
162
-
163
- ```js
164
- if (isProtected && isProtected(row)) continue; // 后台 run 保护(issue #230)
165
- ```
166
-
167
- 默认 `null` → 既有 21 个单测(`rg -c '^test\(' test/warm-host-sweeper.test.mjs` = 21)与 `canAutoDrain`/`isAgentBusy` 语义零改动。
168
-
169
- ### 5.3 `src/runtime/service.mjs`
170
-
171
- - `createService(opts)` 新增注入项(测试用,遵循既有 `opts.X ?? default` 约定):
172
- - `opts.backgroundRuns`(默认 `{ scanBackgroundRuns, probeBackgroundRun }`)
173
- - `opts.workflowHome`(默认 `resolveWorkflowHome()`,每次 service 例化时解析一次)
174
- - `pruneWarmHosts`:按 §4 的第 1-6 步重写;新增 env `AGENT_BOARD_NO_BACKGROUND_RUN_GUARD`(D7)。
175
- - `reconcile`:新增 §4 的第 0 步 pre-pass(G3/D12);既有 `looksActive` 与宿主消失分支不动。
176
- - `src/core/state-commands.mjs:113` `PATCHABLE_FIELDS["service"].state` 增加 `"summary"`(附注释说明用途),并同步更新 `test/state-commands.test.mjs:118` 的白名单断言。这是本 issue 唯一触及协调器契约的改动(见 §8 保证边界 #8)。
177
- - `coordinator_disabled` 回退:pre-pass 的 summary 写入是该逃生门下的**第 4 处** `writeState` 直写(`summaryDirect`)。`test/architecture-writer-boundary.test.mjs:39` 的豁免理由字符串写死了「三处」,需同步改为「四处(host 探测终态 / project 模式 / runner-exited / paused-run summary notice)」,否则豁免说明会漂移,后续 CR 会把第 4 处误判为未登记回归。
178
-
179
- ### 5.4 诊断契约(对外可观察)
180
-
181
- | code | level | 何时 | details | `opts.match` |
182
- |---|---|---|---|---|
183
- | `warm_prune_probe_failed` | warn | 探测失败且发生了候选淘汰(D6) | `{ error }` | `["error"]`(`error` 先归一成字符串) |
184
- | `warm_prune_during_background_run` | warn | 复查发现 running run 仍要停(D8) | `{ runId, hostStartedAt }` | `["runId"]` |
185
- | `background_run_interrupted` | warn | reconcile pre-pass 发现 paused run(D9/D12) | `{ runId, hostState, exitCode }` | `["runId"]` |
186
-
187
- `appendDiagnosticOnce` 的去重**不是默认行为**:`src/core/diagnostics.mjs:29-46` 只有显式传 `opts.match`(detail 键数组,值为原始类型)才按 code + 这些键去重;`match: []` 永不 dedupe。三条都必须传 `match`,否则 60s sweep / 700ms reconcile 会刷爆 `diagnostics.jsonl`。
188
-
189
- ---
190
-
191
- ## 6. 可测性拆分设计(实现阶段的硬约束)
192
-
193
- | 逻辑 | 拆分方式 | 测试边界 |
194
- |---|---|---|
195
- | 记录解析(v2 / legacy / 垃圾) | 纯函数 `parseRunHead` | 表驱动单测,输入 JSON 字符串,零文件系统 |
196
- | 匹配与保护判定 | 纯函数 `classifyRunForRow` / `classifyRunsForRow` | 表驱动单测:sessionFile 匹配、`updatedAt` vs `startedAt`、paused、path 归一 |
197
- | 工作流根目录解析 | 纯函数 `resolveWorkflowHome({env,home})` | 单测注入 `env`/`home`,不碰真实 homedir |
198
- | 目录枚举/预筛/坏文件容错 | IO shell `scanBackgroundRuns` | `mkdtempSync` fixture 造 `projects/*/runs/`,断言 heads 与 `ok` |
199
- | 淘汰纯判定 | `selectIdleHostsToEvict` + 注入谓词 | 既有文件追加用例;fake 谓词,零 IO |
200
- | 端到端淘汰/复查/竞态/逃生门 | service + 注入 `backgroundRuns` + temp `workflowHome` | 复用 `test/warm-host-sweep.integration.test.mjs` 的 net-server 捕获 terminate 模式 |
201
- | reconcile 提示文案 | service + 注入 probe | 断言 `reconcile_finalize` payload / 直写 summary + diagnostics |
202
-
203
- 禁止在实现阶段把上述纯函数折叠回 service(否则 §8 的 A 项无法保持 unit 级)。
204
-
205
- ---
206
-
207
- ## 7. 验收矩阵
208
-
209
- 自动化命令在仓库根执行。既有基线:`npm run verify`(typecheck + perf gate + `node --test test/*.test.mjs` + c8 门槛 + pack:dry)。
210
-
211
- | ID | 功能点 | 验收方式 | 具体验证 | 通过标准 |
212
- |---|---|---|---|---|
213
- | A1 | v2 head 解析;legacy flat 无 `parentSessionFile`;垃圾输入不抛 | 自动化(unit) | `node --test test/background-runs.test.mjs` | 三类输入返回预期结构 / `null` |
214
- | A2 | running + 同会话 + `updatedAt ≥ host.startedAt` → `protecting` | 自动化(unit) | 同上 | `classifyRunForRow` 返回 `protecting` |
215
- | A3 | 陈旧 running(`updatedAt < host.startedAt`)→ 不保护;`host.startedAt` 缺失 → 保护 | 自动化(unit) | 同上 | 前者 `irrelevant`,后者 `protecting` |
216
- | A4 | `paused` / `completed` / `failed` / `aborted` / `pending` 均不保护;`paused` 被单独分类 | 自动化(unit) | 同上 | 非 `protecting`,`paused` 返回 `paused` |
217
- | A5 | path 归一:win32 大小写/分隔符差异判同;不同文件判异 | 自动化(unit) | 同上 | 断言 `isSameSessionFile` 双向 |
218
- | A6 | 目录枚举:跳过 `.bak`/`.events.jsonl`/`.log`;mtime 预筛生效;坏 JSON 不抛;目录缺失 `ok:true` | 自动化(unit + fs fixture) | 同上 | 断言 heads 数量与 `ok` |
219
- | A7 | `selectIdleHostsToEvict` 尊重 `isProtected`:TTL 与 maxWarm 两分支都跳过;不传谓词时行为与今天一致 | 自动化(unit) | `node --test test/warm-host-sweeper.test.mjs` | 既有用例全绿 + 新用例 |
220
- | A8 | 端到端:running run 的 host 不收 terminate;无 run 的 host 收 terminate | 自动化(integration) | `node --test test/warm-host-sweep.integration.test.mjs` | socket 上只出现后者 |
221
- | A9 | 复查竞态:选择后新出现的 run(注入 probe 首次空、二次命中)→ 不 terminate | 自动化(integration) | 同上 | 无 terminate,且有 `warm_prune_during_background_run` 诊断 |
222
- | A10 | 探测失败 fail-open + 去重 warn 诊断 | 自动化(integration) | 同上 | 仍 terminate;`diagnostics.jsonl` 恰一条 `warm_prune_probe_failed`(连续两次 sweep) |
223
- | A11 | `AGENT_BOARD_NO_BACKGROUND_RUN_GUARD=1` 回到今天行为 | 自动化(integration) | 同上 | 有 running run 也 terminate |
224
- | A12 | reconcile pre-pass:**生产真实态**(`semanticState:"idle"`——**不用 `completed`**:`completed`/`holding` 且 `autoState==null` 命中 `isManualVerdict`(`src/core/auto-state.mjs:202-204`)会被 `manual_fence` 拒;`processState:"exited"`、`currentRunId:null`、`host.state:"exited"`、`hostAlive:false`)+ paused run → 行 summary 被 patch 为含 runId 的提示;连续 reconcile 不重复写。手工判决行(completed/holding + autoState==null)不在本项覆盖范围,按 §8#8 跳过 | 自动化(unit/service) | `node --test test/background-run-notice.test.mjs` | summary 断言(`patch_fields` payload 或 coordinator_disabled 直写)+ 诊断计数 = 1 |
225
- | A15 | 协调器契约:`PATCHABLE_FIELDS["service"].state` 含 `summary`,非白名单字段仍被拒 `field_not_allowed`;服务侧仍无新增 `writeState` import | 自动化(unit + static) | `node --test test/state-commands.test.mjs` + `node --test test/architecture-writer-boundary.test.mjs` | 更新断言后全绿 |
226
- | A16 | `resolveWorkflowHome` 优先级与 `~` 展开:`AGENT_BOARD_WORKFLOW_HOME` > `PI_CODING_AGENT_DIR/workflows` > `~/.pi/workflows` | 自动化(unit) | `node --test test/background-runs.test.mjs` | 三种 env 组合 + `~dir` 注入 home 的断言全绿 |
227
- | A13 | 类型与静态约束(新模块 JSDoc、无新 `writeState` import) | 自动化(static) | `npm run typecheck` + `node --test test/architecture-writer-boundary.test.mjs` | 全绿 |
228
- | A14 | 全仓回归 + 覆盖率门槛 | 自动化 | `npm run verify` | 退出码 0(lines 85 / funcs 80 / branches 70) |
229
- | U1 | 真实长 step(> TTL)运行中宿主不被驱逐 | 用户实测 | 见 §9 U1 | 观察窗内 host `state` 保持 `alive`、run 事件无 `skipped/interrupted` |
230
- | U2 | 驱逐/宿主真死后,行摘要有 paused 提示且 resume 成功 | 用户实测 | 见 §9 U2 | summary 含 runId,resume 后 run 完成 |
231
-
232
- 失败分支覆盖:A3(陈旧记录)、A4(非 running)、A6(坏 JSON / 目录缺失)、A10(探测失败)、A11(逃生门)均有独立验收项。
233
-
234
- ---
235
-
236
- ## 8. 保证边界(明确做不到的事)
237
-
238
- 1. **依赖他人磁盘格式**:保护依赖 `pi-workflow-run-v2` 的 `index` 字段。上游改格式后保护静默失效(fail-open + 诊断),**不会**崩溃、也**不会**让回收停摆。
239
- 2. **竞态窗口仍在**:最后一次复查与 `requestHostStop` 之间(毫秒级)启动的 run 仍会被杀——这类 run 在复查时还不存在,D8 探不到,唯一痕迹是事后 reconcile 的 `background_run_interrupted`(D12/G3);run 依旧变 paused(需人工 resume)。
240
- 3. **保护是"运行期"且无上限的**:受保护 host 完全豁免 TTL 与 maxWarm、不计入 idle 池 → 保护期间 warm 池可随 running 后台 run 的数量**无界增长**(不是"最多 4 个")。实际约束只有两条:D1 的 `updatedAt ≥ host.startedAt`(防陈旧记录永久豁免)与 run 结束后下一轮 sweep(≤60s)恢复回收。极端情况下(多个长 run 同时挂着)只能靠人工停止宿主。
241
- 4. **只有非 child 宿主会执行该逻辑**(`isHostedChild` / `AGENT_BOARD_NO_SWEEP=1` 仍是 no-op),与既有 sweeper 的边界一致。
242
- 5. **G3 提示依赖 reconcile 被跑到**:用户不打开 dashboard(无 service 实例跑 reconcile)时,paused 提示不会生成;run 仍在磁盘上可 resume。
243
- 6. **不识别 legacy 记录**:v2 之前的 run record 没有 `parentSessionFile`,一律按"无关"处理(不会误保护)。
244
- 7. **只看 head 记录**:不解析 `.events.jsonl`,因此不判断"run 是否卡死";卡死但 status=running 的 run 会持续保护其宿主(与"宁可少回收,不可再杀工作流"的取向一致)。
245
- 8. **手工判决的行不写提示**:协调器对非 `dashboard-user` 源在 `isManualVerdict(currentState)` 时 `reject("manual_fence")`(`state-commands.mjs:284`)→ 用户手工 completed/holding 的行拿不到 paused 提示;`warn` 诊断仍会写。
246
- 9. **G3 提示由 dashboard 侧驱动**:只有 service 实例(打开 dashboard / footer 状态刷新)会跑 `reconcile()`;不开 dashboard 时提示不写,但 paused run 仍在磁盘上可 resume。
247
-
248
- ---
249
-
250
- ## 9. 部署 / 用户实测步骤(U1、U2)
251
-
252
- **部署前置(必做,否则验的是旧代码)**:本机 board 扩展从 git 安装的 clone 加载(`~/.pi/agent/settings.json` → `git:github.com/zhuxixi/pi-agent-board`,实际路径 `~/.pi/agent/git/github.com/zhuxixi/pi-agent-board`),**不是**本工作副本。
253
-
254
- 1. 在 clone 内 `git fetch origin && git checkout <PR 分支>`(合并后 `git pull`)。
255
- 2. 重启运行 board 的 pi 进程(dashboard host)与所有 warm host(旧 host 仍跑旧代码):`/agent-board` 中先逐行 `k`/停止宿主,或等 TTL 回收后再起。
256
- 3. 压缩时间线(可选,在启动 pi 前设):`AGENT_BOARD_WARM_HOST_TTL_MS=60000`、`AGENT_BOARD_SWEEP_INTERVAL_MS=15000`、`AGENT_BOARD_MAX_WARM_HOSTS=1`。
257
-
258
- **U1(保护生效)**
259
-
260
- 1. dispatch 一个新会话,prompt 让它 `workflow({background:true})` 跑一个单个 agent step ≥ 3 min 的 run(与大 diff 的 code review 等价)。
261
- 2. 关闭 attach 窗口,等 turn 结束(行 `idle`)。
262
- 3. 等待 ≥ 2×TTL(默认 10 min ×2;压缩后 2 min)。
263
- 4. 观察:`<root>/views/<id>/host.json` 的 `state` 仍为 `alive`;run 事件无 `skipped/interrupted`;`<workflowHome>/projects/*/runs/<runId>.json` 的 `status` 仍为 `running`。
264
- 通过标准:以上三条同时成立。
265
-
266
- **U2(痕迹 + resume)**
267
-
268
- 1. 重复 U1 步骤 1-2,这次**故意**让驱逐发生:`AGENT_BOARD_NO_BACKGROUND_RUN_GUARD=1` 重启 pi 后跑同样的 run,等待被驱逐。
269
- 2. 打开 dashboard:目标行的 summary 应含 `Background workflow <runId> was interrupted — resume it from this session`;证据面板(`!`/diagnostics)应含 `background_run_interrupted`(或驱逐时刻的 `warm_prune_during_background_run`)。
270
- 3. attach 该会话并 resume 该 run。
271
- 注:提示由 dashboard 侧的 `reconcile()` pre-pass 写出(每次宿主 `endedAt` 变化探测一次),打开 dashboard 后等一个 tick(<1s)再读行摘要;若仍未变,重开 dashboard 触发新 service 实例。
272
- 通过标准:摘要文案出现且 resume 后 run 跑到 `completed`。
273
-
274
- **回滚**:`AGENT_BOARD_NO_BACKGROUND_RUN_GUARD=1`(无需改代码);或回退版本。
275
-
276
- ---
277
-
278
- ## 10. 命名
279
-
280
- - 模块:`src/core/background-runs.mjs`(kebab-case,与 `warm-host-sweeper.mjs` 对齐)
281
- - 测试:`test/background-runs.test.mjs`(unit)、`test/background-run-notice.test.mjs`(service 级 reconcile 提示);淘汰相关用例追加进既有 `test/warm-host-sweeper.test.mjs` / `test/warm-host-sweep.integration.test.mjs`
282
- - env:`AGENT_BOARD_NO_BACKGROUND_RUN_GUARD`、`AGENT_BOARD_WORKFLOW_HOME`
283
- - commit:`fix(host): keep warm hosts alive while a background workflow run is active (issue #230)`