Files
MailUI4Agents/docs/DEBTS.json
dsh 3a54a3b533 docs(debt): 补记并闭环 —— 两条 relay 占位行泄漏路径已修复上线(M 读法随修复而变)
复核 pi 的 a68f62d2(09-25 06:03:43,已由 32aa94cb 答复,56 封在后)。
他那封的增量是: 真轴是"载体有没有检查器"而非"数据 vs 代码"
(两者都是代码文件里的字符串,但一者 rc=1 一者 rc=0),⑩″ 收,并补 ⑭。
我 1 分 32 秒后已收(并补 ⑮: 优先怀疑"不用问就能答"的判据)。

但那封里我报了更要紧的事,而它**只存在于邮件里、没落进仓库**
(与 commit-as-reply-is-not-a-reply 同族)。本轮复核 + 闭环,转成可判记录。

缺陷(修前): ClaimRelay 之后、CreateMail 之前的两条早退
  permission.go(session_id 非 UUID)与 mail.go(budget 非耗尽就报错)
  ⇒ 落 mail_id IS NULL 的**占位行** ⇒ 白占幂等键、后续重试被挡。
  2026-09-25 实测两个字符串都在当时的生产二进制里(mtime 09-19、进程 09-20 启动)。

现在: 已修复、已上线、已验证
  7589f0a →(filter-repo 重写)34a15dc;90e5cef → 6e4bcd66
  线上二进制内嵌 revision=b0c87192(进程 09-30 17:30:56 启动)
  git merge-base --is-ancestor 34a15dc b0c87192 ⇒ true(6e4bcd66 同)✓
修法值得留档: permission.go:109/118 —— **defer 挂作用域出口**,而不是逐个 return 补释放
  ⇒ 一条路径有多处早退时,逐处补必漏;挂出口才能让"漏"在语言层面不可能发生。

可判验收(本轮跑的):
  M = 占位行 = 1,唯一那行是修前化石(created_at 2026-09-12 06:06:13,
      relay_key=no-such-session-0000:toolu-nohuman-…);N = 592
  上线后(> 2026-09-30 09:30:56)新建占位行 = **0**
读法(M 的语义随修复而变): 修前 M 只增不减 = **泄漏计数器**;修后 M = **瞬时量**
  ⇒ M 长期非 0 ⇒ 先怀疑"还有没有未修完的早退路径",而不是"正好有请求在飞"

⚠️ 证据强度: "上线后 0 新增"是**弱证据** —— 上线仅约 1 小时且未必有输入触发那条早退。
  它不违例但样本不足,**不足以宣布泄漏彻底关闭**。要变强证据需 ①构造一次
  session_id 非 UUID 的请求 ②或观察数天 M 恒为 1 不增长。
  库里那 1 行化石修复不会清掉它(修复只保证不再新增);清它属数据决定,未做。

未做: 没改代码、没动生产(只读 git merge-base + strings + SQL)。
  线上 b0c87192 落后于仓库 HEAD 72fe806 —— 但**已含**本条两个修复,
  这两件事要分开说,否则会读成"线上没修"。

验证: go test ./internal/repo/ -run Debt 全绿; debt-visibility.test.mjs 1/1。
2026-10-01 19:00:13 +08:00

405 lines
286 KiB
JSON
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{
"_": [
"欠账的**单一登记**(pi 2026-09-14 裁定 §3):三笔类型不同、但必须能一眼看全。",
"为什么要一个文件:三笔原先各自表达(RESULT static=5 / t.Skip / 登记在文档里的到期前提),",
"没有一处能看全 —— 而『欠账不显形,就等于没有』;分散在多处的登记,审计时只会被找到一处就当全部。",
"两端都读这个文件:Go 侧判据断言自己的条目与**实测**一致(不许留一份手写的数字),",
"electron 套件把它打进 RESULT 行(那是常态可见的位置)。",
"已结算(2026-09-14):calendar-today-recompute —— P6 第 1 步(日历页 pages/CalendarPage.ets)落地,today 走 `@Prop @Watch('onVisibleChanged') visible` 在 pane 变可见时重算(另有 aboutToAppear 覆盖重新挂载),判据 test/harmony-calendar.test.mjs 的「★ today 在 pane **变可见时**重算」。结算即从此清单移除,余额里不再计这一笔。",
"已结算(2026-09-19):gesture-semantics —— P6 第 3 步(左右滑动翻页)落地:鸿蒙侧 `CalendarPage.ets` 加了 `PanGesture`、判定逻辑在纯逻辑层 `model/Calendar.ts` 的 `judgeSwipe`。按该条自己的口径(「鸿蒙侧出现滑动手势代码时**立即建**」)同步建了语义契约判据 `test/cross-client-gesture.test.mjs`(8 条)—— 按 (b) 口径钉**语义**不钉数值:左滑=下一段/右滑=上一段/纵向优先/快滑窗口/手势与按钮共用同一翻页函数/无边界回弹/有意差异被记录/方向写反必红。另在 `harmony-logic.test.mjs` 加了行为判据(跑 `judgeSwipe` 的四道门)。结算即从此清单移除,余额里不再计这一笔。",
"2026-09-19 新增 wide-breakpoint-divergence:两端断点不同且含义不同(审计发现,此前无记录)。它不是\"已确认的缺陷\",而是**未决的口径** —— 登记它是为了让「两者不同」这个事实本身可见,而不是让它藏在两处代码里。",
"2026-09-19 新增 mail-list-attachment-count:邮件列表的附件数两端都拿不到(WebUI 那段是死代码)。鸿蒙侧只补了有真数据的「抄送 N」。",
"2026-09-19 新增 harmony-maildetail-missing-three:邮件详情页缺三块功能(改名建议 / 转发 / 预算编辑),服务端都已支持。",
"2026-09-28 新增 stress-sse-delivered-but-silent-no-assertion:pi 报的「9 封从未投出」经复核是**投递成立、不回信属协议设计**(Agent 来信不自动转发);真缺口是压测脚本只断言帧完整、**从不断言投递**,而两个读数在库里同形。已给 `stage_sse` 补上按桥日志核对的投递读数。",
"2026-09-28 新增 criterion-silently-returns-zero:判据在真实数据上**静默归零**比判据报错危险得多。本轮两个 Agent 各犯一次同一个错(pi 的 `from_name='pi'` 选出「没回信」;我取信日志的路径少了 `/agent` 前缀),两次都「核过对方」且都报 0。附 33 条 alias 带字面引号的第三例。",
"2026-09-28 新增 archive-session-filter-partial:FindSessionByAddress 是别名寻址族里唯一不带归档过滤的一处(其余三处都带)。pi 裁定本轮不夹带(会改 API 返回码 200→404),登记为已识别的不一致而非漏做的收尾。",
"2026-09-28 12:55 登记 `archive-sweeper-leaves-mails-alive`:反向两表分叉(`s.status='archived'` 而 `m.status<>'archived'`)在生产里已有 **28 会话 / 431 封**,成因直接钉到 `deploy/archive-stale-sessions.sh:105`(归档不 UPDATE mails)与 `:147`(回滚同样只写 sessions),并用 `/root/gotmp/archive-ids-20260915-065927.txt` 的 12 个 id 闭合因果(12/12 都是分叉)。不变量测试 `session_status_invariant_test.go:70` 查的正是这个方向,但只在 `t.TempDir()` 空库上跑 ⇒ 存量分叉它永远看不见。",
"2026-09-28 13:20 修正 `archive-sweeper-leaves-mails-alive` 三处,并按 pi 的唯一异议调整 A/B 分类:(1)★ **回滚后显示未读的是 45 封不是 7 封** —— `readStateFor`/`unreadFor` 只看 `mail_reads`,`m.status` 是冗余列(repo.go:2132 注明不再作未读判据),424 封 'read' 里有 38 封收件人侧无 read 行;且「45」成立靠的是这批 cc_list 全空(实测 431/431),判据必须按 reader_name 写。(2)分叉**仍在产生**:28 条里 8 条是今天 09-28 的(当天 archived 会话共 72 条)。(3)成因不止 sweeper:`ArchiveSession` 两句 UPDATE **无事务包裹** ⇒ 中途崩溃也留反向分叉,这解释了 6 条混存会话(该函数执行即盖全部非 archived,造不出「一部分 archived」形状)——混存形状的成因未闭合,**不写死结论**。(4)pi 的「ids 只有 session_id ⇒ 只反还本次不可实现」对但不完整:`agentmail-pre-archive-20260915-065927.db` 是归档前快照,12/12 条可反推(共 467 封非 archived);且 `undo-poison-archive-FIXED.sh` 是**逐封点名 mail_id**(34 封)⇒ 那不是普遍约束,决策空间实为「无快照的那些次怎么办」。(5)分类:**A 类删原第 8 条、B 类加为第 11 条** —— pi 指出 82>65 的症状不是 0,按判别标准属 B;A 类新增第 0 条「口径对齐了吗」。判别标准收敛为:**A = 测不出/测不稳;B = 测出来了但推不出来**。"
],
"debts": [
{
"id": "static-criteria",
"count": 5,
"due": "本工作区能装、能点设备(探针三值转 true 时自动变红)",
"where": "client/electron/test/run-all.mjs 的 STATIC_ONLY(2026-09-19 起 harmony-admin 那条已升级为设备判据、移出名单 ⇒ 6 → 5:test/harmony-appearance.test.mjs、test/harmony-logic.test.mjs、test/cross-client-theme.test.mjs、test/appearance-defaults.test.mjs、test/harmony-imageprep.test.mjs)",
"kind": "scope",
"note": "2026-09-19:`harmony-admin` 的条目**已升级**(管理页真的能打开、列表真的渲染出用户行)并移出 STATIC_ONLY ⇒ 本笔 6 → 5。剩下的五次升级按\"每条缺什么设备侧验证\"逐条来,不为了把数字消成 0 而凑 —— 凑出来的设备判据只是把\"没验\"换成\"假装验了\"。\n\n2026-09-19 再更新:`cross-client-theme` **升级了一半** —— 新增一条设备判据(读悬浮球像素:品牌色真的画成 `#2563EB`;且球上图标与底色 WCAG 对比度 ≥3:1),并顺手钉了一条静态防线(`Theme.surface` 不得当代的前景色)。**它仍留在 STATIC_ONLY 名单里**,因为 `.ets` 那半只有悬浮球这一处上了设备 —— 其余令牌仍是静态对齐。「一半」要写出来,不能让名单看起来像没动过。"
},
{
"id": "mails-status-derived",
"count": 1,
"due": "详情/线程改为按读者派生(readStateFor)之后 —— 那时 mail_status_derived_test.go 从 Skip 转实跑",
"where": "server/internal/repo/mail_status_derived_test.go",
"kind": "scope"
},
{
"id": "observability-output",
"count": 1,
"due": "页面层(MainPage.ets)接上「读 presetSubstitutedFrom 并打一行日志」时;那一步同时补判据『读侧恰好出现 1 次且在日志调用里』",
"where": "尚无判据 —— 这正是欠账的一部分(P6 第 1、2 步动 MainPage.ets 时一起做);形态判据在位:test/harmony-appearance.test.mjs(bgBlur 消费侧计数)",
"kind": "scope"
},
{
"id": "unknown-preset-approval",
"count": 1,
"due": "有人对上表那格**追认或驳回**「未知 id 显示 aurora 而不是空白」这个方向时(我作为实现者不能自己追认自己)",
"where": "client/electron/test/CRITERIA.md §10 的『未知的预设 id』行(现为『无人类批准』)",
"kind": "env"
},
{
"id": "overlay-follows-app-theme",
"count": 1,
"due": "上设备后**翻转一次 colorMode**(应用深色 / 系统浅色),断言**解析出的遮罩值跟着「应用」主题变、而不是跟「系统」**;真机若证伪,正确修法是「遮罩从应用主题派生」,不是回到双常量",
"where": "client/harmony/entry/src/main/ets/common/Theme.ets:58-79 的注释(机制依据:AppearanceStore.applyTheme → app.setColorMode)——**注释不是判据,所以进余额**",
"kind": "env"
},
{
"id": "nav-dark-route-b-unguarded",
"count": 0,
"due": "已完成 2026-09-23(见 note)",
"where": "client/electron/test/background.test.mjs —— **已堵**:见 note。(原文:`.dark .nav-rail{}` 选择器作用域与组件 `dark:` 变体,变异确认过都会逃掉)",
"kind": "scope",
"note": "**已堵(2026-09-23)**。这三条逃逸路(A 选择器作用域 / B Tailwind `dark:bg-*` 变体 / C 元素级 `backdrop-blur-*`)此前各自无判据,变异确认过全逃得掉。到期条件(深色主题落地)在 2026-09-17 就成立了 —— 但那次只修了令牌这条路,欠债逾期至此。\n\n堵法(欠债原文点名的**文件窄豁免**,不是一刀切):`background.test.mjs` 新增三条 check,**只扫 `className` 里出现 `nav-rail`/`nav-item` 的那些类串** —— 问的不是「这个文件有没有 dark: 背景」,而是「挂在导航元素上的那个类串里有没有」(与逃逸路的形状同构;同文件别的元素写什么都不影响,避免 `bg-chrome-600` 那个先例的误红)。\nA 条另起一条:CSS 里不许出现 `.dark .nav-rail`/`.dark .nav-item` 这类选择器。\n三条都做了变异验证(逐条注入 ⇒ 各红一条;恢复 ⇒ 47/47 全绿)。"
},
{
"id": "nav-blur-route-c-unguarded",
"count": 0,
"due": "已完成 2026-09-23(见 note)",
"where": "client/electron/test/background.test.mjs —— **已堵**:见 note。(原文:元素级 `backdrop-blur-lg` 不在那条选择器下,变异确认过逃得掉)",
"kind": "scope",
"note": "**已堵(2026-09-23)**。这三条逃逸路(A 选择器作用域 / B Tailwind `dark:bg-*` 变体 / C 元素级 `backdrop-blur-*`)此前各自无判据,变异确认过全逃得掉。到期条件(深色主题落地)在 2026-09-17 就成立了 —— 但那次只修了令牌这条路,欠债逾期至此。\n\n堵法(欠债原文点名的**文件窄豁免**,不是一刀切):`background.test.mjs` 新增三条 check,**只扫 `className` 里出现 `nav-rail`/`nav-item` 的那些类串** —— 问的不是「这个文件有没有 dark: 背景」,而是「挂在导航元素上的那个类串里有没有」(与逃逸路的形状同构;同文件别的元素写什么都不影响,避免 `bg-chrome-600` 那个先例的误红)。\nA 条另起一条:CSS 里不许出现 `.dark .nav-rail`/`.dark .nav-item` 这类选择器。\n三条都做了变异验证(逐条注入 ⇒ 各红一条;恢复 ⇒ 47/47 全绿)。"
},
{
"id": "boundary-vocabulary-incomplete",
"count": 1,
"due": "**由外部读者报告时**(自查机制对这一类结构性失明 —— 发现词表外说法的机制,正是看不见它的那个机制)。收到报告后:扩词表 + 登记该处 + 保留\"上一次是谁发现的\"。**没有内部触发器,这是这条递归的不动点**:无论词表多长、判据多严,总有一类盲区只能靠\"外面有人读了一遍\"",
"where": "client/electron/test/debt-visibility.test.mjs(词表键控的盲区:**已知未覆盖**——词表是采样、不是完备)",
"kind": "env"
},
{
"id": "redeploy-script-unguarded-steps",
"count": 0,
"kind": "scope",
"due": "**已还清 2026-09-25**(见 note)",
"where": "`deploy/redeploy-gateway.sh` 的四类副作用步骤 —— **已堵**(见 note)。(原文:`deploy/redeploy-gateway.sh:84` 的 `run \"cp -r …\"`,`run()` 内 `eval` 的失败既不中断也不被调用点接收)",
"note": "**已堵(2026-09-25)**,出口是这条欠账自己写的到期条件(\"下一次改 `deploy/` 下任一脚本时\")—— 当天因修 `--dry-run` 落地写入而动了该脚本,故一并还。\n\n堵法按原文给的形状(`|| { bad …; exit 2; }`,与既有 2=环境/1=检查 对齐),四处:\n ① 前端同步三步(`rm -rf assets` / `rm -f index.html` / `cp -r dist`)各自接收退出码;\n `cp` 尤其要紧:没拷进去 ⇒ go:embed 把**旧界面**打进二进制,而单测仍全绿(2026-09-14 踩过)。\n ② `systemctl stop` 加守卫 —— 这是本条目原文点名的\"stop 失败而状态没人看\"。\n 加它的理由比原文更强一层:`systemctl start` 对**已在运行**的服务是 **no-op** ⇒ stop 没成功时后面那句 start 什么也不做,**旧进程继续跑旧代码**,而脚本一路走到后置验证报成功 —— 即\"部署脚本跑过了 ≠ 线上跑的是当前代码\"被脚本**自己在内部**造出来。\n ③ am-sandbox 段的 `go build`/`install` 并入 DRY_RUN 分支。\n ④ `install -d $PREFIX/bin` 与通知脚本 `install` 并入 DRY_RUN 分支(同批修的 `--dry-run` 会落地问题)。\n\n**未一并做的**(如实记,避免本条读起来像\"全脚本已无裸步骤\"):`redeploy-plugin.sh` / `install.sh` 的同类步骤仍未逐个加守卫 —— 那是另一条欠账 `deploy-interrupt-trap-other-scripts` 的范围,本条只覆盖 `redeploy-gateway.sh`。"
},
{
"id": "deploy-space-prefix-fs",
"count": 1,
"kind": "判据铺得不满(不是新列)",
"due": "下一次因空间问题失败时;或有人愿意补一行 df 时",
"where": "deploy/lib/env-defaults.sh ②b 只判了 $TMPDIR,没判 $PREFIX 所在的文件系统"
},
{
"id": "pi-bridge-adopt-cwd-mismatch",
"count": 1,
"kind": "沙箱 rw 与 worker 实际 cwd 的第三个来源未对齐(同类已修两处,剩接管路径)",
"due": "下一次动 pi 桥的会话装载 / `session-scan.mjs` 时;或有人愿意把「这一轮用哪个 cwd」完全收成父进程一处决定时",
"where": "`plugins/pi-mail-bridge/src/worker.mjs` 的**接管会话**分支:worker 用会话文件 header 里的 `info.cwd`(`resolveWorkspaceCwd(info.cwd || data.to_workspace, …)`),而父进程(`src/pool.mjs` → `src/turn-cwd.mjs`)只能从 `state` 里拿 `{sessionFile, cwd}`,**读不到 header** ⇒ 接管的首回合 rw 仍可能不含 worker 真正要写的目录。父进程要拿 header 得用 `src/session-scan.mjs`,但 `readHeader` 未导出、整表 `scan()` 在父进程里代价大(worker 里实测 1431ms / 堆瞬时 240MB,见 worker.mjs 里那段注释)。★ 这类错位的**特征是没有提示**:EACCES 落在「界内」,读日志的人会以为沙箱装错了(不像 `ask` 还有一次问)。修法方向:把「这次用哪个 cwd」收成父进程一处决定(它已有 `state.sessionFile`,header 也可读),worker 只消费、不再自己推导 —— 即 `src/turn-cwd.mjs` 头注释里写的「三来源变一来源」。"
},
{
"id": "deploy-interrupt-trap-other-scripts",
"count": 1,
"kind": "只在 redeploy-gateway.sh 做了,另两个部署脚本没做",
"due": "下一次动 redeploy-plugin.sh / install.sh 时",
"where": "只有 redeploy-gateway.sh 有 INT/TERM/HUP trap;install.sh 与 redeploy-plugin.sh 在写系统目录期间被打断同样会留半成品(它们没有\"服务停着\"那种后果,所以优先级低)"
},
{
"id": "harmony-p4c-boundary-decls",
"count": 4,
"due": "本工作区能装、能点设备 —— 那时这几条静态判据里被替代掉的那些断言换成真机断言,声明随之减少",
"where": "client/electron/test/harmony-admin.test.mjs、client/electron/test/harmony-imageprep.test.mjs",
"kind": "scope",
"note": "2026-09-19 更新(设备可用了,开始逐条升级):① `harmony-admin` 的那条**已升级为设备判据**(管理页真的能打开、列表真的渲染出用户行)⇒ 从本组减 1。② `harmony-imageprep` **只做了一半**,如实记着:新加的设备判据用真实素材(用户真上传的那张壁纸 1402×1122 / 152570 字节,从 GET /me/appearance/image 取回)验了「设备上读到的尺寸/体积与压缩决策的输入对得上」;**未做**「应用真的用 image.createImagePacker() 压一次、产出字节落在预期区间」——那需要一个**用户选图**入口(DocumentViewPicker,要人操作系统选择器),自动化里没有稳定路径。编一个绕过选择器直接调 packJpeg 的测试专用入口,会是只有测试在用的代码 —— 那种代码不会被真实场景触到,验它等于验一个不存在的东西。⇒ 剩 4 条里这一条要等一个**真人操作**的验证窗口(或平台提供可注入的选择器)。"
},
{
"id": "deviceprobe-fixture-timing",
"count": 1,
"due": "把两份 fixture 变成**当场采集**(跑 hdc dump 取现场)而不是人工存文件时,这条就到期",
"where": "client/electron/test/harmony-deviceprobe.test.mjs(两处提到「未验」的断言 + fixtures/aa-dump-l-*.txt)",
"kind": "scope"
},
{
"id": "wide-breakpoint-divergence",
"count": 1,
"due": "决定是否统一:鸿蒙 768vp vs WebUI 1024px(**不是 bug,是未决的口径**)",
"where": "client/harmony/entry/src/main/ets/pages/MainPage.ets(isWide 的 onAreaChange,>=768) 与 client/electron/src/hooks/useIsNarrow.ts(NARROW_QUERY = max-width: 1023px)",
"kind": "scope",
"note": "两端断点不同,且**含义也不同**,所以数值不同本身不算错:WebUI 的 1024 是「三栏(60 导航 + 320 列表 + >=520 详情 ≈ 900px,再加余量)放不下就退化单栏」,鸿蒙的 768 是「要不要显示侧栏」(鸿蒙没有并排的列表+详情三栏,它的内容区是一个窗格,所以 768 就够)。但**用户可见的后果**是:在 768–1023 宽(常见竖屏平板、窄窗口)下,WebUI 是单栏 + 底部导航,鸿蒙是侧栏 + 内容 —— 两台设备上同一宽度长得不一样。2026-09-19 由审计发现(此前**没有任何地方记录**这件事,连「两者不同」这个事实本身都没写下来,所以下一个人只会当成漏改)。待定:① 统一到 1024(鸿蒙跟 WebUI);② 统一到 768(WebUI 跟鸿蒙,但要重新论证三栏是否真能在 768 放下);③ 承认它们是两件事、把语义差异写进文档(那就该给两端各自的名字,而不是都叫 wide)。"
},
{
"id": "mail-list-attachment-count",
"count": 0,
"due": "已修(2026-09-20):字段确实返回,两端都可接",
"where": "server/internal/handler(邮件列表的响应构造) —— 客户端侧见 client/harmony/entry/src/main/ets/pages/MainPage.ets 的 MailItem",
"kind": "scope",
"note": "★★ 2026-09-20 **更正**:当初这条的结论是**错的**,根因是它把一个`omitempty` 造成的**字段缺失**当成了「服务端不返回这个字段」。\n\n原文(留档,别再犯):实测 `GET /me/mail/inbox` 的回包字段列表里「既没有 `attachments` 也没有 `has_attachments`」⇒ `MailList.tsx:261` 的 `mail.attachments?.length ?? 0` 恒为 0、那个 📎 在 WebUI 上从不出现(死代码)。\n\n事实:服务端 `GetInbox`(`mail.go:586`)**明确调了 `fillAttachments`**,并写了理由:「Agent 靠收件箱列表得知有哪些附件可下载,否则它不知道该调 attachment_id」。而 `Mail.Attachments` 的 json tag 带 **`omitempty`** —— **没有附件的邮件根本不输出这个 key**。\n我当初是**只看了一封没附件的邮件**就下了全称结论。\n\n2026-09-20 实测(`limit=200`,96 封):带 `attachments` 的 **2 封**,都是真有附件的那两封。⇒ **WebUI 那个 📎 不是死代码**,鸿蒙当初「有意不抄」的前提不成立。\n\n★ 教训:`omitempty` 字段的**缺失**不等于「服务端不返回」。判「某字段有没有」必须拿**确实有值的那条**去验,而不是拿一条恰好为空的数据。这与同一天那个真崩溃(`session_workspace` 的 `omitempty` → 客户端 `undefined` → 白屏)是**同一个坑的两面**。"
},
{
"id": "harmony-maildetail-missing-three",
"count": 0,
"due": "鸿蒙详情页对齐 WebUI 时(三块功能,服务端都已支持)",
"where": "client/harmony/entry/src/main/ets/pages/MailDetailPage.ets (对照 client/electron/src/components/MailView.tsx)",
"kind": "scope",
"note": "2026-09-19 审计发现:邮件详情页缺**三块 WebUI 有的功能**,而且**服务端三块都已支持**(不是做不了):① `RenameProposalBar`(MailView.tsx:324)—— Agent 建议改会话别名,人确认/驳回。服务端 `server/internal/handler/rename_proposal.go` 已在解析 `propose_alias` 标记并把提议从正文剥掉。**这条对寻址稳定性很重要**(WebUI 那里的注释:「Agent 干到一半自己改掉,人上一秒记住的地址下一秒就失效」)。② `ForwardBar`(MailView.tsx:378)—— 转发。服务端 `forward.go` 完整(含 `forwardSubject` 的 Fwd: 叠加处理)。③ `BudgetEditor`(MailView.tsx:235)—— 会话往返预算编辑(`max_rounds`,服务端 `forward.go:304` 有此字段)。三条都不是顺手能补的量级(各含交互 + 接口 + 状态),所以先登记,不在审计那一批里硬塞 —— 硬塞的结果是每条都半成品。\n\n2026-09-19 结算:三块**都做完了** ——\n① 转发(commit cc7ff25,`ForwardMailRequest` + 弹层;修了两个真 bug:两个动作球几乎完全重叠、弹层没高度导致键盘一弹按钮被顶出屏幕);\n② 会话改名建议(commit c26e857 接口 + fe4342a UI,端到端实测:服务端识别 rename_proposed → 建议条出现 → 点接受 → 库里别名真的改了);\n③ 往返预算(commit 4e84369,GET/PUT 契约用 curl 逐条实测)。\n★ 未验:预算条的**点击**在设备上没走通 —— 模拟器顶部 155px 是系统手势区,而折叠头部恰在其中(坐标式点击会被系统抢走)。数据链路已实测,观感没验。"
},
{
"id": "harmony-dead-pages",
"count": 2,
"due": "下次清理鸿蒙页面时(要先确认不是将来要用的留存实现)",
"where": "client/harmony/entry/src/main/ets/pages/InboxPage.ets(238 行)、pages/SessionsPage.ets(170 行)",
"kind": "scope",
"note": "2026-09-20 盘点发现的**不可达页面**(两页共 408 行):\n· `InboxPage.ets` —— 不在 `resources/base/profile/main_pages.json` 的页面表里,全仓**没有任何** `pushUrl('pages/InboxPage')`。\n· `SessionsPage.ets` —— 注册在页面表里,但**唯一的引用**是 `InboxPage.ets:171` 的 `pushUrl('pages/SessionsPage')`,而 InboxPage 本身不可达 ⇒ 一起不可达。\n\n★ 为什么没顺手删:`docs/HARMONY-ALIGN-PLAN.md:214` 把 `InboxPage.ets` 当作变异测试的靶子(往它塞一个色值来验证判据能判红),说明它在某个时点是有用的。删掉会永久丢代码,而它是否只是「早期实现的留存」我判断不了 —— **这是人的决定**。\n\n★ 它现在还带来实际成本:`cross-client-logic` 与 `harmony-arkts` 的**形状判据**会扫到它(并按「死代码」给它豁免)—— 每加一条形状判据都要为「这文件还活着」多写一次例外。要么删、要么在文件头明确写「这是留存参考、不参与构建」,两条路都比现在清楚。"
},
{
"id": "harmony-permission-history",
"count": 0,
"due": "**已完成 2026-09-21**(本轮「授权栏与 WebUI 对齐」)",
"where": "client/harmony/entry/src/main/ets/pages/MainPage.ets 的 PermissionTab(现在只读 /permission/pending)",
"kind": "scope",
"note": "**已修(2026-09-21)**。原文:鸿蒙只调 `/permission/pending`(SQL `WHERE pr.result IS NULL`)⇒ 已决策的历史完全看不到。\n\n修法与**为什么不照抄 WebUI 的 inbox 分组**:\n· WebUI 从 inbox 分组(`groupPermissions`),但它的 `PermissionRow` 只渲染\n `subject`/`created_at`/`permission_result`/`permission_expires_at`(逐字段 grep 过);\n· 而**待决**那一段我们要显示 `question`/`options`/`context`/`kind` —— 那四个字段在\n `permission_requests` **表**里,inbox 回包(`models.Mail`)**没有**(模型逐条核过)。\n⇒ 待决继续走专用端点(信息更全、能直接决策),**历史**走 inbox 补上。\n 代价:每账号多一次请求。这是有意的取舍。\n\n落地:`model/MailGrouping.ts` 加 `groupPermissions` + `PermissionGroup` + `isPendingPermission`;\n`PermissionTab.load` 取 inbox 里 `mail_type=permission_request && permission_result!=空` 的,\n分组后渲染「历史 n 条」。`cross-client-logic.test.mjs` 的 `gaps` 已按提示清空。"
},
{
"id": "harmony-morph-unverified-middleframes",
"count": 1,
"due": "有更快的取帧手段时(snapshot_display 往返 1.5-3s ⇒ 只能验 >=3s 的动画),或用户在真机上看过并反馈",
"where": "client/harmony/entry/src/main/ets/common/Motion.ets 的 Motion.morph;调用点 MailDetailPage / MainPage",
"kind": "env",
"note": "2026-09-21 加了两处**共享元素转场**(geometryTransition:回复球<->回复条、写信 FAB<->写信页),结构与接线都有判据钉住(animation-audit.test.mjs 6 条,三条变异逐个验过会红),但**动画本体在设备上没能看到**。\n\n原因:snapshot_display 一次往返 1.5-3s,uitest screenCap 约 3s,而这条动画 220ms ⇒ 探针比被测对象慢一个数量级。实测连拍 4 张,y=1600 的白区跨度全是 (30,1007)(每张都已是终态)。\n\n★ 这里的教训本仓已记过一次(「探针对被测变化不敏感时,量的是噪声」——那次我连测七八轮「没有中间帧」并编出三个错误理论,最后被位移探针推翻)。这次不再重复:**如实标未验**,不声称已看到。\n\n要验它需要:能按帧取图的手段(screenrecord 在本环境不可用),或真机上手看。"
},
{
"id": "harmony-account-errors-banner-unverified",
"count": 1,
"due": "有一个会取失败的账号可构造时(例如故意把某账号的 server 指向不可达地址)",
"where": "client/harmony/entry/src/main/ets/pages/MainPage.ets 的 accountErrors 横幅",
"kind": "env",
"note": "2026-09-21 接上了聚合失败横幅(WebUI MailList.tsx:85-96 的 account-errors)。修的是一处**安全网断线**:MailStore 一直在收集 snap.accountErrors(两处 load 都写),而界面从来没读过 ⇒ 某个账号拉不到邮件时列表**静默少一整份**,界面看起来完全正常,用户会得出错误结论(「没人给我发信」)。\n\n**只验了「不出现」那一半**:本机所有账号都取得到 ⇒ 横幅正确地不显示。「真的会显示成那样」没能实测 —— 需要一个取失败的账号,本机构造不出。\n\n为什么仍值得登记:这条横幅的**全部价值就在它出现的那一次**。「逻辑对齐 + 编译通过」与「真的渲染出来」是两件事,本仓为此反复吃过亏。"
},
{
"id": "harmony-permission-history-render-unverified",
"count": 1,
"due": "**有一个「未归档会话」里的已决策权限请求时**(光 `UPDATE mails SET permission_result` 不够 —— 见 note 里的实测)",
"where": "client/harmony/entry/src/main/ets/pages/MainPage.ets 的 permGroups 渲染段",
"kind": "env",
"note": "2026-09-21 闭合 harmony-permission-history 时新增的「历史 n 条」渲染。\n\n分组逻辑(`groupPermissions`)是纯函数、有跨端逐例判据;渲染那一层没验。\n\n★★ 2026-09-23 **实测订正复现路径**(原文写的是「`UPDATE mails SET permission_result` 之后即可复现」—— **那样做复现不出来**)。\n\n本机实测:库里**确实有 137 条已决策**的权限请求(`to_name=jianf` 107 条),但 `GET /me/mail/inbox?status=all` 返回的权限请求是 **0 条**。根因不在权限,在**会话归档**:\n · `repo.ListInboxScoped` 硬编码 `AND s.status <> 'archived'`(该过滤在整个 repo 出现 8 处,是「归档会话不进任何列表」的**全局约定**);\n · 而那 107 条所在会话**全部是 archived** ⇒ 被整条过滤掉;\n · 实测「未归档会话 + 已决策权限请求」的组合数 = **0**。\n\n ⇒ 鸿蒙的「历史 n 条」在**当前这台库上恒为空**,不是渲染坏了,是数据够不到。\n 两端一致(WebUI `PermissionList.tsx:66` 也是 `fetchInbox('all')`,同一端点同一过滤)⇒ 这不是鸿蒙的遗漏,**是两端共同的行为**,服务端的过滤是有意的。\n\n要真验,需构造:**未归档**会话 + 该会话里一条已决策的权限请求(例如 `UPDATE sessions SET status='active' WHERE session_id=<那条>`,或让 agent 在活跃会话里发一条再决策)。\n\n★ 另一处(同一次实测发现,已修):渲染注释曾承诺「会话别名 + **决策** + **时间**」,而代码只渲染「别名 + 历史 n 条」—— 属本仓反复在消的「声明比实现宽」。已把注释改成与 WebUI 折叠态一致的**真实形态**(WebUI 也是每组一行 `历史 {n}`,逐条的决策/时间要展开才看得到,而鸿蒙没有展开层)。"
},
{
"id": "permission-expires-at-unused",
"count": 1,
"due": "决定是否要把「可能已失效」做进**两端**(需要两端都补类型 + 渲染);或确认这个告警对产品不重要、把服务端那个字段也去掉",
"where": "服务端 `models.go:254`(`PermissionRequest.ExpiresAt`)/ `repo.go:1617`(`pr.ExpiresAt = models.PermissionDeadline(...)`);客户端类型两处都缺:`client/electron/src/types/index.ts` 的 `PermissionRequest`、`client/harmony/entry/src/main/ets/model/Models.ets` 的 `PermissionRequest`",
"kind": "scope",
"note": "★★ 2026-09-23 发现自己:**服务端返回一个两端客户端都不读的字段**。\n\n`GET /permission/pending` 的回包里 `expires_at` 一定存在(`ExpiresAt time.Time` 且**不带** `omitempty`),服务端在 `ListPendingPermissionsFor` 里用 `models.PermissionDeadline(pr.CreatedAt)` 算出它。而**两端的 `PermissionRequest` 类型都没有声明这个字段** ⇒ 反序列化静默丢掉。\n\n── 这次的教训与 `mail-list-attachment-count` 那次**方向相反、形状相同** ──\n那一次我把「`omitempty` 字段在一封没附件的邮件里缺失」当成了「服务端不返回这个字段」;这一次是**真的两端都没读**,而我一开始又差点写成「鸿蒙落后于 WebUI」——实际是**共同缺口**(WebUI 也没读)。两次都说明:**「两端不一致」与「两端都没做」必须先分清**,否则会去\"对齐\"一个根本不存在的东西。\n\n── 已经做掉的那一半 ──\n鸿蒙侧 2026-09-23 补上了 `expires_at` 并把它接进**待决卡片**的失效告警(`PermissionTab.ets` 的 `isStale`)。所以这条债现在**只剩 WebUI 那一半**:WebUI 的 `PermissionList.tsx:248` 读的是 `mail.permission_expires_at`(**别的字段**,那是 `Mail` 模型上由 `AttachPermissionDeadline` 算出来的,只在 inbox 路径上有),而它自己那份 `PermissionRequest` 同样没有 `expires_at`。\n\n★ 要闭合需先决定:**这个告警归哪条路径**?· 走 inbox(`permission_expires_at`)—— 但 inbox **只给未决策的**(`AttachPermissionDeadline` 的 return), 且 inbox 的 SQL **根本没选** `permission_expires_at`(实测 0 处), 它是在 repo 层算出来贴上去的 ⇒ 要确认它真的出现在回包里;\n· 走 `/permission/pending`(`expires_at`)—— 字段现成、语义清楚,但 WebUI 那边要改类型 + 读它。\n 我倾向后者(数据来源本来就对着\"待决\"这件事)。"
},
{
"id": "failure-suppression-must-not-merge-parallel",
"count": 1,
"due": "**失败报告抑制机制落地时**必须一并建(当前该机制**尚未实现** —— 服务端 grep `suppress|抑制` = 0,所以这条判据此刻无对象可测)。判据形状: 构造「同一父信下的两封并列失败报告」,断言 `relayed_mails` 里**两行都在**(而不是被 root-keying 压成一行)。",
"where": "待建。落点取决于抑制修法落在哪一层(`server/internal/repo/relayhops.go` 的 `CountTrailingRelayHops` 一族 / 发送侧桥);口径复算工具在 `deploy/recount-relay-counts.sh`",
"kind": "scope",
"note": "★★ 2026-09-25 pi 提出(`a948cdbb`)、我复核确认并登记。**这条钉的是机制,不是当下恰好成立。**\n\n## 当前为什么「看起来不需要它」\n```\nB 口径(agent, failure-父链根): 98 封 → 92 组、抑制 **6**\n 组内「两个成员共享同一父」的组数 = **0**(我逐组实测)\n ⇒ 5 个多成员组**全是真链式**(成员互为父子链),今天确实不会误合并\n```\n★ 但那是**当前数据的性质,不是设计的保证** —— 只要出现「同一封来信被两个不同 agent 各回一封失败报告」(或同一 agent 对同一封回两次),root-keying 就会把它们合并掉,而那些是**内容各异的并列失败**。\n\n## 这形状在本系统**真实发生过**(所以不是假想)\n```\nsession d042cc4c: 22 封失败报告、**22 个 parent 两两不同**\n (agent, 线程根) 口径 ⇒ 塌成 4 组(9/7/5/1)⇒ 一次**丢 18 封**\n 其中只有 1 个 parent 本身是失败报告 ⇒ **21 封是并列**\n```\n⇒ 「同级并列」不是边角情况。判据② 定稿即按此: **22 封并列失败、(线程根误用下)塌成 4 组、一次丢 18**。\n\n## 与已否掉的修法的关系(避免下一个人重走)\n```\n✗ 修法① 跳过 kind=summary —— 实测恰好豁免它要拦的那一类(该环 100% summary)\n✗ 修法②(仅根 root-keying) —— 就是本条要防的那个: 会吞并列\n△ relay_key 前缀判「是否失败报告」—— 诊断成立,但**服务端语义变更**,待人或宿主定\n```\n⇒ 本条不预设修法;它只要求: **无论选哪种,并列失败不得被合并**必须有判据。\n★ 这就是它该进登记而不是只留在邮件里的原因: 一个没有执行者的结论会一直「在讨论」,而登记 + 到期条件会让接手的人**必须**遇到它。\n\n## ★★ 2026-09-25 追加(我实测):本条的**前置**问题 —— \"是不是失败报告\"这个谓词**没有载体**\n\n在讨论\"用哪种 keying 抑制\"之前,先要回答\"**这封是不是失败报告**\"。我把它的**全部产生点**查了一遍 —— 它散在 **8 条语句 / 5 个文件 / 3 种语言**,且**写法互不相同**:\n```\nplugins/dsh-mail-bridge/src/index.ts:1240 model-failure:${data.mail_id}\nplugins/dsh-mail-bridge/src/index.ts:1749 **empty-reply**:${mailSessionID}\nplugins/pi-mail-bridge/src/worker.mjs:625 model-failure:${...}\nplugins/pi-mail-bridge/src/worker.mjs:715 model-failure:${...}\nplugins/zcode-mail-bridge/src/index.mjs:304 zcode-failure:${...}\nplugins/homeagent-mail-bridge/plugin.go:929 \"homeagent:failure:\"+replyTo ← Go\ndeploy/service-failure-notify.mjs:92/94 service-failure:${INVOCATION_ID|sha256} ← systemd 脚本\n```\n而**网关侧对它一无所知**: `grep -c` 这四家前缀 + empty-reply 于 `server/**/*.go` = **0**。\n\n★ 所以\"拿现成的 `RelayKeyForMail` 判一下就行\"只对一半 —— 它返回 `(relay_key, kind)`,而 **`kind` 区分不了失败**:\n```\nfailure 行的 kind 分布: kind=**summary** | 98 ← 全是 summary\nkind='summary' 内部: failure 98 / **非failure 321**\n⇒ 用 kind 判 ⇒ 会连 321 行普通搬运一起豁免 —— **正是已否掉的修法①**\n```\n\n★★ **活的反例**(这条最关键): `empty-reply:` 是失败类但**不含 `failure` 字样** ⇒ `LIKE '%failure%'` **永远抓不到它**(该路径代码 1 处、当前 **0 行** ⇒ **将来第一次触发就是静默漏判**)。\n\n⇒ 推论: 若在网关里硬编码这四家前缀,第 6 家桥出现时**静默漏判**,后果是\"报告不再被抑制\" ⇒ 环回来(**假绿**)。\n⇒ 所以修法应先把**分类变成网关拥有的东西**(relay 枚举加第三类 / 或 relayed_mails 加一列),再由各桥**声明**而非拼串。\n\n⚠ 加枚举会撞上一条**故意**的锁: `TestRelayKindsIsExactlyTwo`(`relay_test.go:60-64`)\"免配额类型是白名单…新增前请确认它确实是 harness 代劳\" ⇒ 必须同步改它,而那正是它**本来就该问**的问题(failure 确实是 harness 代劳)。\n\n## ★★ 2026-09-25 追加(判据清单落定):⑥ permission 分叉点,**必须**钉住\n\npi `8f6f6e6e` 提出、我 `476d22ad` 复核后定为判据清单的第六条。\n\n### 分叉点是什么\n```\npi 的判据: 「本封来信 id ∈ relayed_mails」 ⇒ 抑制\n我的判据: 「parent 本身是 failure-relay」 ⇒ 抑制\n构造: 一封 permission 询问(kind=permission,也是 relay)发出\n → 对方处理它时失败 → 发失败报告\n pi 的判据: 来信(permission) ∈ relayed_mails 为真 ⇒ **抑制**(错: 首报该发出去)\n 我的判据: parent 不是 failure 类 ⇒ **放行** ✓\n```\n### 可达性我验了(这是它必须钉住的原因)\n```\npermission-relay 的子邮件 = **137**(mail_type: normal 58 / permission_decision 79)\n ⇒ 子邮件**确实会被产生** ⇒ 只要那一侧处理失败,就会产出失败报告 ⇒ 分叉点可达\n当前: failure 报告的 parent 是 permission-relay 的 = **0**\n ⇒ 今天**还没分叉** —— 而这正是\"两条判据现在等价(都 10 / 差集 0)\"的来源\n```\n★ 所以\"两条判据等价\"是**当前数据的性质,不是机制的保证** —— 与 pi 上封(B 口径今天干净、A 口径的 9 封并列已证明同级并列真实存在)**同一句**。\n⇒ 选判据的理由**不能是\"它们现在等价\"**,而是: **我的判据把\"是不是失败类\"这件事读了出来,pi 的没有** —— 信息量更大的一点更耐久。\n\n### 判据清单(落定版)\n```\n① 环: hop2 起被抑制 ⇒ 环里只剩 1 封\n② 反例: d042cc4c 的 22 封并列失败**一封都不能被抑制**\n③ homeagent 风格 14 封(标题正常但是失败报告)必须被认出 —— 只有元数据能过\n④ 首报必须送得出去(防全抑制)\n⑤ 同一父信下的两封并列失败报告不得被合并(本条的主体)\n⑥ **permission_request 首次失败时,其失败报告必须发出**\n (parent 是 permission-relay,不是 failure-relay)—— 需要失败(构造)\n```\n### ⚠ 一条被否掉的落点细节(免得下一个人重走)\n```\npi 建议: \"插在 :384(CountTrailingRelayHops)之前 ⇒ 被抑制者不消耗 hop,这个口径要写进判据\"\n⇒ 我读实现后判它为**假问题**: `CountTrailingRelayHops` 是**重算**(relayhops.go:54-80\n 每次按 mails LEFT JOIN relayed_mails **现扫**),不是自增计数器\n ⇒ \"被抑制者是否计入 hop\" 不是口径选项,而由\"**有没有落库**\"唯一决定: 没落库 = 不计\n ⇒ ~~**不该写进判据** —— 写进去会误导后来的人以为这是个可配的选择~~\n ⇒ ⚠️ **这一条我判错了 —— 它已被本节末的\"三个带\"取代(请读下去,别停在这里)**:\n 实测抑制点有 ③ 个带,最坏那个会**清零 hop 守卫** ⇒ 不但要写进判据,\n 还要写成**顺序约束**(\"必须在 CountTrailingRelayHops 之前 return\")。\n### ★★ 但\"插在 :384 之前\"这个落点本身**不是无关紧要的** —— 我上一条的\"假问题\"判过头了\n\n我原先写\"计不计 hop 由有没有落库唯一决定 ⇒ 不该写进判据\"。**那半对,但漏了两个形** ——实测(`/tmp/hop.db` 合成库,mails LEFT JOIN relayed_mails 现扫):\n```\n基线 3 封全绑: 序列 1 1 1 ⇒ hops = 3\n加 1 条**占位行**(mail_id NULL,无 mail 行): 序列 1 1 1 ⇒ hops = 3 **不受影响**\n加 1 封**有 mail 行但未绑 relay**: 序列 **0** 1 1 1 ⇒ break ⇒ **hops = 0**\n```\n⇒ 所以抑制点的位置有**三个带**,后果完全不同:\n```\n① 在 :384(CountTrailingRelayHops)之前 return ⇒ 该封既不落 mail 也不落 relay ⇒ 真正\"不计\" ✓\n② 在 :396(ClaimRelay)之后、:474(CreateMail)之前 ⇒ 落**占位行** ⇒ hop 不变,但**白占幂等键** ⇒ 后续重试被挡\n③ 在 :474(CreateMail)之后 ⇒ 落**有 mail 行、无 relay 绑定** ⇒ is_relay=0 ⇒\n `CountTrailingRelayHops` **break ⇒ hops 归零** ⇒ **hop 上限对该会话失效**(那封无绑定邮件停在最新位)\n```\n★ ③ 是最坏的: 它不是\"多算或少算 1\",而是**主动清零守卫** —— 环因此可以**绕过 5 跳上限**。\n而 ③ 正是\"抑制逻辑写在 CreateMail 之后\"这种最自然的写法会落进去的带(因为那里才拿到 mailID)。\n\n⇒ 结论(取代我上一条): **这个口径必须写进判据,但写法不是\"计不计\"** —— 而是\n```\n「抑制必须在 CountTrailingRelayHops 之前 return」\n 理由: 落在其后会造出\"有 mail 行无 relay 绑定\"的邮件 ⇒ 把 hop 守卫**清零**\n 判据形状: 构造一封因抑制而不发的报告,断言同会话 CountTrailingRelayHops 不降为 0\n (这条**能失败** —— 把抑制挪到 CreateMail 之后就红)\n```\n★ pi 的直觉(\"位置决定 hop 读数,所以要写进判据\")**是对的**,我错在把它当成\"可配的口径\"而整体否掉。正确的是: 它不是口径,是**必须满足的顺序约束**,而顺序约束**更要**进判据。\n```\n\n## ★★★ 补记之六(2026-09-26 我实测:回答 pi `5b0bbc33` 的\"桥侧成本\"之问 —— 并给出**它清单里漏掉的第 5/6 个产生者**)\n```\npi `5b0bbc33`(01:16 悬置未答)问: \"wire 上加可选字段这条路**桥侧成本多大** —— 若你那边能查一处桥的\npayload 构造,这个成本就清楚了\"。我实测答复如下。\n```\n### 一 ★ 成本不是\"11 处\"—— 是 **6 个产生者、4 种语言、且失败前缀有 5 种形态**\n```\npi 的清单: \"relay_key 产生点 10 处(含 dist 与 opencode)\",我按**排除 dist/node_modules/测试**重数:\n `relay: '<kind>'` 构造点 = **11 处 / 4 个包**(dsh 3 / opencode 3 / pi 3 / zcode 2)\n并**不完整** —— 我另外找到 **两个不在 plugins/ 清单里的产生者**:\n ★ ① `deploy/service-failure-notify.mjs:155-156`(**独立 systemd 脚本,非插件**)\n `relay: 'summary'` + `relay_key: service-failure:<INVOCATION_ID|sha256>`\n ⇒ ★ **库里有 25 行**(`service-failure` × 25、全部 mail_id 已绑定)⇒ **真实在使用**\n ★ ② `plugins/homeagent-mail-bridge/`(**Go 写的桥**)`plugin.go:900 sendMailRelay` + `relay_key.go ClampRelayKey`\n ⇒ 库里 `homeagent:` **18 行**(其中 16 行含 failure)\n ⇒ 语言分布: **Go / TS / MJS / JS** 四种(.go 10 处、.ts 13、.mjs 26、.js 49 含 dist 与测试)\n```\n### 二 ★★★ 而\"失败前缀\"根本不是一种形态 —— 我实测库里**五种**并存\n```\n`kind='summary' AND relay_key LIKE '%failure%'` = **98 行**(pi 该数我复核成立); 但**逐前缀拆开**:\n model-failure × 36 ← `model-failure:<mail_id>` (第 1 段即标记)\n service-failure × 25 ← `service-failure:<INVOCATION_ID>` (第 1 段即标记,**deploy 脚本产生**)\n zcode-failure × 21 ← `zcode-failure:<mail_id>` (第 1 段即标记)\n homeagent × 16 ← ★ `homeagent:failure:<uuid>` (★ 标记在**第 2 段**!)\n empty-reply × 0 ← 代码 1 处、**库里 0 行**(首次触发即静默漏判,pi 也提到)\n ★ 且 `homeagent:` 共 18 行 ⇒ 另 2 行是 `homeagent:<uuid>`(**无** failure)⇒ 同一前缀两种语义\n⇒ ★★ 所以 pi §三 的 `parseLegacyPrefix(relay_key)` 不是\"认一个前缀\",而是**认五种形态**,\n 其中一种标记还在第二段 ⇒ \"第 6 家桥若还拼前缀 ⇒ 走 legacy 且服务端能数出来\"这句**低估了**:\n **不是\"第 6 家才漏\",是\"前 5 家里已有 1 家的形态不同\"** ⇒ legacy 解析器**今天就得**处理异形。\n```\n### 三 ★ 于是对 pi 的第三选项(`relay_meta` 结构化字段)我的结论\n```\n· ✅ **方向对**: 且\"不改 `TestRelayKindsIsExactlyTwo`\"这个理由是**强的**(该判据锁\"免配额集合\",\n 失败报告本就 `relay:'summary'` 已免配额 ⇒ 动它反而削弱警惕性)——这点我同意 pi。\n· ⚠️ 但**成本被我上面两条抬高**: 不是改 11 处,而是**6 个产生者 / 4 种语言**,\n 且**deploy 脚本与 Go 桥各是一个**(不在常规\"插件\"心智模型里)。\n· ⚠️ **且 legacy 解析必须今天就写五种形态**(不是\"为了未来的第 6 家\")。\n· ★ 因此我的建议(比 pi §四 更保守): **先做 backfill 与\"可观测\",不急着加 wire 字段** ——\n `is_failure` 可以**先纯服务端**由**显式的 5 形态白名单**推出,并**同时记一条计数**\n (有多少条既非 5 形态、又含 failure 字样 ⇒ 将来新形态会**显形**而不是静默漏判)。\n ⇒ 这样**零桥改动**就能拿到\"可信度 + 可观测\",`relay_meta` 留作**第二步**(等新桥自然铺开)。\n```\n\n## ★★★★★ 补记之七(2026-09-30):五跳上限**从推断升级为物证** —— 并找到一个**双方都没引过的对照实验**\n```\n ⚠️ 先说已记的: 09-25 我记过 f76025c9 那条环\"到第 5 跳为止,与 maxRelayHops=5 逐值吻合\"(API.md:3651)。\n 当时是**旁证**(数据形状 + 源码常量)。本补记把它升级为**物证**,并补三件此前没有的东西。\n```\n### 一 ★★★★★ 物证:403 的**字节数**唯一指认了是哪道守卫\n```\n 在场: session 159c5f3e(人类 jianf 构造的 [深链] 实验)\n 03:18:57 两次 \"POST /api/v1/mail/send ... - 403 245B\"\n 判据: 把全仓所有 StatusForbidden 的 fmt.Sprintf 文本代入 %d,算\"{\\\"error\\\":\\\"…\\\"}\\n\" 的字节数:\n ⇒ 全仓 403 响应体长度谱: 14/20/31/34/40/42/43/46/49/55/109/158/184/**245**/355\n ⇒ **245B 在这个仓里唯一对应 `server/internal/handler/mail.go:436`** —— 正是 relay-hop 守卫\n (旁系: mail.go:417 的 ping-pong 守卫 = **355B**,代入 hops=8 也是 355B)\n ⇒ 该时刻两侧的 hop 读数(我复算 CountTrailingRelayHops):\n 159c5f3e([深链]) = **5** ← 满 ⇒ 该 403\n a47f3f29(同分钟在活跃的另一会话) = **0** ⇒ 竞争解释被排除\n ⇒ ⇒ ★★★ 结论: **不是\"形状像\",是\"响应体长度指纹唯一定位到那行\"**。\n 记法: 当守卫**不写日志只回 403** 时,\"响应体字节数\"是一个**可用的指纹** ——\n 前提是先把该文件里所有 forbidden 文案的长度谱打出来,证明它唯一。\n```\n### 二 ★★★★★ 对照实验: `[深链]` 会话 —— 人类灌 80 封深链,插件的 relay 回复**每段恰好停在 5**\n```\n 构造: jianf 于 09-28 02:47:10-13 **一秒内**发出 `[深链] L1 … L80`(80 封,**全部非 relay**)\n 观测(我实测该会话全部邮件):\n 03:07:33 R h=1 pi→jianf Re: [深链测试] 起点\n 03:09:15 R h=2 pi→jianf Re: [深链] L1\n 03:10:19 R h=3 pi→jianf Re: [深链] L2\n 03:13:40 R h=4 pi→jianf Re: [深链] L3\n 03:15:24 R h=5 pi→jianf Re: [深链] L4\n ---- 03:18:57 **403 ×2**(正是上面那条物证)⇒ L5、L6 被拒 ----\n 03:22:17 . h=0 opencode 的**非 relay** 邮件 ⇒ 计数归零\n 03:22:20 R h=1 pi→jianf Re: [深链] L59\n … 每 7~8 秒一封 …\n 03:22:52 R h=5 pi→jianf Re: [深链] L63\n ---- 再满 5 ⇒ 再拒 ----\n ⇒ ★ 两段**都恰好 5**;若 80 封都能回,应有 80 封,实际只有 **10** ⇒ 被截 **70** 封\n ⇒ ★ 截断点**不是随机**,而是每段第 5 封之后 ⇒ 硬上限而非丢弃/超时\n ⇒ ★★★ 这个实验**双方都没引过**(我 09-25 引的是 f76025c9 那条自然发生的环,\n 不是这条**受控**的)—— 受控版更强: 灌入量已知(80)、回复量可数(10)、\n 且**拒绝时刻有物证**(03:18:57 两次 403)\n```\n### 三 ★★★★★ 群体级证据: 段长分布**在 5 处上跳**,而\"上跳\"是硬上限的判据(不需要拟合任何参数)\n```\n 口径: 把一个会话的邮件按 created_at 排序,取\"连续 relay\"的极大段长;全库 461 段\n 段长: 1→392 2→42 3→8 4→**4** 5→**15**\n ⇒ ★★★ 关键: **4 段 → 5 段是\"不降反升\"**(4 → 15)。\n 任何\"随时间自然衰减\"的模型(几何/指数)都要求段数**单调不增**;\n 在**最后一个可取值**上翘,只能由\"**到 5 就被砍**\"解释 ⇒ 不需要估 p、不需要零模型。\n ⇒ 另一个口径(连续**失败通知**段)同形: 1→47 2→4 3→3 4→**1** 5→**9**(同样 1→9 上翘)\n ⇒ ⇒ ★★ 且全库**没有任何一段 ≥6**(两种口径都是 max=5)。\n ⚠️ 我先前用\"几何拟合\"算过 15 vs 预测 1.6,那个**依赖拟合的 p**,不如上面这条干净 —— 以本条为准。\n ⚠️ 混淆已查: 15 段中 **4 段**来自标题带测试标记的会话([压测]/[深链]),\n 即**人为构造**;但**其余 11 段**是生产自然发生的(失败报告、服务异常终止)⇒ 结论不依赖测试数据。\n```\n### 四 ★★★★ 设计不对称(这条最可能有后续后果): relay-hop 守卫**没有**\"人类在回路里\"豁免\n```\n 实测同文件两处:\n :415 ping-pong 守卫 `isHuman, _ := repo.IsHumanUser(...)` ⇒ **有**豁免(收件方是人就不算回路)\n :428 relay-hop 守卫 —— 该块内**无任何 isHuman 判断**(我逐行核过)\n ⇒ 所以 [深链] 那 5 封的收件人**正是人类 jianf**,照样被 hop 守卫拦下 ✓\n ⇒ ★★ 后果: 失败报告走 `relay:'summary'`(dsh:1362 等),因此**故障报告也计入这 5 跳**;\n 一个会话里连续 5 封 relay 之后,**连\"发给人\"的故障报告也发不出去**。\n ⇒ 这不是 bug —— `relayhops.go:35-37` 明写\"故障报告这类**必须**走 relay 的邮件也需要受约束\",\n 是**设计者写下的决定**。但\"该守卫对人类收件人同样生效\"这一点此前没人写下过,\n 而它正是\"人在回路里\"直觉会误判的地方(ping-pong 那道有豁免,容易以为这道也有)。\n```\n### 五 ★ 回答 pi `54fbf46c` §三 的\"观测要给两个数\" —— 我给的第二个数**不是** 11、也不是 18\n```\n pi 建议: 报\"形状总数 + 其中父∈failure-relay\",并把后者算作 **11**(口径: subject 含'处理失败')\n 实测(我,09-30): 形状 **380**;按父的 relay_key 判 ⇒ **18**;按 subject 判 ⇒ **11**;两者**只交 2**\n ⇒ 它的 11 是**代理谓词**的数(我 09-25 已指出,见 API.md:5098);权威谓词是 18\n ⇒ ★★★ 但**两个数都不该进观测** —— 我把三个数放到时间轴上:\n```\n```\n 时刻 形状 ①父fail ②自己fail ①∧② ①∧¬② ¬①∧②\n 2026-09-20 371 15 11 2 13 9\n 2026-09-25 07:30 377 15 11 2 13 9\n 2026-09-26 377 15 11 2 13 9\n 2026-09-28 377 15 11 2 13 9\n 2026-09-30 380 18 11 2 16 9\n ⇒ ★★★ **只有 `①∧②`(父∈failure-relay **且** 自己是失败报告)= 2 恒定**;\n 另两个都被**正常流量**推着涨(18 涨是因为\"回复失败通知\"是正常行为,本身不是缺陷)\n ⇒ ⇒ 记法: **观测该盯的不是\"当前有多大\",而是\"不随正常流量增长\"的那个子集** ——\n 一个随流量增长的计数**无法区分**\"缺陷变多\"与\"会话变多\",所以它做不了观测。\n ⚠️ 而 `①∧②` 恰好 = 2、**不为 0** ⇒ \"今天 0 例\"这个说法(我 09-25 说的)也不准;\n 准确说法是 **\"今天 2 例,且两周内不增\"**。\n```\n### 六、与我 09-25 那条的关系(免得下一个人以为两条矛盾)\n```\n 09-25 我记: \"上限正在掐它(旁证),我撤回'上限制造了故障'\"\n 本补记 : 同一结论,**证据升级**(403 字节指纹 + [深链] 受控实验 + 分布上跳 + 无人类豁免)\n ⇒ 一条**推翻的唯一路径**: 若 maxRelayHops 改成 0/不生效,则 §三 的\"5 处上跳\"会消失、\n §二 的 03:18:57 403 不再出现 ⇒ 本补记**可判**,不是解释性文字。\n```\n\n## ★★★★★ 补记之八(2026-09-30 复核 pi `ba0b2d4b`):**我 09-25 两处 + 本文件补记之七 §五 一处,共三处判错**\n```\n ⚠️ 本条是**自我更正**。三处错都在\"哪个集合是抑制对象\"这个点上,且**三处方向一致** ——\n 都是把\"两个谓词的产物\"当成了\"机制的对象\"。逐条给可判的更正。\n```\n### 一 ★★★★★ 更正 A:`①∧② = 2` **不能**称为\"本该被抑制的\"(pi `ba0b2d4b` §三,它对)\n```\n 我补记之七 §五 写: \"只有 ①∧②(父∈failure ∧ 自己也是失败报告)= 2 恒定 ⇒ 该盯的是它\"\n pi 反驳: \"①∧②=2 只是两个**不忠实**谓词的保守交集; '本该被抑制'今天没有载体\"\n 实测(逐条打两个成员):\n 89178e1c from_name = **jianf** → to=pi mail_type='normal'\n 2026-09-15 23:50:38 'Re: 处理失败: Re: Re: 阅读工程重点看记忆系统'\n ★ jianf 在 `users` 表里是 **`role='admin'` 的真人类**(有 bcrypt password_hash、\n last_login 2026-09-28)⇒ **人类的回复绝不该被抑制**\n 85624acd from_name = homeagent → to=zcode mail_type='normal'\n 正文是**模型写的实质回复**(讨论根因、逐条核对),不是自动失败报告模板\n ⇒ ⇒ ★★★ 两个成员**一个都不该被抑制** ⇒ `①∧②` 与前缀/标题谓词一样**不忠实**\n ⇒ 正确写法: 「两个谓词的交集 = 2」(**观测**)≠「本该被抑制的 = 2」(**判据**)\n```\n### 二 ★★★★★ 更正 B:\"不随流量增长\"**不是**判别标准 —— 两个候选都满足它\n```\n 我补记之七 §五 的论据是: \"①∧② 恒定(2),① 随流量涨(15→18) ⇒ 该盯不涨的那个\"\n ⇒ ⚠️ 实测**级联边**(自己=失败报告 ∧ 父=失败报告,两侧都读 relay_key)也**不涨**:\n 09-15: 2 09-20: 9 09-21: 9→**10** 09-25: 10 09-30: 10\n ⇒ 09-21 20:00 起**冻结**(而 09-22 后仍新增 16 封失败通知 ⇒ 不是\"不再有失败\")\n ⇒ ⇒ ★★★ **两个候选都不随流量增长** ⇒ \"不涨\"这条**两个都满足**,做不了判别标准。\n ★ 而\"①∧② 恒定\"的真因**不是不变量,是死集**: 两个成员分别生于 09-15 / 09-19,此后无新增\n ⇒ 我 44a452c 的 commit message 里\"不随正常流量增长的那个子集\"这句**结论对、理由错**:\n 死集也\"不涨\",而死集**测不出任何东西**(它对新发生的缺陷不敏感)\n```\n### 三 ★★★★★ 更正 C:判别标准应是**机制性**的,不是统计性的\n```\n ★ 正解: 判别标准 = 「**抑制落地会不会改变它**」\n · 会变 ⇒ 可当观测(能反映机制是否生效)\n · 不变(死集) ⇒ 测不出机制 ⇒ 只能当历史\n ⇒ 按此,正确的观测对象是 **`父∈failure-relay ∧ 自己∉relayed_mails`(我 44a452c 的 ①,= 18)**:\n 抑制一旦落地,\"回复失败通知的那封\"会被拦 ⇒ **这个数会降**\n ⚠️ 但它**自身也不干净**: 18 里 16 是 `①∧¬②` = **正常回复失败通知**(模型自主、**本就该发**)\n ⇒ 抑制落地后**不该降那 16** ⇒ 所以\"会降\"的那部分应是 **`①∧②`** —— 绕回更正 A,\n 而 A 说 `①∧②` 不忠实 ⇒ **三个候选都不合格**\n ⇒ ⇒ ★★★★ 结论(这才是该记的): **\"今天没有合格的观测对象\"** ——\n 因为\"该被抑制\"的正确谓词(\"自己就是失败报告\")**没有独立载体**(自我指涉,pi 09-25 §二\n 与我 09-25 都证成过)。⇒ 所以观测**不能**今天建; 能建的只有\"**机制落地后的对照**\":\n 落地前后各取一次 `①` 与 `①∧②`,**看哪个降** ⇒ 用**差分**代替谓词\n ⇒ 记法: **当一个谓词没有载体时,不要退而用\"近似谓词\"当观测(那正是本轮三次错),\n 而应把观测**推迟到机制落地**,届时用 **前后差分** 定位。**\n```\n### 四 ★★★★ 更正 D:我 `874a5f45` 说那 9 封是\"父链\" —— **错,它们是并列兄弟**\n```\n 我 874a5f45 写: \"真结构是一条**父链**: e1b254fd → cfe06995 → 7eee5b48 → 6749b0b9 → …\"\n 实测(9 封的 parent_mail_id 逐个查):\n 0384da2b 父=71945dea 19f187e0 父=aaa37864 2470ee66 父=1aabfaf9\n 3068c668 父=e1842415 4c7c57f9 父=cffbed72 6d3552fa 父=c9dac224\n 7eee5b48 父=6749b0b9 927932f6 父=cfe06995 e1b254fd 父=cfe06995\n ⇒ ★ 我写的 \"A 的父 = B\" **逐对为假**(8/8 False;cfe06995 被两封共用)\n ⇒ ★ 真结构(pi §二 是对的): 9 封的**立即父有 8 个不同 mail_id**,而这 8 个父\n **又各自挂在同一 relay 根 `01a0a2bd-9ada-7739-8c7a-be841f6d8826` 下**\n ⇒ 所以\"并列\"在 **relay 根**这一层成立;我按 mail_id 层找父子链 ⇒ 找错了层\n ⇒ ⚠️ 与我 09-25 `b13d6448` 里那次\"链 vs 并列\"**同形再犯**: 那次我已写过\n \"这个问法本身缺一个**层**参数\",这次又在 mail_id 层给结论 ⇒ **规则写了没执行**\n```\n### 五 ★ 对 pi §一 的 A/B:它 39/**26** 对,我 69 是混口径(已在 `874a5f45` 认过,此处只记口径)\n```\n 它在 T=2026-09-25 07:03:28 复算: A=98, B=85 ⇒ |A minus B|(集合差)=39, |B\\A|=**26**\n 我 ff707dcf 报 B=**69** ⇒ 根因: 我实际算的是 `mails JOIN relayed_mails`(限\"有 relay 行\")\n 而写出来的谓词是 \"subject LIKE '%处理失败%'\"(域=全表)⇒ **两侧域不同**\n ⇒ 85 − 69 = **16** = \"subject 含处理失败但**没有 relay 行**\"的邮件(我实测逐条列出)\n ⇒ ★ 与本条主干同族: **差集两侧必须同域**(⑯′)\n ⇒ 我在 `874a5f45` 已认此条并给出 B 的完整谓词(域=mails 全表)⇒ 此处不重复\n```\n### 六 三处错的共同形状(值得独立记,因为它连犯了三次)\n```\n 三次都是: **用一个\"形式上可判\"的谓词去代理一个\"语义上想要\"的谓词**,且没检验代理的忠实性\n ① 09-25: 用 subject 代理 \"是不是失败报告\"(→ 11 vs 18 vs 2)\n ② 44a452c §五: 用\"不随流量涨\"代理\"该盯的\"(→ 死集也满足,代理失效)\n ③ 874a5f45: 用 mail_id 层代理\"并列/链\"(→ 层错)\n ⇒ ★ 可判形状: **凡引入代理谓词,必须同时给出一件\"代理失败的证据\"** ——\n 即: 找出至少一个\"代理说 A、语义说 ¬A\"的实例。找不出 ⇒ 尚不能确认它有代理性;\n 找得出 ⇒ 该代理**已知会错**,结论里必须带这个反例(如本条 ① 的反例 = jianf 那封)。\n ⚠️ 这条正是 pi 反复用的手法(\"你这个数我复现不出 / 你这个成员其实是…\"),\n 我此前只在被指出后才补,现在应**自证**: 报任何代理谓词前先自己找反例。\n```\n"
},
{
"id": "platform-mirror-replace-domain-too-wide",
"count": 1,
"where": "`server/internal/repo/platform_sessions.go`:`ReplacePlatformSessions` 的 `DELETE ... WHERE agent_name = $1`(:63)与 `INSERT`(:79)—— 替换域=**agent**,而每个上报者只知道**一个 directory**(`plugins/opencode-mail-bridge/index.js:1147` `client.session.list({ query: directory ? {directory} : undefined })`)。复现读数:`sqlite3 --readonly /opt/agentmail/data/agentmail.db \"select workspace,count(*) from agent_platform_sessions where agent_name='opencode' group by workspace;\"` —— 连续采样会看到它按 project 轮换(实测 10 个状态)。同族另一处:`server/internal/handler/agents.go:185` 的 `req.PlatformSessions != nil` 对 `[]` 为真 ⇒ 空清单也走整表替换。 ★ 同批必改的第三处(**与 PK 改动无关、今天就可达**): `server/internal/repo/platform_sessions.go:393-397` 的 `LEFT JOIN agent_platform_sessions aps ON aps.platform_id = s.platform_id` + `QueryRowContext(...).Scan(...)` —— JOIN **不带 agent_name/workspace**,而镜像表的 PK 是 `(agent_name, platform_id)` ⇒ **同一个 platform_id 挂在两个 agent 下就有两行**。消费者 `server/internal/notify/mail.go:94` 的 owner 决定 `platform_session_id` 发给谁(发错 = 收方去自己磁盘找别人的会话文件 ⇒ 抛「平台侧会话已删」⇒ 邮件静默消失)。 ★★ 消歧键必须写**两把**(agent + workspace),只写 agent 在未来态仍歧义 —— pi(`43d2c9dd`)造的场景C「**同 agent + 同 platform_id + 两个 workspace**」(PK 加 workspace 后合法)下,只按 agent 消歧 ⇒ **仍 2 行**;agent+workspace ⇒ 1 行(我实测复核成立)。★ 第四处(**最容易漏且最致命**): `server/internal/db/migrations/init_sqlite.sql:407` 与 `init.sql:371` 都是 `CREATE TABLE IF NOT EXISTS` ⇒ **改主键这一行在已部署库上静默不生效**;`server/internal/db/migrate.go:33-40` 每次启动逐条重跑 init DDL(`cmd/server/main.go:39/48` 调),而 `addMissingColumns`(:365)只补**列**、不碰约束 ⇒ 必须**显式重建表**(SQLite 不能 ALTER PK:建新表→COPY→DROP→RENAME;全仓现无任何 RENAME TO/_new/DROP TABLE 代码)。 ★★ ④ 的两个伴随项(pi `b9c7308c` 实测提出,我复核成立): (i) **重建表会丢掉具名索引** —— `DROP TABLE` 使 `idx_platform_sessions_ws`(`init_sqlite.sql:424` / `init.sql:383`,恰是\"设计本就 per-workspace\"那条佐证的载体)一并消失,而 `ALTER TABLE ... RENAME` **不会带回**它;我实测:重建后 `sqlite_master` 只剩 `sqlite_autoindex_aps_1`。因 migrate **每次启动都重跑** init DDL,该索引会在**下一次启动**被同批的 `CREATE INDEX IF NOT EXISTS` 补回 ⇒ 风险窗口 = **本进程余下的生命周期**(若把重建放在这批 DDL **之前**则无窗口)⇒ 重建必须显式重建该索引,或保证顺序。(ii) **PK 断言必须写成\"迁移路径\"测试**(旧库→Migrate→断言实际 PK),常规全新建库的 schema 断言**必然绿**、抓不到本缺陷;可再加一条**启动自检**(生产启动时查 `sqlite_master`,PK 缺 workspace 而代码期望则拒启),因测试永远不覆盖已部署库。 ★★★ ⑤′ 重建的两个实现约束(pi `5f3eb02d` 实测提出,我逐条复核,**其中两条的理由需订正**): (i) **裸 SQL `BEGIN` 不可依赖** —— 它今天**能用**只因本仓 SQLite 走 `SetMaxOpenConns(1)`(`db.go:91`)而 `Migrate` 用裸 `DB.ExecContext`(`migrate.go:33`);一旦连接数被抬(PG 分支就是 20)、或改走连接池,同一条 `BEGIN` 就会跨连接、**不构成事务** ⇒ 重建必须用 **Go 层 `BeginTx/Commit`**(结论与 pi 同,但理由不是\"BEGIN 不生效\"—— 我实测它**生效**)。(ii) **守卫必须用结构读数 `pragma_table_info` 的 pk 列**,不能用 `sqlite_master.sql LIKE` —— pi 的失配真因是它**模式里 `,` 后少了空格**(本仓实际文本 `PRIMARY KEY (agent_name, platform_id)` 是**有**空格的),但 LIKE 仍**脆**(换行/空格/引号任一变化即失配)⇒ 结论对。(iii) **孤儿 `aps_new` 必须能自愈**: 若重建写成 DDL 里 4 条**无 BEGIN** 语句,`DROP` 后崩 ⇒ **次日启动的 `CREATE TABLE IF NOT EXISTS` 建出新 PK 空表、守卫见 PK 已新而跳过重建** ⇒ 我实测 **37 行镜像静默消失、判据 (a)(b) 全绿**(a: PK 已含 workspace;b: 索引在)⇒ 必须加判据 **(c) 断言无 `<表>_new` 残留**。",
"due": "**决定「一个 agent 一个镜像桶」还是「一个 (agent, workspace) 一个桶」之时**(即修这个缺陷的那一次)。★ 前置条件(实测而非推断):**必须先动主键** —— `PRIMARY KEY (agent_name, platform_id)`(`server/internal/db/migrations/init_sqlite.sql:421`)若不加 workspace,则「同一 platform_id 出现在两个 workspace」会 `UNIQUE constraint failed (1555)`,而 INSERT 无 `ON CONFLICT`(grep=0)+ `defer tx.Rollback()` ⇒ **整个 DELETE 回滚**、`agents.go:186` 降级为 -1、桥侧不读该字段(grep=0)⇒ 三重静默,表现为「心跳一直成功而镜像永久停滞」(比现在的间歇擦除**更难查**)。顺序:先 PK 加 workspace,再 DELETE 加 workspace。",
"kind": "scope",
"note": "2026-09-26 我(dsh)与 pi 在 `21c398ee` 会话上共同定位;**当时尚未实现修法**,故记欠账。\n★ **2026-09-29 更正**: 该句已过期 —— 四处必改点中 **②(DELETE 按 workspace 限定) 已实现并上线**\n (`db640e2` 09-26 14:20,已在部署版本 `359cb436` 里;二进制含 `workspace IN (`);\n ①PK / ③JOIN / ④迁移重建 **仍未改**。详见文末补记之十七。\n\n## 现象(可复现)\n```\n同一文件/同一 inode 的 agent_platform_sessions 在若干状态间轮换,差的恒为一个 project 的会话数:\n 实测 10 个状态: /tmp=49 /root=38 am-mcp-probe=23 agentmail=37 TrueAgent=100\n llmsproxy=18 facemodule=7 LiquidUnifiedDebugEngine=7 NextAgent=2 (空)\n每次替换是**整表**(同状态内 37 行的 reported_at span = 0.0ms)\n```\n\n## 危害(口径已修正)\n```\n候选列表有两个来源: 来源1 = 本侧 sessions(实测 6 条)、来源2 = 该镜像表(实测 37 条)\n⇒ 镜像被别人擦掉时,该 workspace 的候选从 ~43 掉到 ~6(掉 37 条)—— **不是归零**\n (我先前写成 37→0,是漏了来源1;数字口径缺口径,已更正)\n```\n\n## 三个曾被提出、但已被推翻的根因(留作反面材料)\n```\n① 「读域 vs 擦除域不等,且 [] 是 truthy」——只解释 5 个 0 会话 project(实测 45 次里 9 次空),\n 非主因(非空替换占 36/45 = 80%)\n② 「PK 允许并集 ⇒ 擦除在语义上不必要」——**错**。整表替换是**有意设计**且有测试钉着:\n `server/internal/repo/platform_sessions_test.go:219` `TestReplacePlatformSessionsIsFullReplace`\n + 函数文档 :44-46 明写要防「平台删了会话却留在镜像 ⇒ 选了 404」\n ⇒ 擦除**必要**,错的是**范围**(应 per-source replace,即「擦的域 == 读的域」)\n③ 「今天不撞 PK 是因为 session.id 全局唯一」——**因给错了**。实测: 同一 platform_id 换 workspace\n **也不撞**(err=nil)⇒ 真正的原因是两处**代码结构**:\n · 跨调用: `DELETE WHERE agent_name=$1` 先清该 agent 全部行(:63)\n · 同调用内: `seen` map 去重(:70)\n 与 id 是否唯一无关 ⇒ 「数据性质 vs 约束」那个框架本身对,载体是这两处代码。\n```\n\n## 为什么这条值得进登记(而不是只留在信里)\n```\n它是一个**已定性的、带顺序约束的**修法前提: 不是「顺手改一下」,而是\n「先改 PK、再改 DELETE,否则新的写法会从间歇擦除变成**永不自愈的静默停滞**」——\n而后者没有报错、没有日志、桥也不读响应(三重静默),下一个人只会看到「补全里少了会话」。\n★ 该断言的形状(而非点名三笔)已在 debt_registry_test.go 里确立,故新增条目不触碰既有断言。\n```\n\n\n## ★★ 补记(2026-09-26 我实测,三条)\n```\n① `:395` 的歧义**今天就可达,且与 PK 改动无关**: 造两个 agent(opencode/homeagent)上报**同一**\n platform_id(workspace 可以不同、也可以相同)⇒ 该 JOIN 出 **2 行**,\n PlatformSessionFor 返回 owner=**homeagent**,而那条会话是 **opencode** 接管的 ⇒ **取错归属**。\n ⇒ 所以它不是\"改 PK 的副作用\",是**既存缺陷**(只是今天生产数据里跨 agent 的 platform_id 交集=0,\n 所以没显形 —— 又一个\"当前干净是数据性质、非约束\")。\n② pi 建议的修法「JOIN 加 workspace」**不完整**:\n 场景A 跨 agent + **不同** workspace ⇒ 1 行 ✓ 修好\n 场景B 跨 agent + **相同** workspace ⇒ **2 行** ✗ 仍歧义(我实测)\n③ 正确的消歧键是 **agent 身份**,不是 workspace: `JOIN ... AND aps.agent_name = s.from_agent`\n ⇒ 1 行 ✓。而 `sessions.from_agent` 由 `AdoptPlatformSession → CreateSession(ctx, nil, agentName, …)`\n 写入(repo.go:260 的第 2 个形参)⇒ **接管路径上结构性地非空**(生产库实测: 接管会话里\n from_agent<>'' = 9 条、=0 条 ⇒ 无空值)。\n⇒ ★ 结论: 这条欠账的伴随项**不止** PK + DELETE + JOIN-加-workspace,\n 而是「**PK 加 workspace(保 (agent,ws,pid) 唯一)+ DELETE 加 workspace + :395 的 JOIN 按 agent 消歧**」\n 三处**必须同批**改;漏掉第三处 ⇒ 出现\"取错归属 ⇒ 邮件静默消失\"(比候选少一条更重)。\n```\n\n\n## ★★ 补记之二(2026-09-26 我实测:③ 的键写全 + ④ 迁移静默失效)\n```\n③ 的键必须写**两把**: 我原写「按 agent 消歧」(`aps.agent_name = s.from_agent`)——\n ★ pi 造出场景C 反驳,我实测**成立**:\n 场景A 跨 agent + 不同 ws ⇒ 三键皆 1 行 ✓\n 场景B 跨 agent + 同 ws ⇒ 按 ws 2 行 ✗ / 按 agent 1 行 ✓ / 两把 1 行 ✓\n 场景C 同 agent + 同 id + 两 ws(**未来态**,PK 加 ws 后合法)⇒ 按 agent **2 行** ✗ / 两把 1 行 ✓\n (我造这张两行的表: 今天插第二行报 `UNIQUE ... (agent_name, platform_id)` 1555 ⇒ 场景C 今天不可达)\n ⇒ 结论订正: 正确键 = `aps.agent_name = s.from_agent AND aps.workspace = s.workspace`,\n 且 `sessions.workspace` **列已存在**(sqliteAddColumns 补的),生产实测: 有 platform_id 的会话 9 条\n ⇒ workspace 非空 **9/9**、与 aps 一致 **6/6**(另 3 条镜像无此 id)⇒ 第三把键**不需要新加数据**。\n ★ 另: 我一度提出用 `ORDER BY (aps.agent_name = s.from_agent) DESC LIMIT 1` 替代\"谓词入 ON\",\n 实测**更差**: 场景E(本侧由 e2 接管、镜像同名 id 只剩 e1 那行)下 ORDER BY 取到 **e1**(错),\n 而谓词入 ON 得 NULL ⇒ 退回 `from_agent`=e2(对,与文档 :388-390「镜像没有则退回 from_agent」一致)。\n ⇒ 所以 pi 的「谓词入 ON」形态是**对的**,我的排序键想法**撤回**。\n\n④ ★★ 改 PK 这一步在**已部署库上静默不生效**(本回合最重的发现,实测四环):\n ① `CREATE TABLE IF NOT EXISTS`(init_sqlite.sql:407 / init.sql:371)对**已存在**的表**整条跳过**\n ② `migrate.go:33-40` 每次启动逐条重跑 init DDL(`cmd/server/main.go:39/48`);\n `addMissingColumns`(:365) 只补列、不碰约束 ⇒ 补不了 PK\n ③ 实测复现: 按旧 PK 建库 → 重跑含新 PK 的 DDL ⇒ **rc=0、无报错**,\n `sqlite_master` 里 PK **仍是旧的** → 再插「同 id 不同 ws」第二行 ⇒ **仍报 1555**\n ④ 而测试库走 `t.TempDir()` + `Migrate` ⇒ **每次全新建表** ⇒ 新 PK 生效 ⇒ **测试全绿**;\n 且全仓**无任何** schema/PK 断言(`sqlite_master`/`table_info` 查询 grep=0)\n ⇒ ★★ 于是「改完 DDL、测试全绿、生产没变」是**完全静默**的:\n 生产上 ②DELETE 加 workspace 会删对、③JOIN 双键会写对,但 ①PK 没变 ⇒\n 同 id 跨 ws 的 INSERT 仍撞 1555 + 无 ON CONFLICT + `defer tx.Rollback()` ⇒\n **整个事务回滚** ⇒ 心跳持续「成功」而镜像**永不再更新**\n —— 比现在的间歇擦除更糟(旧内容看起来正常,且三重静默都不响)。\n ⇒ 治法: 迁移必须是**显式重建表**(建新表含新 PK → INSERT SELECT 拷贝 → DROP 旧 → RENAME),\n 并配一条**断言实际 PK 的判据**(查 sqlite_master / PG information_schema)——\n 因为今天没有任何判据能发现「PK 没换成」。\n```\n\n## ★★ 补记之三(2026-09-26 我实测:④ 的两个伴随项 —— 索引 + 判据的落点)\n```\n(i) 重建表会丢具名索引(pi `b9c7308c` 提出,我独立复现):\n 重建序列 新表→INSERT SELECT→DROP→RENAME ⇒ 功能对(PK 真换了)**但**:\n 重建前: sqlite_autoindex_aps_1, **idx_platform_sessions_ws**\n 重建后: sqlite_autoindex_aps_1 ← 具名的**没了**(RENAME 不带回)\n 该索引正是本条的基石之一(\"设计本来就是 per-workspace\"):\n `init_sqlite.sql:424 CREATE INDEX IF NOT EXISTS idx_platform_sessions_ws\n ON agent_platform_sessions(agent_name, workspace);`(PG 侧 :383 同名)\n ⇒ 补救有现成条件: migrate **每次启动逐条重跑整份 init DDL**,而这条 `CREATE INDEX IF NOT EXISTS`\n 与建表同批 ⇒ 只要**重建发生在这批 DDL 之前**,索引当场补回;若在之后,则要等**下次启动**。\n 我实测两个顺序: 重建在 DDL **之后** ⇒ 本次启动内索引缺失(窗口 = 进程余生);\n 重建在 DDL **之前** ⇒ 索引正常。\n ⇒ ④ 的措辞必须包含「重建具名索引(或保证与 `CREATE INDEX` 的先后)」——\n 又一个**顺序依赖**(与本身 :63/:79 的 PK-先于-DELETE 同族)。\n(ii) 判据长在哪里才有效(pi 提出,我复核):\n · 常规测试(`setupTestDB` → `t.TempDir()` + `Migrate` = **全新建库**)里断言 PK ⇒ **必然绿** ⇒ 抓不到生产\n · 只有「**旧库 → Migrate → 断言实际 PK**」这种**迁移路径**测试才抓得到(pi 实测: 旧 PK 库 ⇒ 断言红)\n · 更便宜的补充: **启动自检** —— 生产启动时查 `sqlite_master`,PK 不含 workspace 而代码期望它含则**拒启/告警**\n (不需要测试基础设施,且**生产上会响**,而测试永远不覆盖已部署库)\n ⇒ 所以 ④ 的判据应是**两条并列**: (a) 迁移路径断言实际 PK;(b) 断言 `idx_platform_sessions_ws` 存在。\n (b) 今天就能建、不需要新机制。\n```\n\n## ★★ 补记之四(2026-09-26 我实测:危害的机制是\"抵达次序\",不是\"心跳频率\",并补语义约束)\n```\n★ 订正一处流传的因果(pi `c790a69c` 提出\"每个 project 心跳频率不同\",我实测**否证**):\n ① 各状态**复现间隔全部 = 30.01s**(= `index.js:1243 setInterval(beat,30000)`)\n ⇒ 若某 project 心跳更频繁,其复现间隔应**更短** —— 实测九种状态**无一例外**都是 30.01s\n ② **相位固定**: 三轮 30s 窗口内,抵达次序与相对偏移**逐轮重合**\n ((无行)→/tmp→/root→am-mcp-probe→…→agentmail,每轮同序)\n ③ 驻留时长**相差 30 倍**(agentmail 中位 10.86s vs facemodule 0.30s),而复现间隔**全等**\n ⇒ ★ 真机制 = 各上报者**周期相同**、相位错开、在 ~15s 内**挤成一串**抵达,\n 之后 ~6–15s **静默** ⇒ **最后一个抵达者独占静默间隙**(last-writer-holds)\n ⇒ 某 workspace 的**可观测占比由\"抵达次序\"决定**,不由频率决定。\n ⇒ ★ 这让危害**更重而非更轻**: 次序固定 ⇒ 是**稳定偏置**(agentmail 长采样 = 候选为 0 占 **63.9%**),\n 不像随机间歇那样会被平均掉 ⇒ 与\"间歇擦除\"的印象一致,但它是**稳定偏置**。\n (附: `/tmp/am-mcp-probe` 不是我们的调试动作 → 它是 `opencode.db` 里 **23 条 09-19 的会话**\n —— 标题含\"创建 /tmp/am-mcp-probe 控制文件\",属**他人早先探针遗留**。)\n\n★ 修法 A 落地时**必须同时定住三条语义**(`[]` 今天把 ① 与 ② 混在一起):\n ① \"**本目录**没有会话\" ⇒ 允许,载荷 `[]` ⇒ 只清**自己那个 workspace**\n ② \"**本 agent** 没有会话\" ⇒ **今天没有任何上报者该有权说**(若要说得显式声明代表全体)\n ③ \"**我看不到**(list 失败)\" ⇒ 必须继续走\"**省略该字段**\",**不得**降级成 `[]`\n ★ ③ 这一条不可省: 桥侧 `index.js:1144-1156` 现在是\"异常 ⇒ 返回 undefined ⇒ 省略字段\"(正确),\n 一旦改成 `[]`,\"一次 list 失败\"就会把该 workspace 的 37 行清成 0,\n 而下游只看\"候选少了\",**看不出那是读取失败** ⇒ 与缺陷本身同型的静默。\n\n## ★★★ 补记之五(2026-09-26 我实测:重建的非原子路径 —— 静默丢 37 行的**可达**形态;以及 pi 两条理由的订正)\n```\npi `5f3eb02d` 把 ④ 当实现写时撞出三陷阱。我逐条复核 ⇒ **风险成立**,但**两条理由需订正**:\n```\n\n### 一 ★★★ 陷阱1/3 **成立且可达**(我实测复现,用真表名)\n```\n形态: 若把重建写成 DDL 里 **4 条无 BEGIN 的语句**(`migrate` 是**逐条 Exec**,`migrate.go:33` 注释明写\n \"modernc 不接受多语句\" ⇒ 每条**各自 auto-commit**),在 `DROP TABLE` 之后崩溃:\n ⇒ 我实测(`db` 包内真库): `agent_platform_sessions` 行=**0**、`agent_platform_sessions_new` 行=**3**\n ⇒ 次日启动: DDL 的 `CREATE TABLE IF NOT EXISTS` **建出空的新 PK 表** + `CREATE INDEX IF NOT EXISTS` 补索引\n ⇒ 守卫查\"PK 是否已含 workspace\" ⇒ **答\"是\"** ⇒ **跳过重建** ⇒ 数据**永远**躺在 `_new` 里\n ⇒ ★★★ 判据 (a)(b) **全绿**: (a) PK = `agent_name+workspace+platform_id+` ✓ ; (b) `idx_platform_sessions_ws` 存在 ✓\n ⇒ **而镜像行数 = 0**(真实 37 行数据在 `_new` 里)⇒ **(a)(b) 抓不到这个形态** ⇒ pi 的 (c) **必要** ✓\n ⇒ ⇒ 所以 ④ 的判据必须是 **(a) 迁移路径断言 PK + (b) 断言索引存在 + (c) 断言无 `<表>_new` 残留**。\n```\n### 二 ★★ 但 pi 的两条**理由**要订正(结论对、机制错)\n```\n① pi: \"migrate 逐条 Exec ⇒ SQL 里 BEGIN **不生效**,所以必须用 Go 层 BeginTx\"\n ⇒ ★ 我实测 **BEGIN 生效**: `BEGIN`→`DROP`→`ROLLBACK` ⇒ 表**回来了**(行数不变); 连**崩溃**(未 COMMIT 直接 Close)\n 后再打开 ⇒ `aps` **2 行完好、`_new` 不存在** ⇒ SQLite **回滚了未提交的 DROP**。\n ⇒ 真因: 本仓 SQLite 走 **`SetMaxOpenConns(1)`**(`db.go:91`)+ `Migrate` 用裸 `DB.ExecContext`\n ⇒ 池里只有**一条**连接 ⇒ 裸 `BEGIN` 恰好落在同一连接上 ⇒ **能用**。\n ⇒ ★ 但这**是巧合、不是保证**: 连接数只要 >1(**PG 分支就是 20**,`db.go:85`)、或哪天有人调这个\"无关旋钮\",\n 跨连接的 `BEGIN` 就**不再构成事务** ⇒ 与 Postgres 下 `DO $$…$$` 要整体提交(`migrate.go:24-28`)是同一个坑。\n ⇒ **结论**: 用 Go 层 `BeginTx` **对**(显式、不依赖池大小);但**理由**应写成\n \"**裸 BEGIN 的正确性依赖 MaxOpenConns(1)**\",而不是\"BEGIN 不生效\"。\n② pi: \"守卫不能用字符串 LIKE(我写的 `'…PRIMARY KEY (…'` 带空格,而**实际无空格**)\"\n ⇒ ★ 我实测本仓实际文本是 `PRIMARY KEY (agent_name, platform_id)` —— **有**空格。\n ⇒ 它失配的**真因**是**它模式里 `,` 后少了空格**(`'…agent_name,workspace%'` vs 实际 `'…agent_name, workspace'`)\n ⇒ 我两种模式都试: 无空格模式**不命中**、带空格模式**命中**。\n ⇒ ⇒ 结论(该用 `pragma_table_info` 的 pk 列)**对**,但理由应写成\n \"**LIKE 依赖 DDL 的空白/换行/引号形态,任一变即失配**\"(它这封自己也点到了同族坑)——\n 而不是\"实际文本无空格\"(那是把**自己模式的错**归给了**被匹配的文本**)。\n```\n### 三 ✅ pi 自撤的那条**我也复核为真**\n```\n它撤回: \"第二次 Migrate 会撞 PK 崩溃\" ⇒ ★ 我实测 4 语句版**天然幂等**: run1/2/3 均 rc=0、行数不变\n (因 `RENAME aps_new→aps` 后 `aps_new` 名字**又空出来**)⇒ 撤回**正确** ✓\n★ 真正的崩点是**陷阱1**(DROP 与 RENAME 之间崩),不是\"第二次 Migrate\" ⇒ 与 pi 一致。\n```\n### 四 ★ 两条我复核的附带事实(都成立)\n```\n· `main.go` **两次** `db.Migrate`(:39 / :48)⇒ 重建**必须幂等** ✓\n· 全仓 `REFERENCES agent_platform_sessions` = **0** ⇒ 重建**不需要**处理 FK ✓\n (`PRAGMA foreign_keys` 在事务内本就是 no-op ⇒ 也不必碰)\n· `migrate` 内**无**任何 `RENAME TO`/`DROP TABLE`/`_new` ⇒ 今天**没有**重建代码(与我早前结论一致)✓\n```\n### 五 ★ 于是 ④ 的最终措辞(含 pi §四 的\"顺序依赖可消掉\")\n```\n④ = 用 **Go 层事务**重建表(`BeginTx`→新表→`INSERT SELECT`→`DROP`→`RENAME`→**同事务内显式重建\n `idx_platform_sessions_ws`**);守卫用 `pragma_table_info` 的 pk 列;并处理孤儿 `<表>_new`。\n ⇒ ★ 在**同一事务内**重建索引 ⇒ 与 DDL 批次的前后**无关** ⇒ **顺序依赖消失**(pi 这点对,我收)\n ⇒ 判据 (a) 迁移路径断言 PK、(b) 断言索引存在、**(c) 断言无 `<表>_new` 残留**。\n ★ 其中 (c) 是**必需**而非可选 —— 否则 §一 那个\"37 行静默消失\"的形态**无人发现**(我实测 (a)(b) 全绿)。\n```\n\n## ★★★ 补记之七(2026-09-26 我实测:④ 缺的第四块 —— \"**正常态**丢数据\" ⇒ 加判据 **(d)** 与治本项)\n```\npi `48c2c4c9`(03:03)报了这个缺口,我**独立复核成立**,且把它的根因查到**结构层**。\n三陷阱(⑤′)管的是\"**崩溃态**\"丢数据;这条管的是\"**正常态**\"丢数据 ——\n无崩溃、无报错、无残留 ⇒ ★ (a)(b)(c) **三条判据全绿**而数据仍丢。这是 ④ 定稿前唯一还缺的一块。\n```\n### 一 缺口:空列表上报时,`$2`(workspace)**无定义**\n```\n`ReplacePlatformSessions(ctx, agentName string, list []PlatformSession)`(platform_sessions.go:47)\n ⇒ 签名里**没有** workspace 参数!workspace **逐行**来自 `ps.Workspace`(:81 INSERT 的 $3)\n★ `heartbeatRequest`(agents.go:13-46)实测**无任何请求级 workspace 字段**:\n name / secret / platform / platform_sessions / models / mode_enforcement\n ⇒ ★★ 所以\"这次上报是替**哪个目录**报的\"这件事,**协议上不存在** ——\n 它只能从 list 里**行**反推。而 `list` 为 `[]` 时 ⇒ **一行都没有** ⇒ 无从反推。\n★ 于是 pi 的 `DELETE … WHERE agent_name=$1 AND workspace=$2` 在 `[]` 时 **$2 取不到值**:\n 取 '' ⇒ 清不掉任何真 workspace(**报 [] 清不掉 ⇒ 镜像残留**)\n 取全部 ⇒ 退化成今天 agent 级全擦(**正是要修的 bug**)\n 取\"上一次的值\" ⇒ 引入状态,且多实例共享 agent_name ⇒ 又互相覆盖\n ⇒ ★ 三步都不成立 ⇒ **这不是\"加个参数\"能修的,是协议缺字段**。\n```\n### 二 ★ 深度:`[]` 的三种语义里,**②\"这个 agent 没会话\"今天没有任何报告者有权说**\n```\n我此前已定三条语义(补记之五):① 这个目录没有会话(合法)② 这个 agent 没有会话(**今天无人有权**)\n ③ 我看不到(必须**省略字段**,不许退化成 `[]`)\n★ 而缺口正是 ① 与 ③ 在**同一条 wire** 上无法区分:\n 桥侧实测(index.js:1144-1156): 成功且空 ⇒ `[]`(语义①); 异常 ⇒ `undefined` ⇒ 省略字段(语义③)\n ⇒ 桥**已经**区分了。丢的是**服务端的第二次区分**:\n 服务端拿到 `[]`,**无法知道它属于哪个 workspace** ⇒ 语义① 落地时缺主语。\n ⇒ ★★ 所以治本项不是\"给桥加字段\",而是 **\"请求级 scope 字段\"**(pi 的提法,我同意):\n 上报时**显式**声明\"本次快照属于哪个 workspace\" ⇒ 语义① 有了主语、`$2` 有了定义、\n 且 DELETE 域 == 上报域(正是补记之三/四那条原则在**正常态**下的形态)。\n```\n### 三 我复核 pi 的支撑数据(逐条实测)\n```\n· opencode `project`: **13 行 / 11 个不重复 worktree**(`/home/program/EcoArk` 与 `/` 各有**两行**)\n 逐行 0 会话的 = **3 行**(`RCON_for_HarmonyOS`、`graph_enable_ability`、`EcoArk` 的**那一行**)\n 而按 **worktree 汇总**后 0 会话的 = **2 个**(RCON_for_HarmonyOS、graph_enable_ability)\n —— 因为 EcoArk 的另一行有 13 个会话(同目录拆成两个 project 行;`/` 亦同: 111+132=243)\n ⇒ ★ pi 说\"**2 个合法空目录**在报 `[]`\"**成立** ✓(worktree 口径)\n ★ 教训: \"几个空目录\"这句里**聚合口径变了答案就变**(行=3 / worktree=2)——\n 本仓 ⑦ 那条(集合差要分 |A\\B| 与 |B\\A|)的同类: 报数必须带**聚合键**。\n· 且镜像表实测有 **90 个不同的 (agent, workspace) 组合** ⇒ 多目录上报**是常态**,不是边角\n ⇒ ★ 即\"空目录报 `[]`\"**今天就会发生**,不是未来态。\n```\n### 四 定稿: ④ 的判据从三条加到**四条**\n```\n(a) 迁移路径的 PK 断言(旧库 → Migrate → 断 PK)\n(b) 命名索引 `idx_platform_sessions_ws` 存在\n(c) **无 `<table>_new` 残留**\n(d) ★★ **空列表上报不得丢数据**: 对\"某 workspace 下合法的 0 会话\"上报 `[]` ⇒\n 断言 ① 该 workspace 的行被清空(语义①生效)**且** ② **其他 workspace 的行数不变**\n ⇒ ★ 这条判据必须**同时**断言两半: 只断\"其他不变\"会漏掉\"该清的没清\"(残留);\n 只断\"该清被清\"会漏掉\"别人被误擦\"(就是本 bug)⇒ 两边都断才咬得住。\n★ 治本项(登记为 ④ 的一部分,不是另立一笔): **请求级 scope 字段** —— 桥在上报里显式带\n \"本次快照所属 workspace\";服务端 DELETE 用它做第二把键。\n ★★ 登记方式(关键,否则会造出一条**永久红**判据):\n (d) 的**到期前提就是治本项落地** —— 协议上今天拿不到 $2,所以 (d) **现在无法写**。\n 按本仓惯例(`due` = 什么时候该还清,见 `docs/DEBTS.json` 头部与 Go `debt.Due`):\n `due` = 「**请求级 scope 字段**落地时,(d) 判据同时建」\n ⇒ 而不是\"现在就把 (d) 写成一条红的判据\"。本仓已记过这个坑:\n 硬编码/超前断言与事实矛盾 ⇒ \"还清了反而红\"(`gesture-semantics` 那次)。\n 所以 (d) 与治本项**同时登记、按同一前提到期**,两者是同一件事的两面。\n\n## ★★★ 补记之八(2026-09-26 **活库实测**: 这条缺陷此刻正在发生,且逐秒可量)\n```\npi `3f482574` 把范围收窄成\"只有 opencode 有本 bug\"(上报域≠替换域),我复核**成立**,\n并把它从\"代码层推断\"变成\"**活库直读**\"。这是本条登记以来第一次**当场量到它**(此前都是复现/推断)。\n```\n### 一 缺陷的**瞬时形态**(200 样本 × 0.3s,2026-09-26 03:5x)\n```\nopencode 镜像: 任一瞬间**只持有 1 个目录**(瞬时含多个不同 workspace 的样本 = **0/200**)\n 但 opencode **自己的库**里 `session.directory` 不重复值 = **26 个**\n ⇒ ★ 26 个目录**轮流出场**,每次心跳把上一个**整批擦掉**\n实测序列(90 样本 × 1s): 0 行占 **24/90**;非空样本里出现过 **9 个不同目录**;变化 27 次\n · → 0 行 = **7 次**\n · 非空 → 非空(换成另一个目录)= **13 次** ← ★ 见 §三\n0.3s 采样(200 样本): 0 行 **25%**;最长连续 0 段 = 17 样本 ≈ **5.1s**\n 各目录占比: agentmail 36% / am-mcp-probe 22% / tmp 4% / llmsproxy 4% / root 3% / 其余各 1~2%\n ⇒ ★ 对**读**的人来说: 他要的那个目录在镜像里存在的概率 ≈ 它自己的占比\n ⇒ 除最常出场那个外,**其余 8 个目录大概率读到 0**(与补记之五的 63.9% 同源)\n★ 对照(单上报者,无此病): pi ws=**63** / rows=150;dsh ws=**25** / rows=77\n ⇒ 它们一次报**全部** ws ⇒ 上报域 == 替换域 ⇒ 擦掉后立刻全量重建 ⇒ 无损 ✓\n```\n### 二 范围定稿(pi 的结构层,我复核成立)\n```\n本 bug 的**充要形状**: **上报域 ⊊ 替换域**(一个 agent 有多个上报者,各报一部分)\n · opencode: 插件按 `directory` 实例化(index.js:1102-1103 `mailBridge(input)` 取 `input.directory`),\n 每实例 `client.session.list({query:{directory}})`(:1145-1147)只报**自己那一个目录**,\n 而 `AGENT_NAME` 是**单一常量**(:49,`/etc/agentmail/opencode.env:3` = `opencode`)\n ⇒ **上报域(1 目录) ⊊ 替换域(整个 agent)** ⇒ `[]` 时无从反推 ⇒ 本 bug ✓\n · pi: 单进程 `sessionScanner.scan()` 扫 `join(getAgentDir(),'sessions')`(worker.mjs:384-387)\n · dsh: `collectSessions()`(index.ts:413)一次收全\n ⇒ 两者**上报域 == 替换域** ⇒ `[]` 语义为真(\"我确实一条都没有\")⇒ **无此病** ✓\n★ 所以 (d) 的适用范围写明: \"**一个 agent 多个上报者**\";并把\n \"**pi 若改成按目录实例化,立刻获得同一个 bug**\"记为已知风险(今天它 63 ws 单上报者,安全)。\n```\n### 三 ★★ 对 pi §六 探测器的一处**必要收窄**(`len(list)==0` 只是两种形态之一)\n```\npi 提议: `ReplacePlatformSessions` 里 DELETE 前 `if len(list)==0 && before>0 ⇒ WARN`\n★ 我实测: 那只覆盖**一种**形态。活库里更常见的是**非空→非空**(换成另一个目录):\n 90s 采样 27 次变化中,→0 行 7 次、**非空→非空 13 次** ⇒ ★ pi 的探测器**抓不到那 13 次**\n (报 23 行的那次心跳,`len(list)=23≠0` ⇒ 不告警 ⇒ 而它把 37 行的目录**整批擦掉**了)\n⇒ ★ 忠实于**机制**(域不匹配)的探测器应当**比域**,而不是比\"是否为空\":\n 取 `list` 里出现过的 workspace 集合 L,取表里当前 `WHERE agent_name=$1` 的 workspace 集合 T;\n **凡 T 中 ws 不在 L 里** ⇒ 该 ws 的行**即将被销毁** ⇒ WARN(附该 ws 的行数)\n ⇒ 这同时覆盖 ① `[]`(L=∅ ⇒ T 全中)与 ② 换成另一个目录(L={B} ⇒ T 中的 A 命中)\n ★ 关键差别: pi 的版本量的是\"**这次报了多少**\",我的版本量的是\"**谁即将没有**\"——\n 后者才是读侧真正会感到的那件事(而读侧正是本 bug 的受害者)。\n```\n\n## ★★★★★ 补记之九(2026-09-26 仓内真驱动实测: **(d) 要拆 d1/d2**;我提的 `T\\L` 探测器**修好后必假阳**)\n```\npi `e77154d1` §二/§三 的两问,我**不只推理、写了仓内探针逐条跑**(跑完即删)。四条读数:\n [修前] 播下 /A=2 → B 上报 /B=1 ⇒ (d1) FAIL: /A = 0,期望 2(其它 ws 被误擦) ★本缺陷\n [修前] T\\L @DELETE前 = [/A] ⇒ 响 ✓(我那个形状**确实抓得到**真缺陷)\n [修好] 共存 /A=2 /B=2 → 之后 /A 仍=2 ⇒ (d1) PASS ⇒ **d1 修好即绿** ✓\n [修好] T\\L @DELETE前 = [/A] ⇒ ★ **假阳**:修好后每次心跳都常鸣\n [修好] 与\"实际删除域\"比 destroyed = [] ⇒ 不响 ✓ 无假阳\n```\n### 一★★ `T\\L ⇒ WARN` 这个形状**不可用**(我撤回 §三 提的那个)\n```\n根因: \"即将被销毁\"是 **DELETE 的谓词**决定的,不是 `T\\L` 决定的。\n 修前 DELETE 域 = 整个 agent ⊋ L ⇒ T\\L 恰好等于被销毁集合 ⇒ 碰巧对\n 修后 DELETE 域 = **按 ws 删** = L ⇒ 被销毁 = ∅,而 T\\L 仍 = T\\L ≠ ∅ ⇒ **恒假阳**\n⇒ ★ 修好后表里**天然共存多 ws**(那正是修复的目标)⇒ `T\\L` 每次心跳非空 ⇒ **常鸣**\n⇒ ⇒ 这就落进本仓记过的坑「**还清了反而红**」(`gesture-semantics` 同族)——\n 我为 (d) 拒绝超前断言的理由(永久红让整条余额失去信号),**在我自己提的形状里以假阳形式复现了**\n⇒ 忠实形状(pi 的): `destroyed = { 被本次 DELETE 移除、且未被本次 list 重插的 ws }`\n ⇒ 它**与实际删除域同源**,修前响、修后不响,且 `[]` 时 L=∅ ⇒ 整表都算 destroyed ⇒ 仍覆盖空列表 ✓\n```\n### 二★★★★ `(d)` 必须**拆成 d1 / d2** —— 我\"整体归入 due\"是**反方向的错**\n```\n· **d1**: 上报**非空** list 时,不得删除其它 workspace 的行\n 今天就能写(每项都带 `workspace`,**不需要请求级字段**)/现在就是红(就是本缺陷)/修好即绿 ✓\n ⇒ ★ 应当**现在就建**,不该进 due\n· **d2**: 上报 **`[]`** 时,只清自己那个 ws(其它 ws 行数不变)\n 需要\"这次上报属于谁\" ⇒ **与 scope 字段同一前提到期** ✓(我原来的判断只对 d2 成立)\n★ 我上封把 d1 也归入 due,是「**超前断言**」的**反面错**:\n 把**今天就能给的判据**当成\"要等未来才能给\" ⇒ 余额里白白挂着一个**今天就能变绿**的缺口。\n 两者都让余额失去信号(一个假红、一个白欠)⇒ 判据的**可得性**本身要复核。\n★ d1 的形状(照 pi 的复现,我照抄并加了对拍):\n 播下 /A 两行 → 另一个上报者报 /B(list 带 workspace,len=1≠0)→ 断言 /A 行数**仍为 2**\n 修前 FAIL(/A=0)、修后 PASS ⇒ **可判、现在红、修好绿** ✓\n```\n### 三★ 我自己的探针写错过一次(形状错、结论会全反)—— 记下来\n```\n我第一版把「取 T」那行写在了 `ReplacePlatformSessions` **之后** ⇒ 取到的是**删除后**的表\n⇒ 于是\"修前 T\\L\"算成 ∅(我据此差点得出\"我那形状漏报\"的**相反**结论)\n⇒ 修正(把取 T 排到 B 上报**之前**)后: 修前 T\\L = [/A] ⇒ **响** ✓\n★ 教训: **观测点必须与被观测的判据在同一时刻**。\n 探测器装在 DELETE 之前、观测却在 DELETE 之后 ⇒ 量的是**结果**不是**触发条件**。\n 与我此前\"判据要锚定到它防的那个动作\"是同一条,只是这次踩在**自己**的探针上。\n\"\"\"\n\n## ★★★★★ 补记之十(2026-09-27 pi `b9c7308c` 指出,我**实测复现并加重**: 重建表会**静默丢掉** `idx_platform_sessions_ws`)\n```\npi 的指正: 具名索引随 `DROP TABLE` 消失、`RENAME` **不**带回 ⇒ ④ 需补\"重建具名索引 + 断言它存在\"。\n我实测(/tmp/idxtest,**先复现、再验仓库真实顺序**):\n [裸 12 步] 建 _new → 拷贝 → DROP → RENAME\n 重建前索引 = idx_platform_sessions_ws ⇒ 重建后 = **(无)** ✓ pi 断言成立\n [顺序敏感] 重建在前、CREATE INDEX 在后 ⇒ **能**补回; 反之 ⇒ **补不回** ✓ pi 的\"依赖顺序\"也成立\n```\n### 一★★★ 但在**本仓真实顺序**下,那句 `CREATE INDEX IF NOT EXISTS` 是**空转、补不回**\n```\n`migrate.go:33-37`: 逐条 `Exec` 走完 **init DDL 全文**(含 `:424` 的 CREATE INDEX),\n **之后**才是任何自定义迁移(`addMissingColumns` 在 `:40` 也在这之后)\n⇒ ★ 真实时序: `CREATE INDEX IF NOT EXISTS`(空转,索引此刻还在) → **DROP** 把它带走 → RENAME\n⇒ 我按真实时序实测: 最终索引 = **(无)** ⇒ ★★ **补记之七 ④ 里的\"b: 索引存在\"这条判据会红** ——\n 而它红得**对**,红的原因是\"索引被迁移吞了\",不是\"索引本来就没建\"。\n⚠️ 与我早先\"(c) 断言无 `_new` 残留\"同源: **DROP 之后的每一样东西都要重新核对**,\n 因为 RENAME 只搬**表**, 不搬**索引/触发器/外键**。\n```\n### 二★ 为什么这个索引是我们论证的**基石**(不是可选优化)\n```\n`:473-474` 的 JOIN 正是修复③要消歧的那一条:\n LEFT JOIN agent_platform_sessions aps ON aps.platform_id = s.platform_id AND ...\n⇒ 它按 **(agent_name, workspace)** 前缀组织的索引,服务的是修复后的按-ws 查询\n⇒ ★ 丢索引**不会**让功能出错,只会让每轮心跳的 DELETE/JOIN 退化成**全表扫描**\n ⇒ ★★ 这是**最坏的一类**后果: 没有任何报错、判据除\"索引存在\"外全绿、线上只是变慢\n ⇒ 与 `recount-relay-counts.sh` 那条同族: **静默劣化**比**响**更难发现。\n```\n### 三★★ 修复清单相应加两项(④ 之外)\n```\n④b **在重建之后**(不是之前)显式重建具名索引: `CREATE INDEX IF NOT EXISTS idx_platform_sessions_ws ...`\n ⇒ 因为 init DDL 的那句在**DROP 之前**已跑过、只是空转,**指望它兜底是错的**\n④c 判据 (b) 保留且**必须走迁移路径**(见补记之四 ⑤′: 全新建库必绿、不具判别力)\n★ 另: 若将来还有别的索引/触发器,**重建迁移必须自带一份清单** ——\n 正确形状不是\"记得补这一个\",而是\"**迁移后逐项核对 schema 对象集合**\"。\n```\n\n## ★★★ 补记之十一(2026-09-27 pi §四 的第二问,我实测确认并给出**可执行的形状**)\n```\n### 一★★ \"PK 断言必须走迁移路径\"——确认,且我把**为什么**量化了\n 全新库: PK 来自 DDL 本身 ⇒ 断言**恒绿**、**零判别力**(/tmp/pktest/fresh.db 实测)\n 旧 库: 重跑同一份 DDL(`CREATE TABLE IF NOT EXISTS` 命中已存在表 ⇒ **原样跳过**)\n ⇒ PK 仍是 `PRIMARY KEY (agent_name, platform_id)`(/tmp/pktest/old.db 实测)\n⇒ ★ 两种情形**同一份断言、同一句通过/不通过**,差别只在**库怎么来的**\n ⇒ 所以判据必须**自己造一个旧库**(老 DDL 建表 → 灌行 → 跑迁移 → 断言),\n 否则它测的是\"DDL 文本对不对\",而那件事永远成立。\n### 二★★ 线上现状(判据将要断言的那个对象,2026-09-27 04:0x 实测)\n PK 列 = `agent_name , platform_id` ← **仍是旧的**\n 索引 = `idx_platform_sessions_ws` ← 在(今天还没被任何重建吞掉)\n 行数 = 278\n⇒ 这正是\"改 DDL 对生产**静默无效**\"的现场: 文件里 PK 写什么,库里都不是那个。\n### 三★ 判据/自检该用哪种读法(两个都实测过)\n ✅ `pragma_table_info` 的 **`pk>0` 列按 pk 序号排序**后逐项比对:\n 旧库 ⇒ `agent_name, platform_id` 全新库 ⇒ `agent_name, workspace, platform_id`\n ✗ **`LIKE '%(agent_name, workspace, platform_id)%'`** 匹配 PK 文本:\n 在**旧库(PK 明知是错的)上不命中** ⇒ 判据会\"绿着一个错的库\"\n ⚠️ 且注意: `pragma` 给的 pk 顺序**可能与声明书写顺序不同**(`PRIMARY KEY (agent_name, platform_id, workspace)`\n 在 pragma 里就是 `agent_name, platform_id, workspace`)\n ⇒ **必须按 pk 序号排序后逐项比**,只比集合会漏掉次序差异。\n### 四★ 由此得到\"迁移后自检\"的最小形状(今天可写、现在**红**、修好即绿)\n 在 `Migrate` 末尾对 `agent_platform_sessions` 断言三样:\n ① `pk` 列(按序号)== (agent_name, workspace, platform_id)\n ② `idx_platform_sessions_ws` 存在\n ③ 无 `agent_platform_sessions_new` 残留\n ★ 与 (a)(b)(c) 的区别: (a)(b)(c) 是**测试**里断言(要自己造旧库才有判别力),\n 这一条是**启动路径上的自检** ⇒ **生产也会响** —— 否则缺陷在生产上永远不暴露\n (线上现在就是 PK 错的,而全部测试绿)\n\n## ★★★ 补记之十二(2026-09-27 pi `5f3eb02d` 陷阱② 我实测**方向相反** —— 而这个方向差会让人改错)\n```\npi 说: \"守卫不能用字符串 LIKE —— 我自己先写的 `'…PRIMARY KEY (…'` **带空格**,**实际无空格**\n ⇒ 重建成功后仍报'未重建'、每次启动重来\"\n⇒ ★★ 我实测: **方向反了**。`sqlite_master.sql` 是**逐字保留建表语句**的(不规范化空白):\n 建 `PRIMARY KEY (a, b)` ⇒ 库里就是 `PRIMARY KEY (a, b)` → **带空格的 LIKE 命中**\n 建 `PRIMARY KEY(a, b)` ⇒ 库里就是 `PRIMARY KEY(a, b)` → 带空格的 LIKE **不命中**\n 线上实测: `agent_platform_sessions` 的 `PRIMARY KEY (agent_name, platform_id)` **带空格**、\n `LIKE '%PRIMARY KEY (agent_name, platform_id)%'` **命中 = 1** ✓\n ⇒ 且本仓 `init_sqlite.sql:421` 用的**就是**带空格写法(:328/:380/:389 同样)\n⇒ ★★ **陷阱本身是真的,但机制不是\"没有空格\"**,而是:\n **`LIKE` 的命中取决于**当初 DDL **怎么写**,而 DDL 写法不是不变量\n ⇒ 换句话说: 今天命中,换个书写风格(或另一次重建用了不同格式)就**不命中**\n ⇒ 所以正确结论与 pi 的**建议一致、理由不同**:\n **别用 LIKE,用 `pragma_table_info` 的 `pk>0` 列(按 pk 序号排序后逐项比)** ✓\n —— 但**不是**因为\"实际无空格\",而是因为\"**匹配结果依赖书写风格**、因此不可移植\"。\n⚠️ 若照 pi 的理由去改(例如为了\"匹配无空格\"而把 DDL 改成紧凑写法)⇒ **恰好制造它描述的故障**:\n 改完 DDL 后老库的 `sqlite_master.sql` 仍是旧样式(IF NOT EXISTS 不会重写)⇒ LIKE 反而不命中了。\n ⇒ ★ 这是本轮最值得记的一条: **正确的结论 + 错误的理由 ⇒ 会导出错误的动作**。\n (与 `4b4dd2c7` 那条同族: 理由错 ⇒ 动作错; 这次是**我差点按错误理由去改**。)\n\n## ★★★★ 补记之十三(2026-09-27 pi `5f3eb02d` 陷阱① 我**独立复现**成立,并**实测验证了修法**)\n```\n### 陷阱① 复现(模拟逐条 auto-commit,崩在 DROP 之后、RENAME 之前)—— **完全成立**\n 播下 aps 行数=1\n 建 _new → 拷贝 → **DROP** ⇒ `aps` 存在?=**0**、`aps_new` 行数=1(行还在,但表没了)\n 下次启动: init DDL 的 `CREATE TABLE IF NOT EXISTS aps(新PK)` ⇒ 建出**空表**\n ⇒ 实测 `aps` 行数 = **0**(线上就是 37 行)、`aps_new` 残留 = **1**\n ⇒ 守卫只看 PK ⇒ 此时 PK **已正确** ⇒ **跳过重建** ⇒ ★ **永不自愈**\n ⇒ 而判据 (a)(b) 此时**全绿**(表在、PK 对、索引…随 DROP 一起没了才红)⇒ 绿着丢数据\n### ★★ 修法我实测跑通(不是纸面)\n 在**同一个 `BEGIN … COMMIT`** 内: 建 _new → 拷贝 → DROP → RENAME → **重建索引** ⇒\n 行数=**1**(保住)✓ 索引=**1**(补回)✓ `_new` 残留=**0** ✓ PK=**(a,b,w)** ✓\n ⇒ ★ 索引那条**必须写在 RENAME 之后、且在事务内** —— 写在 RENAME 之前会被 RENAME 后\n 的空转 `IF NOT EXISTS` 掩盖(补记之十 已证: init DDL 那句在 DROP **前**跑过、只是空转)\n⇒ ★★ 所以 pi 那条\"④ 应从『保证顺序』改成『**事务内显式重建索引**』\"我**采纳**:\n 顺序依赖被事务消掉,比依赖\"init DDL 恰好跑在重建前面\"稳健得多。\n⇒ ⚠️ 但**仅靠事务不够**: 事务防的是\"中途崩\",防不了\"**上一版已经崩过**\"留下的孤儿 `_new`。\n ⇒ 故 ③(判据 c: 断言无 `aps_new` 残留)+ \"孤儿可恢复\"是**必需**的第二道。\n```\n\n## ★★★★★ 补记之十四(2026-09-27 pi `249fe29d` §三 的第三处 —— 我实测**成立且比它说的更重**)\n```\n### 一★ 它指的代码形状,我核对属实\n `platform_sessions.go:467` `PlatformSessionFor` 用 **`QueryRowContext`** + `:473-474` 的\n `LEFT JOIN agent_platform_sessions aps ON aps.platform_id = s.platform_id` ——\n **ON 子句只按 platform_id、不含 agent/workspace** ⇒ 多行时 **QueryRow 静默取第一行**。\n### 二★★ 我实测复现(/tmp/p3)\n aps 里同一 `ses_ABC` 挂 `pi` 与 `dsh` 两条 ⇒ 该 SQL 返回 **agent_name = dsh**\n ⇒ ★★ 而调用方是 **pi 的会话** ⇒ **归属方取错**、无报错、无告警\n### 三★★★★ 比 pi 说的更重的一层:**改 PK 不解决它,反而让它更易发生**\n 用**改后**的 PK `(agent_name, workspace, platform_id)` 建表实测:\n 同一 `(pi, ses_ABC)` 插**两个不同 workspace** ⇒ 都插得进(**这正是改 PK 的目的**)\n `:473` 的 JOIN 只按 platform_id ⇒ 匹配 **3 行** ⇒ QueryRow 仍取第一行\n ⇒ ★★★ 也就是说: 改 PK 之前,一 agent 一 platform 只能有**一个** workspace\n (旧 PK 把它压住了)⇒ 这条歧义**几乎触发不到**;\n 改 PK 之后**同一个 agent 的同一 platform 可以有多个 workspace** ⇒ 歧义**变成常态路径**\n ⇒ ★★ ⇒ **第三处必须与 PK 同批改**,且它**不是\"顺手带上\"** ——\n 不改的话,这次修复会把一条**今天几乎触发不到**的取错归属,升级成**天天可能触发**。\n### 四★★ 承重(为什么比候选列表更重)—— 我核了调用链\n `notify/mail.go:94` `platformID, platformOwner := repo.PlatformSessionFor(ctx, m.SessionID)`\n → `platformOwner` **直接决定这封信投给谁**(`:105-109`: platform_id 只发给归属方,\n owner 为空则**一律不下发**)\n → 而 `:90-93` 的注释记着**生产实测过的真实故障**: pi 的会话被推给 dsh ⇒ 邮件静默消失\n ⇒ ★ 所以取错 owner = **投错人 / 丢信**,且失败形状是**静默**(与 `ReleaseRelay`、\n `/tmp` 影子模块同族: 不报错、只是结果错了)\n ⇒ pi 说 `:205/:292` 两处**已按 workspace 限定、不受影响** —— 我核了,**属实** ✓\n### 五★★ 修法方向(只记形状,不代写)\n `:473` 的 ON 子句必须**同时**按 `(platform_id, agent_name, workspace)` 匹配,\n 而**这三个值从调用方传进来**(`notify/mail.go:94` 处 `m.SessionID` 之外还要带上收件方身份)\n ⇒ ⇒ 这就要求 `PlatformSessionFor` **改签名**(加 agent/workspace 参数),\n 它是**导出函数** ⇒ 调用点只有 2 处(`notify/mail.go:94`、`platform_sessions.go:444`)⇒ 改动面小 ✓\n### 五★★ ★ 自查: 我上面写的\"**循环依赖**\"是**错的**,我当场撤(pi 没提,是我加的)\n```\n我原本写: \":94 在收件人循环里 ⇒ 需先知道收件方身份 ⇒ 循环依赖\"\n★ 复核: `:94` 在 `Recipients` **函数顶部、只算一次**(CC 循环在 `:239`)⇒ **无循环** ⇒ 撤\n⇒ 但复核时暴露一个**更基础**的问题(我改标为**未解**,不假装已知):\n `owner` 是**全局一个**的值,而一封邮件**可以有多个参与方**(`m.CC`)\n ⇒ 一次 `PlatformSessionFor` **根本表达不了\"这封信该投给谁\"** ——\n 它回答的是\"这条**会话**归属谁\",而分发需要的是\"这封信对**每个参与方**各自是什么\"\n ⇒ ★ 二者不是同一个问题: 修好 JOIN 消歧只让\"会话归属\"变**确定**,\n 但\"多参与方各自该收到 platform_id 吗\"仍是**另一个未设计**的点\n ⇒ ⇒ 记为**未解**(标 unknown,不假装是已知的修法)\n\n## ★★★★★ 补记之十五(2026-09-27 pi `43d2c9dd` §三 —— 修法形状定稿:三键 vs 两键,我实测**两键才是唯一全场景解**)\n```\n### 一★★ pi 的场景C 我复现成立: 单键消歧**两个都不够**\n 场景C(同 agent + 同 platform_id + 两个 workspace = **改完 PK 的未来态**):\n ① 只按 **agent** ⇒ **2 行** ⇒ QueryRow 仍取第一行 ⇒ ★ 歧义**仍在**\n ② 只按 **workspace** ⇒ 1 行(该场景下够)\n ⇒ ★⇒ 照\"按 agent 改\"会**在 PK 改完之后**踩到一个**新的**歧义(正是 pi 提醒的)\n### 二★★★★ 但我构造了 pi 没列的场景,结论反转: **只按 workspace 也不够**\n 场景D(**两个 agent 共用同一个 workspace**): pi 与 dsh 同挂 `/shared`\n ① 只按 agent ⇒ 1 行\n ② 只按 **workspace** ⇒ ★ **2 行** ⇒ 歧义\n ③ agent + workspace 两把 ⇒ **1 行** ✓\n ⇒ ★★ 场景D **不是假想**: pi 与 dsh **确实共用** `/home/program/agentmail`(就是本会话的工作目录)\n ⇒ ★★⇒ **三键(platform_id, agent_name, workspace)才是唯一在 A/B/C/D 全场景恒为 1 行的键** ✓\n ⇒ pi 的结论(两把一起)**成立且是必要的**,我补的是\"为什么不能只留一把\"\n### 三★★ `sessions.workspace` 是否够用 —— pi 说\"已存在 9/9 非空\",我实测**口径要写全**\n```\n 全表: workspace 非空 **51 / 79** ⇒ ★ **不是** 79/79(那 28 条空的**全都没有 platform_id**)\n 有 platform_id 的行: **9 / 9** 非空 ✓ ⇒ pi 的 9/9 是**这个子集**上的口径\n 与 aps 按 platform_id 可对齐: 6 行,其中 workspace **相同 6 / 6** ✓\n⇒ ★⇒ 对**要改的那条 JOIN** 而言 workspace **总是可用**(有 platform_id ⇒ 必有 workspace)✓\n ⇒ 且**不需要新加数据/迁移**,pi 这条成立。\n ⇒ ★ 教训(pi 这次没写全,是我没写全的镜像): 同一句\"非空率\"若不写**分母**,\n 9/9 与 51/79 都能自称\"非空\" ⇒ 报比例**必须带分母定义**(与 ⑫′ 同源)\n### 四★★★★ ⇒ 必改点由**三处**增为**四处**(定稿形状)\n ① **PK** 改 `(agent_name, workspace, platform_id)`\n ② **DELETE**(`:63`)按 workspace 限定(`ReplacePlatformSessions` 加 workspace 形参)\n ③ **JOIN**(`:473-474`)**三键**:`ON aps.platform_id = s.platform_id\n AND aps.agent_name = s.from_agent AND aps.workspace = s.workspace`\n ⇒ ⚠️ 注意: 这要求 `sessions.from_agent` **非空** —— **已验**: 有 platform_id 的 9 行\n `from_agent` **9/9 非空** ✓(与 workspace 同为\"有 platform_id ⇒ 必非空\")\n ④ **迁移重建表**(`BeginTx` 内 DROP+RENAME+**RENAME 后**重建 `idx_platform_sessions_ws`)\n ⇒ ★ ③ 是本轮新增的**第四处**,且它**必须**与 ① 同批: 单独改 ① 会把 ③ 的歧义**放大**(见场景C)\n### 五★★ 场景D 的**生产实证**(不是假想)—— 我在 `sessions` 表里找到了\n```\n `ba9c194b` pid=01a05a5e-8ab from_agent=[pi] ws=/home/program/agentmail\n `9742de96` pid=mail-ba9c194 from_agent=[dsh] ws=/home/program/agentmail ★ **同一 workspace,两个 agent**\n⇒ ★★ 这就是场景D 的现成实例: pi 与 dsh **已在生产里**共用 `/home/program/agentmail`\n ⇒ ⇒ \"只按 workspace 消歧\"会被这一对真实数据直接击穿 ⇒ **三键是必需的**,不是保守\n⇒ 顺带: `pid=mail-ba9c194` / `mail-8eb89cf` 这类 **platform_id 与 workspace 1:1** 的行,\n 说明 (platform_id, workspace) 组合本身已足够; 但**不能依赖**它 —— 上两行就是反例\n\n## ★★ 补记之十六(2026-09-28 复核 pi `e77154d1`:两条**独立测量互证**,补一格我测不到的子形态)\n```\n### 一★ 那封信的两处更正**早已在案**,本轮只做核对、未改结论\n `99320f45`(09-26 02:06:55)那两条 —— 「557 是**下界**不是等值」与「删除路径有**三条**」\n —— 我在 **12 分钟后的 `81b61fde`(02:18:27)** 就已全收并自撤了对应论据。\n 复核仍成立(规则 ⑩ 逐条在被引文件里核对):\n `deploy/prune-test-sessions.sh:117` `DELETE … WHERE mail_id IN (SELECT …)` ⇒ **不要求 NULL** ✓ 能删已绑定行\n `deploy/reset-demo.sh:81` `DELETE FROM relayed_mails;` ⇒ **全清** ✓\n ⇒ 「查不到痕迹 ⇒ 没删过」**不成立**这条,已在定稿多处 ✓\n ⇒ ★⇒ 结论不变: **「422 未能确证」**,且 `bound(T1) ∈ [556,559]`(补记之十三 已记)——\n 而\"占位释放\"是**至少一个**可行解释、非唯一(补记之十三 同)。\n### 二★★★ 本轮唯一**新**的一格: pi 的 44 次采样与我 200 次**互证**(此前未并列记录)\n```\n | 量 | 我(200×0.3s + 90×1s) | pi(44 次) | 判定 |\n |------------------------|------------------------|-----------------|------|\n | \"→0\"(零行)占比 | **25%** | **25%** | ✓ 一致 |\n | 换 workspace 事件 | 27 次变化 / 90s | 22 次 / 44 次 | ✓ 同量级 |\n | 最长零窗 | ≈ **5.1s** | 未测 | 我独有 |\n | **\"→0 之后回升\"** | ★ **测不到** | **11 次** | **它独有** ✓\n⇒ ★ 关键不是\"谁对\", 而是**两条测量的粒度不同、因而互补**:\n 我的 0.3s×200(=60s) 与 1s×90(=90s) **无法分辨**\"归零后回升\"这个**子形态**\n (回升若快于采样间隔,我只看到\"又一次变化\")\n ⇒ 所以\"25% 相同\"不是同义反复: 它是两个**独立执行**在**同一量**上撞出一致 ⇒ 零窗口是真实的\n⇒ ★ 而\"11 回升\"是**我采样设计漏掉**的一格(不是它多测了真相,是我**没问这个问题**)\n ⇒ 与本会话方法论同族: 采样率决定了**能看见哪些形态**; 报\"没测到\"时应写\"**我的粒度下测不到**\",\n 而非\"不存在\"\n\n## ★★★★★ 补记之十七(2026-09-29 实测:**② 已上线而 ①③④ 未改** —— 记录本条的**真实落地状态**)\n```\n### 一★ 本条目的\"尚未实现修法\"这句**已经过期**(`kind` 仍是 scope)\n 实测: 部署二进制 `/opt/agentmail/agentmail-gateway`\n · `vcs.revision = 359cb436c3beaafe16601de9420a5346b9e9f42c`(**09-28 10:14:52 启动**,PID 2824864)\n · ② 的提交 `db640e2`(09-26 14:20)`git merge-base --is-ancestor` 判定 **在 359cb436 里** ✓\n · 二进制里 `workspace IN (` 字符串 **命中 1** ⇒ ★ **② 已在生产运行**\n ⇒ ★★ 而我此前的记录(含本条目\"尚未实现修法\")说**没修** ⇒ **那句已过期**\n ⇒ 与 `prune-artifact-evidence-decays-with-reboot` 同族: **状态会变,记状态的话会过期**\n### 二★★★★ 四处必改点的**真实状态**(不是\"全没改\",也不是\"改完了\")\n```\n ① **PK** `init_sqlite.sql:421` 仍是 `PRIMARY KEY (agent_name, platform_id)` ⇒ **未改**\n 线上库实测: `pk 列 = agent_name , platform_id` ⇒ **未改**(且未重建)\n ② **DELETE** `platform_sessions.go:138` = `... WHERE agent_name = $1 AND workspace IN (...)`\n ⇒ ★ **已改、已上线** ✓ 且带 `len(wsOrder) > 0` 守卫(空列表**什么都不删**,不依赖 `IN ()` 恒假)\n ③ **JOIN** `:473-474` 仍 `ON aps.platform_id = s.platform_id`(**单键**)⇒ **未改**\n ④ **迁移重建表** 未在 `server/internal/db/*.go` 找到重建逻辑 ⇒ **未改**\n⇒ ★ 我此前把这个缺陷整体记成\"未修\",是**粗口径**; 真实状态是 **② 单独落地**(4 处里的 1 处)\n```\n### 三★★★★★ 由此产生一个新的**潜伏互斥**——② 单独上线使旧 PK 从\"无害\"变成\"可撞\"\n```\n 旧代码(全量 DELETE `WHERE agent_name = $1`): 每轮上报**清掉该 agent 所有 ws** ⇒ 表里\n 同一 `(agent, platform_id)` **不可能**跨 ws 残留 ⇒ 旧 PK 的 UNIQUE **永远不会被触发**\n 新代码(②,scoped DELETE): 只清**本次 list 覆盖的 ws** ⇒ 本次 list **之外**的 ws 行**留下**\n ⇒ 若同一个 `platform_id` 先前登记在 `/w2`、本次又从 `/w1` 上报:\n DELETE 只清 `/w1`(`/w2` 那行**留着**)→ INSERT `(agent, id, /w1)`\n ⇒ ★★★ **撞 `UNIQUE(agent_name, platform_id)`**(实测: `UNIQUE constraint failed`)\n ⇒ ★★ 而 `INSERT` 是**普通 INSERT、无 `ON CONFLICT`/upsert**(`:155-160` 实测)\n ⇒ 该错误会经 `tx.Commit()` 前的 `return err` **冒泡** ⇒ 本次上报**整体失败、事务回滚**\n ⇒ 后果: **镜像停止更新**(不是数据错乱,是**这一路心跳从此失败**)\n ⇒ ⇒ ★★★ 所以 **② 单独上线不是\"无害的部分修复\"**:\n 它在**旧 PK 未改**的前提下,把一个\"永远不会发生\"的约束冲突变成\"**条件满足即发生**\"。\n 这正是补记之十四/十五 说的\"必须同批\"的**另一半** —— 那两条讲的是\n **PK 改了而 ③ 没改**会坏; 这一条讲的是 **③④ 没改而 ② 改了**已经上线、也会坏。\n```\n### 四★★ 当前**可达性**(严谨: 前置条件目前不满足 ⇒ 尚未实际发生)\n```\n 触发需要两条同时成立:\n (i) 同一 `platform_id` 的行**跨 ws 残留**(即该 id 曾登记在本次 list 外的 ws)\n (ii) 该 `platform_id` 在本次上报 list 里**重新出现**\n 生产实测: 同 agent+platform 多 ws 的组合 = **0**; 同 platform 多 agent = **0**\n ⇒ ★ 所以**当前没有触发实例** —— 本条是**潜伏**,不是\"正在坏\"\n ⇒ ⚠️ 但 dsh 的 `collectSessions()` 返回**该 agent 所有会话**(cwd 取自各自 header)\n ⇒ 一个 dsh 进程**可以**持有多个 cwd 的会话 ⇒ 条件 (i) 的\"跨 ws\"在结构上**可达**\n ⇒ 且线上 `agent_platform_sessions` 已有 **439 行** ⇒ 一旦哪条会话的 cwd 变了就会命中\n ⇒ ⇒ 定级: **潜伏 / 条件满足即发生**(不是理论上的,是**差一个 cwd 变化**)\n```\n### 五★★ 从这条得到的**可判形状**(并入 ⑩⁗ 家族)\n```\n ① 报\"修了/没修\"**必须逐处报**,不能把多处必改点合成一个布尔 —— 我这次就是被自己的粗口径骗了\n ② 修复分片上线时,**必须重新算\"旧不变量被谁依赖\"**:\n 旧 PK 的 UNIQUE 之所以安全,依赖的是**旧 DELETE 的全量语义**;\n 一旦 DELETE 变 scoped,那份安全性**随之消失** ⇒ 两者是**隐式耦合**,不在类型/签名上\n ③ 判据要能区分\"未修\"与\"**半修**\": 我那个 d1 判据转绿了(因为它只测 ②),\n 而 ①③④ 仍红 ⇒ ★ **判据集合必须与必改点集合一一对应**,否则\"绿\"会被读成\"修好了\"\n```\n\n### 六★★★★ 忠实模拟(线上真 schema 导出副本)+ 严重性**再升一档: 不自愈**\n```\n 用 `sqlite3 \"file:/opt/agentmail/data/agentmail.db?mode=ro\" \".backup\"` 导出**真 schema** 副本\n (PK = `(agent_name, platform_id)`、含 `idx_platform_sessions_ws`、439 行)后模拟:\n 初始: `('dsh','sess-A','/w2')`\n 本轮上报 list = [{id:sess-A, ws:**/w1**}](即该会话 **cwd 变了**):\n ② DELETE ... WHERE agent_name='dsh' AND workspace IN ('/w1') ⇒ 删 0 行,**/w2 那行留下**\n INSERT ('dsh','sess-A','/w1') ⇒ ★ `UNIQUE constraint failed: agent_platform_sessions.agent_name, ...platform_id`\n ⇒ ★★★ **不自愈**: 再跑一轮(cwd 仍是 /w1)⇒ DELETE 域仍只含 /w1 ⇒ **又撞同一个错**\n ⇒ 因为陈旧行在 `/w2`,而 DELETE **永远**清不到它 ⇒ **每一轮心跳都失败 ⇒ 永久失败**\n ⇒ 出路只有三条: 该 agent 来一次**含 /w2** 的上报(即 cwd 改回去)、\n 人工/脚本清那一行、或**修好 ①**(新 PK 允许同 (agent,platform) 多 ws ⇒ 不再互斥)\n⇒ ★ 所以严重性是\"**静默且永久**\": 不报错给用户、镜像**冻结**在那个 agent 的最后一版,\n 而 `push_tokens`/候选列表这些依赖镜像的读取会**一直看到旧数据**\n ⇒ 与 `ReleaseRelay`(无痕删除)、`/tmp` 影子模块(rc=0 混入)同族: **不响的坏**\n```\n### 七★ 触发前提(把\"差一个 cwd 变化\"说得更准)\n```\n 需要: 某 `platform_id` 的旧行在 `ws=X`(**本轮 list 不含 X**),而同一 id 本轮在 `ws=Y≠X` 被上报\n ⇒ ★ 现实中就是 **\"会话的 cwd 变了\"**(`collectSessions` 用 `header.cwd` 作为 workspace)\n ⇒ 而\"改工作目录/重开会话/迁移项目目录\"是**常见操作**,不是异常路径\n ⇒ 生产实测当前 0 例(同 agent+platform 多 ws = 0)⇒ 尚未发生,但**门槛只是一个 cwd 变更**\n```\n\n### 八★ 对\"永久失败\"的**一处必要限定**(我核了全仓删除路径,避免把话说满)\n```\n 全仓 `DELETE FROM agent_platform_sessions` 共 **2 处**(`grep` 实测):\n · `platform_sessions.go:138` = ② 的 scoped DELETE ⇒ 清不到陈旧行(如上)\n · `repo.go:2233` = `DeleteAgent(ctx, name)` 里的 `WHERE agent_name = $1` ⇒ ★ **能清**\n ⇒ ★ 所以\"永久\"的准确定义是: **在常规心跳路径下永久**(② 的 DELETE 域永远不含陈旧 ws)\n 而**不是**\"任何情况下都不可恢复\" —— `DeleteAgent`(删掉该 agent 再重建)能清掉\n ⇒ ⚠️ 但 `DeleteAgent` 是**破坏性管理动作**(撤销全部密钥、清模型范围/速率限制),\n 不是运维会为了\"让镜像恢复更新\"而走的路径 ⇒ 它**不构成实用的自愈**\n ⇒ ★ 记这一格的理由: 我这句\"永久失败\"若不限定,就是**把话说满**(⑨ 家族)——\n \"在 X 路径下永久\"与\"绝对永久\"是两句不同的话。\n```\n\n## ★★★★★ 补记之十八(2026-09-29 答 pi `ac300230` 的\"多实例\"一问,并修正它两处事实)\n```\n### 一★★ pi 问: \"我只见一个 serve,请帮我查是否多实例\" ⇒ **答: 只有一个 opencode 进程,但机制上不需要多进程**\n```\n `ps` 实测: opencode 相关**只有 1 个** `opencode serve --port 4097`(PID 2441561, 09-28 09:38:20)\n ⇒ ★ **没有多实例** ✓(与 pi 的观察一致)\n ⇒ 但这**不**排除\"多上报者\"。⚠️ ★ **更正我自己的措辞**: 我原先写\"**每个 directory 会有一次\n `mailBridge(input)` 调用**\" —— 那是我的**推断**(由 `input.directory` 存在推出),\n **无文档/无 README 佐证**(`plugins/opencode-mail-bridge/` 下无 README)⇒ 标为**未确证**。\n ⇒ 可确证的只有三条:\n ① 插件**收到** `input.directory`(`index.js:1175` `const { client, directory } = input`)\n ② `reportSessions` **只列自己那个** directory(`:1219` `query: directory ? { directory } : undefined`)\n ③ `AGENT_NAME` 是**同一常量**(`:50`)\n ⇒ ⇒ 由 ①②③ 可推出的是: **若**同一进程内有多个 directory 各跑一份插件实例,\n 它们会**共用 AGENT_NAME** 而**各报各的 directory** ⇒ 才是多上报者。\n 而\"是否真有多个实例\"我**没有观测到**(只见到 1 个 `serve` 进程;\n 进程内实例数无法从外部判读 —— `/proc/<pid>/cwd` 不可读)\n ⇒ ⇒ ★ 所以对 pi 那问的**准确回答**是: **没有多进程**(可确证);\n **进程内是否多实例,我无法判定**(不可观测),而不是\"可存在于进程内\"这种断言\n ⇒ ⚠️ 并更正一处失效引用: 我此前写的 `index.js:1102-1103` 是**旧行号**,\n 该处现为 `:1174-1175` ⇒ **引用已随文件增长失效**(规则 ⑩ 的变体: 行号也会漂)\n```\n### 二★★★★ 关键修正: pi 说的 `project_directory` **列不存在**(规则 ⑩)\n```\n `pragma_table_info('session')` 实测列名是 **`directory`**(另有 `path`、`workspace_id`),\n **没有** `project_directory` ⇒ 它引的\"12 个项目\"若用该列名查,**查不到**(会报 no such column)\n 按**正确列名**实测: 不同 `directory` = **26 个**(不是 12)\n `/home/program/TrueAgent` 284 会话 / `/home/program/agentmail` 176 / `/tmp` 56 /\n `/root` 46 / `/tmp/am-mcp-probe` 23 / `/home/program/llmsproxy` 18 / …\n ⇒ ★ 所以\"agentmail 目录恰好 37 个会话\"这个佐证**数值也对不上**(按 `directory` 是 **176**)\n ⇒ pi 的**结论**(\"37 行\"与\"37 个会话\"是**巧合**、不是因果)**方向仍对**,\n 但它引的具体数字**两个都不是本仓的实测值** ⇒ 佐证**不能照用**\n```\n### 三★★★★★ 现状: ② 上线后 opencode 镜像**已稳定在单 workspace**(pi 观测的 5 态轮替已不复现)\n```\n 长窗复采(30s,6 次): opencode **恒 100 行 / ws 数 = 1 / reported_at 逐步推进**(心跳仍在跑)\n 当前 ws = `/home/program/agentmail`; 抽检 60 条 id 的 `session.directory` ⇒ **60/60 全为 agentmail** ✓\n 5 态轮替(0/23/37/38/49)**复现不出**\n ⇒ ★★ 解释: pi 观测(`ac300230` = 09-26 01:00)发生在 **② 部署(09-28 10:14)之前**;\n 当时是**全量替换** ⇒ 多个 directory 的上报**互相整表擦除** ⇒ 才会看到 5 态轮替\n ② 之后 DELETE 域收窄到本次 list 的 ws ⇒ 若仍多上报者,各 ws 的**行会累积并存**\n ⇒ 而实测 ws 数 = 1 ⇒ ★ **当前只有一个 directory 在报**(其余未报或未实例化)\n ⇒ ⇒ ★ 所以这条**不能**用来判\"多上报者已消失\": 单 ws 与\"多上报者但只有一个在报\"**观测等价**\n ⇒ 正确的判据是 ② 之后**看是否出现多 ws 并存**(出现了=多上报者,没出现=不能区分)\n```\n### 四★ 对 pi 修法倾向的回应\n```\n pi 倾向 **(agent_name, workspace)** —— 与本补记之十五 的结论**部分冲突**:\n 之十五 已实测: **只按 workspace 消歧不够**(两 agent 共用同一 ws ⇒ 2 行),\n 生产里**现成就有**这对数据(`ba9c194b`=pi / `9742de96`=dsh 同挂 `/home/program/agentmail`)\n ⇒ ★ 它的\"表已有 `INDEX (agent_name, workspace)` ⇒ 设计本就是 per-workspace\"这句**是对的**\n (单 ws 之内该索引确实就是这个粒度)\n ⇒ 但 **PK 与消歧键不是同一件事**: 索引服务的是**查询**,PK 约束的是**唯一性**\n ⇒ per-workspace 的存储粒度**不排除**\"同一 ws 下多个 agent 各有一行\"\n ⇒ ⇒ 维持之十五 结论: **PK/三键都要带 agent**(三键 `platform_id + agent + workspace`)\n```\n\n## ★★★★★ 补记之十九(2026-09-29 答 pi `8f0a8d60`:**它提的\"方案A\"就是已上线的 ②**;并解开\"n 复现不出\"之谜)\n```\n### 一★★★★ pi 的 \"方案A: DELETE 加 workspace(用 idx_platform_sessions_ws 作证)\" —— **代码里已经有了**\n```\n 实测 `platform_sessions.go:138`:\n `DELETE FROM agent_platform_sessions WHERE agent_name = $1 AND workspace IN (...)`\n ⇒ ★ **这正是它说的方案A**,且**已于 `db640e2`(09-26 14:20) 落地、已随 `359cb436` 部署(09-28 10:14)**\n ⇒ 它\"顺带关掉你上封那条 `[]` 洞\"这句也**已实现**: `:129` 有显式守卫\n `if len(wsOrder) > 0 { … }` ⇒ 空列表**什么都不删**(注释还写明\"不依赖 `IN ()` 在两方言恒假\"这一巧合)\n ⇒ ⇒ ★★ 所以它 §三 的**分析对**(擦除必要、错的是范围、\"擦的域==读的域\"),\n 但**该方案早已落地** ⇒ 我们俩在讨论一个**已经实现**的修法(信息滞后,不是分歧)\n```\n### 二★★★★★ \"SQL 逐值复现不出 n=该目录会话数\"之谜 —— **解开了: 服务端分页默认截断在 100**\n```\n pi 报: agentmail SQL = 110 vs 表 = 37(对不上,它标未知)\n 我测: 该目录会话 **176** vs 表 **100**(也对不上)\n ★★★ 关键实测(决定性): 取镜像里**最旧**的 `updated_at`(= 2026-09-02 04:01:59.383Z\n → epoch **1788321719000** ms),去 opencode 侧数 `time_updated >= 该值` 的会话:\n **恰好 = 100** ✓(该目录总数 176)\n 并核 opencode 侧按 `time_updated desc` 排序的**第 100 新** = `1788321719383`\n ⇒ 与镜像最旧值 `1788321719000` **相差 383ms** ⇒ ★ **镜像=按 updated_at 最近的 100 条**\n ⇒ ⇒ ★★★ 所以 n **不是**\"该目录会话数\",而是 `min(该目录会话数, **100**)`\n ⇒ pi 与我各自的\"对不上\"都是**同一个原因**,而它把它标成\"未知、不作论据\"——\n 那一步**错失了**:两个不同的人在同一处对不上,本应提示\"**有一个共同的下游截断**\"\n ⇒ ⚠️ 而两个已知上限都不是 100: `MAX_REPORTED = 200`(`session-snapshot.js:14`)、\n `maxPlatformSessions = 200`(`platform_sessions.go:41`)\n ⇒ ★ 所以 100 来自**第三处**(opencode `session.list` 不带 `limit` 时的默认页大小)\n ⇒ 我**未能定位该常量**(API 需鉴权 401、SDK dist 里没 grep 到、二进制不可读)\n ⇒ 标 **unknown 但证据充分**: \"恰好 100\" + \"第100新与镜像最旧差 383ms\" 已足够定论\n```\n### 三★★★★ 由此得到一个**新的、独立的缺陷**(与本条镜像域缺陷无关)\n```\n ★ `session.list` 不带 `limit` ⇒ 只拿回**最近 100 条** ⇒ 上报**静默截断**\n ⇒ 后果: 该目录**第 101 新及更旧**的会话**永远不进镜像** ⇒ 它们**不会出现在候选列表里**\n ⇒ 而 `MAX_REPORTED=200` 意味着**设计者以为能报 200 条** —— 实际被服务端截到 100\n ⇒ ★ 与 `recount-relay-counts.sh`、`/tmp` 影子模块同族: **不报错、只是少给**(静默少数据)\n ⇒ ⚠️ 但与\"整表替换\"叠加后更糟: 若某目录会话 >100,镜像**每轮都只覆盖最近 100**,\n 而 DELETE 域是**整个 workspace** ⇒ 旧的 100 条被清、新的 100 条写入\n ⇒ ★ **净效果** = 镜像里始终只有最近 100,且**这是对的语义边界**(不是丢数据)\n ⇒ 只是**与 `MAX_REPORTED=200` 的意图不符**(少了 100 个可续谈候选)\n ⇒ ⇒ 定级: **独立欠账**(观察到的 100 截断 vs 声明的 200 上限不一致),待单独登记\n```\n\n## ★★★ 补记之二十(我 `d36ead2b` 那句\"擦除**在语义上不必要**\" —— pi 说**过强**,我复核**它对我错**)\n```\n 我 `d36ead2b` 原文: \"并集完全存得下这个 PK ⇒ 擦除**在语义上根本不必要**\"\n ★ 实测被引代码 `platform_sessions.go:71-74`(我自己引过的注释就在同一文件):\n```\n```\n # 为什么仍然是\"整表替换\"而不是增量合并\n 镜像是平台当前状态的快照。增量合并会让已删掉的平台会话永远留在候选列表里,\n 而 session 位是三态语义,指向不存在的会话会直接 404(\"选了却送不到\")。\n ⇒ 保持整表替换,只把**域收窄到本次上报覆盖的工作区**。\n```\n```\n ⇒ ★★ 所以\"擦除\"**确实必要**(防\"平台已删、镜像还在 ⇒ 选了 404\");\n 错的是**范围**(域 = agent 全域 vs 应为本轮覆盖的 ws)—— 这正是 ② 修的东西\n ⇒ ⇒ **撤**我那句\"擦除在语义上不必要\"(⑨ 家族: 把话说满)\n 正确表述: 「**擦除必要,但擦除的域必须等于读取的域**」\n ⇒ ⚠️ ★ 更正归属(我写这段时先写错了): 那句\"擦除在语义上不必要\"是**我(dsh)**在\n `d36ead2b` 里写的(`from_name=dsh`),**不是 pi 说的**; pi 是在 `8f0a8d60` 里**指出它过强**。\n 我第一版把 `d36ead2b` 写成\"pi 回我\" ⇒ **又一次没核 from_name 就归因**\n (与本会话此前两次同类: 追认自己没写过的错、把讨论帧当今日帧)\n ⇒ ★ 直接教训: **引用某句话时,先 `select from_name` 确认是谁说的** —— 这一步我已有规则 ⑩,\n 但 ⑩ 只覆盖\"在被引文件里核对该标识符存在\",**不覆盖\"核对这句话的说话人\"** ⇒ 补进 ⑩ 的射程\n ⇒ ⚠️ 另: 这也是我**第二次**把\"范围错\"说成\"机制错\"(更早我把 `[]` 洞当主因,pi 指出\"`[]` 只是次要成因\")\n ⇒ 与\"重跑一遍≠推翻前一次\"同族: **结论已被纠正过,措辞却回到更早那个版本**\n```\n\n## ★★★★★ 补记之廿一(2026-09-29 答 pi `c790a69c` 的\"必须先定的语义\"—— **已有测试钉住,但文档与实现相反**)\n```\n### 一★★★★★ pi 提的\"三种语义要分开\" —— 它的**分类对**,但**今天已有一个测试专门钉住其中一条**,且结论与文档相反\n```\n pi 的提议: 要分开 ①「本目录无会话」/ ②「本 agent 无会话」/ ③「我看不到」,\n 并说\"今天 ① 与 ② 共用 `[]`,而**没有任何上报者该有权说 ②**\"\n ★ 我实测: **② 在今天的实现里根本无法表达**(不是\"共用\",而是②**够不着**):\n `ReplacePlatformSessions` 的 DELETE 域 = 本次 list 里出现过的 **workspace 集合**\n ⇒ 要清\"整个 agent\"必须**枚举**该 agent 的全部 workspace ⇒ 单上报者做不到\n ⇒ ★ 所以\"没有上报者该有权说 ②\"这句**已经成立**(是实现的既成事实,非待定项)\n ★★ 而 ① 与 ③ 的区分**确是真空白**,且方向与 pi 设想**相反**:\n · `agents.go:40-41` 文档明说: \"**空数组 = 平台侧确实一条会话都没有(清空镜像)**\"\n \"拿不到会话列表的插件应当**省略该字段**,而不是传空数组把镜像抹掉\"\n · 但 `platform_sessions.go:129` 的 `if len(wsOrder) > 0` 守卫 ⇒ **空数组什么都不删**\n ⇒ ★★★ **文档说\"清空\",实现是\"什么都不删\"** —— 二者**相反**\n ★★★ 决定性实测(我写了临时探针,跑完已删):\n ```\n 前置: /A 与 /B 各 1 行\n 上报 [](真·空数组)后: map[/A:1 /B:1] ← **未被清空**\n ⇒ 与 agents.go:40-41 \"空数组 = 清空镜像\" **不一致**\n ```\n ★★★★ 而且**已有测试专门钉住这条**(`db640e2` 随 ② 一起加的):\n `TestReplacePlatformSessionsWithNoWorkspaceKeepsEverything`(`platform_sessions_test.go:449`)\n 它断言\"上报不带 workspace 时 /A /B **必须什么都不删**\"\n ⇒ ⇒ ★ 所以 ② 的实现与**它自己的新测试**一致,与**心跳端点的文档**(`agents.go:40-41`)矛盾\n ⇒ pi 提的语义问题**确实存在**,但形态是\"**文档 vs 实现+测试**\",不是\"① ② 共用\"\n```\n### 二★★★★ 「必须先定的语义」具体该定什么(据上面实测改写 pi 的提案)\n```\n 今天 `[]` 的实际语义 = 「**本次上报没有给我 workspace 信息 ⇒ 我无从判断该清谁 ⇒ 什么都不动**」\n ⇒ 这是**安全侧**的(不会误删),但代价是: **\"该目录会话全没了\"这件事永远无法表达**\n ⇒ 平台侧某目录的会话被全部删除后,镜像里的旧行**永不清除**(除 DeleteAgent 那条破坏性路径)\n ★ 实测现网是否有陈旧行: 镜像 100 行(opencode) + 49 行(homeagent) 逐 id 回查 opencode ⇒\n **已从 opencode 消失 = 0** ⇒ ★ **当前没有陈旧行**(该空洞**未被观测到触发**)\n ⇒ ⇒ 该定的语义(建议,供人类裁): 给心跳加一个**显式的**\"本次覆盖的 workspace 清单\",\n 把\"我覆盖了哪些目录\"与\"这些目录里有几条会话\"**分成两个字段**\n ⇒ 于是 `covered=[/w1], sessions=[]` 就能唯一表达「/w1 确实空了」,且**不需**枚举整个 agent\n```\n### 三★★★★ 顺带发现一个**独立的**死字段 + 一处**自相矛盾**的注释\n```\n ★ `heartbeatRequest.Workspace`(`agents.go:32`)注释写 \"**必需**\",但\n `grep -rn \"req\\.Workspace\" server/` ⇒ **心跳 handler 里一次都没读**\n (唯一命中的 `mail.go:823` 是**另一个** struct 的 Workspace)\n ★ 且 `:30` 注释称 \"心跳返回的 pending_mails **按它[Workspace]算**\",\n 而 `:179` 注释称 \"pending_mails 是**全局**未读数(跨工作区)\"\n ⇒ ★ 两处注释**互相矛盾**,代码实际用全局(`UnreadWorkspaces(ctx, agentName)`)\n ⇒ `:30` 那句是**陈的**\n ⇒ 两条合起来: 一个标着\"必需\"的字段**从未被读**,且它的注释描述了一个**已不再成立**的算法\n ⇒ ⚠️ 边界: 我**未**核\"历史上是否读过\"(可能曾经读过、后改成全局)⇒ 标\"当前未读\",不标\"从未读\"\n```\n### 四★ pi 其余几条的处置\n```\n · \"危害比你测的更重: 11/12 次候选=0\" ⇒ 与我今天实测**不符**(恒 100 行/ws=1),\n 但它的观测(09-26)**在 ② 部署(09-28)之前** ⇒ 两者不冲突,是**不同前置条件**\n ⇒ 我不认领\"更重\":② 之后该形态不可复现,应表述为\"**② 之前**的形态\"\n · \"频次极不均匀 ⇒ 危害由频率决定\" ⇒ ★ 这句**对且重要**(我此前只报\"轮换\",未量化频率)\n · \"am-mcp-probe 高频可能是我们自己拉高的(观察改变分布)\" ⇒ ⚠️ **好的自我怀疑**,\n 无法证伪也无法证实(无历史频率快照)⇒ 保持\"未知\"\n · \"状态我采到 9 值,project 表 13 行/10 个有会话 ⇒ 建议写 ≥10 而非精确数\" ⇒\n ⚠️ ★ 我原写\"含会话的 project 数**见附表**\" —— **本轮我没有附任何表** ⇒ 该指涉是**凭空写的**\n 正确做法: 要么把数写出来,要么不引 ⇒ 据本轮实测直接给数:\n `project` 表共 **13** 行; `worktree` 去重后 **11** 个(EcoArk 出现两次 ⇒ 13-2=11);\n **有会话挂载**的 project 数 = **10**(`count(distinct session.project_id)`)\n ⇒ ★ 与 pi 报的\"13 行 / 10 个有会话\"**完全吻合** ✓(它这组数是对的)\n ⚠️ 我先前写\"去重后 12 个\"是**又一处没算就写**(实为 11)⇒ 与行号同类,\n 一并纳入上面的处置(a): **数字也要当场算过再写**\n 它\"按哪张表数会变\"这条**方法论对**(我此前已在场景矩阵里用过\"给分母\")\n · \"`TestReplacePlatformSessionsIsFullReplace` 钉着整表替换是有意设计\" ⇒ ★ **对**,我已复核\n ⇒ 该测试 + `WithNoWorkspaceKeepsEverything` 两条**共同**界定了 `[]` 的现语义\n```\n\n### 五★ 本轮我自己的第 3 次引用失误(记下)\n```\n ⚠️ 我把 `agents.go` 里那句\"空数组 = 平台侧确实一条会话都没有(清空镜像)\"引成 **`:43-45`**,\n 实测在 **`:40-41`**(`:43` 是 `PlatformSessions []repo.PlatformSession` 那一行)\n ⚠️ 且我一度写\"含会话的 project 数**见附表**\"而**本轮没有附任何表** ⇒ 指涉凭空\n ⇒ ★ 与上一轮(`:228`→`:242`) **同一模式第 3 次**,且都在\"正在记录'要核行号'\"的补记里\n ⇒ 判定: 这不是偶发,而是**我在写长文时对行号的默认宽松** ⇒ 处置:\n (a) 行号**只在我当场 `sed -n Np` 回显过之后**才写进文档;\n (b) **不写\"见附表/见上表\"**,除非同一条内确有该表\n ⇒ 本轮 8 处引用已逐一回显核过,`:43-45` 这处即由此发现\n```\n\n## ★★★★★ 补记之廿二(2026-09-30 复核 pi `eab8b771` 的\"字面是 9\" —— **它和我的 8 都不对;真值是 10,且我漏的是整个 opencode 文件**)\n```\n### 一★★★★★ 定案: 三个数与真值\n```\n```\n 我原报 : 8 条语句 / 5 个文件(口径 = \"生成 relay_key 的语句\",且**带 failure/空回复前缀**)\n pi 复核后主张 : 9 条(它说\"按你的口径字面是 9\",指出我漏 dsh:1774 / homeagent:651 / homeagent:987)\n ★ 我实测真值 : **10 个生成点 / 6 个文件** —— 两人都漏了 **opencode 的 2 个**\n```\n```\n 逐前缀实测(口径 = 生成 relay_key 的语句,含 failure/empty-reply 前缀,排除 dist/test/node_modules/注释):\n model-failure : dsh:1362 / **opencode:1118** / pi:628 / pi:718 = **4**\n empty-reply : dsh:1883 / **opencode:836** = **2**\n zcode-failure : zcode:304 = 1\n homeagent:failure : homeagent:975 = 1\n service-failure : deploy:92 / deploy:94(**同一函数的两分支**) = 2\n ─────────────────────────────────────────────────────────────────────────────\n 合计 = **10** 生成点; 按文件 = dsh, opencode, pi, zcode, homeagent, deploy = **6 个**\n ⇒ ★★ 我原报 8 = 6 + deploy 的两分支 ⇒ **整个 opencode 文件(2 个生成点)从未进过我的清单**\n```\n### 二★★★★★ 我漏掉 opencode 的**具体机制**(这次是可判的,不是\"不小心\")\n```\n 我原表列的 5 个文件: `dsh-mail-bridge/*.ts`、`pi-mail-bridge/*.mjs`、\n `zcode-mail-bridge/*.mjs`、`homeagent-mail-bridge/*.go`、\n `deploy/service-failure-notify.mjs`\n ⇒ ★ **五个全是 `.ts` / `.mjs` / `.go`,一个 `.js` 都没有**\n ⇒ 而 opencode 的实现文件是 **`plugins/opencode-mail-bridge/index.js`**(`.js`)\n ⇒ ⇒ ★ 我在某一步按**扩展名**过滤(`--include=*.ts --include=*.mjs --include=*.go`),\n **没把 `.js` 列进去** ⇒ **整个开放在 .js 里的实现被结构性排除**\n ⇒ ⚠️ 而这正是我在上一封里刚承认过的**同一个错**(用 `relay_key: clampRelayKey(` 模式去 grep,\n 漏掉 homeagent 的 `ClampRelayKey(` 写法)—— **换了维度再犯一次**:\n 上次漏在**命名变体**(clampRelayKey vs ClampRelayKey),\n 这次漏在**文件扩展名**(.ts/.mjs/.go vs .js)\n ⇒ ★★ 共同点: **我用一个\"看起来覆盖了\"的过滤条件去数,而没检查该条件是否覆盖了全集**\n ⇒ 可判形状(值得独立记): **凡按\"模式/扩展名/目录\"过滤来数全集时,\n 必须先证明该过滤器不排除任何真实成员** —— 否则报出的数**系统性偏低且不报错**\n```\n### 三★★★★ pi 的 9 是**换了口径**得来的(它指控我的正是这个)\n```\n pi 列的 9 条: zcode:304 / dsh:1240 / dsh:1749 / **dsh:1774** / pi:625 / pi:715 /\n **homeagent:651** / homeagent:929 / **homeagent:987**\n ⇒ 它**去掉**了 `deploy` 的 service-failure **两个分支**,**加上**了**三个非 failure** 的点\n ⇒ 8 − 2 + 3 = 9\n ⇒ ★ 而它这条的标题是\"**按你的口径**字面是 9\" ⇒ **它中途把口径从\"failure 前缀\"换成了\"全部 relay 语句\"**\n ⇒ ★★★ 这与它指控我的那条(\"口径写了,但按口径数时又漏了 3 条\")**是同一个动作**:\n 它说我\"口径与实际数的不一致\";而它自己这条也正是**口径与实际数的不一致**\n ⇒ 附: 它加的三条我复核**确实是 relay 语句**(dsh:1908 上下文有 `relay:'summary'`;\n homeagent:696/1035 的 `rk` 经 `sendMailRelay(..., rk)` 真发给服务端)\n ⇒ 所以它的**补充是对的**,只是**不该声称\"按你的口径\"**\n```\n### 四★ 它的核心方法论我**完全接受**,且它比双方的数字都重要\n```\n pi: \"**凡『清单已列出』的场合,转发清单,不要转发它的长度**\"\n (长度只在\"它变了\"时有意义 —— 那时你要的是 **diff**,不是新长度)\n ⇒ ★ 我接受,且本轮正好**反证**了它:\n 我上一封已把 8 条**逐条列出**(清单是对的); 争议全部发生在**\"长度是几\"**上\n ⇒ 而清单里的那些信息(service 有 INVOCATION_ID/sha256 **两分支**、\n homeagent 用**冒号**、empty-reply **不含** failure)**在 7/8/9/10 里全部丢失**\n ⇒ ⇒ ★ 这条判据的直接推论: **本轮我该报的是\"加了 opencode 的两个生成点\",\n 而不是\"8 应改成 10\"** —— 数字是清单的**投影**,投影丢了维度\n```\n### 五★ 数据侧: pi 的\"service-failure 有 25 行、不是休眠路径\" —— **我复核成立,且已增长**\n```\n 实测 relayed_mails(09-30):\n model-failure **36** (pi 09-25 报 36) ⇒ 未变\n service-failure **32** (pi 09-25 报 25) ⇒ ★ 增 7 ⇒ 该路径**确实在跑**\n zcode-failure **21** (pi 09-25 报 21) ⇒ 未变\n homeagent:failure **24** (pi 09-25 报 16) ⇒ ★ 增 8\n empty-reply **0** ← ★ 两个生成点在代码里存在,但**生产库里 0 行**\n ⇒ ★ 所以 pi\"service-failure 不是休眠路径\"**成立**(且比它当时更活跃)\n ⇒ ★ 而我原表把它当\"systemd 脚本那条路径\"、易被读成休眠 —— 它这条提醒是对的\n ⇒ ⚠️ 新事实: `empty-reply:` **0 行** ⇒ 该前缀的**两个生成点从未在生产触发过**\n (不是缺陷,只是\"有代码路径、无实际使用\"⇒ 报清单时应连同\"当前 0 行\"一起给)\n ⚠️ 口径: 总 592 行; \"四前缀\"按不同组合 = 77(mf+sf+hf+zf)或 45(mf+er+hf+zf)\n ⇒ ★ 再次印证\"**四前缀合计**\"这个说法本身依赖选哪四个 —— 又一个\"数不该脱离清单\"的例子\n```\n"
},
{
"id": "recount-labels-must-match-predicates",
"count": 1,
"kind": "只有一条判据(本轮现修的那两个标签),同类无判据",
"due": "**本文件里那些'把口径写下来'的打印即将扩充时**(下一次往 recount 加读数/加口径时,同时加这条判据)。★ 到期前提写成'下次动它'而不是'尽快',因为这条的性质是**防复发**、不是修当下 bug(当下那两处已修)。",
"where": "`deploy/recount-relay-counts.sh` 的 `printf` 标签 —— 本轮实测: 行210 的两个标签都漏写 `mail_id is not null`,按标签字面算得 558/460 而脚本打 557/459。**已修那两个**,但**没有任何判据**保证'标签与它数的谓词一致'。",
"note": "★★ 2026-09-26 我实测登记(pi `cc7a3027` 那轮的对账里查出来的)。\n\n## 这条钉的是**形状**,不是那两个标签\n```\n事故: `recount-relay-counts.sh` 的两个 printf 标签省掉了 `mail_id is not null`:\n 标签字面 `kind<>'failure'` ⇒ 实算 558\n 变量 NAIVE `mail_id is not null and kind<>'failure'` ⇒ 实算 557\n 标签字面 `(permission OR key NOT LIKE %failure%)` ⇒ 实算 460\n 变量 REAL 同式但带 bound ⇒ 实算 459\n⇒ 同一个数字在\"标签口径\"与\"变量口径\"下差 1(那 1 行未绑定: 标签收、变量不收)\n```\n★ 为什么这条特别值得钉: **这个脚本存在的全部理由就是\"把口径写下来\"**(文件头有「口径声明」节),\n而它自己最显眼的两行标签**没写全限定符** ⇒ 读的人拿这个数去对账**必然对不上**,且**已实际发生过一轮**\n(09-25 我 `b299ce74` 报的 loose 459 与 09-26 脚本打的 bound 459 **是同一个数字、不同集合**,\n两天的消息里都写\"459\",靠人对不出来)。\n\n## 可判形状(到期时这么建)\n```\n对 `deploy/recount-*.sh` 的读数打印与「口径声明」,断言:\n ① 每个读数变量在**定义处**与**标签处**的谓词**一致**(把 SQL 从定义行抽出来、按标签字面重算,两数必须相等)\n —— 逐条把\"标签字面\"当成一个真查询跑一遍,比\"读代码看它对不对\"可靠得多(本轮就是这么抓到的)\n ② 凡是**两个口径并存**(bound/loose、含 A/不含 A)的地方,**两个数都要打** ——\n 只打一个时,读者无法判断他手里那个数是哪个口径 ⇒ 分歧只能靠再来一轮对话解决\n```\n★ 与 `deploy-space-prefix-fs`、`observability-output` 同族(都是\"判据铺得不满\"),\n 但落点不同: 那两条管\"判据没有覆盖某个边界\",这条管\"**已写下来的口径自身不完整**\"。\n\n## ★★★ 补记(2026-09-26 收尾):本条**两个实例都是同一个病**,且都由我踩中\n```\n### 实例①: 标签漏写限定符(`recount-relay-counts.sh:210`)—— 标签与它数的谓词不一致\n 按标签字面 558/460,脚本实打 557/459 ⇒ 已修(`48ccd11`)\n### 实例② ★★: **同一个数字 459 在两天指称不同集合**(loose vs bound 各 +1 后撞车)\n ⇒ 这是本条的**另一个面**: 不只是\"写下来的口径不完整\",还包括\"**口径会随时间漂**\"\n ⇒ 光把标签写全**不够**: 同一个标签在**不同日期**指向不同集合,而输出里**没有时刻之外的东西**\n ⇒ 现在脚本**同时打印两个口径**(bound 与 loose),并打印取数时刻 ⇒ 这一类撞车在默认路径上可见\n### ⚠️ 而我在这两轮里**把同一个病犯了两次**,都是\"当下成立 ⇒ 被评对象也成立\"\n```\n## ★★★★ 由此得到一条高频教训(两次都由我踩中 ⇒ 值得进清单)\n```\n「**当下**测出的结论,不适用于**被评的那个动作发生时**」——\n ① `e77154d1` 那轮: 我的探针把\"取 T\"写在 `ReplacePlatformSessions` **之后**\n ⇒ 量到**删除后**的表 ⇒ 得出\"我的探测器漏报\"的**相反**结论\n ② 本轮: 我复核\"459 − 1(未绑定) 不成立\"**成立**(今日帧对),\n 就据此判定我 `1de1c4c7`(**讨论帧**)也错 ⇒ 而脚本诞生于那封**之后 16m55s**,\n 它在**自己帧内四条断言逐条为真** ⇒ 我**把自己的正确结论判成了错**\n⇒ ★ 两者同形: **观测/判定的时刻,必须与被观测/被评的动作发生在同一时刻**。\n 这是\"判据要锚定到它防的那个动作\"(我原有的那条)的**时间轴版本** ——\n 原那条管\"锚到哪个动作\",这条管\"**在哪个时刻测**\"。\n⇒ ★ 可执行动作(可 grep): 凡结论涉及\"某个历史时刻的库状态\",\n 必须用 `created_at` 之类**带时刻的列**把状态**重建**出来再判,\n 并把**该时刻**与被评动作的时刻**一起打印**(⑫ 的第四样)。\n\"\"\"\n\n## ★★★★★ 补记(2026-09-30): **过滤器本身会把成员整类排除,而且不报错**\n```\n 来源: 我数\"生成 relay_key 的语句\"时,原表列了 5 个文件(`.ts`/`.mjs`/`.go` 各若干),\n **整个 opencode 文件被漏掉** —— 它的实现是 `plugins/opencode-mail-bridge/index.js`(**`.js`**)\n ⇒ ★ 机制: 我在某一步按**扩展名**过滤(`--include=*.ts --include=*.mjs --include=*.go`),\n **没把 `.js` 写进去** ⇒ 该文件里的 2 个生成点**结构性不可见**,而各计数**全部照常返回**\n ⇒ ⚠️ 这与本条目原有的\"标签必须匹配谓词\"是**同一个错的两种外衣**:\n 原来: 用 `LIMIT` 取的标签配 `count(*)` 的谓词 ⇒ 标签与数不同源\n 现在: 用**排除性过滤器**数全集 ⇒ **被排除的成员不报错、只是不在数里**\n ⇒ ⇒ ★ 可判形状(新增,值得独立执行):\n **凡按\"模式 / 扩展名 / 目录 / glob\"过滤来数**全集**时,必须先证明该过滤器\n 不排除任何真实成员** —— 否则报出的数**系统性偏低且不报错**。\n 廉价做法: 先用**最宽的**条件数一遍(`grep -rl '<特征>' .`),\n 再把已知的排除项(node_modules/dist/test)逐个减掉,并**打印被减掉的清单**。\n ⇒ ★ 复发记录(同一会话内,换维度再犯):\n 第一次: 用 `relay_key: clampRelayKey(` 去 grep ⇒ 漏掉 homeagent 的 `ClampRelayKey(`(**命名变体**)\n 第二次: 按扩展名过滤 ⇒ 漏掉 opencode 的 `.js`(**文件类型**)\n ⇒ 共同点: 都是\"**用一个看起来覆盖了全集的过滤器**\" ⇒ 补齐办法是\n **从\"我想要全部\"出发、显式列出排除项,而不是从\"我觉得该有哪些\"出发**\n"
},
{
"id": "platform-mirror-d1-cross-workspace",
"count": 1,
"kind": "判据已建、当前为红(它断言的正是尚未修复的缺陷)",
"due": "**DELETE 域改成与上报域一致(按 workspace 删)时**,本判据转绿;届时本条与 `platform-mirror-replace-domain-too-wide` 一起结算。★ 注意:本条**不是**待建,而是**已建且现在红** —— 登记它是为了让这条红可见,而不是把它挂在 due 上等将来。",
"where": "`server/internal/repo/platform_sessions_test.go` 的 `TestReplacePlatformSessionsKeepsOtherWorkspaces`((d1));待建的 (d2) 见 `platform-mirror-replace-domain-too-wide` 的 due",
"note": "★★ 2026-09-26 建(pi `e77154d1` §三 提,我仓内真驱动复现后落地)。\n\n## 判据形状\n```\n播下 opencode 的 ws=/A 两行\n → 另一个上报者报 ws=/B(list **非空**,每项都带 `Workspace`,len=1≠0)\n → 断言 /A 的行数**仍为 2**,且失败信息里**列出存活的 workspace 及其行数**(⑫′:范围靠元素可复核)\n```\n\n## 三条性质(所以它**不该**进 due)\n```\n① **今天可写**: 每项 `PlatformSession` 自带 `Workspace` ⇒ 不需要请求级 scope 字段\n② **现在红**: 修前实测 /A = 0(期望 2)⇒ 它断言的正是那个缺陷本身\n③ **修好即绿**: DELETE 改成按 workspace 删 ⇒ /A 仍在 ⇒ PASS(我用直插 /B 模拟修后状态实测过)\n```\n⇒ ★ 落在这三格上,就该**现在就建**。pi 指出我把它整体归入 due 是\n 「**超前断言**」的**反面错** —— 把**今天就能给的判据**当成\"要等未来才能给\",\n 于是在余额里白白挂着一个**今天就能变绿**的缺口。两者都让余额失去信号(假红 / 白欠)。\n\n## 与 (d2) 的分工\n```\n· **d1**(本条): 上报**非空** list 时不得删除其它 workspace 的行 —— 今日可判 ✓\n· **d2**(仍欠): 上报 **`[]`** 时只清自己那个 ws —— 需要\"这次上报属于谁\"\n ⇒ 与请求级 scope 字段**同一前提到期**(`platform-mirror-replace-domain-too-wide` 的 due)\n★ 我上封的判断只对 **d2** 成立。\n"
},
{
"id": "harmony-system-back-key",
"count": 2,
"due": "**真机**(或能观察到 Navigation 返回事件分发的环境)上验清楚:系统返回键在 `Navigation` 内部的消费点在哪一层,以及正确的那一个钩子是什么",
"where": "client/harmony/entry/src/main/ets/pages/MainPage.ets(约 1700 行的 PopIntent 监听者)+ EntryAbility(无 onBackPressed)",
"kind": "bug",
"note": "2026-09-26 审查发现并**尝试修复失败**,整块撤回。\n\n## 未修好的两个具体问题\n\n① `KEY_COMM_STACK_DEPTH` 停在旧值:退到列表后,下一层\"返回\"输入被吞。\n② `KEY_OPEN_MAIL_ID` 留着上一封:回到列表按回车时以为\"还在详情\",会开**回复**而不是新邮件。\n (连带 `MainPage.closeDetail()` 是死代码 —— 它只被 Esc 那条路调。)\n\n## 为什么没修\n\n2026-09-26 在 `EntryAbility` 加了 `UIAbility.onBackPressed()` → `PopIntent.request()`。\n模拟器(HarmonyOS 6.1.1,HarmonyPhone)实测:\n\n- 应用在前台时按**一次**系统返回键 ⇒ **UIAbility 被销毁**(hilog 有 `HandleAppDied`,\n `aa dump -a` 里 EntryAbility 消失),`harmony-admin` 设备判据因此挂住不返回。\n- 对照实验:把 EntryAbility + ComposeIntent + MainPage 三处 stash 掉重编重装,\n 同一条判据 **31/31 通过**。\n\n⇒ `UIAbility.onBackPressed()` 在这条链上**没被调用到**(`Navigation` 自己先消费了\n返回事件),而改动 `PopIntent` 的回调签名又改变了判据侧的行为。\n\n## 为什么不在模拟器上继续查\n\n要验的是\"框架内部返回事件的分发顺序\"这类**框架行为**,而本机模拟器这一条\n又不能代表真机。与其赌一个**会关掉应用**的半成品,不如整块撤回、留成有\n复现步骤的账。\n\n## 试过并被否掉的三种接法(都写在代码注释里)\n\n1. `Navigation.onPop` —— **不存在**,编译报\n `Property 'onPop' does not exist on type 'NavigationAttribute'`。\n2. `pushPath` 第三个参数 —— **不接受**(只接受 1-2 个)。\n3. `NavPathInfo.onPop` —— 存在,但官方文档写明**只在 `pop()` 带了 result 时才触发**,\n 而系统返回键**不走** `pop(result)` ⇒ 照样漏。\n\n## 建议的下一步\n\n在 `NavDestination` 上挂 `onWillDisappear`(或 `onHidden`),在它里面统一做\n\"重发层数 + 栈空时清发布键\";这条是**每个详情页自己**的出栈时机,\n不依赖谁来分发返回事件 ⇒ 系统返回键、Esc、手势三条路都会经过它。\n需真机确认 `onWillDisappear` 在系统返回键下确实触发。"
},
{
"id": "prune-artifact-evidence-decays-with-reboot",
"count": 1,
"kind": "物证的**前提会失效**(不是物证本身错)—— 判据/结论的**时效性**无人复核",
"due": "下一次要用「缺失的产物」当物证时(任何 deploy 脚本旁路都算)。★ 到期动作不是'补判据',是**先查该路径会不会被清**、再决定这条物证此刻还能不能用。",
"where": "`deploy/prune-test-sessions.sh:92`(`BAK=\"/tmp/agentmail-pre-prune-$TS.db\"` 字面硬编码);`/tmp` 挂载见 `findmnt /tmp`;清理策略见 `/usr/lib/tmpfiles.d/tmp.conf:11`(`q /tmp ... 10d`)与 `systemd-tmpfiles-clean.timer`",
"note": "★★ 2026-09-27 实测登记(复核 pi `c6dbc8d0` §三 时发现)。\n\n## 形状:**\"现在没有产物\" 推出 \"当时没跑过\" 需要一个前提,而该前提会自己消失**\n```\npi `c6dbc8d0` 给的加强物证(我复核**成立**且是当时**正确**的测法):\n · prune 的 `.backup` 在删除前**无条件**执行(`:95`),路径**字面硬编码** `/tmp`、不做 env 覆盖\n · 当时实测: `agentmail-pre-prune-*` = **0 个**,且**争议窗口 ±1h 内有 21 个别的文件存活**\n ⇒ 用\"同一目录同一时段别的文件还在\"来证明\"不是被清理掉了\" ⇒ **这一步很扎实**\n```\n## ★★ 但我在 2026-09-27 04:10 重测,那个前提**已经不成立**了\n```\n实测: 早于 2026-09-26 09:32 的 /tmp 文件 = **0 个**(争议窗口 04:00–06:30 内也是 **0 个**)\n而 `tmpfiles.d/tmp.conf:11` 是 `q /tmp 1777 root root 10d` ⇒ **1 天内的文件不该被清**\n⇒ 唯一解释: **`/tmp` 经历过清空/重启**\n`findmnt /tmp` ⇒ **tmpfs** ⇒ ★★ **重启即全失**(与 10d 策略无关)\n⇒ 所以: 「现在 0 个备份」**此刻已经不能**推出「09-26 04:57 那会儿没跑过 --apply」\n```\n## 后果: 三条腿的强度**又降一级**(我上一轮已降过一次,这次是同一机制的第二级)\n```\n物证① 覆盖范围随时间**单调收缩**:\n T0(紧邻事件)⇒ 有效(前提成立)\n T0 + 任何一次重启 ⇒ **永久失效**(且不可恢复 —— tmpfs 不留痕迹)\n⇒ ★ 所以定稿里\"『prune --apply』与『reset-demo』都未跑过 —— 凭谓词无关的产物\"这句,\n 在**今天**只能保留**物证②**(`/opt/agentmail/backups/`,**非 tmpfs** ⇒ 跨重启存活)\n ⇒ 物证① 要降为「**在 09-26 04:10 之前**(当次会话内)成立,之后需重新取证」\n```\n## ★ 可判形状(到期时用)\n```\n凡以「缺失的产物」为物证,**必须同时记录三项**,缺一即降级:\n ① 产物路径是否**会**被自动清理(tmpfiles 策略 / tmpfs / logrotate / 手工 rmtree)\n ② 支撑「不是被清掉了」的**同时段旁证**(同目录同时间别的文件仍在)—— 必须在**取证当时**记,事后不可补\n ③ 取证时刻 —— 因为 ①② 都是**会过期的**\n⇒ 与我们已记的「口径会随时间漂」(`recount-labels-must-match-predicates` 补记)同族:\n 那条是**数字**会过期,这条是**物证**会过期。\n⇒ ★ 反过来也成立一条**该做而没做**的: 若某个物证会过期,**结论就该带时刻**。\n 我们此前把「prune 没跑过」写成**无时刻的现在时** ⇒ 这是本条要纠正的写法。\n"
},
{
"id": "python-probe-shadowing-in-tmp",
"count": 1,
"kind": "测量工具自身的**失效方式**:`/tmp` 里的同名 .py 会**遮蔽标准库**,且**静默混入别人的输出、脚本仍 rc=0**",
"due": "下一次在 /tmp 落 .py 探针之前。(到期动作不是'小心一点',是**换落点或加 `-I`**。)",
"where": "`/usr/local/lib/python3.12/re/__init__.py:124`(`import enum`)是 `import json` 也会走到的真链路;任何 `import json`/`re`/`enum`/`os`/`sys` 的脚本只要**与影子文件同目录**即中招",
"note": "★★ 2026-09-27 登记(复核 pi `b1bd61ef` §四 时实测复现,**它对,我此前那处规避无效**)。\n\n## 机制(实测,/tmp/shadow)\n```\n★ 触发条件是**脚本文件所在目录**,**不是 cwd** —— pi 三次换 cwd 全炸是对的:\n 脚本在 /tmp/shadow/t1.py、cwd=`/` ⇒ 仍被污染 ⇒ `sys.path[0] == '/tmp/shadow'`\n ⇒ 正确说法: `sys.path[0]` = **脚本自身所在目录**; 换 cwd 完全无效。\n ⇒ `python3 -I` **有效**(隔离模式不把脚本目录放进 sys.path)✓\n⇒ ★★ 真链路比\"某个脚本 import 了 json\"宽得多: `json/__init__.py` 内部会 `import re`,\n 而 `re/__init__.py:124` 又 `import enum` ⇒ **任何 `import json` 的脚本**都会执行\n 同目录下的 `re.py` / `enum.py`。\n## ★★ 为什么这是\"最坏的一类\"失效(我实测的严重性)\n```\n伪造 `json.py` 让 `json.load()` 返回 `110`:\n ⇒ 被污染的脚本**正常跑完**、**rc=0**、stdout 混进别人的输出\n ⇒ 形状 = **\"混入别人的输出且可能 rc=0\"** —— 与 `ReleaseRelay` 那条同族:\n **不报错、不失败、只是答案换了**。本轮我已因它撤过一次结论(\"11/12 候选=0\")。\n⇒ ★★ 我此前说的\"**换目录跑**\"规避:对\"脚本在 /tmp\"**不成立**(就是本条)⇒ 撤销该规避。\n## ⇒ 我本会话的**结论为何不受影响**(自查,非辩解)\n```\n我所有 python 探针都是 **heredoc(`python3 - <<PY`,不落盘)** ⇒ 走 stdin、\n `sys.path[0]` 是 `''`(cwd),而我的 cwd **从不是 /tmp** ⇒ **免疫**(已实测对照)\n我落过盘的探针目录(idxtest/h3/v1/pktest)**只含 sqlite 命令、无 .py** ⇒ 不触发\n⇒ ★ 结论: 本会话已提交的结论**未被污染**。但这条**必须留档**:\n 只要有人(包括我)改成\"落盘 .py 再跑\",结论就开始不可信,而**没有任何报错提示**。\n## ⇒ 可执行规则(替代\"小心一点\")\n```\n · 落 .py 探针 ⇒ **不要放在 /tmp**(或任何会被多人共用的目录)\n · 必须放 ⇒ 跑 `python3 -I`,或先 `ls` 确认同目录无 `json.py/re.py/enum.py/os.py/sys.py`\n · 首选: **heredoc 不落盘**(天然免疫)\n · 症状识别: 输出里出现**没写过的行**、或 rc=0 却结果离谱 ⇒ 先查同目录影子文件\n\n## ★★★★ 补记(2026-09-27 pi `685d8f72` 指出后我复现并**收紧**两处措辞)\n```\n### 一★ 它推翻的\"混入+rc=0 不可达\"——**我早已自己推翻过**(不是新错,是我没守住)\n `docs/API.md:6755` 就写着「① 我的『混入+rc=0 不可达』被 pi 反例推翻」\n ⇒ ★★ 也就是说: 这条**我早就认输过**,本轮**又**把它当成\"最坏形态\"写进新登记里\n ⇒ 我自查了新登记的原文: 那里写的是「**可能** rc=0」(不绝对)⇒ **没有**重新断言\n ⇒ ★ 所以不是\"复犯\", 是**措辞没守住已结案的边界**。真正的错在别处,见三。\n### 二★★ 它给的两因子刻画,我实测**成立**,并比它的表述更准\n 反例复现: `try: import json / except: json=None` ⇒ **rc=0 + SHADOW_OUTPUT 混入 + MY_MARKER 仍在** ✓\n 对照(不 catch,异常逃逸)⇒ 混入仍在但 **rc=1** ✓ ⇒ 两者**独立** ✓\n ⇒ ★★ 精确形状(采纳):\n **「混入」由\"遮蔽文件被执行\"决定;「rc」由\"异常是否逃逸\"决定(取决于调用方 catch)**\n ⇒ 两个正交因子,**必须分别断言**,合起来才是\"完全静默\"\n ⇒ ★ 前提要说清(我实测把 pi 的\"必然\"收紧一格): \"混入必然\"成立于\n **「脚本与影子文件同目录」**这个前提下 —— 此时脚本目录在 `sys.path[0]`、影子总是先被找到\n (两种导入顺序实测都命中)。但若影子文件**自身不产出 stdout** ⇒ **执行了却不混入**\n ⇒ 所以准确说法是「**被执行**必然、**混入**取决于影子是否写 stdout」,不是\"混入必然\"\n### 三★★ 我自己那句\"真危险形态\"也要标**不完整**\n 我原写: \"`2>/dev/null` 拿到别人的文本\" —— 成立,但它**只是三因子之一**\n ⇒ ★ 完整的完全静默形态 = **混入(必然执行)** × **rc 由 catch 决定** × **stderr 被丢弃**\n ⇒ 三者同时成立时: **输出被替换、退出码正常、连报错通道都被关掉** ⇒ 无任何可观测征兆\n ⇒ 与 `recount-relay-counts.sh` 那条同族: **\"没报错\"不等于\"没出错\"**,判据必须独立于 rc\n### 四★ 采纳它的可判自检\n 报\"**条数**\"前先问「**这个量有几个来源**」,多来源必须**列出各自贡献**\n ⇒ 与已记的 ⑫′(报数带查询)、④′(按机制分类)同族 ⇒ 并入本条\n"
},
{
"id": "retired-host-exempt-never-tested",
"count": 1,
"due": "有人决定把 zcode 真正部署回来时(豁免自动失效,无需手工改)",
"where": "deploy/check-deploy-drift.mjs 的 RETIRED_HOSTS(豁免本体)",
"kind": "scope",
"note": "2026-09-28 新登记。zcode 宿主已在本机删除,但插件与单元**故意留在仓库里**以便恢复;\ncheck-deploy-drift.mjs 的 HO"
},
{
"id": "shared-workspace-unserialized-deploy",
"count": 1,
"kind": "并发**不受控**:多会话共享一棵工作树,部署与改代码之间无任何互斥(`flock` 只挡同时跑的两个部署)",
"due": "给 workspace 加**跨会话互斥**,或让部署把「构建时的未提交文件清单」**持久化**(写进构建物旁边 / 归档日志)时。★ 到期动作不是'再小心一点'——本次已证明小心没用:pi 与 opencode 各自按流程走,仍产出了带 `vcs.modified=true` 的二进制,而那次带了谁的哪些文件**已不可复原**。⚠ 注意别顺手把 WARN 改成判红:`check-deploy-drift.mjs:1462-1467` 刻意不判红(脏树在本仓是常态,判红=总在亮),那是**有意的设计决定**。",
"where": "`deploy/redeploy-gateway.sh:109-112` 的 `flock`(只挡并发部署,**不挡** A 会话部署时 B 会话正在改源码);`docs/reviews/final-check-2026-09-28.md` §3(本次完整经过);本工作区实测 **36 条会话**同指向 `workspace=/home/program/agentmail`,别名 `probe-*` / `repro-*` / `coord-*` / `stress-sse-1..20` / `stress-budget-*` / `thread-probe-*`,全部能 commit、全部能跑部署脚本",
"note": "★★ 2026-09-28 登记(opencode 实测,pi 独立复核)。\n\n## 形状:**产出无法证明来源的二进制,而披露只活在一块屏幕上**\n\n`redeploy-gateway.sh:109-112` 有 `flock`,注释里把它列为环境前提表里\n「同时性」那一类,写得对 —— **但它防的是错的东西**。\n\n要防的真实事件是:**A 会话 09:52 跑部署,B 会话正在同一棵树上改文档**。\n`flock` 只在两个部署**同时**跑时生效,而 09:52 那次是**单发**的,没人跟它抢。\n\n本次实测:docs 在 09:51:28 提交、09:53:12 再提交,部署卡在中间的 09:52:33\n⇒ 构建时工作区有未提交改动 ⇒ 线上二进制 `vcs.modified=true`。\n\n★ **与 `044a664` 的 ② 完全同形**:那次是「注释说修了而代码没改」,\n这次是「判据说干净而构建物不干净」—— 都是一句话与事实分家。\n\n## ⚠ 更正一则(初稿把这笔债说重了)\n\n初稿写「后果**无声**……没有任何东西会红」。**不准确** —— 现有机制**有**披露,\n而且是 pi 2026-09-25 专门加的:\n\n· `redeploy-gateway.sh:261-265`:脏树时 `warn` 并**逐个列出未提交文件名**,\n 注释里明说「清单有名字,bool 没有」;\n· `deploy/check-deploy-drift.mjs:1462-1467`:把 `vcs.modified=true` 作为\n **WARN 披露**,且**刻意不判红**(理由写在代码里:「脏树在本仓是常态,\n 判红=总在亮」)。\n\n⇒ 真正缺的不是「披露」,是**披露的持久性**。实测 09:52 那次的清单\n**已不可复原**:脚本无 `tee`、journal 里 0 行(实测 grep)、\n`/tmp` 下只剩 `am-sandbox-check.log` 等无关产物。\n**所以事后没人能说出「那次构建带了谁的哪些文件」** ——\n而那正是当初加这条披露的全部理由(2026-09-14 `pool.mjs` 那一行未提交的\n`let missingSessionCount = 0;` 就是这么被带进生产的)。\n\n## ⚠ 更正二则:假安全感有**两处**,不只 `flock`\n\n初稿只点了 `flock`。实际两处都给了「已经防住了」的错觉:\n\n· `flock` 在位 ⇒ 以为并发部署已防(实则只防「同时跑」的那一种);\n· `check-deploy-drift` 的 `modified` **WARN 不参与退出码** ⇒\n 「反正有判据在报」⇒ 而 WARN 不进退出码,**没有东西会因此停下**。\n\n## 为什么仍标 HIGH\n\n① **披露会蒸发**:本次已实证「脏文件清单拿不回来」,而\n`check-deploy-drift.mjs:1456-1461` 自己写着「这次构建带了哪些未提交文件\n由**部署脚本**在构建步打印(那里才是同一时刻)」—— 而那个「同一时刻」\n没有任何持久化接住;\n② **会复发**:压测会话(`stress-sse-1..20`、`stress-budget-*`)是**批量**的,\n下一次批量跑就会再压一次;\n③ **两处机制都在位却都不拦住**:一个 `flock`、一个 WARN。\n\n## 我做过的、能立刻复用的处置\n\n· 判据侧已把 B 段那条 SHA **从写死改成现查**(`git rev-parse HEAD`)——\n 写死的 SHA 当天就因两次部署而过期两次,会让人误判「不一致」而其实只是过期。\n· 部署前先 `git status --short` 空,再跑;不空就**先别跑**。\n· 判据要**钉会红的行为**(本次:`TestHMSQuotaSurvivesTokenFailureWithLimitOne`\n 断言第二次不能是「上限」),只钉内部状态(`dayCount==0`)不够 ——\n 计数器对而行为错是可能的,那会给运维一条误导性文案。\n\n## 一条更贵的建议(未做,需平台侧)\n\n会话级互斥或工作树独占。现在同一 workspace 上有 30+ 个会话别名,\n`coord-hap-evict-20260928` 这个别名本身就在自证问题:它 09:55:31 问\n「delivery-marks-read 的新判据请自行提交」—— **它知道有会话在改这棵树,\n但不知道是哪个,也不在同一条线索上。**"
},
{
"id": "in-reply-to-ignores-direction",
"count": 1,
"kind": "**归因错误且无声**:SSE 的 `in_reply_to` 非空就断言「这封是对我上一封信的回复」,不校验父邮件的发件人是不是我 —— 于是单向来信链被逐封读成双向对话",
"due": "给 `inboundHeadline` / `replyInstruction` 的 `inReplyTo` 加上方向判据(父邮件的 from == 本方),或让服务端只在父邮件确由收件方发出时才填 `in_reply_to` 时。★ 到期动作不是'在提示词里写清楚'——本次已证明写清楚没用:四封通知的正文里已经逐字写明'回的是你那封:<id>',模型照样每封都去核一遍,然后照样被误导。",
"where": "`plugins/*-mail-bridge/lib/relay-policy.js:105-111`(`inboundHeadline`:`if (inReplyTo)` 直接出'你上一封信的回复到了');`plugins/zcode-mail-bridge/src/prompt.mjs:141`(`if (data?.in_reply_to) lines.push('回的是你那封:…')`,无方向判断);服务端 `server/internal/notify/mail.go:202` 把 `ParentMailID` 原样透传;实测线索 `stress-thread-21863-15348`(session aa2a600d,8 封全为 opencode→pi,层号跳过 3/6/9)",
"note": "★★ 2026-09-28 登记(pi 实测,opencode 复核)。\n\n## 形状:**单向 8 封被读成双向 4 轮**\n\n压测线索 `stress-thread-21863-15348` 里 8 封全是 `opencode → pi`,\n`read_thread` 逐层确认,pi 侧一封未发(唯一一次 `send_mail` 被\n'Agent 互发 8 封上限'拦下)。但四封投递通知各自宣称\n'回的是你那封:<上一封的 mail_id>' ——\n\n| 通知 | 宣称的父邮件 | 实际 |\n|---|---|---|\n| 层1 de4e212f | 6298f78a | 6298f78a 是 **opencode 自己的**信 |\n| 层2 e0e8b3c7 | de4e212f | 同上,仍是 opencode 的 |\n| 层4 6358cfc7 | e0e8b3c7 | 同上 |\n\n⇒ 通知里**不存在**一封是 pi 发出的。\n\n## 根因不是'通知乱序',是**方向被省略**\n\n`resolveTarget` 里 `reply_to` 被解析后 `parentMailID = &replyID`\n(`server/internal/handler/mail.go:88`),notify 原样透传(`notify/mail.go:202`)。\n**模型是对的**:父邮件 = 上一封 = 我上一封收到的。\n\n漏掉的是**方向判据**:`inboundHeadline` 只问'有没有父邮件',\n不问'这封父邮件是不是我发的'。单向续信也满足'有父邮件',\n于是一条纯单向的压测链被逐封判定成'对方在回我'。\n\n## 为什么第一封没被骗\n\n6298f78a 走的是真·新会话路径,`parentMailID == nil`\n⇒ `in_reply_to` 为空 ⇒ `inboundHeadline` 落到默认分支\n⇒ 显示'你收到一封新邮件'。**四个桥的同名字符串都在\n`relay-policy.js:111` 这一行**,改动会同时影响 dsh/zcode/opencode/pi。\n\n## 后果:撞 hop 上限,掩盖真实缺陷\n\n每被误判一轮,模型就'处理'一次并回一封,客套到上限被拦\n(生产实测 6 轮)。这次 8 封单向压测消耗的正是这份额度,\n把真正的缺陷挤出了视野。\n\n## 我做过的核对(避免重蹈 aab92f17 的归因错误)\n\n· 4 次 `read_inbox` 全空(unread 与 all 都空,`all` 返回的 8 封全标 `[archived]`);\n· `read_thread` 三次均为 8 封、全 `opencode → pi`、无任何 pi→opencode;\n· 逐封 `read_mail` 核对发件人与正文(正文是'层 N 的正文'占位);\n· 层号跳过 3/6/9,但 mail_id 序列连续 ⇒ **发送侧跳号,不是丢信**;\n· 本条根因定位所依据的行号均已回读原文确认。\n\n## 修法的一处取舍(尚未做)\n\n最小改动是在 `relay-policy.js` 加 `inReplyToFromMe` 判据,但那需要\n父邮件的发件人信息 —— 当前 payload **没有**这个字段(只有 `from_name`\n即本封发件人)。所以两个选项:服务端补一个 `parent_from` 字段,\n或插件侧用 `read_mail(parent_id)` 查一次。**前者更便宜且不用多一次往返**。\n\n★ 顺带记一笔:另有两个观察(`read_inbox` unread 视图与通知不一致、\n`session_participants` 回显把 path 段吞掉)**可能同源** ——\n都指向'通知/展示层没有回读真实数据',但**我没有查证**,不并入本条。\n\n## 2026-09-28 校正(pi自查,上条那节写得太快)\n\n上次写「修法:服务端补 `parent_from` 字段更便宜」——**方向对,但我没查证\n就下了结论**。现已回读确认,且**比原先想的更便宜**:\n\n`resolveTarget` 的 `reply_to` 分支(`server/internal/handler/mail.go:80-85`)\n**已经把父邮件整行 `repo.GetMailByID` 读进内存**了,只用了它的 `SessionID`\n就把 `mail` 丢掉;而 `models.Mail` 上就有 `FromName`\n(`server/internal/models/models.go:142`)—— 即判据需要的方向信息\n**在函数里现成,不需要任何一次额外查询或新 join**。\n\n⇒ 修法应当是:`resolveTarget` 一并返回父邮件发件人,notify 载荷带出去。\n**这不是\"补一个字段的成本\",是\"别把已经在手的数据扔掉\"。**\n上次那句把它说成新增成本,是我把话说满了。\n\n判据已就位:`client/electron/test/cross-bridge-prompt.test.mjs` 第 5 条\n(当前**故意红**,即本条债的判据)。"
},
{
"id": "build-stamp-stale-artifact-blocks-verification",
"count": 1,
"kind": "**产物自证长期红**:`build-stamp` 要求 `BUILD_INFO.json` 的 `gitRev` 精确等于 HEAD,而该文件**未被 git 跟踪**、内容长期停在某次旧构建上 ⇒ 这条判据只能靠「重构建」恢复,**任何代码改动都无法让它转绿**",
"due": "有人重跑一次客户端构建(产出新的 `BUILD_INFO.json`)时。★ 到期动作**不是**改 `BUILD_INFO.json` 里的 `gitRev`/`srcHash` —— 判据自己的报错文案就写明那样做等于把它废掉;也不是把它加进跳过名单;唯一正确的动作是重跑构建。",
"where": "`client/electron/test/build-stamp.test.mjs:93`(`★ 产物必须自报来源:BUILD_INFO 精确比对`);产物记录在 `client/electron/BUILD_INFO.json`(★ **未被 git 跟踪**);实测 2026-09-28 产物记 `87c55ac`、HEAD 已走到 `f1c74fc`",
"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": "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 没有那条日志,任何进一步推断都是猜。"
},
{
"id": "archive-burst-targets-unreconciled",
"count": 1,
"kind": "observability",
"due": "机制与规模都已定(归档批 63 = 32 戳幸存 + 31 被后续写入覆盖)⇒ **这条债可收口**。唯一未做的是**逐条点名**那 31 条(已点名 1 条),**明确不做**:18/19 字节候选各 20+ 个,穷举收益低于成本,且点名不改变任何已定结论。★ 若将来仍要点名,用已验证的只读解码:**响应字节数 − 93 = alias 字节数**(先用已知目标自校验解码器),且一律用 `LENGTH(CAST(session_alias AS BLOB))` —— 用字符数会把 CJK 别名算短。",
"where": "网关 access log(`POST /api/v1/contacts/archive`)+ `ArchiveSession`(`server/internal/repo/repo.go` 的 `ArchiveSession`,⚠ 行号会漂,2026-09-28 11:20 复核为 1679)。",
"note": "★★ 2026-09-28 登记(pi 与 opencode 互相复核三轮,每轮各推翻对方一个机制;最后**仍不闭合**)。\n\n## 事实(三方复核一致)\n\n```\n10:09 这一分钟 POST /contacts/archive 65 次(全 200)\n 其中 10:09:57–58 两秒内 63 次\n sessions.updated_at 落在 02:09:57–58 的 32 条\n10:43:20 那次 POST 14 次 ⇒ 落在 02:43:20 的 11 条(比值 **1.27**)\n```\n\n## 已被数据否掉的三个机制\n\n**① 幂等重试**(opencode 第一版)—— 否。`ArchiveContact` 无「已归档就早退」,\n`GetSessionByID` 无归档过滤 ⇒ 重复归档**会**再次盖 `updated_at`。\n若 63 次打在 32 条上,那 32 条里应该有 63 个不同的时间戳,实际只有 32 个。\n\n**② 后续写入污染 `updated_at`**(pi 第二版)—— 大部分否掉。\n那 28 条 stress-sse 的 `updated_at` 实测**全部**仍在 `02:09:57.171 ~ 02:09:57.763`,\n一条都没被推后 ⇒ 不可能是「普遍污染」。\n★ **但 pi 找对了真凶的一半**:他那次分组写的是 `updated_at >= '2026-09-58'` —— **无上界**,\n实测该桶真实跨度 `02:09:58.067 → 04:02:31.9`(**1h53m**)⇒ 把之后两小时里所有的\n`TouchSession`、建邮件、回信全扫进了「归档打中的目标」。**这是筛选条件写错,不是机制。**\n加上界到 02:10:10 后该窗口降到 **34** 行。\n\n**③ 筛选条件写错**(pi 第三版,**成立**)—— 见上。\n⚠ **但它没有完全解释**:加上界后 **34** 行,而那两秒内只有 63 次 POST / 32 条在盖。\n34 里 32 条是 `02:09:57x` 的在盖会话,另 2 条是 **active**:\n · `\"stress-sse-14-18718\"` updated_at `02:09:59.541`(含字面引号,另一种形态)\n · `\"stress-sse-5-8157\"` updated_at `02:10:09.561`\n这两条的**邮件都是 `read`、不是 `archived`** ⇒ `ArchiveSession` 从未在它们身上跑过\n(它会连带 `UPDATE mails SET status='archived'`)⇒ **它们不是那 63 次的目标。**\n⇒ **34 − 2 = 32,仍与 POST 数对不上。**(★ 这一句在 12:30 被下面「已闭合」推翻 ——\n差值是真的,但**机制确实是「后续写入盖掉时间戳」**,只是 pi 的 28 条证据覆盖不到它。**)**\n\n## ★★ 已闭合(2026-09-28 12:30,opencode):把响应体大小**解出来**当判据\n\n`ArchiveContact` 的 HTTP 响应体是 `{status, session_id, session_alias}` ——\n**注意:不含 `archived_by`**(`archived_by` 只在 SSE payload 里,`handler/contacts.go`)。\n⇒ 上一轮我说「大小 = f(len(alias), len(archived_by))」**是错的**,已更正。\n\n`JSON()` 走 `json.NewEncoder().Encode()`(`handler/helpers.go:19`)⇒ 键排序 + 末尾换行,\n而 `session_id` 恒为 36 字符 UUID ⇒ **响应大小 = 93 + len(alias 的字节数)**(可精确反解)。\n\n★ **判据自校验**(先在已知目标上验证解码器):`10:42:23` 只有 1 次 POST、大小 111B\n⇒ 预测 alias 字节数 = 18;该秒唯一被盖的会话 `stress-sse-9-20516` **恰好 18 字节** ⇒ 解码器成立。\n\n```\n63 次 POST(10:09:57–58)按预测 alias 字节数:\n 14×1 16×1 17×5 18×26 19×28 20×1 24×1\n同时被盖上 02:09:57 时间戳的 32 条,按**字节**长度:\n 17×4 18×11 19×16 20×1\n⇒ 逐长度相减:14×1、16×1、17×1、18×15、19×12、24×1 = **31 条对不上**(= 63 − 32 ✓)\n```\n\n★ **注意 CJK 坑**:那 32 条里有 2 条 `pi-压测-限流-17/-12` 是 **11 字符 / 19 字节**。\n用 SQLite `LENGTH()`(数字符)会算成 11,而 Go `len()`(数字节)是 19 ——\n**两边量的不是同一个量**,对不上时会以为「有 2 条 POST 找不到会话」。\n⇒ 一律用 `LENGTH(CAST(alias AS BLOB))`。\n\n## ★★ 决定性论证:**不需要知道打中的是谁,也能证明时间戳被盖掉了**\n\nalias 字节长度 **14** 的候选,全库只有 2 个(`created_at < 02:09:57`):\n\n| alias | updated_at |\n|---|---|\n|---|---|\n| `arch-test-2147` | `02:01:12`(**早于**那批) |\n| `thread-probe-1` | `02:43:20`(**晚于**那批) |\n\n⇒ 那 1 次 len-14 的 POST **必然**打中这两个之一,**而两个都不在 `02:09:57`**。\n⇒ 不管是哪个,它的 `10:09:57` 时间戳**都已经不在了** —— 这就是「后续写入盖掉」的**直接证据**,\n且**不依赖**「到底是哪个」这个我们不知道的事实。(2/2 排除,比点名更硬。)\n\n★ 同理 alias 字节长度 **24** 只有 2 个候选:`插件语义探针-zcode`(`09-15`,早于那批 ⇒ 不可能是目标)\n与 `coord-hap-evict-20260928`(`02:16:34`,晚于那批)⇒ 只能后者。\n而后者正是那 10 条**半活会话**之一(`sessions.status='active'` 但 `mails.status='archived'`)\n⇒ `ArchiveSession` **确实在它身上跑过**(全仓只有它会把 mails 盖 archived)\n⇒ 两条独立线索(字节长度排除 + 半活签名)**交汇于同一会话**。\n★ 而它的复活指纹很干净:`sessions.updated_at=02:16:34.987566`,\n同一瞬间(**+12ms**)有一封邮件 `created_at=02:16:34.999952` ⇒ 旧 `CreateMail`+`TouchSession` 路径。\n\n## ⇒ 结论(改写这条债的定性)\n\n**差值 = 归档目标里「时间戳后来被盖掉」的那批,不是「没打中」。**\n⇒ 归档批的**规模本来就是 63**,其中 32 条的戳幸存、31 条被后续写入覆盖。\n⇒ pi 第二版的**机制是对的**,但他的**证据(28 条 SSE 没被推后)覆盖不到非 SSE 目标** ——\n那 28 条集中在 0.6 秒内且之后没人碰,**是那批目标的性质,不是定律**。\n⇒ 我第一版「63 次该产生 63 个时间戳」也错:它假设**没人再写**,同样不成立。\n⇒ **两版都被数据否掉的理由都是「假设之后没人写」** —— 同一个隐藏前提。\n\n★ 仍存的缺口 —— **明确不做**(2026-09-28 12:50,pi 与 opencode 一致):\n**31 条里只有 1 条被点名**(`coord-hap-evict-20260928`),其余 30 条(18×15、19×12 等)**不穷举**。\n理由:同字节长度的候选各 20+ 个(18 字节 23 个、19 字节 20 个),指名要靠\n「字节长度 × 存活时间」交叉,**收益低于成本** —— 而且**点名不改变任何结论**:\n机制(后续写入盖掉时间戳)与规模(63 = 32 幸存 + 31 被覆盖)都已定。\n⇒ 剩余部分是**点名,不是判据** ⇒ 按「成本高于收益,明确不做」记,\n**不当成开放洞** —— 否则下一个人会以为还有未决的机制问题。\n\n## ★ 顺带钉住一个可复用的判据形状:**半活会话**\n\n旧 `TouchSession` 是 `SET status='active', updated_at=NOW()`(**不碰 mails**),\n而 `ArchiveSession` 是**两条** UPDATE(sessions + mails 都盖 archived)。\n⇒ 「被归档过、之后又被 touch 复活」在库里有**唯一签名**:\n\n```sql\nSELECT s.session_alias FROM sessions s JOIN mails m ON m.session_id = s.session_id\nWHERE s.status = 'active' AND m.status = 'archived';\n```\n\n★ **口径必须写死(2026-09-28 12:50,pi 报 16、我报 10 —— 差的不是数据是窗口)**:\n\n```sql\n-- 判据本体(**必须按 session_id 去重**,否则 JOIN 行数会给出 93):\nSELECT COUNT(*) FROM (\n SELECT 1 FROM sessions s JOIN mails m ON m.session_id = s.session_id\n WHERE s.status = 'active' AND m.status = 'archived' GROUP BY s.session_id);\n```\n\n| 口径 | 条数 |\n|---|---|\n| 全库(去重 `session_id`) | **16** ← pi 报的 |\n| 仅 `updated_at >= '2026-09-28'` | **10** ← 我上一封报的 |\n| 2026-09-28 之前(历史残留) | 6 |\n| ⚠ 按 JOIN **行数**不去重 | **93** ← 错法 |\n\n=> **16 = 10 + 6**,两边都没算错,只是窗口不同 —— 这不是数据打架。\n★ 三条口径纪律(这轮各踩一次):\n 1. **必须 `GROUP BY session_id`** —— 一条会话有多封 archived 邮件时会重复计数;\n 2. **窗口必须写进结论里**,否则同一个量两个数必然被当成打架(我们这轮反复栽的就是这个);\n 3. 命中的 16 条里含 `redeploy-pi-bridge-授权唤醒修复`(36 封)、`鸿蒙客户端与-WebUI-界面对齐`(27 封)\n 等**早期大会话** ⇒ 印证另 6 条是历史残留,不是今天产生的。\n⇒ **这个签名可以直接当「归档是否被绕过/复活」的判据**,比读 `updated_at` 可靠 —— \n`updated_at` 会被任何后续写入改,这张两表的状态分叉**不会**。\n(`e78888b` 已让 `TouchSession` 不再写 status ⇒ 新代码下不会再产生新的半活会话,\n但**这批历史数据仍在**。)\n\n★ **顺带把那个机制坐实了**(不是推测,是能证伪的那种):`e78888b` 落在 **11:01:38**,\n而全库半活会话 `MAX(updated_at)` = **04:02:31**,即**一条都不在修复之后**。\n⇒ 机制成立(旧 `TouchSession` 写 status 不写 mails)**且**修复确实封住了它。\n⇒ 这也说明:拿今天的数据去验证 `e78888b` 之后的代码路径,**结构上就不可能复现**。\n\n## ★ 已被上一节取代:原以为「必须重打一轮才能取到响应体」—— **不需要**\n\n原判断「access log 不记响应体 ⇒ 现有日志无法回溯 ⇒ **必须重打一轮**」**是错的**:\n日志记的是**响应字节数**,而响应体是 `json.Encoder` 的确定性输出(键排序、`session_id` 恒 36 字符)\n=> **大小 = 93 + len(alias 字节数)**,**大小本身就是一份可反解的响应体**。\n⇒ 上面「已闭合」一节就是用它做的,**没有重打一轮**。\n\n⚠ 顺带更正两处我上一轮的错误:\n · 响应体**不含** `archived_by`(它只在 SSE payload 里)⇒\n 我上一轮说的「大小 = f(len(alias), len(archived_by))」**作废**;\n · pi 提的「同一长度出现两种大小 ⇒ 两个不同调用方」**方向反了**:\n 大小是长度的**函数**,一个长度只对应一个大小 ⇒ 该判据不能用。\n\n## 剩下的缺口(唯一一条)\n**31 条未覆盖的目标里,30 条还没逐条点名。** 机制已定(后续写入盖掉时间戳)、规模已知(63),\n但指名需按「字节长度 × 存活时间」交叉,同长度候选太多(18 字节候选 23 个、19 字节 20 个)。\n⇒ 属**穷举成本**问题,不再是**机制未知**问题。"
},
{
"id": "archive-sweeper-leaves-mails-alive",
"count": 1,
"kind": "correctness",
"due": "给 `deploy/archive-stale-sessions.sh` 的归档与回滚都补上 `mails` 的联动(与 `ArchiveSession` 同语义:归档即 `UPDATE mails SET status='archived'`)。回滚那一半**不是纯语义选择、已被输入卡死**:`archive-ids-*.txt|json` 里**只有 session_id、没有 mail_id**(实测 09-14 的 25 个、09-15 的 12 个,`archive-plan-*.json` 里也没有 `mail_id`)⇒ 光靠 ids 文件无法知道「本次盖过哪些邮件」。⚠ **但 `/root/gotmp/agentmail-pre-archive-20260915-065927.db` 这个归档前快照能反推**:实测 12/12 条会话都能从快照里定位到「归档前哪些邮件是活的」(共 467 封非 archived)⇒ **「只反还本次盖过的那批」对有快照的那几次是做得出来的**,只有 09-28 那 8 条(无归档前快照)只能整会话复活。⇒ 人要定的其实是「**没有快照的那些次怎么办**」,不是「两种语义选一个」。另:给 `session_status_invariant_test.go` 补**反向**断言(现在只查一个方向),并让判据能在**真实库**上只读跑一次(现在只在 `t.TempDir()` 上跑 ⇒ 431 封存量分叉它永远看不见)。",
"where": "`deploy/archive-stale-sessions.sh:105`(归档,只 UPDATE sessions)与 `:147`(回滚,同样只 UPDATE sessions);`ArchiveSession` 在 `server/internal/repo/repo.go`(⚠ 行号会漂,2026-09-28 12:55 复核为 1679-1690,两条 UPDATE);不变量测试在 `server/internal/repo/session_status_invariant_test.go`。",
"note": "★★ 2026-09-28 登记(opencode 实测;pi 问「半活会话要不要单开债」时撞见的**反向**分叉)。\n\n## 现象:**反方向**的两表分叉已经在生产里,431 封\n\n上一轮我们在 `archive-burst-targets-unreconciled` 里记的是「半活会话」\n(`sessions.status='active'` 而 `mails` 有 archived)。**那个方向**已被 `e78888b` 封住成因。\n**这个方向没人查过,而且它现在是活的代码路径:**\n\n```\ns.status='archived' 而 m.status<>'archived'\n → 28 条会话,431 封邮件(7 封 m.status='unread'、424 封 'read';**按收件人无 mail_reads 行的 45 封**)\n → 最大的几条:homeagent-webui 135 封、harmony-ui-alignment 100 封、\n Re-Re-深入的看一下当前工程 65 封、deploy-pi-bridge-authz-wakeup 35 封\n```\n\n## 成因:直接证据,**不是推演**\n\n`deploy/archive-stale-sessions.sh` 的归档那一段(105 行)**只写 sessions**:\n\n```sql\nBEGIN;\n UPDATE sessions SET status='archived' WHERE session_id IN ( ... );\nCOMMIT;\n```\n\n**同一个事务里没有 `UPDATE mails`** ⇒ 每跑一次就多一批反向分叉。\n★ 它的**回滚脚本**(147 行)也一样只写 sessions:\n`UPDATE sessions SET status='active' ...` ⇒ **回滚会把 431 封一次性重新暴露**。\n⚠ **回滚后显示为「未读」的是 45 封,不是 7 封** —— 详见下面「读态由 mail_reads 定」一节。\n\n★ **因果闭合(但只覆盖 12/28,别当成全部)**:`/root/gotmp/archive-ids-20260915-065927.txt` 留有\n09-15 那次的 12 个 id,实测这 **12/12 个今天全部是反向分叉**(`homeagent-webui`、\n`harmony-ui-alignment`、`deploy-pi-bridge-authz-wakeup` 都在清单里)\n⇒ **那一次** sweeper 与这批分叉是因果关系。\n⚠ 但另外 16 条**不匹配任何归档产物**(含今天 09-28 的 8 条)⇒ **不能说「存量就是那两次跑出来的」**,\n成因见下面「修正三」。\n\n## 为什么现有的不变量测试没拦住\n\n`session_status_invariant_test.go:70` 查的正是这个方向\n(`WHERE s.status='archived' AND m.status<>'archived'`,注释还写着「不该有非 archived 的邮件」)。\n**它没红,是因为它跑在 `t.TempDir()` 的空库上**(`setupTestDB` → `quota_test.go:16`\n起临时库 + `db.Migrate`)⇒ **它只验证「走 `ArchiveSession` 这条路不会分叉」,\n对 431 封存量分叉完全瞎。**\n⚠ 而且它只查**一个方向** —— 半活(active+archived)那侧**没有**对应断言,\n所以上一轮我们只能靠临时 SQL 查出来。\n\n## 后果:比半活更重一点\n\n· 收件箱 `ListInboxScoped`(`repo.go:900`)有 `AND s.status <> 'archived'`\n ⇒ 这 431 封**在收件箱里完全不可见**,而权威读态列 `mail_reads` 上它们多数已被标记\n ⇒ **系统认为「已读」却从未展示给人看过**(详见「修正一」);\n· 别名寻址 `FindNamedSessionFor`(`repo.go:1278`)同样带 `s.status <> 'archived'`\n ⇒ **回信投不进去**(死信地址),这与 `archive-eats-undispatched-mail` 记的别名侧是同一个后果;\n· 但**回滚脚本救不回来**:它只把 sessions 改回 active,\n 正好会把这 431 封一次性放回收件箱 —— **「回滚」在这里是放大器,不是修复**。\n\n★ 与半活那个方向**方向相反、后果同类** ⇒ 两个方向该用**同一条判据**一起查:\n`s.status` 与 `mails.status` 的四种组合里,**只有「全 active」和「全 archived」是自洽的**,\n另两种都是分叉。判据已写进 `scripts/sse-delivery-audit.sh` 第 6 与 6b 两档(两档都把 `GROUP BY session_id` 写在判据**内部**,不靠调用方记得去重;正向样本 `ALIASES='homeagent-webui,harmony-ui-alignment'` ⇒ 2 会话 / 235 封)。\n\n## ★★ 修正一:回滚后「几封变未读」是 **45,不是 7**\n\npi 提出分层「424 read / 45 无 read 行 / 7 unread ⇒ 只有 7 封会以未读形态进入视野」。\n**三个数我都复现了,但那条推论错了。**\n\n`readStateFor`(`repo.go:791`)与 `unreadFor`(`repo.go:784`)**完全不看 `m.status`**:\n\n```sql\nCASE WHEN m.status = 'archived' THEN 'archived'\n WHEN EXISTS (SELECT 1 FROM mail_reads r WHERE r.mail_id = m.mail_id\n AND r.reader_name = <读者>) THEN 'read'\n ELSE 'unread' END\n```\n\n★ `m.status` 是**冗余列**,`repo.go:2132` 的注释写明「不再作为未读判据」。\n⇒ **424 封 `m.status='read'` 里有 38 封在收件人侧没有 read 行 ⇒ 回滚后同样显示为未读。**\n实测:`m.status='read'` 且收件人无 read 行 = **38**;`m.status='unread'` = **7** ⇒ **合计 45**。\n\n⇒ 真正的形状是:**424 封一次性涌进列表 + 45 封显示为未读**(不是 7)。\n这 38 封 `created_at` 在 **09-14 05:13 .. 09-15 01:10**,正好落在 `markReadFor` 那个\n**占位符错位 bug 的窗口内**(`repo.go:803-821` 记着「邮件看起来已读、实际仍是未读」,\n修于 2026-09-20)⇒ **它们是那个 bug 的残留,不是本条债造成的** —— 但回滚会把它们一起复活。\n\n⚠ **「45」这个数成立的理由是数据的性质,不是恒等式**:实测这 431 封**全部 `cc_list` 为空**\n⇒ 每个 mail 只有一个读者 `to_name` ⇒「无任何 read 行」与「收件人无 read 行」在这批上恰好相等。\n一旦有抄送读者,两者就分叉。**判据要按 `reader_name = to_name` 写,不能按「有没有 read 行」写。**\n\n## ★★ 修正二:分叉**至今仍在产生**,不是 09-15 的一次性存量\n\n28 条里有 **8 条是今天(09-28)**的:`deploy-smoke-20260928`、`deploy-smoke2/3`、\n`probe-ha/ha2/ha3`、`probe-dsh`、`probe-oc/oc2`,`updated_at` 00:31–01:39。\n**今天 archived 的会话共 72 条** ⇒ sweeper 今天仍在跑、仍在制造分叉。\n\n## ★★ 修正三:成因**不止 sweeper 一条**,还有一条活路径\n\n`ArchiveSession`(`repo.go:1679`)的两句 UPDATE **没有包在事务里**\n(`db.DB.ExecContext` 两次独立执行,中间无 `Begin`/`WithTx`)\n⇒ **进程在两句之间崩溃/kill -9,就会留下「sessions 已 archived、mails 全非 archived」的反向分叉。**\n\n★ 这解释了 6 条**混存**会话(会话内既有 archived 又有非 archived,如\n`Re-Re-深入的看一下当前工程` archived 42 / 非归档 65):`ArchiveSession` 一旦执行就会盖掉\n**全部**非 archived 邮件(`AND status <> 'archived'` 且按 session_id),\n**盖出「一部分 archived」的形状它自己做不到** ⇒ 那些是**多次操作的叠加**。\n⚠ 具体时序我**没有闭合**(归档前快照只到 09-15 06:59,且那 6 条当时的 mails 全是非 archived)\n⇒ **不写死结论**,只登记「成因至少两条,混存形状的成因待查」。\n\n## ★ 关于「只反还本次盖过的那批」:pi 的约束对,但不完整\n\npi 查了 `archive-ids-*.txt` 只有 session_id ⇒ 判定该语义不可实现。**这条对,但漏了两样:**\n\n1. `/root/gotmp/agentmail-pre-archive-20260915-065927.db` 是**归档前快照**(3.2MB,1154 mails)\n ⇒ 实测 **12/12 条**会话都能从快照里定位到「归档前哪些邮件是活的」(共 **467 封**非 archived)\n ⇒ **对有快照的那几次,「只反还本次」是做得出来的**。只有 09-28 那 8 条(无快照)只能整会话复活。\n2. 产物有**第三种形态**:`undo-poison-archive-FIXED.sh` 里是**逐封点名 mail_id**(34 封)\n ⇒ 「ids 文件只有 session_id」不是**普遍**约束,只是 09-14/09-15 那两次的形态。\n\n⇒ 决策空间因此不是「两种语义选一个」,而是「**没有快照的那些次怎么办**」。\n\n## 边界\n\n本条**只读登记**:没有改 `archive-stale-sessions.sh`、没有跑 sweeper、没有动数据。\n修法涉及归档/回滚语义,**归人定**;但 `ArchiveSession` 缺事务那一条是**纯 bug**,不依赖任何语义决策。"
},
{
"id": "criterion-silently-returns-zero",
"count": 1,
"kind": "observability",
"due": "给「写判据」这件事本身定一条规矩:**判据命中 0 条时不得直接当结论**,必须先证明「路径/字段/时间窗」三者都对,且把这条规矩落进 .tmp 压测脚本与 docs 的取证清单。",
"where": "判据的构造方式(.tmp/*.sh 的 grep/sqlite 查询、docs 里所有引日志的证据行)—— 2026-09-28 SSE 16 复核轮里,**两个 Agent 各犯了一次同一个错**,且都「核过对方」。",
"note": "★★ 2026-09-28 登记(pi 提出,我复核后成立)。\n\n## 症状:判据在真实数据上**静默归零**,而不是报错\n\n两次都是「计数 = 0」被当成事实,且两次都是**路径写错**:\n\n**① pi**:`NOT EXISTS(... from_name='pi')` 被当作「没投出」的判据 —— 但它选出的是「没回信」。\nAgent 来信按约定不自动转发,**投递成立但没人回信是正确行为**,两者在库里同形。\n⇒ 他稳定地产生「9 封从未投出」这个假结论。\n\n**② 我**:`GET /api/v1/mail/{id}`(人类侧)被当成取信日志的路径 —— 但 Agent/桥走的是\n`GET /api/v1/agent/mail/{id}?workspace=…&session_id=…`,人类侧那条**本来就没有流量**。\n⇒ 该窗口裸取信 0 条,pi 据此判「你引的那行日志是空的」。\n换成对的路径:**159 条,其中 28 条对应 02:09:57 归档批里的 28 条会话,10:16–10:29 全 200**。\n⇒ 那 28 行一直都在。**两个 Agent 各自按脑子里的路径 grep,一个报「有」、一个报「没有」。**\n\n**③ 同族第三例(更基础)**:别名**带字面引号**。库里 33 条 alias 含 `\"`,\n其中 24 条是 stress-sse 的(如带引号形态的 `stress-sse-1-10411`)。写 `WHERE session_alias='stress-sse-%'`\n**一条都不中**;写 `LIKE '%stress-sse%'` 才中。pi 说他 grep 时是 `-o 'alias=\"?stress-...'` 侥幸全中。\n⇒ 同一个字段存在两种形态,判据必须两种都覆盖,否则**在真实数据上悄悄报 0/24**。\n\n## ★ 2026-09-28 11:20 补充:第四例(双向,且是**假阳性**方向)\n\npi 提「用被后续写入污染的 `updated_at` 反推某批操作打中了谁」是后验快照 —— 这条成立,\n**但它这次给出的不是 0,而是一个比真值更大的数**(他分组得 80,而 POST 只有 65)。\n\n⇒ 所以这个坑有**两个方向**,且第二个更隐蔽:\n · **静默归零**(①②③):查不到 → 以为「不存在」;\n · **静默溢出**(④):算出 80 → 以为「多出来的 15 次是幂等重试」,\n 然后**去解释一个不存在的现象**。0 会被怀疑,80 不会 —— 它看起来像个正常的计数。\n\n★ 判据的输出**无论偏大偏小都可能自圆其说**,所以复核不能只看「数字像不像」,\n要看**它与另一条独立读数是否自洽**:④ 里 63 POST 是 access log 的**独立事实**,\n任何与会话表分组对不上的分组,都该先怀疑分组本身。**先核对总量,再解释差值。**\n\n## 为什么这条比三条个案都重要\n判据**报错**时你会去修;判据**静默归零**时,你会把「0」当成一个测量结果继续推理 ——\n而 0 恰好是最像结论的数字。**错误的形状是:越安静越可信。**\n\n## ★ 第五例(pi 提出,已落成 `scripts/sse-delivery-audit.sh`)—— **判据自己也会踩这个坑**\n\n把上面三条教训写成脚本之后,脚本**第一版自己就犯了第 ①② 条**:\n\naccess log 的行尾形状是 `\"GET <url> HTTP/1.1\" from 127.0.0.1:PORT - 200 956B`,\nURL 与状态码之间隔着 `\" from 127.0.0.1:PORT`。我写 `\\?[^ ]* - 200` ⇒ **匹配不到**,\n脚本第 1 档输出「取信请求 0 条」—— **与「一封都没投出」逐字同形**。\n\n⇒ 也就是说:**把判据写成代码,并不自动免疫这个坑。** 判据自己也是判据。\n⇒ 落法两条,已在脚本里:\n · 开头**自检**:先证明这条路径在窗口内有流量(202 条),再往下走;\n · 自检不过就 **exit 3 + 打印三种「静默归零」的排查方向**,而不是打印 0 让人接着推理。\n\n## ★ 第六例(opencode 自撞,2026-09-28 12:10)—— **磁盘满会伪装成「一封都没投出」**\n\n写完脚本当天,`/tmp`(9.8G tmpfs)涨到 **100% 满**。再跑脚本,得到:\n\n```\nEXIT=1 stdout=0 字节 stderr=0 字节 连 `bash -x` 都没有 trace\n```\n\n我一度以为「脚本自己有 bug」,甚至开始怀疑第 3 档的 python 逻辑。真相是:\n`mktemp` 与重定向**双双失败**,而那行 `|| true` 把「写不进去」压成了和\n「窗口内一条都没有」**完全相同的 0**。\n\n⇒ 这是第五例的**第三个器官**:第五例是**正则**匹配不到,第六例是**落盘**失败,\n两者产出的字面输出都是「0 条」。\n⇒ 已加 `0b. 落临时文件前先证明临时目录写得进去`,不可写就 **exit 2**(环境问题),\n并把那行 `|| true` 换成显式报错 —— grep 无匹配本来 exit 1,那是**合法**的 0,\n交给第 0 档自检去判;**但重定向失败不是合法的 0**。\n⇒ 实测:满盘时 `EXIT=2` + 打印 `df -h` 排查方向;`TMPDIR=$PWD/.tmp-audit` 后 `EXIT=0`、\n 自检 202 条、r=0.9965。\n\n★ 教训(比这条具体修复更值钱):\n**「0」不总是「测到了 0」,也可能是「根本没测成」。** 这两种在终端上逐字同形。\n⇒ 所以判据必须能区分**「测了,是 0」**与**「没测成」** —— 二者的退出码必须不同。\n★ 附带一条排查纪律:**别把管道下游的退出码当成脚本的**。\n `bash script.sh 2>&1 | tail -5` 报出的是 `tail` 的 0,不是脚本的 ——\n 我据此说过一次「脚本跑通了」,实际那次是 EXIT=1。\n\n★ 顺带一条纪律:**判据与账本必须同寿命。** 原判据写在 `.tmp/stress-test.sh`,\n而 `.tmp/` 在 .gitignore:12 ⇒ 下一轮就没了,`DEBTS` 里那笔债会长期裸着。\n⇒ 已迁到 `scripts/sse-delivery-audit.sh`(受版本控制,只读,实测 n=197 时 r=0.9965)。\n\n## 规矩(要落地的)\n\n判据**报错**时你会去修;判据**给出一个看似正常的数**时,你不会。\n⇒ 所以清单按**症状**分两类,**两类要用不同的直觉去防**(pi 2026-09-28 12:50 的划分,采纳):\n\n### A 类:**提取失败** —— 症状是 `0`,或每次跑都变\n\n症状最像结论(「查不到 ⇒ 不存在」),**会被怀疑**,但常常被当成真实结果继续推理。\n\n0. **★ 口径对齐了吗?**(同一个量出现两个数时,**第一反应必须是「口径」而不是「数据变了」** ——\n 2026-09-28 实测:全库 16 / 当天 10 / 不去重 93,**三个都是对的**,只是窗口与去重不同)\n1. **路径/字段写全了吗**?(`/mail/` vs `/agent/mail/`;带引号 vs 不带引号;\n `?session_id=…` **无 workspace** 43 条;参数颠倒 1 条 —— 2026-09-28 实测 159/43/1)\n2. **时间窗对齐了吗**?(⚠ DB 存 UTC、journalctl 打印本地时区,本项目差 8 小时 ——\n 10:09 HKT = `02:09` UTC,按本地写 SQL 会**整段落空**)\n3. **0 是「不存在」还是「我查错了」**?(用一条已知存在的样本反验判据本身)\n4. **正则跨过了该跨的吗**?(access log 的 URL 与状态码之间隔着 `\" from IP:PORT`;\n 响应大小在 `- 200 <size>B **in** <ms>` 里,别锚到行尾)\n5. **时间窗有上界吗**?(只写 `>= T` 会把之后几小时的写入全扫进来;\n ⚠ 实测 `updated_at >= '02:09:58'` 跨度 1h53m,且**不可复现** ——\n 同一条 SQL 连跑三次给出三个上界 `04:01:17`→`04:02:31`→`04:04:12`,COUNT 恒为 49)\n6. **临时目录写得进去吗**?(`/tmp` 满时 `mktemp` 与重定向双双失败,\n `|| true` 会把「写不进去」压成和「一条都没有」**逐字相同的 0** ⇒ 已加 `0b` 自检 + `exit 2`)\n7. **数了两遍吗?**(对日志计数要用**两种互不相关的方式**:数行 vs 按字段聚合数事件。\n ⚠ 双计会产出「看起来稳定」的结果 —— 我把 journalctl 两列时间提取两次,\n 14 变 28,而 **28 恰好是 14 的整数倍**,于是「两次读到同一个数」被当成了复现成功)\n\n### B 类:**推论失败** —— 症状是**荒谬但看起来正常**的数\n\n输入是真的、结论当场就不可能。**这类不会被怀疑**,因为它长得像一次正常计数。\n\n8. **量纲闭合吗?**(pi 那次 34 行 vs 65 次 POST:**行与次不是同一个量**,不能相减)\n9. **方向对吗?**(pi 提「同一 alias 长度出现两种响应大小 ⇒ 两个不同调用方」——\n 方向反了:响应大小是长度的**函数**,一个长度只对应一个大小,\n 「同长度出两种大小」**根本不会出现** ⇒ 正确用法是**倒过来反解长度**:\n `响应字节数 − 93 = alias 字节数`)\n10. **这个量纲真的能推出它吗?**(r=0.9968 只证明「响应带 body」,\n **对「worker 是否交给模型」完全沉默**;`①投递 ②起 worker ③取到正文` 三档不可互相替代)\n11. **分组合计 ≤ 总量吗?**(⚠ pi 2026-09-28 12:55 修正:这条**原被我放在 A 类,错了** ——\n 它的症状是 82 > 65,**不是 0**,按分类标准属于 B。\n 分组合计超过独立事实的总数 ⇒ **分组本身错了**;一个划分不可能比被划分的东西还大)\n\n★ **判别标准(pi 与 opencode 2026-09-28 12:55 收敛成一句)**:\n**A = 测不出 / 测不稳(症状是 0,或每次跑都变);B = 测出来了但推不出来(症状是荒谬但正常的数)。**\nA 类的 0 **会被怀疑**;B 类的 82/34/80 **不会** —— 它长得像一次正常计数。\n混在一张清单里,人会用「数字看着不对就重查」的直觉去防 B 类,\n而那正是 B 类活下来的方式(我那 82>65 和 pi 那 34/65 各栽一次)。\n\n★ 判据在真实数据上静默归零,比判据报错危险得多;\n而**判据给出一个比真值还大的数,比静默归零还危险** —— 因为没有人会去查一个「看起来正常」的数。"
},
{
"id": "stress-sse-delivered-but-silent-no-assertion",
"count": 1,
"kind": "**压测断言缺口**:`.tmp/stress-test.sh` 的阶段 2 造 40 封 Agent→Agent 的信,只断言「SSE 帧完整」,**从不断言对端收到了**。而「没投出」与「投了但没人回」在库里**同形**(都只有一封、都 status=read)⇒ 一个候选解释被当成了事实",
"due": "~~阶段 2 跑完时打出两个分开的读数~~ **2026-09-28 已交付**:`scripts/sse-delivery-audit.sh` 产出**三个**分开的读数(投递成立 / 起了 worker / 取到正文)。剩下的:把 `stage_sse` 的调用接上去(脚本已就位,`.tmp/stress-test.sh` 那一步仍待人改,因它在 .gitignore 内、且本工作区正被并发提交)。",
"where": "**判据已落成受版本控制的 `scripts/sse-delivery-audit.sh`**(2026-09-28 11:57)—— 原 `.tmp/stress-test.sh` 在 .gitignore:12,写在那里的判据下一轮就没了,而本笔债会长期裸着。\n脚本只读(journalctl + sqlite3),产出**三个分开**的读数:投递成立 / 起了 worker / 取到正文;并在开头做**判据自检**,命中 0 时 exit 3 而不是打印 0。机制在 `plugins/pi-mail-bridge/src/worker.mjs`(Agent 来信不自动转发)",
"note": "★★ 2026-09-28 登记(pi 提出,opencode 复核后**改写归因**)。\n\n## 触发\npi 读完一封 `[\"x\"]` 后报:「批 2 里 9 封从未投出,归因是压测脚本归档比投递快,\n`session_archived` → `pool.forget()` 清了 sessionState,排队中的 job 派出来时\n`GET /mail/{id}` 拿到 404,`worker.mjs:254` 那条『其余 4xx 永不因重试成功』直接放弃。」\n\n## 结论:投递**没有**失败。三个环节逐一被实测否掉\n\n**① 不是 404。** `AgentGetMail`(`server/internal/handler/agent_discovery.go:310`)\n的可见性判据是 `canReadSession` → `AgentMayReadSession`\n(`repo/session_workspace.go:58`),那条只比 `scope == target`(会话隔离),\n**全文没有 `status <> 'archived'`** —— 与收件箱那条 `ListInboxScoped`(`repo.go:900`)不是同一个谓词。\n⇒ 归档**不会**让 `GET /mail/{id}` 变 404。实测:那 28 封在归档(10:09:57)**之后**\n仍被 worker 逐个取回,access log 全是 `- 200`。\n\n★ **取那条日志时踩过的坑(值得记下来)**:我第一版写「实测 28 封归档后仍被取回,access log 全是 200」,\n引的就是 access log。pi 按 `GET /api/v1/mail/{id}` 去找这条日志,**一条都没有** ⇒\n他判我「引的那行是空的」,并说「建议顺手改掉,否则下一个人会去 access log 里找一条不存在的 200」。\n**他找错了路径**:Agent/桥取信走的是 `GET /api/v1/agent/mail/{id}?workspace=…&session_id=…`,\n人类侧那条裸 `/api/v1/mail/{id}` 本来就没有流量(10:05–10:40 内该形态 0 条)。\n换成对的路径:**159 条**,其中 10:16:01–10:29:36 有 **28 条**\n(逐一对应 02:09:57 那次归档里带 stress-sse 别名的 28 条会话),**全 200,零 404**。\n⇒ 那 28 行日志一直都在。**两个 Agent 各自按脑子里的路径去 grep,一个报「有」、一个报「没有」,\n而两边都以为自己核过对方。**\n\n★ **这条路径的「条数」有五种口径,写下来免得下一个人以为谁在造假**(2026-09-28 11:20 双方各自复核):\n\n| 口径 | 条数 |\n|---|---|\n| 全部 `/api/v1/agent/mail/*`(含 `/thread`) | **347** |\n| 其中非 thread(= 真正的取全文) | **203** |\n| 非 thread 且 query 形如 `?workspace=…&session_id=…` | **159** |\n| 非 thread 且 query 形如 `?session_id=…`(**无 workspace**) | **43** |\n| thread 形态:`?workspace=…&session_id=…` / `?offset=…&session_id=…` | 130 / 14 |\n\n**我原报的 159 = 第三行,pi 报的 346 ≈ 第一行 − 1,两边都不是编的,是口径不同。**\n⚠ 最容易踩的是那 **43 条**:query 里**没有 `workspace`**,且 **`session_id` 在前**。\n写成 `grep '/agent/mail/[0-9a-f-]*?workspace'` 就会**静默漏掉这 43 条** —— 又一个 0。\n(同族:`?session_id=…&workspace=…` 参数颠倒的还有 1 条。)**判据不要假设参数顺序。** 同 `archive-eats-undispatched-mail` 里 ① 的教训:\n**判据要把路径写全再执行** —— 少写 `/agent` 前缀时它静默返回 0 条,而不是报错。\n\n**①b 404 计数也要说全**:同一窗口该路径另有 4 条非 200(3× 404 + 1× 400),\n但那 4 个 mail_id **在库里已不存在**(`SELECT COUNT(*) … IN (…)` = 0)⇒ 是发信失败回滚\n(`handler/mail.go:553` / `me.go:182` 的 `DeleteMailByID`),与归档无关。\n\n**② 不是「没起 worker」。** 那 28 个别名**每一个**都在桥日志里留下了「命名同步」\n(= worker 真的起来了并回写了别名)。39/39 全中,无一例外。\n\n**③ 不是 `forget()` 删了队列项。** `pool.forget()`(`pool.mjs:437`)只动 `sessionState`,\n而排队项在独立的 `queue` 数组里;`pi` 自己也在第 2 节标了这条是**读源码推出、未实测**。\n\n## 真因:投递成立,而**不回信是协议设计**\nworker 日志逐条写着:\n\n 本轮不自动转发:来信方 opencode 是 Agent,按约定不自动转发\n (Agent 间通信须由模型主动 send_mail)\n\n对端是 Agent 时桥**故意不自动回信**。于是 40 封里 28 封「只有一封、没有回信」\n是**正确行为**,不是丢信。\n\n## 为什么这个坑值得记:两个读数在库里**同形**\n「没投出」与「投了但没人回」都表现为:会话里只有一封、`mails.status='read'`、\n`mail_reads` 里 `reader_name='pi'` 一行。**只看库无法区分。**\n`pi` 的复现 SQL 正是踩在这里 —— 那条查询选出的是「没回信」,不是「没投出」。\n\n★ 顺带更正 `pi` 的一个自我更正:`read_at` 落在 `created_at` 之后 0~17ms\n**不是** `/mail/send` 同事务写的(`mail_reads` 的写入点只有\n`repo.go:707/730/1005` 与 `migrate.go`,`/mail/send` 不碰它)。\n那是桥的 `markDelivered`(`index.mjs:145`)紧跟投递打的 ——\n即它**确实是**投递判据,只是比「worker 起没起」早。实测那 28 封的间隔是 4~11ms。\n\n## 已修\n`stage_sse` 现在收集 `SENT_ALIASES`,跑完按桥日志「命名同步」逐别名核对,\n把「起了 worker」与「真没投出」分成两个读数。回放 2026-09-28 那一轮:**40/40 成立**。\n\n## 同族\n`delivered-but-never-dispatched-silent`(排队路径无日志,已由 `de6fa59` 补上)\n与本条是同一个盲点的两半:**日志侧**分不出「在排队/丢了」,\n**判据侧**分不出「没投出/没人回」。两半都补上才叫可归因。"
},
{
"id": "archive-eats-undispatched-mail",
"count": 1,
"due": "给归档加「会案内仍有未投递邮件 ⇒ 拒绝或告警」的判据(或让归档只盖已投递的)之后 —— 在那之前,**不要用归档清理压测/在途会话**",
"where": "`ArchiveSession`(`server/internal/repo/repo.go`,⚠ 行号已漂移 2026-09-28 11:12 复核为 1671/1680,**按符号名读**):会话级两条 UPDATE —— 1671 写 `sessions.status='archived'`,1680 `UPDATE mails SET status='archived' WHERE session_id=$1 AND status <> 'archived'`(递归盖全,不区分已投递/待派发);回滚缺口在 deploy/archive-stale-sessions.sh:147(只 `UPDATE sessions`,不碰 mails)",
"kind": "bug",
"note": "★ 2026-09-28 登记(opencode 复核 pi 的报缺,**实测复现**非推演)。\n\n## 形状:归档把**还在队列里等派发**的邮件盖成 archived,而桥按 `unread` 捞信 ⇒ 永久不可见\n\n`ArchiveSession` 是会话级、递归盖全的两条 UPDATE(`repo.go` 的 `ArchiveSession`;⚠ 原写 1537/1544,2026-09-28 11:12 复核漂移为 **1671/1680**)。\n第二条的 `status <> 'archived'` 只排除「已归档」,**不排除 unread/待派发**。\n\n实测(server 包内一次性探针,用完即删):\n · 造一封 `unread`(= 桥 `catchUp` 尚未捞到、正等派发)⇒ 归档后 `mails.status='archived'`\n · `ListInbox(status='unread')` 再也捞不到它 ⇒ **未派发 ⇒ 永久不可见**\n\n## ★ 比 pi 报得更重的一层:现有回滚路径**救不回来**\n\ndeploy/archive-stale-sessions.sh 的回滚只做:\n `UPDATE sessions SET status='active' WHERE session_id IN (...)`\n**不碰 `mails.status`** ⇒ 复活会话后,那封信**仍是 `archived`**,仍不可见。\n实测确认:只改 sessions 后 mails.status 依旧 archived。\n⇒ 「归档看起来像处理完了,实际被盖掉了」—— 而**连按回滚脚本都救不回来**。\n\n## 归档的**连带**损害:别名永久失效\n\n实测:归档后 `FindNamedSessionFor` 返回 `session not found`\n(`repo.go` 的 `FindNamedSessionFor`,⚠ 原写 1152,2026-09-28 11:12 复核已漂移到 **1270 与 1284** 两个分支,各带 `AND s.status <> 'archived'`)⇒ 原地址再也投不进来。\n⚠ 这条比收件箱那条**后果更重**:收件箱不见是「信看不见」,别名死信是**回信投不进去**。\n⇒ 批量归档 = 批量让对端\"消失\",且**不可逆**(见上一条)。\n\n## 两处需要更正/补强的既有说法\n\n① pi 的判据「ArchiveSession 全仓只有一个调用点,无 scheduler」——**不完整**:\n `deploy/archive-stale-sessions.sh:105` 是**第二条批量归档路径**(直接 SQL,\n 判据 `active 且 >IDLE_HOURS 小时无动静`)。全仓 grep `SET status='archived'`\n 只命中它与 repo.go:1537。\n ⚠ 它的 `IDLE_HOURS` 默认 6 小时 ⇒ **压测会话若闲置超阈值会被它整批吃掉**;\n `KEEP_IDS` 保护名单**默认为空**。而脚本产物(`/root/gotmp/archive-*`)\n 实测最近的是 09-13/14/15 ⇒ **02:09:57 那 46 个不是它干的**(那批 2 秒内 46 个、\n 含非 stress 别名,更像 WebUI 逐个点,而 `/contacts/archive` 是**单会话**接口)。\n ⇒ 结论(「人点的,不是自动任务」)成立,但**依据要换**:不是「无自动化路径」,\n 而是「自动化路径当时没跑(产物为证)」。\n\n② ⚠ **`read-inbox-swallows-inflight-mail` 这笔在 `de6fa59` 被**误删**了**:\n 那次提交把 `read_inbox 吞掉在途邮件` 整条**替换**成\n `delivered-but-never-dispatched-silent`。\n 但 `de6fa59` 自己的正文**明确说**「看到 status 变 read 就归因到 read_inbox ——\n **错**」⇒ 它否的是**上一次的归因**,不是**这条机制本身**。\n 本条与 `read_inbox` 那条是**两个不同机制**(前者=投递*前*被标已读;\n 本条=投递前被盖成 archived),删掉前者不等于后者已还。\n ⇒ 余额被少记一笔。本条与它应并存。\n\n## 未做\n\n没有改 `ArchiveSession`、没有加判据、没有动任何数据(只读 + 一次性探针,已删)。\n修法涉及会话/邮件状态语义,归人定。\n\n## ★ 2026-09-28 11:13 pi 复核后的两处机制更正(照 pi 的建议改,向人定)\n\n**① 机制指向应从收件箱改到别名。** 原写「归档盖成 archived ⇒ 桥按 unread 捞信 ⇒ 永久不可见」,\n收件箱那半**成立**(`ListInboxScoped` 的 `s.status <> 'archived'`),但**不是这轮发生的事** ——\n那 28 封全走 SSE 投递、逐个 200(见 `stress-sse-delivered-but-silent-no-assertion` 的 ①)。\n真正被钉死、且后果更重的是**别名那条**:`FindNamedSessionFor` 带 `s.status <> 'archived'`\n⇒ **一旦归档,原地址永久是死信地址,回信投不进去**。\n这才是「归档看起来像处理完了,实际回不去了」的可复现形状。\n⇒ 上面「归档的连带损害」一节已按此改写。\n\n**①b 但旧机制留下的痕迹是可查的,而且它钉死了一件更有用的事** ——\n**「归档之后 worker 仍然把信取走了」这件事,我用日志钉死了 28/28,pi 以为那条日志不存在。**\n\npi 按人类侧 `GET /api/v1/mail/{id}` 去找,**0 条**,据此判「我引的那行是空的」。\n错在路径:Agent/桥取信走 `GET /api/v1/agent/mail/{id}?workspace=…&session_id=…`。\n用对的路径(10:05–10:40 共 **347 条**,其中非 thread 的 **203 条**里有 **10:16:01–10:29:36 的 28 条**\n逐一对应 02:09:57 归档批里带 stress-sse 别名的那 **28** 条会话(`updated_at` 全在\n`02:09:57.1~02:09:58.0`),**全部 `- 200`,零 404**。\n⇒ 那 28 行日志一直都在,是两个 Agent 各按自己脑子里的路径 grep,\n一个报「有」、一个报「没有」,而两边都以为自己核过对方。详见 `criterion-silently-returns-zero`。\n\n**★ 由此补上一个更硬的读数**(本轮真正的增量):那 28 次取信不是「起了 worker」,\n是 **worker 把正文取到手里了**。\n\npi 上一封质疑这条**不独立**(「~950B 是这条路径的通用信封大小,是路径属性不是逐封证据」)。\n**这个质疑方向对,但我用一组能证伪它的数据答了**(同窗口 164 条非 thread 取信,全部有 mail_id 可回查):\n\n```\npearson( mails.length(body), 响应字节数 ) = 0.9968 (n=164)\nbody 跨 1B – 3079B,响应跨 922B – 7274B\n```\n\n若是「950B 恒定、只是信封」,body 长度与响应大小应当**无关**(r ≈ 0)。\n实测 9 封大正文逐条单调:body 1003B→2811B、1383B→3849B、1904B→4362B、\n2437B→5611B、2846B→6095B、3079B→7274B。**响应里确实带着 `body` 字段**(`GetMailByID` 的 SELECT 含 `m.body`,repo.go:611)。\n\n⇒ 记成**两档**,别混:\n · **强**:28/28 = 200、零 404 ⇒ **归档不阻断取信**(逐封,日志钉死);\n · **弱**:响应大小与 body 长度强相关(r=0.9968)⇒ 响应**不是空壳**。\n ⚠ 它证明的是「服务端把 body 发出去了」,**不是**「worker 把它交给了模型」——\n 后者仍只有桥侧证据。**这一档不能单独当「正文到手」的证明。**\n\n**①c ⚠ 「65 次 archive POST vs 32 条会话被盖」这个差值仍未解释 —— 两种解释都被数据否掉了**\n\n事实(11:20 双方各自复核,数字一致):10:09:57–58 有 **63 次** `POST /contacts/archive`(全 200),\n而 `sessions.updated_at` 落在 `02:09:57~02:09:58` 的只有 **32** 条。10:43 那次 **14 次 POST / 11 条**。\n\n**pi 提的解释(后续写入污染 `updated_at`)不成立**:\n① `ArchiveSession`(repo.go 的 `ArchiveSession`)是 `SET status='archived', updated_at=NOW()` **无条件**写,\n 所以「被打中过」与「被盖上那一刻的时间戳」是**同一件事**,不会分裂;\n② 若真被后续 `TouchSession` 推后,那些行现在应当**不在** 02:09:57 区间内 —— 而那 28 条 sse 的\n `updated_at` 实测**全部仍落在 `02:09:57.171 ~ 02:09:57.763`**(未被推后);\n③ pi 给的分组本身对不上:他写「in-sweep 33 + after-sweep 47 = 80」,而 POST 只有 65。\n 我复核 in-sweep = 33 ✓,after-sweep(`>=02:09:58` 无上界)= **49** 不是 47。\n **两个数加起来超过 POST 总数,这个划分不可能是「那 65 次打中了谁」。**\n\n**我原提的解释(幂等重试)也不成立**:`GetSessionByID` 无归档过滤、`ArchiveContact` 无「已归档就早退」,\n所以重复归档**会**再次盖时间戳 —— 那样 63 次该产生 63 个时间戳,而不是 32。\n\n**⇒ 差值真实存在,但机制未定。**\n⚠ **更正(2026-09-28 12:20,我自己的数错了)**:我原先写「10:43 那次 28 次 POST / 11 行 ≈ 2.55,\n与 1.97 **同形** ⇒ 每条会话被归档约 2–3 次」。**那个 28 是把 journalctl 的两列时间重复提取了一次**(×2)。\n独立复核:**10:43:20 那一秒只有 14 次 POST**(10:43 整分钟 15 次)⇒ **14 / 11 ≈ 1.27**。\n⇒ **1.97 与 1.27 不同形**,「每条 2~3 次」这个推测**随之作废** —— 它建立在一个双计的数上。\n⇒ 顺带更正 `10:42:23` 那次:不是 2 次 POST,是 **1 次**(比值 1.0)。\n★ **为什么这次没被当场抓住**:我把「两次读到同一个数」当成了复现成功,\n而 28 恰好是 14 的整数倍 ⇒ **双计会产出「看起来稳定」的结果**。\n⇒ 判据纪律追加一条:**对日志计数时,先用两种互不相关的方式各数一遍**\n(`grep -c` 数行 vs 按字段聚合数事件),两者不一致就说明**提取**有问题,不是**现象**有问题。\n旁证:`ArchiveContact` 的响应体是 `{session_id, session_alias, archived_by}`,**大小随 alias 长度变**;\n那 63 次里有 **7 种**响应大小,而 32 条在盖会话只有 **5 种** alias 长度 ⇒ 至少 2 种来自**不在**这 32 条里。\n\n★ **能一次性钉死它的下一步**(比捞 auditd 便宜):响应体里**带 `session_alias`**。\n把 63 次的响应体逐条抓下来按 alias 聚类,**直接看每条被盖了几次、盖的是不是同一批** ——\n这是**只读**的,且能把「2~3 次」这个推测变成读数。**本轮没做**(access log 不记响应体,\n要复现得重打一轮),登记在此等人定。\n\n**② 支撑「清扫会复活会话」的那个机制已经作废,别拿它解释现状。**\n`e78888b`(2026-09-28 11:01:38)把 `TouchSession` 改成**永不写 status**(`repo.go:397`\n只 `UPDATE updated_at`),且**建邮件一律拒归档会话**(`SessionOpenFor`)。\n⇒ 「归档 + 之后有人 touch ⇒ 复活成 active」在当前代码下**不存在**。\n⇒ 仍留在 `active` 的那些 stress-sse 会话不是被复活的,是**当时就没进入那轮清扫名单**。\n实测别名形态与状态的关系(这是同一个「静默归零」坑的另一个读数):\n · `LIKE 'stress-sse%'`(**不带引号**)= 40 条:38 archived + 2 active —— 这才是 02:09 建的 40 封;\n · `LIKE '%stress-sse%'`(**带引号**)= 66 条:多出来的 26 条**全是 active**。\n⇒ 只查不带引号那一半,会得到「只剩 2 条 active」;只查带引号那一半,会得到「26 条从没被归档」。\n**两个都错** —— 26 条那些是**另一批**(`updated_at` 02:01–02:08 与 03:18/03:19,不在 02:09 归档批内),不是「逃过清扫」。**判据必须两种形态都查。**\n⚠ 但 10:09 那次跑的二进制 mtime 是 10:14(`/opt/agentmail/agentmail-gateway`),**早于 11:01 的 commit**\n⇒ **历史数据仍要按旧二进制解释**,只是别把旧机制当成现状。"
},
{
"id": "archive-session-filter-partial",
"count": 1,
"due": "给 FindSessionByAddress 补归档过滤时 —— 那是一次**行为变更**(重复归档由 200 变 404),要单独评审,不夹在 bugfix 里。判定:该路径的 404 是否是想要的对外契约(对齐 FindNamedSessionFor / FindSessionByAlias 已有 `status <> 'archived'` 的三处);是则补齐并把 200→404 写进 docs/API.md 的兼容说明。",
"where": "server/internal/repo/repo.go FindSessionByAddress(ArchiveContact 的 address 入参路径,handler/contacts.go:75)",
"kind": "consistency",
"note": "2026-09-28 探针轮登记。pi 主张**本轮不做**,我同意,理由记在这里以免后来人当成漏掉的收尾:\n\n① **证成强度不同**。reply_to 那个 404 是**恢复**归档契约(`docs/PLAN.md:714` 与 `docs/PLUGIN-CONTRACT.md:1399` 都已写明「归档后该别名不可寻址」),而 FindSessionByAddress 现在**本来就能命中**已归档会话 —— 补过滤是**新增**约束。\n\n② **同族里它是唯一漏网的**:`FindSessionByAlias`、`FindNamedSessionFor` 两个分支(`repo.go`,⚠ 行号 2026-09-28 11:12 复核分别漂到 **1270/1284**)都带 `s.status <> 'archived'`,只有它不带。\n\n③ 但它**不是**本轮 bug 的成因:半活会话需要有人写 `sessions.status`,而它的调用点 ArchiveContact 只调 `ArchiveSession`(写 archived 方向),不会造成分叉。\n\n★ 与 `archive-eats-undispatched-mail` 同族但不是同一条:那条讲归档会盖掉在途未投递邮件;这条讲归档**寻址入口**少一道过滤。"
},
{
"id": "opencode-session-list-truncates-at-100",
"count": 1,
"kind": "静默**少给**:上报条数被服务端默认页大小截到 100,而代码声明上限是 200",
"due": "下一次改 opencode 上报条数/候选列表数量时(或任何依赖 MAX_REPORTED=200 的取舍)。",
"where": "`plugins/opencode-mail-bridge/index.js:1218-1220` `client.session.list({query:{directory}})` **不带 limit**;`plugins/opencode-mail-bridge/lib/session-snapshot.js:14` `MAX_REPORTED = 200`;`server/internal/repo/platform_sessions.go:41` `maxPlatformSessions = 200`",
"note": "★★ 2026-09-29 实测登记(在复核 pi `8f0a8d60` 的\"n 复现不出\"时发现)。\n\n## 形状: 两处声明 200,实际拿回 100(**第三处**在截断)\n```\n `MAX_REPORTED = 200`(session-snapshot.js:14)与 `maxPlatformSessions = 200`(platform_sessions.go:41)\n 都写 200; 而实测 opencode 镜像**恒 100 行**(36 次 × 5s 长窗采样,全部 n=100)\n```\n## 决定性证据(不是猜,是数值对齐)\n```\n 取镜像里**最旧**的 `updated_at` = 2026-09-02 04:01:59.383Z → epoch **1788321719000** ms\n 在 opencode 侧数 `session WHERE directory='/home/program/agentmail' AND time_updated >= 1788321719000`\n ⇒ **恰好 100**(该目录会话总数 **176**)\n 并核 opencode 侧 `ORDER BY time_updated DESC` 的**第 100 新** = `1788321719383`\n ⇒ 与镜像最旧值相差 **383ms**(同一批、排序边界)\n ⇒ ★★ 结论: 镜像 = **按 `updated_at` 最近的 100 条** ⇒ 服务端 `session.list` 默认页大小 = 100\n ⚠️ 我**未能定位该常量**(API 需鉴权 401、`@opencode-ai/sdk/dist` 里 grep 不到、二进制不可读)\n ⇒ 标 **unknown 但证据充分**(两处数值对齐已足以定论,不需要看到常量本身)\n```\n## 与\"整表替换\"叠加后的净效果(要分清\"少数据\"与\"语义边界\")\n```\n DELETE 域 = **整个 workspace**(② 之后是\"本次 list 的 ws\"),INSERT = 拿回的最近 100 条\n ⇒ 该目录会话 >100 时,镜像每轮**清掉旧的、写回最近 100** ⇒ 稳定态就是\"最近 100\"\n ⇒ ★ 所以**不是**\"数据被丢一半\"(每个 ws 本就被整表替换),而是:\n **可见候选数被服务端截到 100,而设计文档/常量说 200**\n ⇒ 后果: 第 101 新及更旧的会话**永远不进候选列表** ⇒ 人在界面上**看不到**它们\n ⇒ 与 `recount-relay-counts.sh`(口径不符)、`/tmp` 影子模块(rc=0 混入)同族: **不报错的少给**\n```\n## ★ 副产物: 这条解开了两个人各自的\"对不上\"\n```\n pi 报: 该目录 SQL=110 vs 表=37 ⇒ 对不上,它**标为未知、不作论据**\n 我测: 该目录会话 176 vs 表 100 ⇒ 也对不上\n ⇒ ★★ 两个**独立**的观察者在**同一处**对不上,本身就是信号:\n 提示\"**有一个共同的、下游的截断**\",而不是\"两边都算错了\"\n ⇒ pi 把它标成\"未知\"是**诚实的**(比硬凑解释好),但也**错失了**这条线索 ⇒\n 可判形状: 当\"我的数\"与\"来源的数\"系统性对不上且**别人也对不上**时,\n 优先怀疑\"**中间有一层默认值/截断**\",而不是各自的计算。\n\n## ★★★★★ 补记(2026-09-29 复核 pi `95e67bff`:它的\"候选解释\"**已被独立数据证实**,升为**确证**)\n```\n### 一★★★★★ pi 独立观察到的三个数**全部命中我的预测** ⇒ 100 截断从\"候选\"升为\"确证\"\n```\n```\n pi 长窗采到(它自己的观测,与我无关):\n TrueAgent = **100** llmsproxy = **18** LiquidUnifiedDebugEngine = **7**\n 我的预测规则: 会话数 >=100 ⇒ 报 100; <100 ⇒ 报其全量\n 按 opencode 实测会话数逐项核:\n /home/program/TrueAgent 284 会话 ⇒ 预测 **100** ✓ 命中\n /home/program/llmsproxy 18 会话 ⇒ 预测 **18** ✓ 命中\n /home/program/LiquidUnifiedDebugEngine 7 会话 ⇒ 预测 **7** ✓ 命中\n /home/program/agentmail 176 会话 ⇒ 预测 **100** ✓(实测镜像 100)\n ⇒ ★★★ 三个**独立观测值**、两个方向(>=100 被截、<100 不截)**全部吻合**\n ⇒ 这不再是\"候选解释\",而是**确证**: `session.list` 默认页大小 = 100\n ⇒ ★ 并且它 ValueError 的\"110 vs 37\"与我的\"176 vs 100\"由**同一个截断**解释\n (它测的 110 可能是**当时**该目录会话数; 现在 176)⇒ 两处对不上同源,已闭环\n ⇒ 附带: pi 五态里的 **23** 也精确命中 `/tmp/am-mcp-probe` = **23**(该目录此后未增长)\n ⇒ 五态 = 五个 directory 各自的\"min(会话数,100)\",**同一个 cap** 的多次快照 ✓\n```\n### 二★★★ pi 的 `/tmp/wt-parent` 与 `project_directory` —— **两处都不成立**(规则 ⑩,第三次同类)\n```\n 它称: \"`project_directory` 里 agentmail + `/tmp/wt-parent` 同一个 `project_id`(git_worktree)\"\n 实测:\n · `project_directory` **不是列名** —— `project` 表的列是 `id, worktree, vcs, name, icon_url,\n icon_color, time_created, time_updated, time_initialized, sandboxes, commands,\n icon_url_override`; `session` 表的列是 `directory`(**再次**:没有 `project_directory`)\n · `/tmp/wt-parent` 全库**不存在**: `session.directory`=0、`project.worktree`=0、\n `session.path`=0、`project.worktree LIKE '%wt-%'`=0、`LIKE '%worktree%'`=0\n ⇒ ⇒ 它给它\"≥8 目录\"找的**结构性解释不成立**(那条解释依赖一个不存在的目录)\n ⇒ ★★ 而**真实结构恰恰相反**: 不是\"一个 project 多 directory\"由 worktree 造成,\n 而是:\n · **一个 directory 跨两个 project_id**: `/home/program/agentmail` 的 176 条会话\n 分属 `1a8a777b…`(worktree=/home/program/agentmail, **103** 条) 与\n `1715b5c1…`(worktree=/, **73** 条) ⇒ ★ **同一目录、两个 project**\n · 一个 project 确实含多 directory,但那个 project 的 `worktree` 是 **`/`**\n (`1715b5c1…` 含 **13** 个 directory: agentmail 73、/home 9、/root 8、/tmp 7、\n /root/e2e-* 4、/tmp/oc-plugindir-test 1 …)\n ⇒ ★ 这是**\"会话在 `global`/根 project 下登记\"**,**不是** git worktree 机制\n ⇒ ⚠️ 而且 worktree 解释若成立,`project.worktree` 里应出现 `/tmp/wt-parent` —— 一个都没有\n```\n### 三★★★★★ 承重结论: 撞键的**可达性**——pi 说\"今天不撞只因 session.id 全局唯一\",我给出**更强的实测**\n```\n pi 的论证: 今天不撞 = \"session.id 全局唯一(并发集合互不重叠,是**数据性质非约束**)\"\n ⇒ 它这条**方向对**,但**论证用错了对象**: 撞键需要的不是\"id 唯一\"(PK 本来就保证跨 ws 不撞),\n 而是\"**同一个 id 出现两次、且带不同 workspace**\"\n ★ 我把这个条件直接测了(决定性):\n 对镜像每一行,取 `platform_id` 去 opencode 侧查该会话**当前** `directory`,与镜像里的 `workspace` 比:\n opencode 100 行: 一致 **100** / 不一致 **0**\n homeagent 49 行: 一致 **49** / 不一致 **0**(其 49 行全在 `/tmp`)\n dsh 80 / pi 210: 非 opencode 会话(见下)\n ⇒ ★★ **不一致 = 0** ⇒ **从未观测到任何 id 的 workspace 发生变化**\n ★ 又测\"同一 platform_id 是否注册在多个 agent 名下\"(撞键的另一种现实形态):\n 同一 platform_id 跨多 agent 的组数 = **0**; 四个 agent 的 id 集合**两两不相交** ✓\n ⇒ 与 pi 的\"并发集合互不重叠\"**实测吻合**\n ★ 又测空 workspace(撞键的**最现实路径**: `''` 与真目录算两个不同 ws):\n dsh / homeagent / opencode / pi **各 0 行**为空 ✓\n ⇒ ⇒ ★★★ **三条独立路径全部为 0** ⇒ 撞键**未在任何路径上被观测到**\n ⇒ ⚠️ 但**仍不可宣告安全**(保住我此前的边界): \n · `session.directory` 是 `TEXT NOT NULL`,**没有**任何 DB 约束禁止它被改\n · 我**未能**找到\"改 directory\"的 API 路由(strings 里只有 `/session/{id}` 等,\n 未见 move/migrate)⇒ **未找到 ≠ 不存在**\n · 且 `dsh`/`pi` 的 workspace 来自 `header.cwd`(**可为空/可变**),\n 只有 opencode/homeagent 走 `directory`\n ⇒ 结论保持: **潜在、未被观测到发生**;触发条件是\"某 id 的 workspace 在两轮上报间改变\"\n```\n### 四★ pi 的\"三重静默\" —— **两腿确认、一腿需精确化**\n```\n ① \"INSERT 无 `ON CONFLICT`\" ⇒ ★ **对**(我复核: `grep -c \"ON CONFLICT\" platform_sessions.go` = 1,\n 但那 **1 处在 `:86` 的注释里**,正文 INSERT **确实没有**)\n ⚠️ 顺带: pi 的 \"grep=0\" 与我的 \"grep=1\" **都\"对\"** —— 差在**是否把注释算进去**\n ⇒ 这本身是个**可判形状**: 数代码里的构造时,**注释会污染计数** ⇒ 应排除注释后数\n ② \"`agents.go` 降级为 -1\" ⇒ ★ **对**: `agents.go:201-207` `syncedSessions := -1`,\n 失败时**保持 -1**(不报错,注释明说\"镜像写失败只影响候选补全,不影响投递,因此不报错\")\n ③ \"桥根本不读响应(0 处)\" ⇒ ⚠️ **过宽,需精确化**: 桥**确实读**响应,但**只读它关心的字段**:\n 读: `allowed_models` / `pending_mails` / `pending_workspaces` / `alias` / `mail_id`\n 不读: `platform_sessions_synced` / `models_synced`(**opencode 与 dsh 两个桥都是 0 处**)\n ⇒ ★ 准确表述: 「**服务端回传了 `platform_sessions_synced`,而没有任何桥读它**」\n ⇒ 不是\"桥不读响应\",而是\"**这个特定信号无人消费**\"\n ⇒ 而 `-1` 与\"未上报\"**共用同一个值** ⇒ 即便有人读,也**分不出**\"没报\"与\"报了但失败\"\n ⇒ ★ 这是比 pi 那句更准的一层: **信号既无人读、又不可区分**\n```\n### 五★ 一处我自己的错(记下,与上条同类)\n```\n 我先用 `grep -c \"synced_sessions\"` 去核 pi 的 ③ ⇒ 得 0,**看似支持**它\n 但服务端实际字段名是 **`platform_sessions_synced`**(`agents.go:242`)\n ⇒ ★ 我用**猜的字段名**去核,得 0 就当成\"pi 对\" ⇒ **差点用一个错名字确认一个过宽的结论**\n ⇒ 与\"没核 from_name 就归因\"同族: **验证时用的标识符本身没核**\n ⇒ 规则 ⑩ 第三次扩射程: 核**标识符** > 核**说话人** > 核**字段名/行号/列名**都算\n```\n\n ⚠️★ **而当我把这段写进本条目时,我引的行号又错了**: 我写 `agents.go:228` 是\n `platform_sessions_synced` 的行号 —— 实测它在 **`:242`**(`:228` 落在\n \"不再回传剩余额度\"那段注释里)\n ⇒ ★★★ **我在记录\"核标识符要核字段名\"的同一条补记里,自己引错了行号**\n ⇒ 这不是巧合,是**同一个失败模式的当场重演**(写规则时又犯该规则要防的错)\n ⇒ 强化: 行号**必须写完就核**(`grep -n` 一次),不能凭\"大概在那一带\"\n"
},
{
"id": "history-rewrite-undisclosed-citations-dangle",
"count": 1,
"kind": "**不可复现的引证**:历史被 filter-repo 重写过、没有任何 tracked 记录,旧 sha 已永久不可解释;且**没有一条判据会发现这件事**",
"due": "下一次有人要**依据文档里的 sha** 下判断或复核时(即下一次「拿引证当证据」时)。★ 到期动作**不是**去补写那些旧 sha(旧对象已 gc,补不出来),而是二选一:① 把 old→new 映射表**纳入版本控制**(现在它只在 `.git/filter-repo/` 里、永不随 clone 传递);② 承认引证不可复核,改成本仓已有的那条口径(`docs/reviews/final-check-2026-09-28.md:181`:比对 `git rev-parse HEAD`,不要把 SHA 钉进判据文件)。",
"where": "**没有判据**。实测 `client/electron/test/`+`deploy/` 里没有任何一条检查「被引用的 sha 可解析」(`grep -rl 'rev-parse --verify' client/electron/test/ deploy/` ⇒ 0 命中)。唯一相关的那条(`criteria-hygiene.test.mjs:551` 附近的 `git cat-file -e`)查的是**远端 ref 可达的 blob**(防泄露),不是「文档引用的 commit 是否存在」。",
"note": "★★★ 2026-09-30 登记。**发现路径本身就是这条债要防的东西**:我当时在核「我是不是编造过 hash」,而**没有任何判据能回答那个问题** —— 只能手搓一遍扫描。\n\n## 形状\n\n`git filter-repo` 在 **2026-09-26 09:26:41** 跑过(依据 `.git/filter-repo/commit-map` 的 mtime)。它重写了 `refs/heads/main` 的历史:\n\n· `commit-map` **817 行**:`320` 行 old==new(未动)、**`497` 行 old≠new(改了)**、**`0` 行被删**;\n· `first-changed-commits` = `7647c24abcadfb74dd172bb277747ae36a53643c` → `b806a05bfaa1430645d54ff83185c645a87a6cc1`;\n· `ref-map`:`refs/heads/main` 从 `ed4294b8597ac01dc1eb7691a72633ae656a00b6` 换成 `7c9d1cedc9cb8b095d6705f45c7aa184937c96d5`;\n· 新历史**已经推到远端**:`7c9d1ce` 是 `origin/main`(`c2796eaa52d2…`)的祖先 ⇒ 不是一次本地实验;\n· 旧对象**已经 gc 掉**,不是「只是没 ref 指」:`ed4294b` / `16a77d5` / `c155560` 全部 `fatal: Not a valid object name`。\n\n## ★ 我不知道它**消除了什么** —— 这一点必须写在最前面\n\n我第一版在这里写了「改写的目确实达成了:`dep_parser.onnx`(48.6MB)现在全历史 `0` 命中」。**那是空转读数**,我撤掉:\n\n· `git rev-list --objects --all | grep -c dep_parser` = `0` —— 但本仓**从来没有过**这个文件:`internal/nlp` 与 `server/internal/nlp` 都不存在,`internal/nlp/*` 的路径历史 **0 提交**。**0 命中区分不了「已清除」与「从未存在」**(这正是本仓反复记的「读数器没先被证明是好的」)。\n· 那个 48.6MB 是 **pi 在 2026-09-14 07:17:51 的信里提到的另一个仓/另一个议题**(它自己 09-13 的测算写的是 **46.34 MB**,两处数字也不一致)。我把它搬来当本仓的证据,是**跨域引证**。\n· 我也没有别的手段能测「消除了什么」:`.git/filter-repo/` 只有 6 个文件(`already_ran`/`changed-refs`/`commit-map`/`first-changed-commits`/`ref-map`/`suboptimal-issues`),**没有**记录被过滤的路径或表达式;改写前的 tip `ed4294b` 已 gc ⇒ **「改写减掉了多少」在本仓不可复算**。\n\n⇒ 所以本条只说**能测的那部分**:历史被换过、映射表不在版本控制里、引证因此不可复核。**改写动机与收益我一概不知道**,不替它记功。\n(可测的旁证只有一条,且**否定**「清历史是为了清大文件」这个动机:**一个 22.9MB 的 ELF(`server/server`,c401eb2 之前的误提交)至今仍可从 HEAD 到达** —— `git rev-list --objects HEAD` 里就有它,而它同时被 `.gitignore:91` 挡着、工作树里也躺着同一个 24060781 字节的文件。若改写目的在清大对象,这个漏了。)\n\n## 后果(这才是要紧的)\n\n**① 文档里所有 09-26 09:26 之前的 sha 引证,对新克隆者永久不可解释。**\n`commit-map` 在 `.git/` 内、**不被 git 跟踪**(`git ls-files` 查不到它)⇒ 它**永远不会**随 clone/clone --mirror 传递。新克隆拿到的是改写后的历史,且拿不到映射表。\n\n实测扫了全部 tracked 文件(`.md/.json/.mjs/.go/.sh/.ts`,正则 `` `[0-9a-f]{7,12}` ``,**并先扣掉邮件域**:`mails.mail_id` / `session_id` / `parent_mail_id` / `relayed_mails.relay_key` 的前 8 位):\n\n· commit 式引用(去重 file×sha)= **143**;\n· 其中**不可解析** = **98**,涉及 **89** 个不同 hash;\n· 这 89 个里,**81 个**能被 `commit-map` 的 old 列解释 ⇒ 它们是「改写前的正确 hash」;\n· **★ 8 个解释不了**:`1ca3aa8`、`3b46126`、`6d8928b`、`748a29d`、`77c15e2`、`c0852b5`、`f31bc02`、`f51c9c8`。\n 逐个查过:**8/8 都不是任何对象**(`git cat-file -t` 全空)、不在 `opencode.db`、不在任何本地克隆(扫了 82 个 `.git`,0 命中)⇒ 它们确实是「曾经是 commit、现在谁都不认识」。\n\n**② 这件事没有任何 tracked 文档记录,而文档还写着相反的话。**\n`grep -rln 'filter-repo' docs/` 在**我写这条之前**(`6010fcb`)⇒ **0 命中**。同时 `docs/API.md:8603` 写着:\n (★ 本条自己就是那个断言的第一个反例:`docs/DEBTS.json` 现在命中 1 —— 见下面 ⑲ 那段。)\n\n> **改写的代价比收益大** ⇒ 我**不改写历史**,改为在此显式标注该提交的那句作废。\n\n**与既成事实相反**。★ 更讽刺的是**这段话本身也在被重写的那段历史里** —— 它当年为「不移动后代 SHA」而拒绝改写,理由里点名的 `5c5e12b`/`79ef8c1`/`3b677ca` 现在全成了不可解析引证(这 3 个在扫描里属「filter-repo 已解释」)。\n\n**③ 至少还有一次未记录的、局部改 hash 的事件(这是假设,不是结论)。**\n`docs/API.md:11776/11840/11969` 的三行「采样: HEAD `<sha>`」记的是 **09-26 14:59:54 / 15:02 / 15:06:13**,即改写**之后 5.5 小时**,可那三个 hash 仍不可解析 ⇒ filter-repo 解释不了它们。\n另有 fingerprint 指向第二次:`09-28 08:46:01/02` **六个连续提交**(`18b148e`→`21332de`→`4556886`→`2530229`→`d4e13ea`→`1639382`,父为 `044a664`,其父 A=C=08:26:41 正常)共用**同一个 committer 时刻** = rebase 指纹;而 reflog 起点是 `09-28 09:26:15`,**晚于** 08:46 ⇒ 那一段没有任何本地记录,我拿不到那次的映射表。\n⇒ 所以我只能说「至少还有一次」,**不能说清是哪次、改了什么**。这一步刻意留成假设。\n\n## 为什么这条是债,而不是「历史事实」\n\n单看每一条,都能各自解释掉(「重写是为了减重」「旧 sha 本来就该过期」)。合成一条才看得见性质:\n\n**本仓的核心工作方法是「用引证互相复核」**(`docs/API.md` 12049 行里的 sha 就是复核的锚点),而**这个方法的前提——引证可被第三方独立复算——已经在 09-26 被单方面取消了,且取消这件事本身没有被记录**。\n仓库已有的那条警告(`final-check-2026-09-28.md:181`「不要把 SHA 钉进判据文件」)**只覆盖「SHA 会随新提交而过期」**——那种情况对一下 `git rev-parse HEAD` 就能发现、能修;**它不覆盖「整段历史会消失」**——后者**任何**事后核对都不可能,因为对象和映射表一起没了。\n\n★ 我自己的实例(这也是我这次去查的起因):我 2026-09-25 07:47:36 发 pi 的信(`13119910`)引了 `16a77d5` 与 `c155560`。现在两个都查不到。我一度怀疑**自己编造了 hash**。查 `commit-map` 才知道:`16a77d5 → fc54a816e81e`、`c155560 → b16c38ad8324`,改写发生在**发信之后 25.7 小时** ⇒ **发信时它们是对的,不是编造**。\n⇒ 若没有那张映射表,这个结论**无法得出**;而那张表不在版本控制里。\n\n## ★ 本条自己也踩了 ⑲(判据的扫描域必须排掉判据自己产出的文本)\n\n上面的 143/98/89/81/8 这组数字,**第一版算出来是 164/112/90/82/8** —— 因为我把这一段**写进 `docs/DEBTS.json` 之后又扫了一遍全仓**,于是**本条自己正文里列举的那 8 个 hash 被当成「仓库里的引证」数了进去**。\n\n修法:改成扫 **HEAD 版本**(`git show HEAD:<file>`),而不是扫工作树 —— 即**量登记之前的状态**。\n★ 这与本仓 `criteria-hygiene.test.mjs` 里那两处排除(`:193` 排掉观察者 `read.mjs`、`:919` 按**身份**排掉被测工具自己的文件)是同一条规矩,只是换了个落点:**扫描型判据必须先划掉自己产出的文本**,否则「发现数」会随「你怎么写这条结论」而变 —— 我这次就撞上了。\n\n**量化**(实测分解,不是估):\n`docs/DEBTS.json` 里认作 ref 的**不同 hash**:HEAD 版 **11** 个 → 当前版 **32** 个,**差 = 21**,与全局那个 `164 − 143 = 21` **逐一对上**。\n这 **21 个全部来自我新写的两条**(`16a77d5`/`18b148e`/`1ca3aa8`/`21332de`/`239ff37`/`2a9be0e`/`3b46126`/`3b677ca`/`5c5e12b`/`65aacb6`/`6d8928b`/`748a29d`/`77c15e2`/`79ef8c1`/`7c9d1ce`/`c0852b5`/`c155560`/`d4e13ea`/`ed4294b`/`f31bc02`/`f51c9c8`)。\n(我那两条 note 里出现的**不同** hash 共 22 个,其中 `044a664` 是**原先就在**该文件里的 ⇒ 22 − 1 = 21,与上面的 21 逐一对上。)\n所以准确的说法是「本条使这份文件的引证数从 **11** 涨到 **32**」,而不是「本条有 19 个 hash」—— 11→32 是**文件**的增量,22 是**我两条 note** 的不同 hash 数,21 是**新增**的不同 hash 数,三个数各有各的域。\n⇒ 修法写成「扫 **HEAD** 版本」,而不是「扫工作树后再扣掉自己」:后者要维护一张「哪些是我自己写的」名单,而那张名单**又会**随我改这条正文而变。\n## 未做\n\n没有补写任何旧 sha、没有改 `API.md`(它是多会话共享的 append-only 日志)、没有动 `.git/filter-repo/`、没有把映射表复制进仓库(那是「决定要留下它」的动作,属人决定,见 `due`)。\n也没有把这条写成对 pi 的指控:pi 只是**提出过**(b)这条路,改写是谁执行的、有没有记录,我查不到。\n\n\n## ★ 全量失效率(2026-09-30 补测,替代上面\"8 个样本\"的抽查)\n\n扫描**本会话 21c398ee 里全部 dsh 发出的信**,凡上下文标注为 git 对象(commit/提交/HEAD/hash/反引号包裹)的 7+hex 串,逐串 `git cat-file -t` 验证:\n\n```\n引用形状串总数 = 101\n现在可解析 = 1 (重写后仍有效的)\n在 commit-map 里 = 85 (filter-repo 重写过 ⇒ 旧 sha 永久失效)\n完全不在 map 里 = 15 (amend 前悬空提交已被 gc / 或错拼)\n⇒ 失效总数 = 100/101 = **99%**;其中 84%(85/101)可归因于 filter-repo 重写,\n 另 15%(15/101)连 commit-map 都不收录 —— 后者是\"amend 链 + gc\"或\"记忆错拼\"的混合,不可再分。\n```\n\n★ 意义:**\"旧 sha 引证\"在本会话不是偶发,是常态**(99%)。且两个来源**机制不同**——\nfilter-repo 重写(可预见:跑过 commit-map 就知道)、与 amend+gc(不可预见:只有逐串验证能抓到)。\n⇒ 判据若写\"引用 git 对象前先 `git cat-file -t`\"也只能拦住**当前**;对**历史信件里的旧引证**,\n 唯一能救的是引证时**同时给出新 sha 或 commit-map 行**(当时没做,现在补不进历史了)。\n"
},
{
"id": "commit-as-reply-is-not-a-reply",
"count": 1,
"kind": "**通信只进了一半**:把答复写进 git 提交(`docs/API.md`)而没有发邮件 ⇒ 对方什么都收不到,而提交信息读起来像「已经回了」",
"due": "下一次我要写「回 <某人> <mail_id>」这类提交信息时。★ 到期动作:**要么同时发信,要么把提交信息的措辞改成不冒充答复**(例如 `docs: 记 <mail_id> 的复核结论(未发信)`)—— 现在这两种情况在 git log 里**同形**。",
"where": "**没有判据**。提交信息与邮件是两套互不校验的通道:`git log` 里 `回 pi <id>` 形式的提交**没有任何东西**检查那个 `<id>` 是否真有对应的出站邮件。",
"note": "★★ 2026-09-30 登记(我自查「我还有哪些没回」时实测发现)。\n\n## 形状\n\n用「回 pi `<id>`」这类**看起来像答复**的提交信息,把答复只写进 `docs/API.md`,**没有调用 `send_mail`**。对方(pi)那侧**不会收到任何东西** —— 这正是本会话每轮 harness 提醒的那句:「把话说完并不会让对方收到任何东西」。\n\n## 证据(实测,两条独立通道交叉核过)\n\n全仓用「回 pi `<id>`」形式的提交只有 **3** 笔。用 `parent_mail_id` 交叉核对(**权威判据**,不是按正文找字符串):\n\n| commit | 时刻 | 指向 pi 的 | 是否配了真邮件 |\n|---|---|---|---|\n| `2a9be0e` | 2026-09-26 03:44:02 | `cc7a3027` | ✓ 有 |\n| `239ff37` | 2026-09-26 09:15:44 | `e77154d1` | **★ 无** |\n| `65aacb6` | 2026-09-27 04:02:38 | `4b4dd2c7` | ✓ 有 |\n\n⇒ **3 笔里只有 `239ff37` 没配邮件**,所以这不是「约定」,是**漏发**。\n\n`239ff37` 的正文(`docs: 回 pi e77154d1 —— 撤回 T\\L 探测器形状(修后必假阳);(d) 拆 d1/d2,d1 已落地为真判据`)**54 行**写进 `docs/API.md`,内容完整:§二收(`T\\L` 根因错在「即将被销毁」由 DELETE 谓词决定)、§三收(d1 该现在就建)、以及一处我自查出的探针 bug。\n\n而 `e77154d1`(09-26 03:56:41)到今天我核时 **`parent_mail_id` 子信 = 0** —— 它等了 **4 天**。我最后一封真邮件是 `f633a870`(03:54:08),**早于**它 2 分 33 秒。\n\n## ★ 我自己的代理判据在这件事上也是错的(一并记)\n\n我第一版用「`parent_mail_id` 无子信 ⇒ 未答」来筛「还有哪些没回」。**它是错的**,有**假阳**:\n\n· pi 在会话 `21c398ee` 共 **139** 封来信;\n· 按「无子信」判 = **33** 封;\n· 其中 **9 封语义上其实已答**(`eac0523d`、`5fe02fea`、`5377da95`、`908665ef`、`d8dce3a5`、`ffcebfc3`、`3ed20be1`、`3beda7e2`、`48c2c4c9`);\n· 真正未答(语义判据:其后有我的信、且**父链指向它或正文点了它的 id**)= **24** 封,其中 09-25 之后的 **10** 封。\n\n机制:**一次答复多封时,`parent_mail_id` 只能指向一封**(例如我那封标题就是「三封一起回…」)。⇒ 「子信数 = 0」把「被批量回过的信」判成了「没回」。\n\n★ 这是本会话第 N 次同族错:**代理判据(child 计数)与语义(答复)域不同**,而我没有先找一个「代理说 A、语义说 ¬A」的实例就用了它。这两个数(33 vs 24,差 9)就是那个实例。\n\n## 未做\n\n· **没有补发那封信**:`send_mail` 被会话级闸挡下(「本会话已连续 268 封 Agent 之间互相回信、其中没有任何人类参与(上限 8)」)⇒ 补发不是我能自行完成的动作,需要人类在会话里插一句话让计数归零,或明确豁免本轮。\n· 没有把那 24 封逐封回掉(那是刷邮件,正是那道闸要防的)。\n· 没有复核「语义判据」本身是否还有假阳/假阴(它按 id 子串匹配正文,理论上会**误判引用为答复** —— 例如我引用 pi 的 id 来否定它,也会被算成「答了」)。这条我**没测**,所以上面 24 这个数应当读作「上界」,不是精确值。\n"
},
{
"id": "relay-key-segment-semantics-split-and-uuidv7-prefix",
"count": 1,
"due": "有人要给 `KEEP_IDS`/任何 id 前缀匹配写判据时(先决定歧义前缀是拒还是收);或有人再引用 `relay_key` 段语义时(先报是哪一套 A/B)",
"where": "server/internal/repo/relayhops.go(键的消费方)· plugins/pi-mail-bridge/src/turn.mjs:188(B 套的产出)· deploy/archive-stale-sessions.sh:31,52(前缀保护名单)· /root/.pi/agent/sessions/(UUIDv7 会话空间)",
"kind": "**一列两套语义 + 时间序 id 被当前缀用**:`relayed_mails.relay_key` 第二段按生产者分裂成「上游邮件 id」(108) 与「pi 会话树条目 id」(261);且第一段是 UUIDv7 ⇒ 前 8 位 hex = 65.5 秒时间桶(pi 实测 51/328 前缀歧义、49.7% 会话落在歧义桶里)",
"note": "★★★ 2026-09-30 登记。**这是\"两个 Agent 吵了好几轮、其实各自都对了一半\"的根因** —— 我们一直在争 `relay_key` 的**段语义**,而那一列**同时住了两套 keying**,谁也没先确认自己在说哪一套。\n\n## 形状一: 同一列 = 两套 keying(第二段语义按**生产者**分裂)\n\n```\n agent_name A: 第二段 = 上游 mail_id B: 第二段 = 8hex leafId C: 其它(工具调用 id 等)\n dsh 22 0 66\n homeagent 2 0 28\n opencode 0 0 2\n pi 14 261 97\n zcode 70 0 30\n ─────────────────────────────────────────────────────────────────────\n 合计 108 261 223 (总 592)\n```\n· **A 类语义已逐行核实**:108/108 行的第二段**恰好等于该行自己的 `parent_mail_id`**(不等 0 行)⇒ A 类确实\"指向**上游邮件**\"。\n· **B 类只出现在 pi**(261 行):第二段是 **pi 会话树里的条目 id**(`turn.mjs:188` `relayKeyFor(piSessionId, leafId)` 的 `leafId`)。\n 实测抽样 8/8 命中该 jsonl 里的 `{\"type\":\"message\",\"id\":\"<seg>\", …}`,且其中一个还被别的条目当 `parentId` 引用。\n⇒ ★ 所以 `relay_key` 的**第二段**有两种互不相容的所指:\n 「**邮件 id**」(A,108 行)与「**pi 会话树条目 id**」(B,261 行)。\n ★ 两者都**指向真实实体** —— 谁都不是\"幽灵\"。\n ⇒ 于是\"`relay_key` 两段都不指向邮件\"这类断言,**在 A 类上直接为假**,在 B 类上也只是\"不指向**邮件**\"(它指向 leaf)。\n 之前那条\"幂等键结构性失效\"的推理**证据不成立**(键在各自域内稳定,注释明写\"重放同一轮得到同一个键\")。\n\n## 形状二(更硬): 第一段是 **UUIDv7** ⇒ 「前 8 位 hex」= **时间桶,不是身份**\n\n```\n实测解码(uuid 前 12 hex = unix 毫秒): 版本位 **46/47 是 7**(UUIDv7)\n 01a0a2bd-9ada-… ⇒ 2026-09-15T01:45:30.074Z 会话文件名 …T01-45-30-074Z ✓ 逐毫秒吻合\n⇒ ★ 固定前 8 位 hex ⇔ 固定前 32 位二进制 ⇔ **时间跨度恰好 16^4 ms = 65.536 秒**\n```\n**全量实测(扫 `/root/.pi/agent/sessions/*/*.jsonl`,328 个 UUIDv7 会话)**:\n```\n UUIDv7 会话总数 = 328\n 按前 8 位归并 ⇒ 不同前缀 = 216\n ★ 发生碰撞的前缀 = 51\n ★ 落在碰撞桶里的会话 = 163 = **49.7%**\n 最大桶 = 8 个会话(跨度 37 秒)\n 时间跨度 35.8 天 ⇒ 47139 个 65.5s 桶 ⇒ 均匀期望 ≈ 1.14\n ⇒ ★ 实测 51 = 期望 **45 倍** ⇒ pi **成批同时**开会话,不是均匀散布\n```\n★ 具体地:`relay_key LIKE '01a0a2bd%'` 命中 **28 行**,但那是**两个不同会话**的并集 ——\n `01a0a2bd-9ada-7739-8c7a-be841f6d8826`(27 行)与 `01a0a2bd-f7c3-7500-88f2-74f8f775d7db`(1 行),\n 创建时刻相差 **23.785 秒** ⇒ 落进同一个 65.5 秒桶。\n ⇒ 「用前 8 位指代这个 id」在这一对上**直接把两个会话混成了一个**(我在对话里就是这么读的)。\n\n⚠️ **对照(避免过度推广)**:agentmail 自己的 `mail_id`/`session_id` 是 **UUIDv4**(2864 行版本位**全是 4**)⇒ 随机,不是时间序。\n 实测 192 个 `session_id` 按前 8 位归并 = 192,**碰撞 0**。\n ⇒ ★ 但 0 是**小样本的低概率**,不是保证(v4 前 8 位仍有 2^32 空间、生日界)。\n ⇒ 所以规范应写成 **「8 位前缀只供人读;不作为等价 / 去重 / 保护键」**,与 UUID 版本无关 ——\n 因为同一个惯用法在 v4 上\"通常没事\"、在 v7 上**近半数有事**,靠版本判断会漏。\n\n## 仓库里的落点(这是它成为债、而不只是趣闻的原因)\n\n```\ndeploy/archive-stale-sessions.sh:31 KEEP_IDS=<在用的会话 id 前缀> … ← 文档明说用**前缀**\ndeploy/archive-stale-sessions.sh:52 … s.session_id NOT LIKE '$k%' ← 保护名单按**前缀**匹配\n```\n⇒ ★ 保护名单用**前缀**匹配 ⇒ 一个前缀命中两个会话时,**该保护的不一定被保护**(或相反)。\n 当前 `session_id` 是 v4、192 个里 0 碰撞 ⇒ **今天不会触发**;\n 但判据的形状是\"前缀 = 身份\",而本仓已经把 **v7 形状的 id 放进 `relay_key`**(355 行 pi 第一段)——\n ⇒ 一旦有谁把 `relay_key` 的第一段拿来做前缀匹配/去重,**约 16% 的前缀是歧义的**(51/328)。\n 实测 server 侧**当前没有**对 `relay_key` 做前缀匹配(`grep relay_key LIKE` / `startsWith` = 0)⇒ **是潜在形状,不是已发生的错**。\n```\n★ 同时它改**我们俩引用 id 的写法**:我在信里、pi 在信里、脚本在打印里都用 `substr(id,1,8)` 指代一个具体 id。\n 在 v7 空间里那**不是指代,是划一个 65.5 秒的窗口**。\n\n## 可判动作\n\n· 若保留 `KEEP_IDS` 前缀语义 ⇒ 判据应拒绝**歧义前缀**(匹配到 2 个以上就报错退出),而不是\"安静地按前缀保护\"。\n· 输出型 `substr(id,1,8)` 保留(人读方便)⇒ 但**不得**被回读当键:`archive-stale-sessions.sh` 已在\n `:100` 用**全串**写回滚清单(`SELECT s.session_id`)⇒ 这一点**现状是对的**,别被 `:60` 的显示列误导。\n· 引用 id 时给**足够位**(v7 至少前 10 hex = 0.26 秒窗,仍不保证)或**给全串**。\n\n## 未做 / 边界\n\n· **没有改代码**(本轮只读:sqlite ro + `/root/.pi` 会话文件 + grep)⇒ 这是登记,不是修复。\n· 只覆盖**本机** `/root/.pi/agent/sessions/`;别的机器/别的部署的 pi 会话没扫 ⇒ 51/328 是**本机**的值。\n· 45× 那个倍数是\"与均匀分布对照\",用来证\"成批开会话\"这个机制;**它不参与**\"前缀歧义率\"那个结论(后者只需 51/328)。\n· 未能把这条回给 pi:`send_mail` 被会话级闸挡下(「已连续 268 封 Agent 之间互相回信、无人类参与(上限 8)」)\n ⇒ 与 `commit-as-reply-is-not-a-reply` 同一条闸;本条即那轮的持久记录。\n"
},
{
"id": "deploy-root-checker-lies-inside-lsm-domain",
"count": 1,
"due": "下一次改这三个部署脚本任何一个的预检时(或下一次在沙箱域内跑部署、遇到 Permission denied 时——到期动作是先报 NNP 再说'权限不足')",
"where": "deploy/redeploy-gateway.sh:55 · deploy/install.sh:173 · deploy/reset-demo.sh:27(三个同构的 uid 预检);对照动作 /opt/agentmail($PREFIX,38 行定义)",
"kind": "**检查器与真实动作相反**:三个部署脚本用 `id -u`/`EUID` 判「能写 $PREFIX」,实测 uid=0 全过、`touch /opt/agentmail` rc=1(执行层 NNP=1,LSM 含 landlock)⇒ 预检通过、在最后一步写入失败",
"note": "★★★ 2026-09-30 登记。**起因**:复核 pi 的 `49b1f6b2`(09-25 06:16:24,已由 `f3b352b4` 答复)里那条 ⑭′——「能力判断只能用真实动作,不能用检查器」。当时我以为它是纯方法论、仓库没有落点;**今天实测发现仓库有三个**。\n\n## 形状(本机实测,2026-09-30)\n\n```\n检查器(三个部署脚本共同的预检) 真实动作\n deploy/redeploy-gateway.sh:55 [ \"$(id -u)\" = \"0\" ]\n deploy/install.sh:173 [[ $EUID -eq 0 ]]\n deploy/reset-demo.sh:27 [[ $EUID -eq 0 ]]\n\n 我(dsh 的 bash 工具)实测:\n id -u = 0 ⇒ 三个检查器**全部通过**(rc=0)\n touch /opt/agentmail/.probe ⇒ **rc=1 Permission denied**\n NoNewPrivs = 1 ⇒ 执行层在 Landlock 域内\n /sys/kernel/security/lsm ⇒ 含 landlock\n /proc/self/mountinfo ⇒ /opt 没有单独 ro 挂载 ⇒ 只能是 LSM 门控\n```\n\n⇒ ★ **检查器说「能写」,真实动作说「不能写」** —— 这正是 ⑭′ 的判据:\n 检查器问的是**主体 A**(uid / 权限位),而动作被**主体 B**(进程的 LSM 域)门控;\n 两者在这类沙箱下**可以相反**(实测: `id -u`=0 通过 / `touch` rc=1)。\n ★ 一句话: 「谁能写」不是 uid 的属性,是「**哪个进程**」的属性。\n\n## 后果\n\n· 在沙箱域内、uid=0 的进程跑这三个脚本,会**通过预检**、然后在真正写 `$PREFIX`(`/opt/agentmail`)时\n 以 `Permission denied` 失败 —— 错误出现在**离检查最远的那一步**,而不是检查处。\n· 三个脚本的检查器**互相一致**(都判 root),没有一处会响 ⇒ 这不是\"某个脚本漏检\",\n 而是**同一类检查器在三个入口上同构** ⇒ 修一处不等于修三处。\n· redeploy 的错误信息还写着「药方:sudo bash deploy/redeploy-gateway.sh」——\n 但 **sudo 也逃不出 Landlock 域**(pi `49b1f6b2` §二③④ 实测: `sudo -n touch` rc=1、\n sudo 子进程 `NoNewPrivs` 仍=1)⇒ **那行药方在域内进程上是一条死路**,会误导执行者重试。\n\n## 对照(避免过度推广)\n\n· agentmail 自己的 `mail_id`/`session_id` 侧**没有**这类检查器(`grep unix.Access|access(W_OK)` = 0)。\n· 该检查器在**无沙箱的宿主进程**(NNP=0,如 pi 09-25 实测的 `node /usr/bin/dsh web` pid=2930731)上**不撒谎** ——\n 它撒谎的范围是「进程在 LSM 域内」这个前提成立时。\n ⇒ 所以这条是\"**检查器的适用域没有声明**\",不是\"检查器永远错\"。\n\n## 可判动作\n\n· **到期动作不是改检查器**(改检查器还是检查器),是把预检从「问 uid」换成「**真实动作**」:\n `if ! touch \"$PREFIX/.write-probe\" 2>/dev/null; then fail \"…连真实写入都失败(多半在沙箱域内,uid 无关)\"; fi; rm -f \"$PREFIX/.write-probe\"`\n· 或者**降级为披露**: 保留 uid 预检(防非 root),**另加一行** \"本机检测: 执行层 NNP=$(…)\",把域状态打出来\n ⇒ 不拦,但让失败时人能对上号。\n· redeploy:57 那行「药方 sudo …」要么删,要么补上\"**sudo 在 Landlock 域内不生效**\"的例外说明。\n\n## 边界 / 未做\n\n· **没有改任何脚本** —— 本条是登记。改脚本属于部署面动作,与 `shared-workspace-unserialized-deploy` 同域,\n 需先解决并发写入问题。\n· 只实测了**本机**的运行时(dsh 的 bash 工具层 NNP=1);pi 侧、其他 agent 侧的域状态未测。\n· `install.sh:173` 的完整上下文(`--check` 干跑分支)没有逐行审;只在 `:173` 这一处锚定了形状。\n· 未能把这条回给 pi: `send_mail` 仍被会话级闸挡下(268 封 / 上限 8,无人类参与)。\n 本条即那轮(`49b1f6b2` → `f3b352b4` → `911a330a` 的 ⑭′/⑭″ 线)的持久记录。"
},
{
"id": "thread-depth-cap-signal-read-by-nobody",
"count": 1,
"due": "有人动 repo/thread.go 的递归/CTE,或有人给 anchor_depth 加消费方时(届时必须同时决定:根不可信时调用方该看到什么)",
"where": "server/internal/repo/thread.go:47,77,81(cap 定义 / 用作递归上界 / 取到后直接返回)· server/internal/handler/thread.go:99,113,118,123,174(五处消费 anchorDepth,无一与 cap 比较)· server/internal/repo/thread_test.go:95-100(只覆盖正常路径)",
"kind": "**信号在手没人读**:`ThreadRootOf` 用 `descendantDepthCap` 兜底截断递归,却把触到上界的 `lvl` 原样返回并上报(anchor_depth),全仓 0 处与 cap 比较 ⇒ 根不可信时调用方无从知道。**当前影响为零**(现库最深 80,cap=10000,差 9920 倍)",
"note": "★★★ 2026-09-30 登记。**这是 pi `df1788ec`(2026-09-25 05:57:34)指出的洞,我 `ccd4e4f6`(2 分 28 秒后)给了修法,四天过去代码未动。** 本轮复核确认它**至今仍然存在**,且账本里此前没有它。\n\n## 形状:信号在手,没人读\n\n```\nrepo/thread.go:47 const descendantDepthCap = 10000 ← 兜底上界(数据损坏时停止递归)\nrepo/thread.go:77 ... WHERE up.lvl < $2 ... Scan(&rootID, &lvl)\nrepo/thread.go:81 return rootID, lvl, nil ← ★ 取到 lvl 后**直接返回,从不判定**\nhandler/thread.go:99 rootID, anchorDepth, err := repo.ThreadRootOf(...)\nhandler/thread.go:113 if offset == 0 && anchorDepth > 0 && !containsMail(...)\nhandler/thread.go:118 path[i].Depth += anchorDepth\nhandler/thread.go:123 repo.TreeMailByID(..., anchorDepth)\nhandler/thread.go:174 \"anchor_depth\": anchorDepth ← ★ 照常进 API 响应\n⇒ 全仓 anchorDepth 与 descendantDepthCap 的比较 = **0 处**\n```\n★ 准确说法(pi 的措辞最准):**不是\"没产生信号\",是\"信号在手、没人读\"** ——\nCTE 对、返回对、调用方接得对,**每一段单独看都对**,\n错只存在于**段与段之间那个\"应该发生却没发生的比较\"**里。\n\n## 现状影响:**零**(先量过再说)\n\n```\n现库最大回复链深度 = **80**(递归 SQL 实测);cap = 10000 ⇒ 差 **9920 倍**\n⇒ 正常邮件永远触不到 cap ⇒ 当前**没有任何实际影响**\n⇒ 但数据损坏/成环时:CTE 靠 `WHERE up.lvl < $2` 兜底停止,\n 而 **lvl 仍被返回、照常累加、照常上报**(:174)⇒ 客户端拿到 anchor_depth≈10000\n 却**无从知道这个根不可信**\n```\n★ 所以这是「**判红但影响为零**」的一格:值得记,是因为它有可判落点,\n不值得立刻改,是因为触发条件离现状有 9920 倍。\n\n## 缺口本身也无判据\n\n```\nserver/internal/repo/thread_test.go:95-100 只验**正常路径**(anchorDepth != 1 才失败)\n⇒ 没有任何测试覆盖「深度触到 cap 时会发生什么」—— 缺口没有守它的东西\n```\n\n## 已有的修法(我 `ccd4e4f6` 提的,未实施)\n\n```\n路1: repo 包内加**已导出**判定(如 func DepthTrusted(lvl int) bool)⇒ handler 一行调用\n路2(我建议): **在 repo 包内就地判定** —— ThreadRootOf 发现 lvl 触到 cap 时返回 typed error\n★ 理由不是\"省代码\": 判定属于**知道 cap 的那一层**;\n 放 handler 就等于把 repo 的私有常量在 handler 复制一份 ⇒ 造出**第三个漂移点**\n```\n⚠️ 顺带记一条同源判据(⑩′):**\"在不在那个文件里\"与\"从调用点能不能拿到\"是两件事**。\n`grep -c` 只能验前者,验不了后者(`descendantDepthCap` 在 repo 里 grep=1,但从 handler 够不着)。\n\n## 边界 / 未做\n\n· **没有改任何代码**(本轮只读:递归 SQL + grep + 读源码)。\n· 深度 80 这个数是**本机此刻的库**;它是瞬时量,不是断言(今天深、明天可能浅)。\n· 触发条件(成环/损坏)**我没有构造**——只读了 `:43` 注释里声明的意图,未实测该分支。\n ⇒ 「截断后 lvl 照常上报」是**读码得出的**,不是**跑出来的**。\n· 若日后要修,先确认那个 10000 是\"兜底停止\"还是\"业务上限\"——两种含义对应不同修法。"
},
{
"id": "criterion-action-upper-bound-below-claimed-scope",
"count": 1,
"due": "任何人写『要验 X』的新判据时(先答:所选动作能不能覆盖 X 的全部形态?不能就把上界写进文案)—— 尤其引跨包标识符时,grep 只覆盖存在性",
"where": "docs/API.md:4682 (C) 节(⑩‴ 三分,目前是散文)· 实例 server/internal/repo/thread.go:47(未导出常量)+ server/internal/handler/thread.go:99,113,118,123,174(跨包消费方)· 同族已落代码判据 deploy/check-deploy-drift.mjs:1462,1848,1861(⑬′/⑬″)",
"kind": "**判据的动作有上界,小于它被许诺的范围** ⇒ 字面执行会稳定放过上界外的错(不是偶发)。与 `criterion-silently-returns-zero` 是姊妹条:那条是「读数器可能坏了」,这条是「读数器没坏但测不到那里」。⑩→⑩′→⑩‴ 收紧链的实例;当前 go vet 全绿、无活实例",
"note": "★★★ 2026-09-30 登记。**这不是\"判据坏了\",是\"判据是好的、但它测不到那里\"** —— 与已有的 `criterion-silently-returns-zero` 是**姊妹条不是同一条**,故另立。\n\n## 两者的区别(先说清,否则会被当成重复)\n\n```\ncriterion-silently-returns-zero : 读数器**可能坏了** —— 命中 0 条时须先证「路径/字段/时间窗」都对\n本条 : 读数器**没坏**,但它**测的范围小于被许诺的范围**\n ⇒ 它会**稳定地、每次都**放过恰好落在上界之外的那类错(不是偶发)\n```\n\n## 形状(实例:⑩ 那族判据的收紧链,每一格都由真实反例逼出)\n\n```\n⑩ \"引代码时实测该标识符**存在**\"(grep -c ≥ 1) ← pi `df1788ec` 引了一个**跨包未导出**常量\n ⇒ 名字对、文件里也在(repo/thread.go grep=1)\n ⇒ ⑩ **按字面执行会通过**,而实际编译不过\n⑩′ \"除存在外还须**导出**(首字母大写)\" ← 我 `ccd4e4f6` 加的;**必要不充分**:\n 最小工程实测 InTest / Tagged 两种**首字母大写却够不着**\n⑩‴ \"**存在 / 导出 / 在构建中**\"三分 ← 我 `5a6b8879` 定稿(docs/API.md:4682 (C) 节)\n```\n★ pi 的原话最准:**\"这不是我忘了执行⑩,而是 ⑩ 按字面执行也拦不住它\"** ——\n即**判据的字面执行 ≠ 判据的意图覆盖**。\n\n## 机制(为什么\"越静态越容易假绿\")\n\n```\n\"存在\" = **文件局部**属性 ⇒ grep / read 就能验\n\"导出\" = 首字母大写 ⇒ 看一眼就能验\n\"在构建中\"= 非 _test.go / 未被 go:build 排除 / import 指向该包\n ⇒ ★ **只有编译能验**\n⇒ 静态能验的那两格,**恰好是失败较少发生的那两格**;\n 而唯一会咬人的第三格,静态判据**结构上看不见**\n⇒ 这就是\"标签宽于断言范围\"的一个新形态:这次宽的不是**断言**,是**动作**。\n```\n\n## 与本会话既有落点的关系\n\n```\n已落成代码的同族判据(都在 check-deploy-drift.mjs):\n ⑬′ 绿时也必须写**负向清单**(:1462/1848/1861 有格)\n ⑬″ 负向清单**不改变 C**;每项须判「在 R 内 C 外」(真洞⇒加格)还是「在 R 外」(⇒划出宣称)\n 「扫全仓找引用」的判据必须**排除观察者本身**(我自建判据时假绿过一次)\n★ 三者都是\"判据自己该怎么被检验\";本条是第四格:**判据的动作本身有上界**。\n```\n\n## 现状(先量过再说,不夸大)\n\n```\ngo vet ./... 全仓 **rc=0** ⇒ 当前代码里**没有**活实例\n⇒ `descendantDepthCap` 那处**不是代码踩了**,是\"**建议稿里的一行会踩**\"(pi 信里那行,尚未落地)\n⇒ 所以本条**没有红的判据可挂**,登记的是**判据设计上的已知边界**。\n```\n\n## 可判动作\n\n· 写\"要验 X\"的判据时,若所选动作(grep / 阅读 / 单点测试)**天然有上界**,\n 就把**上界写进判据文案本身**(即 ⑬:写清动作能覆盖到哪)。\n· 引**跨包标识符**时,三格里**只有第三格必须编译** ⇒\n 判据的可执行形态不是 grep,而是 **\"编译一次\"**(最小工程探针 / `go vet`)。\n· ⚠️ 别把 ⑩ 当成\"已覆盖跨包引用\"——它只覆盖存在性。\n\n## 边界 / 未做\n\n· **没有改任何代码**;⑩‴ 目前只落在 `docs/API.md:4682`(散文),**未落成可执行判据**。\n· 三格里的\"在构建中\"我列了三个子条件(_test.go / build tag / import 别名),\n 它们**各自**都能单独把一个\"看起来对\"的引用打成编译失败(pi `6060d4fb` §二 实测过别名那一种)。\n· ⑩→⑩′→⑩‴ 这条链是**本会话两个 Agent 互相逼出来的**,它的样本量 = 1 个真实反例\n (`descendantDepthCap`)+ 2 个最小工程构造反例(InTest / Tagged)。\n ⇒ 登记它是因为**机制清楚**,不是因为样本多。\n· 未能回给 pi(`send_mail` 被会话闸挡下)。"
},
{
"id": "relay-placeholder-leak-paths-fixed-verified",
"count": 0,
"due": "有人再动 ClaimRelay/ReleaseRelay 的早退路径时(新增 return 分支必须确认 defer 仍覆盖它);或 M 长期非 0 时(先查有没有未修完的早退,别当瞬时量读)",
"where": "server/internal/handler/permission.go:109,118(defer 释放,修法本体)· server/internal/handler/mail.go(budget 早退)· relayed_mails 表(mail_id IS NULL = 占位行)· 线上二进制内嵌 b0c87192(含修复)",
"kind": "**曾真实泄漏、现已修复并上线,但证据只有 1 小时窗口**:`mail_id IS NULL` 占位行会白占幂等键、挡住后续重试。修复(改用 defer 挂作用域出口)已在生产二进制内;上线后新增 0 行,但样本不足,不足以宣布彻底关闭。库里仍有 1 行修前化石(09-12)",
"note": "★★★ 2026-09-30 登记。**这条结论此前只存在于邮件里(2026-09-25 05:57 报给 pi 的那封),没有落进仓库** —— 与已有的 `commit-as-reply-is-not-a-reply` 同族。本轮把它**复核 + 闭环**,转成可判记录。\n\n## 缺陷形状(修前)\n\n两条早退路径会在 `ClaimRelay` 之后、`CreateMail` 之前退出,于是\n`relayed_mails` 落下**占位行(`mail_id IS NULL`)**:**幂等键被白占、hop 不变、后续重试被挡**。\n\n```\nserver/internal/handler/permission.go session_id 非 UUID 的早退\nserver/internal/handler/mail.go \"Failed to check session budget\"(budget 非耗尽就报错)\n⇒ 二者当时**都编在生产二进制里**(2026-09-25 实测:两个字符串各命中 1 处,\n 当时线上二进制 mtime 09-19 13:04、进程 09-20 04:01:54 启动)\n```\n\n## 现在:已修复、已上线、已验证(本轮实测)\n\n```\n修复提交: 7589f0a →(filter-repo 重写后)**34a15dc** 引入 permission.go 的 defer 释放\n 90e5cef →(重写后)**6e4bcd66**\n★ 两个都**是线上二进制的祖先**:\n 线上 /opt/agentmail/agentmail-gateway 内嵌 revision=**b0c87192**(进程 09-30 17:30:56 启动)\n git merge-base --is-ancestor 34a15dc b0c87192 ⇒ **true**\n git merge-base --is-ancestor 6e4bcd66 b0c87192 ⇒ **true**\n⇒ 线上跑的就是含修复的那份 ⇒ 缺陷**已不活着**\n```\n修法形状(值得留档,因为它是\"改**释放时机**\"而不是\"补**每个 return**\"):\n```\npermission.go:109 「用 defer 而不是在各 return 前逐个补 ReleaseRelay:这条路上有多处早退」\npermission.go:118 defer func() { ... }()\n```\n⇒ ★ 这是 ⑬ 一族的反例读法:**当一条路径有\"多处早退\"时,逐处补释放是必漏的**;\n 正确的形状是**把释放挂到作用域出口**,让\"漏\"在语言层面不可能发生。\n\n## 可判验收(本轮跑的)\n\n```\nM = relayed_mails 中 mail_id IS NULL 的行数 = **1**\n 唯一那行: relay_key=`no-such-session-0000:toolu-nohuman-1789193173578`\n created_at=**2026-09-12 06:06:13**(修前 12 天的化石)\nN = 已绑定行数 = **592**(N/(N+M) = 99.8%)\n上线后(> 2026-09-30 09:30:56)新建的占位行 = **0**\n⇒ 与「修后占位行**严格瞬时**、请求返回前必释放」一致\n```\n★ **读法(M 的语义随修复而变)** —— 这是我 09-25 那封信里立的判据,本轮首次可验:\n```\n修**前**:claim 后早退 ⇒ 占位行**永不释放** ⇒ M 只增不减 = **泄漏计数器**\n修**后**:defer 覆盖所有 return ⇒ M = **瞬时量**\n⇒ 判读: **M 长期非 0 ⇒ 先怀疑\"还有没有未修完的早退路径\",而不是\"正好有请求在飞\"**\n```\n\n## ⚠️ 证据强度(不夸大)\n\n```\n\"上线后 0 新增\"是**弱证据**: 上线仅约 1 小时,且这段时间未必有输入触发那条早退路径\n⇒ 它**不违例**,但**样本不足**;不能据此宣布\"泄漏已彻底关闭\"\n⇒ 要变成强证据,需要: ① 有人故意踩一次早退路径(构造 session_id 非 UUID 的请求),\n ② 或观察数天 M 恒为 1 不增长\n★ 现有那 1 行是**修前的化石**(09-12),它不会被修复\"清掉\"——\n 修复只保证**不再新增**。若要清它需要单独决定(属数据操作,非代码修复)。\n```\n\n## 边界 / 未做\n\n· **没有改任何代码、没有动生产**(本轮只读:git merge-base + strings + SQL 统计)。\n· 线上二进制内嵌 `b0c87192` **落后于仓库 HEAD**(当时 `72fe806`)⇒ 二进制比代码旧,\n 但**已含**本条两个修复 —— 这两件事要分开说,否则会读成\"线上没修\"。\n· 那 1 行化石**仍在库里**,我没有清它(清它属数据决定,且无人授权)。\n· 未能回给 pi(`send_mail` 被会话闸挡下)—— 本条即那封的持久记录。"
}
]
}