@zhuxixi/pi-agent-board 0.9.0 → 0.10.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 (37) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +10 -2
  3. package/VERIFY.md +2 -1
  4. package/docs/superpowers/plans/2026-09-29-issue-145-holding-semantic-state.md +1183 -0
  5. package/docs/superpowers/plans/2026-09-29-issue-145-user-verification.md +66 -0
  6. package/docs/superpowers/plans/2026-09-30-board-delete-key-x-y-confirm.md +649 -0
  7. package/docs/superpowers/plans/2026-09-30-issue-153-runner-stop-boot-window.md +618 -0
  8. package/docs/superpowers/plans/2026-09-30-issue-95-test-discipline.md +555 -0
  9. package/docs/superpowers/plans/2026-09-30-publish-dispatch-tag-check.md +259 -0
  10. package/docs/superpowers/specs/2026-09-29-issue-145-holding-semantic-state-design.md +394 -0
  11. package/docs/superpowers/specs/2026-09-30-board-delete-key-x-y-confirm-design.md +86 -0
  12. package/docs/superpowers/specs/2026-09-30-issue-153-runner-stop-boot-window-design.md +75 -0
  13. package/docs/superpowers/specs/2026-09-30-issue-95-test-discipline-design.md +226 -0
  14. package/docs/superpowers/specs/2026-09-30-publish-dispatch-tag-check-design.md +129 -0
  15. package/package.json +1 -1
  16. package/runner/job-runner.mjs +56 -22
  17. package/runner/pty-runner-legacy.mjs +1 -1
  18. package/runner/state-coordinator.mjs +1 -1
  19. package/runner/state-runner.mjs +2 -2
  20. package/scripts/budget-audit.mjs +135 -0
  21. package/src/core/auto-state.mjs +12 -10
  22. package/src/core/coordinator-protocol.mjs +3 -1
  23. package/src/core/derive.mjs +3 -0
  24. package/src/core/evidence.mjs +4 -1
  25. package/src/core/host-probe.mjs +12 -1
  26. package/src/core/pty-attach-reconnect.mjs +12 -1
  27. package/src/core/rows.mjs +4 -0
  28. package/src/core/state-commands.mjs +62 -8
  29. package/src/core/stop-latch.mjs +41 -0
  30. package/src/core/store.mjs +8 -1
  31. package/src/core/terminal-query-sequences.mjs +26 -0
  32. package/src/core/test-knobs.mjs +28 -0
  33. package/src/core/types.mjs +5 -2
  34. package/src/core/warm-host-sweeper.mjs +13 -0
  35. package/src/runtime/service.mjs +149 -28
  36. package/src/ui/dashboard.ts +46 -1
  37. package/src/ui/pty-attach.ts +46 -3
@@ -0,0 +1,555 @@
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.