fix(pi桥): 排队路径补日志 —— 「在排队」与「丢了」此前在日志上同形
## 起因
压测后重建网关,我发信做端到端验证,**pi 一直没回**。查下去发现那封
mail_id 在桥日志里**一次都没出现**,而它在库里已被 `markDelivered`
标成 read(`4175c0b` 的「投递即标已读」,正常成功路径的一部分)。
真正卡住排查的是:**池满时邮件进 `queue`,而排队路径一句日志都没有。**
于是「这封在排队」与「这封丢了」在日志上**完全同形** ——
当时能给出的结论只有「不知道」。
## 改法
入队/出队各一声,且都带可读数:
· 入队:key、第几位、前面还有几封、在跑 `activeCount/maxWorkers`、第几次尝试
· 出队:**等了多久**(秒)、剩几封在排
`attempt>1` 单独标出 —— 那是**重投**(上次没回报 done),与首次排队不是一回事,
混在一起会让人以为是同一种等待。
## ★ 两次错误归因(都记在 DEBTS 里,因为推理方式会复发)
**① 「是 read_inbox 连带标掉了在途邮件」** —— 错。
我看到 `status` 在 11ms 内变 read 就归因到 read_inbox。
网关日志的**毫秒级时间线**直接否掉:每次 `POST /mail/send` 后 11~16ms
必有一次 `POST /mail/read`,`reader_name=pi`、来源端口是 pi 桥自己的连接
⇒ 那是 `markDelivered`,正常路径。
**② 「是 opencode 的桥串用了 pi 的密钥」** —— 错。
我一度以为 `reader_name=pi` 与「连接来自 opencode 进程」矛盾。
实测两个 CONFIG_DIR 不同、各自的 key 在库里分别属于 pi / opencode。**没有串用。**
★ 共同点:**我先有了候选解释,再去找支持它的证据**。
正确顺序是「先取一条能一次说清的独立时间线,再解释」。
## 顺带纠正我自己上轮的一个测量假象
我曾说「实测同一时刻 4 个 worker 在跑,MAX_WORKERS=3 被绕过」——
**错的**。`pgrep -f` **把执行查询的那条命令自己算进去了**(它含同样的字符串)。
用 `ps -eo pid,args | grep -E "node .*/worker\.mjs$" | grep -v grep` 实测是 **3 个**,
与上限一致。探针把自己算进来 —— 与 `baseline-residue` / `python-probe-shadowing` 同族。
## 判据
新增 `test/queue-observability.test.mjs`(4 格,钉**形状**不钉读数 ——
读数要真把池压满才有):
入队/出队各有一声、出队那声必须含等待时长、入队那声必须带占用比、
以及一条自检(删掉入队日志后源码里确实没有它 ⇒ 判据恒绿的话会先红)
变异验证:删掉入队日志 ⇒ **4 格全红**。
全套:pi 530 / opencode 351 / dsh 426,全绿;共用 lib 一致性 ✅。
This commit is contained in:
@ -276,12 +276,12 @@
|
||||
"note": "★★ 2026-09-28 登记(pi 实测)。\n\n## 形状:**一条判据长期红,且与被测代码无关**\n\n`build-stamp` 断言产物自报来源必须精确等于当前 HEAD。实测:\n产物记 `87c55ac`,HEAD 当时是 `359cb43`、随后因本轮工作走到 `f1c74fc`\n⇒ **无论谁提交什么,这条判据都不会自己变绿** —— 它只能被一次重构建救。\n\n## 为什么值得单独记一笔:它是**共享工作树债的直接产物**\n\n同一个 `HEAD` 上(`shared-workspace-unserialized-deploy`):产物落后于源码,\n因为多个会话连续提交而**没人重跑构建**。`d3a7873` 那笔 HIGH 记的正是\n「判据说干净而构建物不干净」;本条是它的**日常形态** —— 不危险,\n但它会长期挂在套件的红名单上,让「红了」这件事开始钝化。\n\n★ 真正的危害不是这条判据本身,是**它会训练人忽略红色**:\n套件汇总里 `build-stamp` 与另两条真缺陷并列显示,人一旦习惯\n「哦又是 build-stamp」,就会把同一行里的真缺陷一起放过。\n这与本仓反复消的「看不到 ⇒ 绿」是同一族,方向相反:**看到了 ⇒ 当没看见**。\n\n## 已做的核对(避免把别人的问题算成新的)\n\n· 提交 `f1c74fc` 之前就在**干净 HEAD** 上复现过 ⇒ 不是本轮引入;\n· `BUILD_INFO.json` 经 `git ls-files` 确认**未被跟踪** ⇒ 缺的那次构建\n 没人提交过,不是被谁回滚;\n· 判据报错文案本身已警告「别去改 gitRev/srcHash 了事」 —— 本条认同,\n 并把那个正确修法(重跑构建)写进 `due`。\n\n## 未做\n\n没有触发构建、没有改 `BUILD_INFO.json`、也没有把它加进任何跳过名单。\n构建是部署动作,而本工作区正在被多个会话并发提交(已有两个\n`server/internal/repo/zz_*_probe_test.go` 不属于本会话)—— 由人决定何时构建。"
|
||||
},
|
||||
{
|
||||
"id": "read-inbox-swallows-inflight-mail",
|
||||
"count": 1,
|
||||
"due": "read_inbox 的「读完自动标已读」与 SSE 投递解耦之后(两者之间要有互斥或收窄)",
|
||||
"where": "server/internal/handler/mail.go(GetInbox 标已读)+ plugins/*/lib 的 read_inbox;桥侧无判据",
|
||||
"kind": "bug",
|
||||
"note": "2026-09-28 压测后重建网关时**实测复现 3/3**(不是推演)。\n\n现象:给 pi 发一封邮件 ⇒ 库里 `status` 变 `read`(投递后 **10~13ms**),\n而桥的日志里那封 mail_id **一次都没出现** ⇒ 没起会话、没人回信。\n 读数:`mail_reads.read_at - mails.created_at = 0.013s`;\n 桥日志提及次数 = 0/3。发件人视角 = 「信发出去了,然后没声了」。\n\n机制:两件事**各自都对**,但合起来丢信。\n ① `4175c0b` 把「投递即标已读」做成一个动作(`markDelivered`),\n 为的是治「桥重启 → 重投 → 回声」;`catchUp` 按 `status=unread` 捞。\n ② `read_inbox` 工具**读完自动标已读**(README 明写),\n 而它按 inbox 取信,**不区分「这封是不是正在等派发」**。\n ⇒ 任何一次 `read_inbox`(无论模型为什么调)都会把**当时还在 unread 队列里**\n 的信全部连带标掉,其中包含**这一轮刚投递、还没轮到起 worker** 的那封。\n 它随后既不在 unread 里(捞不到)、也不在 deliveredMails 里(还没投递)\n ⇒ **静默消失**。\n\n★★ 与 `4175c0b` 修的不是同一件事,别以为已修:\n 那条治的是「**投过之后**没标已读 ⇒ 重投回声」;\n 这条是「**投递之前**就被别的路径标已读 ⇒ 投不出去」。\n 两条的方向相反,落到同一条 SQL 上。\n\n★ 放大条件:worker 池越小越容易撞(`AGENTMAIL_MAX_WORKERS` 默认 3,\n 实测同一时刻有 4 个 worker 在跑)。池满时信在队列里等,\n 等待窗口正是被 `read_inbox` 扫掉的窗口。\n\n修法方向(未实施,等人定):`GetInbox` 侧只标「**本会话已投递**」的信,\n 或桥侧投递时先落 `deliveredMails` 再让模型读得到 —— \n 后者与 `4175c0b` 的「投递即标已读」直接冲突,**不能两边都要**。"
|
||||
"id": "delivered-but-never-dispatched-silent",
|
||||
"count": 0,
|
||||
"due": "给 pool.mjs 的排队路径加日志(入队/出队/等待时长)—— 没有它,「排队」与「丢失」在日志上同形",
|
||||
"where": "plugins/pi-mail-bridge/src/pool.mjs(排队路径无任何 log)",
|
||||
"kind": "observability",
|
||||
"note": "2026-09-28 压测后重建网关时**实测复现**(不是推演)。\n\n## 现象\n给 pi 发信 ⇒ 桥**从不提及那封 mail_id**(日志 0 次)⇒ 没起会话、没人回信。\n发件人视角 = 「信发出去了,然后没声了」。连发 3 封,3 封全中。\n\n## 已排除的两个错误归因(都记下来,因为它们的推理方式会复发)\n\n**① 「是 read_inbox 连带标掉了在途邮件」** —— 错。\n 我看到 `status` 在 11ms 内变 `read` 就归因到 read_inbox。\n 但网关日志的**毫秒级时间线**显示:每一次 `POST /mail/send` 之后 11~16ms\n 必有一次 `POST /mail/read`,而 `mail_reads.reader_name` = **pi**(不是 opencode),\n `/mail/read` 的来源端口属于 pi 桥自己的连接。\n ⇒ 那是 `markDelivered`(`4175c0b` 的「投递即标已读」),**正常成功路径的一部分**。\n\n**② 「是 opencode 的桥串用了 pi 的密钥」** —— 错。\n 我一度以为 `reader_name=pi` 与「连接来自 opencode 进程」矛盾。\n 实测两个 CONFIG_DIR 不同(pi=`/root/.agentmail-pi`、opencode=`/opt/agentmail/agent-config`),\n 各自的 `AGENTMAIL_AGENT_KEY` 在库里分别属于 pi / opencode。**没有串用。**\n\n★ 两次归因错的共同点:**我先有了候选解释,然后去找支持它的证据**。\n 正确顺序应是「先取一条能一次说清的独立时间线(网关 access log 的毫秒时间线\n + 源端口 + reader_name),再解释」—— 那一刻两条日志就够判定了。\n\n## 仍然为真的部分\n「投递了但桥没起会话」是事实,机制未定位。已知条件:\n · `MAX_WORKERS=3` 且实测**真的**有 3 个 worker 满载(我一度以为 4 个,\n 那是 `pgrep -f` **把查询命令自己算进去了** —— 探针把自己算进来,与\n `baseline-residue` / `python-probe-shadowing` 同族);\n · 池的**排队路径完全无日志**(`pool.mjs` 里入队/出队都不打)⇒\n 「在排队」与「丢了」在日志上**同形**,这才是无法定位的直接原因。\n\n⇒ 待办(按此顺序):**先给排队加日志**,再判是排队超时还是投递丢失。\n 没有那条日志,任何进一步推断都是猜。"
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -132,7 +132,21 @@ export function createWorkerPool({
|
||||
|
||||
function submit(kind, data, attempt = 1) {
|
||||
if (stopped) return;
|
||||
queue.push({ kind, data, key: keyOf(data), attempt });
|
||||
const before = queue.length;
|
||||
queue.push({ kind, data, key: keyOf(data), attempt, enqueuedAt: Date.now() });
|
||||
// ★ 入队/出队**必须出声**(2026-09-28 加)。
|
||||
//
|
||||
// 为什么:池满时邮件排队,而**排队路径此前一句日志都没有**。
|
||||
// 于是「这封在排队」与「这封丢了」在日志上**完全同形** ——
|
||||
// 实测 2026-09-28:发一封信,桥日志里那封 mail_id **一次都没出现**,
|
||||
// 而它在库里已被 `markDelivered` 标成 read(见 docs/DEBTS.json 的
|
||||
// `delivered-but-never-dispatched-silent`)。
|
||||
// 当时能给出的结论只有「不知道」—— 判据与日志都不具备区分能力。
|
||||
//
|
||||
// 报「入队」而不是「在等」:`attempt>1` 说明是**重投**(上一次没回报 done),
|
||||
// 那与首次排队是两回事,混在一起会让人以为是同一种等待。
|
||||
log(`排队 ${queue[before].key}(第 ${before + 1} 位,前面还有 ${before} 封` +
|
||||
`;在跑 ${activeCount()}/${maxWorkers}${attempt > 1 ? `,第 ${attempt} 次尝试` : ''})`);
|
||||
pump();
|
||||
}
|
||||
|
||||
@ -169,6 +183,11 @@ export function createWorkerPool({
|
||||
if (activeCount() >= maxWorkers) return;
|
||||
queue.splice(i, 1);
|
||||
i--;
|
||||
// ★ 与入队配对:让「等了多久」变成一个可读数。
|
||||
// 没有它就只知道「排过队」,不知道是等了几秒还是根本没排上。
|
||||
const waited = job.enqueuedAt ? Math.round((Date.now() - job.enqueuedAt) / 1000) : null;
|
||||
log(`出队 ${job.key}(等了 ${waited === null ? '?' : waited + 's'};` +
|
||||
`剩 ${queue.length} 封在排,在跑 ${activeCount()}/${maxWorkers})`);
|
||||
spawn(job);
|
||||
}
|
||||
}
|
||||
|
||||
51
plugins/pi-mail-bridge/test/queue-observability.test.mjs
Normal file
51
plugins/pi-mail-bridge/test/queue-observability.test.mjs
Normal file
@ -0,0 +1,51 @@
|
||||
/**
|
||||
* ★ 排队路径必须出声(2026-09-28 加)
|
||||
*
|
||||
* 为什么有这条判据:池满时邮件进 `queue`,而**排队路径此前一句日志都没有**。
|
||||
* 于是「这封在排队」与「这封丢了」在日志上**完全同形** ——
|
||||
* 实测当天:发一封信,桥日志里那封 mail_id 一次都没出现,
|
||||
* 而它在库里已被 `markDelivered` 标成 read。
|
||||
* 当时能给出的结论只有「不知道」。
|
||||
*
|
||||
* 这条判据钉的是**形状**(源码里有入队/出队两声),不是读数 ——
|
||||
* 读数要真的把池压满才有,而那要 3 个真 worker。
|
||||
*/
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const src = readFileSync(join(HERE, '..', 'src', 'pool.mjs'), 'utf8');
|
||||
|
||||
test('入队与出队各有一声,且都带 key', () => {
|
||||
const inQ = src.match(/log\(`排队 \$\{[^}]*\}/);
|
||||
assert.ok(inQ, '入队必须打日志(否则「在排队」与「丢了」同形)');
|
||||
|
||||
const outQ = src.match(/log\(`出队 \$\{[^}]*\}/);
|
||||
assert.ok(outQ, '出队必须打日志(否则不知道等了多久)');
|
||||
});
|
||||
|
||||
test('★ 出队那一声要说出等了多久', () => {
|
||||
// 只打「出队」不够:知道排过队,但不知道等了 2 秒还是 20 分钟。
|
||||
// 这正是当天「不知道」的一部分 —— 读数里没有时间维度。
|
||||
assert.match(src, /enqueuedAt/,
|
||||
'入队时要记时间戳,否则算不出等待时长');
|
||||
assert.match(src, /等了 \$\{waited/,
|
||||
'出队那一声必须包含等待时长');
|
||||
});
|
||||
|
||||
test('入队那一声要带当前占用,否则事后无法判断是不是池满导致', () => {
|
||||
assert.match(src, /activeCount\(\)\}\/\$\{maxWorkers\}/,
|
||||
'入队日志必须带 activeCount/maxWorkers —— 区分「池满」与「别的原因」');
|
||||
});
|
||||
|
||||
test('判据有分辨力:去掉入队日志 ⇒ 本组必须变红', () => {
|
||||
const stripped = src.replace(/log\(`排队 \$\{[\s\S]*?\}\);/, '');
|
||||
assert.ok(
|
||||
!/log\(`排队/.test(stripped),
|
||||
'自检:删掉入队日志后源码里确实没有它 —— 否则这条判据恒绿'
|
||||
);
|
||||
assert.notEqual(stripped, src, '自检:变异确实改动了东西');
|
||||
});
|
||||
Reference in New Issue
Block a user