@zhuxixi/pi-agent-board 0.6.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,252 @@
1
+ # Single-Writer Completion (PR #2, issue #91 Phase 2b) Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** 迁移 PR #1 白名单中的全部剩余 writeState/writeStatus 直写点到 View State Coordinator,使架构边界白名单清零到仅剩「设计内豁免」,A7 完全闭合;同时关闭 PR #1 遗留的 pty-runner `markRowFailed` 无 manual-fence 残留风险。
6
+
7
+ **Architecture:** 扩展 `src/core/state-commands.mjs` 的命令种类(lifecycle 命令显式建类 + 元数据/镜像合并为 `patch_fields` 通用命令),coordinator 支持「非 journal 的瞬时命令」(run_progress 热路径),各 runner/service/dashboard 的剩余站点逐个迁移。读侧 revision 一致性 enforcement 明确留给 PR #3(需要所有写者先打戳)。
8
+
9
+ **Tech Stack:** Node.js ESM、node:net JSONL、现有 coordinator/journal/client(PR #1 已建)。
10
+
11
+ **Spec:** `docs/superpowers/specs/2026-09-09-harden-runner-architecture-design.md`(D3 + 验收 A7 + 根治条件 1/2/5 写侧);本 plan 不覆盖读侧 enforcement(PR #3)。
12
+
13
+ ## 设计决策(本 plan 锁定)
14
+
15
+ 1. **新命令种类**(加入 `STATE_COMMAND_KINDS`,决策层每个有显式分支):
16
+ - lifecycle(改 semanticState/processState/currentRunId):`mark_queued`、`run_started`、`run_progress`、`reconcile_finalize`、`host_run_failed`、`archive_view`、`adopt_session`、`sync_foreground`、`plan_ready`、`followup_started`
17
+ - 元数据/镜像合并:`patch_fields`,带 per-source 字段白名单 + 通用守卫
18
+ 2. **`run_progress` 是瞬时命令,不进 journal**:进度写是周期性快照(250ms 节流),被下一拍自愈;journal 化会让 journal 无界膨胀(4 条/秒/run),违背 GC 设计。模糊失败(timeout/reset)→ 跳过 + debug 级 diagnostic,等下一拍。落地仍打 materializedRevision(单调性由 boot counter 的 views 扫描保证)。
19
+ 3. **`host_run_failed` 必须带 manual fence**(通用守卫已覆盖非 user 来源)——这关闭 PR #1 遗留的 pty-runner markRowFailed 残留风险(手动完成后宿主迟到崩溃不再能把行翻成 failed)。
20
+ 4. **永久豁免**(边界测试白名单的终态):(a) 各 runner 的 `coordinator_disabled` 遗留分支(显式 debug 逃生门,文档化);(b) `store.mjs` createView bootstrap(不可能竞争:新 viewId 是随机生成的,不存在其他知情的写者)。豁免理由写进白名单条目;A7 的「清零」含义 = 无未治理写点,不是字面零条目。
21
+ 5. **dashboard.ts 热重载兼容回退直接删除**(旧 service 对象不跨重启存活,窗口是瞬时的)。
22
+ 6. **M2 summary 分歧**:coordinator 的 mark_completed 保持「保留现有 summary」(PR #1 裁决),本 PR 不改;如用户反馈再议。
23
+
24
+ ## Global Constraints
25
+
26
+ - 所有 runner 进程 plain ESM `.mjs`,不依赖 jiti。
27
+ - Commit message 英文 conventional commits;`git add <file>` 显式 stage,禁止 `-A`。
28
+ - 测试 `node --test`;隔离必须同时设 `AGENT_BOARD_ROOT` + `PI_CODING_AGENT_DIR`;coordinator 一律用 tracked fixture(test-support/ensure-coordinator-helper.mjs,带 readiness wait + finally kill)。
29
+ - 可测性硬约束:决策层保持纯函数;新命令的守卫逻辑在 state-commands.mjs 内,不碰 I/O。
30
+ - 模糊结果(timeout/connection_reset)不回退直写;只有 `coordinator_disabled` 走 legacy 分支。
31
+ - 工作区:`$WT = /home/elling/git-repo/github/pi-agent-board/.pi/worktrees/issue-91-single-writer-completion`,全部用绝对路径 + `git -C $WT`。
32
+
33
+ ## 验收映射
34
+
35
+ | Task | 验收 ID | 说明 |
36
+ |---|---|---|
37
+ | Task 1 | A7 基础 | 决策层新命令种类 + 守卫(纯函数) |
38
+ | Task 2 | A7/A7b | coordinator 支持瞬时命令 + patch_fields + host_run_failed |
39
+ | Task 3 | A7 | job-runner 全量迁移(含热路径 run_progress) |
40
+ | Task 4 | A7 | state-runner 镜像迁移 |
41
+ | Task 5 | A7 + PR#1 残留风险 #2 | pty-runner markRowFailed → host_run_failed(带 fence) |
42
+ | Task 6 | A7 | service.mjs 全量迁移 |
43
+ | Task 7 | A7 | dashboard 回退删除 |
44
+ | Task 8 | A7 闭合 | 边界测试白名单 → 设计内豁免;justification 更新 |
45
+ | Task 9 | 对账 | 全量回归 + 验收对账 |
46
+
47
+ A8 已在 PR #1 闭合(本 PR 不破坏其测试);根治条件 5 的读侧在 PR #3。
48
+
49
+ ---
50
+
51
+ ### Task 1: 决策层扩展——新命令种类与守卫(纯函数)
52
+
53
+ **Files:**
54
+ - Modify: `src/core/state-commands.mjs`
55
+ - Test: `test/state-commands.test.mjs`
56
+
57
+ **Interfaces:**
58
+ - Consumes: 现有 `validateCommand`/`decideStateTransition` 结构、守卫顺序(unknown_view → revision_conflict → stale_run → manual_fence → kind 分支)。
59
+ - Produces(后续 task 依赖的精确契约):
60
+ - `STATE_COMMAND_KINDS` 扩展为 13 种(现有 3 种 + 新增 10 种)
61
+ - `TRANSIENT_KINDS` = `["run_progress"]`(coordinator 据此跳过 journal)
62
+ - `PATCHABLE_FIELDS` per source 白名单:
63
+ - `job-runner`/`state-runner`: `["review","evidenceSummary"]`(镜像)+ status 侧 `evidenceSummary`
64
+ - `service`: `["lastVisitedAt"]`(markVisited 经此)
65
+ - 每个新 kind 的守卫(决策层分支,全部纯函数):
66
+
67
+ | kind | 额外守卫(通用守卫之外) | mutate 语义 |
68
+ |---|---|---|
69
+ | `mark_queued` | 无 | state: {currentRunId: payload.runId, semanticState:"queued", processState:"alive", summary:"Queued", needsInput:false, hasError:false, question:null, pendingQuestions:[], error:null, autoState:null} |
70
+ | `run_started` | runId 必须等于 payload.runId | status 新建字段补丁(createRunStatus 形状由调用方算好放在 payload.status);state: {processState:"alive", semanticState:"working", currentRunId} |
71
+ | `run_progress` | `state.currentRunId === command.runId && state.processState === "alive"`,否则 reject "stale_run"(活跃性语义) | payload.statusPatch 稀疏合并 status;state 侧由 coordinator 用 projectViewState(statusClone) 重算(委托,不复制规则) |
72
+ | `reconcile_finalize` | `state.processState === "alive"`(否则 no_change) | payload: {semanticState: "failed"\|"idle", reason};state: {semanticState, processState:"exited", error?};currentRunId 对应 status 存在则同步 finalize 字段 |
73
+ | `host_run_failed` | 无(通用 manual_fence 即 PR#1 残留风险 #2 的关闭点) | state: {semanticState:"failed", processState:"exited", error: payload.error ?? null};status 同理(currentRunId 匹配时) |
74
+ | `archive_view` | busy 时 allow(archive 的 busy 分支原本就写 stopped) | state: {semanticState:"stopped", processState:"exited", needsInput:false, hasError:false, question:null, pendingQuestions:[], error:null, autoState:null, summary:"Stopped"} |
75
+ | `adopt_session` | `processState !== "alive"`(否则 reject "busy") | state: {semanticState:"idle", processState:"exited", summary:"Backgrounded session"} |
76
+ | `sync_foreground` | 无 | payload.projection(service 侧 projectViewState 已算好,currentRunId 强制 null 由本分支保证)稀疏合并 state |
77
+ | `plan_ready` | `processState === "alive"`(否则 no_change) | state: {needsInput:true, question: payload.question ?? null};status 侧 recordPlanReady 对应字段 |
78
+ | `followup_started` | 无 | payload.statusPatch → status;state 侧 projectViewState 重算 |
79
+ | `patch_fields` | 字段白名单(`PATCHABLE_FIELDS[command.source]`,越界字段 reject "field_not_allowed");payload.runId 存在时 stale_run 通用守卫生效 | payload.state/payload.status 稀疏合并(仅限白名单字段) |
80
+
81
+ - [ ] **Step 1: 写失败测试**——每种新 kind 至少 1 个 happy-path + 1 个守卫测试;`patch_fields` 越界字段拒绝测试;`run_progress` 的 stale/alive 守卫测试;`host_run_failed` 的 manual_fence 测试(复用现有 manualCompletedState fixture 模式)。
82
+ - [ ] **Step 2: 运行确认失败** → **Step 3: 实现** → **Step 4: PASS** → **Step 5: Commit**
83
+
84
+ ```bash
85
+ git -C $WT add src/core/state-commands.mjs test/state-commands.test.mjs
86
+ git -C $WT commit -m "feat(core): extend state commands with lifecycle kinds and patch_fields (issue #91)"
87
+ ```
88
+
89
+ ---
90
+
91
+ ### Task 2: Coordinator 支持瞬时命令与新 kinds
92
+
93
+ **Files:**
94
+ - Modify: `runner/state-coordinator.mjs`
95
+ - Test: `test/state-coordinator.integration.test.mjs`
96
+
97
+ **Interfaces:**
98
+ - Consumes: Task 1 的 `TRANSIENT_KINDS` 与新 kinds。
99
+ - Produces:
100
+ - 瞬时命令(`TRANSIENT_KINDS`)路径:validate → dedupe 不需要(无 commandId 幂等语义——run_progress 无需 commandId 去重,client 可省略 commandId)→ decide → 直接物化(仍打 materializedRevision、仍 under view lock)→ 返回结果。**不写 journal、不进 processed 集合**。
101
+ - `run_progress` 的 revision bump 照常(单调性由全局 counter 保证;boot 时 views 扫描已覆盖)。
102
+ - 其余新 kinds 走正常 journaled 路径。
103
+
104
+ - [ ] **Step 1: 失败测试**:run_progress 瞬时命令 applied 且 journal 行数不增长;host_run_failed applied;mark_queued applied;并发 run_progress 不交错(同一 view 两客户端连发)。
105
+ - [ ] **Step 2–5: 失败 → 实现 → PASS → Commit**
106
+
107
+ ```bash
108
+ git -C $WT add runner/state-coordinator.mjs test/state-coordinator.integration.test.mjs
109
+ git -C $WT commit -m "feat(runner): transient run_progress and new lifecycle kinds in coordinator (issue #91)"
110
+ ```
111
+
112
+ ---
113
+
114
+ ### Task 3: job-runner 全量迁移(含热路径)
115
+
116
+ **Files:**
117
+ - Modify: `runner/job-runner.mjs`
118
+ - Test: `test/runner.integration.test.mjs`
119
+
120
+ **Interfaces:**
121
+ - Consumes: Task 1-2 的命令;`sendStateCommand`。
122
+ - Produces:
123
+ - boot bootstrap(L65/69、L95)→ `run_started` 命令
124
+ - `persist()` 热路径(L103-112 及 125/201/217 调用点)→ `run_progress` 瞬时命令(fire-and-forget:不等结果、失败 debug 级忽略——下一拍自愈;**这是本 PR 唯一允许 fire-and-forget 的命令**,理由:周期性快照 + 下一拍覆盖)
125
+ - `refreshEvidenceMirrors`(L152/157)→ `patch_fields`(review/evidenceSummary)
126
+ - plan-ready(L382)→ `plan_ready`;follow-up bootstrap(L408-409)→ `followup_started`
127
+ - post-exit summary persist(maybeModelSummary 尾部的 persistUnlessManual(true))→ `patch_fields`(summary/latestAssistantPreview;manual fence 由通用守卫保证)
128
+ - `coordinator_disabled` 分支保留为设计内豁免(不迁移,但保持现状可用)
129
+ - **迁移完成后 runner/job-runner.mjs 不再 import writeState/writeStatus**(coordinator_disabled 分支改为调 legacy helper——见下)
130
+
131
+ **关键设计点(防回归):** coordinator_disabled 分支需要直写能力。方案:把 PR #1 之前的直写逻辑收进 `runner/job-runner-legacy.mjs`(新文件,只有 disabled 分支 import 它),使 job-runner.mjs 本身不再 import write 函数——边界测试白名单条目从 job-runner.mjs 移到 job-runner-legacy.mjs(justification:disabled 逃生门)。
132
+
133
+ - [ ] **Step 1: 失败测试**:run_progress 经 coordinator 的集成测试(runner 活跃期间 state.json 由 coordinator 物化、journal 不增长);#46 回归与既有 runner.integration 用例保持绿。
134
+ - [ ] **Step 2–5: 失败 → 实现 → PASS → Commit**
135
+
136
+ ```bash
137
+ git -C $WT add runner/job-runner.mjs runner/job-runner-legacy.mjs test/runner.integration.test.mjs
138
+ git -C $WT commit -m "refactor(runner): migrate job-runner writes to coordinator incl. transient run_progress (issue #91)"
139
+ ```
140
+
141
+ ---
142
+
143
+ ### Task 4: state-runner 镜像迁移
144
+
145
+ **Files:**
146
+ - Modify: `runner/state-runner.mjs`(镜像写 → `patch_fields`);disabled 分支同样收进 legacy helper 或直接保留(该文件小,保留 disabled 分支并更新白名单 justification 即可,不必拆文件)
147
+ - Test: `test/state-coordinator.integration.test.mjs`(state-runner 真实进程用例更新断言)
148
+
149
+ - [ ] **Step 1–5: TDD 循环 → Commit**
150
+
151
+ ```bash
152
+ git -C $WT add runner/state-runner.mjs test/state-coordinator.integration.test.mjs
153
+ git -C $WT commit -m "refactor(runner): migrate state-runner evidence mirrors to patch_fields (issue #91)"
154
+ ```
155
+
156
+ ---
157
+
158
+ ### Task 5: pty-runner markRowFailed → host_run_failed(关闭残留风险 #2)
159
+
160
+ **Files:**
161
+ - Modify: `runner/pty-runner.mjs`(markRowFailed,~L1021-1048)
162
+ - Test: `test/pty-runner.integration.test.mjs`
163
+
164
+ **Interfaces:**
165
+ - markRowFailed 改为发送 `host_run_failed` 命令(source "pty-runner"——需加入 COMMAND_SOURCES);payload `{ error, exitCode? }`。
166
+ - 手动完成的行不再能被迟到崩溃翻成 failed(通用 manual_fence 守卫);这是本 task 的核心回归测试。
167
+ - pty-runner 是 detached 进程,coordinator-client 是 plain ESM 可 import。
168
+ - 模糊结果处理:warn diagnostic + 继续(崩溃路径不能因此卡住退出);coordinator_disabled → 保留 legacy 直写(收进 legacy helper 或保留原位并更新白名单 justification)。
169
+
170
+ - [ ] **Step 1: 失败测试**(核心回归):手动 completed 的行 + 宿主迟到崩溃 → state.json 保持 completed(fence 拒绝 host_run_failed);无 fence 时正常 failed。
171
+ - [ ] **Step 2–5: TDD → Commit**
172
+
173
+ ```bash
174
+ git -C $WT add runner/pty-runner.mjs test/pty-runner.integration.test.mjs
175
+ git -C $WT commit -m "fix(runner): route host crash finalization through fenced host_run_failed command (issue #91)"
176
+ ```
177
+
178
+ ---
179
+
180
+ ### Task 6: service.mjs 全量迁移
181
+
182
+ **Files:**
183
+ - Modify: `src/runtime/service.mjs`
184
+ - Test: `test/service.test.mjs`
185
+
186
+ **迁移映射(站点 → 命令):**
187
+ - L423 markQueued → `mark_queued`
188
+ - L433 markVisited → `patch_fields`(lastVisitedAt;metadata,但统一走 coordinator 保持边界干净)
189
+ - L524 archiveView 的 state 部分 → `archive_view`(meta.archived 直写保留——meta.json 是文档化例外)
190
+ - L606 writeForegroundState → `sync_foreground`
191
+ - L1602/1629 adoptSession → `adopt_session`
192
+ - L1855/1864/1873 reconcile 终态 → `reconcile_finalize`
193
+ - `completeViewDirect`(coordinator_disabled 分支)保留为设计内豁免——收进 `src/runtime/service-legacy.mjs` 或原位保留并更新白名单 justification(择实现复杂度低者;service.mjs 很大,原位保留 + justification 更新更简单)
194
+ - 全部迁移后 service.mjs 的直接 writeState/writeStatus 调用点只剩 disabled 分支
195
+
196
+ - [ ] **Step 1–5: TDD → Commit**(现有 service 测试全部保持绿;reconcile 相关用例更新为 coordinator 断言)
197
+
198
+ ```bash
199
+ git -C $WT add src/runtime/service.mjs test/service.test.mjs
200
+ git -C $WT commit -m "refactor(service): migrate remaining state writes to coordinator commands (issue #91)"
201
+ ```
202
+
203
+ ---
204
+
205
+ ### Task 7: dashboard.ts 兼容回退删除
206
+
207
+ **Files:**
208
+ - Modify: `src/ui/dashboard.ts`(删除 ~L981-1020 的 stale-service-object 回退;markCompleted 调用统一走新 async 路径)
209
+ - Test: `test/service.test.mjs` / dashboard 相关测试保持绿
210
+
211
+ - [ ] **Step 1–5: TDD → Commit**
212
+
213
+ ```bash
214
+ git -C $WT add src/ui/dashboard.ts
215
+ git -C $WT commit -m "refactor(dashboard): drop pre-coordinator markCompleted compat fallback (issue #91)"
216
+ ```
217
+
218
+ ---
219
+
220
+ ### Task 8: 边界测试白名单 → 设计内豁免(A7 闭合)
221
+
222
+ **Files:**
223
+ - Modify: `test/architecture-writer-boundary.test.mjs`
224
+
225
+ **终态白名单(每项带永久 justification):**
226
+ - `runner/job-runner-legacy.mjs`(或 state-runner/pty-runner 的 disabled 原位分支)——「coordinator_disabled 逃生门,设计内豁免」
227
+ - `src/runtime/service.mjs`——仅当 disabled 分支原位保留时;若收进 service-legacy.mjs 则换成该文件
228
+ - `src/core/store.mjs`——「createView bootstrap 不可能竞争(新 viewId 随机生成,无其他写者知情),永久豁免」
229
+ - 其余条目全部删除;测试断言白名单外零 importer。
230
+ - 更新文件头注释:从「PR #2 迁移并删除条目」改为「设计内豁免清单」。
231
+ - 顺手修 PR #1 的 deferred minor:non-rotting 检查对 importer 条目改用 import-level 正则(mention-level 只留给 store.mjs)。
232
+
233
+ - [ ] **Step 1–5: TDD → Commit**
234
+
235
+ ```bash
236
+ git -C $WT add test/architecture-writer-boundary.test.mjs
237
+ git -C $WT commit -m "test(arch): shrink writer allowlist to designed exceptions only (issue #91)"
238
+ ```
239
+
240
+ ---
241
+
242
+ ### Task 9: 全量回归 + 验收对账
243
+
244
+ - [ ] `node --test test/*.test.mjs` 全绿 + `npm run typecheck` + 零泄漏进程检查
245
+ - [ ] 验收对账写入 PR 描述:A7 ✅(豁免清单版);A8 不回归;根治条件 1/2 写侧闭合;根治条件 5 读侧 → PR #3
246
+ - [ ] PR 描述诚实声明:读侧 revision 一致性未启用;coordinator 不可用时 mutation fail-closed(mark-done 显示原始 reason)
247
+
248
+ ## Self-Review 记录
249
+
250
+ - Spec 覆盖:D3 写侧全覆盖;读侧 enforcement 明确 PR #3(spec §8 分阶段允许)。
251
+ - 与 PR #1 的接口一致性:`TRANSIENT_KINDS`/`PATCHABLE_FIELDS`/新 kind 名在 Task 1 定义、Task 2-6 消费,名称在 Global Constraints 与各 task Interface 块一致。
252
+ - 占位符:无 TBD;Task 6 的「原位保留 vs 收进 legacy 文件」给了明确的取舍指引(实现复杂度低者)。
@@ -0,0 +1,130 @@
1
+ # Spec: attach 运行期失同步检测 + 限速 heal 兜底(issue #11)
2
+
3
+ - 日期:2026-09-07
4
+ - 状态:approved(2026-09-08 用户批准;review v2 修订:检测改定时器入口、CJK 验证点、A4 可行性标注)
5
+ - 范围校准:attach 期失同步已由 shrink-and-hold 协议(#25 + #42 G6)覆盖;本 spec 只做**运行期**(attach settle 后、会话进行中)的失同步检测与限速补救。
6
+
7
+ ## 1. 背景
8
+
9
+ issue #2 的修复建议 #2(失同步检测)在 #10 复盘中被证明必要:jiggle 链耗尽后系统再无自愈手段,脏画面(双光标+残留帧)一直挂着直到用户 detach/reattach。#25/#42 之后 attach 期已收敛,但运行期失同步(重放垃圾残留、子端渲染异常、未知失败模式)仍无兜底——本 spec 补上这道最后防线。
10
+
11
+ ## 2. 失同步信号原理
12
+
13
+ **正常态**:子端 pi-tui 每帧把 PTY 硬件光标定位到编辑器 marker 处,而 marker 处的编辑器假光标 cell 是反色的(`ESC[7m`)。因此**光标 cell 本身就是反色 cell**。
14
+
15
+ **失同步态**:本地 headless xterm 的光标记账与 buffer 内容不一致(典型:光标停在差分写结束处、footer 段末尾),光标 cell 非反色,假光标留在编辑器行。
16
+
17
+ **streaming 例外**:输出进行中光标落在输出行(非反色)是正常的。用「距上次 socket 输出 > DESYNC_QUIET_MS」区分——输出停止后子端最后一帧会把光标定位回编辑器。
18
+
19
+ ## 3. 设计
20
+
21
+ ### 3.1 失同步判定纯函数 `detectCursorDesync(buf, cursor)`
22
+
23
+ 位置:`src/core/pty-attach-render.mjs`(纯函数、零依赖,随既有模块)。输入:xterm buffer(`getLine`/`getCell`/`isInverse` 最小接口)+ `projectPtyCursor()` 的返回值。返回三态:
24
+
25
+ - `"aligned"`:cursor 非空且光标 cell 反色(含光标列越界但行末 cell 反色的等价形态)——正常;
26
+ - `"misaligned"`:cursor 非空(光标在投影视口内)但光标 cell 非反色(cell 不存在、宽度 0、非反色均算)——候选失同步,等待时间门确认;
27
+ - `"unknown"`:cursor 为 null(光标在视口外,如用户滚动历史)——不判定。
28
+
29
+ 主判定 O(1)(只查光标 cell);仅在光标列越界需回看行末 cell 时退化 O(cols)。
30
+
31
+ ### 3.2 controller 新增 `heal(cols, rows)`
32
+
33
+ 位置:`src/core/pty-attach-jiggle-controller.mjs`。运行期补救入口,复用 shrink-and-hold 协议:
34
+
35
+ 1. healBudget 检查:已达上限(`HEAL_MAX_PER_LIFETIME = 5`)→ return,不再补救;通过检查即消耗一次额度(含后续因极小终端放弃的情况——防反复尝试);
36
+ 2. 清 timer;若有旧 hold(held)先 restore(幂等);
37
+ 3. 重置检测状态(`state = createJiggleRetryState()`、carry 清空);
38
+ 4. **保留 `tuiFrameSeen = true`**(运行期子端必然渲染过——这是与 `start()` 的关键区别,跳过 G1 无帧守卫与首帧 re-arm 分支);
39
+ 5. 重新 shrink 并 hold(`holdSize = resizeJiggleSize(cols, rows)`;极小终端无 holdSize → 直接放弃本次 heal);
40
+ 6. `scheduleNextRetry()` 复用现有退避表(G2 预算耗尽自动 restore)。
41
+
42
+ 清屏检测复用 `feed()`:见 `\x1b[2J` → restore + 停链(现有逻辑)。守卫交互:
43
+
44
+ - **G3**:`restoreAndStop()` 照常清 heal 的 hold;
45
+ - **G4**:`notifyExternalResize()` 清 hold 与 timer,heal 自动取消(现有逻辑不动);
46
+ - **G5**:`start()`(重连)先恢复旧 hold——heal 的 hold 同样被恢复,重连后协议重新走 attach 期自愈;
47
+ - **预算**:healBudget 在 controller 实例生命周期内累计(`start()`/`restoreAndStop()` 均不重置),防断连循环骚扰。
48
+
49
+ ### 3.3 组件接线(`src/ui/pty-attach.ts`)
50
+
51
+ - `pushOutput()` **同步**收到数据时记录 `lastOutputAt`(不在 `term.write` 异步回调里记);
52
+ - `attachSettled` 即现有 `!this.attaching` 字段(不新造计时器),settle 后才开始检测;
53
+ - **检测入口是独立 `checkDesync()` 组件方法 + 低频定时器,不挂 render 路径**——render 是事件驱动的(socket 输出/keypress/resize),而失同步恰恰发生在输出停止后,挂在 render 路径上会让空闲态失同步永远没有检测机会。定时器在 attach settle 后启动(周期 `DESYNC_PROBE_INTERVAL_MS = 2000`),unref 不阻止进程退出,`close()` 时清理;
54
+ - `checkDesync()` 内部用与 `project()` 相同的视口计算(start/height)调 `projectPtyCursor` + `detectCursorDesync`,7 个门全部通过才 heal:
55
+ 1. `attachSettled === true`;
56
+ 2. controller `tuiFrameSeen === true`(子端是 pi,shell/vim 不检测);
57
+ 3. `detectCursorDesync(...) === "misaligned"`;
58
+ 4. `Date.now() - lastOutputAt > DESYNC_QUIET_MS`(1500ms);
59
+ 5. 链不活跃:`getState()` 满足 `state.stopped && !held`(heal/attach 链进行中不重复触发);
60
+ 6. 距上次 heal > `HEAL_RATELIMIT_MS`(10000ms);
61
+ 7. socket 连接存活(`this.connected`)。
62
+ - 门 1-7 通过 → `jiggleRetry.heal(this.cols, this.rows)`,记录 `lastHealAt`。healBudget 上限不是组件层门:由 `heal()` 内部强制(预算耗尽直接拒绝),组件层无需重复检查。
63
+
64
+ ### 3.4 数据流
65
+
66
+ ```
67
+ socket 输出 → pushOutput(记 lastOutputAt)→ term.write 异步解析
68
+ ↘(输出停止后 render 不再触发)
69
+ settle 后定时器(2s 周期)→ checkDesync()(自算视口 + projectPtyCursor)
70
+ → detectCursorDesync → 7 门判定 → heal()
71
+ → sendResize(shrink) → 子端 fullRender(true) 全清
72
+ → feed() 检测 \x1b[2J → restore 原尺寸 → 自愈完成
73
+ ```
74
+
75
+ ## 4. 误报场景与防护
76
+
77
+ | 场景 | 防护 |
78
+ |------|------|
79
+ | streaming 中光标在输出行 | 门 4 时间条件(1.5s 无输出才判) |
80
+ | 全清重绘中间态 | 重绘=大量输出 → lastOutputAt 持续刷新 → 门 4 挡住 |
81
+ | 非 pi 子进程(shell/vim,光标行永无反色) | 门 2 tuiFrameSeen |
82
+ | attach settle 前(重放中间态/banner 遮挡) | 门 1 |
83
+ | 用户滚动历史(光标在视口外) | detectCursorDesync 返回 unknown |
84
+ | pi 静默等待外部输入(密码 prompt 等) | 无法完全防(真误报)→ healBudget=5 上限封顶,闪烁有限次后停止 |
85
+ | heal 进行中重复检测 | 门 5 链活跃检查 |
86
+
87
+ **已知漏报(接受)**:光标 cell 恰好反色但 buffer 其他处有残影——检测不到。兜底不追求完美。
88
+
89
+ **假设**:子端 buffer 的反色 cell 主要来源是编辑器假光标(detach gate `findLastInverseCellLine` 已依赖同一假设)。若存在其他反色源,只影响漏报(把失同步误判为 aligned),不产生误报。
90
+
91
+ **实现时验证点**:CJK 宽字符的 continuation cell(width 0)在 xterm 中是否继承首格的反色属性——若光标停在中文 continuation cell 上被判为非反色,会造成中文输入场景误报。实现时用真实 @xterm/headless 验证,并在 A1 补对应用例(若确实误判,检测逻辑需回看首格)。
92
+
93
+ ## 5. 非目标
94
+
95
+ - attach 期失同步检测(已由 shrink-and-hold 覆盖);
96
+ - 连续补救失败后隐藏光标块 + 状态行提示(原 issue 可选兜底 3,以 healBudget 上限替代);
97
+ - 残留帧特征检测(内容重复行等,成本高收益低);
98
+ - Windows 实机行为(无实机,标 pending)。
99
+
100
+ ## 6. 参数表
101
+
102
+ | 参数 | 默认值 | 说明 |
103
+ |------|--------|------|
104
+ | DESYNC_QUIET_MS | 1500 | 判定失同步所需的输出静默窗口 |
105
+ | DESYNC_PROBE_INTERVAL_MS | 2000 | settle 后定时检测周期 |
106
+ | HEAL_RATELIMIT_MS | 10000 | 两次 heal 最小间隔 |
107
+ | HEAL_MAX_PER_LIFETIME | 5 | controller 实例生命周期内 heal 总上限(进入 heal() 即消耗一次,含极小终端放弃的情况) |
108
+
109
+ ## 7. 验收矩阵
110
+
111
+ | ID | 功能点 | 验收方式 | 具体验证 | 通过标准 |
112
+ |----|--------|----------|----------|----------|
113
+ | A1 | detectCursorDesync 三态判定 | 自动化(unit) | `node --test test/pty-attach-render.test.mjs` | aligned/misaligned/unknown 各场景断言正确(反色 cell、非反色、cell 越界、宽度 0、cursor null) |
114
+ | A2 | heal() 协议行为 | 自动化(unit) | `node --test test/pty-attach-jiggle-controller.test.mjs` | shrink→feed clear→restore;budget 耗尽拒绝;G4 取消 heal;G5 恢复 heal hold;tuiFrameSeen 保留;极小终端放弃 |
115
+ | A3 | 组件接线 + 7 门 + 自愈闭环 | 自动化(integration) | 新增 test-support TS 脚本(`--experimental-transform-types`,仿 detach-gate-smoke 模式)+ `node --test` 包装 | 伪造 buffer/输出流驱动真实组件:misaligned+静默→触发一次 heal resize;10s 内不重复;链活跃/settle 前/视口外/无帧均不触发;模拟子端全清输出后自愈停止;时间相关断言优先时间注入,其次真实等待 |
116
+ | A4 | 健康 session 无误触发 | 自动化(E2E) | 复用 cold-start E2E harness 模式,真实 runner+pi 冷启动 attach 后稳定运行窗口内监控 heal 计数/resize 序列 | 正常会话全流程 heal 触发次数为 0。**可行性依赖**:现有 E2E harness 绕过组件胶水层(advisory 30-3),plan 阶段先核实组件级 harness 能力;不可行则降级为「A3 强化门控回归 + U1 观察」 |
117
+ | U1 | 日常使用观察 | 用户实测 | 日常使用若干天:观察无误触发闪烁;(若偶遇真失同步)观察限速周期内自愈 | 无每 10s 频闪类骚扰性重绘;U1 为观察性验收,允许 pending,不阻塞合并 |
118
+
119
+ ## 8. 可测性拆分设计
120
+
121
+ - **detectCursorDesync(buf, cursor)**:纯函数,输入最小 buffer 接口(伪造 `getLine→getCell→isInverse` 对象,test/pty-attach-render.test.mjs 已有 BufferLineLike 伪造模式)。测试边界:只测函数本身的三态映射,不涉及时间/限速(属组件层)。
122
+ - **heal()**:注入式 controller(fake `sendResize`/`setTimeoutFn`/`clearTimeoutFn`,既有模式)。测试边界:协议时序与守卫交互,不涉及检测信号。
123
+ - **接线门控**:真实组件(TS)+ 伪造 tui/socket 数据流,test-support 脚本模式。测试边界:门的组合逻辑与端到端自愈闭环,不依赖真实子进程。
124
+ - **A4 E2E**:真实 runner+pi,只断言「健康流无误触发」这一回归性质,不构造失同步(构造失同步属于 A3 的伪造层职责)。
125
+
126
+ ## 9. 风险
127
+
128
+ - pi 静默等待场景(密码 prompt/长工具无 spinner 帧)真误报 → 一次闪烁 + budget 封顶,无无限循环;
129
+ - 子端其他反色源(若存在)→ 只漏报不误报;
130
+ - 参数(1.5s/10s/5 次)为工程估值,A3/A4 验证后可调。