pi 桥改为工作进程池 + homeagent 落盘投递账本

## pi 桥:模型工作下到子进程(并发模型重构)

主进程原来自己跑模型,而 pi 的会话装载是同步的:`SessionManager.open()` 走
`openSync` + `readSync` 循环把整个 `.jsonl` 读进内存并逐行 JSON.parse。实测本机
最大那条会话 23MB,`open` 一次**阻塞事件循环 118ms**;模型跑起来后 SDK 内部还有
更多同步工作。SSE 读循环在那期间完全停住 → 后续邮件卡在 TCP 缓冲区 → 久到
Gateway 认为连接死了 → 重连 → 重放。

上一轮我在几个调用点前加 `setImmediate` 是无效的仪式(让出一次之后同步工作照样
占满线程),已回退。这一轮把模型工作整体搬进子进程:实测同样的活在 fork 出的
子进程里跑,主进程事件循环阻塞 **0ms**。

- 新增 `src/worker.mjs`:一封邮件一个进程,跑完就退。权限询问期间的挂起只影响
  那一个 worker(原来 `await new Promise(...)` 等人决策,整座桥不再收信)。
- 新增 `src/pool.mjs`:**不同会话并发**(上限 3,每个 worker 约 140MB RSS)、
  **同一会话严格串行**(pi 假定「一文件一持有者」,两个进程同时装载同一条会话
  文件会让各自的内存索引看不见对方追加的行 → 会话树分叉)、满载排队不丢邮件、
  硬超时 SIGKILL 回收卡死进程。
- `src/index.mjs` 只剩 I/O 与调度:SSE、心跳、去重、分派。
- 选进程而不是 `worker_threads`:模型会跑 bash/write/edit,一次 OOM 不该带走
  整座桥。两者实测都能建起 AgentSession,但线程与主线程共享堆和生命周期。
- 「接管会话短暂持有」那套机制(adopted / adoptTimers / releaseAdopted + 兜底
  计时器)整个删掉 —— worker 退出**就是**释放,且普通会话与接管会话一视同仁。
- 轮次超时 60s → 10 分钟:60s 那个数字是「主进程要腾出手收下一封」的产物,
  worker 没有这个理由,等真结论更准(带工具调用的一轮跑几分钟很正常)。
- IPC 只传路径与标量(sessionFile / cwd / grants / 命名指纹)—— AgentSession
  跨不了进程边界,worker 每次从 sessionFile 重新装载。

`test/pool.test.mjs` +19 例,真 fork 子进程、用桩 worker(不装 SDK)跑毫秒级:
并发上限、同会话串行、不同会话真并发(判据是两个进程的心跳交错,不是 running
map 里有两个条目)、sessionFile/grants/命名指纹跨 worker 传递、config() 每次重取、
硬超时回收、权限决策路由、决策原文透传、worker 退出后清路由、mailDrivenIDs、
kind 透传、stop 先发 shutdown 再杀。跑过三组负向对照确认用例真能抓回归:
拆掉串行守卫 / 不传 sessionFile+grants / 硬超时不杀,对应用例分别失败。

## homeagent:投递去重必须落盘

用户报的重复投递不是上一轮那个 bug。两段提示词的措辞差异指出了来源:
SSE 那段写「你把本轮工作做完」,补投那段写「你把结论说出来就行」。

`deliveredMails` 是进程内的 map,而 homeagent 的插件跑在**子进程**里:

  1. 18:59:38 邮件落库,旧插件进程的 SSE 收到,注入第一次
  2. 同一秒 homed 被重启,那一轮被掐断(`context canceled`)
  3. 18:59:45 新进程起来,`deliveredMails` 是空的
  4. 心跳报 `pending_mails: 1`(第一轮没跑完 → read_inbox 没执行 → 仍未读)
     → catchUp 注入第二次

**不能只记「投过没有」**:那会把「重复」换成「丢件」—— 第 2 步里发件人没收到
回信,而记录说「已投过」→ 永远跳过。丢件比重复严重,重复至少人能看出来。

新增 `ledger.go`:JSONL 账本记两个状态。`completed` 才跳过;`delivered` 但未
`completed` 的仍然重投,但提示词前面插一段说明「上一轮被中断,别把同一件事做
两次」。落在 SDK 的 `Settings().DataDir()`;拿不到时退回 key 文件目录;目录不可
写时退化为纯内存(不比修复前差,也不该让插件起不来)。

- 判定与记录在同一把锁里:SSE 与 catchUp 两个 goroutine 的竞态
- 每行写完 fsync:这个文件的全部意义就是「进程死了之后还算数」
- 坏行跳过而不是报错退出(崩溃时最后一行可能写残)→ 那封退化为重投,安全
- 14 天保留期;过期过半时「临时文件 + rename」压实
- `shortID()` 替代 `id[:8]`:日志不该有能力 panic 掉投递协程

`ledger_test.go` +14 例,含两组负向对照(只记「投过」→ 丢件用例失败;不读账本
→ 跨进程用例失败)。

## 契约文档

`B-7.7`(MUST):子进程形式的插件去重必须落盘且区分「投过」与「跑完」,含事故
时序、两状态表、何时标 completed。已知取舍那节标注投递账本是唯一必须落盘的状态。
验收清单加「模型跑到一半重启宿主」一项。

## 生产验证

- pi 三封 → 三条会话:三个 worker PID 并存,回信「收到 1/2/3」各落自己线索
- pi 同一会话两封:严格串行(收到A 19:26:39 → 收到B 19:26:48,全程单 worker)
- pi 主进程事件循环阻塞 1ms(旧版单进程 open 23MB 一次就 118ms)
- homeagent 正常一封:账本 `c:false` → `c:true`,一封回信
- homeagent 处理中重启:日志「上一轮被中断,带说明重投」,**只有一封 Re:**
- homeagent 再次重启:账本 2 条 completed,不再投递,会话邮件数不变
- gateway 7 包 / web 176+26 / pi 269 / dsh 219 / opencode 201 / homeagent 14
This commit is contained in:
2026-09-04 20:33:44 +08:00
parent c297468819
commit 8e501f041e
8 changed files with 2117 additions and 704 deletions

View File

@ -349,6 +349,7 @@ SSE 只推连上之后的事件。插件重启前发来的邮件不会再推一
| B-7.4 | 按时间**正序**投(收件箱是倒序返回的) | MUST |
| B-7.5 | `mail_type !== 'normal'` 的不补投 | MUST |
| B-7.6 | 逐封投递前**再查一次**去重集合 | SHOULD |
| B-7.7 | 插件以**子进程**形式运行时,去重必须**落盘**,且区分「投过」与「跑完」 | MUST |
> **B-7.1 为什么只补一次**:每轮都补会把「模型正在处理中、尚未标已读」的邮件
> 重复投递 —— 一封邮件起两轮模型。
@ -364,6 +365,39 @@ SSE 只推连上之后的事件。插件重启前发来的邮件不会再推一
>
> **B-7.5 为什么跳过 permission**:原来的工具调用早随进程一起没了,
> 投过去模型没有可恢复的上下文。
>
> **B-7.7 为什么内存去重不够**生产事故2026-09-04
> `deliveredMails` 是进程内的,而 homeagent 的插件跑在**子进程**里 ——
> homed 重启(或插件崩溃自动重启)会换一个新进程,那个集合随之清空。时序:
>
> 1. 邮件落库,**旧**进程的 SSE 收到,注入第一次
> 2. 同一秒进程被重启,那一轮被掉断(日志:`context canceled`
> 3. **新**进程起来,`deliveredMails` 是空的
> 4. 心跳报 `pending_mails: 1`(第一轮没跑完 → `read_inbox` 没执行
> → 邮件仍未读)→ 补投注入第二次
>
> 模型上下文里因此出现两段几乎相同的指令。
>
> **为什么不能只记「投过没有」**:那会把「重复」换成「丢件」。上面第 2 步里
> 那一轮被掉断,发件人**没有**收到回信,而落盘记录说「已投过」→ 永远跳过。
> 丢件比重复严重:重复至少人能看出来,丢件是静默的。
>
> 因此记两个状态:
>
> | 状态 | 含义 | 再次收到时 |
> |---|---|---|
> | `delivered` | 注入过(可能被中断) | **仍然重投**,但提示词里说明「上一轮被中断」 |
> | `completed` | 那一轮真的跑完且回信已发出 | 跳过 |
>
> 「说明上一轮被中断」不是装饰:不说的话模型在上下文里看到两段相似指令,
> 会以为人重复交代了一遍,于是可能把同一件事做两次。
>
> 何时标 `completed`回信发出去了、模型自己回过了B-5.3 让位)、
> 或失败通知发出去了B-6—— 三者都是「发件人得到了一个交代」。
> 回信**发失败**时不标 —— 那时发件人一个字都没收到。
>
> 常驻守护进程形态的插件(如 pi 桥)不受这一条约束:它自己就是进程,
> 重启同时也会丢掉 SSE 连接,那时走的是 B-7 补拉而不是两条路径并发。
### B-8 平台权限询问 → 邮件若平台有审批环节MUST没有则 N/A
@ -1055,11 +1089,16 @@ GET /api/v1/attachments/{id}
| `mailDrivenSessions` | 快照里 `mail_driven` 全变成 `false` |
| `pendingPermissions` | 决策回来时走 `B-4.2` 的退化路径 |
| `explicitSends` | 重启后的第一轮可能重复转发 |
| `deliveredMails` | 由 `B-7`补拉去重兜住 |
| `deliveredMails` | 同一进程内的 SSE 重放靠它;**跨进程**的重复必须`B-7.7`落盘账本兜住 |
要持久化的话应当落在平台的会话元数据里,而不是插件自己的文件 ——
那样才能跟着会话一起被平台清理。
> **例外:投递账本必须落盘**`B-7.7`)。上面那些丢了只是「多开一条会话」
> 或「多转发一次」,而投递去重丢了是「同一封邮件被注入两遍」—— 模型上下文
> 里出现两段相同指令,可能把同一件事做两次。子进程形式的插件尤其如此:
> 它的重启频率由宙主进程决定,不是罕见事件。
---
## 七、验收清单 / Acceptance Checklist
@ -1169,6 +1208,12 @@ GET /api/v1/attachments/{id}
[ ] 插件在线时再发一封
→ 只收到一封回信没有重复投递B-7.3
[ ] (子进程形式的插件)发一封 → 模型跑到一半时重启宙主进程B-7.7
→ 数据库里只有一封 `Re:`(模型上下文里也只有一段有效指令)
→ 日志出现「上一轮被中断,带说明重投」
→ 账本里该 mail_id 先一行 `c:false` 后一行 `c:true`
→ 再次重启(邮件已跑完)→ **不再投递**,会话邮件数不变
```
### 7.6 权限(若平台支持)