@zhuxixi/pi-agent-board 0.6.1 → 0.7.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 +25 -0
- package/docs/superpowers/plans/2026-09-08-issue-11-attach-runtime-desync-heal.md +917 -0
- package/docs/superpowers/plans/2026-09-09-claimpid-blocks-replace.md +267 -0
- package/docs/superpowers/plans/2026-09-09-harden-runner-architecture.md +603 -0
- package/docs/superpowers/plans/2026-09-09-single-writer-completion.md +252 -0
- package/docs/superpowers/specs/2026-09-07-issue-11-attach-runtime-desync-heal-design.md +130 -0
- package/docs/superpowers/specs/2026-09-09-claimpid-blocks-replace-design.md +147 -0
- package/docs/superpowers/specs/2026-09-09-harden-runner-architecture-design.md +298 -0
- package/package.json +1 -1
- package/runner/job-runner-legacy.mjs +68 -0
- package/runner/job-runner.mjs +370 -67
- package/runner/pty-runner-legacy.mjs +50 -0
- package/runner/pty-runner.mjs +69 -31
- package/runner/state-coordinator.mjs +403 -0
- package/runner/state-runner.mjs +89 -15
- package/src/commands/bg.ts +2 -1
- package/src/core/coordinator-client.mjs +313 -0
- package/src/core/coordinator-journal.mjs +282 -0
- package/src/core/coordinator-protocol.mjs +12 -0
- package/src/core/host-coordination.mjs +9 -7
- package/src/core/launch.mjs +15 -0
- package/src/core/paths.mjs +16 -0
- package/src/core/pty-attach-jiggle-controller.mjs +57 -4
- package/src/core/pty-attach-render.mjs +30 -0
- package/src/core/state-commands.mjs +617 -0
- package/src/core/types.mjs +2 -0
- package/src/runtime/service.mjs +419 -114
- package/src/ui/dashboard.ts +77 -110
- package/src/ui/pty-attach.ts +63 -1
|
@@ -0,0 +1,917 @@
|
|
|
1
|
+
# Attach Runtime Desync Detect + Heal 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:** Detect runtime cursor desync (attach-settled, mid-session) via a 2s probe that checks the PTY cursor cell's inverse attribute, and self-heal by re-arming the shrink-and-hold jiggle protocol with rate limiting and a lifetime budget (issue #11).
|
|
6
|
+
|
|
7
|
+
**Architecture:** Three layers matching the existing codebase split: (1) pure classifier `detectCursorDesync()` in `src/core/pty-attach-render.mjs`; (2) protocol entry `heal()` on the existing jiggle controller in `src/core/pty-attach-jiggle-controller.mjs` reusing feed/clear detection and guards; (3) wiring in `src/ui/pty-attach.ts` — `lastOutputAt` timestamp, injectable `nowFn`, a `checkDesync()` method gated by 7 conditions, and a probe timer started at attach settle / stopped at close. Spec: `docs/superpowers/specs/2026-09-07-issue-11-attach-runtime-desync-heal-design.md`.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Node.js ESM (.mjs), TypeScript component compiled via `--experimental-transform-types`, `node:test` + `node:assert/strict`, `@xterm/headless` in smoke tests only.
|
|
10
|
+
|
|
11
|
+
## Global Constraints
|
|
12
|
+
|
|
13
|
+
- Parameter values (copy verbatim): `DESYNC_QUIET_MS = 1500`, `DESYNC_PROBE_INTERVAL_MS = 2000`, `HEAL_RATELIMIT_MS = 10000`, `HEAL_MAX_PER_LIFETIME = 5`.
|
|
14
|
+
- healBudget is consumed on **entry** to `heal()` (including the tiny-terminal give-up path); `start()`/`restoreAndStop()` never reset it.
|
|
15
|
+
- `heal()` preserves `tuiFrameSeen` and never arms G1; it must reuse `feed()`'s existing clear detection and the G3/G4/G5 guard behavior without changing `start()`'s semantics.
|
|
16
|
+
- `checkDesync()` gates, in order: (1) `!attaching && !closed`, (2) `tuiFrameSeen`, (3) `detectCursorDesync(...) === "misaligned"`, (4) `now - lastOutputAt > DESYNC_QUIET_MS`, (5) chain idle (`state.stopped && !held`), (6) `now - lastHealAt > HEAL_RATELIMIT_MS`, (7) `this.connected`.
|
|
17
|
+
- The probe is a self-contained timer (unref'd, cleared in `close()`), NOT hooked into the render path (render is event-driven and stops exactly when desync strikes).
|
|
18
|
+
- `lastOutputAt` is recorded synchronously in `pushOutput()` before `term.write`, never inside its async callback.
|
|
19
|
+
- Never commit to main; all work stays in this worktree branch. Commit messages use conventional commits. `git add` per-file, never `git add -A`.
|
|
20
|
+
- Test commands: `node --test test/pty-attach-render.test.mjs`, `node --test test/pty-attach-jiggle-controller.test.mjs`, `node --test test/pty-attach-desync-heal.test.mjs`, `node --test test/pty-attach-desync-health-e2e.test.mjs`, full suite `npm test`.
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
### Task 1: `detectCursorDesync` pure classifier (A1)
|
|
25
|
+
|
|
26
|
+
**Files:**
|
|
27
|
+
- Modify: `src/core/pty-attach-render.mjs` (append after `projectPtyCursor`)
|
|
28
|
+
- Test: `test/pty-attach-render.test.mjs` (append; add `detectCursorDesync` to the import list)
|
|
29
|
+
|
|
30
|
+
**Interfaces:**
|
|
31
|
+
- Consumes: the `{ row, col } | null` shape returned by the existing `projectPtyCursor(buf, start, height)` (same file).
|
|
32
|
+
- Produces: `detectCursorDesync(buf: { getLine(row): { length: number; getCell(x): { isInverse(): boolean; getWidth(): number } | undefined } | undefined }, cursor: { row: number; col: number } | null): "aligned" | "misaligned" | "unknown"` — consumed by Task 3's `checkDesync()`.
|
|
33
|
+
|
|
34
|
+
- [ ] **Step 1: Write the failing tests**
|
|
35
|
+
|
|
36
|
+
Append to `test/pty-attach-render.test.mjs` (extend the existing import statement with `detectCursorDesync`):
|
|
37
|
+
|
|
38
|
+
```js
|
|
39
|
+
// --- issue #11: runtime desync classifier ---
|
|
40
|
+
|
|
41
|
+
function fakeDesyncBuf(cells) {
|
|
42
|
+
// cells: array of { inverse: boolean, width: number } | null (null = no cell at x)
|
|
43
|
+
return {
|
|
44
|
+
getLine(row) {
|
|
45
|
+
if (row !== 0) return undefined;
|
|
46
|
+
return {
|
|
47
|
+
length: cells.length,
|
|
48
|
+
getCell(x) {
|
|
49
|
+
const c = cells[x];
|
|
50
|
+
if (!c) return undefined;
|
|
51
|
+
return { isInverse: () => c.inverse, getWidth: () => c.width };
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
test("detectCursorDesync: null cursor (viewport-scrolled) is unknown", () => {
|
|
59
|
+
assert.equal(detectCursorDesync(fakeDesyncBuf([]), null), "unknown");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("detectCursorDesync: cursor on an inverse cell is aligned", () => {
|
|
63
|
+
const buf = fakeDesyncBuf([{ inverse: false, width: 1 }, { inverse: true, width: 1 }]);
|
|
64
|
+
assert.equal(detectCursorDesync(buf, { row: 0, col: 1 }), "aligned");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("detectCursorDesync: cursor on a non-inverse cell is misaligned", () => {
|
|
68
|
+
const buf = fakeDesyncBuf([{ inverse: true, width: 1 }, { inverse: false, width: 1 }]);
|
|
69
|
+
assert.equal(detectCursorDesync(buf, { row: 0, col: 1 }), "misaligned");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("detectCursorDesync: cursor past line end falls back to the last inverse cell (aligned)", () => {
|
|
73
|
+
const buf = fakeDesyncBuf([{ inverse: false, width: 1 }, { inverse: true, width: 1 }]);
|
|
74
|
+
assert.equal(detectCursorDesync(buf, { row: 0, col: 99 }), "aligned");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("detectCursorDesync: cursor on a width-0 CJK continuation falls back to the leading wide cell", () => {
|
|
78
|
+
// "你" occupies cols 0-1: col 0 width 2 inverse, col 1 width 0 (continuation)
|
|
79
|
+
const buf = fakeDesyncBuf([{ inverse: true, width: 2 }, { inverse: false, width: 0 }, { inverse: false, width: 1 }]);
|
|
80
|
+
assert.equal(detectCursorDesync(buf, { row: 0, col: 1 }), "aligned");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("detectCursorDesync: fully empty line (no cells, no inverse) is misaligned", () => {
|
|
84
|
+
const buf = fakeDesyncBuf([]);
|
|
85
|
+
assert.equal(detectCursorDesync(buf, { row: 0, col: 0 }), "misaligned");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("detectCursorDesync: missing buffer line is unknown", () => {
|
|
89
|
+
assert.equal(detectCursorDesync(fakeDesyncBuf([{ inverse: true, width: 1 }]), { row: 5, col: 0 }), "unknown");
|
|
90
|
+
});
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
- [ ] **Step 2: Run tests to verify they fail**
|
|
94
|
+
|
|
95
|
+
Run: `cd /home/elling/git-repo/github/pi-agent-board/.pi/worktrees/issue-11-attach-runtime-desync-heal && node --test test/pty-attach-render.test.mjs`
|
|
96
|
+
Expected: FAIL — `detectCursorDesync` is not exported (import error).
|
|
97
|
+
|
|
98
|
+
- [ ] **Step 3: Write the implementation**
|
|
99
|
+
|
|
100
|
+
Append to `src/core/pty-attach-render.mjs`:
|
|
101
|
+
|
|
102
|
+
```js
|
|
103
|
+
/**
|
|
104
|
+
* Classify the PTY cursor's alignment for runtime desync detection (issue #11).
|
|
105
|
+
*
|
|
106
|
+
* Healthy idle pi: the child pi-tui parks the hardware cursor on the editor
|
|
107
|
+
* marker, whose cell is the inverse-video "fake cursor" — so the cursor cell
|
|
108
|
+
* itself is inverse. A desynced buffer leaves the cursor parked elsewhere
|
|
109
|
+
* (typically where the last differential write ended), on a non-inverse cell.
|
|
110
|
+
* Width-0 cells are CJK continuation cells and out-of-range columns sit past
|
|
111
|
+
* the line's cells; in both cases the meaningful attribute lives on the
|
|
112
|
+
* preceding cell, so we look left. Returns:
|
|
113
|
+
* "aligned" — cursor resolves to an inverse cell (healthy);
|
|
114
|
+
* "misaligned" — cursor resolves to a non-inverse cell (candidate desync;
|
|
115
|
+
* callers gate this with an output-quietness window);
|
|
116
|
+
* "unknown" — no cursor (scrolled out of the projected viewport) or no
|
|
117
|
+
* buffer line (defensive); never treat these as desync.
|
|
118
|
+
*/
|
|
119
|
+
export function detectCursorDesync(buf, cursor) {
|
|
120
|
+
if (!cursor) return "unknown";
|
|
121
|
+
const line = buf.getLine(cursor.row);
|
|
122
|
+
if (!line) return "unknown";
|
|
123
|
+
let x = cursor.col;
|
|
124
|
+
let cell = line.getCell(x);
|
|
125
|
+
while ((!cell || cell.getWidth() === 0) && x > 0) {
|
|
126
|
+
x--;
|
|
127
|
+
cell = line.getCell(x);
|
|
128
|
+
}
|
|
129
|
+
if (!cell) return "misaligned";
|
|
130
|
+
return cell.isInverse() ? "aligned" : "misaligned";
|
|
131
|
+
}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
- [ ] **Step 4: Run tests to verify they pass**
|
|
135
|
+
|
|
136
|
+
Run: `node --test test/pty-attach-render.test.mjs`
|
|
137
|
+
Expected: PASS (all new + existing tests).
|
|
138
|
+
|
|
139
|
+
- [ ] **Step 5: Commit**
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
git add src/core/pty-attach-render.mjs test/pty-attach-render.test.mjs
|
|
143
|
+
git commit -m "feat: detectCursorDesync three-state classifier for runtime desync (issue #11)"
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
148
|
+
### Task 2: controller `heal()` + lifetime budget (A2)
|
|
149
|
+
|
|
150
|
+
**Files:**
|
|
151
|
+
- Modify: `src/core/pty-attach-jiggle-controller.mjs` (add `HEAL_MAX_PER_LIFETIME` const, `healCount` state, `heal()` function; extend `getState()` and the returned API)
|
|
152
|
+
- Test: `test/pty-attach-jiggle-controller.test.mjs` (append)
|
|
153
|
+
|
|
154
|
+
**Interfaces:**
|
|
155
|
+
- Consumes: existing `createJiggleRetryState`, `stopRetry`, `resizeJiggleSize`, `scheduleNextRetry`, `clearAllTimers`, `restoreIfHeld`, and module state (`held`, `restored`, `state`, `carry`, `tuiFrameSeen`, `originalCols/Rows`, `holdSize`).
|
|
156
|
+
- Produces: `heal(cols: number, rows: number): boolean` — `true` when a heal hold was armed, `false` when the budget is exhausted or no valid hold size exists. `getState()` additionally exposes `healCount: number`. Consumed by Task 3's `checkDesync()`.
|
|
157
|
+
|
|
158
|
+
- [ ] **Step 1: Write the failing tests**
|
|
159
|
+
|
|
160
|
+
Append to `test/pty-attach-jiggle-controller.test.mjs`:
|
|
161
|
+
|
|
162
|
+
```js
|
|
163
|
+
// --- issue #11: runtime desync heal ---
|
|
164
|
+
|
|
165
|
+
function frameSeenController() {
|
|
166
|
+
const { controller, scheduler, resizes } = makeController();
|
|
167
|
+
controller.start(170, 36);
|
|
168
|
+
controller.feed("\x1b[?2026h"); // first TUI frame → restore via fast path
|
|
169
|
+
controller.feed("\x1b[2J"); // clear → chain done, runtime idle state
|
|
170
|
+
return { controller, scheduler, resizes };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
test("heal(): re-arms shrink-and-hold, preserves tuiFrameSeen, no G1", () => {
|
|
174
|
+
const { controller, scheduler, resizes } = frameSeenController();
|
|
175
|
+
resizes.length = 0;
|
|
176
|
+
assert.equal(controller.heal(170, 36), true);
|
|
177
|
+
assert.deepEqual(resizes, [[169, 35]], "heal sends exactly one shrink");
|
|
178
|
+
assert.equal(controller.getState().held, true);
|
|
179
|
+
assert.equal(controller.getState().tuiFrameSeen, true, "heal must NOT reset tuiFrameSeen");
|
|
180
|
+
assert.equal(scheduler.findByDelay(6000), null, "heal must NOT arm G1");
|
|
181
|
+
assert.ok(scheduler.delays().length > 0, "chain (G2 backoff) is scheduled");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("heal() then feed clear → restore + stop", () => {
|
|
185
|
+
const { controller, resizes } = frameSeenController();
|
|
186
|
+
resizes.length = 0;
|
|
187
|
+
controller.heal(170, 36);
|
|
188
|
+
controller.feed("redraw\x1b[2J\x1b[Hframe");
|
|
189
|
+
assert.deepEqual(resizes.slice(-1), [[170, 36]], "clear restores original size");
|
|
190
|
+
assert.equal(controller.getState().held, false);
|
|
191
|
+
assert.equal(controller.getState().clearDetected, true);
|
|
192
|
+
assert.equal(controller.getState().stopped, true);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("heal() budget: 5 attempts max per controller lifetime", () => {
|
|
196
|
+
const { controller, resizes } = frameSeenController();
|
|
197
|
+
resizes.length = 0;
|
|
198
|
+
for (let i = 0; i < 5; i++) {
|
|
199
|
+
assert.equal(controller.heal(170, 36), true, `heal #${i + 1} succeeds`);
|
|
200
|
+
// resolve each heal with a clear so the next one starts idle
|
|
201
|
+
controller.feed("\x1b[2J");
|
|
202
|
+
}
|
|
203
|
+
resizes.length = 0;
|
|
204
|
+
assert.equal(controller.heal(170, 36), false, "6th heal rejected");
|
|
205
|
+
assert.deepEqual(resizes, [], "no resize sent after budget exhausted");
|
|
206
|
+
assert.equal(controller.getState().healCount, 5);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("heal() budget is NOT reset by start() or restoreAndStop()", () => {
|
|
210
|
+
const { controller } = frameSeenController();
|
|
211
|
+
controller.heal(170, 36);
|
|
212
|
+
controller.feed("\x1b[2J");
|
|
213
|
+
controller.start(170, 36);
|
|
214
|
+
controller.feed("\x1b[2J");
|
|
215
|
+
controller.restoreAndStop();
|
|
216
|
+
assert.equal(controller.getState().healCount, 1);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("heal() consumes budget even on tiny terminals (no valid hold size)", () => {
|
|
220
|
+
const { controller } = frameSeenController();
|
|
221
|
+
assert.equal(controller.heal(20, 5), false, "tiny terminal cannot hold");
|
|
222
|
+
assert.equal(controller.getState().healCount, 1, "budget consumed on entry");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("heal() hold is cancelled by G4 notifyExternalResize", () => {
|
|
226
|
+
const { controller, resizes } = frameSeenController();
|
|
227
|
+
resizes.length = 0;
|
|
228
|
+
controller.heal(170, 36);
|
|
229
|
+
controller.notifyExternalResize(200, 50);
|
|
230
|
+
assert.equal(controller.getState().held, false);
|
|
231
|
+
assert.equal(controller.getState().stopped, true);
|
|
232
|
+
// G4 adopts the new size as original: a later heal restores to the new size
|
|
233
|
+
assert.equal(controller.heal(200, 50), true);
|
|
234
|
+
controller.feed("\x1b[2J");
|
|
235
|
+
assert.deepEqual(resizes.filter(([c]) => c === 200).length >= 1, true, "restore tracks the adopted size");
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("heal() while a previous heal is held: restores the old hold first", () => {
|
|
239
|
+
const { controller, resizes } = frameSeenController();
|
|
240
|
+
resizes.length = 0;
|
|
241
|
+
controller.heal(170, 36); // shrink #1
|
|
242
|
+
controller.heal(170, 36); // shrink #2 — must restore #1 first (no clear between)
|
|
243
|
+
assert.deepEqual(resizes, [[169, 35], [170, 36], [169, 35]], "second heal unwinds the first hold before re-shrinking");
|
|
244
|
+
assert.equal(controller.getState().healCount, 2);
|
|
245
|
+
});
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
- [ ] **Step 2: Run tests to verify they fail**
|
|
249
|
+
|
|
250
|
+
Run: `node --test test/pty-attach-jiggle-controller.test.mjs`
|
|
251
|
+
Expected: FAIL — `controller.heal is not a function`.
|
|
252
|
+
|
|
253
|
+
- [ ] **Step 3: Write the implementation**
|
|
254
|
+
|
|
255
|
+
In `src/core/pty-attach-jiggle-controller.mjs`:
|
|
256
|
+
|
|
257
|
+
3a. Add next to `POST_RESTORE_VERIFY_MS`:
|
|
258
|
+
|
|
259
|
+
```js
|
|
260
|
+
/** Lifetime cap on runtime desync heals (issue #11): a persistent misdiagnosis
|
|
261
|
+
* must not flicker the screen forever; after this many attempts the backstop
|
|
262
|
+
* stays quiet until the controller is recreated. Consumed on heal() entry. */
|
|
263
|
+
const HEAL_MAX_PER_LIFETIME = 5;
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
3b. Add state next to `let g1Timer = null;`:
|
|
267
|
+
|
|
268
|
+
```js
|
|
269
|
+
/** Runtime heals spent (issue #11); never reset by start()/restoreAndStop(). */
|
|
270
|
+
let healCount = 0;
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
3c. Add the method (place after `feed`, before `restoreAndStop`):
|
|
274
|
+
|
|
275
|
+
```js
|
|
276
|
+
/**
|
|
277
|
+
* Runtime desync backstop (issue #11): re-arm the shrink-and-hold protocol
|
|
278
|
+
* mid-session. Unlike start(), tuiFrameSeen is preserved (the child has
|
|
279
|
+
* rendered), G1 is not armed (frames are flowing), and the budget is
|
|
280
|
+
* lifetime-capped so a misdiagnosis cannot flicker the screen forever.
|
|
281
|
+
* Consumes one budget slot on entry, including the tiny-terminal give-up.
|
|
282
|
+
* @param {number} cols
|
|
283
|
+
* @param {number} rows
|
|
284
|
+
* @returns {boolean} true when a heal hold was armed.
|
|
285
|
+
*/
|
|
286
|
+
function heal(cols, rows) {
|
|
287
|
+
if (healCount >= HEAL_MAX_PER_LIFETIME) return false;
|
|
288
|
+
healCount++;
|
|
289
|
+
clearAllTimers();
|
|
290
|
+
if (held) {
|
|
291
|
+
// Unwind any live hold (e.g. a previous clear-less heal) first.
|
|
292
|
+
sendResize(originalCols, originalRows);
|
|
293
|
+
restored = true;
|
|
294
|
+
held = false;
|
|
295
|
+
}
|
|
296
|
+
state = createJiggleRetryState();
|
|
297
|
+
carry = "";
|
|
298
|
+
originalCols = cols;
|
|
299
|
+
originalRows = rows;
|
|
300
|
+
holdSize = resizeJiggleSize(cols, rows);
|
|
301
|
+
if (!holdSize) {
|
|
302
|
+
state = stopRetry(state);
|
|
303
|
+
held = false;
|
|
304
|
+
restored = true;
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
307
|
+
sendResize(holdSize.cols, holdSize.rows);
|
|
308
|
+
held = true;
|
|
309
|
+
restored = false;
|
|
310
|
+
scheduleNextRetry(); // G2 backoff re-shrinks while a renderer is seen but no clear follows
|
|
311
|
+
return true;
|
|
312
|
+
}
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
3d. Extend `getState()` and the return object:
|
|
316
|
+
|
|
317
|
+
```js
|
|
318
|
+
getState: () => ({ ...state, held, tuiFrameSeen, originalCols, originalRows, holdSize, healCount }),
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
and add `heal` to the returned API object (`return { start, feed, heal, restoreAndStop, notifyExternalResize, getState };`).
|
|
322
|
+
|
|
323
|
+
- [ ] **Step 4: Run tests to verify they pass**
|
|
324
|
+
|
|
325
|
+
Run: `node --test test/pty-attach-jiggle-controller.test.mjs`
|
|
326
|
+
Expected: PASS (all new + existing tests).
|
|
327
|
+
|
|
328
|
+
- [ ] **Step 5: Commit**
|
|
329
|
+
|
|
330
|
+
```bash
|
|
331
|
+
git add src/core/pty-attach-jiggle-controller.mjs test/pty-attach-jiggle-controller.test.mjs
|
|
332
|
+
git commit -m "feat: jiggle controller heal() runtime backstop with lifetime budget (issue #11)"
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
---
|
|
336
|
+
|
|
337
|
+
### Task 3: component wiring — probe timer, `checkDesync()`, 7 gates (A3)
|
|
338
|
+
|
|
339
|
+
**Files:**
|
|
340
|
+
- Modify: `src/ui/pty-attach.ts` (constants near `ATTACH_OUTPUT_RENDER_INTERVAL_MS` import; fields near `viewportTop` (~L152); `pushOutput` (~L1050); `finishAttachTransition` (~L514); `close()` (~L1100); import `detectCursorDesync`)
|
|
341
|
+
- Create: `test-support/desync-heal-smoke.ts`
|
|
342
|
+
- Create: `test/pty-attach-desync-heal.test.mjs`
|
|
343
|
+
|
|
344
|
+
**Interfaces:**
|
|
345
|
+
- Consumes: `detectCursorDesync` (Task 1), `controller.heal()` / `getState().healCount` (Task 2), existing `projectPtyCursor`, `bodyHeight()`, `bottomViewportTop()`, `clampViewportTop()`, `this.jiggleRetry`, `this.cols/rows`, `this.connected`, `this.attaching`, `this.send`.
|
|
346
|
+
- Produces (runtime-injectable for tests, same pattern detach-gate-smoke already uses): fields `lastOutputAt: number`, `lastHealAt: number`, `nowFn: () => number`, plus private methods `checkDesync(): void`, `startDesyncProbe(): void`, `stopDesyncProbe(): void`. Smoke tests call `checkDesync()` and read `jiggleRetry.getState()` via `(attach as unknown as {...})` casts.
|
|
347
|
+
|
|
348
|
+
- [ ] **Step 1: Write the failing smoke harness**
|
|
349
|
+
|
|
350
|
+
Create `test-support/desync-heal-smoke.ts` (modeled on `detach-gate-smoke.ts` — real `PtyAttachComponent`, fake tui, runtime injection of private fields):
|
|
351
|
+
|
|
352
|
+
```ts
|
|
353
|
+
// Desync heal wiring harness (issue #11): construct PtyAttachComponent with a
|
|
354
|
+
// fake TUI, drive it through Pi-like buffer states via the real socket-data
|
|
355
|
+
// path (pushOutput + checkClearSequence), then verify the 7 gates of
|
|
356
|
+
// checkDesync() and the end-to-end heal loop:
|
|
357
|
+
// H1. healthy idle (cursor parked on inverse fake cursor) → no heal.
|
|
358
|
+
// H2. desync (cursor parked elsewhere) + quiet window → exactly one heal
|
|
359
|
+
// (shrink sent); immediate re-check is rate-limited + chain-idle-gated.
|
|
360
|
+
// H3. pre-settle (attaching) → no heal.
|
|
361
|
+
// H4. no TUI frame (tuiFrameSeen false) → no heal.
|
|
362
|
+
// H5. cursor scrolled out of viewport → no heal.
|
|
363
|
+
// H6. recent output (within DESYNC_QUIET_MS) → no heal.
|
|
364
|
+
// H7. heal loop: child redraws with clear + cursor back on the fake cursor
|
|
365
|
+
// → restore + no further heal.
|
|
366
|
+
// Timing/state facts the harness encodes (verified against the component and
|
|
367
|
+
// controller source):
|
|
368
|
+
// - checkDesync gate 7 needs this.connected — injected true (no real socket).
|
|
369
|
+
// - The controller must sit in runtime-idle state (tuiFrameSeen=true from a
|
|
370
|
+
// 2026h frame, then chain stopped by a detected \x1b[2J) — feed() learns
|
|
371
|
+
// the frame BEFORE the clear; once clearDetected it short-circuits, and
|
|
372
|
+
// within one chunk a clear wins over a frame start.
|
|
373
|
+
// - The projected window is bottom-anchored (bottomViewportTop), so frames
|
|
374
|
+
// sink 25 newlines first to land the cursor inside [start, start+height).
|
|
375
|
+
// Run via `node --experimental-transform-types` (TS parameter properties).
|
|
376
|
+
import { PtyAttachComponent } from "../src/ui/pty-attach.ts";
|
|
377
|
+
|
|
378
|
+
const ESC = "\x1b";
|
|
379
|
+
/** Leading clear: stops the controller chain (runtime-idle state). */
|
|
380
|
+
const CLEAR = `${ESC}[2J${ESC}[H`;
|
|
381
|
+
/** Push content + cursor to the buffer bottom so they land inside the
|
|
382
|
+
* bottom-anchored projection window (25 > 24-row viewport → 1 line scrollback). */
|
|
383
|
+
const SINK = "\n".repeat(25);
|
|
384
|
+
const tui = {
|
|
385
|
+
terminal: { rows: 24, cols: 80, columns: 80, write: () => {} },
|
|
386
|
+
requestRender: () => {},
|
|
387
|
+
};
|
|
388
|
+
const theme = { fg: (_c: string, t: string) => t, bold: (t: string) => t };
|
|
389
|
+
const keybindings = {} as never;
|
|
390
|
+
|
|
391
|
+
type Drivable = {
|
|
392
|
+
pushOutput: (data: string) => void;
|
|
393
|
+
checkClearSequence: (data: string) => void;
|
|
394
|
+
finishAttachTransition: () => void;
|
|
395
|
+
checkDesync: () => void;
|
|
396
|
+
attaching: boolean;
|
|
397
|
+
viewportTop: number | null;
|
|
398
|
+
jiggleRetry: { getState: () => { healCount: number; held: boolean; stopped: boolean; tuiFrameSeen: boolean }; feed: (data: string) => void };
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
function makeAttach() {
|
|
402
|
+
const attach = new PtyAttachComponent(
|
|
403
|
+
tui as never,
|
|
404
|
+
theme as never,
|
|
405
|
+
keybindings,
|
|
406
|
+
() => {},
|
|
407
|
+
{ socketPath: "/no/such/socket", title: "desync" },
|
|
408
|
+
);
|
|
409
|
+
const sent: Array<Record<string, unknown>> = [];
|
|
410
|
+
(attach as unknown as { send: (msg: Record<string, unknown>) => void }).send = (msg) => sent.push(msg);
|
|
411
|
+
// Gate 7 needs a live connection; the fake socketPath would never connect.
|
|
412
|
+
(attach as unknown as { connected: boolean }).connected = true;
|
|
413
|
+
// Injectable clock: start at t=1_000_000 so "unset" (0) timestamps always look stale.
|
|
414
|
+
const clock = { now: 1_000_000 };
|
|
415
|
+
(attach as unknown as { nowFn: () => number }).nowFn = () => clock.now;
|
|
416
|
+
return { attach: attach as unknown as Drivable, sent, clock };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
async function write(attach: Drivable, data: string): Promise<void> {
|
|
420
|
+
// Mirror the real socket-data path (onSocketData → pushOutput + checkClearSequence):
|
|
421
|
+
// checkClearSequence feeds the jiggle controller (2026h frame detection,
|
|
422
|
+
// clear detection) — without it tuiFrameSeen stays false and gates have no teeth.
|
|
423
|
+
attach.pushOutput(data);
|
|
424
|
+
attach.checkClearSequence(data);
|
|
425
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** Bring the controller to the real runtime-idle state: 2026h frame seen
|
|
429
|
+
* first (tuiFrameSeen=true), then a detected clear (chain stopped). */
|
|
430
|
+
async function primeRuntime(attach: Drivable): Promise<void> {
|
|
431
|
+
await write(attach, `${ESC}[?2026h`);
|
|
432
|
+
await write(attach, CLEAR);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** Healthy idle frame: bottom line carries the inverse fake cursor and the
|
|
436
|
+
* hardware cursor is parked ON it (CUP 24;10 == the inverse cell). */
|
|
437
|
+
const healthyFrame = `${SINK}> editor \u4f60\u597d${ESC}[7m ${ESC}[0m${ESC}[24;7H${ESC}[7m ${ESC}[0m${ESC}[24;7H`;
|
|
438
|
+
/** Desynced frame: same content, but the cursor ends parked on a PLAIN cell
|
|
439
|
+
* one row above the inverse fake cursor (where a garbled differential write
|
|
440
|
+
* left it). */
|
|
441
|
+
const desyncFrame = `${SINK}> editor \u4f60\u597d${ESC}[7m ${ESC}[0m${ESC}[24;7H${ESC}[7m ${ESC}[0m${ESC}[23;5H`;
|
|
442
|
+
/** Child response to a heal: fullRender-style clear + redraw + cursor parked
|
|
443
|
+
* back on the inverse fake cursor. */
|
|
444
|
+
const healResponse = `${ESC}[?2026h${ESC}[2J${ESC}[H${SINK}> editor \u4f60\u597d${ESC}[7m ${ESC}[0m${ESC}[24;7H${ESC}[7m ${ESC}[0m${ESC}[24;7H${ESC}[?2026l`;
|
|
445
|
+
|
|
446
|
+
async function main(): Promise<void> {
|
|
447
|
+
const out: Record<string, boolean> = {};
|
|
448
|
+
|
|
449
|
+
// H1: healthy idle — no heal
|
|
450
|
+
{
|
|
451
|
+
const { attach, sent, clock } = makeAttach();
|
|
452
|
+
await primeRuntime(attach);
|
|
453
|
+
await write(attach, healthyFrame);
|
|
454
|
+
attach.finishAttachTransition();
|
|
455
|
+
clock.now += 10_000; // all gates open — only gate 3 (aligned) can stop it
|
|
456
|
+
attach.checkDesync();
|
|
457
|
+
out.healthyIdleNoHeal = attach.jiggleRetry.getState().healCount === 0 && resizes(sent) === 0;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// H2: desync + quiet → exactly one heal; immediate re-check rate-limited + chain-idle-gated
|
|
461
|
+
{
|
|
462
|
+
const { attach, sent, clock } = makeAttach();
|
|
463
|
+
await primeRuntime(attach);
|
|
464
|
+
await write(attach, desyncFrame);
|
|
465
|
+
attach.finishAttachTransition();
|
|
466
|
+
clock.now += 10_000; // last output now older than DESYNC_QUIET_MS
|
|
467
|
+
attach.checkDesync();
|
|
468
|
+
const healed = attach.jiggleRetry.getState().healCount === 1 && resizes(sent) === 1;
|
|
469
|
+
attach.checkDesync(); // within 10s rate limit AND chain is active (held)
|
|
470
|
+
out.desyncHealsOnce = healed && attach.jiggleRetry.getState().healCount === 1 && resizes(sent) === 1;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// H3: pre-settle (attaching true) — no heal
|
|
474
|
+
{
|
|
475
|
+
const { attach, sent } = makeAttach();
|
|
476
|
+
await primeRuntime(attach);
|
|
477
|
+
await write(attach, desyncFrame);
|
|
478
|
+
// do NOT finishAttachTransition(); attaching is still true
|
|
479
|
+
attach.checkDesync();
|
|
480
|
+
out.preSettleNoHeal = attach.jiggleRetry.getState().healCount === 0 && resizes(sent) === 0;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// H4: no 2026h frame — no heal
|
|
484
|
+
{
|
|
485
|
+
const { attach, sent } = makeAttach();
|
|
486
|
+
// No primeRuntime (it would set tuiFrameSeen); chain is stopped via the
|
|
487
|
+
// clear so only gate 2 (no TUI frame) blocks the heal.
|
|
488
|
+
await write(attach, CLEAR + "plain shell output, no TUI frame");
|
|
489
|
+
attach.finishAttachTransition();
|
|
490
|
+
attach.checkDesync();
|
|
491
|
+
out.noFrameNoHeal = attach.jiggleRetry.getState().healCount === 0 && resizes(sent) === 0;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// H5: cursor scrolled out of the projected viewport — no heal
|
|
495
|
+
{
|
|
496
|
+
const { attach, sent, clock } = makeAttach();
|
|
497
|
+
await primeRuntime(attach);
|
|
498
|
+
// Establish scrollback well beyond the window (60 lines), leave the
|
|
499
|
+
// cursor on the bottom viewport row (absolute ≈ buf.length-1), then
|
|
500
|
+
// have the user scroll to the top: window [0,22) excludes the cursor.
|
|
501
|
+
let scroll = "";
|
|
502
|
+
for (let i = 0; i < 60; i++) scroll += `row ${i}\n`;
|
|
503
|
+
await write(attach, scroll + `${ESC}[24;5H`);
|
|
504
|
+
attach.finishAttachTransition();
|
|
505
|
+
(attach as unknown as { viewportTop: number | null }).viewportTop = 0; // user scrolled up
|
|
506
|
+
clock.now += 10_000; // all gates open — only the viewport (unknown) path can stop it
|
|
507
|
+
attach.checkDesync();
|
|
508
|
+
out.scrolledOutNoHeal = attach.jiggleRetry.getState().healCount === 0 && resizes(sent) === 0;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// H6: recent output (inside the quiet window) — no heal
|
|
512
|
+
{
|
|
513
|
+
const { attach, sent, clock } = makeAttach();
|
|
514
|
+
await primeRuntime(attach);
|
|
515
|
+
await write(attach, desyncFrame);
|
|
516
|
+
attach.finishAttachTransition();
|
|
517
|
+
clock.now += 100; // way less than DESYNC_QUIET_MS
|
|
518
|
+
attach.checkDesync();
|
|
519
|
+
out.recentOutputNoHeal = attach.jiggleRetry.getState().healCount === 0 && resizes(sent) === 0;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// H7: heal loop closes — child clears + parks cursor on the fake cursor again
|
|
523
|
+
{
|
|
524
|
+
const { attach, sent, clock } = makeAttach();
|
|
525
|
+
await primeRuntime(attach);
|
|
526
|
+
await write(attach, desyncFrame);
|
|
527
|
+
attach.finishAttachTransition();
|
|
528
|
+
clock.now += 10_000;
|
|
529
|
+
attach.checkDesync();
|
|
530
|
+
const shrinkSeen = attach.jiggleRetry.getState().healCount === 1;
|
|
531
|
+
// Child answers the shrink with a fullRender: clear + redraw + cursor on fake cursor.
|
|
532
|
+
await write(attach, healResponse);
|
|
533
|
+
const restored = attach.jiggleRetry.getState().held === false && attach.jiggleRetry.getState().stopped === true;
|
|
534
|
+
clock.now += 10_000; // next probe tick: aligned now, and budget intact
|
|
535
|
+
attach.checkDesync();
|
|
536
|
+
out.healLoopCloses = shrinkSeen && restored && attach.jiggleRetry.getState().healCount === 1 && resizes(sent) === 2; // heal shrink + controller restore
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
console.log(JSON.stringify(out));
|
|
540
|
+
const allOk = Object.values(out).every(Boolean);
|
|
541
|
+
if (!allOk) process.exitCode = 1;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
void main();
|
|
545
|
+
```
|
|
546
|
+
|
|
547
|
+
- [ ] **Step 2: Write the failing wrapper test**
|
|
548
|
+
|
|
549
|
+
Create `test/pty-attach-desync-heal.test.mjs` (same execFileSync pattern as `test/pty-attach-detach-gate.test.mjs`):
|
|
550
|
+
|
|
551
|
+
```js
|
|
552
|
+
import assert from "node:assert/strict";
|
|
553
|
+
import { execFileSync } from "node:child_process";
|
|
554
|
+
import { fileURLToPath } from "node:url";
|
|
555
|
+
import { join } from "node:path";
|
|
556
|
+
import test from "node:test";
|
|
557
|
+
|
|
558
|
+
const ROOT_DIR = fileURLToPath(new URL("../", import.meta.url));
|
|
559
|
+
const SMOKE_SCRIPT = join(ROOT_DIR, "test-support", "desync-heal-smoke.ts");
|
|
560
|
+
|
|
561
|
+
// Issue #11: runtime desync detect + rate-limited heal — component wiring gates.
|
|
562
|
+
test("desync heal wiring: 7 gates + heal loop", () => {
|
|
563
|
+
const out = execFileSync(process.execPath, ["--experimental-transform-types", SMOKE_SCRIPT], {
|
|
564
|
+
encoding: "utf8",
|
|
565
|
+
timeout: 60_000,
|
|
566
|
+
});
|
|
567
|
+
const parsed = JSON.parse(out);
|
|
568
|
+
assert.equal(parsed.healthyIdleNoHeal, true, "H1 healthy idle must not heal");
|
|
569
|
+
assert.equal(parsed.desyncHealsOnce, true, "H2 desync must heal exactly once (rate limit + chain gate)");
|
|
570
|
+
assert.equal(parsed.preSettleNoHeal, true, "H3 pre-settle must not heal");
|
|
571
|
+
assert.equal(parsed.noFrameNoHeal, true, "H4 no TUI frame must not heal");
|
|
572
|
+
assert.equal(parsed.scrolledOutNoHeal, true, "H5 cursor out of viewport must not heal");
|
|
573
|
+
assert.equal(parsed.recentOutputNoHeal, true, "H6 recent output must not heal");
|
|
574
|
+
assert.equal(parsed.healLoopCloses, true, "H7 child clear must close the heal loop without a second heal");
|
|
575
|
+
});
|
|
576
|
+
```
|
|
577
|
+
|
|
578
|
+
- [ ] **Step 3: Run tests to verify they fail**
|
|
579
|
+
|
|
580
|
+
Run: `node --test test/pty-attach-desync-heal.test.mjs`
|
|
581
|
+
Expected: FAIL — compile error (`checkDesync` / `nowFn` not on the component) or assertion failures.
|
|
582
|
+
|
|
583
|
+
- [ ] **Step 4: Write the implementation**
|
|
584
|
+
|
|
585
|
+
In `src/ui/pty-attach.ts`:
|
|
586
|
+
|
|
587
|
+
4a. Extend the import from `../core/pty-attach-render.mjs` (~L10):
|
|
588
|
+
|
|
589
|
+
```ts
|
|
590
|
+
import { createAttachOutputRenderScheduler, detectCursorDesync, nextAttachRender, projectPtyCursor, shouldScheduleAttachRenderForMessage } from "../core/pty-attach-render.mjs";
|
|
591
|
+
```
|
|
592
|
+
|
|
593
|
+
4b. Add constants near the other module consts (search `GRACEFUL_SOCKET_CLOSE_MS` / `ATTACH_SETTLE_MS`):
|
|
594
|
+
|
|
595
|
+
```ts
|
|
596
|
+
/** Desync detection window: how long output must stay silent before a
|
|
597
|
+
* misaligned cursor counts as desync (issue #11). Streaming keeps the cursor
|
|
598
|
+
* on plain output cells legitimately; a healthy idle child re-parks it on the
|
|
599
|
+
* inverse fake-cursor cell on its last rendered frame. */
|
|
600
|
+
const DESYNC_QUIET_MS = 1500;
|
|
601
|
+
/** How often the post-settle probe runs checkDesync() (issue #11). */
|
|
602
|
+
const DESYNC_PROBE_INTERVAL_MS = 2000;
|
|
603
|
+
/** Minimum spacing between two runtime heals (issue #11). */
|
|
604
|
+
const HEAL_RATELIMIT_MS = 10000;
|
|
605
|
+
```
|
|
606
|
+
|
|
607
|
+
4c. Add fields near `private viewportTop: number | null = null;` (~L152):
|
|
608
|
+
|
|
609
|
+
```ts
|
|
610
|
+
private lastOutputAt = 0;
|
|
611
|
+
private lastHealAt = 0;
|
|
612
|
+
private desyncProbeTimer: ReturnType<typeof setInterval> | null = null;
|
|
613
|
+
/** Injectable clock for desync gating (tests override this). */
|
|
614
|
+
private nowFn: () => number = () => Date.now();
|
|
615
|
+
```
|
|
616
|
+
|
|
617
|
+
4d. In `pushOutput()` (~L1050), record the timestamp synchronously (add as the second statement, right after the `if (data.length === 0) return;` guard, BEFORE `term.write`):
|
|
618
|
+
|
|
619
|
+
```ts
|
|
620
|
+
this.lastOutputAt = this.nowFn();
|
|
621
|
+
```
|
|
622
|
+
|
|
623
|
+
4e. In `finishAttachTransition()` (~L514), add at the end (after `this.scheduleRender(true);`):
|
|
624
|
+
|
|
625
|
+
```ts
|
|
626
|
+
this.startDesyncProbe();
|
|
627
|
+
```
|
|
628
|
+
|
|
629
|
+
4f. Add the probe + check methods (place after `finishAttachTransition`):
|
|
630
|
+
|
|
631
|
+
```ts
|
|
632
|
+
private startDesyncProbe(): void {
|
|
633
|
+
this.stopDesyncProbe();
|
|
634
|
+
// The probe is deliberately NOT hooked into the render path: rendering is
|
|
635
|
+
// event-driven (socket output / keypress / resize) and stops exactly when
|
|
636
|
+
// desync strikes (output goes quiet). A self-contained timer is the only
|
|
637
|
+
// way an idle desynced screen gets its self-heal without user action.
|
|
638
|
+
this.desyncProbeTimer = setInterval(() => this.checkDesync(), DESYNC_PROBE_INTERVAL_MS);
|
|
639
|
+
this.desyncProbeTimer.unref?.();
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
private stopDesyncProbe(): void {
|
|
643
|
+
if (this.desyncProbeTimer) {
|
|
644
|
+
clearInterval(this.desyncProbeTimer);
|
|
645
|
+
this.desyncProbeTimer = null;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* Runtime desync backstop (issue #11). Seven gates, cheapest first:
|
|
651
|
+
* settled+connected, child is a TUI (frame seen), misaligned cursor,
|
|
652
|
+
* output quiet, chain idle, heal rate limit. All pass → heal() re-arms
|
|
653
|
+
* shrink-and-hold; the child's fullRender clear then restores the size
|
|
654
|
+
* and repaints a consistent screen.
|
|
655
|
+
*/
|
|
656
|
+
private checkDesync(): void {
|
|
657
|
+
if (this.closed || this.attaching || !this.connected) return; // gates 1, 7
|
|
658
|
+
const chain = this.jiggleRetry.getState();
|
|
659
|
+
if (!chain.tuiFrameSeen) return; // gate 2: shell/vim children never heal
|
|
660
|
+
if (!chain.stopped || chain.held) return; // gate 5: attach/heal chain active
|
|
661
|
+
const now = this.nowFn();
|
|
662
|
+
if (now - this.lastOutputAt <= DESYNC_QUIET_MS) return; // gate 4: streaming
|
|
663
|
+
if (now - this.lastHealAt <= HEAL_RATELIMIT_MS) return; // gate 6: rate limit
|
|
664
|
+
const height = this.bodyHeight();
|
|
665
|
+
this.clampViewportTop(height);
|
|
666
|
+
const start = this.viewportTop ?? this.bottomViewportTop(height);
|
|
667
|
+
const buf = this.term.buffer.active;
|
|
668
|
+
if (detectCursorDesync(buf, projectPtyCursor(buf, start, height)) !== "misaligned") return; // gate 3
|
|
669
|
+
this.lastHealAt = now;
|
|
670
|
+
this.jiggleRetry.heal(this.cols, this.rows);
|
|
671
|
+
}
|
|
672
|
+
```
|
|
673
|
+
|
|
674
|
+
4g. In `close()` (~L1100, next to the `attachSettleTimer` cleanup), add:
|
|
675
|
+
|
|
676
|
+
```ts
|
|
677
|
+
this.stopDesyncProbe();
|
|
678
|
+
```
|
|
679
|
+
|
|
680
|
+
- [ ] **Step 5: Run tests to verify they pass**
|
|
681
|
+
|
|
682
|
+
Run: `node --test test/pty-attach-desync-heal.test.mjs && node --test test/pty-attach-detach-gate.test.mjs`
|
|
683
|
+
Expected: PASS (new wiring test + existing detach-gate regression both green).
|
|
684
|
+
|
|
685
|
+
- [ ] **Step 6: Run the full suite to catch regressions**
|
|
686
|
+
|
|
687
|
+
Run: `npm test`
|
|
688
|
+
Expected: PASS.
|
|
689
|
+
|
|
690
|
+
- [ ] **Step 7: Commit**
|
|
691
|
+
|
|
692
|
+
```bash
|
|
693
|
+
git add src/ui/pty-attach.ts test-support/desync-heal-smoke.ts test/pty-attach-desync-heal.test.mjs
|
|
694
|
+
git commit -m "feat: wire runtime desync probe with 7 gates into attach component (issue #11)"
|
|
695
|
+
```
|
|
696
|
+
|
|
697
|
+
---
|
|
698
|
+
|
|
699
|
+
### Task 4: healthy-session E2E — no false heal (A4)
|
|
700
|
+
|
|
701
|
+
**Files:**
|
|
702
|
+
- Create: `test-support/fake-idle-tui-pi.mjs` (idle healthy child: TUI frame with inverse fake cursor + cursor parked on it, then silent; resize → full clear + repaint, mirroring pi-tui's widthChanged → fullRender(true))
|
|
703
|
+
- Create: `test-support/desync-health-e2e.ts` (real runner spawn + real `PtyAttachComponent` + fake tui; attach → settle → hold ≥3 probe ticks with the child idle → assert `healCount === 0`)
|
|
704
|
+
- Test: `test/pty-attach-desync-health-e2e.test.mjs` (execFileSync wrapper)
|
|
705
|
+
|
|
706
|
+
**Interfaces:**
|
|
707
|
+
- Consumes: `atomicWriteJson` + `P.*` paths (same setup as `test/pty-attach-cold-start-e2e.test.mjs`'s `spawnRunner`), real `runner/pty-runner.mjs` protocol, `PtyAttachComponent` opts `{ socketPath, title }`.
|
|
708
|
+
- Produces: standalone verification that a real runner + healthy idle TUI child never triggers a heal across the full attach lifecycle (spec A4).
|
|
709
|
+
|
|
710
|
+
- [ ] **Step 1: Write the fake idle child**
|
|
711
|
+
|
|
712
|
+
Create `test-support/fake-idle-tui-pi.mjs`:
|
|
713
|
+
|
|
714
|
+
```js
|
|
715
|
+
#!/usr/bin/env node
|
|
716
|
+
/**
|
|
717
|
+
* Fake healthy IDLE pi for the desync-health E2E (issue #11).
|
|
718
|
+
*
|
|
719
|
+
* Boots fast (no artificial delay), renders exactly like a healthy pi-tui
|
|
720
|
+
* idle frame — an editor line whose cursor cell is INVERSE (the fake cursor)
|
|
721
|
+
* with the hardware cursor parked ON that cell — and then goes silent.
|
|
722
|
+
* Any resize away from the baseline triggers a fullRender-style clear +
|
|
723
|
+
* repaint (pi-tui widthChanged → fullRender(true)), re-parking the cursor.
|
|
724
|
+
* This is the child the desync probe must leave alone: aligned + quiet.
|
|
725
|
+
*/
|
|
726
|
+
const baseline = [process.stdout.columns, process.stdout.rows];
|
|
727
|
+
|
|
728
|
+
function frame() {
|
|
729
|
+
// 2026h frame; editor line at row 2 col 5: inverse space = fake cursor;
|
|
730
|
+
// CUP to (2,5) parks the hardware cursor ON the inverse cell.
|
|
731
|
+
process.stdout.write(
|
|
732
|
+
"\x1b[?2026h\x1b[2J\x1b[Hready\n> editor line\x1b[2;5H\x1b[7m \x1b[0m\x1b[2;5H\x1b[?2026l",
|
|
733
|
+
);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
process.stdout.on("resize", () => {
|
|
737
|
+
const c = process.stdout.columns;
|
|
738
|
+
const r = process.stdout.rows;
|
|
739
|
+
if (c === baseline[0] && r === baseline[1]) return;
|
|
740
|
+
baseline[0] = c;
|
|
741
|
+
baseline[1] = r;
|
|
742
|
+
frame();
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
frame();
|
|
746
|
+
process.on("SIGTERM", () => process.exit(0));
|
|
747
|
+
process.on("SIGINT", () => process.exit(0));
|
|
748
|
+
```
|
|
749
|
+
|
|
750
|
+
- [ ] **Step 2: Write the E2E harness**
|
|
751
|
+
|
|
752
|
+
Create `test-support/desync-health-e2e.ts`:
|
|
753
|
+
|
|
754
|
+
```ts
|
|
755
|
+
// Healthy-session E2E (issue #11, spec A4): real runner + fake idle TUI child +
|
|
756
|
+
// REAL PtyAttachComponent. The attach runs its full lifecycle (shrink-and-hold
|
|
757
|
+
// protocol, settle, probe). With a healthy child (cursor parked on the inverse
|
|
758
|
+
// fake cursor, then silent) the desync probe must never heal across ≥3 probe
|
|
759
|
+
// ticks. Run via `node --experimental-transform-types`.
|
|
760
|
+
import { mkdtempSync, rmSync, existsSync } from "node:fs";
|
|
761
|
+
import { createServer } from "node:net";
|
|
762
|
+
import { tmpdir } from "node:os";
|
|
763
|
+
import { join, resolve } from "node:path";
|
|
764
|
+
import { spawn } from "node:child_process";
|
|
765
|
+
import { once } from "node:events";
|
|
766
|
+
import { atomicWriteJson } from "../src/core/atomic.mjs";
|
|
767
|
+
import * as P from "../src/core/paths.mjs";
|
|
768
|
+
import { createView } from "../src/core/store.mjs";
|
|
769
|
+
import { PtyAttachComponent } from "../src/ui/pty-attach.ts";
|
|
770
|
+
|
|
771
|
+
const tui = {
|
|
772
|
+
terminal: { rows: 36, cols: 120, columns: 120, write: () => {} },
|
|
773
|
+
requestRender: () => {},
|
|
774
|
+
};
|
|
775
|
+
const theme = { fg: (_c: string, t: string) => t, bold: (t: string) => t };
|
|
776
|
+
const keybindings = {} as never;
|
|
777
|
+
|
|
778
|
+
async function main(): Promise<void> {
|
|
779
|
+
const root = mkdtempSync(join(tmpdir(), "desync-health-"));
|
|
780
|
+
const viewId = "e2ehealth";
|
|
781
|
+
let runner: ReturnType<typeof spawn> | null = null;
|
|
782
|
+
let attach: PtyAttachComponent | null = null;
|
|
783
|
+
try {
|
|
784
|
+
const meta = createView(root, { id: viewId, name: "health", cwd: process.cwd() });
|
|
785
|
+
atomicWriteJson(P.hostConfigPath(root, viewId), {
|
|
786
|
+
root,
|
|
787
|
+
viewId,
|
|
788
|
+
sessionFile: meta.sessionFile,
|
|
789
|
+
cwd: process.cwd(),
|
|
790
|
+
initialPrompt: null,
|
|
791
|
+
piCommand: process.execPath,
|
|
792
|
+
piArgsPrefix: [resolve("test-support/fake-idle-tui-pi.mjs")],
|
|
793
|
+
model: null,
|
|
794
|
+
tools: null,
|
|
795
|
+
env: {},
|
|
796
|
+
cols: 120,
|
|
797
|
+
rows: 36,
|
|
798
|
+
});
|
|
799
|
+
runner = spawn(process.execPath, [resolve("runner/pty-runner.mjs"), P.hostConfigPath(root, viewId)], {
|
|
800
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
const socketPath = P.controlSocketPath(root, viewId);
|
|
804
|
+
// Wait for the runner to create its control socket (same wait as cold-start E2E).
|
|
805
|
+
const deadline = Date.now() + 10_000;
|
|
806
|
+
while (!existsSync(socketPath) && Date.now() < deadline) {
|
|
807
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
attach = new PtyAttachComponent(
|
|
811
|
+
tui as never,
|
|
812
|
+
theme as never,
|
|
813
|
+
keybindings,
|
|
814
|
+
() => {},
|
|
815
|
+
{ socketPath, title: "health" },
|
|
816
|
+
);
|
|
817
|
+
void attach.render(120); // drive one render so resizeIfNeeded sends the initial size
|
|
818
|
+
|
|
819
|
+
// Settle + hold across ≥3 probe ticks (2s period) + quiet window (1.5s).
|
|
820
|
+
await new Promise((r) => setTimeout(r, 9_000));
|
|
821
|
+
|
|
822
|
+
const state = (attach as unknown as {
|
|
823
|
+
jiggleRetry: { getState: () => { healCount: number; held: boolean; stopped: boolean } };
|
|
824
|
+
}).jiggleRetry.getState();
|
|
825
|
+
const result = { healedNever: state.healCount === 0, chainDone: state.stopped === true, held: state.held === false };
|
|
826
|
+
console.log(JSON.stringify(result));
|
|
827
|
+
if (!Object.values(result).every(Boolean)) process.exitCode = 1;
|
|
828
|
+
} finally {
|
|
829
|
+
attach?.close();
|
|
830
|
+
runner?.kill("SIGTERM");
|
|
831
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
832
|
+
rmSync(root, { recursive: true, force: true });
|
|
833
|
+
}
|
|
834
|
+
void createServer; // keep node:net import meaningful for parity with sibling harnesses
|
|
835
|
+
void once;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
void main();
|
|
839
|
+
```
|
|
840
|
+
|
|
841
|
+
Note: `P.controlSocketPath(root, viewId)` and `P.hostConfigPath(root, viewId)` are the exact helpers `test/pty-attach-cold-start-e2e.test.mjs` waits on (verified against `src/core/paths.mjs` L68 / cold-start E2E usage); `createView` is exported from `src/core/store.mjs` L420.
|
|
842
|
+
|
|
843
|
+
- [ ] **Step 3: Write the wrapper test**
|
|
844
|
+
|
|
845
|
+
Create `test/pty-attach-desync-health-e2e.test.mjs`:
|
|
846
|
+
|
|
847
|
+
```js
|
|
848
|
+
import assert from "node:assert/strict";
|
|
849
|
+
import { execFileSync } from "node:child_process";
|
|
850
|
+
import { fileURLToPath } from "node:url";
|
|
851
|
+
import { join } from "node:path";
|
|
852
|
+
import test from "node:test";
|
|
853
|
+
|
|
854
|
+
const ROOT_DIR = fileURLToPath(new URL("../", import.meta.url));
|
|
855
|
+
const E2E_SCRIPT = join(ROOT_DIR, "test-support", "desync-health-e2e.ts");
|
|
856
|
+
|
|
857
|
+
// Issue #11 (A4): a real runner + healthy idle TUI child must never trigger a
|
|
858
|
+
// runtime desync heal across the full attach lifecycle.
|
|
859
|
+
test("desync health e2e: healthy idle session triggers no heal", () => {
|
|
860
|
+
const out = execFileSync(process.execPath, ["--experimental-transform-types", E2E_SCRIPT], {
|
|
861
|
+
encoding: "utf8",
|
|
862
|
+
timeout: 60_000,
|
|
863
|
+
});
|
|
864
|
+
const parsed = JSON.parse(out);
|
|
865
|
+
assert.equal(parsed.healedNever, true, "healCount must stay 0 on a healthy idle session");
|
|
866
|
+
assert.equal(parsed.chainDone, true, "attach chain must complete");
|
|
867
|
+
assert.equal(parsed.held, false || parsed.held === false, "no hold left armed");
|
|
868
|
+
});
|
|
869
|
+
```
|
|
870
|
+
|
|
871
|
+
- [ ] **Step 4: Run the E2E to verify it fails-or-passes honestly**
|
|
872
|
+
|
|
873
|
+
Run: `node --test test/pty-attach-desync-health-e2e.test.mjs`
|
|
874
|
+
Expected: PASS. If it FAILS because the harness wiring (socket path helper names, runner protocol details) is off, fix the harness until it exercises the real attach lifecycle (observable via `chainDone: true`); if it fails because `healedNever` is false, the classifier/probe has a real false-positive — debug `detectCursorDesync` against the fake child's frame, not the harness.
|
|
875
|
+
|
|
876
|
+
- [ ] **Step 5: Commit**
|
|
877
|
+
|
|
878
|
+
```bash
|
|
879
|
+
git add test-support/fake-idle-tui-pi.mjs test-support/desync-health-e2e.ts test/pty-attach-desync-health-e2e.test.mjs
|
|
880
|
+
git commit -m "test: healthy idle session never triggers desync heal (issue #11 A4)"
|
|
881
|
+
```
|
|
882
|
+
|
|
883
|
+
---
|
|
884
|
+
|
|
885
|
+
### Task 5: full verification + spec acceptance sweep
|
|
886
|
+
|
|
887
|
+
**Files:**
|
|
888
|
+
- No new files; verification only.
|
|
889
|
+
|
|
890
|
+
**Interfaces:**
|
|
891
|
+
- Consumes: everything from Tasks 1-4.
|
|
892
|
+
|
|
893
|
+
- [ ] **Step 1: Full test suite**
|
|
894
|
+
|
|
895
|
+
Run: `cd /home/elling/git-repo/github/pi-agent-board/.pi/worktrees/issue-11-attach-runtime-desync-heal && npm test`
|
|
896
|
+
Expected: PASS (568+ existing + 7 A1 + 7 A2 + 1 A3 + 1 A4 new tests).
|
|
897
|
+
|
|
898
|
+
- [ ] **Step 2: TypeScript check (whatever the repo uses)**
|
|
899
|
+
|
|
900
|
+
Run: `npm run verify 2>/dev/null || npx tsc --noEmit`
|
|
901
|
+
Expected: clean (use `npm run verify` if defined in package.json scripts; otherwise tsc).
|
|
902
|
+
|
|
903
|
+
- [ ] **Step 3: Spec acceptance sweep (fill the table into the PR description)**
|
|
904
|
+
|
|
905
|
+
Check off each spec acceptance ID against actual evidence:
|
|
906
|
+
- A1 → `node --test test/pty-attach-render.test.mjs` output (7 new cases)
|
|
907
|
+
- A2 → `node --test test/pty-attach-jiggle-controller.test.mjs` output (7 new cases)
|
|
908
|
+
- A3 → `node --test test/pty-attach-desync-heal.test.mjs` output (H1-H7)
|
|
909
|
+
- A4 → `node --test test/pty-attach-desync-health-e2e.test.mjs` output
|
|
910
|
+
- U1 → mark `pending (observational, post-merge)` — daily-use watching for spurious flicker; does not block merge per spec.
|
|
911
|
+
|
|
912
|
+
- [ ] **Step 4: Commit any residual fixes**
|
|
913
|
+
|
|
914
|
+
```bash
|
|
915
|
+
git status --short
|
|
916
|
+
# fix and git add <specific files> if anything surfaced; otherwise nothing to commit
|
|
917
|
+
```
|