@huseynsnmz/pi-board 0.15.3 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (211) hide show
  1. package/CHANGELOG.md +13 -4
  2. package/CONTRIBUTING.md +7 -0
  3. package/README.md +53 -419
  4. package/SECURITY.md +19 -0
  5. package/docs/how-it-works.md +31 -0
  6. package/docs/reference.md +276 -0
  7. package/package.json +5 -11
  8. package/src/index.ts +1 -1
  9. package/src/runtime/service.mjs +12 -0
  10. package/src/ui/dashboard.ts +14 -8
  11. package/IMPLEMENTATION_PLAN.md +0 -920
  12. package/PRD.md +0 -484
  13. package/PROGRESS.md +0 -142
  14. package/VERIFY.md +0 -115
  15. package/docs/BATCH_SELECTION_READ_FLOW.md +0 -277
  16. package/docs/EXPLORATION.md +0 -187
  17. package/docs/PTY_ATTACH_IMPLEMENTATION_PLAN.md +0 -581
  18. package/docs/RELEASE.md +0 -148
  19. package/docs/superpowers/plans/2026-08-15-screenlog-gc.md +0 -704
  20. package/docs/superpowers/plans/2026-08-16-attach-double-cursor-jiggle-retry.md +0 -499
  21. package/docs/superpowers/plans/2026-08-21-attach-coldstart-jiggle-rearm.md +0 -797
  22. package/docs/superpowers/plans/2026-08-21-dashboard-keypress-lag.md +0 -366
  23. package/docs/superpowers/plans/2026-08-21-issue-14-needs-input-no-auto-done.md +0 -576
  24. package/docs/superpowers/plans/2026-08-22-jiggle-shrink-and-hold.md +0 -337
  25. package/docs/superpowers/plans/2026-08-22-launch-cwd-favorites.md +0 -916
  26. package/docs/superpowers/plans/2026-08-22-question-tool-grouping.md +0 -211
  27. package/docs/superpowers/plans/2026-08-22-rename-state-labels.md +0 -182
  28. package/docs/superpowers/plans/2026-08-22-stable-list-order.md +0 -51
  29. package/docs/superpowers/plans/2026-08-27-locks-acquirelock-spin.md +0 -614
  30. package/docs/superpowers/plans/2026-08-27-pty-runner-test-flaky.md +0 -32
  31. package/docs/superpowers/plans/2026-08-29-code-refs-badges.md +0 -223
  32. package/docs/superpowers/plans/2026-08-30-circular-navigation.md +0 -308
  33. package/docs/superpowers/plans/2026-08-30-post-exit-timing-fix.md +0 -37
  34. package/docs/superpowers/plans/2026-08-30-pty-attach-quality-debt.md +0 -311
  35. package/docs/superpowers/plans/2026-08-30-readme-v2.md +0 -294
  36. package/docs/superpowers/plans/2026-09-01-attach-detach-gate-cursor-anchor.md +0 -284
  37. package/docs/superpowers/plans/2026-09-02-attach-detach-editor-state.md +0 -722
  38. package/docs/superpowers/plans/2026-09-03-code-refs-pr-backlink-narrow.md +0 -551
  39. package/docs/superpowers/plans/2026-09-03-detach-gate-glyph-fallback.md +0 -146
  40. package/docs/superpowers/plans/2026-09-04-evidence-outputpreview.md +0 -209
  41. package/docs/superpowers/plans/2026-09-04-warm-host-reclaim.md +0 -796
  42. package/docs/superpowers/plans/2026-09-05-issue-13-drainnextfollowup-pty-probe.md +0 -114
  43. package/docs/superpowers/plans/2026-09-05-issue-38-windows-wezterm-ime-cursor.md +0 -73
  44. package/docs/superpowers/plans/2026-09-05-issue-39-truncate-codepoint-boundary.md +0 -143
  45. package/docs/superpowers/plans/2026-09-05-issue-61-mention-fallback-guards.md +0 -226
  46. package/docs/superpowers/plans/2026-09-05-issue-63-flaky-manual-completion.md +0 -87
  47. package/docs/superpowers/plans/2026-09-05-issue-64-changelog-release-helper.md +0 -53
  48. package/docs/superpowers/plans/2026-09-05-pty-host-stacking-sock-race.md +0 -731
  49. package/docs/superpowers/plans/2026-09-08-attach-ctrl-left-detach.md +0 -30
  50. package/docs/superpowers/plans/2026-09-08-dashboard-shrink-repaint.md +0 -68
  51. package/docs/superpowers/plans/2026-09-08-issue-11-attach-runtime-desync-heal.md +0 -917
  52. package/docs/superpowers/plans/2026-09-08-legacy-stale-host-recovery.md +0 -125
  53. package/docs/superpowers/plans/2026-09-08-spawn-async-error-swallow.md +0 -56
  54. package/docs/superpowers/plans/2026-09-08-stale-model-attach-guard.md +0 -96
  55. package/docs/superpowers/plans/2026-09-09-claimpid-blocks-replace.md +0 -267
  56. package/docs/superpowers/plans/2026-09-09-harden-runner-architecture.md +0 -603
  57. package/docs/superpowers/plans/2026-09-09-single-writer-completion.md +0 -252
  58. package/docs/superpowers/plans/2026-09-10-reader-consistency.md +0 -115
  59. package/docs/superpowers/plans/2026-09-14-attach-cursor-dectcem-gate.md +0 -469
  60. package/docs/superpowers/plans/2026-09-14-attach-snapshot.md +0 -92
  61. package/docs/superpowers/plans/2026-09-14-host-meta-orphan-lock.md +0 -771
  62. package/docs/superpowers/plans/2026-09-14-issue-106-terminal-frame-cognition.md +0 -299
  63. package/docs/superpowers/plans/2026-09-14-issue-113-foreground-preview-race.md +0 -609
  64. package/docs/superpowers/plans/2026-09-14-terminal-model.md +0 -145
  65. package/docs/superpowers/plans/2026-09-15-coordinator-pipe-root-normalize.md +0 -224
  66. package/docs/superpowers/plans/2026-09-15-lease-publish-eprem-reclaim.md +0 -341
  67. package/docs/superpowers/plans/2026-09-18-detach-anchor-reporter-endpoint.md +0 -875
  68. package/docs/superpowers/plans/2026-09-20-control-lifecycle.md +0 -116
  69. package/docs/superpowers/plans/2026-09-20-issue-121-perf-gate-out-of-coverage.md +0 -517
  70. package/docs/superpowers/plans/2026-09-23-a5-mid-stream-assertion.md +0 -25
  71. package/docs/superpowers/plans/2026-09-23-cwd-browse-tab-drill.md +0 -393
  72. package/docs/superpowers/plans/2026-09-23-terminal-query-forwarding.md +0 -51
  73. package/docs/superpowers/plans/2026-09-23-unconditional-detach-key.md +0 -58
  74. package/docs/superpowers/plans/2026-09-24-control-reconcile-flake.md +0 -698
  75. package/docs/superpowers/plans/2026-09-24-probe-attached-clients.md +0 -605
  76. package/docs/superpowers/plans/2026-09-29-issue-145-holding-semantic-state.md +0 -1183
  77. package/docs/superpowers/plans/2026-09-29-issue-145-user-verification.md +0 -66
  78. package/docs/superpowers/plans/2026-09-30-board-delete-key-x-y-confirm.md +0 -649
  79. package/docs/superpowers/plans/2026-09-30-issue-153-runner-stop-boot-window.md +0 -618
  80. package/docs/superpowers/plans/2026-09-30-issue-95-test-discipline.md +0 -555
  81. package/docs/superpowers/plans/2026-09-30-publish-dispatch-tag-check.md +0 -259
  82. package/docs/superpowers/plans/2026-10-06-attach-settle-scheme-delivery.md +0 -751
  83. package/docs/superpowers/plans/2026-10-09-issue-167-attach-middle-paste.md +0 -509
  84. package/docs/superpowers/plans/2026-10-09-issue-169-fullscreen-mouse-ownership.md +0 -346
  85. package/docs/superpowers/plans/2026-10-10-attach-delete-in-place.md +0 -832
  86. package/docs/superpowers/plans/2026-10-10-issue-174-selection-mirror-paste-source.md +0 -510
  87. package/docs/superpowers/plans/2026-10-14-issue-170-attach-reply-guard.md +0 -771
  88. package/docs/superpowers/plans/2026-10-14-issue-178-accept-registration-race.md +0 -1257
  89. package/docs/superpowers/plans/2026-10-14-issue-184-snapshot-window-contract.md +0 -657
  90. package/docs/superpowers/plans/2026-10-14-issue-185-attach-wheel-alt-screen.md +0 -47
  91. package/docs/superpowers/plans/2026-10-14-issue-187-motionless-drag.md +0 -931
  92. package/docs/superpowers/plans/2026-10-14-issue-189-mixed-input-segments.md +0 -823
  93. package/docs/superpowers/plans/2026-10-15-issue-188-x10-mouse-release.md +0 -526
  94. package/docs/superpowers/plans/2026-10-16-issue-198-attach-wheel-forward-fallback.md +0 -32
  95. package/docs/superpowers/plans/2026-10-18-attach-wheel-replay-pseudo-scrollback.md +0 -437
  96. package/docs/superpowers/plans/2026-10-19-issue-213-reply-guard-csi-key.md +0 -257
  97. package/docs/superpowers/plans/2026-10-19-issue-214-attach-forward-alt-modifier.md +0 -202
  98. package/docs/superpowers/plans/2026-10-21-issue-221-ask-user-question.md +0 -198
  99. package/docs/superpowers/plans/2026-10-23-issue-222-attach-ready.md +0 -290
  100. package/docs/superpowers/plans/2026-10-23-issue-225-monotonic-foreground-state.md +0 -450
  101. package/docs/superpowers/plans/2026-10-23-issue-229-vim-jkl.md +0 -449
  102. package/docs/superpowers/plans/2026-10-23-issue-232-attach-bg-only-cells.md +0 -153
  103. package/docs/superpowers/plans/2026-10-23-lease-heartbeat-staleness.md +0 -537
  104. package/docs/superpowers/plans/2026-10-23-selected-row-highlight.md +0 -405
  105. package/docs/superpowers/plans/2026-10-24-issue-217-windows-pid-reuse-stopping.md +0 -367
  106. package/docs/superpowers/plans/2026-10-24-issue-230-warm-host-background-runs.md +0 -806
  107. package/docs/superpowers/plans/2026-10-24-issue-238-windows-process-identity-token.md +0 -490
  108. package/docs/superpowers/plans/2026-10-24-issue-241-coordinator-root-exit.md +0 -461
  109. package/docs/superpowers/plans/2026-10-25-issue-239-coordinator-replacement-identity.md +0 -504
  110. package/docs/superpowers/plans/2026-10-26-issue-245-key-hints-doc-sync.md +0 -244
  111. package/docs/superpowers/plans/2026-10-27-issue-172-fullscreen-selection-clear.md +0 -344
  112. package/docs/superpowers/plans/2026-10-27-issue-252-select-key-hints.md +0 -209
  113. package/docs/superpowers/plans/2026-10-28-issue-255-coordinator-endpoint-zombie.md +0 -442
  114. package/docs/superpowers/plans/2026-10-28-issue-257-legacy-pty-runner-endpoint-cleanup.md +0 -256
  115. package/docs/superpowers/plans/2026-11-03-cwd-favorites-full-pool.md +0 -266
  116. package/docs/superpowers/specs/2026-08-15-screenlog-gc-design.md +0 -105
  117. package/docs/superpowers/specs/2026-08-16-attach-double-cursor-jiggle-retry-design.md +0 -142
  118. package/docs/superpowers/specs/2026-08-21-attach-coldstart-jiggle-rearm-design.md +0 -110
  119. package/docs/superpowers/specs/2026-08-21-dashboard-keypress-lag-design.md +0 -59
  120. package/docs/superpowers/specs/2026-08-21-issue-14-needs-input-no-auto-done-design.md +0 -109
  121. package/docs/superpowers/specs/2026-08-22-cwd-favorites-design.md +0 -131
  122. package/docs/superpowers/specs/2026-08-22-jiggle-shrink-and-hold-design.md +0 -111
  123. package/docs/superpowers/specs/2026-08-22-question-tool-grouping-design.md +0 -114
  124. package/docs/superpowers/specs/2026-08-22-rename-state-labels-design.md +0 -80
  125. package/docs/superpowers/specs/2026-08-27-locks-acquirelock-spin-design.md +0 -97
  126. package/docs/superpowers/specs/2026-08-27-pty-runner-test-flaky-design.md +0 -35
  127. package/docs/superpowers/specs/2026-08-29-code-refs-badges-design.md +0 -128
  128. package/docs/superpowers/specs/2026-08-30-circular-navigation-design.md +0 -47
  129. package/docs/superpowers/specs/2026-08-30-eprm-atomicwrite-race-design.md +0 -77
  130. package/docs/superpowers/specs/2026-08-30-post-exit-timing-fix-design.md +0 -80
  131. package/docs/superpowers/specs/2026-08-30-pty-attach-quality-debt-design.md +0 -105
  132. package/docs/superpowers/specs/2026-08-30-readme-v2-design.md +0 -115
  133. package/docs/superpowers/specs/2026-09-01-attach-detach-gate-cursor-anchor-design.md +0 -78
  134. package/docs/superpowers/specs/2026-09-02-attach-detach-editor-state-design.md +0 -120
  135. package/docs/superpowers/specs/2026-09-03-code-refs-pr-backlink-narrow-design.md +0 -92
  136. package/docs/superpowers/specs/2026-09-03-detach-gate-glyph-fallback-design.md +0 -99
  137. package/docs/superpowers/specs/2026-09-04-evidence-outputpreview-design.md +0 -50
  138. package/docs/superpowers/specs/2026-09-04-warm-host-reclaim-design.md +0 -106
  139. package/docs/superpowers/specs/2026-09-05-issue-13-drainnextfollowup-pty-probe-design.md +0 -64
  140. package/docs/superpowers/specs/2026-09-05-issue-38-windows-wezterm-ime-design.md +0 -48
  141. package/docs/superpowers/specs/2026-09-05-issue-39-truncate-codepoint-boundary-design.md +0 -64
  142. package/docs/superpowers/specs/2026-09-05-issue-61-mention-fallback-design.md +0 -71
  143. package/docs/superpowers/specs/2026-09-05-issue-63-flaky-manual-completion-design.md +0 -49
  144. package/docs/superpowers/specs/2026-09-05-issue-64-changelog-helper-design.md +0 -76
  145. package/docs/superpowers/specs/2026-09-05-pty-host-stacking-sock-race-design.md +0 -510
  146. package/docs/superpowers/specs/2026-09-07-issue-11-attach-runtime-desync-heal-design.md +0 -130
  147. package/docs/superpowers/specs/2026-09-08-attach-ctrl-left-detach-design.md +0 -58
  148. package/docs/superpowers/specs/2026-09-08-dashboard-shrink-repaint-design.md +0 -52
  149. package/docs/superpowers/specs/2026-09-08-legacy-stale-host-recovery-design.md +0 -87
  150. package/docs/superpowers/specs/2026-09-08-spawn-async-error-swallow-design.md +0 -56
  151. package/docs/superpowers/specs/2026-09-08-stale-model-attach-guard-design.md +0 -79
  152. package/docs/superpowers/specs/2026-09-09-claimpid-blocks-replace-design.md +0 -147
  153. package/docs/superpowers/specs/2026-09-09-harden-runner-architecture-design.md +0 -304
  154. package/docs/superpowers/specs/2026-09-14-attach-cursor-dectcem-gate-design.md +0 -114
  155. package/docs/superpowers/specs/2026-09-14-host-meta-orphan-lock-design.md +0 -120
  156. package/docs/superpowers/specs/2026-09-14-issue-106-terminal-frame-cognition-design.md +0 -146
  157. package/docs/superpowers/specs/2026-09-14-issue-113-foreground-preview-race-design.md +0 -116
  158. package/docs/superpowers/specs/2026-09-15-coordinator-pipe-root-normalize-design.md +0 -84
  159. package/docs/superpowers/specs/2026-09-15-lease-publish-eprem-reclaim-design.md +0 -92
  160. package/docs/superpowers/specs/2026-09-18-detach-anchor-reporter-endpoint-design.md +0 -123
  161. package/docs/superpowers/specs/2026-09-20-issue-121-perf-gate-out-of-coverage-design.md +0 -204
  162. package/docs/superpowers/specs/2026-09-23-a5-mid-stream-assertion-design.md +0 -43
  163. package/docs/superpowers/specs/2026-09-23-cwd-browse-tab-drill-design.md +0 -95
  164. package/docs/superpowers/specs/2026-09-23-terminal-query-forwarding-design.md +0 -63
  165. package/docs/superpowers/specs/2026-09-23-unconditional-detach-key-design.md +0 -57
  166. package/docs/superpowers/specs/2026-09-24-control-reconcile-flake-design.md +0 -98
  167. package/docs/superpowers/specs/2026-09-24-probe-attached-clients-design.md +0 -190
  168. package/docs/superpowers/specs/2026-09-29-issue-145-holding-semantic-state-design.md +0 -394
  169. package/docs/superpowers/specs/2026-09-30-board-delete-key-x-y-confirm-design.md +0 -86
  170. package/docs/superpowers/specs/2026-09-30-issue-153-runner-stop-boot-window-design.md +0 -75
  171. package/docs/superpowers/specs/2026-09-30-issue-95-test-discipline-design.md +0 -226
  172. package/docs/superpowers/specs/2026-09-30-publish-dispatch-tag-check-design.md +0 -129
  173. package/docs/superpowers/specs/2026-10-06-attach-settle-scheme-delivery-design.md +0 -196
  174. package/docs/superpowers/specs/2026-10-09-issue-167-attach-middle-paste-design.md +0 -125
  175. package/docs/superpowers/specs/2026-10-09-issue-169-fullscreen-mouse-ownership-design.md +0 -130
  176. package/docs/superpowers/specs/2026-10-10-attach-delete-in-place-design.md +0 -224
  177. package/docs/superpowers/specs/2026-10-10-issue-174-selection-mirror-paste-source-design.md +0 -154
  178. package/docs/superpowers/specs/2026-10-14-issue-170-attach-reply-guard-design.md +0 -195
  179. package/docs/superpowers/specs/2026-10-14-issue-178-accept-registration-race-design.md +0 -287
  180. package/docs/superpowers/specs/2026-10-14-issue-184-snapshot-window-contract-design.md +0 -288
  181. package/docs/superpowers/specs/2026-10-14-issue-185-attach-wheel-alt-screen-design.md +0 -126
  182. package/docs/superpowers/specs/2026-10-14-issue-187-motionless-drag-design.md +0 -269
  183. package/docs/superpowers/specs/2026-10-14-issue-189-mixed-input-segments-design.md +0 -369
  184. package/docs/superpowers/specs/2026-10-15-issue-188-x10-mouse-release-design.md +0 -252
  185. package/docs/superpowers/specs/2026-10-16-issue-198-attach-wheel-forward-fallback-design.md +0 -67
  186. package/docs/superpowers/specs/2026-10-18-attach-wheel-replay-pseudo-scrollback-design.md +0 -250
  187. package/docs/superpowers/specs/2026-10-19-issue-213-reply-guard-csi-key-design.md +0 -165
  188. package/docs/superpowers/specs/2026-10-19-issue-214-attach-forward-alt-modifier-design.md +0 -131
  189. package/docs/superpowers/specs/2026-10-20-issue-221-ask-user-question-design.md +0 -270
  190. package/docs/superpowers/specs/2026-10-22-issue-225-monotonic-foreground-state-design.md +0 -225
  191. package/docs/superpowers/specs/2026-10-23-issue-222-attach-ready-design.md +0 -204
  192. package/docs/superpowers/specs/2026-10-23-issue-229-vim-jkl-design.md +0 -90
  193. package/docs/superpowers/specs/2026-10-23-issue-232-attach-bg-only-cells-design.md +0 -146
  194. package/docs/superpowers/specs/2026-10-23-lease-heartbeat-staleness-design.md +0 -271
  195. package/docs/superpowers/specs/2026-10-23-selected-row-highlight-design.md +0 -191
  196. package/docs/superpowers/specs/2026-10-24-issue-217-windows-pid-reuse-stopping-design.md +0 -334
  197. package/docs/superpowers/specs/2026-10-24-issue-230-warm-host-background-runs-design.md +0 -283
  198. package/docs/superpowers/specs/2026-10-24-issue-238-windows-process-identity-token-design.md +0 -286
  199. package/docs/superpowers/specs/2026-10-24-issue-241-coordinator-root-exit-design.md +0 -381
  200. package/docs/superpowers/specs/2026-10-25-issue-239-coordinator-replacement-identity-design.md +0 -331
  201. package/docs/superpowers/specs/2026-10-26-issue-245-key-hints-doc-sync-design.md +0 -72
  202. package/docs/superpowers/specs/2026-10-27-issue-172-fullscreen-selection-clear-design.md +0 -248
  203. package/docs/superpowers/specs/2026-10-27-issue-252-select-key-hints-design.md +0 -95
  204. package/docs/superpowers/specs/2026-10-28-issue-255-coordinator-endpoint-zombie-design.md +0 -380
  205. package/docs/superpowers/specs/2026-10-28-issue-257-legacy-pty-runner-endpoint-cleanup-design.md +0 -197
  206. package/docs/superpowers/specs/2026-11-03-cwd-favorites-full-pool-design.md +0 -106
  207. package/scripts/budget-audit.mjs +0 -135
  208. package/scripts/release.mjs +0 -446
  209. package/scripts/release_helper.mjs +0 -309
  210. package/scripts/run-perf-gate.mjs +0 -40
  211. 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.)