@zhuxixi/pi-agent-board 0.8.0 → 0.9.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.
@@ -0,0 +1,698 @@
1
+ # #140 control-reconcile 偶红收敛 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 two load-sensitive assertions in `test/control-reconcile.integration.test.mjs` (signatures A `:295` / B `:383`) deterministic and invariant-true, by windowing the test recorder and realigning assertions to what the design actually guarantees; product change is one comment only.
6
+
7
+ **Architecture:** The test harness's cross-socket `messages`/`events` accumulators get a `mark()/since()` window API (extracted as a pure `createRecorder()`), assertion logic moves into pure predicates (`distinctSeqsFrom` / `hasWireOverlap` / `isContiguousFrom`), and two deterministic interleaving cases are added via harness-side injection seams (`deferWrite` / `deferFeed`, default 0 = zero behavior change). Spec: `docs/superpowers/specs/2026-09-24-control-reconcile-flake-design.md` (v3, approved).
8
+
9
+ **Tech Stack:** Node built-ins only (`node:test`, `node:assert/strict`, `node:net`, `node:fs`). No new dependencies.
10
+
11
+ **Acceptance-ID ↔ Task mapping:** A1,A2→Task1 · A9→Task2 · A3→Task3 · A4→Task4 · A5→Task5 · A6→Task6 · D6,A9→Task7 · A7,A8→Task8. No acceptance item is taskless; no task is acceptance-less.
12
+
13
+ ## Global Constraints
14
+
15
+ - **Work from:** `/home/elling/git-repo/github/pi-agent-board/.pi/worktrees/issue-140-control-reconcile-flake` (absolute paths; never touch the main checkout).
16
+ - **Product behavior is frozen:** the ONLY product-file change is the comment realignment in Task 7 (spec D6). No behavioral edits to `src/` or `runner/`.
17
+ - New logic (predicates, recorder, injection seams, new test cases) lives in `test/control-reconcile.integration.test.mjs` only.
18
+ - Injection seams default to no-op (`0` delay); existing tests must stay byte-for-byte equivalent in behavior when no injection is armed.
19
+ - Test names in the affected set keep matching `--test-name-pattern="A2: reconnect|A2 epoch"` (the §10 test is intentionally out of the stability loop).
20
+ - Stage per file (`git add <path>`), never `git add -A`. Conventional commits, English.
21
+ - Steady tick is 25ms (`test-support/fake-pty-pi.mjs:16`); deferral margins are `150`ms (subscribe write, ≈6 ticks, measured 3/3) and `300`ms (snapshot-path feed). Do not lower them.
22
+
23
+ ---
24
+
25
+ ### Task 1: Seq-window predicates + `createRecorder()` (A1, A2)
26
+
27
+ **Files:**
28
+ - Modify: `test/control-reconcile.integration.test.mjs` (insert after the `listen()` helper, before `let instanceCounter = 0;`)
29
+
30
+ **Interfaces:**
31
+ - Produces (consumed by Tasks 2–6):
32
+ - `distinctSeqsFrom(seqs: number[], from: number): number[]` — distinct seqs strictly `> from`, first-occurrence order.
33
+ - `hasWireOverlap(seqs: number[]): boolean` — true iff any seq repeats.
34
+ - `isContiguousFrom(distinct: number[], from: number, count: number): boolean` — first `count` distinct seqs are exactly `from+1..from+count`.
35
+ - `createRecorder(): { messages: any[]; events: {event: string, payload: any}[]; mark(): {m: number, e: number}; messagesSince(mark): any[]; eventsSince(mark): {event: string, payload: any}[] }`
36
+
37
+ - [ ] **Step 1: Record the baseline**
38
+
39
+ Run: `node --test test/control-reconcile.integration.test.mjs 2>&1 | tail -5`
40
+ Expected: all tests pass; note the `# pass`/`# fail` counts for the PR description.
41
+
42
+ - [ ] **Step 2: Write the failing unit tests**
43
+
44
+ Insert directly above `let instanceCounter = 0;`:
45
+
46
+ ```js
47
+ // --- #140: seq-window predicates (pure) and the windowed recorder ----------
48
+
49
+ test("seq window predicates: distinctSeqsFrom keeps first-occurrence order and drops <= from", () => {
50
+ assert.deepEqual(distinctSeqsFrom([7, 7, 8, 9, 7], 6), [7, 8, 9]);
51
+ assert.deepEqual(distinctSeqsFrom([9, 10, 9, 11], 8), [9, 10, 11]);
52
+ assert.deepEqual(distinctSeqsFrom([13, 14, 15, 16, 17, 18, 13, 14, 15, 16, 17, 18], 12), [13, 14, 15, 16, 17, 18]);
53
+ assert.deepEqual(distinctSeqsFrom([1, 2, 3], 3), []); // boundary: `from` itself excluded
54
+ });
55
+
56
+ test("seq window predicates: hasWireOverlap", () => {
57
+ assert.equal(hasWireOverlap([7, 7, 8]), true);
58
+ assert.equal(hasWireOverlap([7, 8, 9]), false);
59
+ assert.equal(hasWireOverlap([]), false);
60
+ });
61
+
62
+ test("seq window predicates: isContiguousFrom", () => {
63
+ assert.equal(isContiguousFrom([7, 8, 9], 6, 3), true);
64
+ assert.equal(isContiguousFrom([8, 9, 10], 6, 3), false); // replay start too high (gap at 7)
65
+ assert.equal(isContiguousFrom([7, 9, 10], 6, 3), false); // a dropped chunk
66
+ assert.equal(isContiguousFrom([7, 8], 6, 3), false); // not enough yet
67
+ });
68
+
69
+ test("recorder window: since(mark) excludes everything before the mark", () => {
70
+ const rec = createRecorder();
71
+ rec.messages.push({ type: "hello" });
72
+ rec.events.push({ event: "snapshotReady", payload: { nextSeq: 1 } });
73
+ const mark = rec.mark();
74
+ rec.messages.push({ type: "output", seq: 7 });
75
+ rec.events.push({ event: "output", payload: "x" });
76
+ assert.equal(rec.messagesSince(mark).length, 1);
77
+ assert.equal(rec.messagesSince(mark)[0].seq, 7);
78
+ assert.equal(rec.eventsSince(mark)[0].event, "output");
79
+ assert.equal(rec.messages.length, 2); // live arrays: legacy accessors keep working
80
+ });
81
+ ```
82
+
83
+ - [ ] **Step 3: Run to verify failure**
84
+
85
+ Run: `node --test --test-name-pattern="seq window predicates|recorder window" test/control-reconcile.integration.test.mjs`
86
+ Expected: FAIL — `ReferenceError: distinctSeqsFrom is not defined` (or equivalent).
87
+
88
+ - [ ] **Step 4: Implement (same insertion point, above the tests)**
89
+
90
+ ```js
91
+ /** Distinct seqs strictly greater than `from`, in first-occurrence order. */
92
+ function distinctSeqsFrom(seqs, from) {
93
+ const seen = new Set();
94
+ const out = [];
95
+ for (const s of seqs) {
96
+ if (typeof s !== "number" || s <= from) continue;
97
+ if (!seen.has(s)) {
98
+ seen.add(s);
99
+ out.push(s);
100
+ }
101
+ }
102
+ return out;
103
+ }
104
+
105
+ /** True when the wire repeated at least one seq — the broadcast→replay overlap. */
106
+ function hasWireOverlap(seqs) {
107
+ return new Set(seqs).size !== seqs.length;
108
+ }
109
+
110
+ /** The first `count` distinct seqs must be exactly from+1 .. from+count (no gap). */
111
+ function isContiguousFrom(distinct, from, count) {
112
+ if (distinct.length < count) return false;
113
+ for (let i = 0; i < count; i += 1) {
114
+ if (distinct[i] !== from + i + 1) return false;
115
+ }
116
+ return true;
117
+ }
118
+
119
+ /**
120
+ * Cross-socket wire/event recorder with windows (#140): `mark()` snapshots the
121
+ * current lengths; `messagesSince(mark)` / `eventsSince(mark)` scope reads to
122
+ * everything recorded AFTER the mark, so pre-reconnect residue can never leak
123
+ * into post-reconnect assertions. `messages` / `events` stay live arrays for
124
+ * the legacy accessors.
125
+ */
126
+ function createRecorder() {
127
+ const messages = [];
128
+ const events = [];
129
+ return {
130
+ messages,
131
+ events,
132
+ mark: () => ({ m: messages.length, e: events.length }),
133
+ messagesSince: (mark) => messages.slice(mark.m),
134
+ eventsSince: (mark) => events.slice(mark.e),
135
+ };
136
+ }
137
+ ```
138
+
139
+ - [ ] **Step 5: Run to verify pass**
140
+
141
+ Run: `node --test --test-name-pattern="seq window predicates|recorder window" test/control-reconcile.integration.test.mjs`
142
+ Expected: 4 tests PASS.
143
+
144
+ - [ ] **Step 6: Commit**
145
+
146
+ ```bash
147
+ git add test/control-reconcile.integration.test.mjs
148
+ git commit -m "test: seq-window predicates + windowed recorder (issue #140, A1/A2)"
149
+ ```
150
+
151
+ ---
152
+
153
+ ### Task 2: Wire the harness to the recorder + injection seams (A9 regression gate; enabler for A3–A6)
154
+
155
+ **Files:**
156
+ - Modify: `test/control-reconcile.integration.test.mjs` — `attachClientOverSocket()` only
157
+
158
+ **Interfaces:**
159
+ - Consumes: `createRecorder()` (Task 1).
160
+ - Produces (consumed by Tasks 3–6):
161
+ - `attachClientOverSocket(socket, { clientId?, deferWrite?: (msg) => number, deferFeed?: (msg) => number })`
162
+ - `h.mark()`, `h.messagesSince(mark)`, `h.eventsSince(mark)` (new accessors; all existing accessors unchanged)
163
+
164
+ - [ ] **Step 1: Change the harness signature and internals**
165
+
166
+ Replace the first lines of `attachClientOverSocket`:
167
+
168
+ ```js
169
+ function attachClientOverSocket(socket, { clientId = "reconcile-ui", deferWrite = () => 0, deferFeed = () => 0 } = {}) {
170
+ const sent = [];
171
+ const rec = createRecorder();
172
+ const { messages, events } = rec;
173
+ let buf = "";
174
+ let current = socket;
175
+ const route = (msg) => {
176
+ sent.push(msg);
177
+ // Injection seam (#140 A4): defer the WRITE only. `sent` records before
178
+ // the deferral, so wire-order assertions stay truthful.
179
+ const writeDelay = deferWrite(msg);
180
+ if (writeDelay > 0) {
181
+ const target = current;
182
+ setTimeout(() => {
183
+ try { target.write(JSON.stringify(msg) + "\n"); } catch { /* socket death is the close handler's job */ }
184
+ }, writeDelay);
185
+ return;
186
+ }
187
+ try { current.write(JSON.stringify(msg) + "\n"); } catch { /* socket death is the close handler's job */ }
188
+ };
189
+ const feed = (chunk) => {
190
+ buf += chunk.toString("utf8");
191
+ const lines = buf.split("\n");
192
+ buf = lines.pop() ?? "";
193
+ for (const line of lines) {
194
+ if (!line.trim()) continue;
195
+ let msg;
196
+ try { msg = JSON.parse(line); } catch { continue; }
197
+ // Injection seam (#140 A6): defer BOTH the recording and the client
198
+ // handling — a slow consumer, for one message class only.
199
+ const feedDelay = deferFeed(msg);
200
+ if (feedDelay > 0) {
201
+ setTimeout(() => {
202
+ messages.push(msg);
203
+ client.handleMessage(msg);
204
+ }, feedDelay);
205
+ continue;
206
+ }
207
+ messages.push(msg);
208
+ client.handleMessage(msg);
209
+ }
210
+ };
211
+ ```
212
+
213
+ (The rest of the function — `createTerminalAttachClient`, `bind`, the return object — is unchanged.)
214
+
215
+ - [ ] **Step 2: Extend the returned object**
216
+
217
+ Inside the existing return object of `attachClientOverSocket`, add after `eventsOf`:
218
+
219
+ ```js
220
+ mark: () => rec.mark(),
221
+ messagesSince: (mark) => rec.messagesSince(mark),
222
+ eventsSince: (mark) => rec.eventsSince(mark),
223
+ ```
224
+
225
+ - [ ] **Step 3: Zero-behavior-change regression gate**
226
+
227
+ Run: `node --test test/control-reconcile.integration.test.mjs`
228
+ Expected: all tests pass (same counts as Task 1 Step 1). If any test differs, the seams are not no-ops — fix before proceeding.
229
+
230
+ - [ ] **Step 4: Recorder-use audit (spec §4 plan-stage audit item)**
231
+
232
+ Run: `rg -n "h\.(messages|events|outputSeqs|echoCount|eventsOf|mark|messagesSince|eventsSince)" test/control-reconcile.integration.test.mjs`
233
+ Classify every hit: (a) in-window (same socket era) → leave; (b) cross-reconnect → must go through a window or a count-growth wait. Expected conclusions to record in the task report:
234
+ - `:293-295` region → Task 3 rewrites.
235
+ - `:379-384` region → Task 5 rewrites.
236
+ - `echoCount("echo:resume-probe")` is safe *by coincidence* (the echo chunk's seq ≤ both disconnect cursors, so no replay re-sends it) — document, do not change.
237
+ - `eventsOf("hello")` lookups are already scoped by `generation` / `status.instanceId` → safe.
238
+ - The §10 test uses its own `mon.messages` listener, not this recorder → out of scope.
239
+
240
+ - [ ] **Step 5: Commit**
241
+
242
+ ```bash
243
+ git add test/control-reconcile.integration.test.mjs
244
+ git commit -m "test: windowed recorder wiring + deferWrite/deferFeed seams (issue #140)"
245
+ ```
246
+
247
+ ---
248
+
249
+ ### Task 3: Signature A assertion → no-gap over distinct seqs (A3)
250
+
251
+ **Files:**
252
+ - Modify: `test/control-reconcile.integration.test.mjs:288-295` (the A2 reconnect test's continuation block)
253
+
254
+ **Interfaces:**
255
+ - Consumes: `distinctSeqsFrom`, `isContiguousFrom` (Task 1).
256
+
257
+ - [ ] **Step 1: Replace the assertion block**
258
+
259
+ Replace:
260
+
261
+ ```js
262
+ // Gap-free, duplicate-free continuation after the reconnect.
263
+ await waitFor(() => h.outputSeqs().filter((s) => s > disconnectSeq).length >= 3);
264
+ const resumed = h.outputSeqs().filter((s) => s > disconnectSeq).slice(0, 3);
265
+ assert.deepEqual(resumed, [disconnectSeq + 1, disconnectSeq + 2, disconnectSeq + 3], "no gap, no duplicate after reconnect");
266
+ ```
267
+
268
+ with:
269
+
270
+ ```js
271
+ // Gap-free continuation after the reconnect. The WIRE may legally repeat
272
+ // seqs at the broadcast→replay handoff (#140 signature A): a stray the
273
+ // runner raw-wrote before processing our subscribe can only be DELIVERED
274
+ // after we sent it, so the client cannot fold it into the cursor — the
275
+ // ring replay re-sends it and seq-checked consumption dedups. The
276
+ // invariant is "no gap over the distinct seqs"; a UI-level duplicate is
277
+ // the marker guard's job below.
278
+ await waitFor(() => distinctSeqsFrom(h.outputSeqs(), disconnectSeq).length >= 3);
279
+ const distinct = distinctSeqsFrom(h.outputSeqs(), disconnectSeq);
280
+ assert.ok(
281
+ isContiguousFrom(distinct, disconnectSeq, 3),
282
+ `no gap after reconnect: distinct seqs past the cursor were ${JSON.stringify(distinct.slice(0, 8))}`,
283
+ );
284
+ await waitFor(() => h.client.getLastSeq() >= disconnectSeq + 3, 10000);
285
+ ```
286
+
287
+ (Leave the marker-guard block that follows untouched — it is the UI-level authority.)
288
+
289
+ - [ ] **Step 2: Run the test 5 times**
290
+
291
+ Run: `for i in 1 2 3 4 5; do node --test --test-name-pattern="A2: reconnect wires" test/control-reconcile.integration.test.mjs 2>&1 | grep -c "^not ok"; done`
292
+ Expected: `0` five times.
293
+
294
+ - [ ] **Step 3: Commit**
295
+
296
+ ```bash
297
+ git add test/control-reconcile.integration.test.mjs
298
+ git commit -m "test: reconnect continuation asserts no-gap over distinct seqs (issue #140, A3)"
299
+ ```
300
+
301
+ ---
302
+
303
+ ### Task 4: Deterministic overlap interleaving — second reconnect phase (A4)
304
+
305
+ **Files:**
306
+ - Modify: `test/control-reconcile.integration.test.mjs` — the A2 reconnect test: declaration line (`let socket2 = null;` → add `let socket3 = null;`), harness creation, the tail of the test body, and the `finally` block.
307
+
308
+ **Interfaces:**
309
+ - Consumes: `deferWrite` seam (Task 2), `distinctSeqsFrom`, `hasWireOverlap`, `isContiguousFrom` (Task 1), `h.mark()/messagesSince` (Task 2).
310
+
311
+ - [ ] **Step 1: Declare the arming flag and pass the seam**
312
+
313
+ Above `const h = attachClientOverSocket(socket1);` add:
314
+
315
+ ```js
316
+ let deferSecondSubscribe = false;
317
+ ```
318
+
319
+ and change the creation to:
320
+
321
+ ```js
322
+ const h = attachClientOverSocket(socket1, {
323
+ deferWrite: (msg) => (deferSecondSubscribe && msg.type === "subscribe_terminal" ? 150 : 0),
324
+ });
325
+ ```
326
+
327
+ - [ ] **Step 2: Add the phase-2 block**
328
+
329
+ Replace the trailing `socket2.destroy();` of the test body (the one after `assert.equal(h.echoCount("echo:resume-probe"), 1, ...)`) with:
330
+
331
+ ```js
332
+ // --- #140 signature A: deterministic overlap interleaving (A4) --------
333
+ // Second reconnect on the SAME runner, with the subscribe WRITE deferred
334
+ // past ~6 steady ticks (150ms >= 4x the 25ms tick; 40ms measured only
335
+ // 2/5). During the deferral the runner keeps raw-broadcasting to the
336
+ // unsubscribed socket — exactly the CI window. `sent` records before the
337
+ // deferral, so the wire-order assertion above stays truthful.
338
+ const drop2 = h.client.getLastSeq();
339
+ socket2.destroy();
340
+ socket3 = await connectControl(socketPath);
341
+ h.switchSocket(socket3);
342
+ const phase2 = h.mark();
343
+ deferSecondSubscribe = true;
344
+ h.hello();
345
+ h.client.reconnect(drop2);
346
+
347
+ // Non-vacuity: the overlap must actually appear (in-flight strays the
348
+ // client could not fold in, re-sent by the ring replay).
349
+ await waitFor(() => hasWireOverlap(h.outputSeqs().filter((s) => s > drop2)), 5000);
350
+ const distinct2 = distinctSeqsFrom(h.outputSeqs(), drop2);
351
+ assert.ok(
352
+ isContiguousFrom(distinct2, drop2, 3),
353
+ `no gap under the wire overlap: distinct seqs were ${JSON.stringify(distinct2.slice(0, 8))}`,
354
+ );
355
+ await waitFor(() => h.client.getLastSeq() >= drop2 + 3, 10000);
356
+ // UI exactly-once still holds under the overlap (marker guard, phase 2).
357
+ {
358
+ const wireMarkers2 = h.messagesSince(phase2)
359
+ .filter((m) => m.type === "output" && typeof m.seq === "number" && m.seq > drop2)
360
+ .flatMap((m) => String(m.data ?? "").match(/steady-\d+/g) ?? []);
361
+ const uiMarkers2 = h.events
362
+ .filter((e) => e.event === "output")
363
+ .flatMap((e) => String(e.payload ?? "").match(/steady-\d+/g) ?? []);
364
+ const uiCounts2 = new Map();
365
+ for (const mk of uiMarkers2) uiCounts2.set(mk, (uiCounts2.get(mk) ?? 0) + 1);
366
+ for (const mk of new Set(wireMarkers2)) {
367
+ assert.equal(uiCounts2.get(mk) ?? 0, 1, `steady marker ${mk} delivered to the UI exactly once (overlap phase)`);
368
+ }
369
+ }
370
+ socket3.destroy();
371
+ ```
372
+
373
+ In the `finally` block, extend the cleanup line to:
374
+
375
+ ```js
376
+ try { socket2?.destroy(); socket3?.destroy(); } catch {}
377
+ ```
378
+
379
+ - [ ] **Step 3: Run the test 5 times**
380
+
381
+ Run: `for i in 1 2 3 4 5; do node --test --test-reporter=tap --test-name-pattern="A2: reconnect wires" test/control-reconcile.integration.test.mjs 2>&1 | grep -c "^not ok"; done`
382
+ Expected: `0` five times, and each run exercises the overlap (the `waitFor(hasWireOverlap)` must not time out). If the non-vacuity wait proves flaky, apply spec D7: remove this phase, record the reason in the PR description, and note it in the issue.
383
+
384
+ - [ ] **Step 4: Commit**
385
+
386
+ ```bash
387
+ git add test/control-reconcile.integration.test.mjs
388
+ git commit -m "test: deterministic broadcast/replay overlap phase in A2 reconnect (issue #140, A4)"
389
+ ```
390
+
391
+ ---
392
+
393
+ ### Task 5: Signature B assertion → condition-based fresh-baseline wait (A5)
394
+
395
+ **Files:**
396
+ - Modify: `test/control-reconcile.integration.test.mjs` — the A2 epoch test: one insertion before `h.client.reconnect(disconnectSeq);`, one replacement at `:379-384`.
397
+
398
+ **Interfaces:**
399
+ - Consumes: none new (plain count-growth wait).
400
+
401
+ - [ ] **Step 1: Capture the baseline count BEFORE the reconnect**
402
+
403
+ Directly above `h.client.reconnect(disconnectSeq);` insert:
404
+
405
+ ```js
406
+ // Taken before the reconnect can possibly fire the epoch reset: the
407
+ // fresh snapshotReady must be counted from HERE, not from assertion
408
+ // time (the old `.at(-1)`-on-existence wait returned the stale
409
+ // pre-restart event instantly — #140 signature B).
410
+ const readyCountBeforeReconnect = h.eventsOf("snapshotReady").length;
411
+ ```
412
+
413
+ - [ ] **Step 2: Replace the assertion block**
414
+
415
+ Replace:
416
+
417
+ ```js
418
+ // The fresh baseline: snapshot (empty or framed) then live continuation
419
+ // from ITS nextSeq — the old cursor is gone.
420
+ const ready2 = await waitFor(() => h.eventsOf("snapshotReady").at(-1));
421
+ assert.equal(typeof ready2.nextSeq, "number");
422
+ await waitFor(() => h.outputSeqs().length >= 1, 10000);
423
+ assert.ok(h.outputSeqs().at(-1) >= ready2.nextSeq, "live output continues from the new baseline");
424
+ assert.ok(h.client.getLastSeq() >= ready2.nextSeq - 1);
425
+ ```
426
+
427
+ with:
428
+
429
+ ```js
430
+ // The fresh baseline: snapshot (empty or framed) then live continuation
431
+ // from ITS nextSeq — the old cursor is gone. Wait for the snapshotReady
432
+ // COUNT to grow (the old existence-check never waited), and assert the
433
+ // invariant client-level: the applied cursor reaches the new baseline
434
+ // and then advances with a live chunk beyond it. The old wire-level
435
+ // `.at(-1)` compared a cross-socket accumulator against a possibly
436
+ // stale baseline — not a valid invariant (#140 signature B).
437
+ const ready2 = await waitFor(
438
+ () => (h.eventsOf("snapshotReady").length > readyCountBeforeReconnect ? h.eventsOf("snapshotReady").at(-1) : null),
439
+ 10000,
440
+ );
441
+ assert.equal(typeof ready2.nextSeq, "number");
442
+ await waitFor(() => h.client.getLastSeq() >= ready2.nextSeq, 10000);
443
+ ```
444
+
445
+ - [ ] **Step 3: Run the epoch test 5 times**
446
+
447
+ Run: `for i in 1 2 3 4 5; do node --test --test-reporter=tap --test-name-pattern="A2 epoch" test/control-reconcile.integration.test.mjs 2>&1 | grep -c "^not ok"; done`
448
+ Expected: `0` five times.
449
+
450
+ - [ ] **Step 4: Commit**
451
+
452
+ ```bash
453
+ git add test/control-reconcile.integration.test.mjs
454
+ git commit -m "test: epoch fresh baseline waits for count growth, client-level invariant (issue #140, A5)"
455
+ ```
456
+
457
+ ---
458
+
459
+ ### Task 6: Deterministic slow-consumer epoch case (A6)
460
+
461
+ **Files:**
462
+ - Modify: `test/control-reconcile.integration.test.mjs` — new test inserted after the existing A2 epoch test.
463
+
464
+ **Interfaces:**
465
+ - Consumes: `deferFeed` seam (Task 2), count-growth wait pattern (Task 5).
466
+
467
+ - [ ] **Step 1: Add the test**
468
+
469
+ ```js
470
+ test("A2 epoch (slow consumer): the fresh baseline lags behind the epoch reset — stale-baseline window pinned (#140 A6)", async () => {
471
+ const root = freshRoot();
472
+ let runner;
473
+ let runner2;
474
+ let socket2 = null;
475
+ try {
476
+ // Same shape as the epoch test above, but the snapshot-path messages on
477
+ // the new socket are fed late (RELATIVE delay — a uniform delay shifts
478
+ // everything equally and proves nothing). The epoch reset fires
479
+ // immediately; the fresh baseline lags 300ms behind. That is exactly
480
+ // the window in which the old :383 assertion read a stale baseline.
481
+ let slowSnapshots = false;
482
+ const first = spawnOwnedRunner(root, "v1");
483
+ runner = first.runner;
484
+ await waitFor(() => hostReady(root, "v1"));
485
+ const socket1 = await connectControl(first.socketPath);
486
+ const h = attachClientOverSocket(socket1, {
487
+ deferFeed: (msg) => (slowSnapshots && typeof msg.type === "string" && msg.type.startsWith("snapshot") ? 300 : 0),
488
+ });
489
+ h.hello();
490
+ await waitFor(() => h.messages.some((m) => m.type === "hello" && m.generation));
491
+ h.client.start();
492
+ await waitFor(() => h.eventsOf("snapshotReady")[0]);
493
+ await waitFor(() => h.client.getLastSeq() >= 3, 10000);
494
+ const disconnectSeq = h.client.getLastSeq();
495
+
496
+ await stopRunner(runner);
497
+ reapChild(root, "v1");
498
+ const second = spawnOwnedRunner(root, "v1");
499
+ runner2 = second.runner;
500
+ await waitFor(() => hostReady(root, "v1"));
501
+
502
+ socket2 = await connectControl(second.socketPath);
503
+ h.switchSocket(socket2);
504
+ h.hello();
505
+ await waitFor(() => h.messages.some((m) => m.type === "hello" && m.status?.instanceId === second.instanceId));
506
+ const readyCount = h.eventsOf("snapshotReady").length;
507
+ slowSnapshots = true;
508
+ h.client.reconnect(disconnectSeq);
509
+
510
+ const reset = await waitFor(() => h.eventsOf("epochReset")[0]);
511
+ assert.equal(typeof reset.current, "string");
512
+ // Non-vacuity: we proceeded past the epoch reset while the fresh
513
+ // baseline was still in flight — the relative delay guarantees it. (A
514
+ // stray landing in resyncing can flip the client to live early; the
515
+ // delayed snapshot_begin then triggers a resync and the flow still
516
+ // converges — the 10s waits absorb that extra round trip.)
517
+ assert.equal(h.eventsOf("snapshotReady").length, readyCount, "no fresh snapshotReady yet (the lag window is real)");
518
+
519
+ const ready2 = await waitFor(
520
+ () => (h.eventsOf("snapshotReady").length > readyCount ? h.eventsOf("snapshotReady").at(-1) : null),
521
+ 10000,
522
+ );
523
+ assert.equal(typeof ready2.nextSeq, "number");
524
+ await waitFor(() => h.client.getLastSeq() >= ready2.nextSeq, 10000);
525
+
526
+ socket2.destroy();
527
+ } finally {
528
+ await stopRunner(runner);
529
+ await stopRunner(runner2);
530
+ reapChild(root, "v1");
531
+ try { socket2?.destroy(); } catch {}
532
+ rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
533
+ }
534
+ });
535
+ ```
536
+
537
+ - [ ] **Step 2: Run 5 times**
538
+
539
+ Run: `for i in 1 2 3 4 5; do node --test --test-reporter=tap --test-name-pattern="A2 epoch" test/control-reconcile.integration.test.mjs 2>&1 | grep -c "^not ok"; done`
540
+ Expected: `0` five times (both epoch tests run). If the non-vacuity assertion proves flaky, apply spec D7 (remove the test, record why).
541
+
542
+ - [ ] **Step 3: Commit**
543
+
544
+ ```bash
545
+ git add test/control-reconcile.integration.test.mjs
546
+ git commit -m "test: slow-consumer epoch case pins the stale-baseline window (issue #140, A6)"
547
+ ```
548
+
549
+ ---
550
+
551
+ ### Task 7: Product comment realignment + static/full-suite gate (D6, A9)
552
+
553
+ **Files:**
554
+ - Modify: `src/core/terminal-attach-client.mjs` — two comment blocks only.
555
+
556
+ **Interfaces:**
557
+ - Consumes: none. Produces: none (comment-only; no signature changes).
558
+
559
+ - [ ] **Step 1: Fix the over-promising comment in `issueReconnectSubscribe`**
560
+
561
+ Replace:
562
+
563
+ ```js
564
+ // The cursor must clear every stray the legacy broadcast already
565
+ // delivered to this socket during the gate (wire-level no-dup).
566
+ ```
567
+
568
+ with:
569
+
570
+ ```js
571
+ // The cursor must clear every stray the legacy broadcast already
572
+ // DELIVERED to this socket during the gate. Strays still in flight when
573
+ // the subscribe is written cannot be folded in: the ring replay re-sends
574
+ // them and seq-checked consumption dedups — the guarantee is UI-level
575
+ // exactly-once; a wire-level repeat at the broadcast→replay handoff is
576
+ // expected (#140).
577
+ ```
578
+
579
+ - [ ] **Step 2: Align the state-doc wording (`reconciling` block, ~:46-48)**
580
+
581
+ In the `reconciling` state doc comment, replace:
582
+
583
+ ```js
584
+ * they are legacy-broadcast strays that must reach the UI exactly once
585
+ * (strays cover (cursor, strayHighWater]; the replay starts past the
586
+ * high-water; see the reconciling output branch). Resolution:
587
+ ```
588
+
589
+ with:
590
+
591
+ ```js
592
+ * they are legacy-broadcast strays that must reach the UI exactly once
593
+ * (OBSERVED strays cover (cursor, strayHighWater]; the replay starts past
594
+ * the high-water; in-flight strays are re-sent by the replay and deduped —
595
+ * see the reconciling output branch). Resolution:
596
+ ```
597
+
598
+ (If the exact wording differs, apply the same edit semantically: mark the coverage as *observed* strays only.)
599
+
600
+ - [ ] **Step 3: Static + full-suite gate**
601
+
602
+ Run: `npm run typecheck && node --test`
603
+ Expected: typecheck 0 errors; suite passes (any failure must be an existing flake — isolate-rerun before attributing; record the final pass/fail counts against the Task 1 baseline).
604
+
605
+ - [ ] **Step 4: Commit**
606
+
607
+ ```bash
608
+ git add src/core/terminal-attach-client.mjs
609
+ git commit -m "docs: client comment states UI-level exactly-once, not wire-level no-dup (issue #140, D6)"
610
+ ```
611
+
612
+ ---
613
+
614
+ ### Task 8: Detection-power and stability evidence (A7, A8) — verification only, no commit
615
+
616
+ **Files:**
617
+ - Modify (temporarily, then revert): `src/core/terminal-attach-protocol.mjs`, `src/core/terminal-attach-client.mjs`
618
+
619
+ **Interfaces:** none (evidence gathering; results go into the PR description and the issue).
620
+
621
+ - [ ] **Step 1: Mutation 1 — a dropped replay chunk must redden the no-gap assertion**
622
+
623
+ Only `startReplay` is mutated (a bare `sed` would also hit the snapshot flush's identical loop):
624
+
625
+ ```bash
626
+ python3 - << 'EOF'
627
+ p = "src/core/terminal-attach-protocol.mjs"
628
+ s = open(p).read()
629
+ anchor = "function startReplay(sinceSeq) {"
630
+ i = s.index(anchor)
631
+ old = "for (const chunk of after.chunks) {"
632
+ j = s.index(old, i)
633
+ s = s[:j] + "for (const chunk of after.chunks.slice(1)) {" + s[j + len(old):]
634
+ assert "after.chunks.slice(1)" in s
635
+ open(p, "w").write(s)
636
+ print("mutated startReplay only")
637
+ EOF
638
+ node --test --test-name-pattern="A2: reconnect" test/control-reconcile.integration.test.mjs 2>&1 | grep -E "^not ok|no gap" | head -3
639
+ git checkout -- src/core/terminal-attach-protocol.mjs
640
+ ```
641
+ Expected: FAIL with `no gap after reconnect` (the mutated replay skips the first retained chunk → distinct starts at cursor+2).
642
+
643
+ - [ ] **Step 2: Mutation 2 — a UI double-emit must redden the marker guard**
644
+
645
+ In `src/core/terminal-attach-client.mjs`, in the `live` output branch, duplicate the emit:
646
+
647
+ ```bash
648
+ python3 - << 'EOF'
649
+ import re
650
+ p = "src/core/terminal-attach-client.mjs"
651
+ s = open(p).read()
652
+ old = """ if (state === "live") {
653
+ if (typeof msg.seq !== "number") {"""
654
+ assert s.count(old) == 1
655
+ s = s.replace(""" if (msg.seq === lastSeq + 1) {
656
+ lastSeq = msg.seq;
657
+ emit("output", msg.data);
658
+ return true;
659
+ }""", """ if (msg.seq === lastSeq + 1) {
660
+ lastSeq = msg.seq;
661
+ emit("output", msg.data);
662
+ emit("output", msg.data); // MUTATION (#140 A7)
663
+ return true;
664
+ }""")
665
+ open(p, "w").write(s)
666
+ assert "MUTATION (#140 A7)" in open(p).read(), "mutation did not apply — check the anchor text"
667
+ print("mutated")
668
+ EOF
669
+ node --test --test-name-pattern="A2: reconnect" test/control-reconcile.integration.test.mjs 2>&1 | grep -E "^not ok|exactly once" | head -3
670
+ git checkout -- src/core/terminal-attach-client.mjs
671
+ git status --short # must be empty
672
+ ```
673
+ Expected: FAIL with `delivered to the UI exactly once`.
674
+
675
+ - [ ] **Step 3: A8 stability loop — 100 isolated runs of the affected set**
676
+
677
+ ```bash
678
+ fails=0
679
+ for i in $(seq 1 100); do
680
+ if ! timeout 120 node --test --test-reporter=tap --test-name-pattern="A2: reconnect|A2 epoch" test/control-reconcile.integration.test.mjs > /tmp/a8-$i.log 2>&1; then
681
+ fails=$((fails+1)); echo "FAIL run $i"; grep -m3 "^not ok\|error:" /tmp/a8-$i.log
682
+ fi
683
+ done
684
+ echo "A8: $fails failures / 100 runs"
685
+ ```
686
+ Expected: `A8: 0 failures / 100 runs` (~10 minutes). Statistical bound: at the pre-fix baseline failure rate (1/10), 100 green runs have probability ≈ 0.003%.
687
+
688
+ - [ ] **Step 4: Record the evidence**
689
+
690
+ No commit. Put A7 (both mutations reddened the right assertions) and A8 (`0/100`) into the PR description; also comment the outcome on issue #140.
691
+
692
+ ---
693
+
694
+ ## Self-Review (done at plan time)
695
+
696
+ - **Spec coverage:** D1→T1/T2, D2→T1, D3→T3, D4→T5, D5①→T4, D5②→T6, D6→T7, D7→contingency in T4/T6 steps; A1,A2→T1, A3→T3, A4→T4, A5→T5, A6→T6, A7,A8→T8, A9→T2(gate)/T7(static/full). No taskless acceptance, no acceptance-less task.
697
+ - **Placeholders:** none — every step carries exact code or exact commands.
698
+ - **Type consistency:** `distinctSeqsFrom`/`hasWireOverlap`/`isContiguousFrom`/`createRecorder`/`mark()/messagesSince/eventsSince`/`deferWrite`/`deferFeed` used with identical signatures across tasks; `socket3` declared in T4 and cleaned in T4's finally edit.