@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,537 +0,0 @@
1
- # Lease Heartbeat Staleness Reclaim — Implementation Plan
2
-
3
- > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
-
5
- **Goal:** Make the state-coordinator lease reclaimable when its recorded pid has been reused, by judging the lease on its own heartbeat staleness instead of pid liveness alone.
6
-
7
- **Architecture:** `classifyLeaseOwner` stays a pure decision function and gains two injected inputs — the owner's declared heartbeat period and the observed heartbeat age. A thin I/O shell (`readLeaseHeartbeat` inside `reclaimOrBlock`) supplies them. Eligibility is deliberately narrow: either the owner declared `heartbeatMs` in `owner.json` (forward contract), or a `heartbeat.<token>` file exists **and** the lock is older than the existing 5-minute orphan age (self-healing for records written by older builds). Short-hold leases (`host-meta`, `host-start`) therefore keep today's behaviour unless they are both heartbeating and 5 minutes old. The coordinator additionally steps down when it cannot write its own heartbeat, so a live-but-stuck holder always vacates before it can be preempted.
8
-
9
- **Tech Stack:** Node ESM (`.mjs`), `node:test` + `node:assert/strict`, `c8`, TypeScript checkJs (`tsc --noEmit`), detached runner processes.
10
-
11
- **Spec:** `docs/superpowers/specs/2026-10-23-lease-heartbeat-staleness-design.md`
12
-
13
- ## Global Constraints
14
-
15
- - Tabs for indentation; English comments and commit messages (AGENTS.md).
16
- - Conventional commit subjects; append `(issue #224)` until the PR number exists.
17
- - `LEASE_HEARTBEAT_STALE_MS = 60_000`, `MAX_CONSECUTIVE_TOUCH_FAILURES = 3`, coordinator cadence 1000ms — exact values from the spec.
18
- - No new production switches. Test-only knobs must be `AGENT_BOARD_TEST_*` and default to production-identical behaviour when unset.
19
- - Pure decision functions stay free of fs/net/process I/O; injected probes (`fs`, `clock`, `isProcessDead`) are the only seam.
20
- - Tests use self-contained fixtures under `mkdtempSync`; never the host repository.
21
- - `npm run verify` (typecheck → perf gate → tests → coverage → pack:dry) must pass before claiming done.
22
- - Fixture shape for the #224 record is fixed: `identity: { pid, startToken: null }` (Windows reality), a live unrelated pid, `startedAt` 48h ago, `heartbeat.<token>` `at` 24h ago.
23
-
24
- ## Review Focus
25
-
26
- 1. **A legacy lock record must behave exactly as today.** An old `owner.json` with `identity: null`, no `heartbeatMs`, and no heartbeat file must never become reclaimable faster than before — `host-meta` holds for milliseconds and a wrong verdict here means two writers on `host.json`. Pinned by A4 + A1b.
27
- 2. **Concurrent replacement must not be deleted.** If a successor publishes a new lock between the read and the quarantine rename, the reclaim must restore it and report contention. Pinned by A7.
28
- 3. **A corrupt or non-monotonic heartbeat must fail closed.** Broken JSON, a string `at`, or a future timestamp must not make a live holder look stale (or a dead one look fresh). Pinned by A5b, A6.
29
- 4. **A holder that cannot heartbeat must vacate before it can be preempted.** The step-down bound (`3 × cadence`) must stay strictly below the stale threshold, the supervision must start only after the endpoint is bound, and the step-down must survive its own diagnostic write failing. Pinned by A9, A9b, A14, A14b.
30
- 5. **The declared path must not be dead code on Linux.** Linux records carry a real `startToken`; the heartbeat check has to run before the `hasIdentity` busy early-return, and must bypass the identity-less age gate. Pinned by A3, A3b.
31
-
32
- ---
33
-
34
- ### Task 1: Pure classifier — heartbeat eligibility and staleness
35
-
36
- **Files:**
37
- - Modify: `src/core/locks.mjs` (`classifyLeaseOwner`, new constants)
38
- - Test: `test/locks.test.mjs`
39
-
40
- **Interfaces:**
41
- - Consumes: nothing.
42
- - Produces:
43
- - `export const LEASE_HEARTBEAT_STALE_MS = 60_000`
44
- - `classifyLeaseOwner(owner, now, isProcessDead, opts)` where `opts` gains `heartbeatAgeMs?: number|null`, `heartbeatMs?: number`, `heartbeatStaleMs?: number`
45
- - return type unchanged: `"reclaim" | "busy" | "blocked"`
46
-
47
- - [ ] **Step 1: Write the failing tests**
48
-
49
- Append to `test/locks.test.mjs` (after the existing `classifyLeaseOwner` tests). `FIXED_NOW`, `livePid`, `deadPid` already exist in that file:
50
-
51
- ```js
52
- const HB_STALE = 60_000;
53
- const hb = (o) => ({ heartbeatStaleMs: HB_STALE, ...o });
54
-
55
- test("classifyLeaseOwner: declared heartbeat — stale reclaims, fresh stays busy (A3)", () => {
56
- const owner = { token: "t", pid: 1, identity: null, startedAt: FIXED_NOW - 2 * 60_000, heartbeatMs: 1000 };
57
- assert.equal(classifyLeaseOwner(owner, FIXED_NOW, livePid, hb({ heartbeatAgeMs: HB_STALE })), "reclaim");
58
- assert.equal(classifyLeaseOwner(owner, FIXED_NOW, livePid, hb({ heartbeatAgeMs: HB_STALE - 1 })), "busy");
59
- });
60
-
61
- test("classifyLeaseOwner: threshold is max(staleMs, 3 × period) — both sides (A3)", () => {
62
- const owner = { token: "t", pid: 1, identity: null, startedAt: FIXED_NOW - 10 * 60_000, heartbeatMs: 20_000 };
63
- // (a) staleMs (90s) dominates 3 × period (60s)
64
- const a = hb({ heartbeatStaleMs: 90_000, heartbeatAgeMs: 90_000 });
65
- assert.equal(classifyLeaseOwner(owner, FIXED_NOW, livePid, a), "reclaim");
66
- assert.equal(classifyLeaseOwner(owner, FIXED_NOW, livePid, { ...a, heartbeatAgeMs: 89_999 }), "busy");
67
- // (b) 3 × period (60s) dominates a 10s staleMs
68
- const b = hb({ heartbeatStaleMs: 10_000, heartbeatAgeMs: 60_000 });
69
- assert.equal(classifyLeaseOwner(owner, FIXED_NOW, livePid, b), "reclaim");
70
- assert.equal(classifyLeaseOwner(owner, FIXED_NOW, livePid, { ...b, heartbeatAgeMs: 59_999 }), "busy");
71
- });
72
-
73
- test("classifyLeaseOwner: declared bypasses the identity-less age gate (A3b)", () => {
74
- const young = { token: "t", pid: 1, identity: null, startedAt: FIXED_NOW - 30_000, heartbeatMs: 1000 };
75
- assert.equal(classifyLeaseOwner(young, FIXED_NOW, livePid, hb({ heartbeatAgeMs: 120_000 })), "reclaim");
76
- });
77
-
78
- test("classifyLeaseOwner: declared runs before the hasIdentity busy path (A3, Linux shape)", () => {
79
- const linux = { token: "t", pid: 1, identity: { pid: 1, startToken: "s" }, startedAt: FIXED_NOW - 60_000, heartbeatMs: 1000 };
80
- assert.equal(classifyLeaseOwner(linux, FIXED_NOW, livePid, hb({ heartbeatAgeMs: 120_000 })), "reclaim");
81
- assert.equal(classifyLeaseOwner(linux, FIXED_NOW, livePid, hb({ heartbeatAgeMs: 0 })), "busy");
82
- });
83
-
84
- test("classifyLeaseOwner: legacy heartbeat requires age >= orphanAgeMs (A1b/A1c pure shape)", () => {
85
- const young = { token: "t", pid: 1, identity: null, startedAt: FIXED_NOW - 30_000 };
86
- assert.equal(classifyLeaseOwner(young, FIXED_NOW, livePid, hb({ heartbeatAgeMs: 120_000 })), "blocked");
87
- const aged = { ...young, startedAt: FIXED_NOW - 10 * 60_000 };
88
- assert.equal(classifyLeaseOwner(aged, FIXED_NOW, livePid, hb({ heartbeatAgeMs: 120_000 })), "reclaim");
89
- assert.equal(classifyLeaseOwner(aged, FIXED_NOW, livePid, hb({ heartbeatAgeMs: 0 })), "busy");
90
- });
91
-
92
- test("classifyLeaseOwner: negative heartbeat age counts as fresh (A5)", () => {
93
- const owner = { token: "t", pid: 1, identity: null, startedAt: FIXED_NOW - 10 * 60_000, heartbeatMs: 1000 };
94
- assert.equal(classifyLeaseOwner(owner, FIXED_NOW, livePid, hb({ heartbeatAgeMs: -5000 })), "busy");
95
- });
96
- ```
97
-
98
- - [ ] **Step 2: Run tests to verify they fail**
99
-
100
- Run: `node --test test/locks.test.mjs`
101
- Expected: FAIL — declared cases return `busy`/`blocked` because `opts.heartbeatAgeMs` is ignored.
102
-
103
- - [ ] **Step 3: Implement the classifier**
104
-
105
- Add beside `ORPHAN_LEASE_AGE_MS`:
106
-
107
- ```js
108
- /** Age past which a heartbeating lease with no live proof of ownership is an orphan. */
109
- export const LEASE_HEARTBEAT_STALE_MS = 60_000;
110
- /** Consecutive supervised heartbeat failures after which a holder must vacate. */
111
- export const MAX_CONSECUTIVE_TOUCH_FAILURES = 3;
112
- ```
113
-
114
- Rewrite `classifyLeaseOwner` to follow the spec's D1 pseudocode exactly: early `blocked` for missing owner/token/pid; declared branch (age = `heartbeatAgeMs ?? now - startedAt`, threshold `Math.max(heartbeatStaleMs, 3 * heartbeatMs)`) before the `hasIdentity` branch; legacy branch only when `heartbeatAgeMs != null && age >= orphanAgeMs`; then the existing identity / identity-less liveness logic, with the identity-less age gate skipped when `declared`. Clamp with `Math.max(0, …)`.
115
-
116
- - [ ] **Step 4: Run tests to verify they pass**
117
-
118
- Run: `node --test test/locks.test.mjs`
119
- Expected: PASS. **A4** is satisfied by the pre-existing tests in this file (`fresh identity-less lock is still blocked`, `stale identity-less lock (issue #112 residue) is reclaimed`, `classifyLeaseOwner: a full identity reclaims exactly when its pid is dead`) — they must stay green with no edits.
120
-
121
- - [ ] **Step 5: Commit**
122
-
123
- ```bash
124
- git add src/core/locks.mjs test/locks.test.mjs
125
- git commit -m "feat(locks): judge lease ownership by heartbeat staleness (issue #224)"
126
- ```
127
-
128
- ---
129
-
130
- ### Task 2: I/O shell — heartbeat age, reclaim metadata, declared period
131
-
132
- **Files:**
133
- - Modify: `src/core/locks.mjs` (`readLeaseHeartbeat`, `reclaimOrBlock`, `attemptAcquireLease`, `tryAcquireOwnedViewLock`, `acquireOwnedViewLock`)
134
- - Test: `test/locks.test.mjs`
135
-
136
- **Interfaces:**
137
- - Consumes: `classifyLeaseOwner` options and constants from Task 1.
138
- - Produces:
139
- - `readLeaseHeartbeat(lockPath, token, fs = defaultLocksFs, now = Date.now): number | null`
140
- - `lease.reclaimed: { ownerPid: number, heartbeatAgeMs: number | null, lockAgeMs: number, eligibility: "declared" | "legacy" } | null`
141
- - `acquireOwnedViewLock(root, viewId, name, { identity, waitMs, heartbeatMs })` / `tryAcquireOwnedViewLock(..., { identity, heartbeatMs })` — `heartbeatMs` is written into `owner.json` when provided.
142
-
143
- - [ ] **Step 1: Write the failing tests**
144
-
145
- Add `readFileSync` to the `node:fs` import at the top of `test/locks.test.mjs`, import `readLeaseHeartbeat` from `../src/core/locks.mjs`, then append:
146
-
147
- ```js
148
- const HOUR = 3_600_000;
149
- const ME_NULL = { pid: process.pid, startToken: null }; // Windows coordinator identity
150
-
151
- function seedLock(root, viewId, name, owner, heartbeatAgeMs) {
152
- const lockPath = P.viewLockPath(root, viewId, name);
153
- mkdirSync(lockPath, { recursive: true });
154
- writeFileSync(join(lockPath, "owner.json"), JSON.stringify(owner));
155
- if (heartbeatAgeMs != null) {
156
- writeFileSync(join(lockPath, `heartbeat.${owner.token}`), JSON.stringify({ at: Date.now() - heartbeatAgeMs }));
157
- }
158
- return lockPath;
159
- }
160
-
161
- test("#224 A1: reused pid + stale heartbeat + aged lock is reclaimed", () => {
162
- const root = freshRoot();
163
- try {
164
- seedLock(root, "_coordinator", "state-coordinator", {
165
- token: "stranded", pid: process.pid,
166
- identity: { pid: process.pid, startToken: null },
167
- startedAt: Date.now() - 48 * HOUR,
168
- }, 24 * HOUR);
169
- const got = tryAcquireOwnedViewLock(root, "_coordinator", "state-coordinator", { identity: ME_NULL });
170
- assert.equal(got.acquired, true);
171
- assert.equal(got.lease.reclaimed.eligibility, "legacy");
172
- assert.ok(got.lease.reclaimed.heartbeatAgeMs >= 24 * HOUR - 1000);
173
- got.lease.release();
174
- } finally {
175
- rmSync(root, { recursive: true, force: true });
176
- }
177
- });
178
-
179
- test("#224 A1b: young lock with a heartbeat file stays blocked", () => {
180
- const root = freshRoot();
181
- try {
182
- seedLock(root, "v1", "host-meta", { token: "boot", pid: process.pid, identity: null, startedAt: Date.now() - 30_000 }, 29_000);
183
- const got = tryAcquireOwnedViewLock(root, "v1", "host-meta", { identity: ME });
184
- assert.equal(got.acquired, false);
185
- assert.equal(got.reason, "blocked");
186
- } finally {
187
- rmSync(root, { recursive: true, force: true });
188
- }
189
- });
190
-
191
- test("#224 A1c: aged lock with a fresh heartbeat stays busy", () => {
192
- const root = freshRoot();
193
- try {
194
- seedLock(root, "v1", "host-meta", { token: "slow", pid: process.pid, identity: null, startedAt: Date.now() - 10 * 60_000 }, 0);
195
- const got = tryAcquireOwnedViewLock(root, "v1", "host-meta", { identity: ME });
196
- assert.equal(got.acquired, false);
197
- assert.equal(got.reason, "busy");
198
- } finally {
199
- rmSync(root, { recursive: true, force: true });
200
- }
201
- });
202
-
203
- test("#224 A2: declared period reaches owner.json and a fresh heartbeat stays busy", () => {
204
- const root = freshRoot();
205
- try {
206
- const got = tryAcquireOwnedViewLock(root, "_coordinator", "state-coordinator", { identity: ME_NULL, heartbeatMs: 1000 });
207
- assert.equal(got.acquired, true);
208
- const ownerPath = join(P.viewLockPath(root, "_coordinator", "state-coordinator"), "owner.json");
209
- assert.equal(JSON.parse(readFileSync(ownerPath, "utf8")).heartbeatMs, 1000);
210
- got.lease.release();
211
- seedLock(root, "_coordinator", "state-coordinator", {
212
- token: "declared", pid: process.pid, identity: { pid: process.pid, startToken: null },
213
- startedAt: Date.now() - 30_000, heartbeatMs: 1000,
214
- }, 0);
215
- const held = tryAcquireOwnedViewLock(root, "_coordinator", "state-coordinator", { identity: ME_NULL });
216
- assert.equal(held.acquired, false);
217
- assert.equal(held.reason, "busy");
218
- } finally {
219
- rmSync(root, { recursive: true, force: true });
220
- }
221
- });
222
-
223
- test("#224 A5b: readLeaseHeartbeat clamps a future timestamp to 0", () => {
224
- const now = Date.now();
225
- const fakeFs = { ...defaultLocksFs, readFileSync: () => JSON.stringify({ at: now + 5000 }) };
226
- assert.equal(readLeaseHeartbeat("/nonexistent/lock", "t", fakeFs, now), 0);
227
- });
228
-
229
- test("#224 A6: broken heartbeat fails closed for legacy, falls back for declared", () => {
230
- const root = freshRoot();
231
- try {
232
- const legacyPath = seedLock(root, "v1", "host-meta", { token: "b", pid: process.pid, identity: null, startedAt: Date.now() - 10 * 60_000 }, null);
233
- writeFileSync(join(legacyPath, "heartbeat.b"), "{not json");
234
- const legacy = tryAcquireOwnedViewLock(root, "v1", "host-meta", { identity: ME });
235
- assert.equal(legacy.acquired, false);
236
- assert.equal(legacy.reason, "busy", "unreadable heartbeat grants no eligibility");
237
-
238
- seedLock(root, "v2", "host-meta", {
239
- token: "d", pid: process.pid, identity: { pid: process.pid, startToken: null },
240
- startedAt: Date.now() - 10 * 60_000, heartbeatMs: 1000,
241
- }, null);
242
- writeFileSync(join(P.viewLockPath(root, "v2", "host-meta"), "heartbeat.d"), "{not json");
243
- const declared = tryAcquireOwnedViewLock(root, "v2", "host-meta", { identity: ME });
244
- assert.equal(declared.acquired, true, "declared leases fall back to startedAt");
245
- declared.lease.release();
246
- } finally {
247
- rmSync(root, { recursive: true, force: true });
248
- }
249
- });
250
-
251
- test("#224 A7: a successor's lock is never deleted by a stale reclaim", () => {
252
- const root = freshRoot();
253
- try {
254
- const lockPath = seedLock(root, "v1", "host-meta", { token: "old", pid: process.pid, identity: null, startedAt: Date.now() - 10 * 60_000 }, 120_000);
255
- const successor = { token: "new", pid: process.pid, identity: null, startedAt: Date.now() };
256
- const racingFs = {
257
- ...defaultLocksFs,
258
- renameSync: (from, to) => {
259
- // A concurrent winner publishes between our read and our quarantine rename.
260
- if (String(from) === lockPath) writeFileSync(join(lockPath, "owner.json"), JSON.stringify(successor));
261
- return realRenameSync(from, to);
262
- },
263
- };
264
- const got = tryAcquireOwnedViewLock(root, "v1", "host-meta", { identity: ME, fs: racingFs });
265
- assert.equal(got.acquired, false);
266
- assert.equal(got.reason, "busy");
267
- assert.equal(JSON.parse(readFileSync(join(lockPath, "owner.json"), "utf8")).token, "new");
268
- } finally {
269
- rmSync(root, { recursive: true, force: true });
270
- }
271
- });
272
- ```
273
-
274
- - [ ] **Step 2: Run tests to verify they fail**
275
-
276
- Run: `node --test test/locks.test.mjs`
277
- Expected: FAIL — A1 stays `blocked`, `lease.reclaimed` is `undefined`, `readLeaseHeartbeat` is not exported.
278
-
279
- - [ ] **Step 3: Implement the shell**
280
-
281
- `readLeaseHeartbeat` reads `<lockPath>/heartbeat.<token>`, parses `{ at }`, returns `Math.max(0, now - at)`, and returns `null` for a missing file or unparseable/invalid `at`. `reclaimOrBlock` calls it, derives `declared`/`legacy` eligibility, passes `{ heartbeatAgeMs, heartbeatMs, heartbeatStaleMs }` to `classifyLeaseOwner`, and on `"reclaim"` attaches the metadata to the lease returned by `makeLease`. `attemptAcquireLease` writes `heartbeatMs` into the candidate `owner.json` when `opts.heartbeatMs` is a positive number and returns the reclaim metadata with the lease. Existing quarantine token verification stays untouched.
282
-
283
- - [ ] **Step 4: Run tests to verify they pass** — `node --test test/locks.test.mjs` → PASS.
284
-
285
- - [ ] **Step 5: Commit**
286
-
287
- ```bash
288
- git add src/core/locks.mjs test/locks.test.mjs
289
- git commit -m "feat(locks): read lease heartbeats in the reclaim path (issue #224)"
290
- ```
291
-
292
- ---
293
-
294
- ### Task 3: Coordinator-facing helpers
295
-
296
- **Files:**
297
- - Modify: `src/core/locks.mjs`
298
- - Test: `test/locks.test.mjs`
299
-
300
- **Interfaces:**
301
- - Consumes: `LEASE_HEARTBEAT_STALE_MS`, `MAX_CONSECUTIVE_TOUCH_FAILURES` (Task 1); `readLeaseHeartbeat` (Task 2).
302
- - Produces:
303
- - `describeLeaseConflict(root, viewId, name, opts): { reason: "reclaim"|"busy"|"blocked", ownerPid: number, hasIdentity: boolean, lockAgeMs: number, heartbeatAgeMs: number | null, heartbeatMs: number | null } | null`
304
- - `heartbeatFailureAction(consecutiveFailures, maxFailures = MAX_CONSECUTIVE_TOUCH_FAILURES): "continue" | "abandon"`
305
-
306
- - [ ] **Step 1: Write the failing tests**
307
-
308
- ```js
309
- test("#224 A8: describeLeaseConflict returns exactly six fields", () => {
310
- const root = freshRoot();
311
- try {
312
- seedLock(root, "_coordinator", "state-coordinator", {
313
- token: "t", pid: process.pid, identity: { pid: process.pid, startToken: null },
314
- startedAt: Date.now() - 1000,
315
- }, 0);
316
- const world = describeLeaseConflict(root, "_coordinator", "state-coordinator");
317
- assert.deepEqual(
318
- Object.keys(world).sort(),
319
- ["hasIdentity", "heartbeatAgeMs", "heartbeatMs", "lockAgeMs", "ownerPid", "reason"],
320
- );
321
- assert.equal(world.reason, "blocked", "young identity-less lock is fail-closed");
322
- assert.equal(world.ownerPid, process.pid);
323
- assert.equal(world.hasIdentity, false);
324
- assert.equal(world.heartbeatMs, null);
325
- assert.ok(world.lockAgeMs >= 0 && world.lockAgeMs < 60_000);
326
- assert.ok(world.heartbeatAgeMs != null && world.heartbeatAgeMs >= 0);
327
- assert.equal(describeLeaseConflict(root, "_coordinator", "absent"), null);
328
- } finally {
329
- rmSync(root, { recursive: true, force: true });
330
- }
331
- });
332
-
333
- test("#224 A9/A9b: step-down boundary and its relationship to the stale threshold", () => {
334
- assert.equal(heartbeatFailureAction(2, 3), "continue");
335
- assert.equal(heartbeatFailureAction(3, 3), "abandon");
336
- assert.ok(MAX_CONSECUTIVE_TOUCH_FAILURES * 1000 < LEASE_HEARTBEAT_STALE_MS, "step down before anyone can reclaim us");
337
- });
338
- ```
339
-
340
- - [ ] **Step 2: Run to verify failure** — `node --test test/locks.test.mjs` → FAIL (`describeLeaseConflict is not a function`).
341
-
342
- - [ ] **Step 3: Implement both exports** — `describeLeaseConflict` reads `owner.json` and `readLeaseHeartbeat`, computes `reason` with the same classifier call the acquisition path makes, and returns exactly the six keys (return `null` when there is no readable owner). `heartbeatFailureAction` returns `"abandon"` when `consecutiveFailures >= maxFailures`.
343
-
344
- - [ ] **Step 4: Run to verify pass** — `node --test test/locks.test.mjs` → PASS.
345
-
346
- - [ ] **Step 5: Commit**
347
-
348
- ```bash
349
- git add src/core/locks.mjs test/locks.test.mjs
350
- git commit -m "feat(locks): expose lease conflict summary and step-down rule (issue #224)"
351
- ```
352
-
353
- ---
354
-
355
- ### Task 4: Coordinator diagnostics wiring
356
-
357
- **Files:**
358
- - Modify: `runner/state-coordinator.mjs` (acquisition failure path, post-acquisition path)
359
- - Test: `test/state-coordinator.integration.test.mjs`
360
-
361
- **Interfaces:**
362
- - Consumes: `describeLeaseConflict`, `lease.reclaimed`; `appendDiagnostic` / `readDiagnostics` from `src/core/diagnostics.mjs`.
363
- - Produces: `views/_coordinator/diagnostics.jsonl` events `lease_reclaimed` and `coordinator_lease_unavailable`.
364
-
365
- - [ ] **Step 1: Write the failing tests**
366
-
367
- Add `readDiagnostics` to the imports, extend the existing "second coordinator instance exits immediately (lease held)" test with the A11 block, and add the A10 test:
368
-
369
- ```js
370
- // A11: the loser must leave a diagnostic, not just exit silently.
371
- const events = readDiagnostics(root, "_coordinator");
372
- const failure = events.find((e) => e.code === "coordinator_lease_unavailable");
373
- assert.ok(failure, "acquisition failure is recorded");
374
- assert.deepEqual(
375
- Object.keys(failure.details).sort(),
376
- ["hasIdentity", "heartbeatAgeMs", "heartbeatMs", "lockAgeMs", "ownerPid", "reason"],
377
- );
378
- ```
379
-
380
- ```js
381
- test("#224 A10: a stranded lease with a reused pid is reclaimed and writes resume", async (t) => {
382
- const root = freshRoot();
383
- let child = null;
384
- t.after(async () => {
385
- if (child && isAlive(child.pid)) {
386
- child.kill("SIGTERM");
387
- await waitForExit(child);
388
- }
389
- rmSync(root, { recursive: true, force: true });
390
- });
391
- createView(root, { id: "v1", name: "x", cwd: root });
392
- const lockPath = P.viewLockPath(root, "_coordinator", "state-coordinator");
393
- mkdirSync(lockPath, { recursive: true });
394
- writeFileSync(join(lockPath, "owner.json"), JSON.stringify({
395
- token: "stranded", pid: process.pid,
396
- identity: { pid: process.pid, startToken: null },
397
- startedAt: Date.now() - 48 * 3_600_000,
398
- }));
399
- writeFileSync(join(lockPath, "heartbeat.stranded"), JSON.stringify({ at: Date.now() - 24 * 3_600_000 }));
400
-
401
- child = startCoordinator(root);
402
- const { client } = await readyClient(root);
403
- client.send({
404
- type: "state_command", commandId: "a10-1", viewId: "v1",
405
- source: "dashboard-user", kind: "mark_completed", expectedRevision: null, payload: {},
406
- });
407
- const result = await client.next();
408
- assert.equal(result.status, "applied");
409
-
410
- const reclaimed = readDiagnostics(root, "_coordinator").find((e) => e.code === "lease_reclaimed");
411
- assert.ok(reclaimed, "lease_reclaimed is recorded");
412
- assert.equal(reclaimed.details.eligibility, "legacy");
413
- });
414
- ```
415
-
416
- - [ ] **Step 2: Run to verify failure** — `node --test test/state-coordinator.integration.test.mjs` → FAIL (no `_coordinator/diagnostics.jsonl`; A11 finds no event).
417
-
418
- - [ ] **Step 3: Implement** — in the acquisition `catch`, write `coordinator_lease_unavailable` with the six `describeLeaseConflict` fields as `details` (skip when the summary is `null`), then keep `exit(0)`. After a successful acquisition, when `lease.reclaimed` is non-null write `lease_reclaimed` with `{ ...lease.reclaimed }`.
419
-
420
- - [ ] **Step 4: Run to verify pass** — same command → PASS.
421
-
422
- - [ ] **Step 5: Commit**
423
-
424
- ```bash
425
- git add runner/state-coordinator.mjs test/state-coordinator.integration.test.mjs
426
- git commit -m "feat(coordinator): record lease reclaims and acquisition failures (issue #224)"
427
- ```
428
-
429
- ---
430
-
431
- ### Task 5: Coordinator heartbeat supervision and step-down
432
-
433
- **Files:**
434
- - Modify: `runner/state-coordinator.mjs` (heartbeat interval, boot-edge touches, shutdown ordering)
435
- - Test: `test/state-coordinator.integration.test.mjs`
436
-
437
- **Interfaces:**
438
- - Consumes: `heartbeatFailureAction`, `MAX_CONSECUTIVE_TOUCH_FAILURES`, `resolveTestMs`, existing `shutdown(code)`.
439
- - Produces: `coordinator_heartbeat_lost` diagnostic; knobs `AGENT_BOARD_TEST_COORDINATOR_HEARTBEAT_MS`, `AGENT_BOARD_TEST_FORCE_TOUCH_FAILURE`, `AGENT_BOARD_TEST_FAIL_DIAGNOSTIC`.
440
-
441
- - [ ] **Step 1: Write the failing tests**
442
-
443
- ```js
444
- async function startWithHeartbeatFailure(root) {
445
- process.env.AGENT_BOARD_TEST_COORDINATOR_HEARTBEAT_MS = "50";
446
- process.env.AGENT_BOARD_TEST_FORCE_TOUCH_FAILURE = "1";
447
- return startCoordinator(root);
448
- }
449
-
450
- function clearHeartbeatKnobs() {
451
- delete process.env.AGENT_BOARD_TEST_COORDINATOR_HEARTBEAT_MS;
452
- delete process.env.AGENT_BOARD_TEST_FORCE_TOUCH_FAILURE;
453
- delete process.env.AGENT_BOARD_TEST_FAIL_DIAGNOSTIC;
454
- }
455
-
456
- test("#224 A14: persistent heartbeat failure makes the coordinator step down", async (t) => {
457
- const root = freshRoot();
458
- let child = null;
459
- t.after(async () => {
460
- clearHeartbeatKnobs();
461
- if (child && isAlive(child.pid)) {
462
- child.kill("SIGTERM");
463
- await waitForExit(child);
464
- }
465
- rmSync(root, { recursive: true, force: true });
466
- });
467
- createView(root, { id: "v1", name: "x", cwd: root });
468
- child = await startWithHeartbeatFailure(root);
469
-
470
- const code = await waitForExit(child, 5000);
471
- assert.equal(code, 0, "clean shutdown, not a crash");
472
- assert.equal(existsSync(P.viewLockPath(root, "_coordinator", "state-coordinator")), false, "lease released");
473
-
474
- const lost = readDiagnostics(root, "_coordinator").find((e) => e.code === "coordinator_heartbeat_lost");
475
- assert.ok(lost, "step-down is recorded");
476
- assert.ok(lost.details.consecutiveFailures >= 3);
477
-
478
- if (process.platform !== "win32") {
479
- assert.equal(existsSync(P.coordinatorEndpointPathFor(process.platform, root)), false, "socket unlinked");
480
- }
481
- });
482
-
483
- test("#224 A14b: a failing diagnostic write must not block the step-down", async (t) => {
484
- const root = freshRoot();
485
- let child = null;
486
- t.after(async () => {
487
- clearHeartbeatKnobs();
488
- if (child && isAlive(child.pid)) {
489
- child.kill("SIGTERM");
490
- await waitForExit(child);
491
- }
492
- rmSync(root, { recursive: true, force: true });
493
- });
494
- createView(root, { id: "v1", name: "x", cwd: root });
495
- process.env.AGENT_BOARD_TEST_FAIL_DIAGNOSTIC = "1";
496
- child = await startWithHeartbeatFailure(root);
497
-
498
- const code = await waitForExit(child, 5000);
499
- assert.equal(code, 0, "diagnostic failure must not skip shutdown");
500
- assert.equal(existsSync(P.viewLockPath(root, "_coordinator", "state-coordinator")), false, "lease still released");
501
- });
502
- ```
503
-
504
- - [ ] **Step 2: Run to verify failure** — `node --test test/state-coordinator.integration.test.mjs` → FAIL (process keeps running, no step-down).
505
-
506
- - [ ] **Step 3: Implement**
507
-
508
- Read the cadence via `resolveTestMs(process.env, "AGENT_BOARD_TEST_COORDINATOR_HEARTBEAT_MS", HEARTBEAT_MS)` (import from `src/core/test-knobs.mjs`) and pass `heartbeatMs` into `acquireOwnedViewLock`. Then:
509
-
510
- 1. Add boot-edge `lease.touch()` calls whose results are ignored (never counted).
511
- 2. **Start the supervision interval only inside the `server.listen` callback**, after `boundSocketIdentity` is recorded: each tick does the supervised touch (`AGENT_BOARD_TEST_FORCE_TOUCH_FAILURE` forces `false`), resets or increments a counter, and on `heartbeatFailureAction(...) === "abandon"` writes `coordinator_heartbeat_lost` inside `try/catch` (where `AGENT_BOARD_TEST_FAIL_DIAGNOSTIC` makes the write throw) and then calls `shutdown(0)`.
512
-
513
- - [ ] **Step 4: Run to verify pass** — same command → PASS.
514
-
515
- - [ ] **Step 5: Commit**
516
-
517
- ```bash
518
- git add runner/state-coordinator.mjs test/state-coordinator.integration.test.mjs
519
- git commit -m "feat(coordinator): step down when the lease heartbeat cannot be written (issue #224)"
520
- ```
521
-
522
- ---
523
-
524
- ### Task 6: Full verification and acceptance accounting
525
-
526
- **Files:** none (commands only).
527
-
528
- - [ ] **Step 1: Run the full gate**
529
-
530
- Run: `npm run verify`
531
- Expected: typecheck, perf gate, `node --test test/*.test.mjs`, coverage thresholds (lines 85 / funcs 80 / branches 70) and `npm pack --dry-run` all pass; the dry run lists the new docs spec and no new runtime file.
532
-
533
- - [ ] **Step 2: Record the acceptance ledger**
534
-
535
- Produce a table mapping every spec ID to evidence: A1–A14b → test name + PASS; A12/A13 → verify output; U1/U2 → `pending (Windows machine + merged build)`. U1/U2 must **not** be reported as verified.
536
-
537
- - [ ] **Step 3:** Report the ledger in the PR body; no repository artifact unless the repo already stores such ledgers.