@huseynsnmz/pi-board 0.15.3 → 0.17.0

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