Commit Graph

3 Commits

Author SHA1 Message Date
4175c0ba45 fix(bridge): ★ 投递即标已读 —— 修「桥重启 → 重投 → 回声」
用户 2026-09-26 原话:
  「我都不记得我下达这个任务,是你的桥自动重投存在 bug」
  「就是你的错误的重投机制造成了回声」

# 我上一轮把因果搞反了

我先认定是「两个 Agent 自发辩论」,还为此写了第三道防线(数 Agent↔Agent
连续往返)。**方向错了** —— 是**桥把同一封信反复投递**,每次投递起一个
worker 回信,回信又触发下一轮。模型在做什么?它在回答一封被重复投进来的
旧信。用户根本不知道有这回事。

# 根因:deliveredMails 只在内存,库里的 status 从没被写

投递路径(SSE `new_mail` / 心跳补投 / 决策回执)只做两件事:起 worker、
把 id 记进 `deliveredMails`。**没有任何一处调 `/mail/read`** —— 桥里唯一
那处标已读在 `read_inbox` 工具里,要等模型自己去读收件箱。

于是每封被投递的信**永远是 unread**;而 `catchUp` 按 `status=unread` 拉
⇒ 桥一重启(**每次部署都会**),积压的"未读"被当成离线漏投**再投一遍**。

# 实证(不是推断)

· 5 个 mail_id 各出现在**两条不同 pi 会话**里:
    f06129f4 → 04:54:45 投进 01a0a2bd
             → 08:01:20 投进 01a0daf0
  (而那封信库里已有 1 封回信 —— 它早就被处理过)
· 同一封信被投两次 ⇒ 两个 worker 各回一封 ⇒ 对方收到两封 ⇒ 各回两封…
· pi 收件箱 287 封 unread 中 **187 封已经回过信了**
  (`EXISTS(SELECT 1 FROM mails r WHERE r.parent_mail_id=m.mail_id)`)
· 两条会话各烧到 463 / 268 封
· 桥侧:同一邮件会话 id 前缀 `01a0a2bd` 出现在 **4 个** pi 会话文件里
  (投了两次 + 别的历史残留)

# 修法:内存与库必须同时写

`deliveredMails` 是**内存**集合,重启即丢;数据库的 status 才是跨重启的
"我接管过了"记录。两者只写其一 ⇒ 口径不一致 ⇒ 重投。

新增 `markDelivered(id)`:**凡是标记"我接管了这封"的地方都走它**
(SSE / 补投 / 决策回执三个投递点),同时写内存与库。漏一处就是一条重投
路径 —— 这正是缺陷的形状(四处各自 add,没有一处标已读)。

标已读只改 status,不改内容、不删行;`read_inbox` 传 `status=all` 照常可见。
而"已交给一个 worker 处理"正是那封信此刻的真实状态 —— 库里本来就该记这件事,
而不是"模型有没有顺手调过 read_inbox"。

# 四个桥:三个有缺陷,第四个早已修过

| 桥 | 投递标已读 | 说明 |
| --- | --- | --- |
| pi | ✗ → ✓ | 三处 add 都不标 |
| opencode | ✗ → ✓ | 同上 |
| dsh | ✗ → ✓ | 同上 |
| **homeagent** | **✓ 早有** | `ledger` 落盘,跨进程 |

homeagent 不用这个修法:它的 `ledger` 记 `delivered`/`completed` 两个状态,
只有 `completed` 才跳过(投过但被中断的**仍然重投**并带说明)—— 那份设计的
注释里就写着 18:59:38 那次实测,比我今天这个修法更早也更完整。
所以对它只做了「补投按工作区收窄」那一半(见下条)。

# 附带修:homeagent 的 workspace 收窄(我今天打破了它)

我先部署服务端(缺 workspace 直接 400)并修了三个桥,**漏了 homeagent**
⇒ 线上 07:42 起持续报 `read_inbox 工具执行失败: HTTP 400 缺少 workspace`。
这是我造成的,靠自己的日志发现的(pid 还是重启前的旧进程 2291455)。

修法与另三个同源:`currentWorkspace`(信封的 `to_workspace`)在回合期间暂存
(与 `currentSessionID` 同一形状、同一生命周期),`inboxURL`/`scopeQuery` 带上它,
补投从"读一次全局收件箱"改为逐工作区(清单来自心跳的 `pending_workspaces`)。

# 清理重投燃料

151 封归档(80 封回声:会话全程无人类 + 71 封 `permission_decision` 不可投)。
★ 用 `archived` 而不是 `read` —— `read` 还能被 `status=all` 拉出来重投。
判据用服务端自己的口径(`unreadFor` = `m.status<>'archived'` 且
`mail_reads` 无该读者),不手写 SQL 猜语义。

后置:pi / dsh / opencode / homeagent 在**所有工作区**的 unread 全部为 0。

# 判据

· `delivery-marks-read.test.mjs` × 3(pi / opencode / dsh)各 4 条:
  核心那条钉的是「`deliveredMails.add` **只允许**出现在 markDelivered 内部」——
  任何别处直接 add 就是绕过标已读的重投路径。另加自检反例。
  变异验证:绕过投递点 / markDelivered 不写库 / 补投绕过,三处全判红。
· `inbox_workspace_test.go`(homeagent)5 条:URL 带 workspace、带不到时不带
  (让服务端 400:错误可见好过静默越界)、补投逐工作区、两处投递路径都设工作区
  且都清空。变异 3 处全判红。
· 改了两条既有判据(pi / dsh 的 permission-note):原来钉
  `deliveredMails.add(decisionMailID)` —— 那个形状**就是**缺陷载体。
  语义没变(仍"不再当新任务"),载体变了。

全量:pi 517 / opencode 344 / dsh 407 / homeagent 除一条既有的
`TestSDKPinMatchesBuildMachinePointer`(依赖构建机路径,改动前后同样红)全绿。
2026-09-26 09:18:39 +08:00
pi
f0884d03be fix(pi-bridge): 决策到了没唤醒等待者 —— 整条授权链断掉(用户报「授权机制有问题」)
现场(用户:「我发现授权机制有问题,你看看 webui4frpc 的那个 session」):
同一条会话一天被问 6 次「是否允许执行 bash?」,**每次人都在 8 秒内点了同意**,
而每一轮都恰好烧满 10 分钟(TURN_TIMEOUT_MS),回信只有一句 59 字的开场白。
该 agent 自己的会话转录里写着:**"The bash tool keeps returning 'No result provided'"**。

根因:`worker.mjs` 的 `permission_decision` 分支取出了等待者、删了表项、存了备注,
**却没有调用 `resolve`**:

    const resolve = pending.get(msg.relayKey);
    if (!resolve) return;
    pending.delete(msg.relayKey);
    decidedExtra.set(msg.relayKey, { ... });
    return;                       // ← 等的人永远醒不过来

于是一条命令走完下面这一整圈:
  ① 工具调用挂着不动 → 一轮跑到 10 分钟 TURN_TIMEOUT_MS 才结束;
  ② 桥把模型那半句开场白当「本轮总结」发回(59 字);
  ③ 会话里留下**没有 toolResult 的 toolCall** → 下一轮 pi SDK 给它补一条
     `isError: true` 的「No result provided」→ 模型重试 bash → 人又被问一遍。

为什么之前全绿:`permission-note.test.mjs` 的 WIRING 钉的是
`decidedExtra.set(msg.relayKey)`(**备注=装饰**)与 `renderDecisionReason`,
**没有一条钉"唤醒"**。2026-09-13 那次修备注时把唤醒弄丢,判据照样全绿
—— 钉装饰不钉机制。

改:
- `resolve(msg.decision)` 补回,放在 `decidedExtra.set` **之后**(hook 醒来要读备注渲染
  拒绝理由,顺序反了会复现 2026-09-13 的「备注丢失 → 模型重复追问」)
- 判据:WIRING 补一条「必须唤醒」;另加 `checkDecisionBranch` **按标记切出决策分支正文**
  判"存在 + 归属 + 顺序"(不用"相距 N 字符"的窗口断言 —— 第一版就是那样假红的)
- 三种反面写法做变异自检:删掉 resolve / resolve 早于备注 / resolve 挪出分支,都必须红
- 全套 445 条通过

跨端核对:dsh 桥的 `pendingApprovals` 有 `pending.resolve(outcome)`(没这个问题);
opencode 走原生 permission 回复、zcode 走事件钩子 —— 这条路径只有 pi 桥有。
2026-09-14 19:16:22 +08:00
453f451fbb fix(permission): 人类的备注必须到达模型 + 决策回执不再被当成新任务
用户报的「很严重的问题」:被拒绝的 agent 看不到授权备注,且看不到他发的回复邮件。
按数据查到了两个**真缺陷**,都在桥的权限回路上(不是猜测,三层证据)。

## 缺陷一:备注在桥内被连丢三处

网关其实一路都带着备注(`CreateDecisionMail(..., req.Note)` 把备注写进决策邮件正文,
SSE payload 里也有 `"note"`),但桥的三个环节只传 decision:
  index.mjs  `pool.routePermission(relayKey, String(data.decision))`
  pool.mjs   `child.send({type:'permission_decision', relayKey, decision})`
  worker.mjs `resolve(String(msg.decision))`
模型最终看到的只有 `用户拒绝了这次 bash 调用`(pi 会话转录逐字可查)。

现场:人类写「我说了让你拉取仓库到program下你听不懂吗」,模型不知道要改什么,
把同一条命令换个写法又问了 —— 会话里连问 **9 次**(22:16–22:26)。

## 缺陷二:决策回执照样被当"新任务"投递 + 等人的邮件被堵在后面

决策是**双通道**送达:SSE `permission_decision`(唤醒停放的 worker)+ 一封普通形状的
邮件("Re: 权限请求 - 拒绝")。以前两条都会起动作 ⇒ 同一件事被处理两次;而这条会话
的新邮件在 worker 停放期间只能排队。实测:人类 22:18:08 发出的更正
「不对,不是让你拉取到agentmail仓库,是让你拉取到program仓库!!」
直到 22:26:30(worker 回合结束)才被模型看到 —— **8 分钟**里它一直在错误的目录上打转。
转录里那封更正确实是模型自己 `read_mail` 读到的(不是没人给它)。

## 改动

- 网关:`CreateDecisionMail` 写 `mail_type='permission_decision'` —— 桥据此区分
  「控制面回执」与「新任务」。
- pi 桥(新增 `lib/denial-reason.js`、`lib/waiting-mails.js`):
  · 备注随决策一路透传到**模型看到的拒绝理由**(工具拦截与通知投递两条路都带);
  · 恢复停放的 worker 时,顺带把「等人期间新到、尚未标记已读」的邮件附进理由,
    模型当场就能改道(这正是那 8 分钟的洞);
  · 决策回执不再起新任务轮次(记进 deliveredMails);若决策事件尚未到达,
    退化为 B-4.3 的通知投递,且没有会话时不凭空新开。

## 判据

- `test/permission-note.test.mjs`:11 条(备注进理由、无备注不得凭空造说明、
  等人期间的邮件要点名 read_inbox、只挑本会话非权限类未交付的、上限、旧回包缺
  session_id 不能丢邮件、接线 8 处形状、判据自检)。
- **扰动验证**:把备注从 `pool.mjs` 的 send 里去掉 → 接线判据 2 条红;恢复 → 11 绿。
- 既有 pi 套件 420/420;server 10 包全绿(新增 1 条 Go 判据验决策邮件的类型与备注正文)。

## 现场证据(可复核)

- 桥日志:9 次 `权限 <key> 决策 同意/拒绝(决策人 jianf)已转交 worker`,全程不含备注;
  「worker 2135211 等待权限决策,让出并发额度(停放 1/5)」
- 会话转录:`{"toolName":"bash","content":[{"text":"用户拒绝了这次 bash 调用"}]}` ×6
2026-09-13 22:47:25 +08:00