|
|
d1099526ad
|
fix(寻址): flatten 的候选**逐条**标注 —— 第一版把 §C 噪声放进了新端点
## 缺口(部署后实测才发现,是我自己引入的)
第一版 flatten 只在响应的 `paths[]` 数组里标注。实测:
222 条候选,其中 37 条(16%)落在桥内部目录(/root/.pi/mail-sessions/<uuid>)
而标注在**另一个数组** —— 模型必须自己把 candidates 与 paths 对照才认得出
那正是「§C 噪声淹没信号」换个位置复活。我在动手前的判断是「先修 C 再修 A,
否则新端点会把噪声一起放大」—— 做了 A,却让 C 的噪声原样跟进了 A。
只在真机跑过 `flatten=1` 才看见:单测全绿(它们只断言了 paths[] 有标注),
是生产数据的 16% 把它翻出来的。
## 修法
`AddressedCandidate` 逐候选带 `path_kind` / `path_note` / `is_absolute_path`,
MCP 渲染逐条打 `⚠`。
marker 收敛到 repo 层一份,handler 的 `classifyPath` 改为委托调用:
同一目录在 path 列表里标成「工作区」、在候选列表里却没标 ——
而那两个数组是**同一次调用**返回的。两处各写一份 marker 时,
改一处忘另一处就会出现这种自相矛盾,且没有任何报错。
## 判据(2 格)
TestFlattenAnnotatesEachCandidate 桥内部目录/相对路径能分类 + 带说明;
真工作区不得被误标(否则全是噪声)
TestClassifyPathAgreesWithRepo handler 与 repo 口径必须逐条一致
## 顺带
第一版 flatten 本身已验证有效(生产实测):
flatten=1 → 222 条候选、66 个工作区
/home/program/agentmail 125 条 · /root 16 条 · root 2 条
⇒ root 与 /root **同时可见**且各自带 path,不再需要「先猜 path 再枚举」
path 标注:66 条候选里 35 条桥内部目录 + 1 条相对路径被标出
|
2026-10-02 16:06:00 +08:00 |
|
|
|
1b810a4898
|
fix(寻址)★★: 补「按 name 直出全部可投递地址」+ 标注 path 候选里的坑
## 起因
DSH 侧 Agent 报了一份寻址缺口(2026-10-02,全部结论有 API 实测复现)。
三段式寻址 `name@path.session` 里 session 段是**人的寻址入口**,而枚举它
必须先知道 path —— 但 path 恰恰是调用方无从得知的:
给 name → 只给 path(要再调一次才知道有哪些会话)
给 name+path → 给会话别名(但 path 得先猜对)
于是一个闭合的环。报告实测的踩坑:投 `pi@root` 返回 **200**,落进一条标题
为「拓展坞实测硬件正常…」的无关会话 —— 投递成功,所以调用方不知道自己投错了。
## 修法
**① A 项:`flatten=1` 一次给出全部可投递地址**
`SuggestAddressesForPeer` + `suggest?name=&flatten=1`。每个候选自带
`path` 与可直接塞进 send_mail 的 `address` —— 调用方不必自己拼,
拼错就是那个「猜错比报错更糟」。
可见性口径**不放宽**,与原 name+path 那一支逐条一致(「我参与过 + 与该 name
匹配」)。报告本身也确认问题不在权限:同一批数据给了 path 就能列出 17 条。
按 path 分组平铺而非嵌套:嵌套时调用方要发一封「不知道在哪个 path」的信
仍得遍历全部组;平铺一次给全,模型不必做「先猜 path 再枚举」两步。
**② B/C 项:标注而非隐藏**
`paths[]` 每项带 `kind`(workspace / bridge-internal)与 `is_absolute`。
选标注不选过滤的理由:桥内部目录(`/root/.pi/mail-sessions/<uuid>`)
确实**是某些会话的真实 cwd**(实测那条 workspace='root' 的会话 uuid 正是
其中之一)—— 滤掉等于让那些会话彻底不可见;而留着不标,64 条候选里 33 条
是噪声,模型选中即静默投错(实测 64 条中 33 条是它)。
`suggestions` 保持原样与原顺序 —— SuggestPaths 按最近使用倒序
(刚用过的那个几乎总是下一封想用的),排序被打乱等于让模型取最老的那个。
## ★★ 顺带修掉一个生产级缺陷(实测撞出来的)
给 `SessionCandidate` 加 `LastActivity` 时用了:
COALESCE(s.updated_at, '0001-01-01 00:00:00+00')
COALESCE 让驱动返回 **string**,扫进 time.Time 报 `unsupported Scan`
⇒ 命中 `return out, err` ⇒ **整个候选列表变空**(实测一条都列不出)。
生产影响:`updated_at` 为 NULL 的历史会话会全部静默消失。
而那个错误信息里**没有任何线索**指向「是你加的 COALESCE 害的」——
本次是我自己加的列触发的,排查花了几步。
改为扫进 `sql.NullTime`(NULL 即零值),平台镜像那条同理。
注释里写明为什么不能 COALESCE 兜底,免得下次有人再加回去。
## MCP 侧同步
`suggest_address` 加 `flatten` 参数,且**渲染必须单独写**:
flatten 的响应没有 `suggestions` 字段,走原来的分支只会回一句
「(没有 session_flat 建议)」—— 模型拿不到任何地址,等于白问一次。
path 形状的渲染把两类坑直接顶到眼前:桥内部目录、相对路径
(`root` 与 `/root` 在数据里是两个不同工作区,实测 1 条 vs 17 条)。
## 判据(8 格)
含「address 必须与候选自身 path/alias 一致」(那正是静默投错的解药)、
「两个工作区都要出现」(原形状缺的就是这一维)、
「不带 flatten 时行为一字未变」(各桥与 WebUI 都走那一支)、
「flatten 不得把 new 混在候选里」(没有真实会话时它看起来像出路)。
**变异验证**:
COALESCE 兜底(那个真 bug) → 红 1 ✓
flatten 段放回 path=="" 之后(顺序 bug)→ 红 1 ✓(kind 变回 "path")
## 实测校准了一处报告里的数字
报告写「近似写法返回 0 条」,实测返回 **1 条,内容是 `new`** ——
服务端在任何 path 下都追加的新建占位。所以选错 path 时调用方看到的不是
「空」,而是「只有 new 可选」:**看起来像一条出路**,于是顺着它新建,
恰好落进猜错的那个工作区。比报 0 更危险(0 会让人停下,new 会让人继续)。
§E 无需修:`validateSessionAlias` 已拒绝别名含 `.`。
全量 14 包绿。
|
2026-10-02 15:59:56 +08:00 |
|
|
|
477b74230f
|
fix(限流): 用 ORDER BY ts 取窗口内最早一条,不靠 MIN(ts) —— 429 的 retry_after 恒为 60
## 缺陷
`SELECT MIN(ts) FROM rate_limits ...` 的聚合结果被 SQLite 驱动按 **string**
返回,扫进 `*time.Time` 失败 ⇒ 落到兜底 `return false, 60`。
⇒ 所有 429 的 `retry_after` 恒为 60,与真实剩余窗口
(最长 `sessionRateWindow` = 1h)完全无关。调用方拿到的重试提示是错的:
限流窗口还有 55 分钟,它却说 60 秒后重试。
## 修法
`SELECT ts FROM rate_limits WHERE bucket = $1 AND ts >= $2 ORDER BY ts ASC LIMIT 1`
排序取值走**结果集本身**,驱动按列类型给 `time.Time`;语义等价。
★ 同一形状的坑今天已出现两次:上午 2h 冷静期因 UTC vs HKT 差 8 小时而形同虚设,
晚上权限记账因两处 `if` 守卫而静默失效。**根子都是「SQLite 侧的时间/类型处理
与直觉不符」,而症状在别处。**
## 判据
4 格(retry_after 反映真实窗口 / 绝不超过 window / 窗口滚动后放行 /
只数窗口内的记录),其中主判据显式对比「修复前 60,修复后 ≈window」。
本改动此前已随 2026-10-01 的两次部署进入线上二进制(vcs.modified=true),
本次补提交以让 provenance 对得上。
|
2026-10-02 10:02:35 +08:00 |
|
|
|
de6b91516a
|
feat(默认会话): 非邮件轮次用 /tmp 默认会话作合法 session_id —— 配套 15e4fe9 的收严
`15e4fe9` 让未声明 session_id 的读信一律 403,而 homeagent 的工具**全局可调** ⇒
对话里自主调 read_mail/read_thread 时 `currentSessionID` 为空 ⇒ 403。
不能因此让「非邮件轮次读信」这个能力消失(它是 10-01 那个 read_inbox 修复的
用户可见部分),所以给它一个合法声明。
## 关键约束:workspace 能回落 cwd,session_id 不能
`session_id` 是 AgentMail 会话的 UUID,进程 cwd 给不出它 ⇒ 只能问服务端。
落点选 `/tmp`:非邮件轮次没有真实工作目录,而 /tmp 是中性落点(不属于任何真实
项目,不会把项目邮件混进来),且满足 `UnreadWorkspaces` 的 `workspace LIKE '/%'`
(能被寻址补投)。
## 服务端:`GET /api/v1/agent/session/default`
**复用**已有的默认会话语义(`FindOrCreateDefaultSession`,8 个测试覆盖),
只把它开放成可查询形状 —— 不新造概念。
★ 第一版调 `FindOrCreateDefaultSessionCreated`,判据当场报**每次都新建**
(连问两次得到两个不同 UUID)。根因:那个函数的复用条件含
`EXISTS (SELECT 1 FROM mails …)`,空会话不满足 ⇒ 永远「没找到可复用」。
改「先查后建」仍不够。想深一层:**根本不该建** —— 非邮件轮次若 `name@/tmp`
一封都没通过,收件箱本来就该是空的,不需要一条 id 才能表达「空」。
⇒ 改成**纯只读**:没通信过就返回 `session_id: null`。
GET 有副作用是坏味道,它会被桥每轮调一次。
同时把匹配 SQL 抽成 `defaultSessionMatchSQL` 共享常量:`FindExisting` 与
`FindOrCreate` 必须给出**同一个**答案,否则「查到的默认会话」与「发信落进去的
会话」会静默分叉(各写一份 SQL 的话,改一边不会红)。
## 桥(homeagent):effectiveSessionID = 信封 → 默认会话
⚠ 取值函数**不发请求**。我第一版把 HTTP 塞进 `effectiveSessionID`,
`&Plugin{}` 构造的测试当场 nil panic,且 scopeQuery 变成「拼 URL 时顺带发请求」。
IO 移到装配期 `register()` 里的 `ensureDefaultSession()`。
⚠ `client == nil` 时**不标记已问** —— 那不是「答案是空」而是「还没资格问」,
标了会永久缓存空值。而 register() 里就会调它,真的会在插件加载阶段崩。
## 判据
服务端 6 格(含★「不是万能钥匙」:拿默认会话 id 去读别人的会话仍须 403 ——
少了这格,这个端点就是「声明一个合法会话然后读遍全场」的后门)。
homeagent 6 格。
三个变异各红 1 格:退回旧的整体放弃 / 未就绪也标记 / 默认落点与服务端不一致。
## 未改:pi / dsh / opencode
实测它们的裸奔已停止(pi 自 Sep 26、opencode 自 Sep 28,`[agent-scope]` 日志归零),
`getMailSessionId` 由 worker 闭包注入且只有一处装配点。dsh 待单独核。
|
2026-10-02 01:09:19 +08:00 |
|
|
|
2fd18ba134
|
fix(回路): 人类经 /me/mail/send 插话也要解锁 —— 端到端实测抓到的缺口
上一提交(4d8165f)的 403 文案写着「若要立刻恢复,请由人类在会话里插一句话」,
但**那句话是假的**。
## 实测证据
拿真实用户登录态经 `POST /api/v1/me/mail/send` 在被锁会话里插话:
HTTP 200 {"mail_id":"68eda0c3-…"} ← 人类的信进去了(这是对的)
session_agent_locks 锁行数: 1 ← ★ 锁还在
`MeSendMail` 有自己的 resolve + CreateMail,**根本不经过 Agent 侧那道闸**
(main.go 里 UserAuth 组下单独注册)。所以解锁只写在 `SendMail` 里是不够的:
人类插了话,锁仍要等满 2 小时。
## 为什么单测没抓到
`TestHumanPostClearsCooldownImmediately` 走的是 Agent 侧 handler,
证明了「解锁逻辑本身对」,却没证明「人类实际会走的那条路也解锁」。
判据钉的是 A 路径、生产走的是 B 路径 —— 这个形状本仓已遇到三次
(opencode 的 withScope、homeagent 的 InjectInputSync、这次)。
## 修法
把解锁放在 `SetSessionOwner` 旁边 —— 那是「人类参与这条会话」的权威落点,
比在每个调用点各写一遍可靠(漏一处就又是一句假承诺)。
解锁失败只记日志不阻断:人的来信优先入库,代价只是那把锁到期自消(保守方向)。
判据:新增 `TestHumanSendPathAlsoClearsCooldown`,走真实 `middleware.UserAuth`
+ 真实 user_sessions 行。变异(撤掉解锁)后该格变红。
踩到的两个测试夹具坑(都记在判据注释里):
- 直接调 handler 会 401 —— 生产上这条路由在 UserAuth 中间件后面
- UserAuth → SessionToken 读 config.C.CookieName,而测试里 config.C 是 nil ⇒ panic
|
2026-10-01 20:19:47 +08:00 |
|
|
|
4d8165fde9
|
fix(回路): Agent↔Agent 加 2h 冷静期;冷却期内 relay 不自动重投递
缺口:maxAgentPingPong=8 撞闸后**计数永不回落** —— 只有人类插话才归零。
旧文案把出路指向「请由人类插一句话」,而那条线索上常常**根本没有人类**
(2026-10-01 报告:agent 一封都发不出,且那条线索无人在场)。
修法(两条要求分别落地):
① 2h 恢复机制:撞闸即写入 session_agent_locks(落库,内存态一重启就"恢复",
且多副本各算各的);到期自动放行,人类插话立刻解锁(优先于到期)。
② 锁定期间的邮件不自动重投递:冷却期内 relay 直接 403 丢弃 ——
**不排队、不占幂等键、不入库**。排队会在 2h 后一次性灌回去,
那等于把刚压住的回路换个更糟的形状放出来。
实测踩到的三个坑(都被判据抓住):
- `VALUES ($1,$2,$2,...)` 让 until_at 复用 locked_at 的 $2 ⇒ 锁诞生即过期
- driver 以 UTC 扫回 DATETIME,而 time.Now() 是本地(HKT+8) ⇒ 差 8h > 2h 的一半 ⇒ 锁形同虚设
- 只查**收件方**是否人类,漏了**发件方** —— 而「人类插话解锁」的常态就是
人回信、收件方仍是 Agent ⇒ 人插话反被自己写的闸 403 拦下,那条出路根本不存在
- SQLite 没有 GREATEST;在 DATETIME 上按**字符串**比大小 ⇒ CASE 也会错。
改为「已有锁一律不碰 until_at」。
判据:repo 6 格(含"读失败不得读成未锁定"—— 最初 0 格能抓,变异测试补的)
+ handler 4 格。三个变异全部经得起(且每��都先确认变异编译通过再数红格 ——
本轮多次 grep 得 0 实际是 build failed,测试压根没跑)。
|
2026-10-01 19:41:06 +08:00 |
|
|
|
b0c87192b0
|
fix(notify+四桥): ★ 投递通知带 parent_from(方向判据)—— in-reply-to-ignores-direction 转绿
为什么这次顺手修:网关部署门禁(redeploy-gateway.sh 跑全量 Go 测试)被
TestInReplyToCarriesParentSender 拦下 —— 那是 2026-09-28 判据先行的债,
死锁修复本身无涉,但不修它网关换不上去。已用 git worktree 在修复前的
HEAD(16bf474)上验证过该测试原本就红,不是本次改动引入。
服务端(数据本来就在手,零新增查询):
· resolveTarget 的 reply_to 分支原本把父邮件整行读进内存、只用 SessionID
就丢掉;现在把 mail.FromName 一并返回。
· notify.Mail 增 ParentFrom;payload 增 "parent_from"。
· 转发 / 人类发信(me.go)路径如实传 ""(转发本就是新线索)。
四桥(relay-policy.js 四份逐字相同的拷贝 + 各自调用点):
· inboundHeadline 增方向判据:parentFrom === selfName 才说
「你上一封信的回复到了」;parentFrom 非空但≠自己 ⇒ 明说
「多方线索里的续谈(回的那封是 X 发的)」;服务端未升级(无
parent_from)⇒ 退回旧行为(含糊的「回复到了」强于把真回复当新任务
—— 那是互相客套的起点,回退语义被既有判据钉死)。
· 「回的是你那封:<id>」一行同样只在父邮件确为本方发出时才输出。
· 四份 lib + 四份 test 逐一 md5 相同(cross-bridge-prompt 1/2/3/4 继续绿),
判据 5 转绿。
红绿:
· 服务端 TestInReplyToCarriesParentSender 修复前红(16bf474 实测)、修复后绿;
· 四桥新增 3 条方向判据测试(别人发的 / 自己发的 / 未升级回退);
· client/electron 聚合套件 cross-bridge-prompt 5/5 绿。
|
2026-09-30 17:30:27 +08:00 |
|
|
|
974bf2a62f
|
fix(server): ★ relay 邮件不计入 Agent 互发上限 + 该闸放行 relay 发送 —— 修「退信把通道自己锁死」的会话死锁
症状(生产会话 0094eee5 `harmony-push-真机验证`):
homeagent 的 3 封失败退信(relay:"summary")被 CountTrailingAgentPingPong
计入「Agent 互发」计数,与 5 封模型主动往返合计 8 ⇒ 触发上限。之后:
· 模型每次真生成完回复再 send ⇒ 403(网关日志 09-29 22:01 ~ 09-30 10:11 共 7 笔);
· 桥的 sendFailureReply 退信也走 /mail/send ⇒ 同样 403 ⇒ 发件人只收到
「模型未产生回复」,真因被吞;
· 被拒的尝试不产生新邮件 ⇒ 计数永不回落 ⇒ 死锁,无人能解。
发件方收到的错误与「模型没说话」在桥侧合并成同一文案(plugin.go:1022),
把内核侧限流误报成模型故障 —— 两个 Agent 各自查错了方向一整天。
根因:2026-09-26 设计第三道闸时的实测样本(pi↔dsh 175 封)里 relay=0,
于是默认这条路径上没有 relay 邮件。但插件代劳的退信同样满足
「Agent→Agent + 无人类」,被并进同一个闸。relay 本有自己的更严闸
(maxRelayHops=5),两类回路挤在同一个计数里是设计疏漏。
修法(两处,缺一不可):
1. repo:CountTrailingAgentPingPong 跳过 relayed_mails 里登记过的邮件
(LEFT JOIN,与 CountTrailingRelayHops 同一判据源)。
2. handler:该闸加 `relay == ""` 条件 —— 即使计数已满,插件代劳的
退信/转发也必须能发出去(否则 ①② 仍在:闸拦住退信 → 真因被吞)。
红绿(生产同款数据形状):
3 主动 + 3 relay + 2 主动 ⇒ 缺陷版数出 8(FAIL,与生产实测一字不差),
修复后 5(PASS)。人类参与打断连续性的语义不变(回归 TestAgentPingPongResetsOnHuman)。
自证边界:单测证明的是计数器与闸门的判据;「死锁会话已解锁」要在部署后
用真实会话验证(见下一笔提交)。403 文案同时删掉了「或说明为何这轮必须继续」
—— 模型的任何说明本身也要走 send,被同一道闸拦着,这条恢复路径不存在。
|
2026-09-30 17:08:49 +08:00 |
|
|
|
a3ca64b744
|
fix(归档): 权限决策同样不得把归档邮件改回 read + 修正决策路径误用参与方判据
pi 2026-09-28 §五 指出 DecidePermission 与 MarkMailRead 是同一族的反向写入,
只堵后者等于堵一半。成立,本轮收口。
## 1) DecidePermission 补守卫(pi §五)
UPDATE mails SET permission_result=$1, status='read' WHERE mail_id=$2
↑ 无 archived 守卫
与 MarkMailRead 同一处形状,补 `AND status <> 'archived'`。
判据 TestDecidePermissionDoesNotUnarchiveMail,已实测去掉守卫即转红。
★ mail_reads 的 INSERT 仍**不加**守卫(与 MarkMailRead 同口径):
事实表(谁读过,不可撤销)与派生列(全局可见性,可重算)语义不同,
一起挡会让「谁读过」不可审计 —— 判据 TestDecidePermissionDoesNotUnarchiveMail
顺带断言「决策人 bob 的 mail_reads 行仍要写进去」,防止守卫误伤事实表。
## 2) 修正 e78888b 的一处误用(我自己发现的)
DecidePermission 的 handler 路径(handler/permission.go)我原先挂了
`SessionOpenFor`(存在 + 未归档 + **参与方**),而该路径上一行刚放行的是
「该邮件收件人本人 **或** 管理员」—— 管理员本来就可以给任何线索做决策。
叠上参与方会把管理员挡在门外。改为 `EnsureSessionOpen`(只判存在 + 未归档)。
★ 这个错是在写 e78888b 时想到了、说了「要改成 EnsureSessionOpen」,
但**当时没落进文件**就提交了。已补,并在注释里写明两个函数的差别,
免得下一个人「顺手统一」把管理员又挡掉。
## 读侧清册
仍为 repo.go=13:新增 1 处命中在注释散文里,改措辞而非改数字。
repo/handler 全绿;internal/notify 的 TestInReplyToCarriesParentSender
仍是既有欠账 in-reply-to-ignores-direction,非本轮引入。
|
2026-09-28 11:12:59 +08:00 |
|
|
|
e78888b756
|
fix(归档): 两表判据分叉的成因收口 —— TouchSession 不再写 status,建邮件一律拒归档会话
pi 2026-09-28 裁定 §1/§2 认可「判据分叉」这个定性,§4 要求做 1+2,
并把 permission/request 点为第三个复活入口。本轮做 1+2,并补上第四个。
# 缺陷:归档后不可见,判据挂在两张表上
unreadFor / readStateFor 判邮件行 (repo.go:unreadFor)
ListInbox / UnreadWorkspaces 判会话行 (repo.go:ListInbox)
两边对同一条已归档线索给出不同答案,而每一边单独看都「是对的」。
分叉由 `TouchSession` 的 `SET status='active'` 与建邮件 INSERT 只写
邮件行共同造成 ⇒ 只要有一个写路径碰会话行而不碰邮件行,半活会话就能被造出来。
# 改法:让不变量由构造保证,而不是逐个入口堵
· TouchSession 只剩 updated_at —— 它是全库唯一能解除归档的入口
· EnsureSessionOpen 是 CreateMail / CreatePermissionMail / CreateDecisionMail
的共同前置(集中一处,新增建邮件函数必须经过它)
· ErrSessionArchived 与 ErrSessionNotFound 分列:调用方要能分开回话
· resolveTarget 的 reply_to 分支恢复归档契约(此前绕过别名路径的 404)
· permission/request 补 SessionOpenFor:存在 + 未归档 + 参与方
· FindSessionByPlatformID 补 s.status(adopt 路径,pi 未列的第四个入口)
# 判据:写成不变量而不是单点
session_status_invariant_test.go:对任意 session_id,
sessions.status='archived' ⟹ 该会话全部邮件 archived。入口级回归单测仍在,
但它们是说明。已实测把 TouchSession 改回旧实现后该判据转红
(不是「改完就绿」的装饰)。
# 读侧清册
mail_status_readers_test.go 的清册仍为 repo.go=13 / thread.go=1 / migrate.go=4:
本轮新增的 5 处命中全在注释里(散文里拼了列名字面量),已改写措辞而不改数字
—— 让数字变化会给未来新增读取凭空送出 5 格余量,正是那张表要防的事。
# 遗留(pi 裁定本轮不做,已登记)
FindSessionByAddress 无 status 条件:补上会把重复归档从 200 变成 404,
属行为变更,不在 bugfix 里夹带。
|
2026-09-28 11:01:38 +08:00 |
|
|
|
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 |
|
|
|
7634be8966
|
fix(inbox): 收件箱按**工作区**收窄(三维地址的 path 位此前从未被使用)
用户 12 天前就提过(`552fbc7` 只修了 session_id 那一维),这轮才真修。
用户原话:「难道让一个不在项目工作区的 agentsession 去修工程吗?」
# 缺陷(生产实测,2026-09-26)
在 `mc` 工作区干活的 pi 读收件箱拿到 **200 封,其中 191 封属于
`/home/program/agentmail`** —— 它照着那些信里的断言去改 agentmail 的代码,
把手上的 mc 活丢在一边。用户当场问它「你怎么干着干着修 agentmail 去了?」
(这条对话就在 mc 会话的 jsonl 里)
根因:`ListInboxScoped` 的 WHERE 只有 `m.to_name = $1`(+ 可选 session_id),
**没有任何 workspace 条件**。三维地址 `name@path.session` 的 path 位
在收件箱侧从未生效 —— 那不是"另一种语义",是没兑现契约。
# 三条守卫全部只覆盖自动转发,防不住这个
| 守卫 | 只覆盖 | 为何无效 |
| --- | --- | --- |
| 会话预算 | `relay != ""` 才扣 | 这批信 relay=0(模型主动发)⇒ 不扣 |
| maxRelayHops=5 | 同上,只数 relay | 同上 ⇒ 不进那个分支 |
| 插件自动转发守卫 | 插件代劳时 | 日志明说"本轮不自动转发" ⇒ 模型自己发的不受管 |
# 服务端
· `ListInboxScoped` / `CountUnreadScoped` / `MarkAllInboxReadForSession`
三处统一加 `s.workspace = $N`(用会话的 workspace,不用 mails.to_workspace:
后者是信封字段、可能是抄送或历史遗留;"线索属于哪个工作区"是会话属性)。
★ 三处必须是**同一个谓词** —— 列表看不到的信却被"全部标掉"标掉就是静默丢信
(session_scope_test.go 记过这个形状)。
· **workspace 在 Agent 侧必需,缺了 400**(用户裁定:「不带 workspace 是错误
发件格式,直接退回!」)。旧语义(不带=全部)正是缺陷本身,不留兼容回退。
· 人类侧**不过滤**(一个人跨工作区,WebUI 按 session_workspace 分组显示)——
所以"必需"这条约束放在 Handler 而不是 repo 层:它是接口契约,不是数据层不变量。
· 新增 `UnreadWorkspaces`:心跳是**进程级**(一个桥服务所有工作区),没有
"我的工作区"可言;但只有总数桥不知道去哪个工作区补投 ⇒ 心跳回
`pending_workspaces` 清单,桥逐个消费。
· 决策载荷补 `workspace`(服务端知道 session→workspace,插件重启后推不出来)。
· `TouchAgentLastSeen` 从 HeartbeatAgent 拆出:middleware 在每个认证请求上都调它,
而那时工作区还没解析(请求体没读),原来在白算一次 CountUnread。
# 三个插件(pi / opencode / dsh)
· 读类工具带 `workspace`;补投从"读一次全局收件箱"改为**逐工作区**读。
· pi:worker 信封的 `to_workspace` 经闭包递进工具(不是会话文件 header 的 cwd ——
后者是"会话上次落在哪",前者是"这封信寄到哪个工作区")。
· opencode/dsh:插件常驻、信封在 deliverMail 那刻就消费掉了 ⇒ 新增
`sessionWorkspace` 映射(键与既有 reverseMap 同一把)。
· 修一处真 bug:`UnreadWorkspaces` 原先会返回相对路径工作区(历史库里有
`workspace='root'`),桥侧实测撞 400(`补投工作区 root 失败`)⇒ 只报可寻址的。
# 实测凭据
· 改前:`pi` 的收件箱 200 封混 3 个工作区(agentmail 191 / TrueAgent 7 / huawei 2)
· 改后:agentmail=100(total 228)、mc=16、TrueAgent=7 —— 各工作区独立
· 不带 workspace ⇒ **HTTP 400**,话术给出可执行步骤
· 桥日志:`rw=/home/newqqagent/plugindev/editdoc-upgrade` —— 终于是别的工作区了
(改前 78 次 worker 启动**全部**是 `/home/program/agentmail`)
# 判据
· `server/internal/repo/workspace_scope_test.go`(3 条):
两向收窄 + **反向对照**(不带时两条都看得到 ⇒ 证明是收窄不是清空)+
未读数同口径 + 相对路径必须报错
· `plugins/pi-mail-bridge/test/inbox-workspace-scope.test.mjs`(4 条):接线 +
取信封而非 cwd + 补投逐工作区 + 判据自检
· dsh 那条 `取不到会话时退回整体收件箱` **改了**:它钉的"退回整体"正是缺陷,
现在钉"两维各自缺席时各自不带、服务端 400 让错误可见"
· 变异验证:服务端 2 处 + 插件 3 处,全部判红后恢复回绿
全量:server `go test ./...` 绿;三插件 513+340+403 全绿。
|
2026-09-26 07:44:33 +08:00 |
|
|
|
31939f2b10
|
服务端: 顶栏内容端点(一言句库缓存 + 个人签名)+ 修老库升级时序 bug
用户裁定:
· 「可以在服务器集成一言与签名,同时 app 本地缓存一部分」
· 「摘要也应该放在顶部,显示摘要不显示一言,显示一言不显示摘要」
· 「自动轮播,要有消失出现动画。同时注意,是纯文字不要加底」
新增端点
· GET /api/v1/me/topbar → { quotes: [{text, source}], signature }
一次给一批(默认 10 条),客户端拿去本地轮播 —— 轮播是秒级的,
每条问一次服务器既浪费又会在断网时停下(而轮播的观感依赖"一直有下一条")。
· PUT /api/v1/me/signature —— 改个人签名(「我的」页用)
· quotes 表(句库缓存)+ users.signature 列
设计要点
· 一言**落库缓存**:库里有就**不打外网**(常态路径);不足 20 条才去
hitokoto 补一批。补失败**不影响返回** —— 装饰性内容不该成为失败点
(顶栏少轮播内容是小事,整个接口 500 会让 App 启动时顶栏坏掉)。
· 签名存 users 而不是 quotes 表:它是**用户资料**(跟账号走、
在「我的」页可编辑),放 quotes 里会让"改签名"变成"改一条 quote"。
· 限长 80 字,超了**拒绝且不落库** —— 顶栏是一行,静默截断比报错更坏
(用户以为存进去了,实际存的是被砍过的)。
· 迁移改两处(本仓既定纪律):init_sqlite.sql 给新库 +
sqliteAddColumns 给老库。
★ 顺手修掉一个既有 bug(不是本次引入的)
「从很旧的库升级会直接启动失败」:
migrate sqlite (语句 #10 … idx_sessions_path_alias_uniq):
SQL logic error: no such column: workspace
根因是**时序**:这条索引引用 sessions.workspace,而那是**后补的列**
(sqliteAddColumns),索引却住在 init_sqlite.sql(在补列**之前**执行)。
新库没事(建表时就有该列);老库直接炸,且报错指向索引名 ——
看着像索引写错,实际是顺序问题。
生产库一直没暴露,因为它早就补过列了(暴露面只有"从很旧的库升级")。
证据:`git stash` 掉当天全部改动后**同样复现**。
修法:把索引搬到 migrate.go 的 sqliteAddIndexes(那个列表在补列之后跑)。
测试(internal/handler/topbar_test.go,5/5)
① 签名账号隔离 —— bob 没设过就该是空串,不能串到 alice 的
(本仓 user_appearance 那轮踩过"多账号共用一份",同一形状不许重演)
② 有货不打外网(灌 25 条,断言返回不超过 quoteBatchSize)
③ ★ 外网挂了仍返回 —— 耗时 4.01s = quoteHTTPTimeout,
证明它真去拉了并按超时降级,不是假绿
④ 限长:81 字拒绝**且不落库**;80 字(边界)接受
⑤ 未登录读写都 401
★ 两个踩过的坑(记进注释了)
1. `init_sqlite.sql` **只能写 `--` 行注释**:切语句器只跳过 `--` 开头的行,
块注释的文字会被当 SQL 执行。我第一版用 `/* */`,新库初始化直接失败,
且报错指向一个完全无关的地方(no such column: workspace)。
2. 该 SQL 文件的 splitStatements 也会被注释里的反引号/连续减号破坏。
|
2026-09-25 16:29:19 +08:00 |
|
|
|
5753169077
|
docs(test): 去掉 permission.go:112 这个**同一提交内就失效**的行号引用(pi 2026-09-25)
pi 那条"引用位置要用**唯一标识**,行号只作辅助"我收 —— 而它在**我自己的提交**里
就有一个现成的反例:
34a15dc^ : permission.go **112** = Error(w, …, "Invalid session_id") ← 引用写下时是对的
34a15dc : 同一提交给它**前面**插了 16 行注释 ⇒ 真调用移到 **128**
(而新注释自己占了 113 行,所以 112 现在落在注释文本里)
HEAD : 112 = 注释文本;真调用 = 128、另一处提到它的注释 = 113
⇒ **引用与使它失效的改动在同一个提交里** —— 这不是"时间久了漂移",
是"写下时就错了",而且**任何 review 都看不出来**(数字看着很具体、很像核过)。
判据也抓不到:112/301/39 全都在文件行数界内(弱形式"越界检查"无效)。
改法(按 pi 的写法):唯一标识用**函数名 + 错误字符串字面量**
(`RequestPermission` + `Error(w, http.StatusBadRequest, "Invalid session_id")`),
行号删除;并把"为什么故意不写行号"记在注释里,免得下一个人"顺手补回去"。
★ 顺带核实(**只核实、未改动**)另一处同类引用:
`docs/HARMONY-ALIGN-PLAN.md:225` 引 `permission.go:301`,声称是 `DecidePermission`。
实测: e07e3bf(引用写下时)301 = 该函数的文档注释、函数体在 302;
现在函数体在 **318** ⇒ 该行号也已漂移 17 行,落在 `RequestPermission` 里。
未改它(属于另一个 writer 的文档,且是否要改成"唯一标识"由那条线决定)。
验证: `go test ./internal/handler/` 通过。
|
2026-09-25 06:35:53 +08:00 |
|
|
|
6e4bcd66be
|
fix(网关): 附件竞态回滚一件都退不掉 —— BindRelayMail 早于 attachAll 导致撞外键
## 缺陷
`/mail/send` 的回滚块注释写着「三件事都要退:邮件本身、本次往返预算、relay
幂等键」,但实际**一件都退不掉**:
relayed_mails.mail_id REFERENCES mails(mail_id) -- 无 CASCADE
DeleteMailByID : DELETE FROM mails WHERE mail_id = $1
ReleaseRelay : DELETE FROM relayed_mails WHERE ... AND mail_id IS NULL
`BindRelayMail` 原来在 `attachAll` **之前**执行,所以走到回滚块时该键已经绑上了:
- `DeleteMailByID` 撞外键失败(生产 DSN 有 `foreign_keys(1)`)⇒ 邮件留在库里;
- `ReleaseRelay` 的 WHERE 是 `mail_id IS NULL`,对已绑定的行是 no-op ⇒ 键没退。
后果与注释想避免的正好相反:发件方收到 4xx 会重试,收件方看到那封残余邮件 ——
两封。
## 修法
把 `BindRelayMail` 挪到回滚块**之后**。绑定是纯审计关联(`RelayKeyForMail`
反查用),`attachAll` 与 `notifyRecipients` 都不读它(`models.Mail` 零 relay
字段),所以推迟没有副作用。
## 判据
`TestMailSendRollbackRemovesMailAndRelayKey`:同一个 `attachment_id` 传两次 ——
`checkAttachable` 逐条查时都还没挂载(都通过),`attachAll` 的原子 UPDATE 在
第二条改到 0 行 ⇒ 409 ⇒ 回滚。这样无需真并发就能确定性地走到那条路径。
三条断言:① 触发条件成立(409,否则用例会静默退化成空跑);② 邮件没留下;
③ 幂等键退回去了。已变异验证:把 `BindRelayMail` 挪回 `attachAll` 之前 ⇒ 用例红
(mails=1、relays=1),正是原缺陷的形状。
`me.go` 的同名回滚不受影响:人类发信不走 relay,且 `CreateMail` 不写
`mail_reads`,实测 `DeleteMailByID` 返回 nil。
`go test ./...` 13 包全绿;handler 组 -race 通过;gofmt/vet 干净。
|
2026-09-25 05:50:08 +08:00 |
|
|
|
34a15dc09c
|
fix(网关): 占住幂等键后的早退路径没退键 —— 永久占键、重试永远 duplicate
## 缺陷
`ClaimRelay` 成功之后、邮件落库之前有若干条早退路径,只有**部分**在 return 前
调了 `ReleaseRelay`。漏掉的那些会把 (agent_name, relay_key) 永久占住:
之后同一上游消息的重试拿到 `ErrRelayDuplicate` ⇒ 返回 200 `duplicate_relay`
(文案是「该上游消息已转发过,本次调用未产生新邮件」),
而那条消息**从未发出去** —— 响应在陈述一件没发生的事。
野外实例(现库仍在,`relayed_mails` 唯一一行 `mail_id IS NULL`):
agent_name = zcode
relay_key = no-such-session-0000:toolu-nohuman-1789193173578
created_at = 2026-09-12 06:06:13
它的 session 位不是 UUID,于是 `RequestPermission` 走到
`uuid.Parse` 失败那条 `Invalid session_id`(permission.go:112)直接 return,
没有退键。该行从 09-12 留到现在。
`mail.go` 同类:`ClaimRelay`(:396)之后 `ConsumeSessionBudget` 返回
非「额度耗尽」错误时(:433)也没退键。
## 修法
不在各 return 前逐个补 `ReleaseRelay` —— 那正是缺陷的成因(漏一处就是一个静默的
永久占键,而漏掉的那处不会有任何报错)。改为占键成功后立刻 `defer` 一次释放,
让"还键"成为该作用域的唯一出口不变量。
成功路径不需要额外开关:`ReleaseRelay` 的 WHERE 含 `mail_id IS NULL`,
`BindRelayMail` 一旦成功该行就不再匹配,defer 自然什么都不做。
## 判据(两条独立用例,缺一即放过一类坏实现)
`TestRequestPermissionReleasesRelayKeyOnEarlyExit` —— 失败路径:早退后同一个
relay_key 必须还能再次占用(键被还回来了)。
`TestRequestPermissionClaimsRelayKeyOnSuccess` —— 成功路径:同一询问重复投递
必须被幂等键挡住,且键确实绑定到了第一封邮件上。
两条必须分开:早退时"正确实现"与"把 ClaimRelay 换成空操作"的坏实现在表里
**观测等价**(都留下一个空键),单条用例无法同时钉住"该退的时候退"和
"该占的时候占"。已逐个变异验证:
基线(缺陷代码) → 早退用例红、成功用例绿
早退处补 Release → 两条全绿
ClaimRelay 置空 → 成功用例红(早退用例仍绿,符合预期)
BindRelayMail 去掉 → 两条全红
`go test ./...` 13 包全绿;handler 组 -race 通过;gofmt/vet 干净。
|
2026-09-25 05:41:57 +08:00 |
|
|
|
2da38bba83
|
农历走服务端端点:换算只在服务端做一次(两边各写一遍天文算法迟早差一天)
用户定的方案:「加 api 端点」。
## 为什么不移植到 ArkTS
`lunar-javascript` 的 `lunar.js` 有 **43 万字节**,内部是**日月位置的级数展开**
(实测:全文件最大的数字字面量是 16KB 的系数数组,**不是**"某年到某年的月长表")。
即"照搬一张小数据表"这条路**不存在** —— 移植等于在 ArkTS 里再实现一遍天文算法。
两份实现迟早会在某个闰月或某个朔日上差一天,而那种错**表现为日期错位、不是报错**,
界面上完全看不出(用户得自己去查日历才知道)。
所以:`GET /api/v1/calendar/lunar?from=&to=`(服务端 `internal/lunar`,同一作者的 lunar-go)。
## 形状是「按日期键索引的映射」,不是数组
客户端拿到 `map[iso] -> 标签` 直接按格子键查,不用自己遍历比对。
`text` 字段是**格子里直接显示的那个串**(初一=月名、其余=日名)——
由服务端定,两端同源。客户端各拼一份的话,同一天在两边日历上可能长得不一样
(例如闰月到底写不写「闰」)。
几个刻意的取舍:
- **不设默认 from/to**:默认范围会让「我要 3 月」与「服务端以为我要这个月」悄悄不一致;
- 入参只收 `YYYY-MM-DD`(**日期键**,不是 RFC3339):农历是"这一天是农历几号"的
纯日期语义,混用时间戳会被时区挪一天;
- 换不出来的日子**不进 map**(客户端查不到 ⇒ 那格不显示农历),而不是塞空对象 ——
空对象会让客户端以为"有农历、只是没内容";
- 区间上限 400 天(不是安全边界,是防客户端传十年前到十年后)。
## 判据(`server/internal/handler/lunar_test.go`)
参照物是服务端的 `internal/lunar`(权威实现),**不抄一份答案表** —— 库升级时判据跟着走。
钉的点各自对着一个会静默出错的错法:
- `Full` 与权威实现逐字一致(5 个日期,含春节、跨世纪、29 天月的边界年份);
- 日名表覆盖 1..30 且**五种前缀形态都在**(WebUI 那版漏过「二十」);
- ★ 初一显示**月名**、其余显示**日名**(与 WebUI `cellLunarLabel()` 同一口径);
- 闰月必须带「闰」字(不带的话闰六月与六月在格子里一样);
- 极端值(公元 1 年 / 1900 / 2100 / 9999)**不许 panic**,且换出来时文字里不许含
「无效/NaN」这类失败标记。
★ 最后一条我第一版**写错了**:断言「`time.Time{}` 应当换不出来」,实测库**换得出来**
(0001-01-01 → 「〇年冬月十八」)—— 我断言的是自己的想象而不是实际行为。
改成断言真正的契约(不 panic / 失败就不给 / 给了就得是真结果)后才对。
## 客户端
`LunarLabels` / `LunarRangeResponse` 两个模型 + `CalendarApi.listLunar(fromIso, toIso)`;
`CalendarPage` 在 `loadEvents()` 之后**不 await** 地拉农历(附加信息不该拖慢事件列表),
失败**不算 `this.error`**(否则"农历服务抖一下"会变成"整个日历打不开"),
只写 hilog 留痕。区间按**网格**取(不只本月 —— 月视图首尾显示上/下月格子)。
|
2026-09-18 11:23:12 +08:00 |
|
|
|
7e5b11392f
|
fix(push): session_id 非空时必须是合法 UUID —— 它此前静默接受任意字符串,真 bug 就是这么活下来的
dsh(f07daac1 之后的 5fab8734)报:客户端一度把这个字段填成**服务器地址**
(`AccountInfo.server` = `https://…/api/v1`),而这里只 TrimSpace、不看格式 ⇒
不 400、不影响收信、不进日志;而**投递路径也不看它**(通知里的 session_id 来自
邮件自己,见 notify/mail.go;dispatch 只用 token 的 Provider/Token)。
两条路都不看 ⇒ 存了假值没有任何机制会报警,可它存在的唯一目的就是
「点通知回到那条会话」。
改动只有一条校验(非空才校验,空串仍合法 —— 契约允许"客户端还没进任何会话"),
400 文案自带药方(写明"要么 UUID、要么留空")。
判据 `TestPushTokenSessionIDMustBeUUID` 四个用例:服务器地址顶替 / 随便一个词 /
空串 / 真 UUID。**变体验证两个方向**:
· 去掉校验(连 import 一起去)⇒ 「非法」两条当场红(实际 200);
· 把判断写反(`err == nil` 才报错)⇒ 合法的真 UUID 也红 ⇒ 证明它不是"恒 400"。
恢复后 `go test ./...` 全绿、`go vet` 干净。
取舍:老客户端带旧值上报会吃 400,按契约 400 归静默 ⇒ 不打扰用户、只是那次不上报。
`push_tokens` 现为 0 行,**没有历史脏值要清**。
|
2026-09-17 19:42:41 +08:00 |
|
|
|
7e1adfd731
|
revert(过度矫正): 撤回"目录合不合法"的两道门槛 —— 错在投递,不在值
用户否掉了我上一步的方向:「?拒收那个目录干啥?明明是你的网关投递逻辑造成的错误」。
他是对的。/home 是**合法目录**,平台不该替用户裁定"哪个目录算工作目录":
网关地址受理处的 400、建议接口的过滤、桥侧的同类闸(revert 141003d)全部撤回。
判据随之删除 —— 判据不该给错误的实现护航(那会让错的东西看起来有保障)。
保留的是**真修复**(ad1f3f1,已在线):
- 会话解析按 (path, 别名):错投的根因是解析只看别名、path 被当提示;
- 唯一索引 (path, 别名);无 path 时"唯一才认,否则报歧义";
- 毒数据清理(/home 污染值 0|0|0)。
核心回归判据 TestFindNamedSessionForRequiresPath 保留。
仍未修(用户指的那一处):SSE 投递只按 name 广播 —— 同名不同 path 的客户端谁接到谁处理。
下一 commit:投递也带会话身份,只投给声明了该会话的客户端。
|
2026-09-15 12:54:25 +08:00 |
|
|
|
ad1f3f14c3
|
fix(session): 会话身份 = (path, 别名) —— 修用户报的错投,并堵住 /home 这类毒 path
用户 2026-09-15 的订正:「agent 平台的 session 是和 path 绑定的,path+session 才能
指定到准确的 agent,而授权也是对 session 授权,而不是整个 agent」;随后报了一个
严重错投:「工作区在 TrueAgent 的 pi 客户端,发送邮件给 dsh 被投递给了工作区在 home
的客户端」。
根因是四处叠加(都有实测证据):
1. 解析只按别名:`WHERE session_alias = $1`,path 被当可选提示 ⇒ 两条不相干的
线索能落进同一会话;
2. 别名全局唯一 ⇒ path 在解析时冗余,进一步被忽略;
3. 桥按来信的 to_workspace 起 worker(实测日志 `新建 pi 会话 …(cwd=/home)`);
4. 投递(SSE)只按 name 广播。
本提交改 1/2 + 堵源头:
- 解析按 (path, 别名):给了 path 必须两者同时命中,对不上就是 ErrSessionNotFound
(**绝不**退回按别名找);没给 path 则要求该别名唯一,多条时 ErrSessionAmbiguous
(不许掷骰子);
- 唯一索引从 `(session_alias)` 改成 `(COALESCE(workspace,''), session_alias)`
(两方言 + 显式 DROP 旧索引;EnsureSessionAlias 靠唯一索引判撞名,自动变成按 path);
- 新 IsPlausibleWorkspace(与目录建议**同源**一条规则):地址受理处拒收不像工作目录的
path(`/home`、`/root`、`~/.pi/mail-sessions/…`、顶层挂载点),并给出可执行文案;
- 建议接口的候选过滤改用同一条规则(只留具体目录;祖先容器让位给更具体的候选)。
判据:新增/改写 4 组,全部做变异验证 —— 三条核心变异(解析退回按别名找 / 歧义时
掷骰子 / 去掉「深度≥2」)全部变红。过程中判据还抓到自己一个漏洞:
IsPlausibleWorkspace("/home") 在旧规则下是 true(/home 不是 root 的家目录)。
★ 语义变化(会让旧测试红):不同 path 下的同名别名现在是两条独立会话,不再"撞名"。
TestAdoptHandlesAliasCollision 已按新模型重写为两侧(同 path 才加后缀)。
未做:桥侧仍按来信 to_workspace 起 cwd(下一步);线上的库要等下一次启动才跑迁移。
|
2026-09-15 12:20:28 +08:00 |
|
|
|
46fa7fa729
|
feat(push): 可选、配置式、多厂商的推送通道(HMS 为首个实现)
用户要求:推送密钥必须是可选项(自部署后端不能写死推送方式),且要支持
多厂商配置式接入 —— 每个用户各自部署服务器、自己选厂商、自己配凭证。
所以落地成:
· internal/push:通道抽象 + 工厂表(RegisterType),加厂商不改配置层与端点形状;
HMS 只是第一个实现(internal/push/hms.go)
· 配置在 PUSH_CONFIG(默认 <AGENTMAIL_DATA_DIR>/push.json),一项一个厂商,
凭证走文件(app_secret_file / files.*,建议 600);环境变量只是可选覆盖
· 没配 = 整条推送路径连一次查库都不发生(shouldDispatch 早退);
单项配错(未知类型/密钥读不到/enabled:false)只跳过那一条,不影响启动
· push_tokens 表带 provider 维度 + 三个 /me/devices/push-token 端点;
没配推送时端点照存并回 enabled:false(登记成功 != 服务端开了推送)
· notify.Recipients 末尾异步挂钩:收件人名单直接用 SSE 那份 seen(两条通道
共用同一份"谁该收到"的判据);失败只记日志,绝不拖住收信
HMS 的形状是拿真凭证打线上接口问出来的(v1 + message.token[] + testMessage;
payload/target 形状 v1 不认、v2 要服务账号 JWT)。未上架应用必须 test_message=true,
单批 ≤10 token(MaxTokensPerRequest 声明)、每日 1000 条兜底(项目级额度)。
实测:App ID + App Secret 能换到 access_token(3600s);形状被线上服务接受。
判据:repo 6 条 + push 12 条 + handler 3 组,全部做过**变异验证** ——
过程中抓出两条假判据(异步分发与 t.Cleanup 赛跑而假绿;密钥文件优先级没被覆盖)
并补掉。Go 全量测试与 go vet 干净。
★ 未验:端到端真机送达(需要真机 token + 客户端按 com.jianf.agentmail 重编并签名,
签名指纹还要在 AGC 登记)—— 从未真正发出过一条能到达设备的推送。
详见 docs/HMS-PUSH-PLAN.md 的「实现状态」一节。
|
2026-09-15 11:21:00 +08:00 |
|
|
|
a5fc86bc1a
|
fix(addressing)!: 寻址不按工作区筛 + 联系人地址不再从垃圾 from_workspace 拼
用户:「任意 agent 的寻址是任意的,而不是按工作区区分,去落实吧」。
① 拆掉两处"按工作区收窄"(那是我把**寻址**当成了**权限**):
· AgentListContacts 不再走 ListContactsInWorkspace ⇒ 列表 = 我参与过的会话(事实,不是授权);
· AgentSuggestAddress 的会话候选不再走 SuggestSessionCandidatesInWorkspace。
两个 InWorkspace 变体(连同钉旧口径的用例)一并删除,避免死代码。
生产实测:pi 的联系人从"只剩同工作区"变成 5 条,横跨 TrueAgent/agentmail/其它工作区。
agentScope 仍调用(校验 session_id 格式 + 未声明时告警),只是它的工作区不再当过滤器。
② 联系人地址的 path 取自**会话**,不再取第一封邮件的 from_workspace:
`COALESCE(NULLIF(s.workspace,''), NULLIF(m.to_workspace,''), '')`。
那条老路把地址拼成 `zcode@zcode.<别名>` —— 正是用户预言的"感染":一个可被复制出去的
错误地址。from_workspace 与"对方在哪"无关,只用 to_*。
③ **清除感染源(数据)**:658 行 from_workspace = from_name(pi 406 / dsh 137 / zcode 89 /
homeagent 17 / opencode 9)已清空。带去重前备份(/root/gotmp/agentmail-pre-fromws-purge-*.db)
与回滚脚本,回滚**在副本库上真跑过**(恢复 658 行)才敢落地。
|
2026-09-15 10:07:18 +08:00 |
|
|
|
6b86836343
|
fix(read)!: 读信只认会话 —— 删掉「同工作区」那道闸门
用户订正:「是应该发到对应 session 的信,因为 agent 多 session 架构,不同 session 的
记忆是隔离的。你之前那个修法简直荒谬」
我把两条轴混在一起了:**工作区**决定 cwd/沙箱/线索可见面,**会话**决定记忆与收件箱。
原先 AgentMayReadSession 判「参与过 + 同工作区」,两头都错:
· 太松:同工作区内、我参与过的**别的会话**也放行 —— 会话之间记忆隔离,读别的会话
就是绕过隔离(用户上午的要求本就是"不同 session 不同收件箱");
· 太严:我在 A 工作区的上下文里读不到**自己刚发起**、落在 B 工作区那条会话。
zcode 撞上这条,403 文案还写着 cross-workspace ⇒ 它推断出"那五位 agent 不存在"。
新判据两句:① target 必须就是 scope(我当前那条会话);② 我参与过 target(防伪造
session_id)。scope==nil 保持迁移期放行(五家桥实测都带 session_id)。
403 文案换成「这封信不在你当前所在的那条会话里:每个会话各有各的收件箱(会话之间的
记忆是隔离的)。你当前在会话 <id>。要读它,就在它那条会话里读 —— 每封来信都会把 worker
唤醒到它自己那条会话上」。报"我在哪"安全,目标会话在哪不报。
判据:workspace_scope_test 的该用例改名并重写为五条形态(自己那条会话放行 / 同工作区
另一条会话拒 / 别的工作区会话拒 / 伪造 session_id 拒 / 未声明 scope 迁移期放行)。
生产实测:① 200 ② 403 not-your-session(新文案)③ 403(伪造)。
|
2026-09-15 09:59:21 +08:00 |
|
|
|
a684d479bb
|
fix(mail)!: agent 发信不再把 agent 名当工作区存进 from_workspace
用户报「这个 zcode@zcode 转发生成的地址绝对有问题」的**写入侧根因**:
mail.go 的 agent 发信路径传的是 `agentName, agentName` —— 第二个参数是 from_workspace,
于是近两天 zcode 10 封、homeagent/opencode 全部把**自己的名字**存成了工作区
(人类路径 me.go 那一格是空串,所以只有 agent 的信这样;pi 399 封有 4 种值、dsh 116 封 3 种
则是历史与真实路径混着的状态)。
改法(一处):from_workspace = 该会话的工作区 —— 地址里带了 path 就用它,
否则回查 SessionWorkspaceOf(空串语义是「不知道」);再兜一层"等于 agent 名就置空"。
配套的显示侧修复(d9d81b9)已让 `name@name` 不再出现,并对**存量**行生效
(path==name 时当"不知道"丢弃,改用会话反查)——所以历史数据不需要回填。
生产实测(部署后新写入一封):
修复前该格 = "pi"(或 "zcode");现在 = "/home/program/agentmail"(会话工作区)
判据:显示侧 TestQuoteBodySenderAddressNeverNameAtName 已钉住引用块;
本改动为写入侧,用**线上真发一封 + 读库对照**验证(handler 包无发信测试夹具,
不硬加一条只钉形状的假判据)。
|
2026-09-15 09:47:08 +08:00 |
|
|
|
d9d81b94b1
|
fix(forward): 引用里的「转发自」不再是 name@name,改用会话的 workspace/alias 拼
用户(指着转发出来的那封信):「这个 zcode@zcode 转发生成的地址绝对有问题,原本是 zcode@/home…」
成因是**两个错叠在一起**:
① 写入侧遗留:发信路径给 Agent 存 from_workspace 存的是 **agent 名**而不是路径
(实测近两天:zcode 10 封全是 'zcode'、homeagent 'homeagent'、opencode 'opencode';
pi 399 封有 4 种值、dsh 116 封有 3 种 —— 混着真实路径与自己的名字);
② 展示侧无脑拼 `name@from_workspace` ⇒ 两者一乘就是 `zcode@zcode`,
而**那永远不是一个地址**(地址是 name@path.session)。
被转发那封的会话工作区其实是 `/home`、别名 `zcode-打个招呼`,所以正确形式是
`zcode@/home.zcode-打个招呼`(用户说的"原本是 zcode@/home…"就是这个)。
改法:
- quoteBody 增加两个入参(会话 workspace/alias),转发处理器用
repo.SessionWorkspaceOf / SessionAliasOf 取(都是既有函数,空串语义是"不知道");
- 新增 senderAddress:优先用会话的 workspace+alias 拼 `name@path.session`
(空 path 时给平台形式 `name@.alias`);**path 与 name 相同时当"不知道"丢掉** ——
宁可只渲染 `zcode`,也不渲染一个看着像地址其实不是的东西。
判据:新增 TestQuoteBodySenderAddressNeverNameAtName(三条形态:正常地址 / 退化不许 name@name /
空 path 的 .alias 形式);旧的 TestQuoteBodyPrefixesEveryLine 改走退化路径以保持它原本的意图。
变异:去掉"拒绝 name@name"那段 → 红;复原 → 包内全绿。
▲ 仍未部署(与上一笔时间修复一起卡在同一个地方):/opt/agentmail 现在对我不可写
(touch 都被拒),且有 08:57 留下的 /opt/agentmail/.deploy.lock。
需要能写那儿的人:rm -f /opt/agentmail/.deploy.lock && bash deploy/redeploy-gateway.sh
|
2026-09-15 09:38:59 +08:00 |
|
|
|
7ff20feffe
|
fix(forward): 转发引用里的「时间」按本地时区渲染(原先是 UTC 原样)
用户(转发 zcode 的信给我):「它说的它发的这个邮件你收到了吗?它说没有收到回复……」——
问题不在那封信,而在它引用的时间:用户看到的「2026-09-15 01:23:19」其实是 UTC,
本地时间应为 09:23:19(本机 UTC+8)。
根因:quoteBody 用 `m.CreatedAt.Format(...)` 直接格式化(库里存 UTC),而界面各处都转本地;
同一族的正确写法在 scheduler/calendar.go 里(`e.EventTime.Local().Format(...)`)。
★ 顺带发现一条**旧断言一直在保护这个 bug**:TestQuoteBodyPrefixesEveryLine 里写死了
「2026-09-02 10:30:00」(fixture 是 time.UTC)——已改为按本地计算,不再写死字面量
(与前几天 nav-merge.test.mjs 那条同一形态:断言钉着错的意图)。
判据:新增 TestQuoteBodyTimeIsLocal(引用时间必须等于 CreateAt.Local() 渲染,且不得等于
UTC 原样)。变异:去掉 .Local() → 2 条判据变红(新加的 + 改过的旧断言);复原 → 包内全绿。
注:该判据在 TZ=UTC 的机器上是空转的(本地==UTC),本机 UTC+8 会真红。
▲ 部署未完成:/opt/agentmail 现在对我不可写(touch 都被拒;uid/能力问题,08:57 之后出现),
加上一个 08:57 留下的陈旧 /opt/agentmail/.deploy.lock 挡在 redeploy-gateway.sh 前面。
需要能写那里的人跑:rm -f /opt/agentmail/.deploy.lock && bash deploy/redeploy-gateway.sh
|
2026-09-15 09:36:47 +08:00 |
|
|
|
2a5e3d7d15
|
fix(auth): 四家桥的读端点也带上会话收窄 + 转发同一条命(工作区隔离第 2 步)
第 1 步(1b8cd43)把工作区判据放在服务端、pi 桥接上了线。这一步补齐另外四家,
并把**转发**纳入:转发是"把原文引出去",能转发就等于能读到那条线索的全部内容,
与 read_mail 同一条命(服务端 ForwardMail 也加了同一道校验)。
四家各自的会话来源,与各自的 read_inbox 同一处(不引入第二个来源):
- dsh:`mailSessionOf(exec)`(工具第二个参数)—— 五个读工具原本没接 exec,这次补上
- opencode:`reverseMap.get(context.sessionID)`
- zcode:`process.env.AGENTMAIL_SESSION_ID`(一轮一个进程)
- homeagent:`p.currentSessionID`(新增 `scopeQuery(sep)`,与 inboxURL 同构)
判据(每条两侧都钉:包住了 / 没包住的不存在):
- dsh:静态对照,且额外钉 **dist** —— 那是真被 dsh 加载的那份(main: dist/index.js),
src 改了忘了 build 就是"源码对、线上旧代码"
- opencode / zcode:同上(opencode 还钉"会话来自 context 而不是模块级变量")
- homeagent:起 httptest 当网关,**五个读工具 + 转发真调一遍**,断言请求 URL 带
session_id;对照侧:不在回合里(currentSessionID 为空)时不许带
- pi:把 post 的 URL 也纳入记录,forward 进用例表
★ zcode 那条判据我第一版**对照组写错**了:对照组只写裸 URL,而它本来就是
`withScope(\`裸URL\`)` 的子串 ⇒ `!includes(bare)` 恒假。夹具形状不对时判据会以
"恒红/恒绿"的方式骗人(这次是恒红,一眼可见;恒绿就麻烦了)。
变异:homeagent 去掉 read_mail 的收窄 ⇒ 恰好那条断言红。
(工作区共享,只 add 了上面这 12 个文件;dsh 的 dist 是 gitignore 的,由
redeploy-plugin.sh 在 staging 里构建。)
|
2026-09-14 23:18:12 +08:00 |
|
|
|
1b8cd43935
|
fix(auth): 工作区成为读权限的边界 —— Agent 侧读端点按会话工作区收窄
用户报的:「agentmail 工作区的邮件会话被 trueagent 工作区的 agent 看到了,
还需要我亲自去解释。」
## 根因不是漏了一个 WHERE,是隔离单位选错了
Agent 注册时 `workspaces` 是空的(B-1.2:cwd 由每封邮件的 `to_workspace` 决定),
所以**一个 Agent 同时服务所有工作区**。而可见性判据一直是
`AgentCanAccessSession(agentName, sid)` = "这个 Agent 名出现在这条会话的 from/to/cc 里"
—— 于是同一个 agent `pi`,在 TrueAgent 里干活的 worker 眼里,对 agentmail 的会话
也成立。
现场证据:`mail_reads` 里 08:11–09:19 有 8 次「同一瞬间读了多个不同工作区的会话」
(08:23:59 一次跨 agentmail / TrueAgent / webui4frpc 三条会话),最后一次是 09:19:11
—— 正好停在 `read_inbox` 按会话收窄那个提交(552fbc7,09:19:25)之前。
更要紧的是 `mail_reads` 只记 `reader_name`、**没有「读的人当时在哪个工作区」这一列**,
所以这类越界读在数据上与正常读**无法区分** —— 这也是为什么只能由用户自己去解释。
## 改法:补一维,而不是逐个端点打补丁
- 新增 `repo.AgentMayReadSession(agentName, scope, target)`:① 参与过(原有判据)
② 两条会话的 `workspace` 相同(新增)。`scope` = 调用方当前所在的那条会话。
- 服务端只认一条**会话 id**(`?session_id=`),由它反查 workspace ——
**不接受调用方直接声明工作区**,否则等于让它自己给自己发通行证。
- 应用到四个读端点:`read_mail` / `read_thread` / `session_participants` /
`list_contacts`,以及 `contacts/suggest` 的**会话候选**(name/path 两段不收窄:
跨工作区**发信**是设计允许的,被挡的只是"浏览同行的线索")。
- 未声明 `session_id` 时保留旧语义(放行)并**记警告日志**:迁移要能分步走,
但"还有谁没接线"必须可观测(另四家桥仍走这条路)。
- pi 桥:五个读工具全部带上自己那条邮件会话 id(由 worker 闭包注入,模型改不了)。
## 顺手修掉一个真 bug
联系人查询的未读计数子查询里一直有 `r.reader_name = $1`,而原写法是
"forUser 为空就不传参" ⇒ $1 悬空:Postgres 直接报 `no parameter $1`,
SQLite 把 `= $1` 当 `= NULL` 比、次次不成立(未读计数静默退化成"全部未归档")。
管理员 `?all=true` 走的正是这条路。现在 $1 恒传。
## 判据(两侧都验 + 变异)
- repo:同工作区放行 / 跨工作区拒且 reason 分得清 / 没参与过拒 /
未声明 scope 的旧语义;列表类有反向对照(不带收窄两条都在);
建议补全同工作区照常给候选、跨工作区查路径不给、不带收窄会给(对照组)。
- ★ 这条判据我第一版**写错了对照组**:拿 path=wsA 去比 —— 而 path 本来就收窄,
于是"不带收窄"也只剩一条,判据等于空的。改成拿 path=wsB 比才有区分力。
- 变异 3 处(拿掉工作区判据 / ListContactsInWorkspace 不收窄 /
SuggestSessionCandidatesInWorkspace 不收窄)⇒ 各自恰好红在对应那条断言。
- pi 桥 14 条:6 个读工具 × 带上/不带 scope 两侧 + worker 闭包 + 自检;
变异 read_mail 去掉收窄 ⇒ 恰好那一条红。
(工作区是多会话共用的,本次只 add 了 server/ 与 plugins/pi-mail-bridge/ 的 7 个文件。)
|
2026-09-14 23:03:25 +08:00 |
|
|
|
ef82bcdfd3
|
test(权限): 钉住"值没变也广播"—— 已降级会话的恢复路径靠这条否定事实
pi 2026-09-14 §3:这是一个**承重的"没有"**。我给"已降级会话怎么恢复"的答案里,
唯一"今天就能用"的手段是"人在 WebUI 里把同一个档位再选一次"——它之所以有效,
正是因为 `UpdateSessionPermission` **没有** "值没变就提前返回"。
判据是**行为**判据,不是读源码(形状判据挡不住"判断挪到 repo 层/挪到 middleware"):
起真库 + 真 SSE 客户端,连点两次同一个档位,数 `session_update` 帧 ——
第一次必须 ≥1(否则判据自己没接上,Fatal 而不是静默通过),第二次必须更多。
变异验证(先用 `db.DB` 注入 → 报的是 build failed,**不算判据红**,改用已有
`repo.SessionPermissionMode` 重注入):
--- FAIL: TestPermissionUpdateBroadcastsEvenWhenValueUnchanged (0.01s)
撤回后 ok。`go test ./...` 全绿(11 个包)。
|
2026-09-14 16:41:32 +08:00 |
|
|
|
552fbc731e
|
fix(inbox): read_inbox 按会话收窄 —— 修「不同 session 的 agent 都能看到全部邮件」
用户问:「你之前不是说你已经处理了不同 session 的 agent 都可以看到全部邮件的
问题了吗?」——**我得先纠正事实:上一轮我只做了诊断并问要不要动手,没有实施。**
这是我的表述问题(把"已定位并给了方案"说成了像"已处理")。现在实施。
## 缺陷
`read_inbox` 是**按 Agent** 的:列的是该 Agent 的全部未读(含别的会话的来信),
并按契约把列出来的都标成已读 ⇒ A 会话的 worker 标掉 B 会话的未读。平时看不出来
(SSE 事件在途时队列兜着),但桥重启/漏事件后的补投判据是 `?status=unread` ——
被标掉的那封**再也不会补投** ⇒ 静默丢信。现场实例:另一条会话的来信在
`mail_reads` 里的 reader=pi、时间正是我读自己收件箱的那一刻。
## 改动
- **网关**:`GET /mail/inbox` 与 `POST /mail/read` 支持可选 `session_id`。
不带 = 旧语义(整个 Agent 的收件箱,浏览器/脚本仍可用);带了就只在这条会话内
列与标。`ListInbox` / `MarkAllInboxReadFor` 保持原签名并委托给新变体 ——
老调用点一个都不用改。
- **pi 桥**:`read_inbox` 把自己那条会话拼进 URL(worker 通过闭包把**邮件会话 id**
递给工具,而不是在启动时取快照)。
## 判据
- repo 三条:列表按会话收窄(含"不带会话时两条都在"的反向对照)、
★"标会话 A 不动会话 B"、会话内计数与列表口径一致(否则界面会出现"徽标 2、列表 1")。
- handler/网关:非法 `session_id` ⇒ 400(不静默忽略)。
- pi 接线三条(URL 拼了收窄、worker 递了 id、判据自检:旧写法必须判红)。
- 线上只读 E2E:两条真实会话 A/B 列表**无交集**、不带会话能列出全部、非法 id 400。
## 过程中测试当场抓到"只改了一半"
`MarkAllInboxReadForSession` 里插 `mail_reads` 的语句我加了会话条件,
**刷新冗余列的 UPDATE 忘了加** ⇒ 返回的"标掉几封"变成 2(应 1)。
判据一眼看出来了 —— 这类"改一半"正是这次要防的。
## 范围(诚实说明)
另外四家桥(dsh/opencode/zcode/homeagent)的 `read_inbox` 工具签名里**没有会话上下文**
(`execute(args)` / `execute(args, ctx)` 各不相同),要按各自框架的上下文 API 接线,
不是一行改动 ⇒ **未做**,列为待办(位置已定位)。所以:pi 上这个缺陷已消除,
另外四家仍在。
## 部署
网关已部署并线上验证;pi 桥的部署**延迟到本轮结束后 150 秒**执行
(重启 pi 桥会掐掉我自己这一轮 —— 之前真发生过),日志
`/var/log/agentmail-pi-redeploy.log`,可用 `node deploy/check-deploy-drift.mjs` 核对。
|
2026-09-14 09:19:25 +08:00 |
|
|
|
5b6fef764f
|
feat(appearance): 主题与壁纸搬到服务端(账号级)—— 回答"为什么背景存在本地"
用户质问:「为什么背景是保存在本地而不是服务器!」当时的实情是主题与壁纸只写
localStorage:换设备/换浏览器就没了,而且**多账号共用一份**(键是全局常量
`agentmail.background`)—— 同一台机器换账号背景不跟着走。而 localStorage 的 ~5MB
配额也解释了客户端那套"压到 2.4MB 以内"的限制本来就是为本地存储设计的。
现在:**服务端是权威(账号级),本地只是缓存**(首屏秒开、离线可用)。
## 服务端
- 新表 `user_appearance`(两种方言),用**列**而不是 JSON:blob GC 要一眼看出
"这张图还有没有人用"。
- `/api/v1/me/appearance`:GET / PUT(主题+背景档)/ POST image(multipart)/
GET image / DELETE image。鉴权同其余 /me/*(cookie 或 Bearer)。
- 图片走**内容寻址的 blob 存储**(与附件同一套),库里只存 sha256;上限 4MB 兜底
(客户端会先压到 ~2.4MB),只收图片类型(非图片 415 —— 浏览器会把非图片渲染成
空白,用户只会看到"设置了却没变化"),超限 413 不静默截断。
- ★ **blob GC 的引用源加了这张表**:我在实现前先读了 `SweepUnreferencedBlobs`,
它只认 attachments / calendar_attachments。漏了这一处,壁纸会在下次 GC 时被当
孤儿删掉,而库里那行还在 —— 表现为"图 404、设置却显示已设置"。判据同时验了
壁纸存活**与**孤儿确实被清(否则"还在"可能只是因为 GC 没跑)。
## 客户端
- `lib/appearance.ts`(纯函数:两侧形状换算、data URL→Blob)+ `stores/appearanceSync.ts`
(pull / push / 去抖订阅 / 账号切换重新拉取)。
- 三条不变量都有判据:拉取以服务端为准;★ **拉取不会再推回去**(否则是自触发回环,
一次拉取顺带一次 PUT,服务端 updated_at 被无意义刷新);本地改动会推上去。
- 壁纸**只在换图时上传一次**(几 MB 不该每次 PUT 都跟着走)。
- 降级**必须可见**:未登录/不可达 → `local-only`,推失败 → `pending`,背景设置里
有徽标与说明("已同步 / 待同步 / 仅本机")。静默降级会让人以为已经同步,
然后在另一台机器上发现没有 —— 正是这次的缺陷。
- 图片用**带认证的 fetch** 取回再转 data URL:`<img src>` 发不出 Bearer,而
`?token=` 会把密钥写进历史记录与服务端日志(明确不做)。
## 判据
- Go 10 条:往返、★多账号隔离、非法值归一、上传/取回字节一致、非图片 415、
超限 413、删除、未登录 401(五个端点)、★GC 存活 + 孤儿对照。
- 客户端 10 条:形状换算、image 无图退回 none、越界夹取、拉取生效、
★拉取不推送、推送 payload、未登录/500 → local-only、推失败 → pending、
★壁纸只上传一次。
- 全量:server 10 包全绿、客户端 249 通过(含打包一致性判据 —— 它先红后绿,
因为前端改了必须重打安装包,这条护栏是先前特意留下的)。
## 线上验证与交付
- jianf 设置 → 回包 saved=true;**gui-lab 读到自己那份默认值**(隔离生效);
gui-lab 上传 67B PNG → 取回 sha256 一致、`has_image=true`;DELETE 后 404。
- 网关已重打(WebUI 内嵌)并部署;Electron 安装包已重打(AppImage + deb)。
遗留:鸿蒙端还没有外观功能(数据已在服务端,将来可直接读);本地缓存仍在(离线可用)。
|
2026-09-14 08:32:22 +08:00 |
|
|
|
1619399470
|
fix(gateway): 已读改为**按读者**记录 —— 修掉"别人读掉,我就看不到"
用户报的那句 dsh 自述("收件箱列表未展示它,直接按 mail_id 读取成功")不是插件问题,
是网关的已读模型:`mails.status` 是**邮件级**的一个列,任何收件人读掉,对所有收件人
(含抄送)都变成已读 —— 全库没有任何按人记录已读的表,我查过 schema 与迁移文件。
实测复现(两个人类用户、一封共享邮件,排除 Agent 干扰):
gui-lab 读掉 → gui-lab 未读清空(应当)→ **jianf 的未读也没了**(错误)
而 jianf 的 `status=all` 里仍在 ⇒ 是已读语义问题,不是送达问题。
线上那封信正是这个形状:`jianf → dsh` 抄送 pi/opencode/zcode/homeagent,**pi 最先
回复(= 它读过了)** ⇒ 这封对 dsh 也变成 read ⇒ dsh 的 `read_inbox`(默认 unread)
返回空 ⇒ 它只能按提示词里的 mail_id 兜。
三个受害面:① Agent 的 `read_inbox` 拿不到信(换一个不兜的模型就变成"正文是空的");
② 人类的未读被抄送的 Agent 读掉;③ ★ 桥的补投判据 `pending_mails = CountUnread` 归零
⇒ SSE 漏过或进程重启时那封信**不再补投**(静默丢信)。
改动:
- 新表 `mail_reads(mail_id, reader_name, read_at)`,未读 = 这张表里没有该读者的行。
- 判据收敛到一处(repo 的 `unreadFor` / `readStateFor`),六处读写点全部改用它:
单封已读、批量标已读、权限决策(只记**决策人**)、`ListInbox`(过滤 + 返回的
status 都按读者算)、`CountUnread`、`CountUnreadInSession`、会话列表未读计数。
- 一次性回填补历史:`mails.status='read'` 记到**主收件人**名下(唯一可用的推断),
用 `app_meta` 里的标记守住 —— 不能每次启动都跑,那会把"某抄送方读过"按主收件人
写成已读,正是这次要修的错。实测:`done rows=207`。
- `mails.status` 保留为"有人读过 / 已归档"的冗余列,**不再是判据**。
★ 顺带挖出并修掉一个真 bug:`CountUnreadInSession` 用的是 PG 专有语法
(`cc_list @> $3::jsonb`),而线上是 SQLite ⇒ 那条 SQL **语法错误**
(`unrecognized token: "@"`),调用点又是 `unread, _ :=`(吞错)⇒
**会话列表的未读数一直是 0**。现已改用仓库既有的方言助手 `db.CCHas`。
实测:happy-pixel 会话现在 `unread_count=5`(修复前恒 0)。
判据:新增 `internal/repo/readstate_test.go`(5 条:按读者未读、会话内计数、
批量标已读、归档对所有人可见性、权限决策只记决策人)。
**扰动验证**:把 `unreadFor` 退回旧语义 → 4 条判据全红;恢复 → 绿。
全量 server 10 包全绿。文档同步:API.md 的「标记已读」段 + PLUGIN-CONTRACT 的 T-1.4。
线上复验:同一受控实验 —— gui-lab 读掉后,**jianf 的未读仍在且 status=unread** ✅
|
2026-09-13 14:25:44 +08:00 |
|
|
|
a696b2a141
|
fix(handler): 回信的 to_workspace 从会话 workspace 继承 —— 修「每封邮件多一条会话」
# 现象(生产实测)
在 DSH 界面上观察到的:每处理一封邮件就多出一条独立会话。
# 根因
`to_workspace` 是插件唯一能知道「这个任务该在哪个目录干活」的入口,而它取的是
**地址里的 path 位**。Agent 之间的回信、以及人在对话页点回复,地址里通常没有
path 位 —— 平台下发的 `reply_address` 就是这个形状(`FormatAddress(replyTo, "", alias)`)。
空着传下去的后果是可观测的:插件只能自己拼一个临时目录,于是**每封邮件落在一个
不同的空目录**里;DSH / opencode 按 cwd 给会话分组,界面上就成了「每处理一封邮件
就多出一条未分组会话」,而模型在那个空目录里什么项目文件也看不到。
实测取证:
- 线上 5 个兜底目录 `~/.dsh/mail-sessions/mail-*` **全部是空的**(0 条目)
- 全天 journalctl 里**没有任何**相关告警(代码用的是 `ctx.logger.warn`,
而同一文件别处明确写着 DSH 的 logger 不进 journalctl)→ 完全静默
- 走兜底的那条会话(8e982e96)里,`dsh → opencode` 那封 `to_workspace` 有值,
而 `opencode → dsh` 的回信 **to_workspace 全为空** —— 而该会话自身的
`sessions.workspace` 一直是有值的
# 修法
会话的 workspace 才是权威来源(见 models.SessionWorkspace 的注释):回信本来就是
回给**那条会话**的,而那条会话知道自己属于哪个项目。规则抽成纯函数
`resolveToWorkspace(addrPath, sessionWorkspace, toIsHuman)`:
1. 地址里写了 path → 照用(人的明确意图优先)
2. 没写且收件方是**人** → 保持空(人没有工作目录;填了前端会拼出
`gui-lab@/path.别名` 这种错地址,ToHuman 字段就是为此加的)
3. 没写且收件方是 Agent → 用会话的 workspace
**改的是 `to`,不是只改建库那一行**:同一个值还进投递载荷(`to_workspace` /
`self_address`)。改一处另一处不改,会出现「API 读到的与插件推到的不是同一个
目录」——那正是本项目一直在治的静默不一致。
`notify/mail.go` 只加了一段注释说明 reply_address 的 path 位为何**刻意留空**
(它的语义是「**发件人**该在哪儿干活」),免得后人以为那是漏填。
# 测试
- `handler/toworkspace_test.go`:6 条规则用例 + 1 条**反向对照**
(固定其他输入只翻转 toIsHuman,要求结果必须不同 —— 防止该参数被忽略后
「给人也填 path」静默回归)
- `repo/session_workspace_test.go`:锁住**列名与真实 schema**。这个查询读不到时
按设计返回空串,与「这条会话没有工作目录」无法区分 → 列名写错的功能表现是
「看起来还在跑,只是工作目录永远继承不到」
|
2026-09-12 11:19:19 +08:00 |
|
|
|
4e32dd3145
|
fix(permission): Agent 不能把审批指派给与任务无关的人
# 漏洞
`POST /permission/request` 的 `to` 字段由 Agent 自由填写,服务端只检查
「这个名字是不是一个合法的人类用户」:
decider := req.To
if isHuman, _ := repo.IsHumanUser(ctx, decider); !isHuman { …回落… }
于是任何 Agent 都能把「是否允许执行 bash」这类危险操作的审批丢给**任意一个
与这条任务无关的人**(例如管理员)。被点名的人看到一封没有上下文的待办,
只能凭猜点头或拒绝。
这跟同一份代码里的另一段注释直接冲突。那段在论证为什么不把权限转给管理员:
管理员对这条 Agent 链的上下文一无所知,既不知道这个 bash 命令在做什么,
也不知道拒绝后 Agent 该怎么绕过去。
这个理由同样适用于「Agent 自己点名一个无关的人」—— 而且更弱:至少管理员还能
查日志,一个随机被点名的用户连从哪查都不知道。两处都指向同一条规则:
**权限应当追溯到最初分配任务的人**,也就是这条线索上的人。
这是静态审计发现的四项之一。当时三桥实测都不传 `to`,所以是潜在面而非活跃
漏洞 —— 但 `to` 是公开的 Agent API 字段,第三方插件照着文档填就会踩上。
# 修法
新增 `repo.IsHumanOnSessionThread(ctx, sessionID, name)`:人类身份 **且**
(会话 owner 或在这条会话的某封邮件里出现过)。
两个来源缺一不可,各有实测场景:
- **只要参与方**会漏掉「会话由 Agent 建立、owner 由平台指派」的会话 ——
那种 owner 可能一封邮件都没收发过,只看邮件会把合法 owner 判成外人,
于是每次审批都回落到线索上随便一个人类。
- **只要 owner** 会漏掉「人在别人的会话里被抄送进来说了话」这种正常协作。
采信与否的处置是**丢弃提示而不是报错**:`to` 只是一个偏好,丢弃后常规解析仍会
给出一个合法人类(owner 或线索上最近的人),实在没有就是既有的 409 —— 无论哪条
分支,都不会把审批送到错的人手上。硬失败则会让 Agent 一次乐观的提示断掉整个
任务,而它并没有做错什么。因为丢弃是静默的,所以**必须留下日志**:
[permission] 忽略不属于本线索的决策人 "jianf"(会话 …, 由 pi 指定)—— 改走常规解析
# 测试
补了这条路径此前**完全缺失**的两层覆盖(审计发现:决策路径
RequestPermission/DecidePermission/ListPendingPermissions 都没有测试):
- `repo/threadhuman_test.go`:白名单的六种输入(线索上发信/收信的人类、没发过
邮件的 owner、线索外的存在用户、不存在的名字、空串、抄送方),每条都写清
为什么期望这个结果。
- `handler/permission_request_test.go`:**真实 HTTP 层**跑 `RequestPermission`,
断言响应里的 decider 与库里那封权限邮件的 to_name。repo 层 helper 正确但
handler 漏调一次,漏洞就会回来,所以必须有端到端这一层。含纯 Agent 链的
fail-closed 断言(不得退回管理员)。
# 验证
- `go test ./... -count=1` 全绿;`go vet` 干净;`gofmt` 差异行数与改动前完全
相同(8 行,既有的一处空行)—— 即本次改动零新增格式问题
- 真机(workspace 档会话,owner=gui-lab,线索参与者 gui-lab+pi):
- `to=jianf`(线索外人类)→ decider=gui-lab,库中 to_name=gui-lab,
日志有忽略记录
- `to=gui-lab`(线索内)→ 采纳
- `to=pi`(Agent)→ 忽略,回落 owner
- 已部署(redeploy-gateway.sh 自动项全绿)
|
2026-09-11 23:22:53 +08:00 |
|
|
|
bbddee26b9
|
feat(permission): 待办带上失效时刻;越窗的决策不再假装成功
# 起因:一次端到端验证暴露的静默缺口
建了示例工程让 pi 通过邮件干活(plan 档拦截、workspace 档审批、多 agent 指派)。
plan 档与多 agent 都通过,workspace 档却卡住:**人在界面上批准了一条待办,
接口回 200,但那件事什么都没发生。**
追下去是三件事叠在一起:
1. **桥**等不到决策时(pi 的回合超时 TURN_TIMEOUT_MS,默认 10 分钟)会拆掉 worker
与它的决策路由表;此后再来的决策只会作为**通知**投给 Agent,不恢复当时那次
工具调用 —— 该轮已经结束了。
2. **服务端**只有 `permission_requests.result IS NULL`,没有「失效」概念。
迟到决策照样回 `{"status":"decided"}`。
3. **前端**只看 `permission_result` 判待决/已决,没有任何时间或失效提示。
于是那条待办永远挂在授权页上显示「等待你决策」,人点了也白点。这是 I-5
(失败必须当场可见)要消灭的那类静默成功,而且**跨所有客户端**成立 ——
WebUI 不显示,Electron / Harmony 同样无从显示。
# 设计:邮件上给「时刻」,不给「是否失效」的布尔值
服务端不知道插件此刻是否还在等(那是它进程内的状态),所以只标出「这封待办已经
放了很久」,不替插件宣布裁决。
关键取舍:对外只发**截止时刻**(`permission_expires_at`),不发 `stale` 布尔值。
布尔值是「发出那一刻」的快照 —— 经 SSE 推送并被客户端缓存后会永久停在旧值,
界面就会一直显示「等待你决策」。时刻是持久事实,任何客户端在任何时候都能自己
比出现在过没过期。这也是为什么推导而非落库:它是 created_at 的函数,存下来会失真。
`DecidePermission` 的响应里则用布尔值(`expired`)—— 响应本身就是「此刻」的
一次性快照,不会像邮件那样被缓存反复展示。
# 改动
- `models.PermissionWaitWindow`(10 分钟,与 pi 桥的回合超时同量级)+
`PermissionDeadline(createdAt)`;两端共用这一处算式,避免「界面说已过期、
决策说没过期」。
- `Mail.PermissionExpiresAt` / `PermissionRequest.ExpiresAt`:由读路径推导填充。
5 个读路径各插一行(`AttachPermissionDeadline*`)—— 与审计修复① 加
permission_kind 时同一套路数,漏掉任一路径只会静默变成 nil。
只给**仍未决策**的待办填,已决策的不再是待办。
- `decideResponse`(抽出纯函数以便测试):越窗时加 `expired` + `warning`,
讲清「决策已记录、但不会恢复原调用」。**不改 HTTP 状态码**:决策仍是人的真实
意愿、仍然有效(桥会当通知投递,Agent 重起一轮),所以不能拒掉,但必须说清。
- 前端:列表里失效项不再与「还能立刻生效」的长得一样(灰底 + 「可能已失效」);
批准面板在决策**前**(人正要按下去)与决策**后**(人以为事情办了)都显示提示。
# 验证
- Go:models/repo/handler 三处新增测试全绿;全量 `go test ./...` 通过;vet 通过
- 前端:typecheck 通过;200 项测试全绿(含新增 4 条失效态)
- 真机(用现成的过期待办,未造合成数据):
- `/permission/pending` 返回 `expires_at` = 创建 + 10 分钟,服务端判定已过窗
- 邮件载荷带上 `permission_expires_at`(前端列表的数据源)
- 对过期待办提交批准 → `{"expired":true, "expires_at":…, "warning":"该请求已超过
等待窗口(10 分钟)…不会恢复当时那次工具调用…"}`
- 已用 redeploy-gateway.sh 部署,服务 active、四 agent 心跳正常、日志无 panic
|
2026-09-11 22:02:25 +08:00 |
|
|
|
4186ad4784
|
fix(setup): 首个管理员的创建只允许 Gateway 本机
# 之前的缺口
`POST /api/v1/setup/admin` 是公开路由,唯一的门是「系统还没有任何用户」。
Gateway 监听 `*:8180`,于是局域网里任何人可以绕开 nginx 直接调它。
本机已初始化时它只回 409,所以这条是纵深防御;但在**尚未初始化**的部署上,
它是「谁先提交谁成为管理员」——一个可被抢注的管理员入口。
# 为什么不是收紧监听地址
`.106` 上的反代(公网 `mail.jianfgit.xyz`)与本机鸿蒙客户端都直连
`192.168.2.60:8180`,把监听收到 127.0.0.1 会把这两条入口一起切断。
缺口在端点本身,不在监听面,所以只收紧端点。
# 改动
- 新增 `middleware.LocalOnly`:只有真实 TCP 对端为回环地址才放行。
- 新增 `middleware.CapturePeerAddress`,**注册在 `chimw.RealIP` 之前**。
RealIP 会信任 `X-Forwarded-For` 并改写 `RemoteAddr`,直接读它等于让外部
调用者用一个请求头冒充本机;所以先存原始连接地址,安全判断只认那份。
- `SetupAdmin` 的注释同步:本机限制在路由层,`NeedsSetup` 保留为第二道防线。
- 测试(`localonly_test.go`)按生产中间件顺序组装链,覆盖:
IPv4/IPv6 回环放行、局网拒绝、**伪造 X-Forwarded-For 仍拒绝**、
非法地址拒绝,以及未装 CapturePeerAddress 时 fail closed。
# 验证
- 回环 `/setup/admin` → 409(进入处理器,系统已初始化)
- LAN `/setup/admin` → 403
- LAN + `X-Forwarded-For: 127.0.0.1` → 403
- LAN `/setup/status` → 200(登录页判断是否显示向导仍正常)
- 登录 + 收件箱 → 200;Go 全量测试与 vet 通过;已部署,四桥/SSE 正常
|
2026-09-11 15:49:26 +08:00 |
|
|
|
429149e118
|
chore(format): 撤销误入提交的整体重排,并关闭本仓库的格式化器
# 发生了什么
pi-lens 内置「安全格式化」:它会自动安装 biome 并对**编辑过的文件**跑
`biome format --write`。本机原先没有任何 biome 配置,于是 biome 用它自己的
默认值 —— tab 缩进 + 双引号 —— 把文件整体重写。
我在 19a3161 那次提交里用了 `git add -A`,把这批与功能无关的重排一起扫了进去:
约 7000 行改动散落在 20 个文件上,使那次提交无法审查,还掩盖了
server/internal/handler/permission.go 的一处删行(实为文件末尾空行,无代码丢失)。
# 为什么是「关掉」而不是「配置成我们的风格」
试过把缩进/引号/lineWidth 全部对齐本仓库习惯(biome.json + space/2/single/
lineWidth 120):`biome format --write` 仍然改动 17 个文件。原因是本仓库从未按
biome 的规则排版过 —— 注释按语义换行、数组与调用按可读性手工折行,
这些无法由格式化器还原。也就是说只要格式化器开着,每次编辑都会产生与内容无关的
大面积 diff,把真正的改动埋掉。
因此 biome.jsonc 里 formatter 与 linter 都关闭:本仓库的静态检查由
tsc / go vet / tree-sitter / ast-grep 与各自测试套件承担,不引入会改动无关行的
自动修复。
(pi-lens 这一版把 format 服务的 enabled 硬编码为 true,没有配置开关,
所以只能在仓库侧用 biome 配置让它不动文件;已验证 `biome format --write`
对这些文件零改动。)
# 本提交内容
把 19a3161 里除「有意改动」外的 20 个文件还原到重排前的样子。
19a3161 中真正有意的改动是 deploy/install.sh 的扩展注册与
plugins/pi-mail-bridge/extension/index.ts 新文件,两者原样保留。
验证:Go 全量、三桥插件(320/362/409)、前端 196 全绿;
`biome format --write` 对还原后的文件零改动。
|
2026-09-11 12:03:51 +08:00 |
|
|
|
19a3161ee4
|
feat(pi): 交互式 pi 会话接入邮件工具(send_mail/read_inbox 等 10 个)
问题(⑧):守护进程用 noExtensions:true 起会话,它的邮件工具只给模型在邮件
会话里用;人在 TUI 里敲的 pi 拿不到。结果是平台的建设者自己收不到邮件 ——
一个「邮件驱动」的平台,维护者只能绕到 curl + 密钥直连 Gateway 才能看收件箱。
新增 plugins/pi-mail-bridge/extension/index.ts:把同一套工具(createMailTools)
注册到交互式会话。两者是同一条 AgentMail 身份(agent pi)的两个入口,与 DSH 的
「TUI + 邮箱是同一个 Agent」一致。
密钥解析顺序(交互式 pi 的环境里没有 AGENTMAIL_*):
1. 进程环境
2. AGENTMAIL_ENV_FILE(默认 /etc/agentmail/pi.env)—— 与守护进程同一把密钥,
因此身份一致
3. AGENTMAIL_CONFIG_DIR/agent.key 或 ~/.agentmail/agent.key
(兼容 key 与 key_token 两种字段名;实测本机文件用的是 key_token,
只认 key 会静默读不到)
拿不到密钥时不注册任何工具并明确告知 —— 挂一组永远 401 的工具比没有更糟。
不注册 connect_to_server:它会重写 Gateway 坐标并重新登记密钥,而交互式会话与
守护进程共用同一身份,一次 TUI 对话不该改到守护进程的配置。
为什么不会重复注册(读 SDK 实现确认,并用探针实测):
resource-loader.js 里 noExtensions 为真时只用 cliEnabledExtensions,
settings.json 的 extensions 数组被排除 —— 即 noExtensions:true 只加载
命令行 -e 传入的扩展。
探针:noExtensions=true → 扩展数=0;false → 16 个且含 pi-mail-bridge。
deploy/install.sh 增加幂等的扩展注册步骤(写入 settings.json 的 extensions)。
验证:headless pi 实际调用 read_inbox 返回真实邮件主题;工具清单含
send_mail/read_inbox/read_mail/forward_mail/upload_attachment/download_attachment/
suggest_address/list_contacts/session_participants/read_thread(10 个),
connect_to_server 按设计排除。
|
2026-09-11 11:32:47 +08:00 |
|
|
|
f91efd2d8d
|
feat(question): DSH ask_user_question 桥接 + 前端问答面板 + 待办字段全路径透出
问题(P0):DSH 有两个独立的人机交互 seam —— approval/request(危险工具审批)
与 ask_user_question → ctx.userQuestions(模型主动提问)。原来只桥接了前者。
邮件驱动的会话没有本地 UI,而 ask() 的 provider 是 DSH host 注册的本地 UI 实现,
于是在那里等人点选永久等不到,那一轮工具调用**静默挂死**。
修法(不抢注全局 provider —— registerProvider 只允许一个活动实例,抢注会让
平台自己的界面失效):在 tools/execute around-dispatch 里只对**邮件驱动**的
会话接管 ask_user_question,其余原样 next()。失败一律当场报错而不是 next():
下一个 answerer 是本地 UI,邮件会话没有兜底 UI,放过去就是挂死。
- lib/user-question.js(三桥逐字节同源,14 例测试):DSH questions[] ↔ AgentMail
单问题询问邮件的双向映射。多问题时把选项并集摊平、按 label 归属分配回各问题
(label 认不出来就不猜测放行);无选项题走自由文本 custom。
- Gateway:kind=question 且无选项时**不再**回落「同意/拒绝」(那会让自由文本
问题变成两个毫无意义的按钮);主题按类型区分「权限请求 / 需要回答」;
推送 payload 带上 permission_kind / multi_select / options。
- mails.permission_kind / permission_multi_select 此前只存在于结构体与写入路径,
五个读路径的 SELECT/Scan 都没带 —— 前端永远拿到空串,把提问渲染成批准/拒绝。
container 修正五处并加 repo 测试(含反向验证:删掉任一处字段,测试即失败)。
- 前端 PermissionPanel:question 走「勾选 + 自由文本」,多选/单选、空回答禁止提交;
approval 路径不变(回归测试覆盖)。
测试:opencode 316 / dsh 349 / pi 405 / 前端 185 / Go 全量 全绿。
|
2026-09-11 10:44:18 +08:00 |
|
|
|
f9d757b5e5
|
chore: directory migration - gateway→server, web→client/electron
|
2026-09-08 19:16:35 +08:00 |
|