@zhuxixi/pi-agent-board 0.3.0 → 0.4.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/README.md +3 -2
- package/docs/superpowers/plans/2026-08-21-attach-coldstart-jiggle-rearm.md +797 -0
- package/docs/superpowers/plans/2026-08-21-issue-14-needs-input-no-auto-done.md +576 -0
- package/docs/superpowers/plans/2026-08-22-launch-cwd-favorites.md +916 -0
- package/docs/superpowers/plans/2026-08-22-rename-state-labels.md +182 -0
- package/docs/superpowers/plans/2026-08-22-stable-list-order.md +51 -0
- package/docs/superpowers/specs/2026-08-21-attach-coldstart-jiggle-rearm-design.md +106 -0
- package/docs/superpowers/specs/2026-08-21-issue-14-needs-input-no-auto-done-design.md +109 -0
- package/docs/superpowers/specs/2026-08-22-cwd-favorites-design.md +131 -0
- package/docs/superpowers/specs/2026-08-22-rename-state-labels-design.md +80 -0
- package/package.json +1 -1
- package/runner/job-runner.mjs +28 -4
- package/src/commands/agent-board.ts +1 -0
- package/src/core/auto-state.mjs +62 -7
- package/src/core/cwd-stats.mjs +112 -0
- package/src/core/derive.mjs +4 -4
- package/src/core/events.mjs +3 -2
- package/src/core/launch-options.mjs +47 -0
- package/src/core/paths.mjs +2 -0
- package/src/core/pty-attach-jiggle-controller.mjs +112 -0
- package/src/core/pty-attach-jiggle-retry.mjs +29 -10
- package/src/core/pty-attach-render.mjs +21 -0
- package/src/core/rows.mjs +23 -8
- package/src/core/types.mjs +2 -2
- package/src/runtime/service.mjs +15 -1
- package/src/ui/dashboard.ts +64 -15
- package/src/ui/pty-attach.ts +26 -77
|
@@ -0,0 +1,797 @@
|
|
|
1
|
+
# Attach 冷启动双光标根治(jiggle 重试链编排修复)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:** 让 attach 的 jiggle 重试链在冷启动场景可靠落地——首个 TUI 帧(`\x1b[?2026h`)触发一次性 re-arm、退避拉长到 56.12s、jiggle restore 延时 200ms,并用确定性 E2E 验证。
|
|
6
|
+
|
|
7
|
+
**Architecture:** 纯逻辑层 `src/core/pty-attach-jiggle-retry.mjs` 扩展帧检测;编排逻辑抽成可注入控制器 `src/core/pty-attach-jiggle-controller.mjs`(scheduler/sendJiggle/shouldFire 注入);`src/ui/pty-attach.ts` 退化为薄 adapter;E2E 用真实 pty-runner + stub 冷启动子进程驱动真实控制器。
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Node 24(node:test)、TypeScript(tsc --noEmit)、node-pty、@xterm/headless。
|
|
10
|
+
|
|
11
|
+
## Global Constraints
|
|
12
|
+
|
|
13
|
+
- 工作目录必须是 worktree:`/home/elling/git-repo/github/pi-agent-board/.pi/worktrees/issue-10-attach-coldstart-jiggle-rearm`(**禁碰主 checkout**)。
|
|
14
|
+
- 退避表精确值:`[120, 500, 1500, 3000, 6000, 10000, 15000, 20000]`(8 次,累计 56120ms)。
|
|
15
|
+
- `JIGGLE_RESTORE_MS = 200`。
|
|
16
|
+
- 帧开始序列:`\x1b[?2026h`;全清序列:`\x1b[2J`。跨 chunk carry 长度 = 7。
|
|
17
|
+
- re-arm 每次连接仅一次(`tuiFrameSeen` 锁存);同一 chunk 同时出现 frame-start 与 clear 时 **clear 优先**。
|
|
18
|
+
- 代码风格跟随仓库:tab 缩进、双引号、core 模块用 `.mjs` + JSDoc typedef(参照 `pty-attach-jiggle-retry.mjs` 现有风格)。
|
|
19
|
+
- commit message 用 conventional commits(英文)。
|
|
20
|
+
- `git add <file>` 按文件 stage,禁止 `git add -A`。
|
|
21
|
+
- Spec:`docs/superpowers/specs/2026-08-21-attach-coldstart-jiggle-rearm-design.md`(已提交 6043f38)。
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
### Task 1: 纯逻辑层扩展 — 帧开始检测 + carry 扩 7 字节 + 新退避表
|
|
26
|
+
|
|
27
|
+
**Files:**
|
|
28
|
+
- Modify: `src/core/pty-attach-jiggle-retry.mjs`
|
|
29
|
+
- Test: `test/pty-attach-jiggle-retry.test.mjs`
|
|
30
|
+
|
|
31
|
+
**Interfaces:**
|
|
32
|
+
- Consumes: 现有 `feedOutput(state, data, carry)`、`BACKOFF_MS`、`MAX_RETRIES`。
|
|
33
|
+
- Produces:
|
|
34
|
+
- `hasTuiFrameStart(data: string): boolean` — 是否含 `\x1b[?2026h`。
|
|
35
|
+
- `feedOutput(...)` 返回值新增 `frameStartFound: boolean`(Task 2 的控制器依赖此字段)。
|
|
36
|
+
- `BACKOFF_MS = [120, 500, 1500, 3000, 6000, 10000, 15000, 20000]`,`MAX_RETRIES = 8`。
|
|
37
|
+
- `CARRY_LEN = 7`(不导出,行为体现在跨 chunk 检测上)。
|
|
38
|
+
|
|
39
|
+
- [ ] **Step 1: 写失败测试(追加到 test/pty-attach-jiggle-retry.test.mjs 末尾)**
|
|
40
|
+
|
|
41
|
+
```js
|
|
42
|
+
// --- hasTuiFrameStart ---
|
|
43
|
+
|
|
44
|
+
test("detects \\x1b[?2026h in plain data", () => {
|
|
45
|
+
assert.equal(hasTuiFrameStart("noise\x1b[?2026hframe"), true);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("rejects data without \\x1b[?2026h", () => {
|
|
49
|
+
assert.equal(hasTuiFrameStart("plain output\x1b[2J"), false);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("feedOutput detects frame start split across chunks", () => {
|
|
53
|
+
const state = createJiggleRetryState();
|
|
54
|
+
const first = feedOutput(state, "abc\x1b[?20", "");
|
|
55
|
+
assert.equal(first.frameStartFound, false);
|
|
56
|
+
const second = feedOutput(first.state, "26h rest", first.carry);
|
|
57
|
+
assert.equal(second.frameStartFound, true);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("feedOutput detects clear split across chunks after carry widening", () => {
|
|
61
|
+
const state = createJiggleRetryState();
|
|
62
|
+
const first = feedOutput(state, "abc\x1b[2", "");
|
|
63
|
+
assert.equal(first.clearFound, false);
|
|
64
|
+
const second = feedOutput(first.state, "J rest", first.carry);
|
|
65
|
+
assert.equal(second.clearFound, true);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("feedOutput reports both flags when one chunk has both sequences", () => {
|
|
69
|
+
const state = createJiggleRetryState();
|
|
70
|
+
const r = feedOutput(state, "\x1b[?2026h\x1b[2J\x1b[H", "");
|
|
71
|
+
assert.equal(r.frameStartFound, true);
|
|
72
|
+
assert.equal(r.clearFound, true);
|
|
73
|
+
assert.equal(r.state.clearDetected, true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("new backoff table: 8 retries totaling 56120ms", () => {
|
|
77
|
+
assert.deepEqual([...BACKOFF_MS], [120, 500, 1500, 3000, 6000, 10000, 15000, 20000]);
|
|
78
|
+
assert.equal(MAX_RETRIES, 8);
|
|
79
|
+
assert.equal(BACKOFF_MS.reduce((a, b) => a + b, 0), 56120);
|
|
80
|
+
let s = createJiggleRetryState();
|
|
81
|
+
const delays = [];
|
|
82
|
+
for (let d = nextRetryDelay(s); d !== null; d = nextRetryDelay(s)) {
|
|
83
|
+
delays.push(d);
|
|
84
|
+
s = advanceRetry(s);
|
|
85
|
+
}
|
|
86
|
+
assert.deepEqual(delays, [...BACKOFF_MS]);
|
|
87
|
+
});
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
并把文件头部的 import 改为同时引入 `hasTuiFrameStart`:
|
|
91
|
+
|
|
92
|
+
```js
|
|
93
|
+
import {
|
|
94
|
+
advanceRetry,
|
|
95
|
+
BACKOFF_MS,
|
|
96
|
+
createJiggleRetryState,
|
|
97
|
+
feedOutput,
|
|
98
|
+
hasFullClearSequence,
|
|
99
|
+
hasTuiFrameStart,
|
|
100
|
+
MAX_RETRIES,
|
|
101
|
+
nextRetryDelay,
|
|
102
|
+
stopRetry,
|
|
103
|
+
} from "../src/core/pty-attach-jiggle-retry.mjs";
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
- [ ] **Step 2: 跑测试确认失败**
|
|
107
|
+
|
|
108
|
+
Run: `cd /home/elling/git-repo/github/pi-agent-board/.pi/worktrees/issue-10-attach-coldstart-jiggle-rearm && node --test test/pty-attach-jiggle-retry.test.mjs 2>&1 | tail -5`
|
|
109
|
+
Expected: FAIL(`hasTuiFrameStart is not a function` / 退避表断言失败)
|
|
110
|
+
|
|
111
|
+
- [ ] **Step 3: 实现(src/core/pty-attach-jiggle-retry.mjs)**
|
|
112
|
+
|
|
113
|
+
修改点(保持其余导出与 JSDoc 风格不变):
|
|
114
|
+
|
|
115
|
+
```js
|
|
116
|
+
export const BACKOFF_MS = Object.freeze([120, 500, 1500, 3000, 6000, 10000, 15000, 20000]);
|
|
117
|
+
export const MAX_RETRIES = BACKOFF_MS.length;
|
|
118
|
+
|
|
119
|
+
const FULL_CLEAR = "\x1b[2J";
|
|
120
|
+
const TUI_FRAME_START = "\x1b[?2026h";
|
|
121
|
+
/** Carry enough bytes to catch either target sequence split at a chunk boundary. */
|
|
122
|
+
const CARRY_LEN = TUI_FRAME_START.length - 1; // 7
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
`feedOutput` 改为:
|
|
126
|
+
|
|
127
|
+
```js
|
|
128
|
+
export function feedOutput(state, data, carry) {
|
|
129
|
+
const combined = carry + data;
|
|
130
|
+
const clearFound = hasFullClearSequence(combined);
|
|
131
|
+
const frameStartFound = hasTuiFrameStart(combined);
|
|
132
|
+
const newCarry = tailCarry(combined);
|
|
133
|
+
const newState = clearFound ? { ...state, clearDetected: true } : state;
|
|
134
|
+
return { state: newState, carry: newCarry, clearFound, frameStartFound };
|
|
135
|
+
}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
(注意:newCarry 不再因 clearFound 置空,统一走 tailCarry——clear 命中后链即停,carry 无影响;frameStart 命中后保留尾部 carry 可避免漏掉紧邻的半条 clear。)
|
|
139
|
+
|
|
140
|
+
新增导出:
|
|
141
|
+
|
|
142
|
+
```js
|
|
143
|
+
/**
|
|
144
|
+
* Check if data contains the TUI frame-start (synchronized output) sequence.
|
|
145
|
+
* pi-tui begins every frame with \x1b[?2026h; boot-time extension output never
|
|
146
|
+
* contains it, so the first occurrence marks "child TUI has started rendering".
|
|
147
|
+
* @param {string} data
|
|
148
|
+
* @returns {boolean}
|
|
149
|
+
*/
|
|
150
|
+
export function hasTuiFrameStart(data) {
|
|
151
|
+
return data.includes(TUI_FRAME_START);
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
`tailCarry` 注释里的 `CARRY_LEN` 说明同步更新即可(函数体不变)。
|
|
156
|
+
|
|
157
|
+
- [ ] **Step 4: 跑测试确认通过**
|
|
158
|
+
|
|
159
|
+
Run: `cd /home/elling/git-repo/github/pi-agent-board/.pi/worktrees/issue-10-attach-coldstart-jiggle-rearm && node --test test/pty-attach-jiggle-retry.test.mjs 2>&1 | tail -5`
|
|
160
|
+
Expected: PASS(含既有用例不回归)
|
|
161
|
+
|
|
162
|
+
- [ ] **Step 5: Commit**
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
cd /home/elling/git-repo/github/pi-agent-board/.pi/worktrees/issue-10-attach-coldstart-jiggle-rearm
|
|
166
|
+
git add src/core/pty-attach-jiggle-retry.mjs test/pty-attach-jiggle-retry.test.mjs
|
|
167
|
+
git commit -m "feat: detect TUI frame start, widen carry, extend jiggle backoff (issue #10)"
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
---
|
|
171
|
+
|
|
172
|
+
### Task 2: 新建可注入控制器 `pty-attach-jiggle-controller.mjs` + 单测
|
|
173
|
+
|
|
174
|
+
**Files:**
|
|
175
|
+
- Create: `src/core/pty-attach-jiggle-controller.mjs`
|
|
176
|
+
- Test: `test/pty-attach-jiggle-controller.test.mjs`
|
|
177
|
+
|
|
178
|
+
**Interfaces:**
|
|
179
|
+
- Consumes: Task 1 的 `createJiggleRetryState / feedOutput / nextRetryDelay / advanceRetry / stopRetry`。
|
|
180
|
+
- Produces(Task 3 组件与 Task 4 E2E 都依赖):
|
|
181
|
+
- `createJiggleRetryController(deps)`,deps:
|
|
182
|
+
- `sendJiggle: () => void` — 触发一次对子端的 resize jiggle。
|
|
183
|
+
- `setTimeoutFn: (fn: () => void, ms: number) => unknown` — 注入计时器。
|
|
184
|
+
- `clearTimeoutFn: (timer: unknown) => void`。
|
|
185
|
+
- `shouldFire?: () => boolean` — 重试计时器触发时的守卫;返回 false 则不开枪并停链。
|
|
186
|
+
- 返回 `{ start, feed, stop, getState }`:
|
|
187
|
+
- `start(): void` — 重置全部状态(含 carry、`tuiFrameSeen`),按退避表排第一个重试。
|
|
188
|
+
- `feed(data: string): void` — 喂 socket output;clear 优先停链;否则首个 frame-start 且未锁存时 re-arm(重置链、预算计满)。
|
|
189
|
+
- `stop(): void` — 清计时器并 stopRetry。
|
|
190
|
+
- `getState(): { retryIndex, clearDetected, stopped, tuiFrameSeen }`。
|
|
191
|
+
|
|
192
|
+
- [ ] **Step 1: 写失败测试 test/pty-attach-jiggle-controller.test.mjs**
|
|
193
|
+
|
|
194
|
+
```js
|
|
195
|
+
import assert from "node:assert/strict";
|
|
196
|
+
import test from "node:test";
|
|
197
|
+
import { createJiggleRetryController } from "../src/core/pty-attach-jiggle-controller.mjs";
|
|
198
|
+
|
|
199
|
+
/** Fake scheduler: 记录计时器,手动触发。 */
|
|
200
|
+
function fakeScheduler() {
|
|
201
|
+
const timers = new Map();
|
|
202
|
+
let nextId = 1;
|
|
203
|
+
return {
|
|
204
|
+
timers,
|
|
205
|
+
setTimeoutFn(fn, ms) {
|
|
206
|
+
const id = nextId++;
|
|
207
|
+
timers.set(id, { fn, ms });
|
|
208
|
+
return id;
|
|
209
|
+
},
|
|
210
|
+
clearTimeoutFn(id) {
|
|
211
|
+
timers.delete(id);
|
|
212
|
+
},
|
|
213
|
+
/** 触发当前唯一(或指定)计时器 */
|
|
214
|
+
fireNext() {
|
|
215
|
+
const first = timers.entries().next().value;
|
|
216
|
+
assert.ok(first, "expected a pending timer");
|
|
217
|
+
timers.delete(first[0]);
|
|
218
|
+
first[1].fn();
|
|
219
|
+
},
|
|
220
|
+
get pendingDelays() {
|
|
221
|
+
return [...timers.values()].map((t) => t.ms);
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function makeController(overrides = {}) {
|
|
227
|
+
const scheduler = fakeScheduler();
|
|
228
|
+
const jiggles = [];
|
|
229
|
+
const controller = createJiggleRetryController({
|
|
230
|
+
sendJiggle: () => jiggles.push(Date.now()),
|
|
231
|
+
setTimeoutFn: scheduler.setTimeoutFn,
|
|
232
|
+
clearTimeoutFn: scheduler.clearTimeoutFn,
|
|
233
|
+
...overrides,
|
|
234
|
+
});
|
|
235
|
+
return { controller, scheduler, jiggles };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
test("start schedules first retry at 120ms and firing sends jiggle + advances", () => {
|
|
239
|
+
const { controller, scheduler, jiggles } = makeController();
|
|
240
|
+
controller.start();
|
|
241
|
+
assert.deepEqual(scheduler.pendingDelays, [120]);
|
|
242
|
+
scheduler.fireNext();
|
|
243
|
+
assert.equal(jiggles.length, 1);
|
|
244
|
+
assert.deepEqual(scheduler.pendingDelays, [500]);
|
|
245
|
+
assert.equal(controller.getState().retryIndex, 1);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("feed with clear stops chain and marks clearDetected", () => {
|
|
249
|
+
const { controller, scheduler } = makeController();
|
|
250
|
+
controller.start();
|
|
251
|
+
controller.feed("prefix\x1b[2J\x1b[H");
|
|
252
|
+
const s = controller.getState();
|
|
253
|
+
assert.equal(s.clearDetected, true);
|
|
254
|
+
assert.equal(s.stopped, true);
|
|
255
|
+
assert.equal(scheduler.timers.size, 0);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("first TUI frame re-arms the chain with a fresh budget (latch)", () => {
|
|
259
|
+
const { controller, scheduler, jiggles } = makeController();
|
|
260
|
+
controller.start();
|
|
261
|
+
scheduler.fireNext(); // retry 1 fired, budget now at 500ms
|
|
262
|
+
controller.feed("\x1b[?2026h first frame");
|
|
263
|
+
assert.equal(controller.getState().tuiFrameSeen, true);
|
|
264
|
+
assert.equal(controller.getState().retryIndex, 0);
|
|
265
|
+
assert.deepEqual(scheduler.pendingDelays, [120]);
|
|
266
|
+
// second frame does NOT re-arm again
|
|
267
|
+
controller.feed("\x1b[?2026h another frame");
|
|
268
|
+
assert.deepEqual(scheduler.pendingDelays, [120]);
|
|
269
|
+
scheduler.fireNext();
|
|
270
|
+
assert.equal(jiggles.length, 2);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("clear wins when one chunk contains both frame-start and clear", () => {
|
|
274
|
+
const { controller } = makeController();
|
|
275
|
+
controller.start();
|
|
276
|
+
controller.feed("\x1b[?2026h\x1b[2J");
|
|
277
|
+
const s = controller.getState();
|
|
278
|
+
assert.equal(s.clearDetected, true);
|
|
279
|
+
assert.equal(s.stopped, true);
|
|
280
|
+
assert.equal(s.tuiFrameSeen, false);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("re-arm fires even after the chain exhausted (cold-boot case)", () => {
|
|
284
|
+
const { controller, scheduler, jiggles } = makeController();
|
|
285
|
+
controller.start();
|
|
286
|
+
for (let i = 0; i < 8; i++) scheduler.fireNext(); // drain all retries
|
|
287
|
+
assert.equal(controller.getState().stopped, true);
|
|
288
|
+
assert.equal(jiggles.length, 8);
|
|
289
|
+
controller.feed("late frame \x1b[?2026h");
|
|
290
|
+
assert.equal(controller.getState().stopped, false);
|
|
291
|
+
assert.deepEqual(scheduler.pendingDelays, [120]);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test("shouldFire=false stops the chain without firing", () => {
|
|
295
|
+
const { controller, scheduler, jiggles } = makeController({
|
|
296
|
+
shouldFire: () => false,
|
|
297
|
+
});
|
|
298
|
+
controller.start();
|
|
299
|
+
scheduler.fireNext();
|
|
300
|
+
assert.equal(jiggles.length, 0);
|
|
301
|
+
assert.equal(controller.getState().stopped, true);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test("full backoff schedule matches spec table then stops", () => {
|
|
305
|
+
const { controller, scheduler } = makeController();
|
|
306
|
+
controller.start();
|
|
307
|
+
const seen = [];
|
|
308
|
+
for (let i = 0; i < 8; i++) {
|
|
309
|
+
seen.push(...scheduler.pendingDelays);
|
|
310
|
+
scheduler.fireNext();
|
|
311
|
+
}
|
|
312
|
+
assert.deepEqual(seen, [120, 500, 1500, 3000, 6000, 10000, 15000, 20000]);
|
|
313
|
+
assert.equal(scheduler.timers.size, 0);
|
|
314
|
+
assert.equal(controller.getState().stopped, true);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test("start() resets latch and carry for a fresh connection", () => {
|
|
318
|
+
const { controller } = makeController();
|
|
319
|
+
controller.start();
|
|
320
|
+
controller.feed("\x1b[?2026h frame");
|
|
321
|
+
controller.start();
|
|
322
|
+
assert.equal(controller.getState().tuiFrameSeen, false);
|
|
323
|
+
assert.equal(controller.getState().retryIndex, 0);
|
|
324
|
+
});
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
- [ ] **Step 2: 跑测试确认失败**
|
|
328
|
+
|
|
329
|
+
Run: `cd /home/elling/git-repo/github/pi-agent-board/.pi/worktrees/issue-10-attach-coldstart-jiggle-rearm && node --test test/pty-attach-jiggle-controller.test.mjs 2>&1 | tail -3`
|
|
330
|
+
Expected: FAIL(模块不存在)
|
|
331
|
+
|
|
332
|
+
- [ ] **Step 3: 实现 src/core/pty-attach-jiggle-controller.mjs**
|
|
333
|
+
|
|
334
|
+
```js
|
|
335
|
+
/**
|
|
336
|
+
* Injectable orchestration for the attach jiggle-retry chain.
|
|
337
|
+
*
|
|
338
|
+
* Owns the retry state machine, backoff timers, cross-chunk scanning, and the
|
|
339
|
+
* one-shot re-arm on the child TUI's first frame (\x1b[?2026h). The attach
|
|
340
|
+
* component (and the cold-start E2E) drive it through injected callbacks, so
|
|
341
|
+
* the whole choreography is testable without a TUI or socket.
|
|
342
|
+
*
|
|
343
|
+
* Cold-start design (issue #10): the chain starts at socket connect, but a cold
|
|
344
|
+
* child pi-tui installs its SIGWINCH listener only ~5s in — every early jiggle
|
|
345
|
+
* is lost. When the first TUI frame arrives we re-arm once with a fresh budget,
|
|
346
|
+
* so the next jiggle lands on a live TUI and its fullRender emits \x1b[2J,
|
|
347
|
+
* which stops the chain. If pi-tui ever drops the 2026h sequence, this degrades
|
|
348
|
+
* to the plain connect-time chain (still better than the old one-shot).
|
|
349
|
+
*/
|
|
350
|
+
import {
|
|
351
|
+
advanceRetry,
|
|
352
|
+
createJiggleRetryState,
|
|
353
|
+
feedOutput,
|
|
354
|
+
nextRetryDelay,
|
|
355
|
+
stopRetry,
|
|
356
|
+
} from "./pty-attach-jiggle-retry.mjs";
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* @typedef {Object} JiggleRetryControllerDeps
|
|
360
|
+
* @property {() => void} sendJiggle - Fire one resize jiggle at the child.
|
|
361
|
+
* @property {(fn: () => void, ms: number) => unknown} setTimeoutFn - Timer factory.
|
|
362
|
+
* @property {(timer: unknown) => void} clearTimeoutFn - Timer canceller.
|
|
363
|
+
* @property {() => boolean} [shouldFire] - Guard on retry fire; false stops the chain without firing.
|
|
364
|
+
*/
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* @param {JiggleRetryControllerDeps} deps
|
|
368
|
+
*/
|
|
369
|
+
export function createJiggleRetryController(deps) {
|
|
370
|
+
const { sendJiggle, setTimeoutFn, clearTimeoutFn, shouldFire } = deps;
|
|
371
|
+
let state = createJiggleRetryState();
|
|
372
|
+
let carry = "";
|
|
373
|
+
let tuiFrameSeen = false;
|
|
374
|
+
/** @type {unknown | null} */
|
|
375
|
+
let timer = null;
|
|
376
|
+
|
|
377
|
+
function clearTimer() {
|
|
378
|
+
if (timer === null) return;
|
|
379
|
+
clearTimeoutFn(timer);
|
|
380
|
+
timer = null;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function scheduleNext() {
|
|
384
|
+
const delay = nextRetryDelay(state);
|
|
385
|
+
if (delay === null) {
|
|
386
|
+
state = stopRetry(state);
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
timer = setTimeoutFn(() => {
|
|
390
|
+
timer = null;
|
|
391
|
+
if (shouldFire && !shouldFire()) {
|
|
392
|
+
state = stopRetry(state);
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
sendJiggle();
|
|
396
|
+
state = advanceRetry(state);
|
|
397
|
+
scheduleNext();
|
|
398
|
+
}, delay);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** Reset everything (fresh connection) and schedule the first retry. */
|
|
402
|
+
function start() {
|
|
403
|
+
clearTimer();
|
|
404
|
+
state = createJiggleRetryState();
|
|
405
|
+
carry = "";
|
|
406
|
+
tuiFrameSeen = false;
|
|
407
|
+
scheduleNext();
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Feed one socket output chunk. Clear detection wins over re-arm when both
|
|
412
|
+
* sequences appear in one chunk (a hot attach's first frame is often the
|
|
413
|
+
* fullRender we were waiting for). Re-arm fires at most once per start().
|
|
414
|
+
* @param {string} data
|
|
415
|
+
*/
|
|
416
|
+
function feed(data) {
|
|
417
|
+
const result = feedOutput(state, data, carry);
|
|
418
|
+
state = result.state;
|
|
419
|
+
carry = result.carry;
|
|
420
|
+
if (result.clearFound) {
|
|
421
|
+
clearTimer();
|
|
422
|
+
state = stopRetry({ ...state, clearDetected: true });
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
if (result.frameStartFound && !tuiFrameSeen) {
|
|
426
|
+
tuiFrameSeen = true;
|
|
427
|
+
clearTimer();
|
|
428
|
+
state = createJiggleRetryState();
|
|
429
|
+
scheduleNext();
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** Stop the chain (component closed, etc.). */
|
|
434
|
+
function stop() {
|
|
435
|
+
clearTimer();
|
|
436
|
+
state = stopRetry(state);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
return {
|
|
440
|
+
start,
|
|
441
|
+
feed,
|
|
442
|
+
stop,
|
|
443
|
+
getState: () => ({ ...state, tuiFrameSeen }),
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
- [ ] **Step 4: 跑测试确认通过**
|
|
449
|
+
|
|
450
|
+
Run: `cd /home/elling/git-repo/github/pi-agent-board/.pi/worktrees/issue-10-attach-coldstart-jiggle-rearm && node --test test/pty-attach-jiggle-controller.test.mjs 2>&1 | tail -3`
|
|
451
|
+
Expected: PASS
|
|
452
|
+
|
|
453
|
+
- [ ] **Step 5: Commit**
|
|
454
|
+
|
|
455
|
+
```bash
|
|
456
|
+
cd /home/elling/git-repo/github/pi-agent-board/.pi/worktrees/issue-10-attach-coldstart-jiggle-rearm
|
|
457
|
+
git add src/core/pty-attach-jiggle-controller.mjs test/pty-attach-jiggle-controller.test.mjs
|
|
458
|
+
git commit -m "feat: injectable jiggle retry controller with one-shot TUI-frame rearm (issue #10)"
|
|
459
|
+
```
|
|
460
|
+
|
|
461
|
+
---
|
|
462
|
+
|
|
463
|
+
### Task 3: 组件接入控制器 + `JIGGLE_RESTORE_MS = 200`
|
|
464
|
+
|
|
465
|
+
**Files:**
|
|
466
|
+
- Modify: `src/ui/pty-attach.ts`
|
|
467
|
+
|
|
468
|
+
**Interfaces:**
|
|
469
|
+
- Consumes: Task 2 的 `createJiggleRetryController`。
|
|
470
|
+
- Produces: 常量 `JIGGLE_RESTORE_MS = 200`(模块级,靠近其他常量);组件不再直接 import `pty-attach-jiggle-retry.mjs`。
|
|
471
|
+
|
|
472
|
+
- [ ] **Step 1: 改 import(替换整个 jiggle-retry import 块)**
|
|
473
|
+
|
|
474
|
+
把:
|
|
475
|
+
|
|
476
|
+
```ts
|
|
477
|
+
import {
|
|
478
|
+
createJiggleRetryState,
|
|
479
|
+
feedOutput as feedJiggleRetry,
|
|
480
|
+
nextRetryDelay,
|
|
481
|
+
advanceRetry,
|
|
482
|
+
stopRetry,
|
|
483
|
+
} from "../core/pty-attach-jiggle-retry.mjs";
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
替换为:
|
|
487
|
+
|
|
488
|
+
```ts
|
|
489
|
+
import { createJiggleRetryController } from "../core/pty-attach-jiggle-controller.mjs";
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
- [ ] **Step 2: 替换字段(删除旧重试字段,加控制器与常量)**
|
|
493
|
+
|
|
494
|
+
删除这三个字段:
|
|
495
|
+
|
|
496
|
+
```ts
|
|
497
|
+
// Jiggle retry chain: re-send resize jiggle until we see a full-clear sequence
|
|
498
|
+
// in the PTY output, proving the child pi-tui did a fullRender and the replay
|
|
499
|
+
// garbage has been flushed. Replaces the one-shot forceChildRedrawAfterLiveOutput.
|
|
500
|
+
private jiggleRetryState = createJiggleRetryState();
|
|
501
|
+
private jiggleRetryTimer: ReturnType<typeof setTimeout> | null = null;
|
|
502
|
+
private clearCarry = "";
|
|
503
|
+
```
|
|
504
|
+
|
|
505
|
+
替换为:
|
|
506
|
+
|
|
507
|
+
```ts
|
|
508
|
+
// Jiggle retry chain: re-send resize jiggle until we see a full-clear sequence
|
|
509
|
+
// in the PTY output, proving the child pi-tui did a fullRender and the replay
|
|
510
|
+
// garbage has been flushed. The controller re-arms once on the child TUI's
|
|
511
|
+
// first frame so cold-start attaches get a fresh budget exactly when the
|
|
512
|
+
// child can finally observe a resize (issue #10).
|
|
513
|
+
private readonly jiggleRetry = createJiggleRetryController({
|
|
514
|
+
sendJiggle: () => this.forceChildRedraw(),
|
|
515
|
+
shouldFire: () => !this.closed && this.connected,
|
|
516
|
+
setTimeoutFn: (fn, ms) => {
|
|
517
|
+
const t = setTimeout(fn, ms);
|
|
518
|
+
t.unref?.();
|
|
519
|
+
return t;
|
|
520
|
+
},
|
|
521
|
+
clearTimeoutFn: (t) => clearTimeout(t as ReturnType<typeof setTimeout>),
|
|
522
|
+
});
|
|
523
|
+
```
|
|
524
|
+
|
|
525
|
+
在模块常量区(`TERMINAL_PASSTHROUGH_CARRY_MAX_BYTES` 附近)加:
|
|
526
|
+
|
|
527
|
+
```ts
|
|
528
|
+
/** Delay between the shrink and restore halves of a resize jiggle. 200ms keeps
|
|
529
|
+
* the two SIGWINCHs apart well beyond pi-tui's 16ms render throttle, so a busy
|
|
530
|
+
* (booting) child processes them as two separate renders instead of coalescing
|
|
531
|
+
* the pair into a net-zero size change. */
|
|
532
|
+
const JIGGLE_RESTORE_MS = 200;
|
|
533
|
+
```
|
|
534
|
+
|
|
535
|
+
- [ ] **Step 3: 替换 connect/output/close 三处调用点**
|
|
536
|
+
|
|
537
|
+
connect 处理器里把 `this.startJiggleRetry();` 改为 `this.jiggleRetry.start();`。
|
|
538
|
+
|
|
539
|
+
`checkClearSequence` 整方法替换为:
|
|
540
|
+
|
|
541
|
+
```ts
|
|
542
|
+
/** Feed socket output into the jiggle retry controller (clear/frame detection). */
|
|
543
|
+
private checkClearSequence(data: string): void {
|
|
544
|
+
this.jiggleRetry.feed(data);
|
|
545
|
+
}
|
|
546
|
+
```
|
|
547
|
+
|
|
548
|
+
`close()` 里把 `this.cancelJiggleRetry();` 改为 `this.jiggleRetry.stop();`。
|
|
549
|
+
|
|
550
|
+
删除以下三个方法(全部被控制器接管):`startJiggleRetry`、`scheduleNextJiggleRetry`、`cancelJiggleRetry`。
|
|
551
|
+
|
|
552
|
+
- [ ] **Step 4: jiggle restore 延时 40 → JIGGLE_RESTORE_MS**
|
|
553
|
+
|
|
554
|
+
`forceChildRedraw` 里把:
|
|
555
|
+
|
|
556
|
+
```ts
|
|
557
|
+
}, 40);
|
|
558
|
+
```
|
|
559
|
+
|
|
560
|
+
改为:
|
|
561
|
+
|
|
562
|
+
```ts
|
|
563
|
+
}, JIGGLE_RESTORE_MS);
|
|
564
|
+
```
|
|
565
|
+
|
|
566
|
+
- [ ] **Step 5: typecheck + 全量单测**
|
|
567
|
+
|
|
568
|
+
Run: `cd /home/elling/git-repo/github/pi-agent-board/.pi/worktrees/issue-10-attach-coldstart-jiggle-rearm && npm run typecheck 2>&1 | tail -5 && node --test test/*.test.mjs 2>&1 | tail -8`
|
|
569
|
+
Expected: typecheck 无错误;全部测试 PASS(pty-attach-render 等既有用例不回归)
|
|
570
|
+
|
|
571
|
+
- [ ] **Step 6: Commit**
|
|
572
|
+
|
|
573
|
+
```bash
|
|
574
|
+
cd /home/elling/git-repo/github/pi-agent-board/.pi/worktrees/issue-10-attach-coldstart-jiggle-rearm
|
|
575
|
+
git add src/ui/pty-attach.ts
|
|
576
|
+
git commit -m "feat: wire jiggle controller into attach component, 200ms jiggle restore (issue #10)"
|
|
577
|
+
```
|
|
578
|
+
|
|
579
|
+
---
|
|
580
|
+
|
|
581
|
+
### Task 4: 冷启动 E2E — stub 子进程 + 真 runner + 真控制器
|
|
582
|
+
|
|
583
|
+
**Files:**
|
|
584
|
+
- Create: `test-support/fake-coldstart-tui-pi.mjs`
|
|
585
|
+
- Test: `test/pty-attach-cold-start-e2e.test.mjs`
|
|
586
|
+
|
|
587
|
+
**Interfaces:**
|
|
588
|
+
- Consumes: Task 2 控制器;`runner/pty-runner.mjs`(config 协议参照 `test/pty-runner.integration.test.mjs`);`src/core/paths.mjs` 的 `controlSocketPath/hostConfigPath`;`src/core/store.mjs` 的 `createView/readHost`;`src/core/atomic.mjs` 的 `atomicWriteJson`。
|
|
589
|
+
- Produces: 证明"延迟 8s 启动 TUI 的冷子进程,在控制器编排下 30s 内发出 \x1b[2J 且被链检测到自停"。
|
|
590
|
+
|
|
591
|
+
- [ ] **Step 1: 写 stub 子进程 test-support/fake-coldstart-tui-pi.mjs**
|
|
592
|
+
|
|
593
|
+
```js
|
|
594
|
+
#!/usr/bin/env node
|
|
595
|
+
/**
|
|
596
|
+
* Fake cold-booting pi for the attach jiggle E2E (issue #10).
|
|
597
|
+
*
|
|
598
|
+
* Prints boot noise immediately, then after STUB_TUI_DELAY_MS "starts the TUI":
|
|
599
|
+
* installs the stdout resize listener (SIGWINCHes before this point are lost,
|
|
600
|
+
* exactly like a booting pi-tui) and emits frames wrapped in \x1b[?2026h/l.
|
|
601
|
+
* Any resize observed after start triggers a fullRender-style write containing
|
|
602
|
+
* the full clear sequence \x1b[2J\x1b[H\x1b[3J.
|
|
603
|
+
*/
|
|
604
|
+
const delay = Number(process.env.STUB_TUI_DELAY_MS ?? 8000);
|
|
605
|
+
|
|
606
|
+
process.stdout.write("boot: loading extensions...\n");
|
|
607
|
+
|
|
608
|
+
let started = false;
|
|
609
|
+
function startTui() {
|
|
610
|
+
if (started) return;
|
|
611
|
+
started = true;
|
|
612
|
+
process.stdout.on("resize", () => {
|
|
613
|
+
process.stdout.write(
|
|
614
|
+
`\x1b[?2026h\x1b[2J\x1b[H\x1b[3Jframe@${process.stdout.columns}x${process.stdout.rows}\x1b[?2026l`,
|
|
615
|
+
);
|
|
616
|
+
});
|
|
617
|
+
process.stdout.write("\x1b[?2004h\x1b[?25l");
|
|
618
|
+
frame();
|
|
619
|
+
}
|
|
620
|
+
function frame() {
|
|
621
|
+
process.stdout.write("\x1b[?2026hstub: working...\x1b[?2026l");
|
|
622
|
+
}
|
|
623
|
+
setTimeout(startTui, delay);
|
|
624
|
+
setInterval(() => {
|
|
625
|
+
if (started) frame();
|
|
626
|
+
}, 100);
|
|
627
|
+
setInterval(() => {}, 1000); // keep alive
|
|
628
|
+
process.on("SIGTERM", () => process.exit(0));
|
|
629
|
+
process.on("SIGINT", () => process.exit(0));
|
|
630
|
+
```
|
|
631
|
+
|
|
632
|
+
- [ ] **Step 2: 写 E2E 测试 test/pty-attach-cold-start-e2e.test.mjs**
|
|
633
|
+
|
|
634
|
+
```js
|
|
635
|
+
import assert from "node:assert/strict";
|
|
636
|
+
import { mkdtempSync, rmSync, existsSync } from "node:fs";
|
|
637
|
+
import { createConnection } from "node:net";
|
|
638
|
+
import { tmpdir } from "node:os";
|
|
639
|
+
import { join, resolve } from "node:path";
|
|
640
|
+
import { once } from "node:events";
|
|
641
|
+
import { spawn } from "node:child_process";
|
|
642
|
+
import test from "node:test";
|
|
643
|
+
import { atomicWriteJson } from "../src/core/atomic.mjs";
|
|
644
|
+
import * as P from "../src/core/paths.mjs";
|
|
645
|
+
import { createView, readHost } from "../src/core/store.mjs";
|
|
646
|
+
import { createJiggleRetryController } from "../src/core/pty-attach-jiggle-controller.mjs";
|
|
647
|
+
|
|
648
|
+
const hasNodePty = await import("node-pty").then(
|
|
649
|
+
() => true,
|
|
650
|
+
() => false,
|
|
651
|
+
);
|
|
652
|
+
|
|
653
|
+
function send(socket, msg) {
|
|
654
|
+
socket.write(JSON.stringify(msg) + "\n");
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
async function waitFor(predicate, timeoutMs = 10000) {
|
|
658
|
+
const start = Date.now();
|
|
659
|
+
while (Date.now() - start < timeoutMs) {
|
|
660
|
+
const value = predicate();
|
|
661
|
+
if (value) return value;
|
|
662
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
663
|
+
}
|
|
664
|
+
throw new Error("timed out waiting");
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// The stub TUI starts 8s in — beyond the OLD chain's ~5.12s window, so only the
|
|
668
|
+
// TUI-frame re-arm (issue #10) can produce a clear. Old choreography fails this.
|
|
669
|
+
test(
|
|
670
|
+
"cold-start attach: chain re-arms on first TUI frame and sees full clear",
|
|
671
|
+
{ skip: !hasNodePty && "node-pty unavailable", timeout: 45000 },
|
|
672
|
+
async () => {
|
|
673
|
+
const root = mkdtempSync(join(tmpdir(), "agentview-coldstart-"));
|
|
674
|
+
let runner;
|
|
675
|
+
let socket;
|
|
676
|
+
const t0 = Date.now();
|
|
677
|
+
try {
|
|
678
|
+
const meta = createView(root, { id: "cold1", name: "cold", cwd: process.cwd() });
|
|
679
|
+
atomicWriteJson(P.hostConfigPath(root, "cold1"), {
|
|
680
|
+
root,
|
|
681
|
+
viewId: "cold1",
|
|
682
|
+
sessionFile: meta.sessionFile,
|
|
683
|
+
cwd: process.cwd(),
|
|
684
|
+
initialPrompt: null,
|
|
685
|
+
piCommand: process.execPath,
|
|
686
|
+
piArgsPrefix: [resolve("test-support/fake-coldstart-tui-pi.mjs")],
|
|
687
|
+
model: null,
|
|
688
|
+
tools: null,
|
|
689
|
+
env: { STUB_TUI_DELAY_MS: "8000" },
|
|
690
|
+
cols: 120,
|
|
691
|
+
rows: 36,
|
|
692
|
+
});
|
|
693
|
+
runner = spawn(process.execPath, [resolve("runner/pty-runner.mjs"), P.hostConfigPath(root, "cold1")], {
|
|
694
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
695
|
+
});
|
|
696
|
+
await waitFor(() => existsSync(P.controlSocketPath(root, "cold1")) && readHost(root, "cold1")?.state === "alive");
|
|
697
|
+
|
|
698
|
+
socket = createConnection(P.controlSocketPath(root, "cold1"));
|
|
699
|
+
await once(socket, "connect");
|
|
700
|
+
|
|
701
|
+
let clearAt = null;
|
|
702
|
+
const controller = createJiggleRetryController({
|
|
703
|
+
sendJiggle: () => {
|
|
704
|
+
send(socket, { type: "resize", cols: 195, rows: 38 });
|
|
705
|
+
setTimeout(() => send(socket, { type: "resize", cols: 196, rows: 39 }), 200);
|
|
706
|
+
},
|
|
707
|
+
setTimeoutFn: (fn, ms) => setTimeout(fn, ms),
|
|
708
|
+
clearTimeoutFn: (t) => clearTimeout(t),
|
|
709
|
+
});
|
|
710
|
+
controller.start(); // connect-time chain, like the component
|
|
711
|
+
send(socket, { type: "hello", clientId: "e2e", wantOutput: true });
|
|
712
|
+
send(socket, { type: "resize", cols: 196, rows: 39 }); // initial sendResize
|
|
713
|
+
|
|
714
|
+
let buf = "";
|
|
715
|
+
socket.on("data", (chunk) => {
|
|
716
|
+
buf += chunk.toString("utf8");
|
|
717
|
+
const lines = buf.split("\n");
|
|
718
|
+
buf = lines.pop() ?? "";
|
|
719
|
+
for (const line of lines) {
|
|
720
|
+
if (!line.trim()) continue;
|
|
721
|
+
try {
|
|
722
|
+
const msg = JSON.parse(line);
|
|
723
|
+
if (msg.type === "output" && typeof msg.data === "string") {
|
|
724
|
+
controller.feed(msg.data);
|
|
725
|
+
if (clearAt === null && msg.data.includes("\x1b[2J")) {
|
|
726
|
+
clearAt = Date.now() - t0;
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
} catch {}
|
|
730
|
+
}
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
await waitFor(() => clearAt !== null, 30000);
|
|
734
|
+
// 下界证明清屏来自 TUI 启动之后;上界 <11.1s 证明是 re-arm(首帧+120ms)
|
|
735
|
+
// 而非退避尾部第 6 档(11.12s 才能命中)——旧表最后一档 5.12s 注定失败。
|
|
736
|
+
assert.ok(clearAt >= 7800, `clear arrived too early (${clearAt}ms) — not from the re-armed chain`);
|
|
737
|
+
assert.ok(clearAt <= 10000, `clear arrived too late (${clearAt}ms) — re-arm did not fire`);
|
|
738
|
+
const s = controller.getState();
|
|
739
|
+
assert.equal(s.clearDetected, true);
|
|
740
|
+
assert.equal(s.stopped, true);
|
|
741
|
+
assert.equal(s.tuiFrameSeen, true);
|
|
742
|
+
controller.stop();
|
|
743
|
+
} finally {
|
|
744
|
+
try { socket?.end(); } catch {}
|
|
745
|
+
try { runner?.kill("SIGTERM"); } catch {}
|
|
746
|
+
rmSync(root, { recursive: true, force: true });
|
|
747
|
+
}
|
|
748
|
+
},
|
|
749
|
+
);
|
|
750
|
+
```
|
|
751
|
+
|
|
752
|
+
- [ ] **Step 3: 跑 E2E 确认通过**
|
|
753
|
+
|
|
754
|
+
Run: `cd /home/elling/git-repo/github/pi-agent-board/.pi/worktrees/issue-10-attach-coldstart-jiggle-rearm && node --test test/pty-attach-cold-start-e2e.test.mjs 2>&1 | tail -6`
|
|
755
|
+
Expected: PASS(总时长约 9-12s;若环境无 node-pty 则显示 skip,不 FAIL)
|
|
756
|
+
|
|
757
|
+
- [ ] **Step 4: Commit**
|
|
758
|
+
|
|
759
|
+
```bash
|
|
760
|
+
cd /home/elling/git-repo/github/pi-agent-board/.pi/worktrees/issue-10-attach-coldstart-jiggle-rearm
|
|
761
|
+
git add test-support/fake-coldstart-tui-pi.mjs test/pty-attach-cold-start-e2e.test.mjs
|
|
762
|
+
git commit -m "test: cold-start attach E2E with delayed-TUI stub child (issue #10)"
|
|
763
|
+
```
|
|
764
|
+
|
|
765
|
+
---
|
|
766
|
+
|
|
767
|
+
### Task 5: 全量验证 + 收尾
|
|
768
|
+
|
|
769
|
+
**Files:**
|
|
770
|
+
- 无新增(仅验证)
|
|
771
|
+
|
|
772
|
+
**Interfaces:**
|
|
773
|
+
- Consumes: Task 1-4 全部产物。
|
|
774
|
+
|
|
775
|
+
- [ ] **Step 1: 完整验证**
|
|
776
|
+
|
|
777
|
+
Run: `cd /home/elling/git-repo/github/pi-agent-board/.pi/worktrees/issue-10-attach-coldstart-jiggle-rearm && npm run typecheck 2>&1 | tail -3 && npm test 2>&1 | tail -8`
|
|
778
|
+
Expected: typecheck 干净;全部测试 PASS(E2E 在内;总时长 <60s)
|
|
779
|
+
|
|
780
|
+
- [ ] **Step 2: 复核 spec 验收标准逐条对照**
|
|
781
|
+
|
|
782
|
+
对照 `docs/superpowers/specs/2026-08-21-attach-coldstart-jiggle-rearm-design.md` 验收标准 1/2/4:
|
|
783
|
+
1. E2E 通过且 stub 8s > 旧窗口 5.12s(旧编排必失败)✓
|
|
784
|
+
2. 新增单测通过、既要不回归、typecheck 过 ✓
|
|
785
|
+
4. 热 session 行为:控制器在 connect 链照常启动、见 clear 自停(Task 2 单测覆盖)✓
|
|
786
|
+
(验收 3 为人工实机验证,合并前由用户确认。)
|
|
787
|
+
|
|
788
|
+
- [ ] **Step 3: 提交收尾(如有遗漏文件)**
|
|
789
|
+
|
|
790
|
+
```bash
|
|
791
|
+
cd /home/elling/git-repo/github/pi-agent-board/.pi/worktrees/issue-10-attach-coldstart-jiggle-rearm
|
|
792
|
+
git status --short
|
|
793
|
+
# 应为空;若有遗漏,按文件 git add 后 git commit -m "chore: ..."
|
|
794
|
+
git log --oneline main..HEAD
|
|
795
|
+
```
|
|
796
|
+
|
|
797
|
+
Expected: 5 个 commit(spec + 4 个任务 commit)
|