@zhuxixi/pi-agent-board 0.6.2 → 0.8.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 +43 -0
- package/README.md +6 -3
- package/VERIFY.md +2 -1
- package/docs/PTY_ATTACH_IMPLEMENTATION_PLAN.md +3 -1
- package/docs/superpowers/plans/2026-09-08-issue-11-attach-runtime-desync-heal.md +917 -0
- package/docs/superpowers/plans/2026-09-09-harden-runner-architecture.md +603 -0
- package/docs/superpowers/plans/2026-09-09-single-writer-completion.md +252 -0
- package/docs/superpowers/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/specs/2026-09-07-issue-11-attach-runtime-desync-heal-design.md +130 -0
- package/docs/superpowers/specs/2026-09-09-harden-runner-architecture-design.md +298 -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/package.json +3 -2
- package/runner/job-runner-legacy.mjs +68 -0
- package/runner/job-runner.mjs +371 -67
- package/runner/pty-runner-legacy.mjs +50 -0
- package/runner/pty-runner.mjs +685 -58
- package/runner/state-coordinator.mjs +429 -0
- package/runner/state-runner.mjs +90 -15
- 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/commands/bg.ts +2 -1
- package/src/core/control-protocol.mjs +482 -0
- package/src/core/coordinator-client.mjs +313 -0
- package/src/core/coordinator-journal.mjs +282 -0
- package/src/core/coordinator-protocol.mjs +12 -0
- package/src/core/editor-state-reporter.mjs +11 -1
- package/src/core/foreground-preview-cache.mjs +117 -0
- package/src/core/host-protocol.mjs +24 -0
- package/src/core/launch.mjs +15 -0
- package/src/core/locks.mjs +68 -14
- package/src/core/paths.mjs +48 -0
- package/src/core/pid.mjs +32 -1
- package/src/core/pty-attach-jiggle-controller.mjs +83 -6
- package/src/core/pty-attach-reconnect.mjs +13 -6
- package/src/core/pty-attach-render.mjs +50 -0
- package/src/core/state-commands.mjs +699 -0
- package/src/core/status-consistency.mjs +98 -0
- package/src/core/store.mjs +59 -13
- package/src/core/terminal-attach-client.mjs +803 -0
- package/src/core/terminal-attach-protocol.mjs +252 -0
- package/src/core/terminal-model.mjs +222 -0
- package/src/core/terminal-snapshot.mjs +440 -0
- package/src/core/types.mjs +2 -0
- package/src/index.ts +12 -4
- package/src/runtime/service.mjs +694 -121
- package/src/ui/dashboard.ts +67 -92
- package/src/ui/pty-attach.ts +298 -72
- package/src/core/pty-input.mjs +0 -47
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# Spec:host-meta 租约孤锁(identity:null)永不回收(issue #112)
|
|
2
|
+
|
|
3
|
+
日期:2026-09-14 · 状态:approved(用户确认 v2)
|
|
4
|
+
调研:issue 评论 R1(现状核实)/ R2(修向评估)· v1 自查 review 记录见 §6
|
|
5
|
+
|
|
6
|
+
## 1. 根因(systematic-debugging Phase 1-3 结论)
|
|
7
|
+
|
|
8
|
+
**直接根因**:host-meta 租约的两个获取点——`claimHost`(store.mjs:157)与 `updateOwnedHost`(store.mjs:246)——都不传 `identity`,owner.json 的 `identity` 恒为 null;`reclaimOrBlock`(locks.mjs)对 identity-less 锁无条件返回 `blocked`(判死信息缺失),持锁进程暴毙后**没有任何代码路径能回收这把锁**。
|
|
9
|
+
|
|
10
|
+
**证据链**(issue 探针 5 门闸门 + 代码静态核对):
|
|
11
|
+
1. 探针:host-start 锁 acquired ✓,host-meta 锁 blocked ✗ → 唯一 blocker;
|
|
12
|
+
2. 删锁后复跑全绿 → 锁残留是唯一阻塞因素;
|
|
13
|
+
3. 代码:reclaimOrBlock 判定 `owner?.identity?.pid`,缺失即 `blocked`(locks.test.mjs:258 锚定该行为);
|
|
14
|
+
4. 设计盲点已留痕:store.mjs:219 注释明知孤锁场景,设计结论是 "surface as retryable" 但无任何清扫者;updateOwnedHost 3×20ms 有界重试后静默 `{updated:false}`,零诊断。
|
|
15
|
+
|
|
16
|
+
**范围核实**:host-meta 获取点全仓库仅上述 2 处(host-crash.mjs / pty-runner 均经 updateOwnedHost 间接进入)。
|
|
17
|
+
|
|
18
|
+
**间接问题**:失败路径全程静默 → 现场零 diagnostics 记录,排障靠猜。
|
|
19
|
+
|
|
20
|
+
## 2. 修复设计(方向 3 + 1 + 4 组合,R2 已评估)
|
|
21
|
+
|
|
22
|
+
### 2.1 locks.mjs:identity-less 超龄兜底(方向 1,修存量孤锁)
|
|
23
|
+
|
|
24
|
+
新增**纯函数** `classifyLeaseOwner(owner, now, isProcessDead, opts)`(导出,供单测),返回 `"reclaim" | "busy" | "blocked"`。判定契约:
|
|
25
|
+
|
|
26
|
+
| 输入状态 | 输出 | 说明 |
|
|
27
|
+
|---|---|---|
|
|
28
|
+
| owner 不可解析 / 非对象 | blocked | 无判死信息 |
|
|
29
|
+
| `owner.token` 非 string | blocked | quarantine 核对依赖它,缺失不可回收(**v1 遗漏,review 补**) |
|
|
30
|
+
| identity 完整(`pid>0` + `startToken:string`)且 pid 活 | busy | 现有语义不变 |
|
|
31
|
+
| identity 完整且 pid 死 | reclaim | 现有语义不变(方向 3 的未来锁走此路) |
|
|
32
|
+
| identity 不完整(含 `startToken:null`)且顶层 pid 无效 | blocked | 无判死信息 |
|
|
33
|
+
| identity 不完整、顶层 pid 有效、`age < orphanAgeMs` | blocked | 新鲜锁:可能仍在合法短临界区内 |
|
|
34
|
+
| identity 不完整、顶层 pid 有效、`age >= orphanAgeMs`、pid 活 | busy | 活持有者 |
|
|
35
|
+
| identity 不完整、顶层 pid 有效、`age >= orphanAgeMs`、pid 死 | reclaim | **存量孤锁兜底回收** |
|
|
36
|
+
|
|
37
|
+
- `age = now - owner.startedAt`;`orphanAgeMs` 默认 `ORPHAN_LEASE_AGE_MS = 5min`,经 `opts` 可注入(测试用)。
|
|
38
|
+
- **平台差异(v1 遗漏,review 补)**:非 Linux 平台 `startToken` 恒为 null → 新锁也落入「identity 不完整」行,暴毙锁需等 5min 才可回收。这是无 startToken 时无法区分 pid 复用的必然保守降级,可接受。
|
|
39
|
+
- `reclaimOrBlock` 改为调用该函数(传入 clock),quarantine 机制(调用者 token 命名、inspectedToken 核对、concurrent-winner 恢复)**原封不动**。
|
|
40
|
+
- 数据零新增字段:顶层 `pid`/`startedAt` 为 locks.mjs 候选写入时既有。
|
|
41
|
+
|
|
42
|
+
安全边界:超龄阈值(5min,远大于毫秒级临界区)+ pid 确死双条件;host-meta 持锁毫秒级,5min 不误伤合法持锁。
|
|
43
|
+
|
|
44
|
+
### 2.2 store.mjs / pid.mjs:获取点传 identity(方向 3,修增量孤锁)
|
|
45
|
+
|
|
46
|
+
- `src/core/pid.mjs` 新增两个导出(实现取自 service.mjs / pty-runner.mjs 的既有复制体,Linux /proc/<pid>/stat field 22,失败或非 Linux 返回 null):
|
|
47
|
+
- `captureStartToken(pid): string|null`
|
|
48
|
+
- `currentProcessIdentity(): {pid: number, startToken: string|null}`
|
|
49
|
+
- `store.mjs` 内部 `hostMetaIdentity()` = `currentProcessIdentity()`;
|
|
50
|
+
- `claimHost` / `updateOwnedHost` 的 host-meta 获取传入该 identity;
|
|
51
|
+
- **`claimHost` 新增 `opts.lockImpl` 注入点**(与 updateOwnedHost 对齐;v1 遗漏,review 补)——用于测试断言 identity 传递;
|
|
52
|
+
- **文档更新(v1 遗漏,review 补)**:store.mjs:212-219 注释块(现描述 "identity-less short hold ... surfaces as retryable")与 host-owner-store.test.mjs:313 测试注释随行为更新。
|
|
53
|
+
|
|
54
|
+
效果:Linux 上此后任何持锁进程暴毙,contender 看到完整 identity + pid 确死 → 走现有 reclaimOrBlock 立即回收。**非目标**:service.mjs / pty-runner.mjs 本地复制体不迁移(缩小 diff,共享函数已就位可作后续小 PR)。
|
|
55
|
+
|
|
56
|
+
### 2.3 store.mjs:失败路径 diagnostics(方向 4,review 修正版)
|
|
57
|
+
|
|
58
|
+
- `updateOwnedHost` 重试耗尽(`{updated:false, ownerChanged:false}`)→ `appendDiagnostic` 一条 `level: warn, code: "host_meta_lease_contended"`,`details: { attempts, lastReason }`(循环中保存最后一次 acquire 失败 reason)。
|
|
59
|
+
- `claimHost` 锁未取得**且 reason === "blocked"** → `appendDiagnostic` 一条 `level: warn, code: "host_meta_claim_contended"`,`details: { reason }`。busy(正常活锁竞争)不写——**避免误报污染 warningCount(review 修正)**。
|
|
60
|
+
- **进程内节流(review 补)**:模块级「未恢复标记」集合;同一 view 的 contended 事件只写一条,成功写入(updated:true / claim 成功)后清除标记,恢复后再次失败才再写。导出 `clearHostMetaThrottleForTests()`(项目有 `clearXxxCacheForTests` 先例)。
|
|
61
|
+
- 诊断写入 try/catch 包裹(`appendDiagnostic` 内部 `appendFileSync` 无兜底,**会抛**——已核实),best effort,不破坏主流程。
|
|
62
|
+
- store.mjs 新增 import `appendDiagnostic`(与现有 `readDiagnosticSummary` 同模块,无循环依赖,**已核实**)。
|
|
63
|
+
|
|
64
|
+
### 2.4 非目标
|
|
65
|
+
|
|
66
|
+
- sweeper(方向 2):覆盖弱于真实回收路径,另立后续 issue;
|
|
67
|
+
- service.mjs / pty-runner.mjs 的 startToken 复制体迁移到共享模块(本 issue 只新增共享版供 store.mjs 用);
|
|
68
|
+
- host.json state 字段与现实脱节:#70/#87 已覆盖。
|
|
69
|
+
|
|
70
|
+
## 3. 可测性拆分设计(自动化功能点必答)
|
|
71
|
+
|
|
72
|
+
| 拆分 | 位置 | 形态 | 测试边界 |
|
|
73
|
+
|------|------|------|----------|
|
|
74
|
+
| F1 | locks.mjs `classifyLeaseOwner` | 纯函数(无 fs,输入全参数化,orphanAgeMs 可注入) | locks.test.mjs 直接单测:契约表 8 行全覆盖 + 阈值边界 |
|
|
75
|
+
| F2 | locks.mjs `reclaimOrBlock` | fs + quarantine,判定委托 F1 | locks.test.mjs 真实锁目录:超龄死锁回收、新鲜锁 blocked |
|
|
76
|
+
| F3 | pid.mjs `captureStartToken` / `currentProcessIdentity` | 纯函数(读 /proc;非 Linux null) | locks/pid 单测:Linux 下当前 pid 返回非空 string |
|
|
77
|
+
| F4 | store.mjs `hostMetaIdentity` | store 内部 helper | 经 F5 观测点间接验证 |
|
|
78
|
+
| F5a | `updateOwnedHost` identity 传递 | mutate 回调在持锁窗口内执行 → 回调内读锁 owner.json | host-owner-store.test.mjs:真实路径断言 identity={pid, startToken:string} |
|
|
79
|
+
| F5b | `claimHost` identity 传递 | 新增 `opts.lockImpl` 记录+透传 opts | 同上,scriptLock 增强记录 opts |
|
|
80
|
+
| F6 | store.mjs 诊断写入 + 节流 | 侧效,try/catch;未恢复标记集合 | host-owner-store.test.mjs:真 root 断言 diagnostics.jsonl 与节流 |
|
|
81
|
+
|
|
82
|
+
测试边界约定:F1 纯函数零 fs(快、全分支);F2/F5 走真实锁路径复现 issue 现场(关键);F6 真实临时 root + 重置钩子。
|
|
83
|
+
|
|
84
|
+
## 4. 验收矩阵
|
|
85
|
+
|
|
86
|
+
| ID | 功能点 | 验收方式 | 具体验证 | 通过标准 |
|
|
87
|
+
|----|--------|----------|----------|----------|
|
|
88
|
+
| A1 | F1 classifyLeaseOwner 契约表全分支 | 自动化(unit) | `node --test test/locks.test.mjs` 新增用例 | 8 行契约全覆盖(含 token 缺失、startToken:null、age==阈值、corrupt、owner 非对象) |
|
|
89
|
+
| A2a | 存量 identity-less 孤锁真实回收 | 自动化(unit) | host-owner-store.test.mjs:手写 `{token, pid:99999999, identity:null, startedAt:10min前}` 锁 → **真实** `updateOwnedHost`(不注入 lockImpl) | updated:true、写落地、原孤锁 token 不可再观测(reclaim/quarantine 已清) |
|
|
90
|
+
| A2b | identity 完整死锁(方向 3 未来锁)回收 | 自动化(unit) | 同上,锁 `{identity:{pid:99999999, startToken:"x"}}` | 同上(`claimHost` 路径同法验证一次) |
|
|
91
|
+
| A3 | F5a/F5b 获取点带 identity | 自动化(unit) | updateOwnedHost:mutate 回调内读锁 owner.json;claimHost:lockImpl 记录 opts | 两者 owner.json identity = {pid: process.pid, startToken: string} |
|
|
92
|
+
| A4 | 失败写 diagnostics + 节流 | 自动化(unit) | 注入持续 busy(update)/ blocked(claim) | jsonl 含 `host_meta_lease_contended`(details.lastReason)与 `host_meta_claim_contended`;同 view 连续两次耗尽只写一条(节流);busy claim 不写 |
|
|
93
|
+
| A5 | 全量回归 | 自动化(build) | `npm run verify`(typecheck + 全测试 + coverage + pack dry;可退化为 `npm test`) | 0 失败;锚点测试(locks.test.mjs:258、host-owner-store.test.mjs:122/313)按新契约更新后全绿 |
|
|
94
|
+
| A6 | 文档/注释更新 | 自动化(static) | `rg "identity-less short hold" src/ test/` 复核 | store.mjs:212-219 与 host-owner-store.test.mjs:313 注释反映新语义 |
|
|
95
|
+
| U1 | 实机 attach 自愈 | 用户实测 | 真实 view 手动造 identity-null 超龄孤锁:`~/.pi/agent/agent-board/views/<viewId>/host-meta.lock/owner.json` 写 `{token:"manual", pid:99999999, identity:null, startedAt:<10min前>}`(先 `ps -p 99999999` 确认死)→ attach 该 view | attach 自动 reclaim 并拉起宿主,无需手工删锁;diagnostics.jsonl 有对应记录 |
|
|
96
|
+
|
|
97
|
+
## 5. 风险与权衡
|
|
98
|
+
|
|
99
|
+
- **超龄阈值 5min**:host-meta 均为毫秒级临界区持锁,5min 无合法冲突;非 Linux 平台的暴毙锁回收因此延迟 5min(无 startToken,无法更激进)。
|
|
100
|
+
- **pid 复用**:超龄 + pid 确死双条件,host-meta 短临界区 + 5min 门槛,恢复窗口极小;与 host-start 租约现有回收语义一致。
|
|
101
|
+
- **行为变化**:updateOwnedHost 竞争者的观察 reason 从 blocked(identity-less)→ busy(带 identity 后 pid 活)——retry 对两者一视同仁,语义等价;host-owner-store.test.mjs:313 注入测试本身不受影响(注释需更新,见 A6)。
|
|
102
|
+
- **诊断噪音**:节流保证同一 view 一次 contended 事件一条;watch 场景(外进程持锁 >60ms)恰好是值得诊断的异常,保留 warn 合理。
|
|
103
|
+
|
|
104
|
+
## 6. v1 自查 Review 记录(2026-09-14)
|
|
105
|
+
|
|
106
|
+
| # | 发现 | 处置 |
|
|
107
|
+
|---|------|------|
|
|
108
|
+
| 1 | A3 的 claimHost identity 无法观测(无注入点;且 identity 是写入自身锁而非判定他人) | 加 `opts.lockImpl`;updateOwnedHost 改用 mutate 回调观测(§2.2 / F5a/b) |
|
|
109
|
+
| 2 | 诊断无节流 → 1Hz 心跳路径可刷屏 | 未恢复标记节流 + 测试重置导出(§2.3) |
|
|
110
|
+
| 3 | claimHost 对 busy 误报 warn | 仅 blocked 写(§2.3) |
|
|
111
|
+
| 4 | 非 Linux startToken=null 落入兜底路径未声明 | 契约表 + 平台差异说明(§2.1) |
|
|
112
|
+
| 5 | classifyLeaseOwner 未要求 owner.token | 契约表 blocked 行(§2.1) |
|
|
113
|
+
| 6 | A2 单场景不足 | 拆 A2a/A2b |
|
|
114
|
+
| 7 | A2 断言不精确 | 改为「原锁 token 不可再观测」 |
|
|
115
|
+
| 8 | U1 未指定死 pid/路径 | 写明 99999999 + 具体路径 + 验证 diagnostics |
|
|
116
|
+
| 9 | 文档/注释更新缺失 | 新增 A6 |
|
|
117
|
+
| 10 | appendDiagnostic 会抛 | 明确 try/catch(§2.3,已核实 appendFileSync 无兜底) |
|
|
118
|
+
| 11 | startToken helper 命名 | pid.mjs 提供 captureStartToken + currentProcessIdentity(§2.2) |
|
|
119
|
+
|
|
120
|
+
已核实无误:host-meta 获取点全仓库仅 2 处;diagnostics.mjs ↔ store.mjs 无循环依赖;1Hz 读 /proc 开销可忽略;pty-runner.integration.test.mjs:1099(外来活锁 2.3s)行为不变(新增一条 warn 诊断,测试不断言该内容)。
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# issue #106 spec:终态链(clear/settled)仍须学习帧认知
|
|
2
|
+
|
|
3
|
+
日期:2026-09-14 · 状态:设计已确认(用户 2026-09-14 决策,范围 B:两个终态一起覆盖)
|
|
4
|
+
|
|
5
|
+
## 根因(机制链确认)
|
|
6
|
+
|
|
7
|
+
`src/core/pty-attach-jiggle-controller.mjs` 的 `feed()` 把「帧认知学习」放在两条终态早退之后:
|
|
8
|
+
|
|
9
|
+
```js
|
|
10
|
+
220: if (state.clearDetected) return; // chain done; nothing left to detect
|
|
11
|
+
221: if (state.stopped) return; // chain ended (G2/G3/G4); output is inert
|
|
12
|
+
222: const result = feedOutput(state, data, carry);
|
|
13
|
+
...
|
|
14
|
+
229: if (result.frameStartFound) tuiFrameSeen = true; // ← 唯一学习点(start() 的 :186 是唯一重置点)
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
因此,**链在第一个 TUI 帧之前进入终态 ⇒ 本次连接内 `tuiFrameSeen` 恒为 false**(写入点全仓只有 186/229 两处)。
|
|
18
|
+
|
|
19
|
+
后果链:`tuiFrameSeen === false` ⇒ `checkDesync()` 的 gate 2 早退(`src/ui/pty-attach.ts:590`)⇒ `heal()` 不可达(其唯一调用点 `pty-attach.ts:601` 在 gate 2 之后)⇒ 运行期失配自愈(#11)对本连接静默失效。纯漏报:不闪屏、不误触发,只是「该治不治」。
|
|
20
|
+
|
|
21
|
+
## 两个终态的可达性(含 `clearDetected` 的依据)
|
|
22
|
+
|
|
23
|
+
| 终态 | 进入条件(代码事实) | 现实场景 |
|
|
24
|
+
|---|---|---|
|
|
25
|
+
| `stopped` | G2:退避表 8 轮耗尽(120ms…20s,累计 **56.12s**)期间既无 `\x1b[2J` 也无 `\x1b[?2026h`;G4:`notifyExternalResize`(`pty-attach.ts:969`)在首帧前到达 | 子进程是 shell(无 TUI 帧);慢启动 TUI 56s 内一帧未出;attach 后立刻拖窗口 |
|
|
26
|
+
| `clearDetected` | live 输出出现 `\x1b[2J` 而此前/同 chunk 无 `\x1b[?2026h` | shell 里 `clear`、alt-screen 程序退出清屏等,之后**同一 session 内**起 TUI(例如 shell 里敲 pi) |
|
|
27
|
+
|
|
28
|
+
`clearDetected` 是对 issue 正文(只点了 `stopped`)的扩展,依据是同一守卫区同形状、后果相同(gate 2 恒关)。
|
|
29
|
+
|
|
30
|
+
**一处事实纠正**:`#11` 的永久笔记与 682f016 的代码注释称「screen-log replay 混合历史 2J + 2026h」。核实:`replayScreenLog()`(`pty-attach.ts:1091`)只调 `pushOutput()`,replay **不喂 controller**;`checkClearSequence()`(唯一调 `jiggleRetry.feed`,`:981`)只在 `onSocketData` 的 live `output` 分支被调(`:1050`)。所以 clear-wins 的真实来源是 **live 的 fullRender 输出块**(pi-tui 在同一同步更新块内发 `2026h … 2J …`),与 #106 的两个终态同源同路径。
|
|
31
|
+
|
|
32
|
+
**复位路径(故障窗口边界)**:只有 `start()`(socket connect,`pty-attach.ts:427`)全复位 `clearDetected`/`stopped`/`tuiFrameSeen`/`carry`;`heal()`(保留认知,且认知缺失时不可达)、`restoreAndStop()`(G3)、`notifyExternalResize()`(G4)都不复位终态。⇒ **窗口 = 本次连接的整个生命周期**,「用户 reattach 一下就好」不成立(#11 的诉求正是无需用户干预)。
|
|
33
|
+
|
|
34
|
+
## 复现(确定性,无需 56s 真实等待)
|
|
35
|
+
|
|
36
|
+
单测层用现成的 `fakeScheduler` 直接 fire 完 G1 + 8 轮退避 → 链进 `stopped`(现有用例 `no re-arm probe after chain exhausted (stopped)` 已示范这条驱动路径);再 `feed("\x1b[?2026h late frame")` → 当前代码下 `tuiFrameSeen` 仍为 false(红)。`clearDetected` 分支用一次 `feed("\x1b[2J…")` 即可到达。
|
|
37
|
+
|
|
38
|
+
组件层用 `test-support/desync-heal-smoke.ts` 的既有基座(真 `PtyAttachComponent` + fake TUI + 注入 clock/connected + 真实 socket-data 路径)复现:终态后喂帧 → 当前代码下 `checkDesync()` 不 heal(红)。
|
|
39
|
+
|
|
40
|
+
## 修复设计
|
|
41
|
+
|
|
42
|
+
改动只有一个文件、一个函数:`src/core/pty-attach-jiggle-controller.mjs` 的 `feed()`(`pty-attach-jiggle-retry.mjs` 纯层不动——`feedOutput()` 早已返回 `frameStartFound` + `carry`,终态扫描所需信息齐备)。
|
|
43
|
+
|
|
44
|
+
```js
|
|
45
|
+
function feed(data) {
|
|
46
|
+
// Terminal chain states: a clear was seen (chain done) or the chain ended
|
|
47
|
+
// (G2/G3/G4). Output no longer drives the retry protocol — but frame
|
|
48
|
+
// cognition must still be learned from it (issue #106): a TUI whose first
|
|
49
|
+
// frame lands after the chain settled must still open the runtime desync
|
|
50
|
+
// backstop's gate 2 (issue #11), otherwise heal() stays unreachable for the
|
|
51
|
+
// rest of this connection. Cognition only — no timers, no resizes, and no
|
|
52
|
+
// retry-state change: re-opening the protocol is heal()'s job (rate-limited
|
|
53
|
+
// and lifetime-capped), not an output chunk's.
|
|
54
|
+
if (state.clearDetected || state.stopped) {
|
|
55
|
+
if (tuiFrameSeen) return; // latched already — nothing left to learn
|
|
56
|
+
const terminal = feedOutput(state, data, carry);
|
|
57
|
+
carry = terminal.carry; // keep cross-chunk marker detection intact
|
|
58
|
+
if (terminal.frameStartFound) tuiFrameSeen = true;
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const result = feedOutput(state, data, carry);
|
|
62
|
+
...(以下原样不动)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### 为什么这样切
|
|
66
|
+
|
|
67
|
+
- **合并两个终态为一个分支**:两者对重试协议的含义相同(链已结束),对认知学习的需求相同;分开写会复制同一段学习逻辑。
|
|
68
|
+
- **`if (tuiFrameSeen) return;` 前置短路**:终态里认知已锁存后,每个 chunk 的两次 `includes` 扫描是无用功(惰性 shell session 会长期持续输出);短路后稳态成本为零,且 `carry` 也不再需要推进。
|
|
69
|
+
- **`carry` 仍要推进(仅在未锁存时)**:帧标记 `\x1b[?2026h` 8 字节可能跨 chunk 边界,不推进会漏学(「只学一半」的假修)。
|
|
70
|
+
- **不改 retry state**:终态是「协议已结束」的判定,不能被后续输出改写(否则 G2 的预算语义、`no re-arm probe` 契约都被推翻)。
|
|
71
|
+
|
|
72
|
+
### 契约(终态分支的行为边界)
|
|
73
|
+
|
|
74
|
+
| 允许 | 禁止 |
|
|
75
|
+
|---|---|
|
|
76
|
+
| 读 `data`/`carry`,锁存 `tuiFrameSeen`,推进 `carry` | 设/清任何 timer(`chainTimer`/`g1Timer`) |
|
|
77
|
+
| 立即返回 | 发任何 resize |
|
|
78
|
+
| | 改 `state`(`clearDetected`/`stopped`/`retryIndex`) |
|
|
79
|
+
|
|
80
|
+
### 数据流(修复后)
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
live 输出 → onSocketData → checkClearSequence → feed()
|
|
84
|
+
├─ 链活跃:原逻辑(clear 优先 / 首帧 fast-path / F1 慢启动探针)
|
|
85
|
+
└─ 终态 :未锁存 → feedOutput 扫一遍 → 命中 2026h 即 tuiFrameSeen=true → 返回
|
|
86
|
+
↓
|
|
87
|
+
desync 探针(2s)→ checkDesync → gate 2 通过 → 后续门(链空闲/静默/限速/失配)
|
|
88
|
+
↓
|
|
89
|
+
heal() → 重新 shrink-and-hold → 子端 fullRender 自愈
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### 风险与取舍
|
|
93
|
+
|
|
94
|
+
| 风险 | 评估 |
|
|
95
|
+
|---|---|
|
|
96
|
+
| 误判面扩大(终态后 latch 的程序不一定是 pi-tui,如 vim 等使用 DECSET 2026 的程序) | 与「链活跃期内 latch」的既有语义完全一致(同一子进程若早 10 秒出帧就会被 latch)。heal 后续仍有 gate 3(光标真失配)+ 静默 + 10s 限速 + 5 次终身上限;伤害面最坏是一次重绘 |
|
|
97
|
+
| 惰性链上持续扫描的开销 | 仅发生在「终态 + 认知未锁存」窗口;锁存后立即短路。窗口内每 chunk 成本 = 一次字符串拼接 + 两次 `includes` |
|
|
98
|
+
| 把 G2 的「预算耗尽 = 放弃」语义变松 | 不变:终态分支不发 resize、不重开协议,重打开仍由 heal() 的独立预算把关 |
|
|
99
|
+
|
|
100
|
+
## 备选方案(考虑并否决)
|
|
101
|
+
|
|
102
|
+
| 备选 | 否决理由 |
|
|
103
|
+
|---|---|
|
|
104
|
+
| 只在 `stopped` 早退前学习(issue 原文范围) | `clearDetected` 同形状同后果,分两次改动要重复复核同一守卫区(用户已选 B) |
|
|
105
|
+
| 把学习点整体移到 `feed()` 最前面(无条件先学,再走各分支) | 等价于 682f016 的写法,但会把「链活跃」路径的学习语义也一并改写(`firstFrame` 与 clear 分支的相对顺序),改动面大于必要;本次只在终态新开一条零副作用通路,既有分支逐字不动 |
|
|
106
|
+
| 终态后由 `feed()` 直接走 F1 式 re-arm(重开 hold) | F1 的前提是「预算未耗尽」(`ensureHold` 守卫含 `state.stopped`);在终态里 re-arm 等于绕过 G2 的预算判定,把「放弃」改成「无限重试」。重打开应交由 heal()(有独立限速与上限) |
|
|
107
|
+
| 让 `checkDesync` 的 gate 2 改用别的信号(如「子端曾是 TUI」的其它痕迹) | 无更可靠信号;`tuiFrameSeen` 是现有唯一「子端确实在渲染帧」的证据,改判据会引入误报面(对 shell 子进程 heal 是明确要避免的) |
|
|
108
|
+
| 改 `heal()` 使其可在无认知时自举 | 同上:会让 shell 子进程进入 heal 路径,直接违反 gate 2 的设计意图 |
|
|
109
|
+
|
|
110
|
+
## 非目标
|
|
111
|
+
|
|
112
|
+
- 不改 G1/G2/G4 的守卫语义、退避表、`heal()` 预算与限速参数。
|
|
113
|
+
- 不改 clear-wins 语义(同 chunk 内 clear 仍优先决定 re-arm)。
|
|
114
|
+
- 不为「非 TUI 子进程」打开任何 heal 通路(无帧 ⇒ 仍不 heal,H4 语义保持)。
|
|
115
|
+
- 不把 56s 的 G2 窗口改短/改长(#106 与窗口长度无关,只与「窗口结束后能否再学」有关)。
|
|
116
|
+
|
|
117
|
+
## 可测性拆分设计(自动化验证类功能点)
|
|
118
|
+
|
|
119
|
+
维持现有边界,**不新增抽象**:`pty-attach-jiggle-retry.mjs`(纯函数层:`feedOutput`/`advanceRetry`/`stopRetry`…)不动;`createJiggleRetryController(deps)` 是「状态机 + 注入副作用(`sendResize`/`setTimeoutFn`/`clearTimeoutFn`)」的既有拆分。观测点 = `getState()` 与测试记录下的 `resizes`/scheduler timer 集合——足以证明「认知锁存」与「零副作用」两个断言维度。
|
|
120
|
+
|
|
121
|
+
| 功能点 | 独立单元 | 测试边界 |
|
|
122
|
+
|---|---|---|
|
|
123
|
+
| 终态认知锁存(两个终态) | `feed()` 终态分支(输入 `data`/`carry`/`tuiFrameSeen`;**无副作用**:不调 `sendResize`、不设 timer) | controller 单测(`fakeScheduler` + `resizes`):断言 `getState().tuiFrameSeen` 翻 true、`resizes.length` 不变、timer 集合不变、`stopped`/`clearDetected` 不变 |
|
|
124
|
+
| 跨 chunk 帧标记拼接 | `feedOutput()` 的 `carry` 语义(既有纯函数,不改) | controller 单测:先喂半截 `\x1b[?202`(不锁存)→ 再喂 `6h`(锁存) |
|
|
125
|
+
| 认知锁存后 heal 可达 | `heal()` 既有入口 | controller 单测:终态 + 锁存 → `heal(cols,rows) === true` 且 `held === true` |
|
|
126
|
+
| 组件级 gate 2 真打开 → 真触发一次 heal | `PtyAttachComponent.checkDesync`(注入 `nowFn`/`connected`/`finishAttachTransition`) | `test-support/desync-heal-smoke.ts` 新场景 H8(走真实 socket-data 路径:`pushOutput` + `checkClearSequence`),断言 `healCount === 1` 且 resize 次数 = 1 |
|
|
127
|
+
| 无帧子进程仍不 heal(回归) | 同上(H4 既有场景) | smoke H4 保持 `false`(不喂帧 ⇒ 学不到 ⇒ gate 2 仍挡) |
|
|
128
|
+
|
|
129
|
+
## 验收矩阵
|
|
130
|
+
|
|
131
|
+
| ID | 功能点 | 验收方式 | 具体验证 | 通过标准 |
|
|
132
|
+
|----|--------|----------|----------|----------|
|
|
133
|
+
| A1 | `stopped` 终态后帧认知仍锁存,且零副作用 | 自动化(unit) | `node --test test/pty-attach-jiggle-controller.test.mjs`(新增用例) | 预算耗尽 → feed 帧 → `tuiFrameSeen=true`;`resizes` 不变;无新增 timer;`stopped` 仍 true |
|
|
134
|
+
| A2 | `clearDetected` 终态后帧认知仍锁存,且零副作用 | 自动化(unit) | 同上 | clear-only 终态(无帧)→ feed 帧 → `tuiFrameSeen=true`;`resizes` 不变 |
|
|
135
|
+
| A3 | 跨 chunk 拆分的帧标记在终态下仍被 `carry` 接住 | 自动化(unit) | 同上 | 半截标记不锁存;补齐后锁存 |
|
|
136
|
+
| A4 | 锁存后 `heal()` 可达(gate 2 打开) | 自动化(unit) | 同上 | `heal(cols,rows) === true`,`held === true`,`tuiFrameSeen` 保持 true |
|
|
137
|
+
| A5 | 组件级:终态(shell,无帧)后晚到帧 → 真触发一次 heal | 自动化(integration) | `node --test test/pty-attach-desync-heal.test.mjs`(新增 H8 断言) | `healCount === 1` 且 resize = 1;H4(无帧)仍为 false |
|
|
138
|
+
| A6 | 零副作用契约(终态后不发 resize)回归 | 自动化(unit) | 同 A1 | 既有 `no re-arm probe after chain exhausted (stopped)` 保持绿(27 条 controller 用例全绿) |
|
|
139
|
+
| A7 | 静态与类型 | 自动化(static) | `npm run typecheck` | 无错误 |
|
|
140
|
+
| A8 | 全量回归 + 打包 | 自动化(build) | `npm test`、`npm run verify`(含 `test:coverage`、`pack:dry`) | 全绿,无新增未覆盖分支告警 |
|
|
141
|
+
| A9 | 红/绿自证(TDD 纪律) | 自动化(unit + integration) | 先在未改源码上跑 A1–A5 断言 | 修复前必失败(红),修复后必通过(绿);red 证据记入 PR |
|
|
142
|
+
| U1 | 真实 session 观察性验收 | 用户实测(**非阻塞**) | 1) attach 到一个 shell 型/长静默 session;2) 等链走完预算(>60s)或先 resize 一次;3) 在该 session 内启动 pi;4) 观察是否出现周期性全清重绘 | 无误触发闪烁;若发生真失配,限速周期内自愈。**pending 理由**:需真实 ≥56s 无帧窗口 + 人为构造失配,脚本化成本高、收益低;与 #11 的 U1 同性质,合并观察 |
|
|
143
|
+
|
|
144
|
+
## 附:随修复一并提交的文档纠正(可单独 revert)
|
|
145
|
+
|
|
146
|
+
同一 doc-comment 块内的这句陈述不准确:「a clear only proves the child redraws, not that it isn't a TUI (**screen-log replay bundles historical frames with clears**, issue #11)」。依据:replay 不喂 controller(`replayScreenLog` 只调 `pushOutput`),真实来源是 live 的 fullRender 输出块。建议改为「a live fullRender chunk bundles a frame start with its clear」。理由:错误的成因描述会把后续排查引向 replay 路径。**若 review 认为应保持最小 diff,可单独 revert 这一句,不影响修复正确性。**
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# Issue 113 Spec — coordinator 写入竞态:前台预览被 agent_end 空投影覆盖
|
|
2
|
+
|
|
3
|
+
> Issue: zhuxixi/pi-agent-board#113
|
|
4
|
+
> 修订版(v2):吸收 spec 审查 findings(归档站点纠错、测试隔离、测试保真度、规则证据链、备选否决记录、字段边界、U1 前提)。
|
|
5
|
+
|
|
6
|
+
## 根因(机制链确认)
|
|
7
|
+
|
|
8
|
+
`syncForegroundEvent` → `syncRowEvent` 每个事件开头 `statusFromRow(row)` **从磁盘 state.json 重新重建**内存 status(`listRows → loadRow → readState` 直读磁盘,已逐行确认)。`message_end` 设置 `latestAssistantPreview`/`lastAgentActivityAt` 后经 `void writeForegroundState(...)` **fire-and-forget** 发往 coordinator;coordinator 先 fsync journal 再 materialize(毫秒级延迟)。`projectViewState` 对 `latestAssistantPreview` 无 previousState fallback(空串 `""` 不被 `??` 遮挡),因此 7ms 后到达的 `agent_end` 重读到未落盘的旧 state → 重建出空投影 → `finalizeRun` 的 `deriveSummary` 兜底 "Needs instructions" → **await 写入把非空 preview 覆盖为 `""`**。
|
|
9
|
+
|
|
10
|
+
影响面:`sync_foreground` 仅 service.mjs 发送(交互式前台镜像),evidence.json 直写不受影响。0.6.x 子进程直写磁盘(read-your-writes 天然成立),coordinator 间接层(socket RTT + fsync-before-materialize)破坏该不变量。
|
|
11
|
+
|
|
12
|
+
正交路径(已排查,确认不是同 bug 变体):手动完成 fence(`isManualCompletion`,agent_end 分支先行返回);`reconcile()` 的 `reconcile_finalize`(service.mjs:2006)是 PTY host 崩溃恢复判定,payload 不含 preview。
|
|
13
|
+
|
|
14
|
+
## 修复设计(主修复 = issue 评论区已验证 patch 的规范落地)
|
|
15
|
+
|
|
16
|
+
### 新模块 `src/core/foreground-preview-cache.mjs`
|
|
17
|
+
|
|
18
|
+
进程内 read-your-writes 缓存,**模块级单例**——依据:`src/index.ts:88` 的 `serviceFor(ctx)` 无记忆化、每次调用都 `createService`("reuse one instance" 注释只针对 sweeper 的 `sweepService`,因其构造副作用),闭包级缓存会被每事件新建的实例丢弃。
|
|
19
|
+
|
|
20
|
+
```js
|
|
21
|
+
export function createForegroundPreviewCache() {
|
|
22
|
+
const map = new Map(); // viewId -> { latestAssistantPreview, lastAgentActivityAt }
|
|
23
|
+
return {
|
|
24
|
+
// 记住投影中的已知字段。新鲜度规则(以 lastAgentActivityAt 为序):
|
|
25
|
+
// 更新时间戳的投影整体获胜;较旧/无时间戳的投影只补空缺,永不降级已有条目。
|
|
26
|
+
remember(viewId, { latestAssistantPreview, lastAgentActivityAt }),
|
|
27
|
+
// 回填:缓存时间戳严格更新于重建 status(含 status 无时间戳的 legacy 行)时
|
|
28
|
+
// 整体采纳缓存两字段;否则仅填空字段(磁盘更新或同静时磁盘优先)。
|
|
29
|
+
backfill(viewId, status),
|
|
30
|
+
forget(viewId), // 归档时清理,防无界增长
|
|
31
|
+
clear(), // 测试隔离:清空全部条目(模块级状态跨测试存活)
|
|
32
|
+
size, // 测试/诊断用
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
export const foregroundPreviewCache = createForegroundPreviewCache(); // 模块级单例
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
判定谓词(与仓库既有约定一致):`latestAssistantPreview` 判空用 falsy(空串);`lastAgentActivityAt` 判空用 `== null`。纯逻辑规则可独立测试,副作用(Map 读写)集中在模块内。
|
|
39
|
+
|
|
40
|
+
### 规则依据(为什么"空值永不覆盖"是安全的)
|
|
41
|
+
|
|
42
|
+
全仓 `latestAssistantPreview` 赋值只有两处,且都只会写非空值:
|
|
43
|
+
|
|
44
|
+
- `src/core/events.mjs:132`——`message_end` 分支,位于 `if (text)` 内,只写 `truncate(text)`;
|
|
45
|
+
- `src/core/state-commands.mjs:331`——`finalize_run` overlay,其唯一发送方 `runner/job-runner.mjs:272` 用 `if (status.latestAssistantPreview) payload...` 的 truthiness guard 只带非空值。
|
|
46
|
+
|
|
47
|
+
即"非空才带"已是本仓库处理该字段同类时序问题的既有约定,本修复把它延伸到前台镜像路径。此外 viewId↔sessionFile 映射不可变(全仓 src/runner/scripts 无 `.sessionFile =` 赋值;adopt 同文件复用同一 view、新文件新建 viewId),故按 viewId 缓存不会跨会话串值。
|
|
48
|
+
|
|
49
|
+
**证据链 caveat(终审补充)**:`runner/job-runner.mjs:743` 还有一条不经 truthiness guard 的写入路径——`patch_fields` 命令携带 `state: { summary, latestAssistantPreview }`(字段白名单在 `state-commands.mjs:112`)。该写入方(后台 runner)不在前台缓存的覆盖范围内:缓存只在本进程 `writeForegroundState` 时 remember。因此回填必须"仅当缓存严格更新时才整体采纳"——绝不能用较旧的缓存值覆盖磁盘上更新的 runner 写入(实现已按此新鲜度规则落地)。
|
|
50
|
+
|
|
51
|
+
### `src/runtime/service.mjs` 改动点
|
|
52
|
+
|
|
53
|
+
1. `writeForegroundState`:`projected` 计算后、发送/直写前 → `foregroundPreviewCache.remember(row.meta.id, projected)`(coordinator 开/关两模式都执行;直写模式下 backfill 天然 no-op → 零行为变化)。
|
|
54
|
+
2. `syncRowEvent`:`statusFromRow(row)` 之后立即 `foregroundPreviewCache.backfill(row.meta.id, status)`(在所有事件分支之前——agent_end 分支的 `finalizeRun`/`deriveSummary`、throughput 分支的投影都从回填后的 status 出发)。
|
|
55
|
+
3. 归档清理(**仅两个真实写入点**,全仓 `archived = true` 的写入清单):
|
|
56
|
+
- `service.mjs:670`(`archiveView`,`archiveMany`/`archive` 均委托到此)
|
|
57
|
+
- `service.mjs:1968`(`archiveByState`,内联写 `row.meta.archived = true`,不经过 archiveView)
|
|
58
|
+
|
|
59
|
+
两处均加 `foregroundPreviewCache.forget(row.meta.id)`。
|
|
60
|
+
|
|
61
|
+
### 数据流(修复后,竞态时序)
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
message_end: 磁盘读(空) → reduceEvent 设 preview=B → writeForegroundState 投影(B) → remember(v, {B}) → 发送
|
|
65
|
+
agent_end: 磁盘读(空 preview) → backfill(v, status) 回填 B → finalizeRun → summary=首句B → 投影(B) → 发送 ✓
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### 边界与降级
|
|
69
|
+
|
|
70
|
+
- coordinator off(直写):`writeState` 同步落盘,磁盘读回自己的写 → backfill 不触发,行为与 0.6.x 一致。
|
|
71
|
+
- 多轮会话:同一竞态在第 N≥2 轮下磁盘已有上一轮非空 preview,若只"填空"会把行冻结在上一轮回复(终审发现)。落地规则为新鲜度感知合并:缓存条目时间戳严格更新于磁盘重建值时整体采纳,较旧的重建写(agent_end baseline)也不得降级缓存;磁盘更新或同静时磁盘优先。
|
|
72
|
+
- 多进程:缓存在单进程内;竞态发生在同进程事件流内,足够。
|
|
73
|
+
- 已知边界(非本次修复范围):coordinator 命令到达乱序(0.7.0 架构既有行为,概率极低,窗口远小于 0.6.x 并发直写)。
|
|
74
|
+
- 内存:viewId→2 字段;两个归档站点 forget;模块导出 `clear()` 供测试复位。上限不做额外 LRU(归档清理已覆盖生命周期)。
|
|
75
|
+
|
|
76
|
+
## 备选方案(考虑并否决,记录理由)
|
|
77
|
+
|
|
78
|
+
| 备选 | 否决理由 |
|
|
79
|
+
|---|---|
|
|
80
|
+
| 方案 2:agent_end finalize 复用本轮 message_end 内存投影 | 需跨事件保留整个 status 对象;status 其他字段(lastActivityAt 等)依赖磁盘实时性,易引入新偏差;改动面大 |
|
|
81
|
+
| 方案 3:coordinator `sync_foreground` 合并「非空最后写入获胜」 | 改单写者层覆盖语义 = 翻 #107/#91 的契约与测试;且不解决本进程重读决策用旧数据的问题;无第二发送方,暂不引入 |
|
|
82
|
+
| 方案 4:per-view await 在途写入(通用 read-your-writes) | 需在每事件前 await 上一笔未落盘写:coordinator 降级时 ensure 窗口最长约 10s,会直接拖慢事件处理;且在发版前改动事件时序语义面过大。保留为未来若出现第二类字段回退问题时的候选 |
|
|
83
|
+
|
|
84
|
+
## 非目标
|
|
85
|
+
|
|
86
|
+
- 同类 stale rebuild 理论上可回退的其它字段(如 `error` 的清除)不在本规则覆盖范围:「非空获胜」规则修不了 clear 型回退,套用会让陈旧 error 更黏(属设计上不能做的事)。当前无证据表明其造成用户可见问题。
|
|
87
|
+
- 多轮乱序到达边界(既有架构行为,非本 issue 回归)。
|
|
88
|
+
- legacy 路径行为变更:仅回归验证,不做主动改动。
|
|
89
|
+
- coordinator 层防护(方案 3):见上表。
|
|
90
|
+
|
|
91
|
+
## 可测性拆分设计(自动化验证类功能点)
|
|
92
|
+
|
|
93
|
+
| 功能点 | 独立单元 | 测试边界 |
|
|
94
|
+
|---|---|---|
|
|
95
|
+
| 缓存写入规则(新鲜度胜出、旧写不降级、空值不覆盖非空) | `remember`(覆盖语义) | 新模块单测:构造实例直接断言规则 |
|
|
96
|
+
| 回填规则(严格更新时整体采纳;否则仅填空字段,磁盘优先) | `backfill`(字段级选择) | 同上:更新/较旧/同静/无时间戳四类输入 |
|
|
97
|
+
| 事件流集成(message_end→agent_end 竞态) | `createService(opts.sendStateCommand)` 注入 fake | service.test.mjs 新用例:fake **延迟 N ms 落盘**(保真真实时序:晚到的 message_end 写不得回退 agent_end 的正确投影),断言最终磁盘状态 |
|
|
98
|
+
| 真实 coordinator 不变式 | 既有 `startTrackedCoordinator` + `waitFor` 基座 | service.test.mjs 新用例(同型先例::862 真实 coordinator 前台测试):断言不变式,不依赖竞态是否发生 |
|
|
99
|
+
| 归档清理 | `forget` + 两个归档站点 | 模块单测 + archiveView/archiveByState 路径断言 |
|
|
100
|
+
| 测试隔离 | 模块 `clear()` | 新用例以 beforeEach/finally 复位模块级缓存(service.test.mjs 大量测试共用 viewId "v1",不复位会跨测污染、掩盖回归) |
|
|
101
|
+
|
|
102
|
+
关键:**延迟落盘 fake** 使竞态确定性复现(第二事件在延迟窗口内同步读取旧磁盘),且覆盖"晚到写入不回退"的真实语义——无 sleep 竞态、非 flaky。红证:未打缓存修复时 A3 必失败;绿证:修复后必通过。
|
|
103
|
+
|
|
104
|
+
## 验收矩阵
|
|
105
|
+
|
|
106
|
+
| ID | 功能点 | 验收方式 | 具体验证 | 通过标准 |
|
|
107
|
+
|----|--------|----------|----------|----------|
|
|
108
|
+
| A1 | 缓存写入规则(新鲜度胜出、旧写不降级、空值不覆盖非空) | 自动化(unit) | `node --test test/foreground-preview-cache.test.mjs` | 规则表断言全部通过 |
|
|
109
|
+
| A2 | 回填规则(严格更新时整体采纳;否则仅填空,磁盘优先;preview 用 falsy、时间戳用 `== null`) | 自动化(unit) | 同上 | 四类输入断言通过 |
|
|
110
|
+
| A3 | 竞态事件流:延迟落盘 fake,message_end→agent_end | 自动化(integration) | `node --test test/service.test.mjs`(新增用例) | 最终磁盘 state:preview=回复文本、summary=首句(非 "Needs instructions");移除缓存修复时该用例失败(红/绿自证);调用方式与生产一致(每次 `service(root)` 新建实例,验证缓存确在模块级) |
|
|
111
|
+
| A4 | 真实 coordinator 不变式 | 自动化(integration) | `node --test test/service.test.mjs`(新增用例,复用 startTrackedCoordinator/waitFor) | 最终磁盘 preview/summary 正确(任何时序下成立) |
|
|
112
|
+
| A5 | 直写模式不回归 | 自动化(unit 回归) | `node --test test/service.test.mjs`(既有 syncForegroundEvent 用例) | 全部通过,行为不变 |
|
|
113
|
+
| A6 | 归档清理(两个站点) | 自动化(unit + integration) | 缓存单测 + `archiveView`/`archiveByState` 路径断言 | 归档后缓存无该 viewId |
|
|
114
|
+
| A7 | 测试隔离 | 自动化(integration) | 新用例 beforeEach/finally 调用 `clear()` | 跨测试无残留(viewId "v1" 复用时行为一致) |
|
|
115
|
+
| A8 | 全量回归 | 自动化(build) | `npm run typecheck && npm test` | 全绿 |
|
|
116
|
+
| U1 | 真实交互会话端到端 | 用户实测 | 安装后开交互式 pi 会话,完成一轮带 assistant 回复的对话,等 idle,观察 dashboard | idle 行显示回复首句,非 "Needs instructions";state-journal.jsonl 无空投影覆盖序列(**前提**:修复进程内生效,仅对新代码加载后的事件有效;存量已写坏的行需等下一轮 message_end 才会显示正确预览,不追溯治愈) |
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# Spec:coordinator 管道名对 root 字符串形式敏感(issue #124)
|
|
2
|
+
|
|
3
|
+
日期:2026-09-15 · 状态:approved(用户确认)
|
|
4
|
+
调研:issue 评论 R1(根因确认 + 修复可行性 + 向后兼容实测)
|
|
5
|
+
|
|
6
|
+
## 1. 根因(systematic-debugging 结论)
|
|
7
|
+
|
|
8
|
+
**根因**:`coordinatorEndpointPathFor`(src/core/paths.mjs:101-107)在 win32 上以 `sha256(String(root))`——root **原始字符串**——作为命名管道名,不做任何归一化;而同一逻辑 root 的**锁路径**经由 `path.join` 归一化。两个键对 root 形式敏感度不一致 → 同一逻辑 root 的两种写法(`C:/x` vs `C:\x`、尾分隔符、`.` 段)= **同一把锁 + 两根不同管道**。
|
|
9
|
+
|
|
10
|
+
**触发条件**:任何绕过 `defaultRoot()`(`src/index.ts:27`,`path.resolve` 归一化)的 root 传入。`runner/state-coordinator.mjs:88` 直接 `const root = process.argv[2]`(原样使用)—— 手动/外部以不同形式启动 coordinator 即触发。
|
|
11
|
+
|
|
12
|
+
**症状**:面板按自身形式 probe → ENOENT → 反复 spawn 新实例 → 新实例抢锁失败(锁被占)→ 面板永久 `coordinator_unavailable`;占锁实例永远收不到面板命令。现场误导性强(coordinator 活着、心跳正常、ping 通),易与 #114 混淆。
|
|
13
|
+
|
|
14
|
+
**证据链**:
|
|
15
|
+
1. 实机复现:面板 root = 反斜杠(`AGENT_BOARD_ROOT=C:\...` → `path.resolve`),手动启动用正斜杠 argv → 两根管道(`0af89859…` / `8b9d0e3c…`)+ 同一把锁;
|
|
16
|
+
2. 同机实测:两形式 endpoint 不同(issue 正文表格),锁路径相同;
|
|
17
|
+
3. 代码静态核对:paths.mjs 三个 endpoint 中**只有** coordinator 以 root 为键(control 用 viewId、host 用 instanceId);
|
|
18
|
+
4. 归一化实测:4 种写法折叠为同一 hash,且**规范形式 hash 不变**。
|
|
19
|
+
|
|
20
|
+
## 2. 修复设计
|
|
21
|
+
|
|
22
|
+
### 2.1 归一化 hash 输入(D1)
|
|
23
|
+
|
|
24
|
+
`src/core/paths.mjs`:
|
|
25
|
+
|
|
26
|
+
```js
|
|
27
|
+
export function coordinatorEndpointPathFor(platform, root) {
|
|
28
|
+
if (platform === "win32") {
|
|
29
|
+
// Normalize before hashing: the pipe name must be invariant to the root's
|
|
30
|
+
// spelling (C:/x vs C:\x, trailing separators, dot segments). The lock path
|
|
31
|
+
// derived from the same root already is (via path.join); a mismatch yields
|
|
32
|
+
// "same lock, two pipes" — panel permanently locked out (issue #124).
|
|
33
|
+
// resolve() is idempotent on canonical roots, so already-deployed
|
|
34
|
+
// coordinators keep their pipe name and need no restart.
|
|
35
|
+
const normalized = path.win32.resolve(root);
|
|
36
|
+
const hash = createHash("sha256").update(normalized).digest("hex").slice(0, 16);
|
|
37
|
+
return `\\\\.\\pipe\\agent-board-coordinator-${hash}`;
|
|
38
|
+
}
|
|
39
|
+
return path.join(root, "coordinator.sock");
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
- **win32 语义显式指定**:hash 前用 `path.win32.resolve` 而非宿主 `path.resolve` —— 该分支必须在任何宿主 OS 上产出同一管道名(Linux/macOS 宿主的 `path.resolve` 是 POSIX 语义,会把 `C:\x` 当作相对路径:没有盘符与反斜杠分隔符语义),使 platform 注入式单测与 CI 在任何 OS 上行为一致;Windows 宿主上 `path === path.win32`,输出逐字节不变。
|
|
44
|
+
- **可测性拆分**:该函数已是纯函数(platform + root → string,零副作用、无 I/O)→ 直接单测,无需新增抽象;副作用(bind/connect)留在 client/runner。
|
|
45
|
+
- **向后兼容(实测)**:`path.resolve(canonical) === canonical` 成立 → 规范形式(生产 `defaultRoot()` 的产物)hash **不变** → 已部署 coordinator 管道名不变、零中断(实测数据见 issue R1 评论)。
|
|
46
|
+
- **POSIX 分支不改**:其 endpoint 是文件系统路径,`path.join` 已归一化;改为 `path.resolve` 会让相对 root 语义变化(行为改变而非修复)。
|
|
47
|
+
|
|
48
|
+
### 2.2 测试(D2)
|
|
49
|
+
|
|
50
|
+
**unit —— `test/socket-path.test.mjs`**(该函数专属测试文件,platform 参数注入 → 跨平台可跑):
|
|
51
|
+
|
|
52
|
+
1. win32:同一逻辑 root 的多种写法(`C:/r`、`C:\\r`、`C:\\r\\`、`C:\\.\\r`)→ **同一**管道名;
|
|
53
|
+
2. win32:不同 root → 不同管道名(per-root 隔离保留);
|
|
54
|
+
3. win32:前缀与长度约束(`\\.\pipe\agent-board-coordinator-` + 16 hex,≤256);
|
|
55
|
+
4. linux/darwin:返回 `path.join(root, "coordinator.sock")`,行为与修复前一致;
|
|
56
|
+
5. 断言不硬编码本机路径的 hash —— 用"多写法互相相等"+"与 `path.resolve` 语义一致"表达,保证跨机器可移植。
|
|
57
|
+
|
|
58
|
+
**integration(可选强化)—— `test/coordinator-client.test.mjs`**:以 root 的一种写法 spawn/ensure,用另一种写法的客户端调用 → 成功(Windows 上是本 issue 的端到端复现;POSIX 上两写法本就是同一 socket)。
|
|
59
|
+
|
|
60
|
+
### 2.3 非目标
|
|
61
|
+
|
|
62
|
+
- **大小写折叠**(`C:\...` vs `c:\...`):仍产生不同 hash。`path.resolve` 不改大小写;无条件 case-fold 会改变现有规范形式的 hash(破坏向后兼容),且 POSIX 大小写敏感语义不允许无条件折叠 → 记为已知限制。
|
|
63
|
+
- POSIX 分支改动。
|
|
64
|
+
- root 入口统一归一化(`defaultRoot()` 已归一化;runner argv 归一化无额外收益 → YAGNI)。
|
|
65
|
+
- 不复用/改动 #114 的孤锁回收逻辑。
|
|
66
|
+
|
|
67
|
+
## 3. 验收矩阵
|
|
68
|
+
|
|
69
|
+
| ID | 功能点 | 验收方式 | 具体验证 | 通过标准 |
|
|
70
|
+
|----|--------|----------|----------|----------|
|
|
71
|
+
| A1 | win32 管道名对 root 写法不变 | 自动化验证(unit) | `node --test test/socket-path.test.mjs` | 4 种写法同一管道名 |
|
|
72
|
+
| A2 | per-root 隔离保留 | 自动化验证(unit) | 同上 | 不同 root 管道名不同 |
|
|
73
|
+
| A3 | 管道名格式/长度约束 | 自动化验证(unit) | 同上 | 前缀正确、16 hex、≤256 |
|
|
74
|
+
| A4 | POSIX 行为不变 | 自动化验证(unit) | 同上 | 等于 `path.join(root, "coordinator.sock")` |
|
|
75
|
+
| A5 | 端到端跨写法互通 | 自动化验证(integration,真机) | `node --test test/coordinator-client.test.mjs` | 一种写法启动、另一种写法 ensure 成功 |
|
|
76
|
+
| A6 | 无回归 + 类型干净 | 自动化验证(static + unit) | `npm run typecheck`;`npm test` | typecheck 零错误;全量失败集不新增(Windows 既有环境类失败除外,需给出基线对比) |
|
|
77
|
+
|
|
78
|
+
无必须的用户实测项:A5 已覆盖原始症状的端到端路径(跨写法互通);如你希望,也可在合并后用正斜杠形式手动启一次 runner 复验。
|
|
79
|
+
|
|
80
|
+
## 4. 影响文件(预估)
|
|
81
|
+
|
|
82
|
+
- `src/core/paths.mjs`(+~5 行:归一化 + 注释)
|
|
83
|
+
- `test/socket-path.test.mjs`(+~40 行:不变量/隔离/长度/POSIX 断言)
|
|
84
|
+
- `test/coordinator-client.test.mjs`(可选 +~30 行:跨写法 e2e)
|
|
@@ -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
|
+
- 无其他生产文件改动。
|