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:
@ -84,8 +84,17 @@ type Plugin struct {
|
||||
|
||||
// B-7.3 邮件级去重:SSE 重放会重发同一批事件,没有这层去重
|
||||
// 每封邮件会被注入 agent 两遍。契约 B-7.3 要求:每封只注入一次。
|
||||
//
|
||||
// 这只挡得住**本进程内**的重复。跨进程(homed 重启、插件子进程被换)
|
||||
// 靠 ledger —— 它落盘,且区分「投过」与「跑完」。
|
||||
deliveredMails map[string]bool
|
||||
|
||||
// 跨进程投递账本(见 ledger.go)。
|
||||
//
|
||||
// 它与 deliveredMails 不是重复:后者是同一进程内 SSE 重放的快速路径,
|
||||
// 前者回答的是「上一个进程有没有已经把这封跑完」。
|
||||
ledger *deliveryLedger
|
||||
|
||||
// 单调递增的 last-seen-ID:被重放的旧事件不会让它回退。
|
||||
// 原来直接赋值(p.lastEventID = eid),Gateway 重放时发旧 ID,
|
||||
// 于是 lastEventID 从 123 退回 116 → 下次重连又报 116 → 又重放。
|
||||
@ -185,6 +194,20 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
// B-1.1 解析密钥(环境变量 → 本地文件 → 生成)
|
||||
p.key, p.keyFile = resolveKey()
|
||||
|
||||
// 跨进程投递账本:子进程被换时(homed 重启 / 插件崩溃自动重启)
|
||||
// 内存里的 deliveredMails 全丢,只靠它防不住重复注入。
|
||||
//
|
||||
// DataDir() 是 SDK 保证存在的插件专属目录;拿不到时退回 key 文件所在目录
|
||||
// (那个目录本来就要能写)。
|
||||
dataDir := ""
|
||||
if sett := s.Settings(); sett != nil {
|
||||
dataDir = sett.DataDir()
|
||||
}
|
||||
if strings.TrimSpace(dataDir) == "" {
|
||||
dataDir = filepath.Dir(p.keyFile)
|
||||
}
|
||||
p.ledger = openDeliveryLedger(dataDir)
|
||||
|
||||
// ─── 注册工具 ───
|
||||
|
||||
// registerTool 包一层只为计数:日志里的工具数必须与实际注册数一致。
|
||||
@ -413,7 +436,14 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
}
|
||||
|
||||
func (p *Plugin) Stop() error {
|
||||
p.stopOnce.Do(func() { close(p.stopCh) })
|
||||
p.stopOnce.Do(func() {
|
||||
close(p.stopCh)
|
||||
// 关账本句柄。每行写入都 Sync 过,所以不关也不丢数据 ——
|
||||
// 关只是为了不把 fd 泄给下一个插件实例。
|
||||
if p.ledger != nil {
|
||||
p.ledger.close()
|
||||
}
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -532,8 +562,31 @@ func (p *Plugin) catchUp(pending int) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 构造注入消息(与 handleNewMail 一致)
|
||||
prompt := fmt.Sprintf(
|
||||
// 跨进程去重:这次重启前那个进程可能已经把这封跑完了。
|
||||
//
|
||||
// 这才是那次事故的真正修法:死掉的那个进程已经注入过一次,
|
||||
// 而 deliveredMails 随它一起消失了。ledger 落盘,能说出区别:
|
||||
// - 已跑完 → 真的跳过
|
||||
// - 投过但未跑完(上一轮被中断)→ 仍然重投,但带上说明
|
||||
// 后一条很要紧:那一轮被中断意味着发件人没收到回信,跳过它就是静默丢件。
|
||||
deliver, resumed := p.ledger.claim(m.MailID)
|
||||
if !deliver {
|
||||
log.Printf("[homeagent-mail-bridge] 补投跳过 %s:已在之前的进程里处理完毕", shortID(m.MailID))
|
||||
continue
|
||||
}
|
||||
if resumed {
|
||||
log.Printf("[homeagent-mail-bridge] 补投 %s(上一轮被中断,带说明重投)", shortID(m.MailID))
|
||||
}
|
||||
|
||||
// 注入消息。与 handleNewMail 那份的差异只在一句措辞上(这里不说
|
||||
// 「把本轮工作做完」)—— 那个差异正好是上次定位重复投递的线索:
|
||||
// 两段提示词同时出现在上下文里,一看措辞就知道一段来自 SSE、
|
||||
// 一段来自补投。
|
||||
prefix := ""
|
||||
if resumed {
|
||||
prefix = resumeNote(m.MailID)
|
||||
}
|
||||
prompt := prefix + fmt.Sprintf(
|
||||
"你收到一封新邮件(AgentMail)。\n\n"+
|
||||
"发件人:%s\n主题:%s\n邮件 ID:%s\n身份:你是 %s\n\n"+
|
||||
"请先调用 read_inbox 读取完整正文,然后处理其中的请求。\n\n"+
|
||||
@ -545,8 +598,9 @@ func (p *Plugin) catchUp(pending int) {
|
||||
|
||||
reply := p.sdk.InjectInputSync(p.name, p.name, prompt)
|
||||
if reply == "" {
|
||||
// B-6:模型没回,发一封告知
|
||||
// B-6:模型没回,发一封告知。发出去就算处理完(理由同 handleNewMail)。
|
||||
p.sendFailureReply(m.FromName, m.Subject, m.MailID, "模型未产生回复")
|
||||
p.ledger.complete(m.MailID)
|
||||
continue
|
||||
}
|
||||
// B-5.3:检查模型是否已经自己发过信
|
||||
@ -557,11 +611,17 @@ func (p *Plugin) catchUp(pending int) {
|
||||
|
||||
if sent {
|
||||
// 模型已经在这一轮里自己回了这封信,不再重复 relay
|
||||
p.ledger.complete(m.MailID)
|
||||
continue
|
||||
}
|
||||
|
||||
// B-5.2:自动回信带 relay:"summary" —— 搬运不算模型自主发信,不扣配额
|
||||
p.sendMailRelay(m.FromName, "Re: "+m.Subject, reply, m.MailID, "homeagent:"+m.MailID)
|
||||
if err := p.sendMailRelay(m.FromName, "Re: "+m.Subject, reply, m.MailID, rk); err != nil {
|
||||
// 回信没发出去 —— 不标完成,下次重启重试。
|
||||
log.Printf("[homeagent-mail-bridge] 补投回信失败(不标完成): %v", err)
|
||||
continue
|
||||
}
|
||||
p.ledger.complete(m.MailID)
|
||||
}
|
||||
}
|
||||
|
||||
@ -707,12 +767,22 @@ func (p *Plugin) parseSSELine(line string) {
|
||||
p.deliveredMails[evt.MailID] = true
|
||||
p.sseMu.Unlock()
|
||||
|
||||
// 跨进程去重:上一个插件子进程可能已经把这封跑完了。
|
||||
// deliveredMails 只在本进程内有效,homed 重启会把它清空 ——
|
||||
// 实测过一次两段几乎相同的通知堆在模型上下文里(一段来自这里的
|
||||
// SSE 路径,一段来自重启后的 catchUp)。
|
||||
deliver, resumed := p.ledger.claim(evt.MailID)
|
||||
if !deliver {
|
||||
log.Printf("[homeagent-mail-bridge] 邮件 %s 已在之前的进程里处理完毕,跳过", shortID(evt.MailID))
|
||||
return
|
||||
}
|
||||
|
||||
// InjectInputSync 会阻塞几十秒(查日志、调工具、转发 QQ),
|
||||
// 而它跑在 readSSE 的读循环里 —— 循环卡住期间 SSE 事件积压在
|
||||
// TCP 缓冲区,卡到超时断线重连后 Gateway 全部重放一遍。
|
||||
// 把处理丢到独立 goroutine:parseSSELine 立刻返回,读循环继续。
|
||||
// homeagent 是单事件循环,InjectInputSync 自己会排队。
|
||||
go p.handleNewMail(evt)
|
||||
go p.handleNewMail(evt, resumed)
|
||||
}
|
||||
}
|
||||
|
||||
@ -822,8 +892,18 @@ func (p *Plugin) handleNewMail(evt struct {
|
||||
Workspace string `json:"to_workspace"`
|
||||
Alias string `json:"session_alias"`
|
||||
ReplyAddr string `json:"reply_address"`
|
||||
}) {
|
||||
prompt := fmt.Sprintf(
|
||||
}, resumed bool) {
|
||||
// resumed = 上一个进程注入过这封但那一轮被中断了。
|
||||
//
|
||||
// 必须把这件事告诉模型:不说的话它在上下文里看到两段几乎相同的指令,
|
||||
// 会以为人重复交代了一遍,于是可能把同一件事做两次。
|
||||
prefix := ""
|
||||
if resumed {
|
||||
prefix = resumeNote(evt.MailID)
|
||||
log.Printf("[homeagent-mail-bridge] 邮件 %s 重投(上一轮被中断)", shortID(evt.MailID))
|
||||
}
|
||||
|
||||
prompt := prefix + fmt.Sprintf(
|
||||
"你收到一封新邮件(AgentMail)。\n\n"+
|
||||
"发件人:%s\n主题:%s\n邮件 ID:%s\n身份:你是 %s\n\n"+
|
||||
"请先调用 read_inbox 读取完整正文,然后处理其中的请求。\n\n"+
|
||||
@ -839,8 +919,12 @@ func (p *Plugin) handleNewMail(evt struct {
|
||||
// B-6:模型没回(空 = turn/end 信号 kind=error,或模型没说话)
|
||||
if reply == "" {
|
||||
log.Printf("[homeagent-mail-bridge] 邮件 %s(来自 %s:%s)agent 无回复,发失败通知",
|
||||
evt.MailID[:8], evt.FromName, evt.Subject)
|
||||
shortID(evt.MailID), evt.FromName, evt.Subject)
|
||||
p.sendFailureReply(evt.FromName, evt.Subject, evt.MailID, "模型未产生回复")
|
||||
// 失败通知发出去了就算**处理完**:发件人得到了一个明确的交代。
|
||||
// 不标的话下次重启会把同一封再投一遍 —— 而模型上一次就没回,
|
||||
// 重投只会再发一封相同的失败通知。
|
||||
p.ledger.complete(evt.MailID)
|
||||
return
|
||||
}
|
||||
|
||||
@ -855,15 +939,19 @@ func (p *Plugin) handleNewMail(evt struct {
|
||||
|
||||
if sent {
|
||||
// 模型已经在这一轮里自己回了这封信,让位
|
||||
log.Printf("[homeagent-mail-bridge] 邮件 %s 模型已自行回复,跳过自动 relay", evt.MailID[:8])
|
||||
log.Printf("[homeagent-mail-bridge] 邮件 %s 模型已自行回复,跳过自动 relay", shortID(evt.MailID))
|
||||
p.ledger.complete(evt.MailID)
|
||||
return
|
||||
}
|
||||
|
||||
// B-5.2:自动回信带 relay:"summary" + relay_key
|
||||
if err := p.sendMailRelay(evt.FromName, "Re: "+evt.Subject, reply, evt.MailID, rk); err != nil {
|
||||
log.Printf("[homeagent-mail-bridge] 自动回信失败: %v", err)
|
||||
// 回信没发出去 —— **不标完成**,让下次重启能重试。
|
||||
// 发件人至今一个字都没收到,这时标「已完成」就是静默丢件。
|
||||
log.Printf("[homeagent-mail-bridge] 自动回信失败(不标完成,下次会重试): %v", err)
|
||||
} else {
|
||||
log.Printf("[homeagent-mail-bridge] 已自动回信给 %s(%d 字)", evt.FromName, len(reply))
|
||||
p.ledger.complete(evt.MailID)
|
||||
}
|
||||
|
||||
// 清理过期的 explicitSends 记录
|
||||
|
||||
Reference in New Issue
Block a user