@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.
- package/CHANGELOG.md +13 -4
- package/CONTRIBUTING.md +7 -0
- package/README.md +53 -419
- package/SECURITY.md +19 -0
- package/docs/how-it-works.md +31 -0
- package/docs/reference.md +276 -0
- package/package.json +5 -11
- package/src/index.ts +1 -1
- package/src/runtime/service.mjs +12 -0
- package/src/ui/dashboard.ts +14 -8
- package/IMPLEMENTATION_PLAN.md +0 -920
- package/PRD.md +0 -484
- package/PROGRESS.md +0 -142
- package/VERIFY.md +0 -115
- package/docs/BATCH_SELECTION_READ_FLOW.md +0 -277
- package/docs/EXPLORATION.md +0 -187
- package/docs/PTY_ATTACH_IMPLEMENTATION_PLAN.md +0 -581
- package/docs/RELEASE.md +0 -148
- package/docs/superpowers/plans/2026-08-15-screenlog-gc.md +0 -704
- package/docs/superpowers/plans/2026-08-16-attach-double-cursor-jiggle-retry.md +0 -499
- package/docs/superpowers/plans/2026-08-21-attach-coldstart-jiggle-rearm.md +0 -797
- package/docs/superpowers/plans/2026-08-21-dashboard-keypress-lag.md +0 -366
- package/docs/superpowers/plans/2026-08-21-issue-14-needs-input-no-auto-done.md +0 -576
- package/docs/superpowers/plans/2026-08-22-jiggle-shrink-and-hold.md +0 -337
- package/docs/superpowers/plans/2026-08-22-launch-cwd-favorites.md +0 -916
- package/docs/superpowers/plans/2026-08-22-question-tool-grouping.md +0 -211
- package/docs/superpowers/plans/2026-08-22-rename-state-labels.md +0 -182
- package/docs/superpowers/plans/2026-08-22-stable-list-order.md +0 -51
- package/docs/superpowers/plans/2026-08-27-locks-acquirelock-spin.md +0 -614
- package/docs/superpowers/plans/2026-08-27-pty-runner-test-flaky.md +0 -32
- package/docs/superpowers/plans/2026-08-29-code-refs-badges.md +0 -223
- package/docs/superpowers/plans/2026-08-30-circular-navigation.md +0 -308
- package/docs/superpowers/plans/2026-08-30-post-exit-timing-fix.md +0 -37
- package/docs/superpowers/plans/2026-08-30-pty-attach-quality-debt.md +0 -311
- package/docs/superpowers/plans/2026-08-30-readme-v2.md +0 -294
- package/docs/superpowers/plans/2026-09-01-attach-detach-gate-cursor-anchor.md +0 -284
- package/docs/superpowers/plans/2026-09-02-attach-detach-editor-state.md +0 -722
- package/docs/superpowers/plans/2026-09-03-code-refs-pr-backlink-narrow.md +0 -551
- package/docs/superpowers/plans/2026-09-03-detach-gate-glyph-fallback.md +0 -146
- package/docs/superpowers/plans/2026-09-04-evidence-outputpreview.md +0 -209
- package/docs/superpowers/plans/2026-09-04-warm-host-reclaim.md +0 -796
- package/docs/superpowers/plans/2026-09-05-issue-13-drainnextfollowup-pty-probe.md +0 -114
- package/docs/superpowers/plans/2026-09-05-issue-38-windows-wezterm-ime-cursor.md +0 -73
- package/docs/superpowers/plans/2026-09-05-issue-39-truncate-codepoint-boundary.md +0 -143
- package/docs/superpowers/plans/2026-09-05-issue-61-mention-fallback-guards.md +0 -226
- package/docs/superpowers/plans/2026-09-05-issue-63-flaky-manual-completion.md +0 -87
- package/docs/superpowers/plans/2026-09-05-issue-64-changelog-release-helper.md +0 -53
- package/docs/superpowers/plans/2026-09-05-pty-host-stacking-sock-race.md +0 -731
- package/docs/superpowers/plans/2026-09-08-attach-ctrl-left-detach.md +0 -30
- package/docs/superpowers/plans/2026-09-08-dashboard-shrink-repaint.md +0 -68
- package/docs/superpowers/plans/2026-09-08-issue-11-attach-runtime-desync-heal.md +0 -917
- package/docs/superpowers/plans/2026-09-08-legacy-stale-host-recovery.md +0 -125
- package/docs/superpowers/plans/2026-09-08-spawn-async-error-swallow.md +0 -56
- package/docs/superpowers/plans/2026-09-08-stale-model-attach-guard.md +0 -96
- package/docs/superpowers/plans/2026-09-09-claimpid-blocks-replace.md +0 -267
- package/docs/superpowers/plans/2026-09-09-harden-runner-architecture.md +0 -603
- package/docs/superpowers/plans/2026-09-09-single-writer-completion.md +0 -252
- package/docs/superpowers/plans/2026-09-10-reader-consistency.md +0 -115
- package/docs/superpowers/plans/2026-09-14-attach-cursor-dectcem-gate.md +0 -469
- package/docs/superpowers/plans/2026-09-14-attach-snapshot.md +0 -92
- package/docs/superpowers/plans/2026-09-14-host-meta-orphan-lock.md +0 -771
- package/docs/superpowers/plans/2026-09-14-issue-106-terminal-frame-cognition.md +0 -299
- package/docs/superpowers/plans/2026-09-14-issue-113-foreground-preview-race.md +0 -609
- package/docs/superpowers/plans/2026-09-14-terminal-model.md +0 -145
- package/docs/superpowers/plans/2026-09-15-coordinator-pipe-root-normalize.md +0 -224
- package/docs/superpowers/plans/2026-09-15-lease-publish-eprem-reclaim.md +0 -341
- package/docs/superpowers/plans/2026-09-18-detach-anchor-reporter-endpoint.md +0 -875
- package/docs/superpowers/plans/2026-09-20-control-lifecycle.md +0 -116
- package/docs/superpowers/plans/2026-09-20-issue-121-perf-gate-out-of-coverage.md +0 -517
- package/docs/superpowers/plans/2026-09-23-a5-mid-stream-assertion.md +0 -25
- package/docs/superpowers/plans/2026-09-23-cwd-browse-tab-drill.md +0 -393
- package/docs/superpowers/plans/2026-09-23-terminal-query-forwarding.md +0 -51
- package/docs/superpowers/plans/2026-09-23-unconditional-detach-key.md +0 -58
- package/docs/superpowers/plans/2026-09-24-control-reconcile-flake.md +0 -698
- package/docs/superpowers/plans/2026-09-24-probe-attached-clients.md +0 -605
- package/docs/superpowers/plans/2026-09-29-issue-145-holding-semantic-state.md +0 -1183
- package/docs/superpowers/plans/2026-09-29-issue-145-user-verification.md +0 -66
- package/docs/superpowers/plans/2026-09-30-board-delete-key-x-y-confirm.md +0 -649
- package/docs/superpowers/plans/2026-09-30-issue-153-runner-stop-boot-window.md +0 -618
- package/docs/superpowers/plans/2026-09-30-issue-95-test-discipline.md +0 -555
- package/docs/superpowers/plans/2026-09-30-publish-dispatch-tag-check.md +0 -259
- package/docs/superpowers/plans/2026-10-06-attach-settle-scheme-delivery.md +0 -751
- package/docs/superpowers/plans/2026-10-09-issue-167-attach-middle-paste.md +0 -509
- package/docs/superpowers/plans/2026-10-09-issue-169-fullscreen-mouse-ownership.md +0 -346
- package/docs/superpowers/plans/2026-10-10-attach-delete-in-place.md +0 -832
- package/docs/superpowers/plans/2026-10-10-issue-174-selection-mirror-paste-source.md +0 -510
- package/docs/superpowers/plans/2026-10-14-issue-170-attach-reply-guard.md +0 -771
- package/docs/superpowers/plans/2026-10-14-issue-178-accept-registration-race.md +0 -1257
- package/docs/superpowers/plans/2026-10-14-issue-184-snapshot-window-contract.md +0 -657
- package/docs/superpowers/plans/2026-10-14-issue-185-attach-wheel-alt-screen.md +0 -47
- package/docs/superpowers/plans/2026-10-14-issue-187-motionless-drag.md +0 -931
- package/docs/superpowers/plans/2026-10-14-issue-189-mixed-input-segments.md +0 -823
- package/docs/superpowers/plans/2026-10-15-issue-188-x10-mouse-release.md +0 -526
- package/docs/superpowers/plans/2026-10-16-issue-198-attach-wheel-forward-fallback.md +0 -32
- package/docs/superpowers/plans/2026-10-18-attach-wheel-replay-pseudo-scrollback.md +0 -437
- package/docs/superpowers/plans/2026-10-19-issue-213-reply-guard-csi-key.md +0 -257
- package/docs/superpowers/plans/2026-10-19-issue-214-attach-forward-alt-modifier.md +0 -202
- package/docs/superpowers/plans/2026-10-21-issue-221-ask-user-question.md +0 -198
- package/docs/superpowers/plans/2026-10-23-issue-222-attach-ready.md +0 -290
- package/docs/superpowers/plans/2026-10-23-issue-225-monotonic-foreground-state.md +0 -450
- package/docs/superpowers/plans/2026-10-23-issue-229-vim-jkl.md +0 -449
- package/docs/superpowers/plans/2026-10-23-issue-232-attach-bg-only-cells.md +0 -153
- package/docs/superpowers/plans/2026-10-23-lease-heartbeat-staleness.md +0 -537
- package/docs/superpowers/plans/2026-10-23-selected-row-highlight.md +0 -405
- package/docs/superpowers/plans/2026-10-24-issue-217-windows-pid-reuse-stopping.md +0 -367
- package/docs/superpowers/plans/2026-10-24-issue-230-warm-host-background-runs.md +0 -806
- package/docs/superpowers/plans/2026-10-24-issue-238-windows-process-identity-token.md +0 -490
- package/docs/superpowers/plans/2026-10-24-issue-241-coordinator-root-exit.md +0 -461
- package/docs/superpowers/plans/2026-10-25-issue-239-coordinator-replacement-identity.md +0 -504
- package/docs/superpowers/plans/2026-10-26-issue-245-key-hints-doc-sync.md +0 -244
- package/docs/superpowers/plans/2026-10-27-issue-172-fullscreen-selection-clear.md +0 -344
- package/docs/superpowers/plans/2026-10-27-issue-252-select-key-hints.md +0 -209
- package/docs/superpowers/plans/2026-10-28-issue-255-coordinator-endpoint-zombie.md +0 -442
- package/docs/superpowers/plans/2026-10-28-issue-257-legacy-pty-runner-endpoint-cleanup.md +0 -256
- package/docs/superpowers/plans/2026-11-03-cwd-favorites-full-pool.md +0 -266
- package/docs/superpowers/specs/2026-08-15-screenlog-gc-design.md +0 -105
- package/docs/superpowers/specs/2026-08-16-attach-double-cursor-jiggle-retry-design.md +0 -142
- package/docs/superpowers/specs/2026-08-21-attach-coldstart-jiggle-rearm-design.md +0 -110
- package/docs/superpowers/specs/2026-08-21-dashboard-keypress-lag-design.md +0 -59
- package/docs/superpowers/specs/2026-08-21-issue-14-needs-input-no-auto-done-design.md +0 -109
- package/docs/superpowers/specs/2026-08-22-cwd-favorites-design.md +0 -131
- package/docs/superpowers/specs/2026-08-22-jiggle-shrink-and-hold-design.md +0 -111
- package/docs/superpowers/specs/2026-08-22-question-tool-grouping-design.md +0 -114
- package/docs/superpowers/specs/2026-08-22-rename-state-labels-design.md +0 -80
- package/docs/superpowers/specs/2026-08-27-locks-acquirelock-spin-design.md +0 -97
- package/docs/superpowers/specs/2026-08-27-pty-runner-test-flaky-design.md +0 -35
- package/docs/superpowers/specs/2026-08-29-code-refs-badges-design.md +0 -128
- package/docs/superpowers/specs/2026-08-30-circular-navigation-design.md +0 -47
- package/docs/superpowers/specs/2026-08-30-eprm-atomicwrite-race-design.md +0 -77
- package/docs/superpowers/specs/2026-08-30-post-exit-timing-fix-design.md +0 -80
- package/docs/superpowers/specs/2026-08-30-pty-attach-quality-debt-design.md +0 -105
- package/docs/superpowers/specs/2026-08-30-readme-v2-design.md +0 -115
- package/docs/superpowers/specs/2026-09-01-attach-detach-gate-cursor-anchor-design.md +0 -78
- package/docs/superpowers/specs/2026-09-02-attach-detach-editor-state-design.md +0 -120
- package/docs/superpowers/specs/2026-09-03-code-refs-pr-backlink-narrow-design.md +0 -92
- package/docs/superpowers/specs/2026-09-03-detach-gate-glyph-fallback-design.md +0 -99
- package/docs/superpowers/specs/2026-09-04-evidence-outputpreview-design.md +0 -50
- package/docs/superpowers/specs/2026-09-04-warm-host-reclaim-design.md +0 -106
- package/docs/superpowers/specs/2026-09-05-issue-13-drainnextfollowup-pty-probe-design.md +0 -64
- package/docs/superpowers/specs/2026-09-05-issue-38-windows-wezterm-ime-design.md +0 -48
- package/docs/superpowers/specs/2026-09-05-issue-39-truncate-codepoint-boundary-design.md +0 -64
- package/docs/superpowers/specs/2026-09-05-issue-61-mention-fallback-design.md +0 -71
- package/docs/superpowers/specs/2026-09-05-issue-63-flaky-manual-completion-design.md +0 -49
- package/docs/superpowers/specs/2026-09-05-issue-64-changelog-helper-design.md +0 -76
- package/docs/superpowers/specs/2026-09-05-pty-host-stacking-sock-race-design.md +0 -510
- package/docs/superpowers/specs/2026-09-07-issue-11-attach-runtime-desync-heal-design.md +0 -130
- package/docs/superpowers/specs/2026-09-08-attach-ctrl-left-detach-design.md +0 -58
- package/docs/superpowers/specs/2026-09-08-dashboard-shrink-repaint-design.md +0 -52
- package/docs/superpowers/specs/2026-09-08-legacy-stale-host-recovery-design.md +0 -87
- package/docs/superpowers/specs/2026-09-08-spawn-async-error-swallow-design.md +0 -56
- package/docs/superpowers/specs/2026-09-08-stale-model-attach-guard-design.md +0 -79
- package/docs/superpowers/specs/2026-09-09-claimpid-blocks-replace-design.md +0 -147
- package/docs/superpowers/specs/2026-09-09-harden-runner-architecture-design.md +0 -304
- package/docs/superpowers/specs/2026-09-14-attach-cursor-dectcem-gate-design.md +0 -114
- package/docs/superpowers/specs/2026-09-14-host-meta-orphan-lock-design.md +0 -120
- package/docs/superpowers/specs/2026-09-14-issue-106-terminal-frame-cognition-design.md +0 -146
- package/docs/superpowers/specs/2026-09-14-issue-113-foreground-preview-race-design.md +0 -116
- package/docs/superpowers/specs/2026-09-15-coordinator-pipe-root-normalize-design.md +0 -84
- package/docs/superpowers/specs/2026-09-15-lease-publish-eprem-reclaim-design.md +0 -92
- package/docs/superpowers/specs/2026-09-18-detach-anchor-reporter-endpoint-design.md +0 -123
- package/docs/superpowers/specs/2026-09-20-issue-121-perf-gate-out-of-coverage-design.md +0 -204
- package/docs/superpowers/specs/2026-09-23-a5-mid-stream-assertion-design.md +0 -43
- package/docs/superpowers/specs/2026-09-23-cwd-browse-tab-drill-design.md +0 -95
- package/docs/superpowers/specs/2026-09-23-terminal-query-forwarding-design.md +0 -63
- package/docs/superpowers/specs/2026-09-23-unconditional-detach-key-design.md +0 -57
- package/docs/superpowers/specs/2026-09-24-control-reconcile-flake-design.md +0 -98
- package/docs/superpowers/specs/2026-09-24-probe-attached-clients-design.md +0 -190
- package/docs/superpowers/specs/2026-09-29-issue-145-holding-semantic-state-design.md +0 -394
- package/docs/superpowers/specs/2026-09-30-board-delete-key-x-y-confirm-design.md +0 -86
- package/docs/superpowers/specs/2026-09-30-issue-153-runner-stop-boot-window-design.md +0 -75
- package/docs/superpowers/specs/2026-09-30-issue-95-test-discipline-design.md +0 -226
- package/docs/superpowers/specs/2026-09-30-publish-dispatch-tag-check-design.md +0 -129
- package/docs/superpowers/specs/2026-10-06-attach-settle-scheme-delivery-design.md +0 -196
- package/docs/superpowers/specs/2026-10-09-issue-167-attach-middle-paste-design.md +0 -125
- package/docs/superpowers/specs/2026-10-09-issue-169-fullscreen-mouse-ownership-design.md +0 -130
- package/docs/superpowers/specs/2026-10-10-attach-delete-in-place-design.md +0 -224
- package/docs/superpowers/specs/2026-10-10-issue-174-selection-mirror-paste-source-design.md +0 -154
- package/docs/superpowers/specs/2026-10-14-issue-170-attach-reply-guard-design.md +0 -195
- package/docs/superpowers/specs/2026-10-14-issue-178-accept-registration-race-design.md +0 -287
- package/docs/superpowers/specs/2026-10-14-issue-184-snapshot-window-contract-design.md +0 -288
- package/docs/superpowers/specs/2026-10-14-issue-185-attach-wheel-alt-screen-design.md +0 -126
- package/docs/superpowers/specs/2026-10-14-issue-187-motionless-drag-design.md +0 -269
- package/docs/superpowers/specs/2026-10-14-issue-189-mixed-input-segments-design.md +0 -369
- package/docs/superpowers/specs/2026-10-15-issue-188-x10-mouse-release-design.md +0 -252
- package/docs/superpowers/specs/2026-10-16-issue-198-attach-wheel-forward-fallback-design.md +0 -67
- package/docs/superpowers/specs/2026-10-18-attach-wheel-replay-pseudo-scrollback-design.md +0 -250
- package/docs/superpowers/specs/2026-10-19-issue-213-reply-guard-csi-key-design.md +0 -165
- package/docs/superpowers/specs/2026-10-19-issue-214-attach-forward-alt-modifier-design.md +0 -131
- package/docs/superpowers/specs/2026-10-20-issue-221-ask-user-question-design.md +0 -270
- package/docs/superpowers/specs/2026-10-22-issue-225-monotonic-foreground-state-design.md +0 -225
- package/docs/superpowers/specs/2026-10-23-issue-222-attach-ready-design.md +0 -204
- package/docs/superpowers/specs/2026-10-23-issue-229-vim-jkl-design.md +0 -90
- package/docs/superpowers/specs/2026-10-23-issue-232-attach-bg-only-cells-design.md +0 -146
- package/docs/superpowers/specs/2026-10-23-lease-heartbeat-staleness-design.md +0 -271
- package/docs/superpowers/specs/2026-10-23-selected-row-highlight-design.md +0 -191
- package/docs/superpowers/specs/2026-10-24-issue-217-windows-pid-reuse-stopping-design.md +0 -334
- package/docs/superpowers/specs/2026-10-24-issue-230-warm-host-background-runs-design.md +0 -283
- package/docs/superpowers/specs/2026-10-24-issue-238-windows-process-identity-token-design.md +0 -286
- package/docs/superpowers/specs/2026-10-24-issue-241-coordinator-root-exit-design.md +0 -381
- package/docs/superpowers/specs/2026-10-25-issue-239-coordinator-replacement-identity-design.md +0 -331
- package/docs/superpowers/specs/2026-10-26-issue-245-key-hints-doc-sync-design.md +0 -72
- package/docs/superpowers/specs/2026-10-27-issue-172-fullscreen-selection-clear-design.md +0 -248
- package/docs/superpowers/specs/2026-10-27-issue-252-select-key-hints-design.md +0 -95
- package/docs/superpowers/specs/2026-10-28-issue-255-coordinator-endpoint-zombie-design.md +0 -380
- package/docs/superpowers/specs/2026-10-28-issue-257-legacy-pty-runner-endpoint-cleanup-design.md +0 -197
- package/docs/superpowers/specs/2026-11-03-cwd-favorites-full-pool-design.md +0 -106
- package/scripts/budget-audit.mjs +0 -135
- package/scripts/release.mjs +0 -446
- package/scripts/release_helper.mjs +0 -309
- package/scripts/run-perf-gate.mjs +0 -40
- 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.
|
package/docs/superpowers/specs/2026-10-28-issue-257-legacy-pty-runner-endpoint-cleanup-design.md
DELETED
|
@@ -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.)
|