@zhuxixi/pi-agent-board 0.7.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 +46 -0
- package/README.md +16 -12
- package/VERIFY.md +2 -1
- package/docs/PTY_ATTACH_IMPLEMENTATION_PLAN.md +3 -1
- package/docs/RELEASE.md +148 -0
- package/docs/superpowers/plans/2026-09-10-reader-consistency.md +115 -0
- package/docs/superpowers/plans/2026-09-14-attach-cursor-dectcem-gate.md +469 -0
- package/docs/superpowers/plans/2026-09-14-attach-snapshot.md +92 -0
- package/docs/superpowers/plans/2026-09-14-host-meta-orphan-lock.md +771 -0
- package/docs/superpowers/plans/2026-09-14-issue-106-terminal-frame-cognition.md +299 -0
- package/docs/superpowers/plans/2026-09-14-issue-113-foreground-preview-race.md +609 -0
- package/docs/superpowers/plans/2026-09-14-terminal-model.md +145 -0
- package/docs/superpowers/plans/2026-09-15-coordinator-pipe-root-normalize.md +224 -0
- package/docs/superpowers/plans/2026-09-15-lease-publish-eprem-reclaim.md +341 -0
- package/docs/superpowers/plans/2026-09-18-detach-anchor-reporter-endpoint.md +875 -0
- package/docs/superpowers/plans/2026-09-20-control-lifecycle.md +116 -0
- package/docs/superpowers/plans/2026-09-20-issue-121-perf-gate-out-of-coverage.md +517 -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-14-attach-cursor-dectcem-gate-design.md +114 -0
- package/docs/superpowers/specs/2026-09-14-host-meta-orphan-lock-design.md +120 -0
- package/docs/superpowers/specs/2026-09-14-issue-106-terminal-frame-cognition-design.md +146 -0
- package/docs/superpowers/specs/2026-09-14-issue-113-foreground-preview-race-design.md +116 -0
- package/docs/superpowers/specs/2026-09-15-coordinator-pipe-root-normalize-design.md +84 -0
- package/docs/superpowers/specs/2026-09-15-lease-publish-eprem-reclaim-design.md +92 -0
- package/docs/superpowers/specs/2026-09-18-detach-anchor-reporter-endpoint-design.md +123 -0
- package/docs/superpowers/specs/2026-09-20-issue-121-perf-gate-out-of-coverage-design.md +204 -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 +3 -2
- package/runner/job-runner.mjs +8 -7
- package/runner/pty-runner.mjs +633 -31
- package/runner/state-coordinator.mjs +43 -17
- package/runner/state-runner.mjs +6 -5
- package/scripts/release.mjs +446 -0
- package/scripts/run-perf-gate.mjs +40 -0
- package/src/commands/agent-board.ts +8 -8
- package/src/commands/attach-flow.ts +5 -5
- package/src/core/control-protocol.mjs +482 -0
- package/src/core/editor-state-reporter.mjs +11 -1
- package/src/core/foreground-preview-cache.mjs +117 -0
- package/src/core/host-protocol.mjs +62 -0
- package/src/core/launch-options.mjs +37 -0
- package/src/core/locks.mjs +68 -14
- package/src/core/paths.mjs +35 -3
- package/src/core/pid.mjs +32 -1
- package/src/core/pty-attach-jiggle-controller.mjs +27 -3
- package/src/core/pty-attach-reconnect.mjs +13 -6
- package/src/core/pty-attach-render.mjs +20 -0
- package/src/core/state-commands.mjs +88 -6
- package/src/core/status-consistency.mjs +98 -0
- package/src/core/store.mjs +59 -13
- package/src/core/terminal-attach-client.mjs +809 -0
- package/src/core/terminal-attach-protocol.mjs +252 -0
- package/src/core/terminal-model.mjs +222 -0
- package/src/core/terminal-query-sequences.mjs +132 -0
- package/src/core/terminal-snapshot.mjs +440 -0
- package/src/index.ts +12 -4
- package/src/runtime/service.mjs +284 -16
- package/src/ui/dashboard.ts +61 -41
- package/src/ui/pty-attach.ts +318 -72
- package/src/core/pty-input.mjs +0 -47
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# Spec:Windows 上 coordinator 租约孤锁因 EPERM 无法自动接管(issue #114)
|
|
2
|
+
|
|
3
|
+
日期:2026-09-15 · 状态:approved(用户确认)
|
|
4
|
+
调研:issue 评论 R1(根因收敛 + Windows 测试基线)
|
|
5
|
+
前置:#112(PR #116)已修复「identity-less 超龄回收」;本 issue 修复其剩余阻塞
|
|
6
|
+
|
|
7
|
+
## 1. 根因(systematic-debugging Phase 1-3 结论)
|
|
8
|
+
|
|
9
|
+
**唯一剩余根因**:`attemptAcquireLease`(src/core/locks.mjs)的 publish-rename 冲突白名单只认 `EEXIST`/`ENOTEMPTY`;Windows 上 `renameSync(candidate, lockPath)` 对已存在目标抛 `EPERM`(errno -4048,实测目标空/非空一致),被 catch 直接 `throw`,执行流永远到不了 `reclaimOrBlock`。
|
|
10
|
+
|
|
11
|
+
**证据链**:
|
|
12
|
+
1. 实机(2026-09-12 / 09-13 两次):coordinator 暴毙 → 残留 `state-coordinator.lock` → `node runner/state-coordinator.mjs <root>` 输出 `lease unavailable (EPERM); ...; exiting`(exit 0)→ ensureCoordinator 10s 窗口内 probe 全败 → 面板报 `coordinator_unavailable`(DONE/archive/adopt 全断)。
|
|
13
|
+
2. 复现脚本(最新代码 a129b09,Windows):构造「死 pid + `startToken:null` + 超龄 10min」残留锁后 `tryAcquireOwnedViewLock` → `THREW: EPERM`(锁已满足 #112 全部回收条件,仍被白名单拒之门外)。
|
|
14
|
+
3. 代码静态核对:locks.mjs:224(renameSync)→ :228(`if (code !== "EEXIST" && code !== "ENOTEMPTY") throw err;`)。
|
|
15
|
+
4. Windows 测试基线:`node --test test/locks.test.mjs` = **20 tests / 4 fail**,4 个失败全部是 lease 接管路径(含 #112 新增的 2 个)→ 证明该路径在 Windows 从未真实执行过(CI 为 Linux;#112 的纯函数测试不触碰真实 rename)。
|
|
16
|
+
5. 影响面不止 coordinator:pty host 的 `host-meta` / `host-start` 租约共用 `attemptAcquireLease`,Windows 上锁已存在即同断。
|
|
17
|
+
|
|
18
|
+
**已修复部分(#112,确认无需重做)**:`classifyLeaseOwner` 对 identity-less owner(含非 Linux `startToken:null`)在「`age >= ORPHAN_LEASE_AGE_MS`(5min) 且顶层 pid 确死」时返回 `reclaim`;活 pid 恒 `busy`;quarantine + inspectedToken 核对保留。
|
|
19
|
+
|
|
20
|
+
## 2. 修复设计
|
|
21
|
+
|
|
22
|
+
### 2.1 locks.mjs:rename 冲突码判定抽为纯函数并纳入 EPERM(D1)
|
|
23
|
+
|
|
24
|
+
新增导出纯函数:
|
|
25
|
+
|
|
26
|
+
```js
|
|
27
|
+
/**
|
|
28
|
+
* Whether a publish-rename failure means "the lock path already exists"
|
|
29
|
+
* (contention) rather than a genuine filesystem error.
|
|
30
|
+
* POSIX reports EEXIST/ENOTEMPTY; Windows reports EPERM (errno -4048) for
|
|
31
|
+
* renaming a directory onto an existing directory — this op's platform
|
|
32
|
+
* equivalent of EEXIST (issue #114).
|
|
33
|
+
* @param {string|undefined} code
|
|
34
|
+
* @returns {boolean}
|
|
35
|
+
*/
|
|
36
|
+
export function isPublishConflictCode(code) {
|
|
37
|
+
return code === "EEXIST" || code === "ENOTEMPTY" || code === "EPERM";
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`attemptAcquireLease` catch 改为:
|
|
42
|
+
|
|
43
|
+
```js
|
|
44
|
+
if (!isPublishConflictCode(code)) throw err;
|
|
45
|
+
const verdict = reclaimOrBlock(lockPath, token, fs, isProcessDead, now);
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
- **可测性拆分**:判定为纯函数(零副作用、可注入断言);副作用(reclaim/quarantine)仅在判定为冲突后触发,边界清晰。
|
|
49
|
+
- **POSIX 语义变化**:真权限类 EPERM 将进入 `reclaimOrBlock` → 锁不存在/不可解析时返回 `blocked` → 最终抛 `LOCK_TIMEOUT`(原为 `EPERM`)。两者皆为失败退出、不产生错误回收,形态变化可接受(#48 的 `renameWithRetry` 亦已把 EPERM 列入无条件白名单,语义一致)。
|
|
50
|
+
- **安全性**:能否回收仍由 `classifyLeaseOwner` 决定(pid 确死 / 超龄 + pid 确死),白名单化 EPERM 不放宽回收条件。
|
|
51
|
+
|
|
52
|
+
### 2.2 测试(D2)
|
|
53
|
+
|
|
54
|
+
1. **纯函数层**(unit):`isPublishConflictCode` 三分支断言(EEXIST/ENOTEMPTY/EPERM → true;ENOENT/EACCES/undefined → false)。
|
|
55
|
+
2. **接管路径层**(unit + 注入 fs 模拟 Windows):注入 `fs.renameSync` 首次对 `lockPath` 抛 EPERM,后续走真实 rename,覆盖:
|
|
56
|
+
- 死 owner(identity 完整)→ `acquired=true`,lease 可正常 release;
|
|
57
|
+
- 死 owner(identity-less 超龄)→ `acquired=true`(#112 路径打通);
|
|
58
|
+
- 活 owner → `acquired=false, reason="busy"`(不误抢);
|
|
59
|
+
- 身份不明(pid 活/无 token)→ 该场景回落 `blocked`/`busy` 既有语义。
|
|
60
|
+
3. **真机 integration**:Windows 上 `node --test test/locks.test.mjs` 全绿(修复前 16/20)。
|
|
61
|
+
|
|
62
|
+
### 2.3 非目标
|
|
63
|
+
|
|
64
|
+
- 不实现 Windows `startToken`(PowerShell `CreationDate`):修复后最坏为 5min 超龄自愈(原为永久卡死),扩围留独立 issue;
|
|
65
|
+
- 不改 `ORPHAN_LEASE_AGE_MS` / ensureCoordinator 的 `ENSURE_WINDOW_MS`;
|
|
66
|
+
- 不动 host-meta 获取点 / service.mjs / pty-runner.mjs 的 identity 传递;
|
|
67
|
+
- 不加 coordinator 失败诊断(#112 已在 store 侧落地 contended 诊断,coordinator 侧留后续)。
|
|
68
|
+
|
|
69
|
+
### 2.4 部署路径
|
|
70
|
+
|
|
71
|
+
PR 合并 → 同步运行副本(`~/.pi/agent/git/github.com/zhuxixi/pi-agent-board`)→ 用户实测。合并前可用同一 worktree 代码做 U1。
|
|
72
|
+
|
|
73
|
+
## 3. 验收矩阵
|
|
74
|
+
|
|
75
|
+
| ID | 功能点 | 验收方式 | 具体验证 | 通过标准 |
|
|
76
|
+
|----|--------|----------|----------|----------|
|
|
77
|
+
| A1 | 冲突码判定含 EPERM | 自动化验证(unit) | `node --test test/locks.test.mjs`(新纯函数用例) | EEXIST/ENOTEMPTY/EPERM→true;其余→false |
|
|
78
|
+
| A2 | EPERM 下死 owner(identity 完整)可接管 | 自动化验证(unit) | 同上(注入 fs renameSync 首抛 EPERM) | `acquired=true`,lease.release() 成功 |
|
|
79
|
+
| A3 | EPERM 下死 owner(identity-less 超龄)可接管 | 自动化验证(unit) | 同上 | `acquired=true`(#112 回收路径被打通) |
|
|
80
|
+
| A4 | EPERM 下活 owner 不误抢 | 自动化验证(unit) | 同上 | `acquired=false, reason="busy"` |
|
|
81
|
+
| A5 | Windows 真机接管测试转绿 | 自动化验证(integration,真机) | `node --test test/locks.test.mjs`(Windows 11) | 20/20 pass(基线 16/20) |
|
|
82
|
+
| A6 | 无回归 + 类型干净 | 自动化验证(static + unit) | `npm run typecheck`;`npm test` | typecheck 零错误;全量失败集不新增(Windows 既有环境类失败除外,需给出基线对比) |
|
|
83
|
+
| A7 | 实机 coordinator 自愈 | 自动化验证(E2E 脚本,真机) | 临时 root:造超龄 identity-less 孤锁 → 启动 coordinator → 断言接管成功 + socket 可 probe | coordinator 常驻,owner.json 刷新为新 pid |
|
|
84
|
+
| U1 | 面板实机回归(用户实测) | 用户实测 | 修复部署后:kill 当前 coordinator(保留孤锁)→ 等 5min 超龄 → 面板按 `d` → `y` | 标记 DONE 成功;无需手动删锁;coordinator 自拉起 |
|
|
85
|
+
|
|
86
|
+
U1 执行时机说明:需 5min 等待窗口,可在修复部署后按用户时间安排;未执行前标记 `pending`,不宣称全量验收完成。
|
|
87
|
+
|
|
88
|
+
## 4. 影响文件(预估)
|
|
89
|
+
|
|
90
|
+
- `src/core/locks.mjs`(+~12 行:导出纯函数 + catch 改造 + 注释)
|
|
91
|
+
- `test/locks.test.mjs`(+~70 行:纯函数用例 + EPERM 注入接管用例)
|
|
92
|
+
- 无其他生产文件改动。
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# Spec:← detach tier-1 锚点劫持 + editor-state reporter 端点断连(issue #103)
|
|
2
|
+
|
|
3
|
+
日期:2026-09-18 · 状态:approved(用户确认 D1 = R1)
|
|
4
|
+
基线:`main @ f657528`
|
|
5
|
+
调研:issue #103 评论 Round 1(根因复现)/ Round 2(方案约束)/ Round 3(f657528 复核 + 4 条增量发现)
|
|
6
|
+
|
|
7
|
+
## 1. 根因(systematic-debugging 结论)
|
|
8
|
+
|
|
9
|
+
### A. tier-1 反色锚点无形态校验
|
|
10
|
+
|
|
11
|
+
`src/ui/pty-attach.ts:372 childInputLooksEmpty()` 调 `findLastInverseCellLine()`(自底向上取**第一个含反色 cell 的行**),`:381-386` 无条件把该行当编辑器行。
|
|
12
|
+
|
|
13
|
+
假设「反色 cell 只可能来自编辑器假光标」不成立:pi 本体在聊天区也渲染反色 —— `renderDiff` 的行内变更片段、通知条整条反色、searchCurrentMatch、alt-screen 选择高亮。而编辑器假光标行**常常不在 buffer 里**(差分帧不重绘未变化的行),于是锚点落到聊天区反色行 → 该行有文本 → 判"有草稿" → `←` 被吞。
|
|
14
|
+
|
|
15
|
+
**间歇性来源**:假光标是否在 buffer 内取决于绘制时序。
|
|
16
|
+
|
|
17
|
+
**证据**:view_06f9f09cee 的 screen.log 3.4MB 中 117 处 `ESC[7m`;活体 view_e81b4d8d10 的 2.7MB 日志中反色行 275 = 263 行「空白 + 单反色 cell」编辑器假光标 + 12 行聊天区 diff 内容。
|
|
18
|
+
|
|
19
|
+
### B. editor-state reporter 端点失配(仅 owned 路径)
|
|
20
|
+
|
|
21
|
+
| 端 | 地址 |
|
|
22
|
+
|---|---|
|
|
23
|
+
| reporter 连接(`src/index.ts:118`) | `controlSocketPathFor()` → `views/<id>/control.sock`(posix)/ `\\.\pipe\pi-agent-board-<viewId>`(win32) |
|
|
24
|
+
| owned runner 绑定(`runner/pty-runner.mjs:439` ← `src/runtime/service.mjs:279`) | `hostEndpointPathFor(..., instanceId)` → `control.<instanceId>.sock` / `…-<8hexhash>` |
|
|
25
|
+
| legacy runner 绑定(`runner/pty-runner.mjs:108`) | `controlSocketPath()` —— 与 reporter 一致 |
|
|
26
|
+
|
|
27
|
+
reporter 永远 ENOENT/EADDR → error 监听触发无限退避重连(上限 5s),永不成功 → `editorEmpty` 恒 `null` → 门禁永远落回有缺陷的启发式(A)。分派见 `runner/pty-runner.mjs:97-104`(有 `instanceId` 走 ownedMain)。
|
|
28
|
+
|
|
29
|
+
**实测证据**:view_e81b4d8d10 目录内只有 `control.2cec1af1….sock`,无 `control.sock`;活得好的子 pi 进程 `/proc/<pid>/fd` 中 socket fd = 0。
|
|
30
|
+
|
|
31
|
+
### C. 直修 B 会掐死 warm-host 回收(阻塞级)
|
|
32
|
+
|
|
33
|
+
reporter 是**常驻连接**,而 runner 把连接数直接当 `attachedClients`:
|
|
34
|
+
|
|
35
|
+
- owned:`:704 clients.add(socket)` → `:502 attachedClients: clients.size`;`attachedEver` 在 hello 时翻(`:914`,probe 除外)
|
|
36
|
+
- legacy:`:303 clients.add(socket)` + `:304 update({attachedEver: true})` —— **connect 即翻**
|
|
37
|
+
|
|
38
|
+
消费方 `src/core/warm-host-sweeper.mjs:53` 与 `src/runtime/service.mjs:807` 均以 `attachedClients !== 0` 跳过回收 → 宿主与子 session 永久泄漏(issue #75 的回收机制失效)。
|
|
39
|
+
|
|
40
|
+
## 2. 修复设计
|
|
41
|
+
|
|
42
|
+
### 2.1 A:tier-1 锚点形态校验(D1 = R1,已确认)
|
|
43
|
+
|
|
44
|
+
新增纯函数 `pickEditorAnchorLine(candidates)`,自底向上遍历**含反色字符的行**:只有「**恰好 1 个反色字符**」的候选才被采信。
|
|
45
|
+
|
|
46
|
+
| 候选行形态(反色字符数 = 1) | 判定 | 依据 |
|
|
47
|
+
|---|---|---|
|
|
48
|
+
| 字形行(`isProbablyPiInputLine`) | 编辑器行,empty 由 `isProbablyEmptyPiInputLine` 决定 | 场景 B/B3 的既有语义(draft 保护保留) |
|
|
49
|
+
| 非字形 + 文本全空白 | 新式 pi 空编辑器行(假光标)→ empty,放行 detach | pi-tui 空编辑器行 = 反色假光标 + 填充 |
|
|
50
|
+
| 非字形 + 有文本 | 新式 pi draft 行 → **跳过**(R1 取舍:兜底不采信) | 见下方「已知代价」 |
|
|
51
|
+
| 反色字符数 ≠ 1(多字符片段 / 通知条) | **跳过,继续向上扫** | 聊天区内容不得否决 detach |
|
|
52
|
+
| 扫完全部候选 | `null` → 交 tier-2 字形扫描 → 仍无 → `return true`(放行) | #48/#69/#72 逃生链不变 |
|
|
53
|
+
|
|
54
|
+
**为什么判据是「反色字符数」而不是「反色 cell 数」**(本机 `@xterm/headless` 6.x 实测):
|
|
55
|
+
|
|
56
|
+
| 缓冲行 | 反色 cell 数 | 反色 `getChars().length` 累加 |
|
|
57
|
+
|---|---|---|
|
|
58
|
+
| `\x1b[7m草\x1b[27m稿`(宽字符 draft) | 2 | **1** |
|
|
59
|
+
| `\x1b[7m \x1b[27m`(空编辑器假光标) | 1 | 1 |
|
|
60
|
+
| `\x1b[7m Session saved \x1b[27m`(通知条) | 15 | 15 |
|
|
61
|
+
| `+ 65 ## \x1b[7mR2 · \x1b[27m#\x1b[7m822 新 step\x1b[27m`(diff) | 16 | 15 |
|
|
62
|
+
|
|
63
|
+
宽字符占 2 个 cell 但只有 1 个字符(续格 `getChars()` 返回空串)。**按 cell 计数会把中文假光标算成 2 → 字形行被误跳过 → 场景 B 的 draft 保护被破坏**,故判据必须是累加 `getChars().length`。
|
|
64
|
+
|
|
65
|
+
判定所需的「反色字符数」超出 `findLastInverseCellLine()` 的存在性判断 → UI 侧新增提取(副作用隔离),判定逻辑全部移入纯函数。
|
|
66
|
+
|
|
67
|
+
**已知代价(D1=R1 明确接受)**:新式 pi(编辑器行无 prompt 字形)+ 有 draft + reporter 不在线 → 兜底认不出 draft → 误 detach。可接受:detach 不丢草稿(子 session 继续跑,重新 attach 即回),而困死不可接受;B 修好后权威通道在位,A 只在连接窗口期/扩展缺失时兜底。按 K2 先例用场景 O3 钉住。
|
|
68
|
+
|
|
69
|
+
### 2.2 B:端点发现 + 反污染协议
|
|
70
|
+
|
|
71
|
+
1. **env 注入(两处 spawn 都做)**:`runner/pty-runner.mjs` 的 legacyMain(`:226`)与 ownedMain(`:784`)在子进程 env 中加 `AGENT_BOARD_CONTROL_SOCKET = socketPath`(各自"本宿主实际绑定的端点")。
|
|
72
|
+
2. **reporter 侧解析**:新增纯函数 `resolveControlEndpoint({ envSocketPath, platform, root, viewId })` → env 有值优先,缺失/空串回落 `controlSocketPathFor()`(兼容旧 runner 与 legacy)。
|
|
73
|
+
3. **身份握手**:reporter 每次(重)连成功后立刻发 `{ type: "hello", clientId: "editor-reporter" }`,随后才开始推 `editor_state`。
|
|
74
|
+
4. **runner 反污染**:hello 分类抽成纯函数 `classifyClientHello(msg)` → `"probe" | "editor-reporter" | "client"`:
|
|
75
|
+
- `probe`:维持现状(`markProbe`,不写 `attachedEver`),但 owned/legacy 都要把 `attachedEver` 移到 hello 判定之后;
|
|
76
|
+
- `editor-reporter`:把该 socket **移出 `clients`**(恢复 `attachedClients` 计数),不翻 `attachedEver`,转入独立的 `editorReporters` 集合;
|
|
77
|
+
- `client`:现状(记 `attachedEver`)。
|
|
78
|
+
5. **生命周期补齐**:`editorReporters` 在宿主关闭阶梯(owned `:560-563`)中一并 destroy,避免悬挂 socket 与重连退避循环。
|
|
79
|
+
|
|
80
|
+
## 3. 验收矩阵
|
|
81
|
+
|
|
82
|
+
| ID | 功能点 | 验收方式 | 具体验证 | 通过标准 |
|
|
83
|
+
|----|--------|----------|----------|----------|
|
|
84
|
+
| A1 | 锚点判定纯函数 | 自动化(unit) | `node --test test/pty-input.test.mjs` | 覆盖:字形空行→empty、字形 draft(含中文宽字符)→non-empty、非字形空白单反色→empty、非字形 draft→跳过、多字符 diff 行→跳过、通知条→跳过、无候选/畸形输入→null |
|
|
85
|
+
| A2 | 聊天区 diff 反色 + 无假光标空编辑器 → detach | 自动化(smoke) | `node --experimental-transform-types test-support/detach-gate-smoke.ts` 场景 **O1** | `detach=true`(当前 false) |
|
|
86
|
+
| A3 | 反色通知条 + 同上 → detach | 自动化(smoke) | 同上,场景 **O2** | `detach=true`(当前 false) |
|
|
87
|
+
| A4 | 钉住 D1/R1 取舍 | 自动化(smoke) | 同上,场景 **O3**:新式 draft(文本 + 单反色,无字形)+ `editorEmpty=null` | `detach=true`(有意放行,照 K2 先例钉住) |
|
|
88
|
+
| A5 | 既有 detach 语义不回归 | 自动化(smoke) | 同上,A/B/B1/B2/B3/C/D/E/F/H/I/J/K1/K2/L/M/N/E2 | 全绿,B/B3 的 draft 保护保留 |
|
|
89
|
+
| A6 | 端点解析纯函数 | 自动化(unit) | `node --test test/socket-path.test.mjs` | env 优先(posix 路径 / win32 pipe 名原样)、env 缺失或空 → 回落稳定路径 |
|
|
90
|
+
| A7 | reporter 身份握手 | 自动化(unit) | `node --test test/editor-state-reporter.test.mjs` | 首次 connect 后首帧为 `hello{clientId:"editor-reporter"}`;重连后再次发送 |
|
|
91
|
+
| A8 | reporter 不污染 attach 计数(owned) | 自动化(integration) | `node --test test/pty-runner.integration.test.mjs` | reporter 握手后 `attachedClients === 0` 且 `attachedEver === false`;真实客户端连接后 `attachedClients === 1` |
|
|
92
|
+
| A9 | legacy 路径同 A8 | 自动化(integration) | 同上(legacy 分支) | connect 不再单独翻 `attachedEver`;reporter 连接零污染 |
|
|
93
|
+
| A10 | 静态与全量 | 自动化(static + 全量 unit) | `npm run typecheck && npm test` | typecheck 0 error;测试 0 fail |
|
|
94
|
+
| U1 | 实机:diff 高亮在场时空编辑器可退出 | 用户实测 | attach 一个 Running 会话,聊天区有 diff 高亮,编辑器清空后单按 `←`,重复 ≥5 次 | 每次都 detach,无需 Ctrl+← |
|
|
95
|
+
| U2 | 实机:draft 保护仍在 | 用户实测 | attach 后输入草稿,按 `←` | 光标左移、**不** detach |
|
|
96
|
+
| U3 | 实机:权威通道恢复 | 用户实测 | attach 后观察 hello / host.json(或临时诊断) | `editorEmpty` 为 `true/false` 而非恒 `null` |
|
|
97
|
+
| U4 | 实机:warm-host 仍可回收 | 用户实测 | reporter 在线时让会话 idle,等 sweeper 周期 | 宿主被回收,无泄漏(回归 C 的阻塞点) |
|
|
98
|
+
|
|
99
|
+
## 4. 可测性拆分设计(实现硬约束)
|
|
100
|
+
|
|
101
|
+
| 拆分单元 | 位置 | 形态 | 测试边界 |
|
|
102
|
+
|---|---|---|---|
|
|
103
|
+
| `pickEditorAnchorLine(candidates)` | `src/core/pty-input.mjs` | 纯函数:入参 `Array<{text, inverseCharCount}>`(自底向上),出参 `{empty:boolean}\|null` | 零 IO / 零终端依赖 → `test/pty-input.test.mjs` 直接单测 |
|
|
104
|
+
| `resolveControlEndpoint({envSocketPath, platform, root, viewId})` | `src/core/paths.mjs` | 纯函数,platform 显式注入 | 零 IO;win32 分支断言 pipe 名原样返回,不真实绑定 |
|
|
105
|
+
| `classifyClientHello(msg)` | `src/core/host-protocol.mjs`(新) | 纯函数 → `"probe"\|"editor-reporter"\|"client"` | 双端共用、零 IO → 单测 |
|
|
106
|
+
| UI 侧提取 | `src/ui/pty-attach.ts` | 只做 `BufferLine → {text, inverseCharCount}`(副作用隔离) | 判定逻辑**不得**回流进 UI |
|
|
107
|
+
| reporter 握手 | `src/core/editor-state-reporter.mjs` | 复用现有 DI(`connect`/`scheduler` 注入) | 用现有 manual-clock fakeScheduler 断言帧序 |
|
|
108
|
+
| runner 反污染 | `runner/pty-runner.mjs` | `clients` / `editorReporters` 分账 | 走既有真实进程 integration 模式 |
|
|
109
|
+
|
|
110
|
+
## 5. 非目标
|
|
111
|
+
|
|
112
|
+
- 不改 pi 本体渲染;不引入边框结构锚点 / 光标 cell 锚点方案(留作后续 issue)。
|
|
113
|
+
- 不重构 warm-host 回收策略本身(本 issue 只保证它不被 reporter 掐死)。
|
|
114
|
+
- 不动 `Ctrl+←` 兜底语义(#89)与断开态的 `←` 逃生(#48)。
|
|
115
|
+
|
|
116
|
+
## 6. 风险
|
|
117
|
+
|
|
118
|
+
| 风险 | 对策 |
|
|
119
|
+
|---|---|
|
|
120
|
+
| R1 让新式 pi draft 失去兜底保护 | 已知代价,场景 O3 钉住;B 修好后权威在线率高 |
|
|
121
|
+
| legacy `attachedEver` 语义调整波及别的消费方 | 实现前 grep 全部 `attachedEver` 消费点,逐条确认 |
|
|
122
|
+
| Windows 命名管道名经 env 传递 | A6 单测(platform 注入)+ U1 真机;不依赖真实管道绑定 |
|
|
123
|
+
| reporter 移出 `clients` 后漏清理 | A8/A9 断言 + 关闭阶梯测试(`editorReporters` 一并 destroy) |
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# Spec — Issue #121: 性能门禁脱离覆盖率插桩(A11)
|
|
2
|
+
|
|
3
|
+
> 状态:**已确认**(2026-09-20 会话确认;同目录调研件保留在 `~/.claude/github-issue-driven/zhuxixi/pi-agent-board/issue-121/`)
|
|
4
|
+
> 认领:zhuxixi(2026-09-14);R3 复核于 2026-09-20 HEAD `794c755`
|
|
5
|
+
> 调研:`~/.claude/github-issue-driven/zhuxixi/pi-agent-board/issue-121/research/01-prior-art-and-precedent.md`、`02-measurements.md`、`03-revalidation-at-head.md`
|
|
6
|
+
> 上游:issue #121(本 spec 的目标);#95(真实进程预算与串行化,**不在本 spec 范围**);#31/PR #32(CI 结构来源);#117(性能用例引入方)
|
|
7
|
+
|
|
8
|
+
## 1. 问题陈述
|
|
9
|
+
|
|
10
|
+
`main` 连续四次 CI 红,失败点全部是 **Coverage 步骤里的 A11 性能断言**,而同一次 run 的 Unit tests 步骤全绿:
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
not ok 809 - A11: paced stream (80ms interval, ~5s wall) stays within thresholds
|
|
14
|
+
error: 'paced feed p99 11.379ms exceeds 8ms'
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
`main` 启用了分支保护(必需检查 `Test (Node 22)` / `Test (Node 24)`,`enforce_admins: true`),所以这道红持续卡住所有后续 PR。
|
|
18
|
+
|
|
19
|
+
目标:让性能门禁**继续真实生效**,但只在**它能产生有效测量的环境**里决定 CI;把无效环境(覆盖率插桩、并行争抢)下的性能断言从判定路径上移除。
|
|
20
|
+
|
|
21
|
+
## 1.5 根因(已完成,含实测证据;不再重复调试)
|
|
22
|
+
|
|
23
|
+
见 `research/02-measurements.md` 与 `research/03-revalidation-at-head.md`。结论两条:
|
|
24
|
+
|
|
25
|
+
1. **插桩路径是无效测量环境(R3 收紧后的表述)**:同一份代码在 Coverage 步骤测到 `paced p99` = 1.541ms / 3.838ms(绿)与 **11.379ms**(红),跨度 7.5×。R3 在 HEAD `794c755` 上用**单文件、机器空闲、无并行套件竞争**的 c8 运行复现了失败——5 次中 3 次超 8ms(**13.904** / 6.057 / 6.295 / **10.755** / **13.221**),同期无插桩 4 次为 2.376 / 2.073 / 2.659 / 2.107ms(余量 3.0–3.9×,很稳)。→ **插桩本身就是判定失败的充分项,CI 并行只是放大器**;8ms 在插桩下测的是 c8 的采样开销,不是终端模型的代码开销。对照 09-14 本机插桩实测(3.1–6.5ms,4 次全过),余量正在收窄,「偶尔侥幸通过」就是当前形态。
|
|
26
|
+
2. **阈值本来就不是为 CI 标定的**:`#117` 的 plan 原文写的是 `… (80×24 + 256 scrollback **on dev hardware**)`,且该 plan 已预设处置规则:`if CI shows systematic misses, **re-baseline with evidence (not silently relax)**`。本 spec 的设计约束即此句。
|
|
27
|
+
|
|
28
|
+
附带事实(影响设计):
|
|
29
|
+
- 性能用例自 #117 起在 CI 每次运行被执行**两次**(Unit tests + Coverage),且其负载已被证实会拖慢套件内其他真实进程测试(plan 记录:为它把 `pty-runner.integration` 的 `waitFor` 默认值从 3s 提到 10s)。
|
|
30
|
+
- 把性能用例排除后,覆盖率**仍远离阈值**(实测 Lines 92.2 / Funcs 91.16 / Branch 79.58,阈值 85/80/70;`terminal-model.mjs` 仍为 100%)→ 「排除会掉阈值」的顾虑已被实测否定。
|
|
31
|
+
|
|
32
|
+
## 2. 设计决策
|
|
33
|
+
|
|
34
|
+
### D1:性能断言只在专用 perf 入口里决定 CI(**opt-in 门禁**)
|
|
35
|
+
|
|
36
|
+
性能断言默认**不测量**,仅在显式开启时测量:
|
|
37
|
+
|
|
38
|
+
- 新环境变量 `AGENT_BOARD_PERF_GATE=1` 表示「允许性能断言决定结果」。
|
|
39
|
+
- 未开启时,三个性能用例以 `{ skip: reason }` 跳过(reason 里写明入口 `npm run test:perf`),**不执行测量循环**(因此也不贡献 CPU 负载)。
|
|
40
|
+
- 新入口 `npm run test:perf` 是唯一权威执行路径。
|
|
41
|
+
|
|
42
|
+
**为什么 opt-in 而不是 opt-out**(备选方案对照):
|
|
43
|
+
|
|
44
|
+
| 方案 | 优点 | 为什么不选 / 代价 |
|
|
45
|
+
|---|---|---|
|
|
46
|
+
| **opt-in(选定)** | 测量环境受控;默认套件不含性能负载;语义明确「只有专用入口才是判定」 | 本地 `npm test` 不再顺带守性能 → 由 `verify` + CI step 兜住 |
|
|
47
|
+
| opt-out(默认测,CI 覆盖率步骤关掉) | 本地默认守性能 | CI 的 Unit tests 步骤仍在并行环境里判定同一阈值 → 噪声问题原样保留;且默认路径依旧把性能负载压给其他测试 |
|
|
48
|
+
| 放宽阈值 | 改动最小 | **被 plan 明文禁止**(not silently relax);放宽到噪声带之上等于失去回归检出能力 |
|
|
49
|
+
| `--test-skip-pattern` 按名字排除 | 不动测试文件 | 名字耦合(改名即静默失效);只治 CI,不治本地 `npm run test:coverage` |
|
|
50
|
+
|
|
51
|
+
### D2:覆盖率与并行套件都不测量性能(不改 glob)
|
|
52
|
+
|
|
53
|
+
`test` / `test:coverage` 两个脚本**保持原样**(`node --test test/*.test.mjs`)——因为 D1 的门禁会让性能断言在未 opt-in 时自然跳过。这样:
|
|
54
|
+
|
|
55
|
+
- 覆盖率步骤不再被性能断言判定;
|
|
56
|
+
- 并行套件不再承担性能用例的 CPU 负载(消除 #117 已实测过的那类连带 flake);
|
|
57
|
+
- 不改文件路径 → **不使 #91 的 A11 验收行、plan、VERIFY.md 中已记录的命令路径失效**,也把与 #95 的 `ci.yml` 冲突面缩到「新增一个 step」这一处。
|
|
58
|
+
|
|
59
|
+
### D3:权威测量跑在「独立 step、单文件、串行」环境
|
|
60
|
+
|
|
61
|
+
CI 新增一个 step(**不新增 job**,避免改分支保护配置):
|
|
62
|
+
|
|
63
|
+
```yaml
|
|
64
|
+
- name: "Perf gate (serial, no coverage)"
|
|
65
|
+
run: npm run test:perf
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
- 位置:`Typecheck` 之后、`Unit tests` 之前——此时机器最安静(本 job 尚无可争抢的测试进程),且失败快速暴露。
|
|
69
|
+
- step 内只跑 `test/terminal-model-perf.test.mjs` 一个文件;**首轮不携带 `--test-concurrency=1`**——该 flag 需 Node 21+,而 `package.json` 声明 `engines: node>=20`,且单文件时它是 no-op(2026-09-20 用户裁决:YAGNI,删掉;将来新增第二个 perf 文件时加回,并行测量必须保持不可能)。
|
|
70
|
+
- **升级路径(写进 plan 的残留账)**:若该 step 在连续 5 次运行中仍抖动 → (a) 先按 D4 用证据重设界;(b) 仍不行才升级为独立 job(独立 runner)并把它加入必需检查(需改分支保护,属人工配置步骤)。
|
|
71
|
+
|
|
72
|
+
### D4:基线按证据重设,禁止静默放宽
|
|
73
|
+
|
|
74
|
+
- 首轮**维持现有数值**(feed p95 ≤ 5ms / p99 ≤ 8ms、capture ≤ 50ms、hydrate ≤ 100ms)。
|
|
75
|
+
- perf step 必须保留现有 `burst:` / `paced:` 数值日志行(趋势证据,本次已存在,不新增打印)。
|
|
76
|
+
- 规则:**任何阈值调整必须附证据**——至少包含连续 N≥5 次专用 step 的实测分布,且新界需高于实测噪声上限并留有 ≥2× 系数;调整记录写回本 issue。禁止出现「阈值悄悄变大」的 diff。
|
|
77
|
+
|
|
78
|
+
### D5:`NODE_V8_COVERAGE` 作为机械兜底门禁
|
|
79
|
+
|
|
80
|
+
c8 会给子进程设置 `NODE_V8_COVERAGE`(本机实测值 `<repo>/coverage/tmp`,与 KB 笔记 `202608250830231905` 一致)。因此:
|
|
81
|
+
|
|
82
|
+
- 门禁判定为**两个环境变量的纯函数**:即使有人开了 `AGENT_BOARD_PERF_GATE=1`,只要 `NODE_V8_COVERAGE` 存在就**拒绝测量**(reason 写明「正在被覆盖率插桩」)。
|
|
83
|
+
- 入口脚本在此情形下**不静默跳过,而是响亮失败**(退出码非 0 + 明确错误),避免「在插桩下跑 perf 门禁得到全绿」这种假信号。
|
|
84
|
+
|
|
85
|
+
## 3. 组件契约
|
|
86
|
+
|
|
87
|
+
### 3.1 门禁纯函数(新文件 `test-support/perf-gate.mjs`)
|
|
88
|
+
|
|
89
|
+
`test-support/**` 已在 `.c8rc.json` 的 exclude 中,不影响覆盖率口径。
|
|
90
|
+
|
|
91
|
+
```js
|
|
92
|
+
/**
|
|
93
|
+
* @param {Record<string,string|undefined>} env
|
|
94
|
+
* @returns {{run: boolean, reason: string}}
|
|
95
|
+
*/
|
|
96
|
+
export function perfGateDecision(env = process.env)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
真值表(域:`AGENT_BOARD_PERF_GATE` ∈ {未设置, `"1"`, `"0"`, 其它值} × `NODE_V8_COVERAGE` ∈ {未设置, 已设置} = **8 种组合**;下表按规则合并成 5 行,A1 必须覆盖全部 8 种):
|
|
100
|
+
|
|
101
|
+
| `AGENT_BOARD_PERF_GATE` | `NODE_V8_COVERAGE` | `run` | `reason` 要点 |
|
|
102
|
+
|---|---|---|---|
|
|
103
|
+
| 未设置 | 未设置 | false | 需要 `AGENT_BOARD_PERF_GATE=1`(指向 `npm run test:perf`) |
|
|
104
|
+
| 未设置 | 已设置 | false | 同上(插桩不改变「未 opt-in」的判定) |
|
|
105
|
+
| `"1"` | 未设置 | **true** | 空 |
|
|
106
|
+
| `"1"` | 已设置 | false | 正在被覆盖率插桩,拒绝测量 |
|
|
107
|
+
| `"0"` / 其它值 | 任意 | false | 需要 `AGENT_BOARD_PERF_GATE=1` |
|
|
108
|
+
|
|
109
|
+
### 3.2 入口脚本(新文件 `scripts/run-perf-gate.mjs`)
|
|
110
|
+
|
|
111
|
+
- 副作用隔离层:只做「读环境 → 判定插桩 → 执行或拒绝」,**不含**测量/断言逻辑,也**不**调用 `perfGateDecision` 做静默跳过——入口是 opt-in 路径,进来的人就是来测量的。
|
|
112
|
+
- 分层契约(硬约束):`perfGateDecision` 只服务**测试文件**(决定用例跳过与否);入口脚本只做**插桩检测**——`NODE_V8_COVERAGE` 存在 → 打印错误并 `exit 1`(响亮失败,**不**清除该变量后照跑)。若实现成「调纯函数得 run=false → 静默 exit 0」,插桩下的 perf 门禁就会假绿,这正是 D5 要堵的洞。
|
|
113
|
+
- 否则 `spawn(process.execPath, ["--test", "--test-concurrency=1", "test/terminal-model-perf.test.mjs"], { stdio: "inherit", env: { ...process.env, AGENT_BOARD_PERF_GATE: "1" } })` 并透传退出码。
|
|
114
|
+
- 跨平台:脚本内设置环境变量,**不用** npm 脚本里的内联 `VAR=1 cmd` 写法(Windows cmd 不支持;本仓库有 Windows 支持面)。
|
|
115
|
+
|
|
116
|
+
### 3.3 性能测试文件(改 `test/terminal-model-perf.test.mjs`)
|
|
117
|
+
|
|
118
|
+
- 顶层求值 `const gate = perfGateDecision(process.env)`,三个用例各自 `test(name, { skip: gate.run ? false : gate.reason }, fn)`。
|
|
119
|
+
- 阈值常量(`FEED_P95_LIMIT` 等)**留在本文件**,不下移到 helper:它们是被测契约,helper 只决定「是否测量」。
|
|
120
|
+
- 断言与测量逻辑其余部分不动(含 ring overflow 用例:它无延迟断言,但因同文件跳过而一并跳过——记录为已知取舍,见 §6)。
|
|
121
|
+
|
|
122
|
+
### 3.4 脚本与 CI(改 `package.json`、`.github/workflows/ci.yml`)
|
|
123
|
+
|
|
124
|
+
```json
|
|
125
|
+
"test": "node --test test/*.test.mjs", // 不变
|
|
126
|
+
"test:coverage": "c8 node --test test/*.test.mjs", // 不变
|
|
127
|
+
"test:perf": "node scripts/run-perf-gate.mjs", // 新增
|
|
128
|
+
"verify": "npm run typecheck && npm run test:perf && npm test && npm run test:coverage && npm run pack:dry"
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
`verify` 把 perf 放在覆盖率之前(先安静测量,再插桩跑全量),并保持与 CI 步骤顺序一致。
|
|
132
|
+
|
|
133
|
+
## 4. 可测性拆分设计(硬约束,实现阶段不得重新耦合)
|
|
134
|
+
|
|
135
|
+
| 单元 | 性质 | 怎么测 | 覆盖的验收项 |
|
|
136
|
+
|---|---|---|---|
|
|
137
|
+
| `perfGateDecision(env)` | **纯函数**,无副作用、不读全局 | 直接传 env 对象遍历真值表 | A1 |
|
|
138
|
+
| `scripts/run-perf-gate.mjs` | **副作用层**(spawn / env / 退出码),不含决策逻辑与测量 | 用 `child_process` 注入 `NODE_V8_COVERAGE` 验证拒绝路径(A2);正常路径由 A3 端到端验证 | A2 / A3 |
|
|
139
|
+
| `test/terminal-model-perf.test.mjs` | 消费门禁 + 执行测量与断言 | 跳过路径由 A4 验证(默认套件汇总里出现 skipped 与 reason);测量路径由 A3 验证(真实数值行 + 3 passed) | A3 / A4 |
|
|
140
|
+
| CI 接线与脚本契约 | 静态 | 读 `ci.yml` / `package.json` 断言关键行(A5) | A5 |
|
|
141
|
+
|
|
142
|
+
边界声明:**决策(纯)↔ 执行(副作用)↔ 测量(断言)三层不得合并**;不允许把 `perfGateDecision` 内联回测试文件,也不允许把阈值下移到 helper。
|
|
143
|
+
|
|
144
|
+
## 5. 验收矩阵
|
|
145
|
+
|
|
146
|
+
### 自动化验证
|
|
147
|
+
|
|
148
|
+
| ID | 功能点 | 验收方式 | 具体验证 | 通过标准 |
|
|
149
|
+
|---|---|---|---|---|
|
|
150
|
+
| A1 | 门禁真值表 | 自动化(unit,新文件 `test/perf-gate.test.mjs`) | `node --test test/perf-gate.test.mjs` | §3.1 域的 **8 种组合**全部覆盖,`{run, reason}` 与表逐行一致(`reason` 只断言关键子串,不锁全文) |
|
|
151
|
+
| A2 | 插桩下拒绝执行 | 自动化(unit/integration,同文件内 `child_process`) | 以 `NODE_V8_COVERAGE=/tmp/x` 跑 `node scripts/run-perf-gate.mjs` | 退出码 ≠ 0,stderr 含明确拒绝说明,且**没有**执行测量 |
|
|
152
|
+
| A3 | 权威入口真实测量 | 自动化(integration) | `npm run test:perf` | 退出码 0;输出含 `burst:` 与 `paced:` 数值行;`pass 3 / fail 0 / skipped 0` |
|
|
153
|
+
| A4 | 默认套件不再测量性能 | 自动化(static + 行为) | `npm test` 与 `npm run test:coverage` 各跑一次 + `rg "perfGateDecision" test/terminal-model-perf.test.mjs` | 3 个 perf 用例出现在 skipped 列表且 reason 指向 `npm run test:perf`(reason 可见性已实测:Node 24 spec reporter 与 Node 22 TAP 均显示 `# SKIP <reason>`);**不对 skipped 总数做硬断言**(套件将来新增其它 skip 属正常);无 perf 断言失败;grep 命中 |
|
|
154
|
+
| A5 | CI 接线与脚本契约 | 自动化(static) | 读 `.github/workflows/ci.yml` + `package.json` | perf step 存在且位于 Unit tests 之前;`test`/`test:coverage` 两行为原值(glob 未改);`test:perf` 指向入口脚本 |
|
|
155
|
+
| A6 | 覆盖率阈值仍过 | 自动化(build) | `npm run test:coverage` | 退出码 0;All files:Lines ≥ 85、Functions ≥ 80、Branches ≥ 70;R3 在 HEAD `794c755` 实测基线 92.46 / 91.41 / 80.07(Lines / Functions / Branches),排除 perf 文件后 941 pass / 0 fail |
|
|
156
|
+
| A7 | 并行套件稳定性不倒退 | 自动化(多次运行) | `npm test` 连续 3 轮 | 3 轮全绿;记录每轮 `pass/fail/skipped` 计数。若出现失败,只允许落在既有 flake 台账(#95 家族、#122 引入的 A5 mid-stream 断言)内且逐条对账——凡涉及本改动触及文件(perf-gate / run-perf-gate / terminal-model-perf / package.json / ci.yml)的失败即不通过 |
|
|
157
|
+
| A8 | 文档与脚本契约一致 | 自动化(static) | 读 `README.md` / `VERIFY.md` / `package.json` | README 的 `npm run verify` 说明行与 VERIFY.md §0 静态检查块均含 `npm run test:perf` 及「性能断言默认跳过、需专用入口」说明;`package.json` 的 `verify` 含 `test:perf` 且顺序在 `test` / `test:coverage` 之前 |
|
|
158
|
+
|
|
159
|
+
### 用户实测 / 合并后观测
|
|
160
|
+
|
|
161
|
+
| ID | 功能点 | 验收方式 | 步骤与观察 | 通过标准 |
|
|
162
|
+
|---|---|---|---|---|
|
|
163
|
+
| U1 | 合并后 main 连续绿 | 用户实测(合并后 CI 观测) | `gh run list --branch main --limit 5`;对绿 run 执行 `gh run view <id> --log \| grep -E "burst:\|paced:"` | main 连续 ≥2 次绿,且 perf step 日志中能看到真实 `burst:` / `paced:` 数值行(证明门禁在跑而非 skip)。**前置条件:A5 mid-stream 断言缺陷(#122 引入的独立 issue,见 research/03 §四)也必须已合入——否则红与本 issue 无关,会误判验收失败。** |
|
|
164
|
+
| U2 | 基线证据收集与判定 | 用户实测(合并后证据汇总) | 收集 perf step 连续 5 次运行的 `paced p99` 值,写入本 issue | 5 次全部通过 → 维持现阈值并记录分布;若仍抖动 → 按 D4 用证据重设界(或按 D3 升级为独立 job) |
|
|
165
|
+
|
|
166
|
+
## 6. 非目标
|
|
167
|
+
|
|
168
|
+
- **不改阈值数值**(除非 U2/D4 触发,且必须附证据)。
|
|
169
|
+
- **不碰 #95 范围**:真实进程用例的墙钟预算、并行套件串行化、A10 恢复链验证方式,均不在本 spec。
|
|
170
|
+
- **不新增依赖**、不改生产代码(`src/`、`runner/`)。
|
|
171
|
+
- **不新增 CI job、不改分支保护配置**(首轮;升级路径见 D3)。
|
|
172
|
+
- **不引入 `--test-skip-pattern`**(理由见 D1 对照表第 4 行)。
|
|
173
|
+
- 已知取舍(记录、不视为缺陷):ring overflow 用例无延迟断言,但同文件跳过 → 默认套件里它也不再跑;其行为由 `test/terminal-model.test.mjs` 与 `test/terminal-snapshot.test.mjs` 覆盖,A6 的实测证明本模块覆盖率不降。
|
|
174
|
+
- 已知取舍(记录):`scripts/run-perf-gate.mjs` 会随 `package.json` 的 `files` 字段发布到 npm(`scripts/` 目录已有 `patch-vulns.mjs` / `release_helper.mjs` 的先例);它只被 `npm run test:perf` 调用,不影响扩展运行时。
|
|
175
|
+
|
|
176
|
+
## 7. 风险与回退
|
|
177
|
+
|
|
178
|
+
| 风险 | 缓解 |
|
|
179
|
+
|---|---|
|
|
180
|
+
| opt-in 门禁被遗忘(有人删掉 CI step) | skip reason 写明入口;README/VERIFY.md 记录;A5 静态断言把接线钉在 `ci.yml` |
|
|
181
|
+
| 覆盖率口径变化 | 已实测(§1.5 附带事实 + A6 基线),余量 ≥7 点 |
|
|
182
|
+
| CI 上 perf step 仍抖动 | 两级升级路径(D3):先证据重设界,再独立 job + 必需检查 |
|
|
183
|
+
| 本地 `npm run verify` 变重(+~7s)且机器繁忙时 perf 门禁可能 flake | R3 无插桩实测 2.07–2.66ms(≥3× 余量);真撞上按 D4 处理,不静默放宽 |
|
|
184
|
+
| 与 #95 改同一 `ci.yml` 区域 | 两 issue 均已写明**串行合入**;本改动把冲突面压到「新增一个 step」 |
|
|
185
|
+
| U1 被 A5 断言红挟持 | A5(`test/terminal-snapshot.integration.test.mjs:179`,测试断言过严)已认定为独立缺陷;其修复**必须同期合入**才能观测到「main 连续绿」,否则 U1 会误判。本 issue 自身的绿的判据是「perf step 通过 + Coverage 步骤不再判定性能断言」 |
|
|
186
|
+
| Windows 兼容 | 入口用脚本设环境变量,不写内联 env;不新增依赖 |
|
|
187
|
+
|
|
188
|
+
**回退**:本改动只涉及测试文件、新脚本、`package.json` 脚本、`ci.yml` 与文档,无数据迁移、无生产行为变化 → 回退 = revert 单个 PR。
|
|
189
|
+
|
|
190
|
+
## 8. 文档更新
|
|
191
|
+
|
|
192
|
+
- `README.md`:`npm run verify` 说明行补上 perf 门禁(现文仅写 typecheck/tests/coverage/pack)。
|
|
193
|
+
- `VERIFY.md` §0「Static checks」:补 `npm run test:perf`(并说明「性能断言默认跳过,需专用入口」)。
|
|
194
|
+
- 本 issue 评论:U2 的基线分布与最终判定。
|
|
195
|
+
|
|
196
|
+
## 9. 迁移顺序(step 6 的 plan 将据此拆 task)
|
|
197
|
+
|
|
198
|
+
1. `perf-gate.mjs` 纯函数 + 真值表单测(A1)
|
|
199
|
+
2. `scripts/run-perf-gate.mjs` 入口脚本 + 拒绝路径测试(A2)
|
|
200
|
+
3. `test/terminal-model-perf.test.mjs` 接门禁、skip reason(A4 前半)
|
|
201
|
+
4. `package.json` 脚本与 `verify`(A3/A5 前半)
|
|
202
|
+
5. `ci.yml` 新增 perf step,置于 Unit tests 之前(A5 后半)
|
|
203
|
+
6. 文档(README / VERIFY.md)→ A8
|
|
204
|
+
7. 验收扫尾:A3 / A4 / A6 / A7 / A8 逐条执行并记录;提交 spec/plan 之外的证据到 issue
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Spec: A5 mid-stream 断言窗口修正(issue #132)
|
|
2
|
+
|
|
3
|
+
## 根因(issue + 调研双确认,非猜测)
|
|
4
|
+
|
|
5
|
+
`runner/pty-runner.mjs:342-345`:未处理完 `subscribe_terminal` 的 socket 仍收 legacy 广播行(同形 `{type:"output", seq, data}`)。`test/terminal-snapshot.integration.test.mjs` A5 mid-stream 测试(:241)把 socket B 收到的全部 output(含订阅前 legacy 行)当协议序列统计 → 头部混入一条订阅前行(seq 4);尾部追平(:227)只等 clientA 进度没等 socket B → 尾部少一条。两端叠加 → 负载敏感 CI 偶红。**不是协议违约**(A 路真实 client 全部断言通过)。
|
|
6
|
+
|
|
7
|
+
## 设计(单点修正,两处断言加固)
|
|
8
|
+
|
|
9
|
+
### D1 统计窗口限定(修法一,issue 首选)
|
|
10
|
+
|
|
11
|
+
`:241` 改为只统计 `snapshot_begin` 之后到达的 output:`messagesB.filter(m => m.type === "output" && m.seq >= endB.nextSeq)`——窗口从 `endB.nextSeq`(快照后第一条协议输出)起,订阅前 legacy 行天然排除(legacy 行 seq 也来自同一条轴,但快照判定前的行 seq ≤ snapshotSeq < nextSeq,全部被过滤掉;注意 issue 样本里那条 seq=4 行 snapshotSeq=4,4 < nextSeq=5 ✓)。
|
|
12
|
+
|
|
13
|
+
### D2 尾部追平显式化(修法二)
|
|
14
|
+
|
|
15
|
+
`:227` 的 `waitFor(clientA.getLastSeq() >= readyA.nextSeq + ticks)` 之后,加显式 `waitFor(seqsB_now.length >= ticks)`(socket B 自己收满 ticks 条协议 output),再断言连续。这样尾部缺口消失,不再借 clientA 进度推断 socket B 的追平状态。
|
|
16
|
+
|
|
17
|
+
### 不变式保留
|
|
18
|
+
|
|
19
|
+
- 连续性断言形态不变(`seqsB === [nextSeq, nextSeq+1, ...]`——窗口限定后就是纯协议流,严格连续依然正确)
|
|
20
|
+
- 至少 ticks 条断言保留(D2 等待保证)
|
|
21
|
+
- A 路断言不动(本来全绿)
|
|
22
|
+
- 30s 上限(A5 两个用例的 waitFor 天花板)不动——那是 #95 的负载层问题,本 issue 只修断言语义
|
|
23
|
+
|
|
24
|
+
## 验收矩阵
|
|
25
|
+
|
|
26
|
+
| ID | 功能点 | 验收方式 | 具体验证 | 通过标准 |
|
|
27
|
+
|----|--------|----------|----------|----------|
|
|
28
|
+
| A1 | 统计窗口限定 | 自动化验证(integration) | `node --test test/terminal-snapshot.integration.test.mjs` | A5 mid-stream 绿;断言语义变更为从 nextSeq 起(代码审查可证) |
|
|
29
|
+
| A2 | 尾部显式追平 | 自动化验证(integration) | 同上 | waitFor 显式等 socket B 收满协议条数(代码审查可证) |
|
|
30
|
+
| A3 | 负载下不再复现 | 自动化验证(stress) | 并行全量跑 3 轮 `node --test test/*.test.mjs`(高争用条件) | A5 mid-stream 不再出现 "subscriber seqs strictly contiguous, no dup" 失败 |
|
|
31
|
+
| A4 | 既有断言语义保留 | 自动化验证(integration) | 全量 | 其余断言不变绿 |
|
|
32
|
+
|
|
33
|
+
A3 的「3 轮并行全量」是概率验证而非根除证明(flake 本质如此),如实记录。若 3 轮里 A5 mid-stream 仍挂,说明窗口修正漏了路径,须回炉。
|
|
34
|
+
|
|
35
|
+
## 可测性拆分设计
|
|
36
|
+
|
|
37
|
+
本工单是测试自身的语义修正,没有新生产代码。可测性 = 断言本身即是测试。改动局限在单个 test case 体内,不动 helper/wireClient/runner。
|
|
38
|
+
|
|
39
|
+
## 非目标
|
|
40
|
+
|
|
41
|
+
- 不动 runner 广播逻辑(协议 attach 判定后停写 legacy 行是另一设计决策,可能破坏 legacy 客户端在订阅判定期间看到输出的既有行为——#130 面板提案里相关,不在本 issue)
|
|
42
|
+
- 不动 A5 burst 的 30s 上限、不动 subscribe_terminal 15s waitFor(#95 范畴)
|
|
43
|
+
- 不动 subscribe/dedup 协议语义
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# issue #127 spec —— cwd 选择器 browse 模式 Tab 补全失效
|
|
2
|
+
|
|
3
|
+
> 状态:**已确认**(2026-09-23 用户拍板:选项 B shell 式下钻;`~` 展开不纳入本次范围)
|
|
4
|
+
> 调研留痕:research/01-kb-design-history.md、02-root-cause-code.md、03-test-infra.md(结论已评论到 issue)
|
|
5
|
+
|
|
6
|
+
## 根因报告(systematic-debugging Phase 1–3 结论)
|
|
7
|
+
|
|
8
|
+
**直接原因**:`src/ui/dashboard.ts:598-608` `handleLaunchPickerKey()` 的 Tab 分支内层条件是
|
|
9
|
+
`launch.cwdPickerMode === "favorites" && launch.cwdSuggestions.length > 0`。
|
|
10
|
+
browse 模式下条件不成立,控制流落到分支末尾 `return`——Tab 键被消费但什么都不做。
|
|
11
|
+
|
|
12
|
+
**促成原因**:`src/core/launch-options.mjs:358` `filterCwdCandidates()` 做原始子串匹配、不做 `~` 展开,
|
|
13
|
+
而收藏榜存绝对路径,所以任何 `~/...` 路径式输入必然零匹配,`nextCwdPickerState()`(:372)翻到 browse 模式。
|
|
14
|
+
browse 的候选列表本身是对的(`listDirectorySuggestions()` 内部已 `expandHome`)——
|
|
15
|
+
用户看到「候选对、Tab 死」,正是这条链路。
|
|
16
|
+
|
|
17
|
+
**可稳定复现**:结构性缺陷,无竞态——browse 模式下按 Tab 100% 走空 return。
|
|
18
|
+
修复前先写探针测试复现(Phase 4 要求)。
|
|
19
|
+
|
|
20
|
+
**历史脉络**:browse 是 issue #22(PR #23)之前就有的旧能力;Tab 补全是 #22 只给 favorites 模式新加的,
|
|
21
|
+
旧模式漏接。设计语义应保持 #22 的决定:Tab = 补全进输入框,Enter 才确认。
|
|
22
|
+
|
|
23
|
+
## 关键设计发现(issue 建议 1 单独不成立)
|
|
24
|
+
|
|
25
|
+
browse 补全后重算 `listDirectorySuggestions("/abs/dir")`,返回的 suggestions[0] 恒等于「目录自身」
|
|
26
|
+
(代码里 `if (existsDir(resolved)) out.push(resolved)` 在最前)。若只做建议 1(补全 + 重算),
|
|
27
|
+
下一次 Tab 会补全到自身 = 无进展,**无法像 shell 一样连续 Tab 下钻**。
|
|
28
|
+
issue 自己也意识到这点(建议 2「可选:追加路径分隔符或等价处理」)——结论是建议 2 不是可选,是下钻体验的必要组成。
|
|
29
|
+
|
|
30
|
+
## 设计方案(两个选项,待用户拍板)
|
|
31
|
+
|
|
32
|
+
### 选项 A:最小镜像(favorites 同款逻辑铺到 browse)——未采用
|
|
33
|
+
|
|
34
|
+
Tab 分支去掉 `cwdPickerMode === "favorites"` 限制,两模式共用:
|
|
35
|
+
取 `cwdSuggestions[cwdSuggestionIndex] ?? cwdSuggestions[0]` 写入 cwdQuery,`nextCwdPickerState()` 重算,index 指向已补全项。
|
|
36
|
+
|
|
37
|
+
- 效果:`~/wo` → Tab → `/home/elling/work`;此后 Tab 无进展(补全自身),下钻需 ↓ 选子目录再 Tab。
|
|
38
|
+
- 优点:改动最小、语义与 favorites 完全一致、无新行为。
|
|
39
|
+
- 缺点:不符合 issue 描述的「连续按 Tab 逐层下钻」预期。
|
|
40
|
+
|
|
41
|
+
### 选项 B:shell 式下钻(**已采用**)
|
|
42
|
+
|
|
43
|
+
在 A 的基础上加两条纯函数语义(browse 模式专属):
|
|
44
|
+
|
|
45
|
+
1. **无进展前进**:若高亮候选与当前 query 解析到同一目录(忽略尾部分隔符),Tab 循环前进到下一个候选。
|
|
46
|
+
2. **补全即到位**:browse 模式补全时给目录路径追加 `path.sep` 写入 query——
|
|
47
|
+
重算后列表第一项仍是该目录自身(Enter 可选它),连续 Tab 经规则 1 自然进入子目录,实现纯 Tab 下钻。
|
|
48
|
+
|
|
49
|
+
- favorites 模式行为一字不动(只删模式守卫,不动其分支语义)。
|
|
50
|
+
- 边界:候选只剩自身时下钻自然停止(循环回自身 = no-op);补全后若路径命中收藏榜子串,状态机自动翻回 favorites,可接受。
|
|
51
|
+
|
|
52
|
+
### 范围外(单独的改进,本 issue 不含)——已确认不纳入
|
|
53
|
+
|
|
54
|
+
- issue 建议 3(收藏匹配前先 `~` 展开):正交的 UX 改进,改变模式翻转条件,建议另开 issue 或用户明确点名再加。
|
|
55
|
+
|
|
56
|
+
## 改动面(选项 B)
|
|
57
|
+
|
|
58
|
+
1. `src/core/launch-options.mjs`:新增两个纯函数(见可测性拆分)。
|
|
59
|
+
2. `src/ui/dashboard.ts`:
|
|
60
|
+
- `handleLaunchPickerKey()` Tab 分支删模式守卫,browse 走新 helper(约 :598-608);
|
|
61
|
+
- browse 底部提示(:1586)补 `tab complete`,与 favorites(:1582)对齐。
|
|
62
|
+
|
|
63
|
+
## 可测性拆分设计(实现硬约束)
|
|
64
|
+
|
|
65
|
+
新逻辑全部沉到 `launch-options.mjs` 纯函数层,dashboard 只做接线:
|
|
66
|
+
|
|
67
|
+
| 函数 | 职责 | 副作用 | 测试方式 |
|
|
68
|
+
|------|------|--------|----------|
|
|
69
|
+
| `sameResolvedDir(a, b)` | 两路径串是否解析到同一目录(expandHome + resolve + 去尾部分隔符) | 无(纯字符串运算) | unit |
|
|
70
|
+
| `browseTabCompletion(query, suggestions, index)` | 返回 `{ query, usedIndex }`:含无进展前进、目录追加分隔符 | 仅 `existsDir` 读 fs(与模块既有风格一致,测试用 tmpdir 真实目录) | unit |
|
|
71
|
+
|
|
72
|
+
dashboard.ts 的 Tab 分支瘦身成:取候选 → 调 helper → 写 query → `nextCwdPickerState()` 重算 → 设 index。
|
|
73
|
+
这层接线用既有探针模式覆盖(test-support/*.ts + `node --experimental-transform-types` 子进程),
|
|
74
|
+
不新造测试基建。实现不得把这两条语义重新耦回 dashboard.ts(保持纯函数边界)。
|
|
75
|
+
|
|
76
|
+
## 验收矩阵
|
|
77
|
+
|
|
78
|
+
| ID | 功能点 | 验收方式 | 具体验证 | 通过标准 |
|
|
79
|
+
|----|--------|----------|----------|----------|
|
|
80
|
+
| A1 | browse 模式 Tab 补全高亮候选进输入框并重算状态 | 自动化(integration,探针) | `node --test test/dashboard-cwd-tab.test.mjs` | 补全后 cwdQuery 等于高亮候选路径;修复前此测试必失败 |
|
|
81
|
+
| A2 | 连续 Tab 逐层下钻(选项 B 专属) | 自动化(integration,探针) | 同上探针:构造 tmp 目录树 `root/work/app`,连续 Tab | cwdQuery 依次深入 `work/` → `work/app/` |
|
|
82
|
+
| A3 | favorites 模式 Tab 行为不回归 | 自动化(integration,探针) | 同上探针:ranked 非空 + 子串命中场景 | Tab 仍补全高亮收藏项,模式保持 favorites |
|
|
83
|
+
| A4 | 纯函数 helper 边界 | 自动化(unit) | `node --test test/launch-options.test.mjs` | sameResolvedDir / browseTabCompletion 用例全过(含 `~`、尾部 `/`、单候选循环、index 越界) |
|
|
84
|
+
| A5 | browse 提示文案含 tab | 自动化(integration,探针渲染输出断言) | 同上探针渲染 launch 视图 | browse 模式提示行含 `tab complete` |
|
|
85
|
+
| A6 | 全量测试无回归 | 自动化(unit+integration 混合) | `npm test`(node --test test/*.test.mjs) | 全绿;另跑 `npm run typecheck` 通过 |
|
|
86
|
+
| U1 | 真实 TUI 手感 | 用户实测 | 开 agent-board → Start session → cwd 输 `~/wo` → Tab 补全为 `~/work/`(或绝对路径)→ 连续 Tab 下钻 → Enter 确认 → 启动 session | 补全/下钻符合 shell 手感;启动的 session cwd 正确 |
|
|
87
|
+
|
|
88
|
+
说明:U1 无法自动化的原因——补全「手感」(连续 Tab 节奏、渲染刷新是否顺眼)只有人在真实终端能判断;
|
|
89
|
+
探针已覆盖功能正确性,U1 是最终 UX 确认,时机为 PR 合并前。
|
|
90
|
+
|
|
91
|
+
## 非目标
|
|
92
|
+
|
|
93
|
+
- 不改 Enter/上下键/Esc 语义;不动 cwd-stats 计数;不动 favorites 排序与 home 兜底。
|
|
94
|
+
- 不做模糊匹配(fuzzy subsequence)——#22 已明确否决。
|
|
95
|
+
- 不做 issue 建议 3 的 `~` 展开进收藏匹配(除非用户点名纳入)。
|