@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.
- package/CHANGELOG.md +20 -0
- package/README.md +12 -11
- package/docs/RELEASE.md +148 -0
- package/docs/superpowers/plans/2026-09-23-a5-mid-stream-assertion.md +25 -0
- package/docs/superpowers/plans/2026-09-23-cwd-browse-tab-drill.md +393 -0
- package/docs/superpowers/plans/2026-09-23-terminal-query-forwarding.md +51 -0
- package/docs/superpowers/plans/2026-09-23-unconditional-detach-key.md +58 -0
- package/docs/superpowers/plans/2026-09-24-control-reconcile-flake.md +698 -0
- package/docs/superpowers/plans/2026-09-24-probe-attached-clients.md +605 -0
- package/docs/superpowers/specs/2026-09-23-a5-mid-stream-assertion-design.md +43 -0
- package/docs/superpowers/specs/2026-09-23-cwd-browse-tab-drill-design.md +95 -0
- package/docs/superpowers/specs/2026-09-23-terminal-query-forwarding-design.md +63 -0
- package/docs/superpowers/specs/2026-09-23-unconditional-detach-key-design.md +57 -0
- package/docs/superpowers/specs/2026-09-24-control-reconcile-flake-design.md +98 -0
- package/docs/superpowers/specs/2026-09-24-probe-attached-clients-design.md +190 -0
- package/package.json +1 -1
- package/runner/pty-runner.mjs +34 -21
- package/scripts/release.mjs +446 -0
- package/src/core/host-protocol.mjs +43 -5
- package/src/core/launch-options.mjs +37 -0
- package/src/core/terminal-attach-client.mjs +11 -5
- package/src/core/terminal-query-sequences.mjs +132 -0
- package/src/ui/dashboard.ts +20 -8
- package/src/ui/pty-attach.ts +84 -2
|
@@ -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.
|