@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,555 +0,0 @@
1
- # Issue #95 Test-Discipline (F3→F2→F4) — 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:** Give issue #95's flaky real-process tests a failure black-box (F3 postmortem), a mechanical budget-discipline gate (F2 audit), and test-only ladder knobs (F4) — all on one branch, verified by A5/A6/A7 plus `npm run verify`.
6
-
7
- **Architecture:** F3 adds a pure formatter + capture shell + waitFor wrapper in `test-support/` and adopts it in the four flaky files' local `waitFor` helpers (optional `capture` thunk threaded to the known-flaky call sites). F2 is a pure source-audit script wired as a test over fixtures AND the real tree. F4 converts six wall-clock ladder constants to dynamic test-scoped env readers (use-site reads, exported constants kept for compatibility).
8
-
9
- **Tech Stack:** Node.js 20+ ESM (.mjs), JSDoc types, `node:test` + `assert/strict`, tabs.
10
-
11
- **Spec:** `docs/superpowers/specs/2026-09-30-issue-95-test-discipline-design.md` (refreshed; F1/A1–A4/A10 already delivered by #153).
12
-
13
- ## Global Constraints
14
-
15
- - Tabs; English comments; conventional commits ending `(issue #95)`.
16
- - No wall-clock widening: no test timeout or wait budget may grow beyond rule-derived values; the 40→90→150s pattern is retired.
17
- - `.github/workflows/ci.yml` untouched. `CHANGELOG.md` never hand-edited.
18
- - Production behavior byte-identical when knobs are unset; `AGENT_BOARD_TEST_*` naming only.
19
- - Fixtures self-contained under mkdtemp roots removed in `finally`; never mutate `~/.pi/agent/agent-board`.
20
- - All work in this worktree only; `npm run verify` must exit 0 at branch head.
21
-
22
- ## Review Focus
23
-
24
- 1. **Postmortem must not change waitFor's success semantics** — the wrapper returns the predicate value identically; only the timeout path throws. Pinned: Task 1's return-value tests + Task 2's suites staying green.
25
- 2. **A thrown postmortem must still contain the legacy "timed out waiting" prefix** — anything grepping old output keeps working. Pinned: Task 1 unit test asserting the prefix.
26
- 3. **The audit must not false-positive on the untouched real tree** — pinned by Task 3's real-tree zero-violations assertion; planted fixtures prove both rules bite.
27
- 4. **Knob readers must be dynamic at use sites** (module-load-time evaluation would make in-process test knobs dead) — pinned: Task 4 requires use-site reader calls, and Task 5's A7 test sets env AFTER import and must observe compressed behavior.
28
- 5. **Invalid knob values fall back to defaults with a one-shot warn** — never NaN into the ladders. Pinned: Task 4 unit tests.
29
-
30
- ---
31
-
32
- ### Task 1: F3 core — flake-postmortem module
33
-
34
- **Files:**
35
- - Create: `test-support/flake-postmortem.mjs`
36
- - Test: `test/flake-postmortem.test.mjs`
37
-
38
- **Interfaces:**
39
- - Produces: `formatPostmortem(snapshot) → string` (pure), `capturePostmortem(root, viewId, runId=null) → snapshot` (I/O shell), `waitForWithPostmortem(predicate, opts) → Promise<value>` (throws on timeout with the postmortem attached).
40
-
41
- - [ ] **Step 1: Write the failing tests** — `test/flake-postmortem.test.mjs`:
42
-
43
- ```js
44
- /** F3 unit tests (issue #95): the flake black-box. */
45
- import assert from "node:assert/strict";
46
- import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
47
- import { tmpdir } from "node:os";
48
- import { join } from "node:path";
49
- import { test } from "node:test";
50
- import { createView } from "../src/core/store.mjs";
51
- import { capturePostmortem, formatPostmortem, waitForWithPostmortem } from "../test-support/flake-postmortem.mjs";
52
-
53
- test("A6: formatPostmortem renders the last-observed block deterministically", () => {
54
- const text = formatPostmortem({
55
- status: { semanticState: "working", processState: "alive", endedAt: null, pid: 4242 },
56
- state: { semanticState: "working", currentRunId: "run_1" },
57
- host: null,
58
- diagnosticsTail: [{ code: "runner_start", at: 1 }, { code: "stop_latch_armed", at: 2 }],
59
- journalTail: ["{\"kind\":\"run_started\"}"],
60
- });
61
- assert.match(text, /flake postmortem/);
62
- assert.match(text, /"semanticState":"working"/);
63
- assert.match(text, /stop_latch_armed/);
64
- assert.match(text, /run_started/);
65
- assert.doesNotMatch(text, /undefined/);
66
- });
67
-
68
- test("A6: formatPostmortem marks absent artifacts explicitly", () => {
69
- const text = formatPostmortem({ status: null, state: null, host: null, diagnosticsTail: [], journalTail: [] });
70
- assert.match(text, /status: <absent>/);
71
- assert.match(text, /state: <absent>/);
72
- assert.match(text, /host: <absent>/);
73
- });
74
-
75
- test("A6: waitForWithPostmortem returns the predicate value on success", async () => {
76
- const value = await waitForWithPostmortem(() => 42, { timeoutMs: 100, intervalMs: 5 });
77
- assert.equal(value, 42);
78
- });
79
-
80
- test("A6: a timeout throws with the legacy prefix plus the postmortem block", async () => {
81
- let captured = 0;
82
- const err = await waitForWithPostmortem(() => false, {
83
- timeoutMs: 30,
84
- intervalMs: 5,
85
- capture: () => {
86
- captured += 1;
87
- return formatPostmortem({ status: { semanticState: "working", endedAt: null }, state: null, host: null, diagnosticsTail: [], journalTail: [] });
88
- },
89
- }).then(
90
- () => null,
91
- (e) => e,
92
- );
93
- assert.ok(err instanceof Error, "timeout throws");
94
- assert.match(err.message, /^timed out waiting/);
95
- assert.match(err.message, /flake postmortem/);
96
- assert.match(err.message, /"semanticState":"working"/);
97
- assert.equal(captured, 1, "capture runs exactly once, at timeout");
98
- });
99
-
100
- test("A6: a timeout without a capture thunk still throws the legacy error", async () => {
101
- const err = await waitForWithPostmortem(() => false, { timeoutMs: 20, intervalMs: 5 }).then(
102
- () => null,
103
- (e) => e,
104
- );
105
- assert.ok(err instanceof Error);
106
- assert.equal(err.message, "timed out waiting");
107
- });
108
-
109
- test("A6: capturePostmortem reads the real artifacts from a fixture root", () => {
110
- const root = mkdtempSync(join(tmpdir(), "agentview-pm-"));
111
- try {
112
- createView(root, { id: "v", name: "t", cwd: root });
113
- const snap = capturePostmortem(root, "v", null);
114
- assert.ok(snap.state, "state.json captured");
115
- assert.equal(snap.status, null, "no run status without runId");
116
- assert.deepEqual(snap.diagnosticsTail, []);
117
- } finally {
118
- rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
119
- }
120
- });
121
- ```
122
-
123
- - [ ] **Step 2: Run to verify RED** — `node --test test/flake-postmortem.test.mjs` → all fail (`Cannot find module .../flake-postmortem.mjs`).
124
- - [ ] **Step 3: Implement** — `test-support/flake-postmortem.mjs`:
125
-
126
- ```js
127
- /**
128
- * Flake postmortem black-box (issue #95 F3).
129
- *
130
- * When a real-process waitFor burns its budget, the failure currently says
131
- * only "timed out waiting" — the red carries no scene. formatPostmortem
132
- * renders the last observed durable state; capturePostmortem reads it from a
133
- * root; waitForWithPostmortem attaches it to the thrown error at timeout.
134
- * Pure rendering; the only I/O is capturePostmortem's reads.
135
- */
136
- import { readFileSync } from "node:fs";
137
- import { readDiagnostics } from "../src/core/diagnostics.mjs";
138
- import { readHost, readState, readStatus } from "../src/core/store.mjs";
139
-
140
- /**
141
- * @param {{
142
- * status: object | null,
143
- * state: object | null,
144
- * host: object | null,
145
- * diagnosticsTail: object[],
146
- * journalTail: string[],
147
- * }} snapshot
148
- * @returns {string}
149
- */
150
- export function formatPostmortem(snapshot) {
151
- const show = (label, value) => `${label}: ${value == null ? "<absent>" : JSON.stringify(value)}`;
152
- const lines = [
153
- "--- flake postmortem (last observed state at waitFor timeout) ---",
154
- show("status", snapshot.status),
155
- show("state", snapshot.state),
156
- show("host", snapshot.host),
157
- `diagnosticsTail: ${snapshot.diagnosticsTail.length ? JSON.stringify(snapshot.diagnosticsTail) : "<empty>"}`,
158
- `journalTail: ${snapshot.journalTail.length ? snapshot.journalTail.join(" | ") : "<empty>"}`,
159
- ];
160
- return lines.join("\n");
161
- }
162
-
163
- /**
164
- * @param {string} root @param {string} viewId @param {string | null} runId
165
- * @returns {{ status: object | null, state: object | null, host: object | null, diagnosticsTail: object[], journalTail: string[] }}
166
- */
167
- export function capturePostmortem(root, viewId, runId = null) {
168
- const safe = (fn) => {
169
- try {
170
- return fn();
171
- } catch {
172
- return null;
173
- }
174
- };
175
- let journalTail = [];
176
- try {
177
- const raw = readFileSync(join(root, "state-journal.jsonl"), "utf8");
178
- journalTail = raw.trim().split("\n").slice(-8);
179
- } catch {
180
- /* absent journal */
181
- }
182
- return {
183
- status: safe(() => (runId ? readStatus(root, viewId, runId) : null)),
184
- state: safe(() => readState(root, viewId)),
185
- host: safe(() => readHost(root, viewId)),
186
- diagnosticsTail: safe(() => (readDiagnostics(root, viewId) ?? []).slice(-8)) ?? [],
187
- journalTail,
188
- };
189
- }
190
-
191
- /**
192
- * waitFor with an optional postmortem. Success semantics identical to the
193
- * files' local helpers (poll → return the predicate value); on timeout throws
194
- * "timed out waiting" plus the capture thunk's postmortem, when provided.
195
- * @param {() => any | Promise<any>} predicate
196
- * @param {{ timeoutMs?: number, intervalMs?: number, capture?: () => string }} [opts]
197
- */
198
- export async function waitForWithPostmortem(predicate, { timeoutMs = 15000, intervalMs = 25, capture = null } = {}) {
199
- const start = Date.now();
200
- for (;;) {
201
- const value = await predicate();
202
- if (value) return value;
203
- if (Date.now() - start > timeoutMs) {
204
- const detail = capture ? "\n" + capture() : "";
205
- throw new Error("timed out waiting" + detail);
206
- }
207
- await new Promise((r) => setTimeout(r, intervalMs));
208
- }
209
- }
210
- ```
211
-
212
- (Note: `join` needs `import { join } from "node:path";` — add it.)
213
-
214
- - [ ] **Step 4: Run to verify GREEN** — `node --test test/flake-postmortem.test.mjs` → 6/6 pass.
215
- - [ ] **Step 5: Commit** — `git add test-support/flake-postmortem.mjs test/flake-postmortem.test.mjs && git commit -m "feat: flake postmortem black-box for waitFor timeouts (issue #95)"`
216
-
217
- ---
218
-
219
- ### Task 2: F3 adoption — the four flaky helpers
220
-
221
- **Files:**
222
- - Modify: `test/runner.integration.test.mjs`, `test/host-concurrency.integration.test.mjs`, `test/pty-runner.integration.test.mjs`, `test/terminal-snapshot.integration.test.mjs`
223
-
224
- **Interfaces:**
225
- - Consumes: `waitForWithPostmortem`, `capturePostmortem`, `formatPostmortem` from Task 1.
226
- - Produces: each file's local `waitFor(predicate, timeoutMs, capture)` delegating to the shared wrapper (runner.integration also keeps its `intervalMs` third-arg position — its signature becomes `(predicate, timeoutMs = 15000, capture = null)`; the shared default interval for it is overridden to 50 via opts).
227
-
228
- - [ ] **Step 1:** In each of the four files, import the helpers and replace the local `waitFor` body with a delegation (keep each file's existing default timeout):
229
-
230
- ```js
231
- import { capturePostmortem, formatPostmortem, waitForWithPostmortem } from "../test-support/flake-postmortem.mjs";
232
-
233
- async function waitFor(predicate, timeoutMs = 15000, capture = null) {
234
- return waitForWithPostmortem(predicate, { timeoutMs, intervalMs: 50, capture });
235
- }
236
- ```
237
-
238
- (host-concurrency / pty-runner / terminal-snapshot use `intervalMs: 25`; runner.integration used 50 — preserve each file's original interval. runner.integration's helper is `waitFor(fn, timeoutMs = 15000, intervalMs = 50)` — extend it as `waitFor(fn, timeoutMs = 15000, intervalMs = 50, capture = null)` and forward all four.)
239
-
240
- - [ ] **Step 2:** Add `capture` at the historically-flaky call sites ONLY (pass a closure over that test's `root`/view/run ids):
241
- - `runner.integration.test.mjs` — the `stopping the runner finalizes the run as stopped` test's two waits: `capture: () => formatPostmortem(capturePostmortem(root, "v", "r"))`.
242
- - `host-concurrency.integration.test.mjs` — A10's three waits (original-alive 30s, runner-death 10s, replacement 30s): `capture: () => formatPostmortem(capturePostmortem(root, "v1", null))`.
243
- - `pty-runner.integration.test.mjs` — the `subscribe_terminal: snapshot + live continuity alongside legacy clients` test's waits (grep its waitFor calls inside that test): closure over its fixture root/view.
244
- - `terminal-snapshot.integration.test.mjs` — the four explicit 30s waits (`:276`, `:293`, `:319`, `:352` region): same closure pattern.
245
- - [ ] **Step 3:** `node --test test/runner.integration.test.mjs test/host-concurrency.integration.test.mjs test/terminal-snapshot.integration.test.mjs test/pty-runner.integration.test.mjs` → all pass (pty-runner may need `node-pty`; if unavailable locally it skips — record it).
246
- - [ ] **Step 4:** Red-world: in `flake-postmortem.mjs` temporarily make `capture` never invoked (comment the `const detail = ...` line down to `throw new Error("timed out waiting")`), then hand-edit ONE call site's predicate to always-false with timeout 50ms — expect the thrown message WITHOUT the postmortem block (proving the adoption path is the wrapper). Restore both. Record outputs.
247
- - [ ] **Step 5:** Commit — `git add test/*.integration.test.mjs && git commit -m "test: attach flake postmortems to the known-flaky waits (issue #95)"`
248
-
249
- ---
250
-
251
- ### Task 3: F2 — budget audit gate
252
-
253
- **Files:**
254
- - Create: `scripts/budget-audit.mjs`, `test/budget-audit.test.mjs`
255
- - Modify: `test/host-concurrency.integration.test.mjs` (escape comment on the A10 deadline line)
256
-
257
- **Interfaces:**
258
- - Produces: `parseTestBudgets(source) → {fileDefaultWaitMs, tests: [{name, declaredTimeoutMs, waitLiterals[], deadlineLiterals[]}]}` and `auditBudgets(sources) → violations[]` (pure; `sources` = `[{path, source}]`).
259
-
260
- - [ ] **Step 1: Write the failing tests** — `test/budget-audit.test.mjs`:
261
-
262
- ```js
263
- /** F2 budget-audit gate (issue #95): fixtures must bite, the real tree must be clean. */
264
- import assert from "node:assert/strict";
265
- import { readdirSync, readFileSync } from "node:fs";
266
- import { join } from "node:path";
267
- import { test } from "node:test";
268
- import { auditBudgets, parseTestBudgets } from "../scripts/budget-audit.mjs";
269
-
270
- const FIX_A_VIOLATION = `async function waitFor(predicate, timeoutMs = 15000) { /* poll */ }
271
- test("rule-a violation", { timeout: 20000 }, async () => {
272
- await waitFor(() => null, 20000);
273
- });
274
- `;
275
- const FIX_A_PASS = `async function waitFor(predicate, timeoutMs = 15000) { /* poll */ }
276
- test("rule-a pass", { timeout: 27000 }, async () => {
277
- await waitFor(() => null, 20000);
278
- });
279
- `;
280
- const FIX_DEFAULT_VIOLATION = `async function waitFor(predicate, timeoutMs = 15000) { /* poll */ }
281
- test("default violation", { timeout: 10000 }, async () => {
282
- await waitFor(() => null);
283
- });
284
- `;
285
- const FIX_RULE_B_VIOLATION = `test("rule-b violation", async () => {
286
- const r = await resolveAttachTarget("v1", { timeoutMs: 150_000 });
287
- return r;
288
- });
289
- `;
290
- const FIX_RULE_B_ESCAPE = `test("rule-b escape", async () => {
291
- const r = await resolveAttachTarget("v1", { timeoutMs: 150_000 }); // budget: knob
292
- return r;
293
- });
294
- `;
295
-
296
- function violations(source) {
297
- return auditBudgets([{ path: "fixture.mjs", source }]);
298
- }
299
-
300
- test("A5: rule-a flags a declared timeout that cannot contain its wait", () => {
301
- const v = violations(FIX_A_VIOLATION);
302
- assert.equal(v.length, 1);
303
- assert.equal(v[0].rule, "rule-a");
304
- assert.match(v[0].message, /20000/);
305
- });
306
-
307
- test("A5: rule-a passes when the timeout covers max wait + margin", () => {
308
- assert.deepEqual(violations(FIX_A_PASS), []);
309
- });
310
-
311
- test("A5: the file's default wait participates when a call omits the budget", () => {
312
- const v = violations(FIX_DEFAULT_VIOLATION);
313
- assert.equal(v.length, 1);
314
- assert.equal(v[0].rule, "rule-a");
315
- assert.match(v[0].message, /15000/);
316
- });
317
-
318
- test("A5: an app-level deadline >= 30s needs a covering timeout or an escape", () => {
319
- const v = violations(FIX_RULE_B_VIOLATION);
320
- assert.equal(v.length, 1);
321
- assert.equal(v[0].rule, "rule-b");
322
- assert.match(v[0].message, /150000/);
323
- });
324
-
325
- test("A5: a // budget: escape line exempts the deadline", () => {
326
- assert.deepEqual(violations(FIX_RULE_B_ESCAPE), []);
327
- });
328
-
329
- test("A5: parseTestBudgets exposes the declared timeout and waits", () => {
330
- const parsed = parseTestBudgets(FIX_A_VIOLATION);
331
- assert.equal(parsed.fileDefaultWaitMs, 15000);
332
- assert.equal(parsed.tests[0].declaredTimeoutMs, 20000);
333
- assert.deepEqual(parsed.tests[0].waitLiterals, [20000]);
334
- });
335
-
336
- test("A5: the real test tree audits clean", () => {
337
- const dir = join(import.meta.dirname, "..");
338
- const sources = readdirSync(join(dir, "test"))
339
- .filter((f) => f.endsWith(".test.mjs"))
340
- .map((f) => ({ path: f, source: readFileSync(join(dir, "test", f), "utf8") }));
341
- assert.ok(sources.length > 50, "found the test tree");
342
- assert.deepEqual(auditBudgets(sources), []);
343
- });
344
- ```
345
-
346
- - [ ] **Step 2: RED run** — `node --test test/budget-audit.test.mjs` → module-not-found.
347
-
348
- - [ ] **Step 3: Implement `scripts/budget-audit.mjs`:**
349
-
350
- ```js
351
- /**
352
- * Budget audit for test files (issue #95 F2).
353
- *
354
- * Mechanical discipline: a test's declared timeout must be able to contain
355
- * its waits (rule a), and an app-level deadline a test drives must be covered
356
- * by the test's own budget unless explicitly escaped (rule b). Pure functions
357
- * only — the gate lives in test/budget-audit.test.mjs, which runs these over
358
- * fixture sources AND the real test tree.
359
- */
360
-
361
- /** Strip escaped lines (`// budget: <reason>`) from a source. @param {string} source @returns {string} */
362
- export function stripEscapedLines(source) {
363
- return source.split("\n").filter((line) => !line.includes("// budget:")).join("\n");
364
- }
365
-
366
- /** @param {string} source @returns {number | null} */
367
- export function parseFileDefaultWaitMs(source) {
368
- const m = /async function waitFor\([^)]*\)\s*\{[\s\S]{0,400}?timeoutMs\s*=\s*([0-9_]+)/.exec(source);
369
- return m ? Number(m[1].replace(/_/g, "")) : null;
370
- }
371
-
372
- /** Balanced-paren argument text for the call whose `(` sits at fromIndex. @param {string} text @param {number} fromIndex @returns {string | null} */
373
- function callArgs(text, fromIndex) {
374
- let depth = 0;
375
- for (let i = fromIndex; i < text.length; i++) {
376
- if (text[i] === "(") depth++;
377
- else if (text[i] === ")") {
378
- depth--;
379
- if (depth === 0) return text.slice(fromIndex + 1, i);
380
- }
381
- }
382
- return null;
383
- }
384
-
385
- /**
386
- * @param {string} source
387
- * @returns {{ fileDefaultWaitMs: number | null, tests: Array<{ name: string, declaredTimeoutMs: number | null, waitLiterals: number[], hasDefaultWaitCall: boolean, deadlineLiterals: number[] }> }}
388
- */
389
- export function parseTestBudgets(source) {
390
- const clean = stripEscapedLines(source);
391
- const fileDefaultWaitMs = parseFileDefaultWaitMs(clean);
392
- const tests = [];
393
- const starts = [];
394
- const re = /^(?:export )?test\(/gm;
395
- for (let m = re.exec(clean); m; m = re.exec(clean)) starts.push(m.index);
396
- for (let i = 0; i < starts.length; i++) {
397
- const chunk = clean.slice(starts[i], starts[i + 1] ?? clean.length);
398
- const nameM = /^(?:export )?test\("([^"]+)"/.exec(chunk);
399
- const toM = /timeout:\s*([0-9_]+)/.exec(chunk.slice(0, 240));
400
- const waitLiterals = [];
401
- let hasDefaultWaitCall = false;
402
- const waitRe = /waitFor\(/g;
403
- for (let w = waitRe.exec(chunk); w; w = waitRe.exec(chunk)) {
404
- const args = callArgs(chunk, w.index + "waitFor".length);
405
- if (args == null) continue;
406
- const trailing = /,\s*([0-9_]+)\s*,?\s*$/.exec(args.trim());
407
- if (trailing) waitLiterals.push(Number(trailing[1].replace(/_/g, "")));
408
- else hasDefaultWaitCall = true;
409
- }
410
- const deadlineLiterals = [];
411
- const dlRe = /timeoutMs:\s*([0-9_]+)/g;
412
- for (let d = dlRe.exec(chunk); d; d = dlRe.exec(chunk)) deadlineLiterals.push(Number(d[1].replace(/_/g, "")));
413
- tests.push({ name: nameM ? nameM[1] : `<test ${i + 1}>`, declaredTimeoutMs: toM ? Number(toM[1].replace(/_/g, "")) : null, waitLiterals, hasDefaultWaitCall, deadlineLiterals });
414
- }
415
- return { fileDefaultWaitMs, tests };
416
- }
417
-
418
- /**
419
- * Rule (a): declared timeout >= max(single explicit wait, file default when a
420
- * call omits it) + margin, margin = max(5000, wait / 3).
421
- * Rule (b): an app deadline >= 30s needs a declared timeout >= it (escape via
422
- * `// budget:` lines, stripped upstream).
423
- * @param {Array<{ path: string, source: string }>} sources
424
- * @returns {Array<{ path: string, test: string, rule: string, message: string }>}
425
- */
426
- export function auditBudgets(sources) {
427
- const violations = [];
428
- for (const { path, source } of sources) {
429
- const parsed = parseTestBudgets(source);
430
- for (const t of parsed.tests) {
431
- const waits = [...t.waitLiterals];
432
- if (t.hasDefaultWaitCall && parsed.fileDefaultWaitMs != null) waits.push(parsed.fileDefaultWaitMs);
433
- if (t.declaredTimeoutMs != null && waits.length > 0) {
434
- const maxWait = Math.max(...waits);
435
- const margin = Math.max(5000, Math.round(maxWait / 3));
436
- if (t.declaredTimeoutMs < maxWait + margin) violations.push({ path, test: t.name, rule: "rule-a", message: `declared timeout ${t.declaredTimeoutMs}ms cannot contain max wait ${maxWait}ms + margin ${margin}ms` });
437
- }
438
- for (const d of t.deadlineLiterals) {
439
- if (d >= 30000 && (t.declaredTimeoutMs == null || t.declaredTimeoutMs < d)) violations.push({ path, test: t.name, rule: "rule-b", message: `app deadline ${d}ms needs a declared test timeout >= it (or a // budget: escape)` });
440
- }
441
- }
442
- }
443
- return violations;
444
- }
445
- ```
446
- - [ ] **Step 4: GREEN** — all fixtures report as designed AND the real tree is clean after adding this line to host-concurrency A10 (immediately after the `resolveAttachTarget` line): `// budget: app deadline 150s, runtime-compressed by the F4 knob; node-default test timeout accepted`
447
- - [ ] **Step 5:** `node --test test/host-concurrency.integration.test.mjs` still green (comment-only change).
448
- - [ ] **Step 6:** Commit — `git add scripts/budget-audit.mjs test/budget-audit.test.mjs test/host-concurrency.integration.test.mjs && git commit -m "test: budget audit gate for test timeout/wait nesting (issue #95)"`
449
-
450
- ---
451
-
452
- ### Task 4: F4 — test-only ladder knob readers
453
-
454
- **Files:**
455
- - Create: `src/core/test-knobs.mjs`, `test/test-knobs.test.mjs`
456
- - Modify: `src/runtime/service.mjs:61-69`, `src/core/host-probe.mjs:17-19`, `src/core/pty-attach-reconnect.mjs:12-14`
457
-
458
- **Interfaces:**
459
- - Produces: `resolveTestMs(env, name, defaultMs)` plus per-constant readers `resolveHostStartGraceMs / resolveHostRecoveryGraceMs / resolveHostRecoveryPollMs / resolveAttachResolveTimeoutMs / resolveHostProbeTimeoutMs / resolveAttachReconnectTimeoutMs` (each `(env = process.env) → number`). Exported constants KEEP their names and default values (compat).
460
-
461
- - [ ] **Step 1: Failing tests** — `test/test-knobs.test.mjs`: unset/empty ⇒ default; `"250"` ⇒ 250; invalid (`"abc"`, `"-5"`) ⇒ default AND a one-shot stderr warning (monkeypatch `process.stderr.write` to collect; second invalid call for the same knob adds no second warning); each per-constant reader: `{}` ⇒ exact default (`10_000`, `5_000`, `150`, `120_000`, `250`, `15_000`), env-set ⇒ override; module-level exports `HOST_START_GRACE_MS` etc. still equal the defaults (env unset).
462
- - [ ] **Step 2: RED run.**
463
- - [ ] **Step 3: Implement `src/core/test-knobs.mjs`:**
464
-
465
- ```js
466
- /** Test-only wall-clock knob readers (issue #95 F4). Ladder constants gain a
467
- * dynamic reader so tests can compress production grace periods instead of
468
- * waiting them out. Unset ⇒ default (production byte-identical). Invalid ⇒
469
- * default plus a one-shot stderr warning (no diagnostics root at this layer). */
470
- const warned = new Set();
471
-
472
- /** @param {NodeJS.ProcessEnv} env @param {string} name @param {number} defaultMs @returns {number} */
473
- export function resolveTestMs(env, name, defaultMs) {
474
- const raw = env[name];
475
- if (raw == null || raw === "") return defaultMs;
476
- const value = Number(raw);
477
- if (!Number.isFinite(value) || value < 0) {
478
- if (!warned.has(name)) {
479
- warned.add(name);
480
- process.stderr.write(`test-knobs: ignoring invalid ${name}=${JSON.stringify(raw)}; using default ${defaultMs}ms\n`);
481
- }
482
- return defaultMs;
483
- }
484
- return value;
485
- }
486
- ```
487
-
488
- Then in `src/runtime/service.mjs`: import `resolveTestMs`; add the four readers next to the constants; **switch the ladder USE SITES to reader calls** (dynamic — module-load evaluation would make in-process knobs dead): grep every reference of the four constant names inside service.mjs and replace with the reader call (e.g. `now - (row.host.claimAt ?? ...) < resolveHostStartGraceMs()`); keep the `export const` definitions (unchanged values) for compat, and check `rg -n "HOST_START_GRACE_MS|HOST_RECOVERY_GRACE_MS|HOST_RECOVERY_POLL_MS|ATTACH_RESOLVE_TIMEOUT_MS" src/ runner/ test/` for external importers — if any test imports them, leave those imports valid. Same pattern in `host-probe.mjs` (HOST_PROBE_TIMEOUT_MS, default 250) and `pty-attach-reconnect.mjs` (ATTACH_RECONNECT_TIMEOUT_MS, default 15_000).
489
- - [ ] **Step 4:** GREEN on `node --test test/test-knobs.test.mjs`; then `node --test test/host-concurrency.integration.test.mjs test/runner.integration.test.mjs test/service.test.mjs test/host-probe.test.mjs test/pty-attach-reconnect.test.mjs` (adapt list to existing files) all green — proving unset ⇒ identical behavior.
490
- - [ ] **Step 5:** Commit — `git add src/core/test-knobs.mjs test/test-knobs.test.mjs src/runtime/service.mjs src/core/host-probe.mjs src/core/pty-attach-reconnect.mjs && git commit -m "feat: test-only ladder knobs with dynamic use-site readers (issue #95)"`
491
-
492
- ---
493
-
494
- ### Task 5: F4 A7 — knobs compress the recovery chain
495
-
496
- **Files:**
497
- - Modify: `test/host-concurrency.integration.test.mjs` (new test)
498
-
499
- - [ ] **Step 1:** Append after A10 (same fixture shape):
500
-
501
- ```js
502
- test("A7: ladder knobs compress the A10 recovery chain without weakening it", { skip: !hasNodePty }, async () => {
503
- process.env.AGENT_BOARD_TEST_HOST_START_GRACE_MS = "1000";
504
- process.env.AGENT_BOARD_TEST_HOST_RECOVERY_GRACE_MS = "500";
505
- process.env.AGENT_BOARD_TEST_HOST_RECOVERY_POLL_MS = "50";
506
- process.env.AGENT_BOARD_TEST_ATTACH_RESOLVE_TIMEOUT_MS = "30000";
507
- const root = freshRoot();
508
- try {
509
- createView(root, { id: "v1", name: "knob", cwd: process.cwd() });
510
- ensureSessionFile(root, "v1");
511
- const first = await runHelper(root, "v1");
512
- assert.equal(first.result?.started, true, `first helper started: ${JSON.stringify(first)}`);
513
- const original = await waitFor(() => {
514
- const h = readHost(root, "v1");
515
- return h?.state === "alive" && h.readyAt != null && h.childPid && isAlive(h.runnerPid) ? h : false;
516
- }, 30000, () => formatPostmortem(capturePostmortem(root, "v1", null)));
517
- process.kill(original.runnerPid, "SIGKILL");
518
- await waitFor(() => !isAlive(original.runnerPid), 10000, () => formatPostmortem(capturePostmortem(root, "v1", null)));
519
- // The compressed chain must converge well inside the 30s resolve budget —
520
- // the same budget the production default would need 150s of headroom for.
521
- const service = testService(root);
522
- const resolved = await service.resolveAttachTarget("v1", { timeoutMs: 30_000 });
523
- assert.equal(resolved.kind, "pty", `resolver produced a pty target: ${JSON.stringify(resolved)}`);
524
- assert.notEqual(resolved.instanceId, original.instanceId, "replacement is a new instance");
525
- assert.equal(isAlive(original.childPid), false, "old child is dead once the resolver returns");
526
- } finally {
527
- delete process.env.AGENT_BOARD_TEST_HOST_START_GRACE_MS;
528
- delete process.env.AGENT_BOARD_TEST_HOST_RECOVERY_GRACE_MS;
529
- delete process.env.AGENT_BOARD_TEST_HOST_RECOVERY_POLL_MS;
530
- delete process.env.AGENT_BOARD_TEST_ATTACH_RESOLVE_TIMEOUT_MS;
531
- await teardownHost(root, "v1", testService(root)).catch(() => {});
532
- rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
533
- }
534
- });
535
- ```
536
-
537
- (Verify the exact `freshRoot`/`ensureSessionFile`/`runHelper`/`teardownHost` names against the file; `rmSync`/`readHost`/`isAlive`/`waitFor` are already imported there — add what's missing. If the 30s budget still proves too tight on this machine, lower only the knob values, never the assertion.)
538
- - [ ] **Step 2:** `node --test test/host-concurrency.integration.test.mjs` → all green including A7 and the untouched A10.
539
- - [ ] **Step 3:** Red-world: set `AGENT_BOARD_TEST_ATTACH_RESOLVE_TIMEOUT_MS = "3000"` in a temporary copy of the fixture with a `timeoutMs: 3000` resolve — expect it to time out (proves the knob actually governs the budget). Restore; record outputs.
540
- - [ ] **Step 4:** Commit — `git add test/host-concurrency.integration.test.mjs && git commit -m "test: A7 knob-compressed recovery chain (issue #95)"`
541
-
542
- ---
543
-
544
- ### Task 6: Full gate + evidence
545
-
546
- - [ ] `npm run verify` → exit 0 (coverage thresholds may need the new files' branches — if a threshold fails on the new modules, add the missing branch tests, never lower thresholds).
547
- - [ ] Record A5 (`node --test test/budget-audit.test.mjs`), A6 (`node --test test/flake-postmortem.test.mjs`), A7 (host-concurrency suite) evidence lines for the PR body.
548
- - [ ] No commit unless a defect surfaces (then fix per task rules).
549
-
550
- ## Self-Review
551
-
552
- - Spec coverage: F3 → Tasks 1–2 (A6); F2 → Task 3 (A5, two-inversion handling: the A10 deadline gets the escape comment; runner.integration:282 needs NO change under max+margin — recorded here as a deliberate no-op with the arithmetic: 20000 ≥ 15000+5000); F4 → Tasks 4–5 (A7); A8 → Task 6. F5/F6/F7 excluded per spec delivery-status.
553
- - Placeholders: none; every step carries code or an exact command with expected output.
554
- - Type consistency: reader/option names identical across Tasks 1→2 and 4→5 (`waitForWithPostmortem` opts; knob env names verbatim).
555
- - Review Focus: all five pinned inline.