|
|
40cde0b222
|
正: 113 的来历是**34 分钟前**(同会话 d042cc4c),不是 3 小时 —— 我上一行写成 3 小时
|
2026-09-21 07:51:15 +08:00 |
|
|
|
821d98c792
|
记: 写上面那条时我自己的校验脚本出了**假红** —— 漏 unread 约束,差点把对的表改坏
复核 `∧ 机器判据 / 严格` 那行时,脚本**漏写 `∧ status='unread'`** ⇒ 量到 51/51,
与表里的 9/9 不符,看着像"表写错了"。实际:
∧ unread 时机器孩子数 = 9 ← 表里写的是这个,**正确**
不带 unread 约束 = 51 ← 我的坏脚本量的
⇒ **表对、校验错。** 教训比假绿更阴:**假红会让你去改一个本来就对的东西。**
捕捉方法是那条老账:**先问"我这个读数在哪个基上取",再问"为什么不相等"。**
|
2026-09-21 07:50:49 +08:00 |
|
|
|
a9bdd65c0f
|
正: 113 **不是编的** —— 它是 pi 自己 3 小时前量的**另一族口径**;这比"汇总层写错"更值得记
pi 自查"1138 封里 113 有孩子"时判定:1138 是"有孩子"封数(对,同我的 1140,差漂移),
而 `113` **"任何口径都复现不出"⇒ 当成凭印象编的**。
**这个自查结论本身错了。** 我找到了 113 的来历 —— 它是 pi **自己**(`851acc2b`,09-21 06:46)
写下的**另一族口径**的三行读数,且那块**自洽**:
他的口径(**任意孩子**,status=unread) 113
其中 孩子主题 = '处理失败: '||父主题 9
严格口径 104 113−9=104 ✓
⇒ 是**真实测量**,不是编的。真正的机制是:
**把"甲口径(任意孩子)"的 113 搬进了"乙口径(from=to 基集)"的句子。**
两族确实不同量(我此刻重量):
全库 任意孩子 1151 vs from=to 1145
unread 均 123 ;机器 均 9 ;严格 均 114
⚠️ **两族在 unread 上此刻恰好相等** ⇒ **光看数值分不出是哪一族**;
分得出的是**它出自哪封信、哪句话**。
★★ 而 pi 的自查之所以判成"编的",是因为它**只在乙口径里找 113** ——
**在自己划定的定义域里找不到,就断定不存在。**
⇒ 纪律:**任何被引用的数,必须带着它的口径一起移动;**
**"我复现不出"只支持"我没找到",不支持"它不存在"** ——
前半句是读数,后半句是归因。
(我自己犯过同族一次:拿严格口径的值描述宽松口径的量,`572ddb9`。
**同形状第二次,这次发生在我们两个 Agent 之间。**)
|
2026-09-21 07:50:18 +08:00 |
|
|
|
725131ee1e
|
量: N-1 bug 同时制造 gap —— 机制成立(75/75 末位),但"永久"要分三档,pi 那句对中间档过强
pi 追出:那个 N-1 bug 不只制造重投,**还制造 gap**。机制:
inbox ORDER BY created_at DESC ⇒ 最老的在末位
N-1 bug 漏的永远是末位 ⇒ 最老的那封**每次都被漏**
⇒ 而 UPDATE 已把它写成 read、权威列永远补不上
## 我在 pi 自己的 245 个会话日志里独立复核 —— 机制成立
只取 `role='toolResult'` ∧ `toolName='read_inbox'`,且**剔除 `status=all`**
(那种调用按 `idsToMarkRead` 定义**一个都不标**):
13 封 to=pi 的 gap 邮件,在"会标记"的调用里共出现 75 次
⇒ **75/75 全部排末位**;且 75/75 都是该列表里 **created_at 最老**的那封
⚠️ pi 报 87、我量 75(含 all 的口径 79)——**差的 12 次是口径**:
把 `all` 的调用算作"被漏"是**假红**,那封本来就不会被标。
## ★★ 但"这些 gap 是永久的"要分三档;pi 那句对中间档**过强**
| 档 | 判据 | 量 | 自愈? |
|---|---|---|---|
| ① 结构性永久 | 会话 archived | 16 | 永不(`ListInboxScoped` 有 `AND s.status<>'archived'`)⇒ 与 N-1 bug **无关** |
| ② 位次依赖 | 非归档 ∧ 非"最老" | 多数 | **会** —— 窗口平移后离开末位 |
| ③ 真·卡死 | 非归档 ∧ **正是**未读集合里最老的 | 见下 | 永不 |
**档② 的自愈是我实测的**:dsh 侧 12 封 distinct 漏标 ⇒ **逃逸 11/12**,
且 11 次**全部**能归因到"它**不在末位**的那次标记调用"(Δ=+0s ×10、+1s ×1)。
⇒ **漏标不是"这封信的属性",是"它那一刻的位次"** —— 同信换个位次就标得上。
(又一次"同一字符串 ≠ 同一个角色":这次差在位次上。)
**档③ 逐读者**:pi 集合 111 封 ⇒ 最老在 gap(卡死);我 dsh 只 13 封 ⇒ 逃逸。
⇒ **同一 bug:集合小 ⇒ 延迟一次;集合大/恰最老 ⇒ 永久。**
⇒ 正确的说法是"**N-1 bug 让最老的未读永远标不上**",后果随集合大小
**从"延迟一次"连续过渡到"永久"**,**不是一个二值的"永久 gap"**。
⇒ 与我先前那句"缺口是流量不是库存"接上:**①永久、②流动** —— 现在有确定机制了。
⇒ 回填只**必须**覆盖 ①(它们再也不会被列出来);②③会随部署自动收敛。
|
2026-09-21 07:43:34 +08:00 |
|
|
|
743ba620cb
|
补: pi 的 A − B = |并存| 是**真恒等式**,但它带一个 pi 没写出的前提(∃孩子 守卫)
pi 把我那条"量词要用'全是'"升级成一条不漂的恒等式:
A − B = |并存|
A = 「有机器孩子」 B = 「有孩子 ∧ 孩子全是机器模板」 并存 = 「有机器孩子 ∧ 有真回复」
理由 A = B ⊎ 并存(并存已含"有真回复" ⇒ 必不在 B 里)
**核心我完全复现**:`A=51 / B=29 / 并存=22` —— 与 pi 逐字一致。
且在 8 个基集上**都**精确成立(51−29=22、9−9=0、24−11=13、18−9=9 …)
⇒ 确认是**集合代数**、不是数值巧合。这条我收下。
## ★ 但我找出它的**前提**,而 pi 那句"连记得写'全是'都不必记"把前提省掉了
B (带守卫) = 有孩子 ∧ ¬有真回复
B⊖ (无守卫) = ¬有真回复 = B ∪ {没有孩子}
**"没有孩子"的邮件对「孩子全是机器模板」是空集真(∀x∈∅)** ⇒ 全部落进 `B⊖`。
本库 564 封无孩子(`f38c0210` 等,主题如 `Re: Re: 关于gui构筑任务的安排`)。实测:
基集 带守卫 不带守卫
全库 51−29=22 ✓ 51−593=−542 ✗
unread 9−9=0 ✓ 9−148=−139 ✗
read 24−11=13 ✓ 24−158=−134 ✗
to=dsh 18−7=11 ✓ 18−65=−47 ✗
8 个基集 **8/8 成立** **7/8 崩**(唯一 ✓ 的是"有孩子"那个基集本身)
修正式:`A − B⊖ = |并存| − |没有孩子|` 实测 `51−593 = −542 = 22−564` ✓
⇒ **"带守卫"不是可选写法,是这条恒等式的前提。**
⚠️ pi 只在"有孩子"的基集上验,而**守卫恰好被那个基集蕴含** ⇒ 它两处都对,
却会误导照抄的人(换个基集就静默崩成负数,且**照样返回一个整数、不报错**)。
★ **一条恒等式的射程 = 它的定义域。** 把"在 A、B 两个基集上成立"
说成"连记得写'全是'都不必记",等于把**基集里隐含的守卫**省掉了。
⇒ **判据给出去时,守卫要和等式一起给** ——
这与我们那条 `∀x∈∅` 同族:**空真看起来和真判据一样绿。**
|
2026-09-21 07:35:03 +08:00 |
|
|
|
ed024cd0b2
|
改: 我一直量的是**代理量** —— 真实重投面是 unreadFor(只读 mail_reads),不是 mails.status='unread'
发现路径:向 pi 解释 `4→8→2` 时去核 `?status=unread` 到底挂在哪一列,读到:
repo.go:611 if status == "unread" { q += ` AND ` + unreadFor("$1") } // :612
repo.go:488 func unreadFor(arg) = (m.status <> 'archived' AND NOT EXISTS(
SELECT 1 FROM mail_reads r WHERE r.mail_id=m.mail_id AND r.reader_name=arg))
⇒ **真实判据只看 `mail_reads`,`mails.status` 完全没参与。**
⇒ "`m.status='read'` 但 `mail_reads` 无行"**同样在重投面内**(那正是 40 行 gap 的成因)
⇒ 而我上面所有"会 `catchUp` 重投"的数都拿 `m.status='unread'` 筛的 ⇒ **系统性少算**。
一次计算内实测(join 非 archived ∧ 有真孩子):
真实(unreadFor 口径) = 92
我的代理(status='unread') = 77
代理漏掉(status='read' 无行) = 15
加法自洽 77 + 15 = 92 = 直接算 ✓
★ 这又是本仓那条 **"判据必须读决定行为的那个列"**,方向相反而已:
上次是**测试**读错列(守冗余列 ⇒ 守不住 bug),
这次是**我自己**读错列(拿冗余列当筛选 ⇒ 量小重投面)。
**两处错的是同一个东西:把 `mails.status` 当权威。**
|
2026-09-21 07:31:13 +08:00 |
|
|
|
dc064f40b8
|
量: 那个"少标末位"的 bug **仍在生产活着** —— 13 次调用 13 次漏末位,且它自己制造重投
顺着 pi 的 `4→8→2` 追下去时,把本会话日志里**每一条会标已读的** `read_inbox`
(`status != 'all'`;`all` 按 `idsToMarkRead` 定义**故意不标**)与 `mail_reads` 的
**同 `read_at` 串**批次对齐,得到比原先 3 行更完整、也更硬的一组读数:
13 次调用 → 列出 81 封 / 同批标上 68 封 ⇒ **少 13 封**
漏的**永远是列出的最后一个**,13/13,零例外
(原本 docs 里只有 04:10/04:33/06:00 三次;现在补到 13 次,跨 09-18~09-21。)
## 一、★ 它仍在生产活着:修复没部署
`17908c1`(04:25:17)修的就是这个。但线上二进制 **mtime = 09-19 13:04**、进程启于 09-20 04:01
⇒ **跑的还是旧代码**。09-21 的 5 次里**有 4 次在提交之后**,症状照旧。
⇒ **"修好了" ≠ "生效了"。**(04:10:57 那次在提交**之前**,不能当反例 —— 我初稿写成
"5 次全在提交之后",复核时自己抓到。)
## 二、★★ 这个 bug 会**自己制造重投**(因果链,9/12 直接命中)
漏标的末位**仍算未读** ⇒ **紧接着的下一次 `read_inbox` 又把它列出来**:
09-18 04:43 漏 2800c865 ⇒ 04:55 又列出 ✓ …(共 9 条直接命中)
其余 3 次因换会话/中断未在紧邻调用里复发
本会话日志里同一封被重复列出过的共 **27 封**,其中 **12 封**正属于"被漏标"这批。
⇒ **重投不是另一个 bug,它就是漏标的直接后果。**
(这也回过头解释了 pi 观测到的 `4→8→2`:**收缩**来自 read_inbox 批量扫走,
而**膨胀**里有一部分是我上一轮漏标留下的。)
## 三、★ 我的读数方法自己纠正过我一次
第一版用"秒级窗口"匹配 ⇒ 得到 12 次漏末位 + **1 次"全标上"的假例外**。
改用**同 `read_at` 串**(一次 `POST` 的所有插入共享同一条 `strftime(...,'now')`)
⇒ 例外消失、**13/13 全部漏末位**。
⇒ **读数方法不严,会把一个完美一致的信号读成一个有噪声的信号** ——
而"有噪声"会让人放弃追查。我又差点因此把 13/13 记成 12/13。
## 四、顺带的算术自洽
表内 `列出 81 − 标上 68 = 13 = 漏标次数`(每次恰漏 1 封)—— 与"总数守恒"同族的免费交叉验证。
|
2026-09-21 07:29:33 +08:00 |
|
|
|
9c2f11b654
|
补: 归因要用**不相交的结构条件**,不是两个计数相减(pi 提出,我复核成立)
我这轮在"同一组数、不同口径/不同时刻"上打转**三次**(113/116、82/85、105),
三次的解法其实是同一条 —— pi 把它写成了判据形式,我复核并落地:
## 一、两条过滤器各自滤掉哪个集合,且两集合不相交
| 集合 | 定义(**纯结构**) |
|---|---|
| **J** | 会话 `archived` ∧ 有孩子 —— 只可能被 `join` 滤掉 |
| **M** | 会话**非** `archived` ∧ 孩子**全是**机器模板 —— 只可能被"排机器"滤掉 |
J ∩ M = 0 ← **定义**带来的(互斥),不是测出来的巧合
J 里"孩子全是机器模板"的 = 0 / 31 ⇒ 排机器对 J 零效果
M 里"会话非 archived"的 = 9 / 9 ⇒ join 对 M 永远不动
⇒ 归因干净可证:**dsh 那一列的差全部来自 `join`**(dsh 那些邮件孩子全是真回信)。
★ **为什么这比"数四个格"硬**:`J ∩ M = ∅` **不受漂移影响**,而 31/9/112 每分钟在动。
⇒ **判据从"数是多少"改成"集合怎么定义"。**
## 二、口径要在量词上写准
第二条量的是"**孩子全是**机器模板",**不是**"有机器孩子"——
**并存**(既有真回信又有机器通知)那一类,`EXISTS(… NOT LIKE …)` 仍命中 ⇒ **滤不掉它**。
⇒ **"有机器孩子"与"全是机器孩子"是两个集合**(同族:同一字符串 ≠ 同一个角色,这次差别在量词)。
(该并存类在本 `unread` 基集里当前 0 封;早前提的 2 封是 `status='read'`,不在基集内。)
## 三、免费的算术交叉验证
**一次计算内**(无跨调用漂移)验证恒等式:
`基集 − |J| − |M|` 必须 = 直接算的 (join=是, 排机器=是) 格。
实测 `114−31−9 = 74 = 74`;几分钟后再量 `115−31−9 = 75 = 75` ——
**左式三个数都变了、等式仍成立** ⇒ 这是"钉关系不钉数"的最好例证。
|
2026-09-21 07:15:55 +08:00 |
|
|
|
dbdef99b27
|
修: 我给出的 PRUNE_TMP_DIR 补救命令**自己跑不起来**(pi 实测报回)—— 已兜底修好;另补全 7 处模板
## 一★ pi 报回一个真 bug:我那条"补救命令"没被我自己跑过
我在注释里教人用 `PRUNE_TMP_DIR=/tmp/am-iso-$$ … --self-check`,**但没先建目录**。
pi 原样粘贴,实测 **1 通过 / 6 失败**。我复现:**1/6**,与它完全一致。
根因:夹具用 `mktemp -d "$TMPD/am-prune-selftest-XXXXXX"`(`:154`/`:203`),
**要求 `$TMPD` 已存在** —— `mktemp -d` **不建中间目录**。
夹具一个都没建起来 ⇒ 后面所有判据对空目录求值 ⇒ **失败项全是【干净样本】**,
而且"残留"那条**根本不出现**(容易被读成"隔离没用",实际是"没建起来")。
修法:两处 `mktree`/`mkdtree_fail` 里补 `mkdir -p "$TMPD"`(幂等),
**不再把"目录没建"的责任推给使用者**。三态实测:
基线(默认 /tmp) ⇒ rc=0 23/0
PRUNE_TMP_DIR 未预建(**原 bug 场景**) ⇒ rc=0 23/0 (修前 1/6)
PRUNE_TMP_DIR 已建 + 持续外部 rm 干扰 ⇒ rc=0 23/0
★ **教训**:我把一条**自己没跑过**的命令当成"实测可用"发了出去。
"机制对"不等于"命令对" —— **一条命令的价值,在于它被原样粘贴后能不能跑。**
(我上一封还在说"有这个开关≠用了这个开关",转头又犯"机制可用≠命令可用"。)
## 二、`处理失败:` 模板:7 处,我原来只列了 5 处
pi 复核后报 **7** 处,我逐处核过行号:
pi src/worker.mjs:619 / :711
dsh src/index.ts:1215 / :1719
opencode index.js:781 / :1042 ← 我第一版**整段漏了 opencode 的 2 处**
zcode src/index.mjs:300
另记 pi 指出的**形似但不算**的两处:`crash-notify`
(`pi/lib/crash-notify.mjs:20`、`opencode/lib/crash-notify.js:20`)——
我核了:两处 `to: 'jianf@'` 且 **`reply_to` 出现 0 次**
⇒ **不会成为"孩子"**,对本判据无影响。
⇒ **"主题里带 `处理失败:`"是形状;"会不会成为某封信的孩子"才是判据条件。**
(病因与 `/mail/read` 那张表相同:**搜索路径没覆盖全 ⇒ 数少了也看不出来**。
这是我这轮**第二次**在"数有几处"上少数。)
|
2026-09-21 07:14:41 +08:00 |
|
|
|
572ddb9ca7
|
正: 我拿"甲口径的数"去纠正 pi 的"乙口径之差" —— **是我错、它基本对**(张冠李戴)
上一封(`d96c78ba`)我对 pi 说:"82 vs 85 的差**不是** join 不 join,而是**排不排机器回信**",
并端出一个 **74**,还让它"重算一遍,我们会对齐"。**复核后:我错。**
四口径并列实测(均 `status='unread'` + 无 read 行):
join sessions 排除机器回信 封数
否 否 112
否 是 103
是 否 81 ← **我报的 85 与 pi 报的 82 都是这一格**
是 是 72 ← 我说的 74 **是这一格**
⇒ 85 与 82 **是同一口径的两个时刻** ⇒ 差来自**漂移**,不是口径;
我端出去的 74 属于**另一个口径**。
⇒ **我把甲口径的数拿去解释乙口径两个读数的差,还把结论当成对 pi 的更正。**
这不是"数错了",是**标签错了** —— 本仓那条
**"列的类型/精度没核对,判据就静默答错"**的同族:
**数的"口径标签"没核对,比较就静默错位。** 而且我错得比单纯报错数更糟:
**我据此要求对方重算。**
⚠️ 顺带:连 82/85 那一格本身也在动(**现在 81**;`to=dsh` 那 8 封**现只剩 2**
⇒ pi 报的 4、我报的 8 **都过期了**)。
⇒ **这组数上唯一站得住的做法:只比较"同一时刻、同一口径"的两个数;
跨口径比较必须先并排重算,绝不引用记忆里的读数。**
同步修正:
- `docs/API.md`:补上四口径对照表 + 我这个错的完整记录;
- `docs/PLUGIN-CONTRACT.md`:删掉我误植的 **74**(它被我用错标签写进 T-12 那条),
改成**只钉关系与形状**(`严格 < 宽松`、`join 后 ≪ join 前`、
`read` 档能被回填选到而 `unread`/`archived` 两档都选不到),
并写明"引用任何计数前先写清三件套:`status` 怎么限、排不排机器回信、join 不 join"。
|
2026-09-21 07:04:04 +08:00 |
|
|
|
19470ddbf8
|
修: 我刚提醒完 pi「别把漂移量当阈值」,转头自己又把 105/116/248 写成绝对数
`18f194f` 那张表里我写了"宽松 114 / 严格 105",**几分钟后回去复核,它已经是 115 / 106**。
差(9)没变,两个绝对数**各 +1** —— 我们自己的邮件往来就在改它。
⇒ 这正是本仓那条"**别把漂移量当阈值**"(`9336fa7` 删"133"、`33b6033` 改成"比了 N 个")。
**我刚在给 pi 的信里提醒完这条,转头在同一段里踩了它。**
改法:
- 那张表**加一列"同一分钟再量"**,把"114→115 / 105→106 / 差稳定 9"这个**事实本身**写成证据;
- 明确 **"唯一稳定的是差(9)与'严格 < 宽松'这个关系"** ⇒ 验收钉关系、不钉绝对值;
- 下游的 `248` / `116` / `85` 全部降级为"旧口径、演示用、会漂",
只保留**形状**(`read` 档能被回填选到,`unread`/`archived` 两档一档都选不到);
- `≥ 248` 改成 `≥ 该判据命中数`(不绑死一个会过期的数);
- 实例(`fd375458` 无 read 行且 pi 回过)保留 —— **那个是结构性证据,不会漂**。
复核:严格口径(不限 status)= **238**(已同步进 docs);回填 40 行的当前值仍 = **40**。
|
2026-09-21 07:01:50 +08:00 |
|
|
|
18f194f924
|
改: 判据收洞(机器回信不算"回过")+ 写清 T-12 的语义空白;★ 记我自己的量错两处
pi 指出我那条 `收件人回过 ⇒ 必然读过` 有一个洞。**逐条复核成立**,已改。
## 一★ 洞:**机器回信**也被算成了"回过"
"回"只有在**是模型的产物**时才蕴含"读过"。桥有**自动**回信路径 ——
模型一次都没跑起来时,桥代它回一封 `处理失败: <父主题>`:
pi src/worker.mjs:619 / :711
zcode src/index.mjs:300
dsh src/index.ts:1215 / :1719 ← dsh 侧也有,pi 只列了 3 处
⇒ **那封"回信"恰恰是"没读过"的证据。**
精确模板匹配实测(`status='unread'`):
宽松:任意孩子(含机器回信) 114
严格:**至少一个孩子不是**机器模板 105
差 9 ← 与 pi 独立列出的 9 封**完全一致**
## 二★ 我自己在这条上先写错了量词(复核时才抓到)
我第一版写成 `NOT EXISTS(… AND ch.subject NOT LIKE '处理失败:%')` ——
**那问的是"一个真回信都没有",是反向量词**,实测只剩 **9 封**(正好是那批机器信)。
**判据从 105 翻成 9,照样返回行、照样不报错 —— 静默答错。**
正确写法是**带模板排除的存在量词**(已写进 docs 的 SQL)。
★ 又一次"先写结论、后复核",顺序反了。另记"并存"2 封(真回信+机器通知,均 `status=read`)
说明**不能用"父信含机器孩子就排除"的粗写法**。
## 三、T-12 的语义空白已写进契约(四个桥逐个量过)
`read_mail` 是否产生"已读",契约沉默。**四个桥各只有 1 处 `/mail/read`,且四处都只在 `read_inbox` 里**:
dsh src/index.ts:1340 read_inbox(:1302)
pi src/tools.mjs:188 read_inbox(:159)
zcode lib/tools.mjs:155 read_inbox(:120)
opencode index.js:291 —
而 `read_mail` 实现体只有 `client.get(...)` ⇒ **"只取正文、不改状态"是各桥一致的设计意图**。
⇒ 写进契约:**`read_mail` 不产生已读状态 ⇒ 未读计数不等于"没人读过"**;
治法是把语义写进契约,**不是多回填几行**((a) 本来就没有权威记录,补只是猜)。
## 四★ 记我自己的量错两处(同一形状,连错两次)
做上面那张四桥表时,我**两次**把"grep 返回空"读成了"没有":
1. opencode:grep 了 `opencode-mail-bridge/src/` —— **该目录不存在**,源码在包根 `index.js`。
2. zcode:grep 了 `zcode-mail-bridge/src/`(存在,但标已读那行在 `lib/tools.mjs`)。
**`grep` 对不存在的目录不报错、只返回空** ⇒ "路径写错"与"真的没有"**读数完全相同**。
⇒ **数一个东西"有几处"之前,先确认搜索路径存在、且覆盖所有落点。**
(第二次之所以抓到,是因为我改完表**回去逐处 `sed -n '<n>p'` 对行号** ——
只按 `grep -c` 收工,这张表就会带着两个"0 处"进仓库。)
★ 另修一处**漂移的绝对数**:严格口径下"会话未归档"是 **74**(我先前写 83,是旧口径的残留)。
已在 docs 注明该数会随我们的往来漂移,**只当量级、不当阈值**。
|
2026-09-21 07:00:30 +08:00 |
|
|
|
8ca1ce1f97
|
补: 那 116 封不是"历史账",是**活的重投源** —— 85 封会被 catchUp 选中;且别顺手扩回填
接上一笔:`status='unread'` 且"收件人回过"且无 read 行的 116 封里,
**85 封**在 `sessions.status <> 'archived'` 的会话里 ⇒ 会出现在 `?status=unread`、会被 `catchUp` 选中重投:
pi 67 / dsh 8 / zcode 8 / opencode 1 / homeagent 1
⇒ **只落回填不动部署,重投不会停**:回填清的是 (b)(`markReadFor` 漏写),
而把这些信持续留成"未读"的是 (a)(`read_mail` 本就不标已读 ⇒ 契约 T-12 对此沉默)。
(a) 是**契约缺口**不是可修的 bug ⇒ 这些信**永远**是未读,每天 04:00 按 limit=20 捞一批重投。
⚠️ 同时记一条**反向警告**:**别顺手把回填扩到 `status='unread'`**。
那 116 封有"收件人回过"作证;其余 `unread` 的**分不出**"读过没记上"与"压根没读"
⇒ 扩下去就是**把没读的标成已读**。要扩只能按可证的子集扩,并写明判据只覆盖**充分**证据那部分。
|
2026-09-21 06:34:08 +08:00 |
|
|
|
f0c7e5cbbc
|
补: 那条 40 行回填**只清 (b) 那一半** —— (a) 留下的 116 行它一条都选不到
商定的回填 SQL 带 `WHERE m.status='read'`,只覆盖**冗余列已经是 read** 的(成因 (b))。
成因 (a)(`read_mail` 不标已读)留下的是 **`mails.status` 仍 unread + `mail_reads` 无行**
⇒ 那个 SQL **一条都选不到**。
## 判据:不靠"我觉得没读过",靠可观测的因果
**收件人回了这封信 ⇒ 它必然读过** ⇒ 此时若无 `mail_reads` 行,则两条记录都没记上
(父信的 `to_name` 恰是子信的 `from_name`)。实测 **248 封**:
read 28 ✅ 回填选得到(属于那 40 行的一部分)
unread 116 ❌ 选不到
archived 104 ❌ 选不到
实例:`fd375458`(dsh→pi)—— **pi 回了 `494b29e4`**(`parent_mail_id` 指向它)⇒ pi 读过;
但该信 `mail_reads` 零行、`mails.status` 仍是 `unread`。
## 结论
落那 40 行只清掉 **(b)**;**(a) 留下的 116 行原样留着**。
不要把它写成"补完历史缺口"—— 它补的是**冗余列与权威列之间**的差,
不是**"读过"与"没记上"之间**的差。后者要另立一条判据。
(判据口径说明:`收件人回过`是**充分**证据,不是全部 ⇒ 真实漏记量 **≥ 248**。)
★ 与本节开头"计数对了不等于账记上了"是**同一个形状**:这次是"补了 40 行"≠"账平了"。
|
2026-09-21 06:32:43 +08:00 |
|
|
|
1e0af51a24
|
修正: 上一笔把「成员换了」写成了实测 —— 「哪一行进来」是**推断**,mails 表没有 updated_at
`cc4db68` 里我写「1494154f 被补标(-1)、c416c98e 新漏(+1),净额 0 ⇒ 池子换了两个人」,
读起来像两条都是读数。**其中第二条我证明不了。**
- **可证**:`1494154f` 04:33:34 排末位 5/5(漏标)→ 06:00:46 拿到 `read_at`(排 3/10)
⇒ 它**确实离开了缺口**(-1)。
- **可推**:40 → 40 而确有 1 行离开 ⇒ **必然有 ≥1 行在同一窗口进入**。
- **不可证**:**哪一行进来了**。`mails` 表**没有 `updated_at`**
(列只有 `status` / `created_at`)⇒ 缺口的历史成员集合**无法重建**。
`c416c98e` 是最可能的候选(同期在末位 10/10 漏标、`status='read'`、`mail_reads` 零行),
但那是**推断不是读数**。
⇒ docs 里已改成三段式(可证 / 可推 / 不可证),并点名"最可能是 c416c98e,但这是推断"。
结论(缺口是流量不是库存、不能只数总数)不受影响 —— 它只依赖"-1 确实发生"这一条,
而那条是实测的。
同族第 4 次:**把"我推出来的"写成"我量到的"。** 前三次是 133 个文件 / 107-81 /
PATH shim 的 in_use 理由。
|
2026-09-21 06:11:20 +08:00 |
|
|
|
cc4db68e68
|
补: 那个静默 bug **还在生产活着** —— 今天现场复现 3 次,并量到「缺口是流量不是库存」
`17908c1` 修了代码,但**线上跑的还是有 bug 的二进制**(构建于 09-19 13:04,早于修复)。
2026-09-21 我自己调 `read_inbox` 时**当场复现三次**,形态与离线探针逐字一致:
**列 N 封、只标上 N-1 封,漏的永远是末位。**
04:10:57 列 5 标 4 末位 474323c3 漏
04:33:34 列 5 标 4 末位 1494154f 漏
06:00:46 列 10 标 9 末位 c416c98e 漏
**跨调用同信同位对照**(最强一档):`474323c3` 在 04:10:57 排**末位 ⇒ 漏**,
在 04:33:34 排**第 2 位 ⇒ 标上**。同一封信、只换位置、结果相反 ⇒ 因果钉在**批次位置**。
## 由此得到一条容易看错的教训:缺口是「流量」不是「库存」
回填前的缺口**连续两次量都是 40 行**,看着像稳定常数,会诱出"可以等部署"的结论。
但**成员每天都在换**:同一天里 `1494154f` 被补标(-1)、`c416c98e` 新漏(+1),
净额 0 ⇒ **总数不变,池子换了两个人**。
⇒ 判"还欠多少"不能只数**总数**,要数**成员集合**(或直接看部署了没有)。
**一个稳定的计数可以掩盖一个持续在发生的错误。**
(与前面几笔同族:`133 个文件`、`107/81`、`41→40` —— 都是"把一个变动量当成常数"。
这次的区别是:那几次是**我读数过期**,这次是**计数本身在掩盖流量**。)
|
2026-09-21 06:09:11 +08:00 |
|
|
|
63d430f423
|
文档: API.md 记一条 —— **"计数"对了不代表"账记上了"**(已读权威列静默少行)
接在「已读按调用者记录」那节后面(同一段语义迁移的下游后果)。
`markReadFor` 把 reader 前置成 `$1`,而调用方 `where` 早把 `$1` 用成 recipient
⇒ 绑定表右移一格 ⇒ 最后一封永远插不进(单封一封都不插)。
`marked` 与 `mails.status` 都对,只有权威列 `mail_reads` 静默少行
⇒ 邮件"看起来已读、实际仍算未读" ⇒ 重启补投时被当新信重投。
**为什么 7 天零痕迹**:`marked` 来自**另一条 UPDATE**,而当时那 4 个测试断言的
是冗余列 `mails.status` —— **判据守错了列**。
教训(写成可复用的):**一个值的"计数"来自 A 语句、"记账"来自 B 语句时,
A 对不代表 B 对;判据必须断言决定行为的那个列。**
修在 `17908c1`,回归判据 `internal/repo/markread_authcolumn_test.go`。
|
2026-09-21 04:26:49 +08:00 |
|
|
|
9fa509844a
|
feat(pi-bridge): 有沙箱时 workspace 档不再逐条问人 —— 界内不问、界外内核拒
沙箱上线后,"工作区档"的语义第一次可以按档位表兑现:**边界是内核在守**,再问一遍
只是让人点一次"同意",点完该失败的还是失败(人点了也挡不住内核)。所以闸门改成
**按档位 × 有没有沙箱** 决策,纯函数收在 `lib/sandbox.js`:
| 档位 | 沙箱 | 决定 |
|---|---|---|
| full | 任意 | allow(发件人已声明全权) |
| plan | 任意 | block(本档只许看;沙箱是第二层) |
| workspace | **在** | **allow** ← 这一步改的(界内不问、界外 EACCES) |
| workspace | 不在 | ask(回退到原来那唯一一层) |
没有沙箱时**继续问** —— 这条是"不会更松"的保证:沙箱缺失/未装/被关掉时行为与改前
逐字一致。
## 标记不等于事实:worker 自证
`AGENTMAIL_PI_SANDBOXED=1` 只是父进程的**声明**。判断错会让闸门既不问也不拦
(最坏的一类),所以 worker 现场自证一次:往界外写一个金丝雀(`/.agentmail-sandbox-canary-<pid>`,
根目录永远不在 rw 里)—— 写得进去 ⇒ 判为"没有沙箱",**退回逐条问人**(方向取严);
被拒(EACCES/EROFS/EPERM)⇒ 在边界内。结果缓存在进程级。
## 顺带把 plan 档变成真的只读
plan 档的 rw 清单**不含会话工作区**(只有临时目录/pi 会话登记/桥配置/`/dev/null`):
"一个字都不许写"从"钩子拒绝 + 提示词"{升级为内核第二层。
## 判据
- `sandbox-launch.test.mjs` 10 条(原 6 + 新 4):决策矩阵四档 × 有无沙箱、
自证两侧(被拒=在边界内;能写=必须判"没沙箱")、plan 档 rw 不含工作区、
"pool 设标记 + worker 自证 + 走 guardDecision"三处接线在。
- 变异:把 workspace+sandboxed 改回 'ask' ⇒ 那条断言红。
- pi 桥全套 489 项通过。
- ★ 又被自己撞一次同类坑并当场红:新变量起名 `decision`,与同一个函数里后面那个
`const decision = await new Promise(...)` 撞名 ⇒ SyntaxError。上一轮的 `spawn`
撞名也是这一族(局部名与既有作用域重名),两次都是**语法检查/测试**立刻抓到。
## 文档
`docs/PLAN.md` §7.11 的 L5 矩阵与"向更严取整"那条纪律、`docs/API.md` 的档位表
都改成新语义(有沙箱=内核拒、无沙箱=逐条问),并写明 pi 的沙箱为什么必须由宿主提供。
|
2026-09-14 23:50:35 +08:00 |
|
|
|
5ce711fbcd
|
test(suite): 自检 3(判据不得埋在 process.exit 之后)+ TMPDIR 固化 + API 同名不同义表
## 自检 3(pi 提议)
自检 1/2 管"文件没接线",管不到"检查写在 `process.exit()` 之后"—— 而那正是实际发生的
第 4 例(4 条玻璃判据被并发写入落到文件末尾)。成因是**结构性**的(并发写入总是往文件末尾
追加),所以它一定会再发生,而它下一次仍然不报错。静态扫一遍即可:`process.exit(` 之后
若再出现 `check(`,直接判红并指出文件。变异验证:往 `theme.test.mjs` 尾部追加一条 check → 判红。
## TMPDIR 固化(pi 建议,采纳)
"记得加 TMPDIR"这种约定活不过两次踩坑(hvigor、fpm 各一次)。所以不再靠口径:
- `npm run build:linux` 自带 `mkdir -p .tmp && TMPDIR=${TMPDIR:-$PWD/.tmp}`;
- `BUILD.md` 的 deb 一节写明这条前置与原因(`/tmp` 是 tmpfs、占内存、常年近满)。
## `docs/API.md`:`total` 不是总封数 + 同名不同义表
`GET /me/mail/inbox` 的 `total` 是**未读总数**(`repo.CountUnread`),不是本页/全部邮件数 ——
鸿蒙端曾因此写出「共 7 封」和「未读 7」两行自相矛盾的字。在人类接口开头加了醒目提示,
并新增一张表:`total`(未读数)/ `status`(邮件=unread|read,会话=active|archived)/
`status` 与 `is_read` 同义不同名。
## 验证
`npm test` 退出码 0:9 个判据文件全绿 + vitest 258/258(安装包已按判据要求重打,
AppImage 与 deb 均为最新)。
|
2026-09-14 13:53:34 +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 |
|
|
|
bf32369ebd
|
docs/API.md: 补权限档位端点与字段(dsh 反馈的文档缺口)
dsh 在鸿蒙计划反馈中指出 API.md 未收录权限档位相关:
- PUT /sessions/{id}/permission 端点
- GET /sessions/{id} 返回的 permission_mode/permission_enforcement
- 发信请求体的 permission_mode 字段
- new_mail SSE 事件的档位字段
补:
- 会话端点清单加 PUT /sessions/{id}/permission
- 会话对象字段表(permission_mode 三档 + permission_enforcement native/advisory)
- 新增「权限档位」节:三档语义、新建/续谈均可设定、Agent 继承约束
- 发信请求体 JSON 示例加 permission_mode + 说明(两种情形都生效)
- new_mail SSE payload 示例补档位字段(含 from_human/to_human/in_reply_to)
注:后端只有 PUT 没有 GET /sessions/{id}/permission,文档已注明读取走会话详情。
|
2026-09-07 07:32:00 +08:00 |
|
|
|
289f37f7fb
|
docs: PLUGIN-GUIDE 重写为 PLUGIN-CONTRACT(可核对的插件规格)
原 PLUGIN-GUIDE 是叙事式的「怎么做 + 踩过的坑」,读者要自己从散文里推断
「我到底必须做什么」。接第三个平台时这不够用 —— 尤其当照着实现的是一个代理。
改为规格式,编号可引用、强度明确标注、每条尽量给出可机械核对的判据。
旧文档的内容全部保留(迁进第八、九节),另补上原先没有的四类:
## 一、能力矩阵(新增)
回答「这个平台能不能接」。七项必需能力(C-1..C-7)加七项可选(C-8..C-14),
每项给出判据。附一个七问自检 —— 任何一问答不出来就先别写代码。
其中 C-4「轮次结束信号必须能区分成功与出错」在两次适配里都被漏掉过,
两次都造成「无效模型被判成成功」,所以单独标了出来。
## 二、行为约定(重写)
原先散在各节的要求收拢成一个状态机,按事件逐条规定:B-1 启动 / B-2 心跳 /
B-3 new_mail / B-4 permission_decision / B-5 轮次结束 / B-6 无法处理时回信 /
B-7 启动补拉 / B-8 权限询问 / B-9 关停。
## 四、降级语义(新增)
平台缺某项能力时的确切退化路径(D-1..D-7)。原文档只说了「可选」,
没说缺了之后该怎么办 —— 于是「不支持权限钩子」很容易被实现成
「提供 request_permission 工具补偿」,而那正是 I-1 反对的模式。
## 六、不变量与禁止事项(新增)
12 条 MUST NOT,每条附「违反会怎样」。这些是测试全绿、跑起来也不报错,
但行为就是错的那类问题 —— 例如拉取失败时传 [] 而非省略字段会清空服务端目录。
## 七、验收清单(新增)
八组可勾选项,每条给出具体命令:grep 自查禁止事项、sqlite3 查在线状态与
配额未被消耗、停插件发信再启动看补投日志。
## 核对过的事实
写完逐项核对了代码,不是凭记忆:
- 12 个端点全部在 main.go 里存在且方法一致
- 发信 9 个字段名与 sendMailRequest 的 json tag 一致
- 心跳响应 12 个字段名与 handler 一致
- 九个数字(30s 心跳 / 25MB 附件 / 20 次每小时 / 补投 5 封 / 快照 200 条 /
模型上限 10 / 目录上限 300 / 降级超时 60s / inbox 默认 5)都能在代码里找到出处
- 验收清单里的六条 sqlite 查询都在生产库上跑通
- 38 个编号无重复,18 处交叉引用全部有定义
引用同步:PLAN.md、PHASE7-REMAINING.md、API.md、README.md、
install.sh、check-shared-libs.sh。
|
2026-09-03 08:09:54 +08:00 |
|
|
|
9e5c557cdf
|
feat: 跨主机 Agent 验证 + 离线邮件补投 + 400 指向具体字段
7.8「跨主机 Agent 发现」原计划(Gateway + Registry 拆分、etcd/Consul 注册)
取消,改为验证现有协议已经够用。验证过程暴露两个真实缺陷,一并修掉。
## 为什么不做注册中心
它要解决「Gateway 怎么找到 Agent」,而这个问题在本架构里不存在:
连接方向是单向的 —— Agent 主动连 Gateway,Gateway 从不外呼。
远端 Agent 只需要一个公网 URL 加一把密钥,被叫方自己会打进来。
注册中心要解决的「被叫方在哪」根本没出现过。
同一个理由此前已经决定了平台会话同步走插件上报而不是 Gateway 拉取。
## 验证方式:一个纯标准库脚本
`deploy/remote-agent-demo.py` 在另一台主机(192.168.2.106)上跑,
不装 AgentMail 的任何代码。注册 / 心跳(带模型目录)/ SSE 长连 /
收件箱 / 标记已读 / 发信全通,Gateway 侧 status=online 且 last_seen 随心跳推进。
完整一轮往返跑通:admin 发给 remotebot@/tmp/remotebot-ws,脚本回信入库。
「协议层面已支持」的含义就是这个:跨主机不需要新组件,只需要三个环境变量。
## 缺陷一:SSE 只推连上之后的事件,没人补拉积压
写那个脚本时第一版只挂了 SSE,启动前发的邮件永远不会被处理。
查了才发现**两个正式插件也有这个洞** —— 原以为它们做了补拉,实际没有。
后果比明确的失败更难排查:邮件躺在收件箱里,而发件人以为 Agent 收到了。
新增共用模块 `lib/catchup.js`,两插件在首个成功心跳后补投一次。五条约束
都对应一种具体的坏行为:
- 只在**首个**心跳后补 —— 每轮都补会把「模型正在处理中、尚未标已读」的
邮件重复投递
- 串行、一次最多 5 封 —— 每封都要起一轮模型,并发放出去等于对上游打 N 个
并发请求,且最后几封要等前面全部跑完
- 与 SSE 共用 deliveredMails 去重 —— 心跳与 SSE 建连之间有个窗口,
那期间到的邮件两条路都会到
- 按时间**正序**投(收件箱倒序返回)—— 倒着塞进去同一会话的上下文是乱的
- permission 类不补投 —— 原来的工具调用早随进程没了,没有可恢复的上下文
端到端两平台各验一次:停插件 → 发信 → 启插件 → 日志「补投 1 封离线期间的
邮件」→ 回信入库;随后在线再发一封确认只回一次。
## 缺陷二:400 只说 "Invalid JSON",不说是哪个字段
脚本把 `workspaces` 传成字符串数组(它要 `[{name, path}]`),
得到的只是一句固定文案,只能靠翻服务端结构体才能发现。
两个官方插件都传 `workspaces: []`,所以这个洞一直没暴露;
第三方客户端没有「翻服务端源码」这个条件。
新增 `handler.DecodeBody`,22 处 `Decode` + 固定文案的调用点全部换过去:
{"error": "字段 \"workspaces\" 类型不对:期望 object,收到 string"}
{"error": "JSON 语法错误(第 8 字节处)"}
{"error": "请求体为空"}
刻意不回显 encoding/json 的原文 —— 它带 Go 类型名(models.Workspace),
那是本侧的实现细节,不该出现在公开 API 的响应里。期望类型用 JSON 的说法。
截断的 JSON 走 io.ErrUnexpectedEOF 而不是 json.SyntaxError,单独一条分支,
否则会落到笼统的兜底文案里(写测试时才发现)。
## 验证
- Go:13 个新测试(decode_test.go 含「不得泄漏 Go 类型名」断言)
- 插件:两侧各 10 个补投测试,共 200 个
- 共用模块同源校验通过(catchup 已纳入 check-shared-libs.sh)
- 生产已部署
|
2026-09-02 22:47:31 +08:00 |
|
|
|
89356d4a9b
|
feat: 每平台可用模型范围 + 降级尝试 + 失败回报
配置页为每个 Agent 平台划定「邮件场景下可用的模型」,插件按顺序逐个尝试,
全部失败把原因封装成邮件回复。目录由插件上报、管理员只做勾选 —— 手打模型名
会打错,而打错的后果要到真发邮件时才暴露成一次失败。
## 目录上报走心跳,不另设端点
模型清单会在运行中变(换 provider 配置、上游上下线、换 API key)。
只在注册时报一次的话目录会静静变陈,管理员在配置页选中一个平台其实调不到的
模型。心跳本来就是 30 秒一次的现成通道;另设一个 POST 等于给「目录是谁写的」
留两个答案,排查时要同时看两处。
心跳响应回传 `allowed_models`,因此管理员改了范围后最多一个周期生效,
不必重启插件。
与 platform_sessions 同一约定:拉不到目录时**省略字段**(保留现有目录),
传空数组会把配置页清成空白。
## 目录与选择分两张表
模型会从平台目录里消失(上游临时下线、换了 provider 配置)。合成一张带
allowed 标记的表时,整行被删就连带把管理员的选择也删了,模型回来还得重配一遍。
分开存之后「选了什么」是持久的,目录只决定「这一项现在是否可用」;
已选但不在目录里的标为 stale 显示出来 —— 不显示会让人以为自己没选过它。
## 最难的一点:模型失败不是同步抛出的
两个平台都踩了。`promptAsync()` 立即返回、`ctx.agents.create()` 不校验模型,
只包 try/catch 的话第二个模型永远不会被试到 —— 第一个无效模型会被判成成功。
必须等异步结论:
- opencode → `session.error` 事件(event 钩子在 deliverMail 之外,
因此用 turnWatchers 表把两者接起来)
- DSH → `turn/end` 的 `reason.kind === 'error'`
DSH 还有个陷阱:**`assistant/chunk` 不能当成功信号**,它的 `finish` 子类型
也带错误 —— `{chunk:{type:'finish',reason:{kind:'error',failure:{code:'NO_ADAPTER'}}}}`。
实测「无效 provider 却判成功」正是因为把任意 chunk 当成了走通。判据要落在
chunk 的类型上:finish 看 reason,其余才意味着模型真的在产出。
超时按成功处理(60 秒窗口):模型可能只是很慢,把慢当成失败会在换模型的同时
把已经在跑的那一轮丢掉。
DSH 换模型要换会话 id(`<原 id>-r1`)并 dispose 失败那个 agent:复用同一个 id
会让重试接在一条已经出错的会话后面,不 dispose 则 agent/status 还会为那个
死会话触发一次自动转发。
## 其他决策
- **范围优先于环境变量**:范围是运行时可改的策略,`AGENTMAIL_REPLY_*` 是部署时
的兜底。反过来的话管理员在配置页改了却不生效,得去改 service 文件重启
- **范围为空返回 `[undefined]` 而非 `[]`**:空数组会让调用方一次都不试,
而「管理员没配」的正确含义是不限定,不是「一个都不许用」
- **上限 10 个**:降级是串行的,选 50 个意味着最坏情况下一封邮件要等 50 次超时
- 前端 key 按**第一个** `/` 切分 provider/model:model id 可能含 `/`
(如 `org/model-name`),按最后一个切会把 provider 切错
- 保存后用服务端返回的结果刷新界面而非回显入参:repo 层会跳过重复与空字段
## 验证
- Go 10 个新测试(含「模型从目录消失后选择必须留存」的直接回归)
- 两插件各 18 个模型范围测试,共 180 个
- 端到端四轮:正常路由 → 全部无效(收到失败回报邮件,used_rounds 保持 0
确认走了免配额通道)→ DSH 降级(fake-a 失败 → llmsproxy/AUTO 成功)→
opencode 降级(nonexistent/bad 失败 → AUTO 成功,日志确认「前 1 个失败」)
- 生产已部署,前端「模型范围」页可用
|
2026-09-02 21:34:55 +08:00 |
|
|
|
7c9be9fd58
|
docs: 插件适配指南 + 共用模块提取(为接入更多平台做准备)
两次适配(opencode、DeepSeek Harness)里的方法与坑此前散落在提交信息和
代码注释里,接第三个平台时要重新翻。这次固化成文档,并把与平台 SDK 无关的
逻辑提到共用模块。
## docs/PLUGIN-GUIDE.md
八节:职责边界、必须实现的六件事、会话命名回写、平台会话快照上报、
平台差异对照表、踩过的坑(按排查成本降序)、新平台适配清单、共用模块清单。
三条设计原则贯穿全文,后面每一节都是它们的推论:
1. **平台原生信号才是真相来源**,不要求模型「记得」调工具 —— 因此不提供
request_permission(改挂权限钩子)、不要求模型主动回信(改在「一轮结束」
的平台信号上自动转发)
2. **插件代劳的转发不消耗配额** —— 因此这两类转发带 relay + relay_key
3. **平台命名优先** —— 因此创建会话时不传占位标题(那会掐掉平台自己的命名机制)
「踩过的坑」一节按排查成本排序,头一条是花了一下午的 followup() 参数形状。
## 共用模块提取
`lib/inbox-format.js`(新):收件箱渲染与已读策略。三条规则各对应一次错误行为,
而它们与平台 SDK 无关:
- 附件必须带 attachment_id(只说「有附件」模型无从下载)
- 抄送人要显示(不显示模型以为是私信,回信时漏掉其他参与方)
- 只标本次列出的、status=all 时不标(limit 之外的还没看过;把历史邮件标成已读
会让下一轮的新邮件混在里面认不出来)
顺带修好两处不一致:DSH 的 read_inbox 此前**完全没有标记已读**(每轮重复捞同一批),
且默认 status=all(同上);附件大小两边一个显示字节数一个显示 KB/MB。
`lib/workspace.js`:提到两侧共用。签名从 (workspace, fallbackKey) 改为
(workspace, fallback) —— 各平台的兜底不同:opencode 有插件启动时的 directory,
DSH 只能落到 ~/.dsh/mail-sessions/<会话>(mailSessionFallback)。
opencode 侧此前是内联的三行判断,没有「目录不存在时不创建」与「拒绝相对路径」
这两条保护。
## deploy/check-shared-libs.sh
`lib/` 与 `test/` 下的共用文件必须逐字节相同,纳入 install.sh 门禁。
一侧改了另一侧没改,两个平台的行为就会悄悄分叉:同一封邮件在 opencode 那边
标了已读、在 DSH 那边没标,而两处代码看起来都「对」。这类分叉没有测试能发现,
只能靠 diff。
## 文档同步
- PLAN.md §7.7 从「待做」改为已完成,补 7.7.1(工作目录归属)与
7.7.2(平台会话快照)两节,记录根因而非只记改法
- API.md 加「心跳与平台会话快照」章节;SSE 章节补 new_mail 与
permission_decision 的 payload 说明(to_workspace 的语义、relay_key 的用途)
- PHASE7-REMAINING.md 移除已完成的 7.7,新增「每平台可用模型范围」的进展
(repo 层已就绪,handler/插件/前端待做)
- README 文档索引与项目结构
验证:两插件共 136 个测试通过,同源校验通过,Go/前端全绿;
端到端发信 → DSH 用新的 read_inbox 渲染读取 → 自动回信 213 字节。
|
2026-09-02 20:28:19 +08:00 |
|
|
|
07e6b789b2
|
feat: 配额下沉到会话 + 窄屏覆盖式布局 + 工作列表卡片视图
## 配额重构:废除 Agent 终身额度
原实现在 agents 上放一个 max_rounds/used_rounds 计数器,used_rounds 单调递增、
永不重置 —— 跑满就要管理员手工重置才能再干活。那是把一次性资源模型套在长期
在线的服务上,且并行任务互相抢额度。
改为:
- 唯一被强制的预算是【会话】的往返预算(sessions.max_rounds/used_rounds),
写信时给、对话页里随时改 —— 配额的语义是「这件事值得多少个来回」,
那是任务的属性而不是 Agent 的属性
- agents.default_rounds 只作为「派给这个 Agent 的新任务」的默认值(默认 20)
- agents.used_rounds 降级为纯统计
- 新建会话速率限制(1h/20 条)堵住用 .new 开一串新会话绕过预算;
人类不受限(agentLimiterKey 返回空串即不计量)
## 窄屏适配(用户反馈「窄屏基本不可用」)
原先只有三栏并排:60(导航)+320(列表)+详情,375px 屏上详情被挤到 0。
第一版做成「一次只显示一栏」,用户纠正应当是新页面覆盖老页面并带动画,
于是重做为覆盖式:
- NarrowStack:底层列表始终挂载,详情绝对定位盖在上面。两个好处 ——
列表滚动位置与选中态天然保留;退出动画有东西可播(直接卸载再渲染另一个
组件的话,没有任何一帧能让旧页面往右滑出去)
- 因此必须区分「逻辑上是否打开」与「是否还在 DOM 里」:关闭时先播 200ms
滑出,动画结束才卸载
- 入场用双层 requestAnimationFrame:必须让浏览器至少绘制一帧「在右侧之外」
的状态,否则挂载与 translate-x-0 在同一帧内完成,transition 不触发
- 窄屏专属控件用 useIsNarrow() 条件渲染而非 md:hidden —— 后者只是视觉隐藏,
宽屏用户按 Tab 会聚焦到看不见的返回按钮
- 底部导航 + 抽屉侧栏 + env(safe-area-inset-bottom)
## 工作列表卡片视图(Phase 7.1 最后一项)
中间栏可切列表/卡片。列表答「跟谁在聊」,卡片答「在聊什么、进展如何」:
主题 + 最新一封的发件人与摘要 + 往返预算徽标。
- 两种视图共用同一份数据与同一套动作;归档确认框也共用 —— 归档是破坏性操作,
换个视图就换套确认 UI 只会让人对「自己点了什么」更没底
- 预算徽标在「不限」时不显示(对每张卡片都成立的「0/0」是纯噪声)
- 数据一次取回,不让卡片为每条会话再打一次库
## 修掉的缺陷
- GET /me/sessions 一直 500:ListSessionsFor 的 SELECT 加了预算两列却没加进
Scan,列数不匹配。联系人栏一条数据都拉不到,而错误只是「Failed to list sessions」
- GET /sessions/{id} 忘了填充附件:前端会话视图走的是这个端点,于是 Agent
回信里的附件在 UI 上完全不存在(另一个端点填了但没人调用)
- 插件曾完全没在加载:为了可测在 index.js 里 export 了辅助函数与一个 Map,
而 opencode 把入口模块的每一个导出都当成插件工厂逐个检查,多导出一个 Map
就 "Plugin export is not a function",插件静默失效、邮件全投不进去。
逻辑挪到 lib/relay-dedup.js,并加断言钉住「入口只有 default 导出」
- 同一件事发两封邮件:模型带附件主动回信后,session.idle 又把它最后那段话
自动转了一遍(生产实测 311 与 342 字节各一封)。explicitSends 记录本轮
主动发信,自动转发据此让位;relay_key 幂等管不了这个 —— 那个键保证的是
「同一条消息不转两次」
- SQLite 时间戳只有秒精度:同秒插入的多封邮件排序不确定(实测同秒插 5 封,
顺序由随机 UUID 决定)。「会话里最早那封」(决定联系人身份)与「最后那封」
(决定最新进展)都会取错。NOW() 升到微秒 + mails 的 INSERT 显式传它
(改 schema 默认值只对新库生效,SQLite 没有 ALTER COLUMN)+ 所有
ORDER BY created_at 补 mail_id 兜底
- fillAttachments 从逐封查询改成一次 IN(...):原来是 N+1,200 封的会话打开
要打 200 次库
- repo 层 5 处 rows.Next() 循环补 rows.Err():没有它,读到一半连接断掉会
静默返回部分结果,UI 上表现为「邮件凭空少了几封」
- go:embed 占位页改名 placeholder.html:叫 index.html 会被 Vite 产物覆盖并
提交进去,而它引用的 assets/ 是被忽略的 —— 新克隆打开是白屏
## 回复/转发栏
- 两处都加抄送(可折叠);原邮件带抄送时多一个「回复全部」,回填用
cc_list[].raw 而非重拼 name@path(后者会丢掉会话段)
- 会话视图每张卡片加转发入口:转发之前只存在于单封邮件视图,而人多数时间
待在会话视图里,等于功能在 UI 上找不到
- ReplyBar 的错误从 console.error 改为显示出来:预算耗尽、地址不存在、
速率限制都走这条路,之前点发送毫无反应
## 测试
- repo: 列顺序(三个 SQL 分支)、卡片字段、previewRunes 边界、时间戳亚秒精度、
批量附件查询、速率限制(80 goroutine 断言恰好 20 条通过)
- web: 窄屏布局 16 条结构性断言(覆盖而非分栏、延迟卸载、双层 rAF、
条件渲染而非 md:hidden)
- 插件: 自动转发去重 17 条(含「入口只有 default 导出」不变量)
- install.sh 把插件测试也纳入部署前门禁
|
2026-09-02 14:16:46 +08:00 |
|
|
|
0e754617a4
|
feat: AgentMail —— 以邮件为统一范式的多智能体协作平台
Go 单二进制网关 + React 前端 + opencode 桥接插件。部署产物是
「一个二进制加一个 .db 文件」:前端经 go:embed 打进二进制,
数据库默认内置 SQLite,systemd 托管。
核心设计
- 三维寻址 name@path.session,按最后一个 . 切分;session 位三态:
省略=默认会话 / new=强制新建 / 具体别名=必须已存在(否则 404 无法送达)
- 会话别名默认复用 Agent 平台自己的命名机制(opencode 的 slug 与模型生成的
标题),不在本侧另造一套;人显式定过的别名不被平台同步覆盖
- 对话树不建 tree_nodes 表:parent_mail_id 已完整编码树结构,
再维护一张表就是第二份真相。用递归 CTE 查,按方向分块加载
- 附件内容存磁盘、按 sha256 内容寻址,数据库只存元数据;天然去重,
且路径与用户 filename 无关,杜绝 ../ 穿越
- 配额约束的是模型的自主发信,不是 harness 的转发:插件代劳的权限询问与
最终总结走免配额通道,靠上游消息 id 做幂等键而非计数
- 往返预算下沉到会话(写信时给、对话页里改)+ Agent 全局配额,两层都要过
后端 gateway/
- models/repo/handler/middleware/sse/blob 分层;两方言(SQLite/PostgreSQL)
共用一份 repo 层 SQL,差异集中在 internal/db
- 多用户认证(bcrypt cost12、登录限速、会话隔离、权限边界)
- 密钥体系:Agent 密钥与用户密钥分表,三种生命周期;登记式密钥让全文
只从客户端流向服务器一次
- 所有「判断 + 自增」都在同一条 UPDATE 里(配额、预算、one_time 密钥、
附件挂载),并发下不会刷穿
前端 web/
- 三栏布局、三段式地址补全、权限卡片、密钥面板、配额面板、对话树、附件
- 全站纯 SVG 图标,不使用 emoji
- api/ 即可复用的客户端 SDK:基地址与凭证集中在 api/config.ts
插件 plugins/opencode-mail-bridge/
- 六个工具 + 两类自动转发(permission.ask 钩子接管平台原生权限询问、
session.idle 时转发本轮总结)
|
2026-09-02 10:29:26 +08:00 |
|