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