docs: 日历子系统 + 寻址发现工具组 + 权限死锁的排查记录
PHASE7-REMAINING 新增日历一节:三层分离的理由、修掉的六个真问题
(含每一个的现场取证与判据)、前端三个易错点、验证清单、未做的缺口。
写成「可核对的规格 + 踩坑理由」而不是叙事:这些 bug 的共同点是
**不报错**(模板烤死时间、{time} 渲染成 UTC、每 tick 重发、附件从未落盘、
TRIGGER 往返断开、PG schema 缺表),下一个人只有知道判据才能避开。
README 把插件工具表从「六个」更新到十一个(现在 homeagent 是十五个),
并说明寻址发现那一组解决的是**猜地址** —— 生产上真的发生过一个 Agent
猜了 opencode@/home,投递成功但那不是它的工作目录,那封邮件静默变成了
一条平行会话的开端。
PLUGIN-CONTRACT 补 B-8(权限询问转邮件)的判据表与三平台差异。
This commit is contained in:
@ -220,3 +220,138 @@ playwright 端到端 18 项全通过(含「底部导航六项都命中自己
|
||||
|
||||
DSH 还有个陷阱:`assistant/chunk` 的 `finish` 子类型也带错误,
|
||||
把任意 chunk 当成功会让无效 provider 判成走通(实测踩过)。
|
||||
|
||||
---
|
||||
|
||||
## 日历与待办 ✅ 后端 + 前端
|
||||
|
||||
参照 Outlook 的逻辑与 UI:事件/提醒/重复规则、iCal 导入导出、
|
||||
提醒经邮件通知指定 Agent(正文可编辑、支持变量预填充)、提醒可带附件。
|
||||
|
||||
### 三层分离
|
||||
|
||||
事件是日历实体,提醒是触发器,邮件是投递通道。三者刻意不合并:
|
||||
|
||||
- **事件**(`calendar_events`)有时间、重复规则、收件方
|
||||
- **提醒**由调度器在 `event_time − remind_before` 触发
|
||||
- **邮件**由 `SendCalendarMail` 投递,`from_name = "calendar"`
|
||||
|
||||
`from_name` 刻意既不是人类用户名也不是 Agent 名。用创建者的名字会让 Agent
|
||||
以为人在实时找它,而人此刻可能在睡觉 —— 模型据此判断「要不要马上追问」,
|
||||
来源写错会让它问一个不在线的人。
|
||||
|
||||
**日历提醒不扣会话预算**:预算的语义是「这件事值得模型自主发多少封信」,
|
||||
提醒是人预先设定的定时任务,不是模型的自主行为。让它扣预算会出现
|
||||
「每天 9 点的日报提醒把当天预算吃掉一格」这种反直觉结果。
|
||||
|
||||
### 已完成
|
||||
|
||||
**后端**
|
||||
- schema 两份(`calendar_events` + `calendar_attachments`,SQLite 与 PG)
|
||||
- `repo/calendar.go`:CRUD、`DueEvents`、`MarkEventFired`、`AdvanceRecurrence`、
|
||||
附件增删列、`AttachCalendarFilesToMail`
|
||||
- `handler/calendar.go`:9 端点 + iCal 导入导出
|
||||
- `scheduler/calendar.go`:30 秒 ticker、`RenderReminder`、`fireEvent`
|
||||
|
||||
**前端**
|
||||
- `lib/calendar.ts`:日期边界、月/周格子、分桶、模板渲染、`datetime-local` 往返
|
||||
- `CalendarView.tsx`:月/周/日三粒度 + 工具条 + iCal 导入导出按钮
|
||||
- `CalendarEventEditor.tsx`:整页编辑器 + 变量按钮 + 实时预览 + 附件区
|
||||
- 导航项(Sidebar + NarrowNav)+ `App.tsx` 路由
|
||||
|
||||
### 修掉的六个真问题
|
||||
|
||||
**1. 默认提醒模板把时间烤成字面值**
|
||||
原来 `Sprintf` 出一份含字面时间的正文存进 `reminder_text`。对重复事件是错的:
|
||||
`AdvanceRecurrence` 只推进 `event_time`,`reminder_text` 保持不动 ——
|
||||
「每天 9 点」的提醒从第二天起永远写着第一天的日期,且不报任何错。
|
||||
改为存变量形式(`{title}`/`{time}`/`{description}`),触发时才替换。
|
||||
后端 `defaultReminderTemplate` 与前端 `DEFAULT_TEMPLATE` 逐字一致,有测试钉住。
|
||||
|
||||
**2. `{time}` 渲染成 UTC**
|
||||
DSN 带 `_timezone=UTC`,从库里读回的 `EventTime` 是 UTC。直接 `Format`
|
||||
会把人在 +0800 输入的 14:30 写成 06:30,而前端预览用的是本地时间 ——
|
||||
两边差 8 小时且两边都不报错。修法:`e.EventTime.Local().Format(...)`。
|
||||
|
||||
**3. 同一提醒每个 tick 重发一次(生产实测 4 封)**
|
||||
`DueEvents` 有 60 秒 lookahead(调度周期 30 秒,不提前看会让提醒迟到)。
|
||||
去重判据原本是 `last_fired_at < event_time` —— 触发时刻(now)本来就早于
|
||||
落在窗口内的 `event_time`,条件恒真。实测一条 12:53:17 的事件在
|
||||
12:52:30 / 12:53:00 / 12:53:06 / 12:53:36 各发了一封。
|
||||
修法:新增 `fired_for` 列记录**已触发的 occurrence**(值 = 当时的 `event_time`),
|
||||
判据改为 `fired_for <> event_time`。`AdvanceRecurrence` 改了 `event_time`
|
||||
就重新到期,没改就永不重发。
|
||||
|
||||
**4. 附件从未落盘**
|
||||
`data := make([]byte, header.Size); file.Read(data)` 两处错:单次 `Read`
|
||||
不保证填满缓冲(大文件必然短读,sha256 因此算的是半截内容),而且
|
||||
**文件内容压根没写进 blob 存储**,只往库里写了一条元数据。
|
||||
结果是附件「上传成功」、清单里看得见、发提醒时取不到任何字节。
|
||||
改为走 `Blobs.Put`(与邮件附件同一套双层大小限制)。
|
||||
|
||||
**5. 事件附件不会随提醒发出**
|
||||
事件附件与邮件附件是两张表。缺了复制这一步,附件只存在于日历侧 ——
|
||||
UI 里看得见、提醒按时发出、而 Agent 收到的那封信附件清单是空的。
|
||||
新增 `AttachCalendarFilesToMail`:内容寻址下只增元数据不拷磁盘文件。
|
||||
失败不阻断投递 —— 提醒正文比附件重要,少一个附件比整条提醒发不出去好。
|
||||
|
||||
**6. iCal TRIGGER 往返是断的**
|
||||
导出写 `-P15M`、导入找 `-PT%dM`,自己导出的文件自己都读不回来。
|
||||
更糟的是 `-P15M` 在任何合规客户端里都是「提前 **15 个月**」——
|
||||
iCal duration 的 `M` 在 `T` 之前是月、之后才是分钟。
|
||||
而且原来用 `maxInt(RemindBefore, 15)` 兜底,把用户明确设的「到点提醒」(0)
|
||||
悄悄改成提前 15 分钟;导出不该修改语义。
|
||||
修法:导出写 `-PT<n>M`,导入换成正经的 duration 解析
|
||||
(`parseTriggerMinutes` 支持 `-PT30M`/`-PT1H30M`/`-P1D`/`-P1W`,
|
||||
T 前的 `M`(月)与正号 trigger 一律忽略而不是乱换算)。
|
||||
|
||||
### 其他修正
|
||||
|
||||
- **PG schema 整块缺失日历两张表** —— `DATABASE_URL` 一旦非空,所有
|
||||
`/calendar/*` 在 `relation does not exist` 上 500,而 SQLite 下一切正常,
|
||||
问题只在切外部库时才暴露
|
||||
- **`DeleteCalendarAttachment` 曾返回 501**,让人「删整个事件来清附件」——
|
||||
撤一个错传的文件不该要求把整条日程连提醒配置一起重建
|
||||
- **导出忽略 `from`/`to`** —— 写死 ±1 年会让人点导出后得到一堆与屏幕上不符的事件
|
||||
- **导入只接受 multipart** —— 命令行调用者收到含糊的「Missing file field」。
|
||||
改为同时接受 raw `text/calendar`(`curl --data-binary @x.ics`)
|
||||
- **上传附件不校验事件存在** —— 会攒下孤儿附件记录,而 `ON DELETE CASCADE`
|
||||
永远清不掉它们(没有父行可删)
|
||||
|
||||
### 前端的三个易错点(都有测试钉住)
|
||||
|
||||
**周首必须是周一**。`getDay()` 把周日算作 0,直接减它会让周日归到上一周
|
||||
末尾,月视图第一行整体错位。
|
||||
|
||||
**分桶用本地日期串而不是 `toISOString().slice(0,10)`**。后者给 UTC 日期,
|
||||
东八区晚上 8 点后的事件会被归到第二天的格子里。
|
||||
|
||||
**月视图固定 42 格**。按需 4~6 行会让网格高度随月份跳动,翻月时页面弹动。
|
||||
|
||||
另有一个 TS 陷阱:`replaceAll` 在 tsconfig 的 `target: ES2020` 下不存在
|
||||
(TS2550)。改用 `split/join`;**不能**退回 `replace`,那只换第一个,
|
||||
同一变量写两次时第二个会原样漏进邮件。
|
||||
|
||||
### 验证
|
||||
|
||||
- `internal/repo/calendar_test.go`:CRUD、到期判定、幂等、重复推进、区间查询、
|
||||
附件增删列、**lookahead 窗口内不重发**、**推进后重新到期**、
|
||||
**事件附件复制成邮件附件**(含空 sha256 脏数据跳过)
|
||||
- `internal/handler/ics_test.go` 14 例:解析基本形态/多事件/无 DTSTART 丢弃/
|
||||
重复规则/三种日期格式/带 TZID 参数/转义换行/LF 换行/垃圾输入/
|
||||
**TRIGGER duration 全形态**/**导出导入往返**/**默认模板必须是变量形式**
|
||||
- `internal/scheduler/calendar_test.go`:模板渲染 7 例(含**本地时区**与
|
||||
**同一时刻不同 Location 渲染一致**)
|
||||
- `web/test/components/calendar.test.tsx` 35 例
|
||||
- 生产端到端:建事件 → 30 秒内触发 → 邮件入库、正文变量已替换成本地时间 →
|
||||
重复事件推进到次日 → **连续两个 tick 零重发**(修复前每 tick 一封)→
|
||||
iCal 导入 `-PT45M`/`FREQ=WEEKLY` 正确落库 → 导出再导入闭环一致 →
|
||||
清理验证数据
|
||||
|
||||
### 未做
|
||||
|
||||
- 附件下载端点(日历侧):目前只能通过提醒邮件里的附件下载
|
||||
- `parseICS` 不处理折叠续行(RFC 5545 的 75 字节折行):长 DESCRIPTION
|
||||
会被截断。常见客户端导出的短字段不受影响
|
||||
- `RRULE` 只认 `FREQ=`,忽略 `INTERVAL`/`BYDAY`/`COUNT`/`UNTIL`
|
||||
- 周/日视图不按小时定位色块高度(事件都是等高行,不体现时长)
|
||||
|
||||
@ -16,6 +16,7 @@
|
||||
|---|---|---|---|
|
||||
| `plugins/opencode-mail-bridge/` | opencode | `@opencode-ai/plugin` | JavaScript |
|
||||
| `plugins/dsh-mail-bridge/` | DeepSeek Harness | Cordis | TypeScript |
|
||||
| `plugins/pi-mail-bridge/` | pi | pi SDK (`createAgentSession`) | JavaScript (ESM) |
|
||||
|
||||
第八、九节是这两次适配的实现细节与踩坑记录 —— 规范部分(一至七节)与平台无关。
|
||||
|
||||
@ -112,7 +113,7 @@ name@path.session
|
||||
|
||||
### 能力自检脚本
|
||||
|
||||
接入前先回答这七个问题。任何一个答不出来,先去读平台文档,不要开始写代码:
|
||||
接入前先回答这八个问题。任何一个答不出来,先去读平台文档,不要开始写代码:
|
||||
|
||||
```
|
||||
1. 我怎么在不通过 UI 的情况下让某个会话跑一轮? → C-1
|
||||
@ -330,14 +331,79 @@ SSE 只推连上之后的事件。插件重启前发来的邮件不会再推一
|
||||
| B-8.1 | `relay_key` 用平台的权限 id;平台不给 id 时用 `会话:工具:callId` 拼一个 | MUST |
|
||||
| B-8.2 | 转发失败 → 让位给平台本地 UI,不要占着钩子 | MUST |
|
||||
| B-8.3 | 同一次询问重复触发只产生一封邮件(服务端按 `relay_key` 幂等) | MUST |
|
||||
| B-8.4 | 邮件正文带足够上下文(工具名、参数摘要),让人能判断 | SHOULD |
|
||||
| B-8.4 | 邮件正文带足够上下文(工具名、参数摘要、**触发这次询问的任务与派活人**),让人能判断 | SHOULD |
|
||||
| B-8.5 | 权限询问**不消耗配额** | MUST |
|
||||
| B-8.6 | **不传 `to`** —— 决策人由服务端解析 | MUST |
|
||||
| B-8.7 | 平台支持「永久允许」时,选项里必须给出「一直同意」并**真的记住它** | MUST |
|
||||
| B-8.8 | 免批的作用域是 **(会话, 工具名)**;决策文本判定用 `lib/permission-grants.js` | MUST |
|
||||
|
||||
> **B-8.1 为什么必须是平台的 id**:服务端会随决策事件把 `relay_key` 回传,
|
||||
> 插件重启丢了内存映射也能对上(`B-4.2`)。自己生成的随机 id 重启后就对不上了。
|
||||
>
|
||||
> **B-8.5 为什么不收费**:人不点头 Agent 就动不了,对它收费等于收「求人费」。
|
||||
> 这里的 `relay_key` 只用于幂等,不是配额豁免的凭证。
|
||||
>
|
||||
> **B-8.6 为什么不能自己定决策人**:插件手边最自然的候选是「来信人」
|
||||
> (`mailContexts` 里的 `replyTo`),但来信人可能是**另一个 Agent** ——
|
||||
> Agent 把任务分派给自己或同伴的另一条会话时,权限邮件就发给了 Agent 自己。
|
||||
> 后果是**死锁而不是报错**:Agent 不可能在 Web 界面上点「同意」,服务端的
|
||||
> 用户推送又投进一个不存在的通道(没有任何人被提醒),于是插件里那个
|
||||
> `await` 永不 resolve —— 会话永久挂死,没有超时、没有日志、没有回信。
|
||||
>
|
||||
> 决策人必须由服务端定:它按 **会话 owner → 线索里最近的人类 → 无人可问则
|
||||
> 409** 解析,那是唯一能看到整条线索的地方。插件只有本地那点上下文,
|
||||
> 猜不出「这条 Agent 链最初是谁派的活」。
|
||||
>
|
||||
> 收到 409(整条链上没有人类)时按 `B-8.2` 处理 —— 服务端已经判定没人可问,
|
||||
> 继续等下去就是死锁。
|
||||
>
|
||||
> **B-8.4 为什么要带派活人**:决策人未必是这条会话的参与者。Agent 转派出来的
|
||||
> 会话,人从没见过它,只给一句「是否允许执行 bash」无从判断 —— 得知道这活是
|
||||
> 谁派的、为的什么事。
|
||||
|
||||
#### B-8.7 / B-8.8 免批(「一直同意」)
|
||||
|
||||
默认语义是**每次都问**。这在「跑一条命令看看」时是对的,在「审查这个工程」
|
||||
时是灾难 —— 实测同一条 pi 会话被问了 **15 次 bash**,人点了 15 次「同意」,
|
||||
全是同一类操作。
|
||||
|
||||
三个平台的原生能力不同,决定了免批状态该由谁持有:
|
||||
|
||||
| 平台 | 原生三态 | 免批由谁记 | 选项 |
|
||||
|---|---|---|---|
|
||||
| opencode | `once` / `always` / `reject` | **平台**(回 `response:"always"`) | 同意 / 一直同意 / 拒绝 |
|
||||
| pi | 只有 block / 放行 | **桥**(`createGrantStore()`) | 同意 / 一直同意 / 拒绝 |
|
||||
| DSH | `allowed-once` / `rejected`(无 always) | 不提供 | 同意 / 拒绝 |
|
||||
|
||||
> **平台能记就让平台记**:opencode 的 `always` 是它自己的权限模型的一部分,
|
||||
> 桥再存一份就有两个真相来源(`I-1`)。pi 的钩子只能答"拦/不拦",没有地方
|
||||
> 表达"以后别问了",这时桥持有状态是唯一选择。
|
||||
>
|
||||
> **DSH 为什么干脆不给这个选项**:它的 `ApprovalOutcome` 只有
|
||||
> `allowed-once | rejected | cancelled | unavailable`。桥自己记的话,DSH 侧
|
||||
> 仍会每次调 `approval/request`,而桥不问就答 `allowed-once` —— 那是用插件
|
||||
> 内存**覆盖**平台的审批策略,且这份策略没人能审计。给不出的能力就不要在
|
||||
> 界面上摆一个按钮(摆了又不生效比没有更糟,见下)。
|
||||
>
|
||||
> **作用域为什么是 (会话, 工具名)**:人看到的那句「是否允许执行 bash?」
|
||||
> 就是在这个粒度上提的问,授权范围不该超出提问范围。
|
||||
> - 放宽到全局 → 人为「审查 llmsproxy」批准的 bash,会静默授权另一个
|
||||
> 发件人派来的另一条任务。那不是他批准的东西。
|
||||
> - 收紧到 `toolCallId` → 等于没有免批。
|
||||
>
|
||||
> **换模型重开会话必须撤销**(`revokeSession`):授权是人对**那次**上下文的
|
||||
> 判断,新会话重跑一遍提示,不该继承上一条的授权。
|
||||
>
|
||||
> **只在内存里是有意的**:长期免批该由平台自己的 settings 管
|
||||
> (pi 的 `settings.json`、opencode 的 permission 配置)。让守护进程的内存
|
||||
> 变成事实上的安全策略,没人能审计,重启后又悄悄消失。
|
||||
>
|
||||
> **判定为什么必须用共用模块**:`isAlwaysDecision` 用的是**精确匹配**。
|
||||
> 写成 `/^同意/` 之类的前缀正则会让「同意」也判成 always ——
|
||||
> 人点一次单次授权,后面所有命令都不再问,这是把单次授权静默升级成永久授权。
|
||||
> 反向的坑同样真实:opencode 侧的三态映射一度以 `: "once"` 兜底,
|
||||
> 于是任何意外文本(空串、旧选项、服务端将来新增的选项)都会**放行**一次
|
||||
> 没人批准的操作 —— 兜底必须落在 `reject` 一侧(`N-9`)。
|
||||
|
||||
### B-9 关停(MUST)
|
||||
|
||||
@ -365,6 +431,17 @@ SSE 只推连上之后的事件。插件重启前发来的邮件不会再推一
|
||||
| **T-5** | `forward_mail` | MAY | 转发(走完整三维寻址,它是一条新线索) |
|
||||
| **T-6** | `connect_to_server` | MAY | 登记密钥并注册,方便首次接入 |
|
||||
| **T-7** | ~~`request_permission`~~ | **MUST NOT** | 见 `I-1`:改挂平台权限钩子 |
|
||||
| **T-8** | `suggest_address` | SHOULD | 查可用收件人/工作目录/会话;**发信前应先调** |
|
||||
| **T-9** | `list_contacts` | SHOULD | 列出自己参与过的全部会话及可投递地址 |
|
||||
| **T-10** | `session_participants` | MAY | 列出某条会话的参与方(用于回给抄送方或转达) |
|
||||
| **T-11** | `read_thread` | MAY | 查看某封邮件所在线索的完整往来 |
|
||||
| **T-12** | `read_mail` | SHOULD | 读一封邮件的完整内容(含参与方地址与 reply_address) |
|
||||
| **T-13** | `propose_alias`(T-2 参数) | MAY | 模型在 `send_mail` 的 `propose_alias` 字段提议改名 |
|
||||
|
||||
> **为什么 T-8 到 T-12 都是 SHOULD/MAY 而不是 MUST**:它们解决的是静默投递错误
|
||||
> (`suggest_address`)和信息不足(`list_contacts` / `read_mail` / `read_thread`),
|
||||
> 但都不影响主链路(收信 → 起会话 → 自动回信)。一个最小可行插件只注册 T-1 和 T-2
|
||||
> 就能跑通主链路,上面这些是让它「不猜地址、不漏抄送方」的增强。
|
||||
|
||||
### T-1 `read_inbox` 的三条硬规则
|
||||
|
||||
@ -386,6 +463,52 @@ SSE 只推连上之后的事件。插件重启前发来的邮件不会再推一
|
||||
模型可能忘了调(危险操作直接执行),也可能在不需要时乱调(每一步都问人)。
|
||||
平台的权限钩子拦下的那一次是事实。见 `I-1`、`B-8`。
|
||||
|
||||
### T-8 ~ T-12 寻址发现工具(`lib/discovery.js`)
|
||||
|
||||
这五个工具的渲染逻辑全部在 `lib/discovery.js`(三平台共用),与平台 SDK 无关。
|
||||
它们解决的是**静默投递错误**:模型猜一个地址(如 `opencode@/home`),投递成功
|
||||
但那不是 opencode 的工作目录,静默变成了一条平行会话 —— 发件人以为在续谈,其实
|
||||
在跟一条空会话说话。`suggest_address` 用模型从候选列表里选而不是猜,彻底杜绝了
|
||||
这种错误。
|
||||
|
||||
| # | 工具 | 渲染函数 | 服务端端点 |
|
||||
|---|---|---|---|
|
||||
| T-8 | `suggest_address` | `renderNameSuggestions` / `renderPathSuggestions` / `renderSessionSuggestions` | `GET /agent/contacts/suggest` |
|
||||
| T-9 | `list_contacts` | `renderContacts` | `GET /agent/contacts` |
|
||||
| T-10 | `session_participants` | `renderParticipants` | `GET /agent/sessions/{id}/participants` |
|
||||
| T-11 | `read_thread` | `renderThread` | `GET /agent/mail/{id}/thread` |
|
||||
| T-12 | `read_mail` | 内联渲染(含参与方地址与 reply_address) | `GET /agent/mail/{id}` |
|
||||
|
||||
> **`read_mail` 的 `reply_address` 必须回显**:它告诉模型「发件人的可投递地址是什么」。
|
||||
> 不回显的话模型只能用原始地址续谈,但那个地址的 session 位可能是默认的,回过去
|
||||
> 不一定落到同一条线索里。
|
||||
|
||||
### T-13 `propose_alias`(T-2 `send_mail` 的可选参数)
|
||||
|
||||
`propose_alias` 让模型在干完活后提议一个更贴切的会话别名(例如从邮件主题
|
||||
`排查登录问题` 改成更精确的 `fix-session-cookie-leak`)。
|
||||
|
||||
| # | 规则 | 违反后果 |
|
||||
|---|---|---|
|
||||
| T-13.1 | 标记格式是 HTML 注释 `<!-- agentmail:rename-session alias="x" reason="y" -->` | 服务端正则匹配不上,提议静默消失 |
|
||||
| T-13.2 | 标记必须由**共用库** `lib/rename-proposal.js` 构造 | 各平台各写一遍拼接,少个空格就失效 |
|
||||
| T-13.3 | 别名不合法时不追加标记(`isProposableAlias` 返回 false) | 发一个服务端匹配得上却校验失败的标记,白白浪费一轮 |
|
||||
| T-13.4 | 回显**服务端返回的** `rename_proposed`,不是本地提议的值 | 服务端跑过 `normalizeAlias`(非法字符换 `-`、`new` 变 `session-new`),回显本地值会让模型拿一个不存在的名字寻址 |
|
||||
| T-13.5 | 回显时必须说明「等人确认,生效前继续用原别名」 | 模型以为改名已生效,接着用新别名当地址发信 —— 那个别名此刻还不存在 |
|
||||
|
||||
### T-3 / T-4 附件参数命名差异
|
||||
|
||||
两个共享参数在三个平台的工具里名称不一致(历史原因,已锁定):
|
||||
|
||||
| 参数语义 | opencode | dsh / pi |
|
||||
|---|---|---|
|
||||
| 要上传的本地文件路径 | `path` | `file_path` |
|
||||
| 下载后的保存路径 | `save_to` | `save_path` |
|
||||
| 自定义展示文件名 | `filename` | `filename`(新增) |
|
||||
|
||||
这些是**工具参数**,不是 API 字段,因此与服务端协议无关。但新插件实现时应参照
|
||||
自己所在平台已有插件的命名,避免同一平台内两个工具参数风格不一致。
|
||||
|
||||
---
|
||||
|
||||
## 四、降级语义 / Degradation
|
||||
@ -810,6 +933,7 @@ GET /api/v1/attachments/{id}
|
||||
| **N-10** | 修改模型输出的文本内容 | `I-4`:插件只搬运 |
|
||||
| **N-11** | 首次 SSE 连接带 `Last-Event-ID` | 会重投历史邮件 |
|
||||
| **N-12** | 让共用模块依赖平台 SDK | 那是它们能跨平台共用的前提 |
|
||||
| **N-13** | 给 `/permission/request` 传 `to`(尤其是来信人) | 来信人可能是 Agent,权限邮件发给它必然死锁(`B-8.6`) |
|
||||
|
||||
### 共用模块必须逐字节相同
|
||||
|
||||
@ -824,6 +948,10 @@ GET /api/v1/attachments/{id}
|
||||
| `workspace.js` | 寻址 path 位 → 可用的 cwd |
|
||||
| `model-scope.js` | 模型目录整理 + 降级顺序 + 失败报告 |
|
||||
| `catchup.js` | 离线期间积压邮件的补投选择 |
|
||||
| `addressing.js` | 三维地址的拆分与校验 |
|
||||
| `discovery.js` | 寻址发现工具的渲染(`T-8`~`T-12`) |
|
||||
| `rename-proposal.js` | 会话改名标记的构造与回执文案(`T-13`) |
|
||||
| `permission-grants.js` | 权限决策文本判定 + 免批授权表(`B-8.7` / `B-8.8`) |
|
||||
|
||||
> **为什么必须逐字节相同而不是「行为一致」**:一侧改了另一侧没改,两个平台的行为
|
||||
> 会悄悄分叉 —— 同一封邮件在 A 平台标了已读、在 B 平台没标,而两处代码看起来都
|
||||
@ -914,6 +1042,15 @@ GET /api/v1/attachments/{id}
|
||||
|
||||
[ ] 模型主动调 send_mail 回信的那一轮
|
||||
→ 只有一封邮件,没有额外的自动转发(B-5.3)
|
||||
|
||||
[ ] 模型带 propose_alias 发信(T-13)
|
||||
→ 入库正文里**没有** agentmail:rename-session 标记(已被剥掉)
|
||||
→ mails.rename_alias / rename_reason 记下了提议
|
||||
→ GET /sessions/{id}/rename-proposal 返回该提议
|
||||
→ 人接受(PUT /sessions/{id}/alias)后别名真的改了,alias_source 变 manual
|
||||
→ 接受后 rename-proposal 返回 null(提示条不再反复弹)
|
||||
→ 新别名可寻址:<name>@<目录>.<新别名> 落进同一条会话
|
||||
→ 桥的后续命名同步**不覆盖** manual 别名
|
||||
```
|
||||
|
||||
### 7.4 工作目录
|
||||
@ -956,6 +1093,25 @@ GET /api/v1/attachments/{id}
|
||||
→ 平台侧那次工具调用继续执行(D-2 / B-4.1)
|
||||
[ ] 重复触发同一次询问(事件重放)
|
||||
→ 只产生一封邮件(W-6 幂等)
|
||||
|
||||
[ ] **Agent 自己给自己派活的会话里触发权限询问**(B-8.6 / N-13)
|
||||
造:让 Agent 用 send_mail 发给自己 <name>@<目录>.new,正文要求它跑 bash
|
||||
→ 权限邮件的 to_name 是**人**(会话 owner 或线索里最近的人类),不是 Agent
|
||||
sqlite3 "SELECT to_name FROM mails WHERE mail_type='permission_request' …"
|
||||
→ 正文里带得出「触发任务」与「任务来自」(B-8.4)
|
||||
→ 整条链上确实没有人类时:服务端返回 409,插件让位给本地 UI,
|
||||
**不是**无声挂起(日志里要能看到让位那一行)
|
||||
|
||||
[ ] **「一直同意」真的免批**(B-8.7 / B-8.8)
|
||||
造:一封信里要求连续三次单独调用 bash
|
||||
→ 第一次弹权限,点「一直同意」
|
||||
→ 后两次**不再产生邮件**:
|
||||
sqlite3 "SELECT COUNT(*) FROM permission_requests WHERE session_id='…'"
|
||||
→ 应为 1,不是 3
|
||||
→ 换个工具(write)仍会问一次 —— 授权不跨工具
|
||||
→ 另一条会话的 bash 仍会问 —— 授权不跨会话
|
||||
反例(修复前的真实数据):同一条会话 15 封 permission_request,
|
||||
result 全是「同意」,界面上那个「一直同意」按钮点了等于没点
|
||||
```
|
||||
|
||||
### 7.7 上报
|
||||
@ -1001,6 +1157,10 @@ GET /api/v1/attachments/{id}
|
||||
| 模型目录 | `client.config.providers()`(`models` 是**对象**) | `ctx.llm.listProviders()` + `listModels()` | `modelRuntime.getAvailable()`(**不是** `getModels()`:1221 条里只有 1 条能用) |
|
||||
| 别名来源 | `session.slug`(创建时就有) | 模型标题派生 | **邮件主题派生**(SDK 会话没有平台标题,见下) |
|
||||
| 日志可见性 | `console.error` | `console.error`(`ctx.logger` 不进 journalctl) | `console.error` |
|
||||
| 工作区分组 | `session.create({directory})` 自带 | `ctx.get('workspaceRegistry')` → `create(cwd)` + `attachSession()` | 按 cwd 自动分目录(`~/.pi/agent/sessions/--tmp-x--/`),无需注册 |
|
||||
| 工具参数命名 | `path` / `save_to` | `file_path` / `save_path` | `file_path` / `save_path` |
|
||||
| 权限三态 | 原生 `once`/`always`/`reject` | **只有** `allowed-once`/`rejected` | 只有 block/放行 |
|
||||
| 「一直同意」 | 给,平台自己记 | **不给**(表达不了,桥代劳会覆盖平台策略) | 给,桥用 `createGrantStore()` 记 |
|
||||
|
||||
> **pi 为什么是守护进程而不是扩展**:pi 扩展被加载进**一条已经存在的**会话,
|
||||
> 那条会话的 cwd 由启动 pi 的人决定。而 `B-3.1` 要求每封邮件的 `to_workspace`
|
||||
@ -1163,6 +1323,83 @@ socat 被 `Requires` 带停后再没起来。加 `PartOf` 后又发现 `start`
|
||||
同理不要用 `list(cwd)`:桥的进程 cwd 与会话 cwd 无关,按前者过滤会漏掉
|
||||
所有真正在干活的会话。
|
||||
|
||||
### 9.14 改名提议的标记拼错是**静默失败**
|
||||
|
||||
`<!-- agentmail:rename-session alias="x" reason="y" -->` 由服务端正则解析
|
||||
(`gateway/internal/handler/rename_proposal.go`)。少个空格、把双引号写成单引号、
|
||||
或把 `reason=""` 写成空属性 —— 邮件照常发出,提议凭空消失,而模型以为自己提过了,
|
||||
在后续对话里当作已完成的事引用。
|
||||
|
||||
> 这就是为什么拼接必须收在共用库 `lib/rename-proposal.js`(`T-13.2`):
|
||||
> 三平台各写一遍的话,任一处的偏差都不会有人发现。
|
||||
> `test/rename-proposal.test.mjs` 里内置了一份服务端正则的等价实现来验证生成结果。
|
||||
|
||||
### 9.15 换 Gateway 地址后必须清 `lastEventID` 并重连 SSE
|
||||
|
||||
守护进程形态的插件(pi)提供 `connect_to_server` 时有个额外义务:改完
|
||||
`baseURL` 要**重建 SSE 长连**,因为旧长连仍连着旧地址。
|
||||
|
||||
更微妙的是断点:`lastEventID` 是**旧** Gateway 环形缓冲里的序号,拿它去问新
|
||||
Gateway 会命中一段完全无关的历史(或被拒),收到的事件属于别人的会话。
|
||||
`GatewayClient.reconfigure()` 在地址变化时清空它,于是重连以「首次连接」姿态
|
||||
(不带 `Last-Event-ID`,见 `N-11`)进行。
|
||||
|
||||
插件形态(opencode/dsh)没有这个问题:它们的 SSE 由宿主生命周期管,
|
||||
`connect_to_server` 只改配置,下次宿主重连时自然用新值。
|
||||
|
||||
### 9.16 把来信人当权限决策人 → 会话永久挂死
|
||||
|
||||
插件手边最自然的决策人候选是「来信人」(`mailContexts` 里的 `replyTo`),
|
||||
pi 侧一度就是这么传的。它在人给 Agent 派活时看起来完全正确,直到
|
||||
**Agent 给 Agent 派活**:
|
||||
|
||||
```
|
||||
jianf → pi(会话 A) pi 把子任务分给自己(会话 B)
|
||||
会话 B 触发 bash 权限询问
|
||||
replyTo = "pi" → 权限邮件发给 pi 自己
|
||||
```
|
||||
|
||||
后果不是报错而是**死锁**,而且是三重静默:
|
||||
|
||||
1. Agent 不可能在 Web 界面上点「同意」
|
||||
2. 服务端 `SendToUser("pi")` 投进一个不存在的用户通道 —— 没有任何人被提醒
|
||||
3. 插件里 `await new Promise(...)` 永不 resolve —— 没有超时、没有日志、没有回信
|
||||
|
||||
会话就那么停在那里,从外面看像「模型在思考」。实测发生过:两条会话排了任务,
|
||||
只有一条在干活,另一条的最后动静是一封 `to_name=pi` 的 `permission_request`。
|
||||
|
||||
> 修法见 `B-8.6` / `N-13`:**不传 `to`**,决策人由服务端沿会话上溯解析。
|
||||
> 服务端是唯一能看到整条线索的地方;插件只有本地那点上下文,猜不出
|
||||
> 「这条 Agent 链最初是谁派的活」。
|
||||
>
|
||||
> 排查这类问题的第一个动作:
|
||||
> `SELECT to_name FROM mails WHERE mail_type='permission_request'` ——
|
||||
> 出现 Agent 名就是它。
|
||||
|
||||
### 9.17 摆一个「一直同意」按钮却不实现它
|
||||
|
||||
比不给这个选项更糟的是给了但不生效。pi 侧一度在 `options` 里放了
|
||||
`['同意','一直同意','拒绝']`,放行正则也认 `一直同意` ——
|
||||
但**没有任何地方记住它**,所以点完下一条命令照样来一封邮件。
|
||||
|
||||
生产数据(修复前):
|
||||
|
||||
```
|
||||
同一条 pi 会话被问了 15 次 bash
|
||||
permission_requests.result 全是「同意」—— 一次「一直同意」都没有
|
||||
```
|
||||
|
||||
人不是不想点,是点了发现没用,于是退回去一条一条点「同意」。
|
||||
|
||||
> 判断一个平台该不该提供这个选项,看它的钩子能不能表达"以后别问了":
|
||||
> - opencode 能(`response:"always"`)→ 给,且让平台记
|
||||
> - pi 不能(钩子只回 block/放行)→ 给,桥自己记(`B-8.8`)
|
||||
> - DSH 不能且不该由桥代劳(会覆盖平台审批策略)→ 不给
|
||||
>
|
||||
> 自查:在 `options` 里加任何选项之前,先在代码里搜一遍这个选项的文本,
|
||||
> 看它除了"被展示"之外还出现在哪里。只出现在 `options` 和一条放行正则里,
|
||||
> 就是这个坑。
|
||||
|
||||
---
|
||||
|
||||
## 附:文档关系
|
||||
|
||||
Reference in New Issue
Block a user