@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,380 +0,0 @@
1
- # Issue #255 — View State Coordinator endpoint zombie
2
-
3
- Status: draft (round 4 revised after cross-review rounds 1–3; cross-review **converged** — no true gaps)
4
- Repo: zhuxixi/pi-agent-board · issue #255 · base `79debb6` (v0.13.0)
5
- Drafted: deepseek-account/deepseek-flash (selected) · deepseek-account/deepseek-flash (physical) @ 2026-10-28T19:36:26+08:00
6
- Revised: deepseek-account/deepseek-flash (selected) · deepseek-account/deepseek-flash (physical) @ 2026-10-28T19:52:00+08:00 — merged recon rounds 2–3
7
- Revised: deepseek-account/deepseek-flash (selected) · deepseek-account/deepseek-flash (physical) @ 2026-10-28T21:12:00+08:00 — round-1 cross-review: accepted R1-G1..G5 + R1-D1..D6 (all)
8
- Revised: deepseek-account/deepseek-flash (selected) · deepseek-account/deepseek-flash (physical) @ 2026-10-28T21:20:00+08:00 — round-2 cross-review: accepted R2-G1/G2 + R2-D1..D4; re-ordered R1/R2 as contract-restoration vs primary self-heal after the R2-D3 physical critique
9
- Revised: deepseek-account/deepseek-flash (selected) · deepseek-account/deepseek-flash (physical) @ 2026-10-28T21:28:00+08:00 — round-3 cross-review: accepted R3-G1 + R3-D1..D7 (all)
10
- Revised: deepseek-account/deepseek-flash (selected) · deepseek-account/deepseek-flash (physical) @ 2026-10-28T21:36:00+08:00 — round-4 convergence: R4-D1 (A6 command pattern) + R4-D2 (status metadata)
11
-
12
- ## 1. Problem
13
-
14
- A live View State Coordinator can end up serving an endpoint that no longer exists on
15
- disk. It keeps the `<_coordinator>` lease and keeps heartbeating, so `ensureCoordinator()`
16
- treats it as healthy-busy, but every client connect fails with ENOENT and the caller
17
- reports `state_command_not_applied … coordinator_unavailable`. Rows freeze at their last
18
- materialized state with no user-visible error. Observed on macOS/APFS; manual recovery
19
- required `kill <pid>`.
20
-
21
- ## 2. Goals / constraints
22
-
23
- - G1 A deposed coordinator must never be able to delete an endpoint it no longer owns
24
- (restore #70's owner-token half of the cleanup contract).
25
- - G2 A lease holder whose endpoint vanished or was replaced must stop claiming to serve
26
- and must step down, so the next `ensureCoordinator()` can spawn a replacement. This is
27
- the **primary** guarantee: it heals socket loss from *any* cause, including causes not
28
- yet identified (§3.2). POSIX-only (see §4.3).
29
- - G3 No new background timers, no new protocol messages, no diagnostics that can recreate
30
- a deleted board root beyond the residue #241 already accepts, and no wire change.
31
- - G4 Correct existing behaviour stays: SIGTERM on a healthy owner still unlinks its own
32
- endpoint (new `#255 A6`), a forced-touch-failure step-down still unlinks (A14), root-gone
33
- step-down still writes no diagnostic.
34
- - G5 The cleanup fix restores an **existing** repo contract rather than inventing one:
35
- #70's `cleanupOwnedSocket()` ("dev/ino match **and** owner token match, else no-op",
36
- `docs/superpowers/specs/2026-09-05-pty-host-stacking-sock-race-design.md:418`).
37
-
38
- ## 3. Root cause
39
-
40
- ### 3.1 Confirmed by source (79debb6)
41
-
42
- Facts, reconstructed from `runner/state-coordinator.mjs` (`:298-303`, `:344-421`),
43
- `src/core/locks.mjs` (`:302-332`, `:372-419`, `:484-519`), `src/core/host-coordination.mjs`
44
- (`:160-162`) and `src/core/coordinator-client.mjs` (`:488-527`), cross-checked against the
45
- issue's `views/_coordinator/diagnostics.jsonl` timeline:
46
-
47
- 1. **A stale reclaim can happen while the old owner is still alive.** `reclaimOrBlock` can
48
- return `reclaim` for a declared lease whose heartbeat file reads as missing:
49
- `classifyLeaseOwner` falls back to `heartbeatAgeMs ?? lockAgeMs` and reclaims at
50
- `>= max(60 s, 3×heartbeatMs)` (locks.mjs:318-320, issue #224 D3). The old owner's lock
51
- directory is renamed to quarantine, so its `touch()` immediately fails
52
- (`stillOwns()` can no longer read its `owner.json`, locks.mjs:487-508).
53
- 2. **The old owner stays up for 3 more seconds.** `heartbeatFailureAction` only steps down
54
- after `MAX_CONSECUTIVE_TOUCH_FAILURES = 3` supervised failures at 1 s cadence
55
- (locks.mjs:155, 472). Deterministic: the observed 3.001 s is D4's designed behaviour.
56
- 3. **The successor boots and binds its own socket at the same path** (state-coordinator.mjs:298-303
57
- unlinks the leftover first; libuv's `uv_pipe_bind` additionally pre-unlinks an existing path).
58
- 4. **The deposed owner's `shutdown()` deletes the successor's socket through libuv.** The old
59
- code calls `server.close()` on exit; libuv unlinks the unix-socket path captured at
60
- `bind()` time, unconditionally (`nodejs/node#19729`: "if your old process calls
61
- `server.close()` afterwards, it will unlink your *new* socket"). The deposed owner's dev+ino
62
- comparison never enters into it — the deletion happens inside libuv. **This is the root
63
- cause**, and it explains the evidence that looked like inode reuse: `lsof` shows the
64
- successor listening on an inode that `ls` no longer shows.
65
- 5. **Nothing heals it.** `ensureCoordinator` probes the missing path (null) → spawns one
66
- replacement → the replacement loses the lease race to the zombie (fresh heartbeat ⇒
67
- `busy`) → writes `coordinator_lease_unavailable` → exits 0 → the ~10 s poll window
68
- expires → `coordinator_unavailable`. `launchCoordinator` never re-spawns, and the
69
- client has no "endpoint unreachable while the lease looks healthy" shape detector.
70
-
71
- Two structural gaps (confirmed): inside `shutdown()`, `lease.release()` is token-fenced but
72
- the endpoint cleanup is not; and the cleanup leans on `server.close()`, whose libuv side
73
- effect deletes whatever occupies the bind path. One guarded resource, one unguarded, one
74
- implicit and unobservable from the project code.
75
-
76
- ### 3.2 Inferred vs unknown
77
-
78
- - **Root cause: libuv's close-time unlink (`nodejs/node#19729`).** A coordinator that closes
79
- its listener deletes whatever currently occupies its bind path — including a successor's
80
- endpoint. No inode reuse is needed, and the earlier inode-reuse hypothesis is superseded: it
81
- is physically implausible (round-2 review: the deposed owner's listener fd keeps the old
82
- inode alive, so the successor's `bind()` cannot reuse it). The Linux CI run of the new A3/A5
83
- cases confirmed the libuv mechanism — both failed with the endpoint gone while the code
84
- under test had already gated its own explicit unlink.
85
- - **The two reclaims in the issue timeline have different (and partly mundane) readings:**
86
- - `11:10:22, heartbeatAgeMs: 338, lockAgeMs: 346, eligibility: "declared"` — the declared
87
- heartbeat branch cannot fire at 338 ms (`>= 60_000` required), but `eligibility` only
88
- records the *record shape*, not which branch fired. The most mundane reading is the
89
- **normal D1 crash reclaim**: the previous holder stopped touching ~8 ms before its lock
90
- was inspected and `isProcessDead(pid)` was true (locks.mjs:325-327), which reclaims
91
- regardless of heartbeat age. Treat "a healthy holder was reclaimed" as open until the
92
- raw JSONL + pid-liveness evidence is available.
93
- - `11:20:40, heartbeatAgeMs: null, eligibility: "declared", lockAge >= 60 s` — fully
94
- explained by D3's declared fallback to lock age (a live holder whose heartbeat read
95
- failed). Candidate mechanism (unproven, untested): `makeLease.touch()` overwrites the
96
- heartbeat with a plain in-place `fs.writeFileSync` (locks.mjs:503, no temp+rename), so a
97
- concurrent POSIX reader can observe a truncated file → `JSON.parse` throws →
98
- `readLeaseHeartbeat` → null. Reclaim-audit input (R4), not a claimed trigger.
99
- - #104 introduced the coordinator socket cleanup in one commit (`b022a7b`, 2026-09-09), never
100
- touched since (`git blame` all `b022a7b`); #224 never considered endpoint self-checks (not
101
- rejected — out of scope); #241 only added another reason to enter `shutdown()` (it guards
102
- the root directory inode, which an inner-file unlink never changes); #239 proved the
103
- client cannot infer liveness from a failed probe.
104
-
105
- ## 4. Fix design
106
-
107
- ### 4.1 Decision table
108
-
109
- | # | Candidate | Verdict | Why |
110
- | --- | --- | --- | --- |
111
- | R2 | Supervise the bound endpoint in the existing 1 s heartbeat tick; step down when missing/replaced | **Do — primary self-heal** | Heals socket loss from any cause; reuses the #241 tick, no new timer; POSIX-only (§4.3) |
112
- | R1 | Stop the implicit libuv unlink (never call `server.close()`) **and** restore the #70 cleanup contract (unlink only when the lease is still ours **and** the path still carries the inode we bound) | **Do — root-cause fix** | Removes the deletion path that actually created the zombie (§3.1 step 4); restores an existing spec'd invariant |
113
- | R3 | Client kills a zombie lease owner before respawn | **No (non-goal)** | R2 caps the zombie window at one tick; #239 established `probe null ≠ dead`, so a client kill needs a boot-grace heuristic that would race the normal 1-3 s boot |
114
- | R4 | Audit reclaim eligibility (declared fallback, torn heartbeat read) | **No (follow-up issue)** | Needs the reporter's raw JSONL + pid liveness; changing #224 D3 is a separate contract decision |
115
- | R5 | Same guard in `runner/pty-runner.mjs` (ownedMain finishHost `:736-743` has dev+ino only; legacy `shutdown()` `:512` is unconditional; both close their listener → same libuv issue) | **No (follow-up issue)** | Different ownership model (per-view host record); own design + tests |
116
- | R6 | Re-derive dropped `agent_end` projections at reconcile | **No (follow-up issue)** | Orthogonal recovery gap |
117
-
118
- ### 4.2 R1 — stop the implicit unlink, restore the cleanup contract
119
-
120
- Two changes in `shutdown()`:
121
-
122
- 1. **Do not call `server.close()`.** libuv unlinks the bind path when the listening handle
123
- closes (`nodejs/node#19729`). The process exits immediately afterwards, and
124
- `process.exit()` skips the uv handle cleanup, so the implicit unlink never runs. Cleanup
125
- becomes explicit.
126
- 2. **Gate the explicit unlink** with `shouldUnlinkEndpoint`.
127
-
128
- Pure function, added next to `ownsEndpoint` in `src/core/host-coordination.mjs`:
129
-
130
- ```js
131
- /**
132
- * Whether this instance may unlink the endpoint path now.
133
- * Restores the #70 cleanup contract: the path must still be the inode this
134
- * instance bound AND this instance must still own the lease. `compromised`
135
- * lets the supervised tick veto its own cleanup after detecting that the
136
- * endpoint is gone/replaced (issue #255).
137
- */
138
- export function shouldUnlinkEndpoint({ stillOwner, compromised, bound, current }) {
139
- if (!stillOwner || compromised) return false;
140
- return ownsEndpoint(bound, current);
141
- }
142
- ```
143
-
144
- `state-coordinator.mjs` shell changes:
145
-
146
- - add `let endpointCompromised = false;` next to `boundSocketIdentity`;
147
- - **remove `try { server.close(); } catch {}`** from `shutdown()` (with the nodejs/node#19729
148
- comment), so no implicit libuv unlink can run;
149
- - before the explicit unlink:
150
- ```js
151
- const stillOwner = safeIsOwner(); // lease.isOwner(); false on any throw
152
- if (process.platform !== "win32" && stillOwner && !endpointCompromised && boundSocketIdentity) {
153
- try {
154
- const st = statSync(socketPath);
155
- if (shouldUnlinkEndpoint({ stillOwner, compromised: endpointCompromised,
156
- bound: boundSocketIdentity, current: { dev: st.dev, ino: st.ino } })) {
157
- try { unlinkSync(socketPath); } catch { /* best effort */ }
158
- }
159
- } catch { /* path gone — nothing to clean */ }
160
- }
161
- ```
162
- then `lease.release()` as today. `safeIsOwner` reads `lease.isOwner()` and returns false
163
- on any throw (fail-closed: skip the unlink; the next boot's *boot-time site* unlink cleans
164
- a leftover file).
165
-
166
- Behaviour matrix:
167
-
168
- | Scenario | stillOwner | compromised | unlink? |
169
- | --- | --- | --- | --- |
170
- | SIGTERM/SIGINT on healthy owner | true | false | yes (shutdown-time site) — new `#255 A6` pins it |
171
- | Forced-touch-failure step-down, no reclaim (A14) | true | false | yes — regression, **no test change needed** |
172
- | Step-down after the lease moved to a successor | false | n/a | no — R1 contract restoration (§4.2); not the reported trigger (§3.2) |
173
- | Tick detected missing/replaced endpoint (R2) | true or false | true | no — the primary self-heal path |
174
- | Root-gone step-down | true | false | stat ENOENT → no-op |
175
- | owner.json unreadable during shutdown | unknown → false | — | no (fail-closed) |
176
-
177
- ### 4.3 R2 — supervised endpoint health
178
-
179
- Two pure functions in `host-coordination.mjs`, with an explicit three-state observation
180
- (`R1-G1`: a bare string sentinel would make `ownsEndpoint` treat the sentinel as
181
- `"replaced"`), plus an exported classifier for the shell's stat→observation mapping
182
- (`R2-D2`: the "transient error never kills a healthy coordinator" guarantee needs a unit
183
- test, so the mapping must live in the pure module, not in an unexported shell function):
184
-
185
- ```js
186
- /**
187
- * Map one stat attempt to the supervised observation discriminator.
188
- * `errorCode === null` means the stat succeeded.
189
- * @param {{dev:number,ino:number}|null} stat
190
- * @param {string|null} errorCode
191
- * @returns {{kind:"ok",dev:number,ino:number}|{kind:"missing"}|{kind:"unknown"}}
192
- */
193
- export function socketIdentityObservation(stat, errorCode) {
194
- if (stat) return { kind: "ok", dev: stat.dev, ino: stat.ino };
195
- return errorCode === "ENOENT" ? { kind: "missing" } : { kind: "unknown" };
196
- }
197
-
198
- /**
199
- * Classify the supervised endpoint against the identity bound at boot.
200
- * @param {{dev:number,ino:number}|null} bound
201
- * @param {{kind:"ok",dev:number,ino:number}|{kind:"missing"}|{kind:"unknown"}|null} observed
202
- * @returns {"ok"|"missing"|"replaced"|"unknown"}
203
- */
204
- export function classifyEndpointHealth(bound, observed) {
205
- if (!bound || !observed || observed.kind === "unknown") return "unknown";
206
- if (observed.kind === "missing") return "missing";
207
- return ownsEndpoint(bound, observed) ? "ok" : "replaced";
208
- }
209
- ```
210
-
211
- Shell: in the existing `heartbeatTimer` tick (state-coordinator.mjs:344), **after**
212
- `rootVanishedOrReplaced` and **before** `lease.touch()`:
213
-
214
- ```js
215
- const observation = statSocketObservation(socketPath); // shell: statSync + error code
216
- const endpointState = classifyEndpointHealth(boundSocketIdentity, observation);
217
- if (endpointState === "missing" || endpointState === "replaced") {
218
- endpointCompromised = true; // veto cleanup in shutdown (R1)
219
- try {
220
- appendCoordinatorDiagnostic("coordinator_endpoint_lost",
221
- { state: endpointState }, "warn");
222
- } catch { /* a failing diagnostic must not block the step-down (#224 D4) */ }
223
- process.stderr.write(`state-coordinator: endpoint ${endpointState} (${socketPath}); stepping down\n`);
224
- shutdown(0);
225
- return;
226
- }
227
- ```
228
-
229
- with the shell helper `statSocketObservation(socketPath)` doing the single `statSync` and
230
- delegating the classification to `socketIdentityObservation(statOrNull, errCodeOrNull)`.
231
-
232
- Details that matter:
233
-
234
- - `unknown` never steps down (degrades to today's behaviour).
235
- - **The diagnostic is safe only to the same degree #241 accepts**: the root guard ran first
236
- with the boot identity, but `captureRootIdentity()` degrades to existence-only when ino is
237
- 0/unavailable, and a delete landing between the guard and the append can leave a directory
238
- skeleton (exactly the #241-accepted residue: data, not a process).
239
- - **R2 is POSIX-only.** `boundSocketIdentity` is captured only when
240
- `process.platform !== "win32"` (state-coordinator.mjs:333-339), so on win32 the classifier
241
- returns `unknown` forever. Deliberate degrade: Windows endpoints are named pipes with no
242
- unlink/inode semantics; equivalent pipe liveness belongs to #104/#70, not this issue.
243
- - **`boundSocketIdentity == null` also degrades R2 to a no-op on POSIX** (the existing
244
- listen-callback `statSync` failed; the code already says "cleanup degrades to no-op"). The
245
- tick then never steps down. To keep that from being silent, the same catch emits one
246
- stderr line (`state-coordinator: endpoint identity unavailable (…); self-supervision
247
- disabled`); A2 pins the pure-function behaviour (`null bound ⇒ unknown ⇒ never step down`).
248
- - Step-down, not rebind: keeps the path a pure shutdown; a successor binds on the next
249
- `ensureCoordinator()` call. Rebinding inside the tick would reopen the "who owns the path"
250
- ambiguity (§9 Q4).
251
-
252
- ### 4.4 End-to-end effect and its boundaries
253
-
254
- 1. Old owner A is stale-reclaimed while alive → its `touch()` fails deterministically.
255
- 2. Successor B boots: unlinks A's leftover socket (*boot-time site*) and binds. A still
256
- listens on the orphaned inode but every new client reaches B.
257
- 3. A's 3rd failed touch → `shutdown(0)`: `lease.isOwner()` is false → R1 suppresses the
258
- unlink. This closes one unguarded path; whether it is the path that produced the report
259
- is unknown (§3.2).
260
- 4. If the endpoint disappears or is replaced for **any** reason, B's next tick sees
261
- `missing`/`replaced` → R2 steps down with `coordinator_endpoint_lost` → the next
262
- `ensureCoordinator()` acquires the released lease → fresh coordinator binds. **The
263
- process vacates within ≤1 tick. The state_command that raced this window can still fail**
264
- (the replacement spawned by that call loses the lease race and exits 0;
265
- `launchCoordinator` never retries, so the caller waits out `ENSURE_WINDOW_MS` ≈ 10 s and
266
- reports `coordinator_unavailable`). Recovery lands on the next command (§7-2).
267
-
268
- **The fix does not retroactively heal.** A coordinator already in the zombie state at
269
- upgrade time still holds a fresh lease; the client probes ENOENT, spawns, loses the lease
270
- race, and the new R2 never gets a chance to run because the old (old-code) process is never
271
- asked. Such an install must `kill` the old coordinator once; from then on R2 self-heals.
272
-
273
- ## 5. Testability split (可测性拆分设计)
274
-
275
- | Unit under test | Layer | Why pure |
276
- | --- | --- | --- |
277
- | `shouldUnlinkEndpoint({stillOwner, compromised, bound, current})` | `src/core/host-coordination.mjs` (pure export) | plain data only, no fs |
278
- | `socketIdentityObservation(stat, errorCode)` | `src/core/host-coordination.mjs` (pure export) | maps one stat attempt; covers the EACCES/unknown branch that R2-D2 requires |
279
- | `classifyEndpointHealth(bound, observed)` | `src/core/host-coordination.mjs` (pure export) | plain data only |
280
- | `statSocketObservation(socketPath)` | `runner/state-coordinator.mjs` I/O shell (not exported) | one stat call; classification delegated to the pure mapper |
281
- | `safeIsOwner()` | `runner/state-coordinator.mjs` I/O shell | one lease read; fenced |
282
-
283
- Testing boundaries:
284
- - Pure functions: `test/host-coordination.test.mjs` — exhaustive truth tables, no spawn,
285
- every platform.
286
- - Tick + shutdown wiring: `test/state-coordinator.integration.test.mjs` — real spawned
287
- coordinator, temp root, POSIX-only assertions guarded by `process.platform !== "win32"`
288
- (same style as A14 at :1274). New case titles MUST be prefixed `#255 A<n>: …` so
289
- `--test-name-pattern` can select them.
290
- - **A3's discriminating fixture (the one that can falsify R1):** start A, wait ready, assert
291
- the endpoint file exists (guard against the #70 vacuous assertion), pin
292
- `AGENT_BOARD_TEST_COORDINATOR_HEARTBEAT_MS` explicitly (default 1000), then **delete A's
293
- lease directory** (simulating "the lease moved elsewhere / was quarantined") while leaving
294
- A's socket untouched. A's next three touches fail (`stillOwns()` false) and it exits 0
295
- after ~3 heartbeats. Two decidable assertions: (1) immediately after deleting the lease
296
- directory, assert the endpoint file still exists (anti-vacuous precondition); (2) after
297
- `waitForExit`, assert **it still exists**:
298
- - old code: no `stillOwner` gate, `ownsEndpoint(bound, current)` is true (A's own inode
299
- never changed) → unlinks → assertion fails ⇒ **red test**;
300
- - fixed code: `stillOwner` false → keeps the file ⇒ green.
301
- No inode reuse, no successor. A14 asserts the mirror case (socket gone when the lease was
302
- not revoked); together they pin the token condition.
303
- - **A5's replaced fixture must be atomic** (R2-G2): bind the stand-in on a temp path first,
304
- then `renameSync(tmp, socketPath)` — the deposed owner's old inode is still held by its own
305
- listener fd, so the replacement inode is guaranteed different and the tick can never
306
- observe an intermediate `missing`. **The temp path MUST live inside `<root>`** (same
307
- filesystem: `rename(2)` throws EXDEV across mounts), e.g. `<root>/.standin-<pid>.sock`.
308
- Pin the heartbeat knob and, before asserting, read `statSync(socketPath).ino` to prove the
309
- inode really changed.
310
- - **No CI-constructible test can exercise the hypothesised inode-reuse trigger itself**
311
- (APFS-only physics, and §3.2 doubts it). R2's missing/replaced paths are tested with a
312
- deleted endpoint and an atomically replaced stand-in; the trigger-cause investigation is
313
- U1 + R4.
314
- - Dual-owner handoff (optional smoke, not a gate): spawn A, delete A's lease, spawn B, assert
315
- B binds and answers a ping while A exits. Kept out of the acceptance gate because it
316
- cannot falsify R1 without inode reuse.
317
-
318
- ## 6. Acceptance matrix
319
-
320
- | ID | Functional point | Verification | Command / steps | Pass criteria |
321
- | --- | --- | --- | --- | --- |
322
- | A1 | `shouldUnlinkEndpoint` truth table | 自动化验证(unit) | `npm test` (host-coordination suite) / `node --test test/host-coordination.test.mjs` | 8 combinations (`stillOwner` × `compromised` × match) + 2 null-degenerate rows: only owner=true, compromised=false, match=true ⇒ true |
323
- | A2 | `socketIdentityObservation` + `classifyEndpointHealth` truth tables | 自动化验证(unit) | `node --test test/host-coordination.test.mjs` | observation: success ⇒ ok+dev/ino; ENOENT ⇒ missing; EACCES/ELOOP/anything else ⇒ unknown. classify: null bound ⇒ unknown; `{kind:"unknown"}` ⇒ unknown; `{kind:"missing"}` ⇒ missing; different dev/ino ⇒ replaced; equal ⇒ ok |
324
- | A3 | Deposed owner (lease gone, endpoint unchanged) keeps its own socket | 自动化验证(integration, POSIX) | `node --test --test-name-pattern="#255 A3" test/state-coordinator.integration.test.mjs` | pin heartbeat; delete A's lease dir; A exits 0; **A's socket file still exists** (assert present before exit; old code fails this) |
325
- | A4 | Missing endpoint ⇒ supervised step-down | 自动化验证(integration, POSIX) | `node --test --test-name-pattern="#255 A4" test/state-coordinator.integration.test.mjs` | unlink the endpoint under a ready A (heartbeat pinned); A exits 0 within bounded ticks; `coordinator_endpoint_lost` with `state:"missing"`; lease released |
326
- | A4b | A failing diagnostic write must not block the step-down | 自动化验证(integration, POSIX) | `node --test --test-name-pattern="#255 A4b" test/state-coordinator.integration.test.mjs` | `AGENT_BOARD_TEST_FAIL_DIAGNOSTIC=1` + unlink endpoint ⇒ still exits 0, lease released |
327
- | A5 | Replaced endpoint ⇒ step-down, replacement not unlinked | 自动化验证(integration, POSIX) | `node --test --test-name-pattern="#255 A5" test/state-coordinator.integration.test.mjs` | atomic rename of a stand-in bound **inside `<root>`** (same filesystem; heartbeat pinned); assert `statSync` ino changed; A exits 0; `state:"replaced"`; stand-in file still present; old code fails this. Note: the `compromised` veto checked by this case is defence in depth — a genuinely different inode is already refused by the dev+ino comparison, so this case pins R2's step-down + endpoint survival; the veto's own truth table is pinned by A1 |
328
- | A6 | Healthy SIGTERM still unlinks its own endpoint (regression) | 自动化验证(integration, POSIX) | `node --test --test-name-pattern="#224 A14|#255 A6" test/state-coordinator.integration.test.mjs` | A14/A14b unchanged and green; new `#255 A6`: SIGTERM ⇒ exit 0, endpoint absent after exit |
329
- | A7 | Root-gone step-down still writes no diagnostic/state | 自动化验证(integration, POSIX) | `node --test --test-name-pattern="A3|A4" test/coordinator-lifecycle.test.mjs` | existing #241 assertions unchanged, including A3's "root stays deleted after the self-exit" (:374) — the assertion that proves no diagnostic/state write; A3/A3b/A3c/A4 all match and run |
330
- | A8 | Dual-owner handoff smoke (B serves after A vacates) | 自动化验证(integration, POSIX, non-gating smoke) | `node --test --test-name-pattern="#255 A8" test/state-coordinator.integration.test.mjs` | B answers a ping after A exits; not a PR gate (cannot falsify R1 without inode reuse) |
331
- | A9 | Type contract | 自动化验证(static) | `npm run typecheck` | exit 0 |
332
- | A10 | No collateral breakage | 自动化验证(unit+integration) | `npm run test:coverage` | all pass; thresholds lines 85 / funcs 80 / branches 70 |
333
- | A11 | Perf gate unaffected | 自动化验证(build/perf) | `npm run test:perf` | passes |
334
- | A12 | Full pre-PR gate | 自动化验证(static+build+test) | `npm run verify` | exit 0 |
335
- | U1 | Socket-loss observation on the reporter's macOS box | 用户实测 | **前置**:merge 后 `git pull` 到含 fix 的 commit;`pkill -f runner/state-coordinator.mjs`(旧 detached 进程不随 Pi 重启退出);触发一次 ensure 让新代码 coordinator 成为租约主;确认 `lsof -p <新 pid>` 路径与 `ls` 一致。**执行**:24 h 观察;记录 `views/_coordinator/diagnostics.jsonl`;每次 endpoint 丢失时采集**谁 unlink 了它**的证据(`lsof`/`fs_usage`/外部清理脚本排查)。inode 复用不可按需制造。 | 24 h 内 `coordinator_unavailable` 次数 ≤ 基线(先记录基线);有 coordinator 时 `ls` 与 `lsof` 一致;**条件判据**:若 24 h 内发生 endpoint 丢失,须附至少一次归因证据;若未发生丢失,本条记 N/A,以前两条为准;若出现 `coordinator_endpoint_lost`,其后下一次操作应恢复 |
336
- | U2 | Zombie recovery without manual kill | 用户实测 | **前置**:同 U1(必须先 kill 旧 coordinator,否则测的是旧代码)。**执行**:对一个运行中的新代码 coordinator `rm <root>/coordinator.sock`;立即触发一次 dashboard 操作并等待其返回(允许一次 `coordinator_unavailable`);再触发第二次 | 第二次操作在本次 `ENSURE_WINDOW_MS`(10 s)内成功应用;`coordinator_endpoint_lost` 出现一次;全程无需 `kill` |
337
-
338
- ## 7. Non-goals (tracked separately)
339
-
340
- 1. **Identify the actual unlink cause / reclaim-eligibility audit** — declared fallback
341
- (#224 D3), torn heartbeat write, external unlink. Needs raw JSONL + pid liveness + FS
342
- evidence (§3.2).
343
- 2. **Accepted residual: the command that races the step-down can still fail once** (the
344
- single-shot spawn loses the lease race; §4.4). Recovery lands on the next command.
345
- 3. **Dual-writer window during stale reclaim** (≤3 s) — accepted residual.
346
- 4. **`pty-runner.mjs` endpoint cleanup** (ownedMain finishHost is dev+ino-only; the legacy
347
- shutdown path is unconditional) — different ownership model, own issue.
348
- 5. **Client-side zombie killer / boot-grace heuristic** — superseded by R2 here.
349
- 6. **Re-projection of `agent_end` events dropped during an outage.**
350
- 7. **R2 on win32** — no inode identity exists; named-pipe liveness is #104/#70 territory.
351
-
352
- ## 8. Risks / rollback
353
-
354
- | Risk | Mitigation |
355
- | --- | --- |
356
- | `lease.isOwner()` throws during shutdown | `safeIsOwner()` → false (fail-closed; next boot's *boot-time site* unlink cleans a leftover) |
357
- | Classifier reads a transient stat error as replaced | exported `socketIdentityObservation` maps only ENOENT → missing; A2 pins EACCES/ELOOP |
358
- | Diagnostic write throws in the tick | fenced `try/catch` (same shape as :390-392); A4b pins it |
359
- | Delete-window skeleton on the diagnostic append | same residue #241 already accepted (skeleton directory, never a process) |
360
- | A14 regression | run A14/A14b by name before the full suite; A14's lease is not revoked so `stillOwner` stays true |
361
- | Step-down loop | each step-down releases the lease; the successor binds before the next tick; `coordinator_endpoint_lost` makes a loop visible |
362
- | Rollback | revert two source files (`src/core/host-coordination.mjs`, `runner/state-coordinator.mjs`) + two test files (`test/host-coordination.test.mjs`, `test/state-coordinator.integration.test.mjs`); no persisted state or wire-format change |
363
-
364
- ## 9. Open questions for cross-review
365
-
366
- 1. Does A3 + A1 adequately pin R1 in CI given the §3.2 physical objection? (Current answer:
367
- they pin the *gate logic*; the trigger itself needs U1/R4.)
368
- 2. Diagnostic name `coordinator_endpoint_lost` — consistent with
369
- `coordinator_heartbeat_lost` / `coordinator_lease_unavailable`? Naming only.
370
- 3. Should A8 (handoff smoke) be kept, or is it noise without inode reuse?
371
- 4. Step-down vs rebind on endpoint loss — decision recorded in §4.3.
372
- 5. Is "delete the lease directory" the right fixture for A3, or should a second case go
373
- through the real reclaim path (#224 A10 pattern: fabricated stale `startedAt`)? The
374
- delete fixture is deterministic and tests R1's gate; the A10 pattern also exercises
375
- `lease_reclaimed`.
376
- 6. Given §3.2, is the issue's title/root-cause framing ("live lease holder serves an
377
- unlinked socket") still the right scope, or should this PR be split into "self-heal
378
- (R2)" + "contract restoration (R1)" + "cause investigation (R4)" as separate issues?
379
- Current plan keeps one PR because both changes are small and the acceptance matrix
380
- covers both; R4 is tracked as a follow-up.
@@ -1,197 +0,0 @@
1
- # Issue #257 — legacy pty-runner endpoint cleanup
2
-
3
- Status: draft (round 2 revised after cross-review round 1)
4
- Repo: zhuxixi/pi-agent-board · issue #257 · base `f885944` (#255 merged)
5
- Drafted: deepseek-account/deepseek-flash (selected) · deepseek-account/deepseek-flash (physical) @ 2026-10-28T23:12:00+08:00
6
- Revised: deepseek-account/deepseek-flash (selected) · deepseek-account/deepseek-flash (physical) @ 2026-10-28T23:35:00+08:00 — round-1 cross-review: accepted R1-G1/G2 + R1-D1..D8 (all)
7
-
8
- ## 1. Problem
9
-
10
- The legacy PTY-host runner (`legacyMain`, taken when a host config has no `instanceId`) can
11
- delete an endpoint that a successor owns. Its `shutdown()` has two independent deletion
12
- sources: `server.close()` (libuv unlinks the bind path — `nodejs/node#19729`, the #255 root
13
- cause) and an unconditional `unlinkSync(socketPath)` with no dev/ino or ownership check.
14
- Symptom if hit: a live host whose `control.sock` path is gone — attach/probe see ENOENT while
15
- `lsof` shows the listener — the host-level analogue of #255.
16
-
17
- Reachability: current service code always writes instance-scoped configs
18
- (`service.mjs:357,1332,1434`), but the legacy path is (a) still deliberately test-covered
19
- (`test/pty-runner.integration.test.mjs:1440,1542`) and (b) reachable in the upgrade window —
20
- an old service process keeps spawning the on-disk runner script, which is NEW code while the
21
- config keeps the legacy shape.
22
-
23
- ## 2. Goals / constraints
24
-
25
- - G1 A legacy runner that no longer owns the path must not delete it (mirror of #255).
26
- - G2 A legacy runner cleans up its own endpoint on a normal shutdown **when the bind-time
27
- identity was captured**; if the capture failed, cleanup degrades to a no-op and the next
28
- boot's unconditional unlink (`:140-142`) is the backstop.
29
- - G3 `ownedMain` is **not** in scope. It is not "already safe": `finishHost` does a bounded
30
- (1 s) `server.close()` at `:692-707` and only afterwards applies the dev+ino cleanup gate
31
- (`:736-743`), so libuv can still unlink its bind path. Its endpoint is per-instance
32
- (`control.<instanceId>.sock`, `paths.mjs:102-107`), which bounds the blast radius — but it
33
- remains a real follow-up: the #255 design doc's R5 tracks the pty-runner endpoint cleanup,
34
- and the close-before-gate mechanism is this issue's finding.
35
- - G4 No wire/protocol change; POSIX-only semantics as in the existing code.
36
- - G5 No new timers, no new modules; the fix stays inside `legacyMain` + tests.
37
-
38
- ## 3. Root cause (confirmed from source)
39
-
40
- `runner/pty-runner.mjs`:
41
-
42
- - `:140-142` boot — unconditional `unlinkSync(socketPath)` of a leftover path (**not changed**
43
- by this issue, see §7).
44
- - `:409` — `server.listen(socketPath, ...)`; **no bind identity recorded** (the owned path
45
- records it at `:930-936`).
46
- - `:503-531` shutdown:
47
- ```js
48
- try { server?.close(); } catch {}
49
- try { if (existsSync(socketPath)) unlinkSync(socketPath); } catch {}
50
- ```
51
-
52
- Two ways the successor's socket dies:
53
- 1. `server.close()` → libuv unlinks `socketPath` (the path bound at listen time), regardless
54
- of who owns it now (`nodejs/node#19729`);
55
- 2. the explicit unconditional unlink — no `ownsEndpoint` gate at all.
56
-
57
- ## 4. Fix design (legacyMain only)
58
-
59
- ### 4.1 Changes
60
-
61
- 1. **Declare the identity holder** in `legacyMain` (it does not exist there today — it is
62
- declared only in `ownedMain` at `:575`):
63
- ```js
64
- /** {dev,ino} recorded at bind time; cleanup unlinks only this exact inode. */
65
- let boundSocketIdentity = null;
66
- ```
67
- 2. **Record the identity before publishing readiness** — capture first, then `update(alive)`,
68
- so "host ready" implies "identity recorded" for the integration fixture:
69
- ```js
70
- server.listen(socketPath, () => {
71
- if (process.platform !== "win32") {
72
- try {
73
- const st = statSync(socketPath);
74
- boundSocketIdentity = { dev: st.dev, ino: st.ino };
75
- } catch { /* without the identity, cleanup degrades to a no-op */ }
76
- }
77
- update({ socketPath, state: "alive" });
78
- });
79
- ```
80
- 3. **Do not call `server.close()` on POSIX** in `shutdown()` — with no close there is no libuv
81
- close-time unlink. (`shutdown` still awaits child exit up to ~5 s afterwards; that is
82
- unrelated — the deletion vector is the close itself.) Add the nodejs/node#19729 comment.
83
- **win32 exception:** keep `server?.close()` there — named pipes are kernel objects with no
84
- close-time unlink, and the close preserves the historical prompt refusal of new
85
- connections during the shutdown window.
86
- 4. **Gate the explicit unlink** exactly like the owned path's `:736-743`:
87
- ```js
88
- if (process.platform !== "win32" && boundSocketIdentity) {
89
- try {
90
- const st = statSync(socketPath);
91
- if (ownsEndpoint(boundSocketIdentity, { dev: st.dev, ino: st.ino })) {
92
- try { unlinkSync(socketPath); } catch {}
93
- }
94
- } catch { /* path gone — nothing to clean */ }
95
- }
96
- ```
97
-
98
- `ownsEndpoint` is already imported (`:20`). No new exports needed.
99
-
100
- Behaviour matrix:
101
-
102
- | Scenario | identity matches? | unlink? |
103
- | --- | --- | --- |
104
- | SIGTERM/SIGINT on a normal legacy host with captured identity | yes | yes → G2 |
105
- | Shutdown after the path was replaced by a successor | no | no → G1 |
106
- | Path already gone at shutdown | stat ENOENT | no-op |
107
- | win32 (named pipe) | n/a (no identity) | no unlink; keeps the historical prompt close (no close-time unlink on pipes) |
108
- | identity capture failed at boot | null | no unlink (degrade, §7-1 backstop) |
109
-
110
- ### 4.2 What this does NOT cover (boundaries)
111
-
112
- - **Not retroactive.** A legacy runner already running old code keeps the bug until it exits;
113
- the fix only lives in the new `legacyMain`. And after a normal restart the current service
114
- no longer spawns legacy runners at all (`service.mjs:356-378`, `paths.mjs:102-107`), so the
115
- fixed path is only reachable inside the in-place-upgrade window. There is therefore no
116
- stable user-facing reproduction after updating — see §6 (no U-item).
117
- - **Boot-time unlink is unchanged** (§7-1): a new legacy owner still clears a leftover path
118
- unconditionally, matching the accepted #255 boot-site semantics.
119
- - **`ownedMain` is untouched** (§2 G3): it still closes its listener; its per-instance path
120
- bounds the risk, and it is tracked as the #255-R5 follow-up.
121
-
122
- ## 5. Testability split (可测性拆分设计)
123
-
124
- | Unit under test | Layer | Why |
125
- | --- | --- | --- |
126
- | `ownsEndpoint(bound, current)` | `src/core/host-coordination.mjs` (already pure, already unit-tested) | reused as-is; no new pure logic |
127
- | legacy bind-identity capture + gated unlink | `runner/pty-runner.mjs` `legacyMain` I/O shell | one stat at listen, one stat at shutdown |
128
- | Integration fixture | `test/pty-runner.integration.test.mjs` (existing legacy fixtures at :1440/:1542 build a no-instanceId host config and wait for `hostReady`) | drives the real runner process |
129
-
130
- Testing boundary: no new pure function is needed; the decision is the existing `ownsEndpoint`.
131
- New integration tests are POSIX-guarded (`process.platform === "win32"` → `t.skip("POSIX
132
- AF_UNIX only")`, the precedent used by the #255 coordinator cases) because Windows endpoints
133
- are named pipes with no file to unlink. **Title convention: every new case is titled
134
- `#257 A<n>: <summary>`**; because `#257 A1` is a prefix of `#257 A1b`, A1's selector anchors on
135
- `#257 A1:`.
136
-
137
- **A2 (the discriminating regression test)**: spawn a legacy runner from a no-instanceId host
138
- config; wait `hostReady` (which now also implies the identity was captured); bind a stand-in
139
- server on a temp path **inside the board root** and `renameSync` it onto
140
- `P.controlSocketPath(root, viewId)` (atomic; the runner's old inode stays alive in its listener
141
- fd, so the replacement inode is guaranteed different); assert `statSync(...).ino` changed; send
142
- SIGTERM; assert exit 0 and **the stand-in socket file still exists**.
143
- - old code: `server.close()` (libuv unlink) and/or the unconditional unlink delete the
144
- stand-in ⇒ red;
145
- - fixed code: no close, dev/ino mismatch ⇒ keeps it ⇒ green.
146
-
147
- **A1 (regression)**: the same legacy fixture without the replacement: assert the socket exists
148
- **before** SIGTERM (anti-vacuous, per the #70 lesson), SIGTERM ⇒ exit 0 and the control socket
149
- is gone (normal cleanup preserved).
150
-
151
- **A1b (ENOENT branch)**: legacy fixture, unlink the socket before SIGTERM ⇒ exit 0, no crash.
152
-
153
- ## 6. Acceptance matrix
154
-
155
- | ID | Functional point | Verification | Command / steps | Pass criteria |
156
- | --- | --- | --- | --- | --- |
157
- | A1 | Legacy normal shutdown still cleans its own endpoint | 自动化验证(integration, POSIX, Linux CI) | `node --test --test-name-pattern="#257 A1:" test/pty-runner.integration.test.mjs` | precondition: socket exists before SIGTERM; exit 0; socket absent after exit; summary shows exactly 1 matching test (the `:` anchor excludes A1b) |
158
- | A1b | Socket already gone at shutdown ⇒ clean exit | 自动化验证(integration, POSIX, Linux CI) | `node --test --test-name-pattern="#257 A1b" test/pty-runner.integration.test.mjs` | exit 0, no crash |
159
- | A2 | Legacy shutdown does not delete a replaced endpoint | 自动化验证(integration, POSIX, Linux CI) | `node --test --test-name-pattern="#257 A2" test/pty-runner.integration.test.mjs` | inode changed before SIGTERM; exit 0; stand-in file still present (red on old code); summary shows 1 matching test |
160
- | A3 | Owned path unaffected | 自动化验证(integration) | `node --test --test-name-pattern="owned" test/pty-runner.integration.test.mjs` (plus the existing legacy :1440/:1542 cases, unchanged) | all pass unchanged |
161
- | A4 | Type contract | 自动化验证(static) | `npm run typecheck` | exit 0 |
162
- | A5 | No collateral breakage | 自动化验证(unit+integration, Linux CI authoritative) | `npm run test:coverage` on ubuntu-latest (CI runs Node 22/24) | all tests pass, including the three new POSIX cases actually executing (not skipped); thresholds 85/80/70 |
163
- | A6 | Perf + package + full gate | 自动化验证(build/test, Linux CI authoritative) | `npm run test:perf && npm run pack:dry && npm run verify` | exit 0 |
164
-
165
- **Verification channel note (D8).** A1/A1b/A2 are POSIX-only and this dev box is Windows with no
166
- WSL/Docker, so they show as `skip` locally; **Linux CI is the only place they execute**, and the
167
- PR must show the three `#257` cases passing there. There is no listed "known Windows failures"
168
- baseline — the local Windows suite is simply not the gate for these cases (same position as
169
- #255's A3/A5). No U-item exists for this issue: §4.2 explains why no stable user-facing
170
- reproduction remains after the fix, so a manual check could only ever pass vacuously.
171
-
172
- ## 7. Non-goals
173
-
174
- 1. The **boot-time** unconditional unlink (`:140-142`) — matches the accepted #255 boot-site
175
- semantics; hardening it needs an ownership proof the legacy path does not have.
176
- 2. Deprecating/removing the legacy path — separate decision (compat + tests exist).
177
- 3. `ownedMain` — still closes its listener (§2 G3); per-instance endpoint bounds the risk; the
178
- real fix belongs to the #255-R5 follow-up (this issue does not claim it is safe).
179
- 4. The #255 reclaim-audit / agent_end re-projection follow-ups.
180
-
181
- ## 8. Risks / rollback
182
-
183
- | Risk | Mitigation |
184
- | --- | --- |
185
- | Leaving a stale socket when the identity capture failed | next legacy boot unlinks it (`:140-142`); probes treat it as stale and respawn |
186
- | The close removal changes shutdown timing? | no: `shutdown` never waited on the close callback (it has no callback argument today); child-exit awaits are unchanged |
187
- | A1/A2 fixture flake | wait `hostReady` (identity captured before ready by design), atomic rename inside the root, assert inode change first; no heartbeat knobs involved |
188
- | Rollback | revert one runner file + one test file |
189
-
190
- ## 9. Open questions for cross-review
191
-
192
- 1. Is the A2 stand-in enough to pin the fix, given the explicit unconditional unlink would also
193
- fail the test? (Yes: it fails on old code for either deletion source; the fix addresses both.)
194
- 2. Should `ownedMain`'s bounded close be fixed here too? (Proposal: no — §2 G3/§7-3; separate
195
- issue tracking the #255-R5 follow-up.)
196
- 3. Anything needed on win32? (No: named pipes never unlink; the legacy shutdown's
197
- `existsSync`/`unlinkSync` on a pipe name is already a no-op.)