@zhuxixi/pi-agent-board 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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 的 `~` 展开进收藏匹配(除非用户点名纳入)。
@@ -0,0 +1,63 @@
1
+ # Spec: attach 终端能力透传——亮暗探测与颜色方案通知(issue #128)
2
+
3
+ ## 根因(三缺口,调研钉死)
4
+
5
+ attach 客户端用 headless xterm 重建屏幕,宿主 Pi 的终端能力查询被本地模型消费、从不转发真实终端(缺口 A);即使转发,996/2031 应答会被本地 pi-tui `consumeTerminalColorSchemeReport` **无条件消费**,到不了宿主(缺口 B);宿主首次探测发生在 runner spawn 时(attach 前),纯转发只救 /settings 重选,救不了首帧(缺口 C)。
6
+
7
+ ## 设计(四组件,全部在 attach 客户端侧)
8
+
9
+ ### D1 出站转发器:`extractOscQuerySequences`(纯函数)
10
+
11
+ - 转发:OSC 11 查询 `\x1b]11;?` + BEL/ST 定界;`\x1b[?2031h`、`\x1b[?2031l`
12
+ - 不转发:OSC 11 **设置** `\x1b]11;rgb:...`(防污染本地配色);kitty/DA 协商 `\x1b[>7u`、`\x1b[?u`、`\x1b[c`(见非目标)
13
+ - 结构照抄 `extractOsc52Sequences` 的 carry 模式(跨 chunk 前缀保留,独立 `oscQueryCarry`),接入 `forwardTerminalProtocols` 第三提取器
14
+ - 转发是追加行为:序列照常喂 headless 屏幕模型(xterm 忽略 OSC 11 查询,无副作用)
15
+
16
+ ### D2 亮暗桥:`onTerminalColorSchemeChange` → 宿主
17
+
18
+ - attach 时 `this.tui.onTerminalColorSchemeChange(scheme => this.send({type:"input", data: toColorSchemeReport(scheme)}))`;detach 时退订(生命周期对称)
19
+ - 原理:本地 pi-tui 消费 996 报告/2031 通知时**同步通知**该监听器——桥把消费事件重打包为颜色方案报告(`\x1b[?997;Ps n`,2=light 1=dark——报告码点是 997,996 是查询 DSR;task 1 实测对齐 pi-tui terminal-colors.js:19)写宿主 pty,宿主 pi-tui 同 parser 消费并通知宿主的 query/2031 监听
20
+ - `toColorSchemeReport(scheme)` 纯函数;格式对齐 pi-tui `parseTerminalColorSchemeReport` 的接受形态(实现时从其源码对齐)
21
+
22
+ ### D3 首帧回放:attach settle 时主动发一条探测
23
+
24
+ - attach settle 后向本地终端写一条 `\x1b]11;?\x07`(`tui.terminal.write`)
25
+ - 应答**不需要截获**:本地 pending=0(正常场景)时穿透消费链 → attach 组件 handleInput 兜底 → `send({type:"input"})` → 宿主收到 → 主题翻转
26
+ - 失败模式(探测无应答/本地 pending>0 截获):静默保持现状(dark),不重试
27
+ - 仅 settle 时一次;后续宿主自身重探测(/settings 重选)由 D1 转发覆盖
28
+
29
+ ### D4 应答天然路径(零代码,验收依据)
30
+
31
+ kitty flags/DA/OSC 11 应答不被本地消费链 ①②④ 消费 → 流到 attach 组件兜底转发(既有行为,issue 正文已确认)——D1/D3 的应答回程都靠它。
32
+
33
+ ## 验收矩阵
34
+
35
+ | ID | 功能点 | 验收方式 | 具体验证 | 通过标准 |
36
+ |----|--------|----------|----------|----------|
37
+ | A1 | 查询提取器 | 自动化验证(unit) | `node --test test/terminal-query-sequences.test.mjs` | 查询(BEL/ST)转发、设置形态不转发、2031h/l 转发、跨 chunk carry 正确 |
38
+ | A2 | scheme→报告序列 | 自动化验证(unit) | 同上 | `toColorSchemeReport` 输出与 pi-tui parser 接受形态一致(light/dark 两态) |
39
+ | A3 | 出站转发接线 | 自动化验证(smoke) | `node --test test/pty-attach-detach-gate.test.mjs`(扩展场景) | 远端 output 含 OSC 11 查询 → 本地 terminal.write 收到;含 `rgb:` 设置 → 不写 |
40
+ | A4 | 亮暗桥接线 | 自动化验证(smoke) | 同上 | 伪 tui 触发 colorScheme listener → socket 收到 `type:"input"` 且 data 为颜色方案报告(`\x1b[?997;2n` / `\x1b[?997;1n`);detach 后触发不再发 |
41
+ | A5 | 首帧回放 | 自动化验证(smoke) | 同上 | attach settle → 本地 terminal.write 恰好一条 `\x1b]11;?\x07`;detach/未 settle 不写 |
42
+ | A6 | kitty/DA 不转发 | 自动化验证(smoke) | 同上 | 含 `\x1b[>7u\x1b[?u\x1b[c` 的远端 output 不写本地终端 |
43
+ | A7 | 桥生命周期 | 自动化验证(unit) | 同 A4 用例内 | 注册/退订对称,无泄漏监听 |
44
+ | U1 | 重选恢复 | 用户实测 | WezTerm Latte 浅色 + attach 会话 → /settings → Theme → Automatic | 主题变 light-warm(accent 色对) |
45
+ | U2 | 首帧正确 | 用户实测 | 浅色终端 attach 一个新会话,不进 /settings | 进入即 light-warm(回放生效) |
46
+ | U3 | 实时跟随 | 用户实测 | attach 中切系统亮暗(KDE appearance) | 会话主题实时跟随(2031 桥) |
47
+
48
+ ## 可测性拆分设计(硬约束)
49
+
50
+ - `extractOscQuerySequences(input) → {sequences, carry}`:纯函数,独立文件 `src/core/terminal-query-sequences.mjs`(与提取器家族同址),单测直测(A1)
51
+ - `toColorSchemeReport(scheme) → string`:纯函数,同文件,单测直测(A2);格式常量从 pi-tui `parseTerminalColorSchemeReport` 源码对齐并在测试注释标注来源
52
+ - 桥/回放/转发接线:attach 组件内三个小方法(`attachColorSchemeBridge`/`replayBackgroundQuery`/forward 接线),副作用(tui.terminal.write、send)通过既有 smoke harness 的伪 tui/socket 断言(A3-A7),不在单测层 mock 组件内部
53
+
54
+ ## 非目标(已知未覆盖,如实记录)
55
+
56
+ - **kitty/DA 键盘协议协商**:宿主协商仅在 spawn 时(attach 前已错过,转发无效);且转发 `\x1b[>7u` 会污染真实终端 kitty 栈顶(本地 pi 与宿主共享 stdin,flags 不一致时本地输入解析错乱)。修饰键编码差异保持现状(modifyOtherKeys 回退)
57
+ - **OSC 10/12 查询**:pi 0.87.0 实测不发,不做 speculative 转发
58
+ - **本地 pi pending>0 边角**:本地探测超时且上游 counter bug 未清零时,回放应答被本地截走一次(宿主下次重探测即恢复);修上游(counter 递减)超出本仓范围
59
+ - **kill switch**:沿用既有 env 约定(`AGENT_BOARD_FORWARD_TERMINAL_QUERIES=0` 关闭全部新行为,默认开)
60
+
61
+ ## 降级路径
62
+
63
+ 全部新行为 best-effort:转发写失败静默(沿用 forwardTerminalProtocols catch);桥/回放失败不影响 attach 主体;kill switch 一键回退现状。
@@ -0,0 +1,57 @@
1
+ # Spec: 终端无关的无条件 detach 逃生键(issue #126)
2
+
3
+ 状态:draft(等用户确认)
4
+ 日期:2026-09-23
5
+
6
+ ## 问题
7
+
8
+ `Ctrl+←` 是 attach 表面唯一的无条件 detach 键(#89 引入),但它的可达性依赖「终端愿意把 Ctrl+Left 编码为转义序列发送」——macOS Terminal.app 默认不发任何字节、WezTerm-macOS 把它消费为 tab 切换(#126 实测)。D1(#135)后 `←` 只在 `editorEmpty === true` 时 detach,于是在坏终端 + 草稿/未知编辑器状态下用户无键可逃,只能 Ctrl+C/D 连带杀 child——触碰 #48「attach 永远可退出」红线。
9
+
10
+ ## 设计决策
11
+
12
+ **新增 `Ctrl+\`(裸字节 0x1c)作为无条件 detach 键**,形成三层逃生梯队:
13
+
14
+ 1. `←` — 智能层:`editorEmpty === true` 时 detach,否则转发(D1 契约不变)
15
+ 2. `Ctrl+←` — 快捷层:好终端保留(不依赖 child 状态,已实现)
16
+ 3. `Ctrl+\` — **无条件层**:任何状态(含 editorEmpty=false/null、connected 与否)立即 detach,且不依赖终端对修饰键的编码
17
+
18
+ ### 为什么是 Ctrl+\(被丢弃的选项)
19
+
20
+ - 0x1c 是单字节 ASCII 控制字符(FS):raw mode 下**所有**终端原样发送(kitty legacy ctrl-mapping 官方表;pi-tui keys.js 头注释引用同一映射);没有任何主流终端默认占它做快捷键;SIGQUIT 语义只存在于 cooked mode,board/pi/child 全链路 raw mode 不受影响。
21
+ - 实测证据(spec review 全部复验):pi-tui `matchesKey` 对**三种键盘模式编码**全部原生匹配——裸字节 `\x1c`、kitty CSI-u `\x1b[92;5u`、modifyOtherKeys `\x1b[27;5;92~`(pi 本体开启 modifyOtherKeys/kitty 探测,WezTerm 等真实环境发的正是后两种);pi 本体 dist 全量 rg 无 `ctrl+\` 绑定;本仓 src/runner/test 无占用。
22
+ - **已知限制一(键盘布局)**:「所有终端」的断言只覆盖终端协议层,不覆盖键盘布局层——德式/北欧等布局中 `\` 位于 AltGr 层(如 AltGr+ß),Ctrl+\ 未必产出 0x1c。布局受限场景由梯队兜底:这些布局高发的 Linux 现代终端上 `Ctrl+←`(快捷层)可靠;`editorEmpty=true` 时 `←`(智能层)也始终在。README 已知代价中如实写明。
23
+ - **已知限制二(Ctrl+4 别名)**:legacy ASCII 映射中 Ctrl+4 同样产出 0x1c,故 legacy 模式下按 Ctrl+4 也会 detach。Pi 不使用 Ctrl+4,风险可忽略,但属于「从 child 抢键」代价的一部分,文档如实记录。
24
+ - 被丢弃:Esc(#89 否决:vim 冲突)、双击 ←(#89 否决:误触)、F 键(零星终端绑定冲突 + Pi 未来可能占用)、鼠标 detach 区(误触 + 可发现性差,不承担红线)、只修文案(不根治)。
25
+ - 已知代价(文档化):从 child 抢 Ctrl+\。Pi 无此绑定;仅 child 内 vim terminal-mode 的 Ctrl+\ Ctrl+N 前缀受影响(极小众)。
26
+
27
+ ### 不做的事(Non-goals)
28
+
29
+ - 不翻转 D1 的 `editorEmpty=null → 转发` 契约(坏终端由无条件层兜住,不动刚钉死的语义)。
30
+ - 不移除 `Ctrl+←`(好终端保留为快捷层)。
31
+ - 不改断线路径(`!connected` 已无条件逃生)。
32
+ - 不为 0x1c 做可配置化(YAGNI;若未来 child 需要 Ctrl+\,届时加配置项)。
33
+
34
+ ## 组件契约
35
+
36
+ `src/ui/pty-attach.ts` `handleInput`:在 `Key.ctrl("left")` 分支之前新增分支——
37
+ `if (matchesKey(data, "ctrl+\\")) { this.detach(); return; }`(0x1c 裸字节直达,无终端编码依赖)。
38
+ header(render)与 loading banner 文案更新:`Ctrl+← detach` → `Ctrl+← / Ctrl+\ detach`;README 按键段(:96/:195 区域)补无条件键说明与已知代价。
39
+
40
+ ## 验收矩阵
41
+
42
+ | ID | 功能点 | 验收方式 | 具体验证 | 通过标准 |
43
+ |----|--------|----------|----------|----------|
44
+ | A1 | Ctrl+\ 任何状态无条件 detach | 自动化验证(unit,detach-gate smoke 场景) | `node --test test/pty-attach-detach-gate.test.mjs`:注入 `\x1c` 于 editorEmpty=true/false/null × connected=true/false 四象限 | 四象限全部 detach(唯一无条件断言集) |
45
+ | A2 | Ctrl+\ 不受 child 草稿门禁影响 | 自动化验证(unit) | 同 A1 的 editorEmpty=false 用例显式 pin(草稿在、仍 detach) | 草稿态 detach 成功 |
46
+ | A3 | 0x1c 与既有键无匹配冲突 | 自动化验证(unit) | 注入 `\x1b[1;5D`(ctrl+left)、`\x1b[D`(left)、`q`、`\x1c` 各自独立断言行为 | ctrl+left 仍 detach;left 按 D1 门禁;q/可打印键转发;0x1c detach |
47
+ | A4 | 文案与 README 同步 | 自动化验证(unit + static) | detach-gate 测试断言 header 含 `Ctrl+\`;rg README 两处按键段含无条件键说明 | 断言通过 |
48
+ | U1 | 真实坏终端实测 | 用户实测(mac 环境,**pending**:本机 Linux 无法执行) | macOS Terminal.app(默认设置)attach 会话 + 编辑器含草稿 → 按 Ctrl+\ | detach 回 dashboard,child 存活(重新 attach 草稿还在) |
49
+ | U1b | Linux 等效实测(U1 的本机可执行替代) | 用户实测 | WezTerm(kitty 开启,CSI-u 路径)与 xterm(legacy 路径)各一次:attach 会话 + 编辑器含草稿 → 按 Ctrl+\ | 两种终端均 detach,child 存活(重新 attach 草稿还在) |
50
+
51
+ ## 可测性拆分设计
52
+
53
+ - A1-A3 全部走既有 `test-support/detach-gate-smoke.ts` 真组件 harness(真实 xterm buffer → handleInput → didDetach()/转发包内容),新增 4 个场景(四象限)+ 1 个冲突隔离场景组;连接态用 smoke 既有 `connected=true` 显式模式。无需新纯函数(改动是单分支拦截,行为面全部由组件级 harness 覆盖)。
54
+ - A1 的四象限注入应同时覆盖**三种编码形态**(裸 `\x1c` / kitty `\x1b[92;5u` / modifyOtherKeys `\x1b[27;5;92~`)至少各一条,防止未来键解析层变更悄悄断掉某一模式(spec review 实测三种编码当前均被 `matchesKey` 原生匹配)。
55
+ - A4 的 header 断言沿用既有 `headerMentions*` 模式。
56
+ - 测试边界:不改 `shouldEscapeAttach`(它的语义是 ← 门禁,与 Ctrl+\ 分支正交);smoke 场景不得依赖终端模拟层(0x1c 是裸字节,天然无模拟依赖)。
57
+ EOF
@@ -0,0 +1,98 @@
1
+ # Spec(v3,已确认):#140 control-reconcile 偶红收敛 —— 断言窗口化与口径对齐
2
+
3
+ - issue: zhuxixi/pi-agent-board#140
4
+ - 根因报告:`research/root-cause.md`(含复现脚本 `research/repro-140.test.mjs`,产品代码零改动)
5
+ - 状态:已确认(2026-09-24,用户批准 v3)
6
+ - 变更记录:v1→v2(首轮评审)与 v2→v3(终审)见文末 §8
7
+
8
+ ## 1. 问题
9
+
10
+ `test/control-reconcile.integration.test.mjs` 的两条断言在负载下偶红(签名 A `:295`、签名 B `:383`),**产品行为正确**:
11
+
12
+ - 签名 A:wire 上出现重复 seq。机制是 runner 无法原子「停止裸广播 + 开始 ring 重放」——订阅前已写入 socket 的 stray 若在客户端**发出订阅之后**才投递,就落在 `resyncing` 分支(既不计入 `strayHighWater`,又被当成重放起点)。客户端按 `seq <= lastSeq` 丢弃重放的那一份 ⇒ **UI 仍恰好一次**。
13
+ - 签名 B:`assert.ok(h.outputSeqs().at(-1) >= ready2.nextSeq)` 不是有效不变量——`outputSeqs()` 是跨 socket 累加器,`ready2` 的 `waitFor` 因旧事件已存在而**立即返回**(取到旧 baseline)。新 runner 的 seq 从 1 重启,只要断言时刻最后一条 output 是快照前的 stray,断言即假。
14
+ - 共同根因:`attachClientOverSocket()` 的 `messages` / `events` 是闭包级单数组,`switchSocket()` 不清空、不分段 ⇒ 断言把「重连前残留 / 窗口外消息」与「新流」混在同一集合里比较。
15
+
16
+ 对 issue 三条出口的对账:命中「断言过严/把窗口外残留计入」;**未**命中「runner 真实重复写 seq」(重复 = 一次订阅前裸广播 + 一次重放);**不属** #95 墙钟类(两条均为断言失败,326ms/359ms,非超时)⇒ 不重分类。
17
+
18
+ **证据强度(v2 补)**:「UI 恰好一次在 wire 重复下成立」共 4 次观测(基线自然交错 1 次 + 150ms 确定性交错 3 次,marker guard 全部通过,其中 3 次窗口内有 6–7 个重复);「老断言不完备」已从推断升级为观测——150ms 交错下 wire 含 6 个重复而老 `deepEqual` 通过(前 3 个恰好被 strays 填满)。
19
+
20
+ ## 2. 目标 / 非目标
21
+
22
+ **目标**:让该文件 A2 相关断言不再依赖消息到达时序;同时保住对真实回归的检测力;把与真实保证不符的注释改正。
23
+
24
+ **非目标**:
25
+ - 不改产品行为(唯一产品改动是注释,见 D6)。
26
+ - 不做 F2(runner 侧为每 socket 记裸广播高水位、重放起点取 `max(sinceSeq, broadcastHighWater)`):只改 runner 会让客户端 **UI 冻结**:客户端 `resyncing` 对 `seq !== lastSeq + 1` 的 chunk 一律忽略,且该状态没有 gap 检测 / resync 触发路径,直到下一次重连才能恢复(v3 措辞精确化),必须同时改客户端游标语义;而收益仅「wire 无重复」,UI 早已正确 ⇒ 留给未来协议级设计(本 spec 只记录评估结论,不实现)。
27
+ - 不动 #95 的墙钟预算策略。
28
+
29
+ ## 3. 决策
30
+
31
+ | ID | 决策 | 理由 |
32
+ |---|---|---|
33
+ | D1 | 在测试 harness 内加**窗口/标记 API**:recorder 抽成文件顶部的 **`createRecorder()` 纯数据结构**(`push` / `mark` / `since`),`attachClientOverSocket` 只是它的一个消费者(socket 绑定) | 根治「跨 socket 累加器」;纯数据结构让窗口语义真正可单测(见 D2/A2) |
34
+ | D2 | 判定逻辑抽成**纯函数**(文件顶部,与 harness 并列):`distinctSeqsFrom(seqs, from)`、`hasWireOverlap(seqs)`、`isContiguousFrom(distinct, from, count)` | 判定收口、可单测、避免断言内联逻辑再次漂移 |
35
+ | D3 | 签名 A 的断言改为设计真正保证的性质:**去重后连续**(无 gap)+ 既有的 **marker guard(UI 恰好一次)**;测试内注释写明「wire 允许在广播→重放交接处重复,UI 重复才是 bug」 | 设计只保证 UI 级 exactly-once;老断言(要求 wire 无重复)既过强(重复合法)又不完备(只查前 3 个 seq,窗外重复抓不到,已观测证实)。**检测力等价性论证**:对「重放起点偏高(跳过 d+1)」与「gap」两类真实回归,新断言与老断言同样变红(去重后首个 distinct ≠ d+1 即失败);唯一放弃检测的是「wire 重复」——恰是合法行为,其 UI 级真回归由 marker guard 覆盖 |
36
+ | D4 | 签名 B 改为**条件式等待 + 客户端级不变量**:等 `snapshotReady` **数量增长**后取新事件的 `nextSeq`;断言 `h.client.getLastSeq()` 到达新 baseline 并继续前进;如需 wire 级证据,用 **mark 窗口 / 该 socket** 的切片 | `waitFor(存在任意旧事件)` 不是等待;跨 socket 的 `.at(-1)` 不是「新流的最后一条」 |
37
+ | D5 | 新增**确定性交错用例**(harness 侧注入,产品代码不动),注入设计如下(v2 修正):<br>① **late-subscribe**:延迟**出站**的重连订阅写。参数依据:steady tick = 25ms(`test-support/fake-pty-pi.mjs:10`),**延迟 ≥ 4× tick 周期(150ms ≈ 6 tick)**——实测 3/3 每次恰好 6 个 strays 落窗,确定性成立;不可改成 ~40ms(≈ 1.6 tick,实测 2/5 命中,回到不确定)。优先实现为现有 A 测试内**同一条连接的第二次 reconnect**(注入只对第二次生效),新增 spawn = 0。<br>② **slow-consumer(epoch)**:**相对延迟**——只延迟 snapshot 路径消息(`deferFeed: msg => msg.type?.startsWith("snapshot") ? X : 0`),让 epochReset 立即触发而快照滞后到达。⚠️ **统一延迟无效**:stray/reply/snapshot 各后移同样 X,`[epochReset, 快照投递]` 窗口宽度不变(已从时序推演确认)。此用例必须新 spawn runner(epoch 换代无法复用),+2。<br>**非空转断言的观测点(v2 修正)**:重叠**滞后到达**——`waitFor(≥3)` 返回时重放尚未落地(实测该时刻 duplicates=0)。因此非空转必须写成**条件式等待**:① 用 `waitFor(() => 窗口内出现重复, 上限)` 后再断言不变量;② 的中间态检查在 epochReset 触发后立即执行,非空转条件是**此刻新 snapshotReady 事件尚不存在**(相对延迟保证快照必然未投递,确定性成立);注意 stray 是否先到达属时序不确定(新 runner 首个 tick 与 hello/reconcile 往返是竞速,两种结局都观测过),**不**作为非空转条件(v3 修正) | 把偶红变成必红的确定性覆盖;非空转断言防止用例静默退化为「什么都没覆盖」 |
38
+ | D6 | 产品侧仅改一处注释:`src/core/terminal-attach-client.mjs` 中 `(wire-level no-dup)` 的表述 → 精确为「覆盖**已观察到的** stray;UI 级 exactly-once 才是保证,wire 可能重复在途 stray」;顺带核对该测试内 `:283-285` 的注释(「replay 恰好从 cursor 起 / stray 高水位起」同样只对已观察到的 stray 成立) | 注释即契约:这条过度承诺正是过严断言的来源(与 #130 同型根因) |
39
+ | D7 | 降级路径:若确定性交错用例在本地/CI 出现抖动(非空转等待超时等),退化为「自然交错 + 稳健断言(D3/D4)」,移除该用例并在 PR 描述记录原因 | 不为了确定性而引入新的不稳定 |
40
+
41
+ ## 4. 可测性拆分设计(自动化项必答)
42
+
43
+ | 层 | 单元 | 怎么测 |
44
+ |---|---|---|
45
+ | 纯函数(无副作用) | `distinctSeqsFrom` / `hasWireOverlap` / `isContiguousFrom` | 直接单测:给定 seq 数组(含重复、缺口、空窗、边界 `from`)断言返回;不依赖 socket、不依赖时序 |
46
+ | 纯数据结构(副作用仅限内部数组) | `createRecorder()`(push/mark/since)——窗口 API 的唯一新增记录侧逻辑 | 单测 mark 语义(mark 之前的消息/事件不得进入窗口);socket 绑定由集成用例覆盖 |
47
+ | 时序边界 | 交错注入点(`deferWrite` / `deferFeed` 钩子,默认 0 = 行为不变) | 由 D5 两个确定性用例覆盖;默认关闭保证既有用例零影响;注入只延迟**写或投递**,`sent` 的记录顺序不变(不扰动 wire-order 断言) |
48
+ | 端到端 | 两条既有 A2 用例(改断言后)+ 两个确定性用例 | `node --test` 按名过滤,重复运行验证稳定 |
49
+
50
+ 测试边界声明:**不**新增产品侧纯函数(无产品逻辑变更);新增纯函数/纯数据结构全部位于测试文件内,不得反向引入产品依赖。<br>**plan 阶段审计项(v3 新增)**:全文件 grep recorder 累加器的所有断言使用(`outputSeqs` / `eventsOf` / `echoCount` 等全局计数),凡跨越断连/重连边界的断言一律改走窗口 API——例如 `echoCount` 目前恰好没事只是因为 echo chunk 的 seq 落在重放范围之外(seq ≤ 断连游标),是巧合不是设计。
51
+
52
+ ## 5. 验收矩阵
53
+
54
+ | ID | 功能点 | 验收方式 | 具体验证 | 通过标准 |
55
+ |---|---|---|---|---|
56
+ | A1 | 判定谓词行为 | 自动化(unit) | `node --test --test-name-pattern="seq window predicates" test/control-reconcile.integration.test.mjs` | 重复/缺口/空窗/边界用例全部符合预期 |
57
+ | A2 | 窗口 API 语义 | 自动化(unit) | 同上(`createRecorder` 的 mark 语义用例) | 窗口内不含 mark 之前的消息/事件 |
58
+ | A3 | 签名 A 断言口径(自然交错) | 自动化(integration) | `node --test --test-name-pattern="A2: reconnect wires"` | 去重后连续(`[d+1,d+2,d+3]`)+ marker guard 通过 + 客户端游标推进 ≥ d+3 |
59
+ | A4 | 确定性交错 ①(late-subscribe) | 自动化(integration) | 同上(150ms 出站订阅延迟,注入只对 reconnect 生效) | `waitFor(窗口出现重复)` 命中(**非空转**)⇒ 去重后无 gap + UI 恰好一次 |
60
+ | A5 | 签名 B 断言口径 | 自动化(integration) | `node --test --test-name-pattern="A2 epoch"` | 等**新** `snapshotReady`(数量增长);客户端游标到达 `nextSeq-1` 并继续前进 |
61
+ | A6 | 确定性交错 ②(slow consumer) | 自动化(integration) | 同上(snapshot 路径相对延迟 X) | epochReset 后立即检查中间态:**新 snapshotReady 事件尚不存在**(**非空转**,确定性成立)+ 新断言通过 |
62
+ | A7 | 检测力(mutation 检查) | 自动化(integration,临时副本) | 在临时副本里分别制造「丢掉一个 chunk」与「对 UI 重复 emit 一次」 | 新断言分别变红:no-gap 断言捕获丢包、marker guard 捕获 UI 重复 ⇒ 证明未空转 |
63
+ | A8 | 稳定性 | 自动化(integration) | 对受影响用例(两条 A2 + 两条确定性,共 4 个)连续 **100** 次孤立运行(约 5 分钟) | 0 失败。统计边界:若签名 A 的真实失败率仍为基线 1/10,100 次全绿概率 ≈ 0.003% ⇒ 证伪力足够。真正的保证来自「失败模式被机制性移除 + A4/A6 确定性覆盖」,A8 是 sanity |
64
+ | A9 | 静态与全量 | 自动化(static/build) | `npm run typecheck`;全量 `node --test` | typecheck 0 error;测试通过数不下降,失败仅既有 flake(需隔离复跑归因) |
65
+ | — | 用户实测 | — | — | **无 U 项**:本 issue 无用户可见行为变更(产品侧仅注释),故不设用户实测;这是结论而非兜底分类 |
66
+
67
+ ## 6. 风险与缓解
68
+
69
+ | 风险 | 缓解 |
70
+ |---|---|
71
+ | 放宽 wire 断言后,真实的「同路径重复写」回归可能漏检 | marker guard 保持为 UI 级权威;A4/A6 的非空转断言确保交错被真实覆盖;A7 mutation 检查证明断言仍能变红;测试注释写明「wire 允许、UI 不允许」 |
72
+ | 确定性注入自身引入新的不稳定(非空转等待超时 = 反向 flake) | 注入参数有硬依据(≥4× tick,tick=25ms 实测);非空转为有界条件等待;抖动时按 D7 降级并记录 |
73
+ | 新增真实进程用例加剧 #95 的并行挤压 | late-subscribe 复用现有 runner(+0 spawn);epoch 用例 +2 不可避免;净增 spawn = 2 |
74
+ | 断言改动掩盖「客户端游标未推进」这类真问题 | A3/A5 显式断言客户端游标推进(不只看 wire) |
75
+
76
+ ## 7. 范围核实记录(v2 新增)
77
+
78
+ - 跨 socket 录制器模式(`attachClientOverSocket` / `switchSocket`)**全仓仅此一个文件**(grep 证实)⇒ 无需姊妹文件审计,范围判断成立。
79
+ - flake「账本」无独立文件(docs/ 下无 ledger);issue 所述账本实际是 #95 的追踪。本修复无需账本动作;PR 描述注明「#140 两条签名已机制性消除(断言口径修正 + 窗口化),非运气性通过」。
80
+
81
+ ## 8. 评审修正记录(v1 → v2)
82
+
83
+ 1. **[必修] A4 观测点**:重叠滞后到达(`waitFor(≥3)` 返回时 duplicates=0,实测),非空转改为条件式等待后再断言。
84
+ 2. **[必修] A6 注入设计**:统一延迟无效(不改变相对时序,推演确认);改为 snapshot 路径相对延迟。
85
+ 3. **[应修] A8 统计效力**:30 次 → 100 次,并如实声明「机制性移除为主、A8 为 sanity」。
86
+ 4. **[应修] D5 参数依据**:写明 tick=25ms 与「≥4× tick」余量,禁止回退到 ~40ms。
87
+ 5. **[建议] runner 复用**:late-subscribe 用例复用现有 A 测试的连接(+0 spawn),净增 spawn 降为 2。
88
+ 6. **[建议] recorder 纯数据结构**:`createRecorder()` 与 socket 绑定解耦,A2 的 unit 定位名副其实。
89
+ 7. **[建议] D3 等价性论证**:写明对 gap / 起点偏高两类回归检测力不降,防未来评审者误判。
90
+ 8. **[核实] 范围两件事**:录制器模式无姊妹文件;账本无需动作(见 §7)。
91
+
92
+ ### v2 → v3(终审)
93
+
94
+ 9. **[必修] A6 非空转条件**:stray 到达时机是竞速(新 runner 首 tick 25ms vs hello/reconcile 往返 ~1–3ms),非确定性;改写为「测试推进时新 snapshotReady 事件尚不存在」(相对延迟保证,确定性)。
95
+ 10. **[应修] A8 口径**:全文件 100 次 ≈ 12 分钟 → 收窄为受影响 4 用例 100 次 ≈ 5 分钟,统计边界不变。
96
+ 11. **[应修] F2 否决措辞**:「卡死」→「UI 冻结(resyncing 无 gap 检测 / resync 触发路径,直至下一次重连)」。
97
+ 12. **[建议] plan 阶段审计项**:全文件 recorder 累加器断言审计(`echoCount` 目前安全是巧合而非设计)。
98
+ 13. **[核对] tick 参数契约**:注释 25ms 与 `setInterval(..., 25)` 实参一致(`fake-pty-pi.mjs:10-16`)✓。
@@ -0,0 +1,190 @@
1
+ # Spec:probe 在连接窗口内计入 attachedClients(issue #130)
2
+
3
+ 日期:2026-09-24 · 状态:**approved(用户确认 D1=A / D2=A / 范围=A)**
4
+ 基线:`main @ 3dad7f9`(issue 行号基于 `794c755`,已逐条核对未漂移)
5
+ 调研:issue #130 评论「根因确认 + 确定性复现」/ `~/.claude/github-issue-driven/zhuxixi/pi-agent-board/issue-130/research/root-cause.md`
6
+
7
+ ## 1. 根因(systematic-debugging 结论)
8
+
9
+ ### 1.1 一句话根因
10
+
11
+ `runner/pty-runner.mjs` 的 `clients` 这个 Set 同时承担两个角色——**广播目标**与**已连接真实客户端计数**
12
+ (`attachedClients: clients.size`,legacy `:256` / owned `:611`)。hello 分类把 reporter 从 `clients`
13
+ 移了出去(#103),**却漏了 probe**:probe 只在 owned 路径被 `markProbe()` 标记(`:1096-1098`)、
14
+ legacy 路径完全不处理(`:427-434`),于是 probe 从 connect 到 close 一直被计入 `clients.size`。
15
+
16
+ ### 1.2 两条路径的差异(决定危害大小)
17
+
18
+ | | 计数窗口 | 窗口内的错值如何被修正 |
19
+ |---|---|---|
20
+ | **legacy** | connect(`:375`)→ close(`:389/:396`) | close 处理器**无条件 `update()`** → 立即落盘正确值 |
21
+ | **owned** | connect(`:869`)→ close(`:883/:899`) | close/error **对 probe 提前 return**(`:888`/`:898`,为抑制写放大)→ 错值要等下一次心跳(`HEARTBEAT_MS = 1000`,`:1055-1070`)才回落 |
22
+
23
+ ### 1.3 实测暴露度(`/tmp/measure-130.mjs`:真实 `probeHost` 按 `HOST_PROBE_RETRY_MS = 150` 节奏压测,5ms 采样 host.json)
24
+
25
+ 单次 12s / 80 次 probe 跑(宿主与采样器并行争抢 CPU)时:
26
+
27
+ ```
28
+ [owned] attachedClients!=0 in 195/2344 samples (8.32%)
29
+ [owned] longest contiguous non-zero run ~975ms ← ≈ 一个心跳周期
30
+ [owned] 落盘写入:1/12 次心跳记录了 attachedClients!=0
31
+ [legacy] attachedClients!=0 in 0/2340 samples (0.00%)
32
+ ```
33
+
34
+ 随后 3 × 20s(~400 次 probe)单跑均为 0 命中。**结论:**
35
+
36
+ 1. **owned 是真实暴露面,legacy 不是。** legacy 的 close 刷盘把错值寿命压到 probe 生命周期内,实测 0 命中;
37
+ owned 因为 close 抑制,**一次命中就要错满一个心跳周期(~975ms)**。
38
+ 2. 命中频率低但非零,且随宿主 CPU 负载(event loop 延迟)上升——正是 attach 解析进行时的工作状态。
39
+ 3. 消费方周期是 60s(`src/index.ts:67` `AGENT_BOARD_SWEEP_INTERVAL_MS` 默认 60000),
40
+ 所以最坏后果是「空闲宿主多活一个 sweep 周期(~1 分钟)」,方向保守、不误杀、不丢数据——与 issue 的评估一致。
41
+
42
+ ### 1.4 影响链已端到端证实
43
+
44
+ ```
45
+ attachedClients=0 -> {"ttlEvicted":["v1"],"excessEvicted":[]} ← 到点即回收
46
+ attachedClients=1 -> {"ttlEvicted":[],"excessEvicted":[]} ← 永不回收
47
+ ```
48
+
49
+ (`selectIdleHostsToEvict`,`src/core/warm-host-sweeper.mjs:53`;同款复查在 `src/runtime/service.mjs:807`。)
50
+
51
+ ### 1.5 还有一条结构性理由(比 1.3 的暴露度更值得修)
52
+
53
+ **owned 路径今天把「probe 停止计数」这件事挂在「观察到 close」上。** 一旦 close/error 事件永远不来
54
+ (对端半开:进程卡死而非退出,issue 已点名的尾部风险),计数就永久挂住。改成「probe hello 即移出」后,
55
+ 清理点从**生命周期末端**移到**握手早期**,probe 只要成功握手就不会留下计数——这是本修复的主要价值,
56
+ 1.3 的暴露度收窄是副产品。
57
+
58
+ ### 1.6 一句话根因之外
59
+
60
+ - issue 的「注释与实现不符」判断正确:`:1092-1094` 与 `:423-426` 的注释都声称 probe 不得让 `attachedClients` 非 0,
61
+ 实现不符;`src/core/host-protocol.mjs` 模块注释同款。
62
+ - `attachedEver` **无任何生产消费方**(全仓 grep 仅 runner 写入 + `src/core/types.mjs:144` 注释 + 测试断言),
63
+ 本 issue 的危害面完全落在 `attachedClients`。
64
+
65
+ ## 2. 修复设计
66
+
67
+ ### 2.1 决策 D1:probe hello 时把 socket 移出 `clients`,**不写 host.json**
68
+
69
+ 采纳 issue 建议 1 的「移出 `clients`」半条,**明确不采纳**其「并刷新一次计数」半条。理由:
70
+
71
+ | 取向 | 后果 |
72
+ |---|---|
73
+ | 移出 `clients`(做) | 内存计数在 hello 处理后立刻正确;后续心跳不可能再持久化 probe 的 1;清理点从 close 前移到 hello(§1.5) |
74
+ | 移出时写 host.json(不做) | 解析期每 150ms 一轮 probe → 最多 ~6.7 次/秒的 fenced host.json 写入。这正是 `:864-867` 注释要防的写放大,reporter 的 `ownedUpdate` 是常驻连接的一次性写入才可接受 |
75
+ | 不写会不会留下错误持久值 | 不会:`clients.size` 已在内存中正确,下一次心跳(≤1000ms)落盘正确值;错值寿命与 legacy 同量级,不引入新退化 |
76
+
77
+ 残留窗口收窄为 `connect → hello 处理`(一次本地 socket RTT,且不依赖 close 事件)。**接受**该残留:
78
+ 它在允许的 1 个心跳周期内自愈,且消费方是「非 0 就不杀」的保守判据。
79
+
80
+ ### 2.2 决策 D2:把 hello 记账策略抽成纯函数
81
+
82
+ 现状:两条 hello 分支各自内联判断(`:427-434` vs `:1095-1105`),已经漂移——owned 有 `markProbe`、legacy 没有;
83
+ 注释声称的 probe 语义在两边都不成立。新增纯函数收拢决策(与 #103 抽出 `classifyClientHello` 同款做法):
84
+
85
+ ```js
86
+ // src/core/host-protocol.mjs(与 classifyClientHello 同文件)
87
+ /**
88
+ * Socket bookkeeping implied by a hello classification (issue #103 §C / #130).
89
+ *
90
+ * ...probe 必须 persist:false + suppressCloseWrite:true 的理由注释...
91
+ * @param {"probe" | "editor-reporter" | "client"} kind
92
+ * @returns {{ keepInClients: boolean, registerReporter: boolean,
93
+ * flipAttachedEver: boolean, persist: boolean, suppressCloseWrite: boolean }}
94
+ */
95
+ export function helloBookkeeping(kind) { ... }
96
+ ```
97
+
98
+ | kind | keepInClients | registerReporter | flipAttachedEver | persist | suppressCloseWrite |
99
+ |---|---|---|---|---|---|
100
+ | `probe` | false | false | false | **false** | true |
101
+ | `editor-reporter` | false | true | false | true | false |
102
+ | `client` | true | false | true | true | false |
103
+
104
+ 两条分支改为消费该描述符(副作用仍留在 runner,纯函数零 IO):
105
+
106
+ ```js
107
+ const kind = classifyClientHello(msg);
108
+ const book = helloBookkeeping(kind);
109
+ if (!book.keepInClients) { clients.delete(socket); terminalSubscriptions.delete(socket); }
110
+ if (book.registerReporter) editorReporters.add(socket);
111
+ if (book.suppressCloseWrite) socket.markProbe?.(); // legacy 无此抑制点,markProbe 为 undefined → 跳过
112
+ if (book.flipAttachedEver) update({ attachedEver: true }); // owned: ownedUpdate(...)
113
+ else if (book.persist) update(); // owned: ownedUpdate(...)
114
+ ```
115
+
116
+ `terminalSubscriptions.delete` 一并做:probe 从不 `subscribe_terminal`,留着只会让它继续收 `output` 原始行
117
+ (`child.onData` 的 `terminalSubscriptions` 循环,legacy `:341` / owned `:991`)。收窄语义,与 reporter 对称,
118
+ 已核对无副作用(probe 读到第一行数据就 `destroy()`,`src/core/host-probe.mjs:47-130`)。
119
+
120
+ **若用户认为 D2 过度设计**:退化为「两条分支各自内联同样的修复 + 镜像注释」,A2/A3/A4 仍成立,
121
+ 只是 D2 对应的 A1 单测与「两侧不漂移」的结构保证消失。**建议保留 D2。**
122
+
123
+ ### 2.3 改动点清单(`runner/pty-runner.mjs`)
124
+
125
+ | 位置 | 改动 |
126
+ |---|---|
127
+ | `:422-437` legacy hello 分支 | 改为消费 `helloBookkeeping`;probe 时 `clients.delete` + `terminalSubscriptions.delete`,**不** `update()` |
128
+ | `:1091-1108` owned hello 分支 | 同上;probe 时补 `clients.delete` + `terminalSubscriptions.delete` + `markProbe()`,**不** `ownedUpdate()` |
129
+ | `:423-426` legacy 注释 | 与实现对齐:probe「不翻转 `attachedEver`、不长期占计数、不写 host.json」,而非「从不计数」 |
130
+ | `:1092-1094` 注释 | 同款对齐 |
131
+ | `:864-867` owned probe 注释 | 补一句:probe 在 hello 时即移出 `clients`(#130),close 抑制只负责「不写」 |
132
+ | `src/core/host-protocol.mjs` | 新增 `helloBookkeeping` + 模块注释对齐(把「keep bookkeeping-only connections out of attachedClients」的承诺落到显式策略) |
133
+
134
+ ## 3. 验收矩阵
135
+
136
+ | ID | 功能点 | 验收方式 | 具体验证 | 通过标准 |
137
+ |----|--------|----------|----------|----------|
138
+ | A1 | hello 记账策略纯函数 | 自动化(unit) | `node --test test/host-protocol.test.mjs` | 三行决策表逐字段断言(probe: keepInClients/persist/flipAttachedEver 均 false 且 suppressCloseWrite true;reporter: registerReporter true、persist true、keepInClients false;client: keepInClients/persist/flipAttachedEver 均 true)。既有 `classifyClientHello` 用例保持全绿 |
139
+ | A2 | owned:probe 在连时不计入 `attachedClients` | 自动化(integration) | `node --test test/pty-runner.integration.test.mjs`(新用例) | probe 握手完成后(见到第 2 条 `hello`,证明分类路径已跑完)保持 socket 连接,跨过至少一次心跳落盘,读到的 `attachedClients === 0`;`attachedEver !== true` |
140
+ | A3 | legacy:同 A2 | 自动化(integration) | 同上(legacy 分支新用例,配置走 `P.hostConfigPath` 无 `instanceId`) | 同 A2 |
141
+ | A4 | 判别性:probe 与真实 client 并存时计数恰为 1 | 自动化(integration) | 同上两条用例内 | probe 仍在连时,真实 client 的 hello 所触发的落盘写入里 `attachedClients === 1`(**修复前为 2**);client 断开后回落 `0` |
142
+ | A5 | 错值在一个心跳内自愈(D1 不做即时刷盘的安全性) | 自动化(integration) | 同上两条用例内 | probe 断开后再跨一个心跳落盘,`attachedClients === 0` |
143
+ | A6 | probe 应答能力零回归 | 自动化(integration) | 新用例内调用真实 `probeHost(socketPath, {...})` | `classification === "ready"`;且 `probeHost` 之后 `attachedClients === 0` |
144
+ | A7 | 既有 probe / reporter 语义零回归 | 自动化(integration) | `node --test test/pty-runner.integration.test.mjs` | 既有用例 `:1159`、`:1206`、`:1289` 全绿(reporter 计数隔离、probe 不翻 `attachedEver`、legacy reporter/probe 隔离) |
145
+ | A8 | 静态 + 全量 | 自动化(static + 全量 unit) | `npm run typecheck && npm test` | typecheck 0 error;与基线(实现前在 `main@3dad7f9` 记录,见 plan)对比**无新增失败** |
146
+
147
+ **端到端效果的验收由组合证据给出**(不新建慢速 E2E,避免 flake):
148
+
149
+ ```
150
+ A2/A3(probe 在连 → attachedClients 落盘为 0)
151
+ + test/warm-host-sweeper.test.mjs A2(attachedClients>0 → 不淘汰)
152
+ + test/warm-host-sweep.integration.test.mjs A6(attachedClients=0 的 idle host → 收到 terminate)
153
+ ⇒ probe 不再延迟 warm-host 回收
154
+ ```
155
+
156
+ 无需 `用户实测` 项:本改动是进程内记账语义,全部功能点都能在 unit/integration 层稳定判真,
157
+ 不存在需要真机/外部服务才能观察的部分。
158
+
159
+ ## 4. 可测性拆分设计(实现硬约束)
160
+
161
+ | 拆分单元 | 位置 | 形态 | 测试边界 |
162
+ |---|---|---|---|
163
+ | `helloBookkeeping(kind)` | `src/core/host-protocol.mjs`(新增) | **纯函数**:入参三值字符串,出参为每次调用新建的记账描述符对象(不冻结,各次调用互不共享实例,别名安全);零 IO、零 socket | `test/host-protocol.test.mjs` 表驱动直接单测 → A1 |
164
+ | `classifyClientHello(msg)` | `src/core/host-protocol.mjs`(既有,不改) | 纯函数 | 既有单测保持 → A1 |
165
+ | hello 分支副作用 | `runner/pty-runner.mjs` 两处 | 只消费描述符、只做 `clients`/`terminalSubscriptions`/`editorReporters`/`update` 的副作用调用;**判定逻辑一律不得回流进分支** | 真实进程 integration → A2–A7 |
166
+ | 计数落盘点 | 不改(`clients.size`,legacy `:256` / owned `:611`) | 保持单一来源 | 由 A2–A5 观测 |
167
+
168
+ **硬约束**:D2 要求「probe 不写 host.json」这一决策只存在于 `helloBookkeeping` 一处;两条分支不得再出现
169
+ `if (kind === "probe")` 式的判定。这样 A1 的单测就是该不变量的完整证明,integration 只证明接线正确。
170
+
171
+ ## 5. 非目标
172
+
173
+ - **不动 owned close/error 的 probe 写抑制**(`:888`/`:898`)。它负责「不放大写入」,D1 已让「不计入」与它解耦。
174
+ - **不改 legacy close 时的无条件 `update()`**。既有行为,且它正是 legacy 实测 0 命中的原因。
175
+ - **不改 `attachedEver` 语义**(无消费方;probe 本就不该翻,已正确)。
176
+ - **不重构 `clients` 的「广播目标 / 计数来源」双角色**。这是根因层架构气味(§1.1),
177
+ 但拆开需要改广播语义,超出保守修复边界——记为已知债,不在本 issue 处理。
178
+ - **不处理「probe 连上但从不发 hello」**:分类必须依赖 hello(握手前无法区分 probe 与尚未 hello 的真实客户端),
179
+ 该窗口由 `HOST_PROBE_TIMEOUT_MS = 250`(`src/core/host-probe.mjs:16`)与 probe 自身的 `destroy()` 兜底。
180
+ - 不改 warm-host 回收策略本身(TTL / maxWarm / sweep 周期)。
181
+
182
+ ## 6. 风险
183
+
184
+ | 风险 | 对策 |
185
+ |---|---|
186
+ | hello 被同一 socket 重复发送且身份改变(先 `probe` 后 `client`)→ 永久漏计(危险方向:可能误杀活跃客户端) | 既有协议假设「hello 一次且权威」;reporter 的 `clients.delete` 已在 #103 依赖同一假设。在代码注释中显式写明该前提,并由 A4 钉住「probe 仍在连时真实 client 计 1」 |
187
+ | D1 不即时刷盘 → 某次心跳仍可能落盘错值 | 接受:错值寿命 ≤ 1 个心跳周期,消费方是「非 0 就不杀」的保守判据;A5 钉住自愈 |
188
+ | D2 新增描述符与 runner 实际副作用脱节(描述符说 `persist:false` 但分支仍写了) | A2/A3 用真实落盘观测(不是看描述符):probe 在连时跨心跳读到的必须是 0 |
189
+ | `terminalSubscriptions.delete` 影响 probe 回包 | probe 只读第一行数据即 `destroy()`(`src/core/host-probe.mjs`);A6 用真实 `probeHost` 断言 `ready` |
190
+ | Windows 命名管道路径下的时序差异 | 本改动不引入路径/平台分支;A2–A5 走既有跨平台 integration 基建(`P.hostEndpointPathFor`) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhuxixi/pi-agent-board",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Agent-board dashboard for Pi: dispatch, monitor, peek/reply, and attach to background Pi sessions.",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -37,7 +37,7 @@ import {
37
37
  } from "../src/core/control-protocol.mjs";
38
38
  import { readHost, readState, updateOwnedHost, writeHost } from "../src/core/store.mjs";
39
39
  import { sendStateCommand } from "../src/core/coordinator-client.mjs";
40
- import { classifyClientHello } from "../src/core/host-protocol.mjs";
40
+ import { classifyClientHello, helloBookkeeping } from "../src/core/host-protocol.mjs";
41
41
  import { markRowFailedDirect } from "./pty-runner-legacy.mjs";
42
42
  import { ensureNodePtySpawnHelperExecutable } from "../src/core/pty-support.mjs";
43
43
 
@@ -421,17 +421,21 @@ function legacyMain(config) {
421
421
  switch (msg.type) {
422
422
  case "hello": {
423
423
  // Bookkeeping-only clients must never pin the host against warm-host
424
- // reclaim (issue #103 §C): probes are read-only, the editor reporter
425
- // is resident. A reporter socket leaves `clients` (the attachedClients
426
- // source) but stays writable so editor_state keeps flowing.
427
- const kind = classifyClientHello(msg);
428
- if (kind === "client") update({ attachedEver: true });
429
- if (kind === "editor-reporter") {
424
+ // reclaim (issue #103 §C / #130): probes are read-only and transient,
425
+ // the editor reporter is resident. Both leave `clients` — the sole
426
+ // source of `attachedClients` — while their socket stays writable so
427
+ // probe replies and editor_state keep flowing. The policy lives in
428
+ // `helloBookkeeping`, so this path and the owned one cannot drift.
429
+ const book = helloBookkeeping(classifyClientHello(msg));
430
+ if (!book.keepInClients) {
430
431
  clients.delete(socket);
431
432
  terminalSubscriptions.delete(socket);
432
- editorReporters.add(socket);
433
- update();
434
433
  }
434
+ if (book.registerReporter) editorReporters.add(socket);
435
+ // A real client writes via flipAttachedEver alone (hence the `else
436
+ // if`); `persist` is the write flag for rows that write as-is (reporter).
437
+ if (book.flipAttachedEver) update({ attachedEver: true });
438
+ else if (book.persist) update();
435
439
  send(socket, { type: "hello", status: host, editorEmpty, generation: GENERATION });
436
440
  break;
437
441
  }
@@ -863,7 +867,9 @@ async function ownedMain(config) {
863
867
  const onError = (err) => resolveListen({ ok: false, error: err });
864
868
  // Probe connections (clientId:"probe" hello) must not write host.json: the
865
869
  // attach resolver's 150ms probe loop would amplify fenced writes and flip
866
- // attachedEver with no client ever attached (CR round-1 finding 3).
870
+ // attachedEver with no client ever attached (CR round-1 finding 3). The
871
+ // "not counted" half is handled at hello time instead (#130): the socket
872
+ // leaves `clients` there, so this WeakSet only suppresses the write.
867
873
  const probeSockets = new WeakSet();
868
874
  server = createServer((socket) => {
869
875
  clients.add(socket);
@@ -1089,20 +1095,27 @@ async function ownedMain(config) {
1089
1095
  }
1090
1096
  switch (msg.type) {
1091
1097
  case "hello": {
1092
- // Probe and reporter sockets are bookkeeping-only: neither may flip
1093
- // attachedEver nor keep attachedClients non-zero, or warm-host reclaim
1094
- // never fires and hosts leak (issue #103 §C).
1095
- const kind = classifyClientHello(msg);
1096
- if (kind === "probe") {
1097
- socket.markProbe?.();
1098
- } else if (kind === "editor-reporter") {
1098
+ // Same bookkeeping contract as the legacy path (issue #103 §C /
1099
+ // #130): probes and the resident editor reporter are
1100
+ // bookkeeping-only — neither may flip attachedEver nor keep
1101
+ // attachedClients non-zero, or warm-host reclaim never fires and
1102
+ // hosts leak. The policy lives in `helloBookkeeping`.
1103
+ const book = helloBookkeeping(classifyClientHello(msg));
1104
+ if (!book.keepInClients) {
1099
1105
  clients.delete(socket);
1100
1106
  terminalSubscriptions.delete(socket);
1101
- editorReporters.add(socket);
1102
- ownedUpdate((cur) => ({ ...cur }));
1103
- } else {
1104
- ownedUpdate((cur) => ({ ...cur, attachedEver: true }));
1105
1107
  }
1108
+ if (book.registerReporter) editorReporters.add(socket);
1109
+ // The probe's close-time host.json refresh stays suppressed: the
1110
+ // resolver probes every HOST_PROBE_RETRY_MS, so flushing there
1111
+ // would amplify fenced writes (see the probeSockets comment at the
1112
+ // listen block). Being out of `clients` is what keeps the count
1113
+ // right — this only avoids a pointless write.
1114
+ if (book.suppressCloseWrite) socket.markProbe?.();
1115
+ // A real client writes via flipAttachedEver alone (hence the `else
1116
+ // if`); `persist` is the write flag for rows that write as-is (reporter).
1117
+ if (book.flipAttachedEver) ownedUpdate((cur) => ({ ...cur, attachedEver: true }));
1118
+ else if (book.persist) ownedUpdate((cur) => ({ ...cur }));
1106
1119
  send(socket, { type: "hello", status: host, editorEmpty, generation: GENERATION });
1107
1120
  break;
1108
1121
  }