{ "_": [ "欠账的**单一登记**(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": 4, "due": "本工作区能装、能点设备(探针三值转 true 时自动变红)", "where": "client/electron/test/run-all.mjs 的 STATIC_ONLY(当前 4 条:test/harmony-appearance.test.mjs、test/harmony-logic.test.mjs、test/cross-client-theme.test.mjs、test/harmony-imageprep.test.mjs)· 历史:2026-09-19 harmony-admin 那条已升级为设备判据、移出名单 ⇒ 6 → 5;2026-10-02 appearance-defaults 上设备(判据改为「落盘键真的带账号段」,已在真机验过红绿)并移出名单 ⇒ 5 → **4**", "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` 那半只有悬浮球这一处上了设备 —— 其余令牌仍是静态对齐。「一半」要写出来,不能让名单看起来像没动过。\n\n★★ 2026-10-03 结算 5 → 4:`appearance-defaults` 已上设备(`62b7c94`),并从 STATIC_ONLY 移出 ⇒ 登记数必须跟着减,否则 `commit-hygiene` 的「可见副本不许漂移」那条会红。**这次漂移的真实成因是:移出名单那一步改了 `run-all.mjs`,却没回头改这笔登记** —— 与本仓反复出现的「同一个数字的多个副本各改各的」同族,而机器镜像(`commit-hygiene.test.mjs:180-196` 只比对 count 与 STATIC_ONLY.length)正是唯一抓住它的地方。" }, { "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: ''` 构造点 = **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:`\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:` (第 1 段即标记)\n service-failure × 25 ← `service-failure:` (第 1 段即标记,**deploy 脚本产生**)\n zcode-failure × 21 ← `zcode-failure:` (第 1 段即标记)\n homeagent × 16 ← ★ `homeagent:failure:` (★ 标记在**第 2 段**!)\n empty-reply × 0 ← 代码 1 处、**库里 0 行**(首次触发即静默漏判,pi 也提到)\n ★ 且 `homeagent:` 共 18 行 ⇒ 另 2 行是 `homeagent:`(**无** 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) **无 `_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//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 - </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` 时。★ 到期动作不是'在提示词里写清楚'——本次已证明写清楚没用:四封通知的正文里已经逐字写明'回的是你那封:',模型照样每封都去核一遍,然后照样被误导。", "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 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 B **in** ` 里,别锚到行尾)\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 ``」记的是 **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:`),而不是扫工作树 —— 即**量登记之前的状态**。\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": "下一次我要写「回 <某人> 」这类提交信息时。★ 到期动作:**要么同时发信,要么把提交信息的措辞改成不冒充答复**(例如 `docs: 记 的复核结论(未发信)`)—— 现在这两种情况在 git log 里**同形**。", "where": "**没有判据**。提交信息与邮件是两套互不校验的通道:`git log` 里 `回 pi ` 形式的提交**没有任何东西**检查那个 `` 是否真有对应的出站邮件。", "note": "★★ 2026-09-30 登记(我自查「我还有哪些没回」时实测发现)。\n\n## 形状\n\n用「回 pi ``」这类**看起来像答复**的提交信息,把答复只写进 `docs/API.md`,**没有调用 `send_mail`**。对方(pi)那侧**不会收到任何东西** —— 这正是本会话每轮 harness 提醒的那句:「把话说完并不会让对方收到任何东西」。\n\n## 证据(实测,两条独立通道交叉核过)\n\n全仓用「回 pi ``」形式的提交只有 **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\":\"\", …}`,且其中一个还被别的条目当 `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 的行数\n ★ **2026-09-30 更新**:本条初稿写的是 M=**1**(唯一那行 relay_key=\n `no-such-session-0000:toolu-nohuman-1789193173578`、created_at 2026-09-12 06:06:13,\n 我称之为\"修前化石\")。**同日复核 M 已 = 0,且那行连 relay_key 都不在表里了** ——\n 连**存量**都被清掉了(不是\"不再新增\",是那条也没了)。\n ⚠️ 更正我第一版的推断(写完即查,发现机制说错了):\n 我先写\"它被清了\" ⇒ 实测**不止那一行**:\n · `relay_key like 'no-such-session%'` 现在命中 **0**(整类都没了)\n · 与之配对的那封 zcode 权限请求邮件 `8f056b73-…` 在 `mails` 表里**也不存在**\n · 而 `relayed_mails` **总行数仍是 592** ⇒ 删除的同时**有等量新增**\n ⇒ 准确说法: **那批测试夹具数据(邮件 + relay 行)被成组删除了**,\n 而 592 这个总数看不出来(删旧增新抵消)。\n ⇒ ★ 无论按哪种读法,有一件事成立: **这是一次写库动作,而本库没有审计/历史表**\n (`sqlite_master` 里 name like '%audit%'/'%history%'/'%log%' 唯一命中\n `agent_model_catalog`,与 relay 无关)⇒ **谁删的、依据什么删的,事后无从查证**\n —— 这与 `history-rewrite-undisclosed-citations-dangle`(历史被重写且无记录)\n 是**同一族**:不可复核的删除。\n ⚠️ 所以本条的\"证据强度\"一栏要按两段读:\n ① 09-30 早: M=1 且上线后新增 0(**弱证据**,上线仅 1 小时)\n ② 09-30 晚: M=**0**(存量也没了)⇒ 泄漏**已不再可见**,\n 但\"清存量\"这个动作**无据可查** ⇒ 不能据此宣布\"从未泄漏过\"\nN = 已绑定行数 = **592**(当日实测)\n```\n\n### ★★ pi `18ac26c2` 的校账:别把\"测试残留\"当成\"那 1 行未绑定\"\n\n```\npi 09-25 的账: 类 = 458(已绑定 ∧ kind≠failure),其中**含 1 行测试残留** ⇒ 纯业务 457\n★ 关键区分(pi 的增量): 「**未绑定**」与「**测试残留**」是**两个不同的集合**:\n · mail_id IS NULL 的那 1 行 —— 是残留,**但它不是类成员**(类按\"已绑定\"划)\n · 「测试残留」按**键名形状**匹配(`no-such-session%` / `%toolu-nohuman%`)\n 实测命中 **2 行**,其中 **1 行已绑定**(一封真实的 permission_request,\n from=zcode,主题「权限请求: 是否允许执行 Bash?」)\n ⇒ ★ 所以\"测试残留 = 那 1 行未绑定\"**不完整**: 测试残留里有一行**真的发出去过**,\n 它**会**进类统计 ⇒ 要说\"纯业务\"必须**再减这一行**\n⚠️ **本条的所有具体数字都是 2026-09-25 的瞬时值**,本轮(09-30 晚)实测已全部不同:\n 已绑定 ∧ kind≠failure = **592**(当日 458);测试残留形状命中 = **1**(当日 2);\n 纯业务 = **591**;permission_request 邮件 138 封(当日 137,那封\"真发出去的\"在列)\n⇒ 引用这组数时**必须带日期与口径**,否则就是\"同一数字换了所指\"(`03adbf14` 已立此判据)。\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` 被会话闸挡下)—— 本条即那封的持久记录。" }, { "id": "relay-count-keying-label-has-no-guard", "count": 1, "due": "下一次动 deploy/recount-relay-counts.sh 的输出格式时(顺手把那条判据一起写掉并做变异验证);或任何人再报组数/抑制数/去重数时(必须同时给 keying + scope)", "where": "deploy/recount-relay-counts.sh:191,192,234(标签已写进 JSON 键名与文本行)· 缺口:client/electron/test/criteria-hygiene.test.mjs 与 run-all.mjs 里 'keying' 均 0 命中 · 可照抄的同族判据 criteria-hygiene.test.mjs:952", "kind": "**改输出 ≠ 钉判据**:抑制数的 keying+scope 标签已写进脚本输出,但没有任何判据守着它(删掉不会红)。pi 说的『丢标签这类错判据抓不到』成立——要抓它得先知道这里该有标签,而那正是缺失的东西本身", "note": "★★★ 2026-09-30 登记。**pi `dd579a8e` 说对了一句 mechanism,我照做了一半**:它说\"**丢标签这类错判据抓不到**\",而我做的恰好是\"把标签写进输出\"——那让错**看得见**了,但仍然**不会红**。\n\n## 缺口形状(实测)\n\n```\ndeploy/recount-relay-counts.sh:234 printf '抑制数 [keying=(agent,根) scope=全局]: …'\ndeploy/recount-relay-counts.sh:191 \"suppress_full_groups_keying_agent_root_scope_global\":%s\ndeploy/recount-relay-counts.sh:192 \"suppress_fail_groups_keying_agent_root_scope_global\":%s\n⇒ 标签**已经在输出里**(键名与文本两处都带 keying + scope)\n\n★ 但没有任何东西守着它:\n client/electron/test/criteria-hygiene.test.mjs 里 'keying' 命中 = **0**\n client/electron/test/run-all.mjs 里 'keying' 命中 = **0**\n⇒ ★ 删掉 `keying=` 之后,**没有一条判据会红** —— 标签可以被下一个人顺手删掉而无人知晓\n```\n\n## 为什么它是\"丢标签\"这一族(本会话第 2 次同形)\n\n```\n① 2026-09-25 §7 负向清单\"一处有守一处无守\" —— 我补了判据(b1eb0ab0 / criteria-hygiene:952)\n② ★ 本条: 标签写进了输出,**判据没补** ⇒ 与 ① 同形,只是这次更隐蔽:\n ①里\"无守\"那份是**另一处文件**(一眼能看出不对称),\n ②里\"无守\"是**根本没有那条判据**(看不出少了什么)\n```\npi 的原话值得原样留着:**\"丢标签这类错,判据抓不到\"** ——\n因为判据要能抓它,得先知道\"这里该有标签\",而那正是缺失的东西本身。\n\n## ⑯ 的内容(已落进脚本输出,尚未落成判据)\n\n```\n⑯ 报\"组数 / 抑制数 / 去重数\"必须同时报 **keying 与 scope**:\n keying ∈ {(agent, 根), (根)}、scope ∈ {全局, 某 session}\n ★ 两者共同决定那个数 —— 少任何一个,下一个人都只能靠猜\n ★ 根因(pi `a68f62d2` §一): 他那句 `21/1` **跑的是对的**((agent,根) 口径),\n **错的是抄进信里时把 keying 标签丢了** ⇒ 同一封信里有标签的三句逐值吻合 (agent) 口径,\n 唯一\"看起来像仅根\"的那句恰恰是没标签的那句\n```\n\n## 修法有现成模式可照(不必新造)\n\n```\nclient/electron/test/criteria-hygiene.test.mjs:952\n test('★ ⑤b 的负向清单在 JS 与 shell 两处都有副本,两份都必须点名同一对失败类', …)\n⇒ 同一族、同一形状(\"必须同时点名\")。本条可照它写:\n 「recount-relay-counts.sh 的抑制数,文本行与 JSON 键名**两处**都必须带 keying 与 scope」\n⇒ ★ 写判据时必须做**变异验证**(删掉 keying ⇒ 该格红),\n 否则又是一次\"以为钉上了\"——本会话已栽在这上面一次(见\"已犯\")\n```\n\n## 已犯(我自己)\n\n```\n2026-09-25 我在 fcb406c4 里写「我把它钉进脚本了(键名 + 文本行),下一轮谁都不可能再『抄来源时丢标签』」\n⇒ ★ 那句话**只对\"输出\"成立**,对\"没人会删\"不成立 —— 我把\"改了\"当成了\"守住\"\n⇒ 形态: **改输出 ≠ 钉判据**。前者让错可见,后者让错会红。\n```\n\n## 边界 / 未做\n\n· **没有写那条判据**(本轮只读 + 统计)。它是本条最直接的修法,但属测试文件改动,\n 且 `criteria-hygiene.test.mjs` 由并发会话经手 ⇒ 改前需确认无人同时在改。\n· 未复跑 `recount-relay-counts.sh` 本身(依赖生产库);本条只读它的源码与输出格式。\n· keying 标签**当前是正确的**(61/37、92/6 与 pi 逐值一致)⇒ 本条**不是纠错,是防再犯**。\n· 未能回给 pi(`send_mail` 被会话闸挡下)。" }, { "id": "doc-line-ref-decidepermission-drifted-33", "count": 1, "due": "下一次改 docs/HARMONY-ALIGN-PLAN.md(或任何按行号引代码的文档)时:先判它指的是不是它声称的符号;顺手把它改成函数名 + 行号仅作定位", "where": "docs/HARMONY-ALIGN-PLAN.md:225(引 permission.go:301,实为响应体字段;func DecidePermission 现 in 334)· 已修的同族样本 server/internal/handler/permission_relay_release_test.go:32-37(a67c8a3)· 同形状命中 6 处、其中 1 处为已修样本 ⇒ 待查 5 处:sse/manager.go:115、docs/reviews/harmony-client-review.md:101、docs/API.md:4147、docs/API.md:4490", "kind": "**行号引用漂到无关代码**:文档声称 `permission.go:301` 是 DecidePermission,实际 301 是 permission_request 响应体字段,函数已在 334(从 09-25 的 318 又漂 16 行,累计 33 行)。pi 那格『引用带唯一标识、行号只作辅助』已在一处落实(a67c8a3),这处未落实——且 09-25 我推迟它的理由(『另一个 writer 的决定』)经查已不成立(文件干净)", "note": "★★★ 2026-09-30 登记。**pi `f74da994`(2026-09-25 04:59:03)那格记法已经落了一处(`a67c8a3`),但另一处至今没落、且已漂得更远。**\n\n## 那格记法(pi 提出,我 09-25 认领并落了一处)\n\n```\n引用代码位置时**同时给唯一标识**(函数名 / `relay_key` 字面量 / 块首注释),\n**行号只作辅助** —— 同族:「判身份用唯一键,不用位置」。\n★ 已落实的一处: server/internal/handler/permission.go 的注释从 `permission.go:112`\n 改成「函数名 `RequestPermission` + 错误字符串 `Invalid session_id`」,行号删掉。\n 理由**已写进注释**(在 server/internal/handler/permission_relay_release_test.go:32-37),\n 连\"112 行现在是本注释的上游注释文本\"都实测记录了 ⇒ 防下一个人补回行号 ✓\n```\n\n## ★ 缺口:docs/HARMONY-ALIGN-PLAN.md:225 仍用裸行号,且已漂 33 行\n\n```\n文档写: (`server/internal/handler/permission.go:301`) ← 声称是 DecidePermission\n现在 301 行实际是: `\"subject\": mailSubjectFor(kind, req.Question),`(逐行实测;相邻行是 mail_type/role 那组字段)\n (构造 permission_request 响应体的一组字段,**与 DecidePermission 无关**)\n`func DecidePermission` 现在在 **334** 行\n⇒ 漂移轨迹: e07e3bf 时 301≈该函数文档注释(302 是函数体)\n 2026-09-25 我测得 318(漂 17 行)\n **今天 334(漂 33 行)** ⇒ 期间又漂了 16 行\n⇒ ★ 与我 09-25 记的\"同类、不同严重度\"相比又进一层:\n 当时 301 落在**同文件另一个函数体内**(还能说\"就是这文件里 DecidePermission 那块附近\"),\n 现在 301 落在**一个与该函数无关的响应体构造**上 ⇒ 已不是\"漂移\"、是**指向了别的东西**\n```\n\n## 为什么\"越界检查\"这类弱判据抓不到(我 09-25 已实测,本轮沿用)\n\n```\n112 <= 478(permission.go 行数)⇒ 在界内 301 <= 478 ⇒ 在界内\n⇒ ★ 弱形式判据(行号在不在文件范围内)**一次都抓不到**:\n 行号还在,只是**指向了别的符号** ⇒ 只有\"唯一标识 + 编译/实测\"能判\n```\n\n## 09-25 我为什么没改它(以及现在那个理由还成立吗)\n\n```\n我当时写: \"未改它 —— 那是另一个 writer 的文档,是否改成唯一标识由那条线定\"\n⇒ 本轮核: `git status --porcelain docs/HARMONY-ALIGN-PLAN.md` = **干净**(无人在改)\n⇒ 所以\"等那条线定\"这个理由**已不成立**,剩下的只是没做。\n⇒ ★ 这一条本身是那格记法的**自反例**: 我用\"位置/归属\"当理由推迟了\"改成唯一标识\"这件事,\n 而那正是我认领的规矩。\n```\n\n## 可判动作\n\n· 把 `permission.go:301` 改成 **`DecidePermission`(函数名)**,行号要么删、要么写成辅助\n (形如 `func DecidePermission` 在 permission.go 内,行号仅供定位)。\n· 若要全仓一致:`grep -rn \"permission\\.go:[0-9]\"` 现在命中 **6 处**,其中\n `permission_relay_release_test.go:32` 是 **a67c8a3 的已修样本**(它**故意**引用 112\n 来讲\"同一提交内自失效\"这件事,**不是**待改项)⇒ 真正的待查是 **5 处**:\n `sse/manager.go:115`、`docs/reviews/harmony-client-review.md:101`、\n `docs/HARMONY-ALIGN-PLAN.md:225`、`docs/API.md:4147`、`docs/API.md:4490`\n —— 每处都要判\"它指的是不是它声称的那个符号\",而不是一律替换。\n· ⚠️ `docs/API.md` 与 `docs/reviews/*` 也是并发写入的文件,改前须确认无人同时在改。\n\n## 边界 / 未做\n\n· **没有改任何文件**(本轮只读:sed/grep/git status)。\n· 漂移 33 行是**今天此刻**的值;它是瞬时量,不是断言。\n· 我没有逐一核那 5 处是否**真的指错**(只核了 HARMONY-ALIGN-PLAN.md:225 这一处,\n 因为它是 09-25 我亲手记下\"已核实为错\"的那一处)。\n ⇒ 其余 4 处**可能对可能错**,未核 ⇒ 上面\"全仓一致\"那条是待办清单,不是结论。\n· 未能回给 pi(`send_mail` 被会话闸挡下)。" }, { "id": "class-boundary-needs-real-mechanism-not-string-shape", "count": 1, "due": "任何人用字符串/形状谓词划一个『类』(统计口径、筛选条件、判据范围)时:先判它背后有没有真机制;没有就按 ④′ 申报形状 + 漏面 + 命名巧合", "where": "deploy/recount-relay-counts.sh(该口径的产地)· server/internal/repo/relay.go(kind 只有两个 ClaimRelay 调用点:permission.go:93 传字面量 \"permission\"、mail.go:520 传变量 `relay`(种类声明见 mail.go:39 的 \"\" | \"permission\" | \"summary\"))· 反例见 relayed_mails.relay_key 的 %failure% vs 三前缀两套匹配", "kind": "**伪机制:用字符串形状冒充机制**(比『用示例划类』更难识破,因为形状看起来更一般)。④′ 三栏的定稿内容 + 一个反直觉实测:『依赖命名巧合』通常**不是词序**(%failure% 与 %failure:% 逐次同值 ⇒ 换序不变类),真依赖是『那个词有没有写进键里』", "note": "★★★ 2026-09-30 登记。④′ 在会话里已定稿(pi `44dccaee` 2026-09-25 05:48:04 收、我 `9b71532b` 3 分 51 秒后照用),但**账本里只被别的条目顺带提过一次**,没有条目。本轮把它连同**实测出的反直觉核心**一起落进来。\n\n## ④′ 的内容\n\n```\n④′ 类要由一个**真的存在且可判**的机制来划;\n 若只能用字符串匹配近似 ⇒ 那是**示例级**判据,用的时候必须**申报三件**:\n ① 它匹配的是什么**形状**\n ② 它**可能漏什么**\n ③ 它**依赖哪个命名巧合**\n```\n\n## ★ 核心(反直觉,且我实测过两次):\"依赖命名巧合\"通常**不是词序**\n\npi 提的示范是「把 `homeagent:failure:` 改写成 `failure:homeagent:` 会得到不同的类」——\n**我否掉了它**,因为 `%failure%` 是**子串**匹配,换序不改变命中。\n\n实测(2026-09-25 与 2026-09-30 各一次,两次都成立):\n```\nLIKE '%failure%' 命中 = 98(09-25) / **113**(09-30)\nLIKE '%failure:%' 命中 = 98(09-25) / **113**(09-30)\n⇒ ★ 两者**逐次完全相同** ⇒ **换词序不改变类**\n```\n★ 真正可验的依赖是**\"那个词有没有被写进去\"**:\n```\n全表 557(09-25) / 592(09-30);三前缀命中 82 / 89\n若 homeagent 那族不叫 failure ⇒ 类 = 474(09-25) / **503**(09-30)\n⇒ 换**词**(failure→error)类就变;换**序**与换**分隔符**都不变\n```\n⇒ 而\"分隔符不同\"(homeagent 用 `:`、另三个用 `-`)**也无关** ——\n两种 LIKE 模式都命中 113。\n★ 所以 ④′③ 栏该写的是:**依赖\"某个词出现在键里\",不是依赖词序或分隔符。**\n 我 09-25 给的措辞(可直接用):\n```\n③ 它依赖哪个命名巧合 —— 本例: 类的边界依赖 relay_key 里**出现 \"failure\" 这个词**;\n 把 homeagent 那族的词换成 error/breakage ⇒ 类从 458 变 474。\n (不依赖词序、不依赖分隔符 —— 这两者实测不改变命中数。)\n```\n\n## 为什么这条值得留(它的错法比\"用示例划类\"更难识破)\n\n```\n我 09-25 的原话被 pi 抓住: 我写\"类由**机制**划(`kind !== 'failure'`)\",\n而实测 `kind` 只有 summary|permission 两值、kind='failure' **0 行**\n⇒ 照字面执行得到**几乎全表**,不是 458 ⇒ **那句是空真**\n★ 错法: **用\"字符串形状\"冒充\"机制\"** —— 形状看起来比例子更一般 ⇒ 更难识破\n (对比\"用示例划类\":那个至少知道自己不一般)\n⇒ 这是 ④′ 那族的一个新形态: **伪机制 = 用了看起来更一般的谓词**\n```\n\n## 边界 / 未做\n\n· **没有改任何代码、没有改判据脚本**(本轮只读 SQL)。\n· 上面每个数都标了日期 —— 它们是**瞬时量**(同一条线上 ⑧ 的应用:见\n `recount-labels-must-match-predicates` 里\"口径会随时间漂\"那一节)。\n ⇒ ★ 引用这组数**必须带日期**,否则就是\"同一数字换了所指\"。\n· 「\"failure\"这个词被写进去」这个说法**依赖当前命名**;若将来重命名族,\n 这条会失效 ⇒ 它记的是**方法**(怎么验依赖),不是**结论**。\n· ⑧(非单调载体上不许用大小互推方向/先后)本轮**未单独登记** ——\n 它在会话里已定稿且已被 `recount-labels` 那条实际应用过;\n 要不要独立成条,等它第一次真的被违反时再定(避免登记未被现实打到的规则)。\n· 未能回给 pi(`send_mail` 被会话闸挡下)。" }, { "id": "code-ref-name-must-exist-in-cited-file-and-time-stamped", "count": 1, "due": "引用任何标识符(变量名/形参/字段)或代码行号时:先在被引文件里 grep 实测,再带取数时刻", "where": "实例 server/internal/handler/permission.go(pi 09-25 报 RelayKeyForMail 实参为 mailID;parentMailID 在该文件 0 次)· server/internal/repo/relay.go:78,81(形参名 mailID、WHERE mail_id = $1,两处 09-30 复核仍成立)· 同族坐标债 doc-line-ref-decidepermission-drifted-33 · 区分于 criterion-action-upper-bound(那是『够不够得着』)", "kind": "**引代码时名字不跟载体走 + 坐标会漂**:错名只是字符串、不参与执行 ⇒ **无任何运行时反馈**(数据版的错下一步就现形,代码版不会),只能静态核对。⑩ 要求被引文件里 grep 实测名字存在;本轮再补一维——名字仍对但行号已漂(09-25 引的那一行现在已不对),故实测还必须带时刻", "note": "★★★ 2026-09-30 登记。⑩ 在会话里已落地(pi `542f4e08` 提出,我 `902f1d2c` 10 分钟后收),但账本里此前只有另一格(见下\"与已有条目的区别\")。\n\n## ⑩ 的内容\n\n```\n⑩ 引代码(变量名 / 形参 / 字段)时,必须**在被引文件里实测它出现**(`grep -c` 该标识符);\n 0 次 ⇒ 是另一个作用域的同名/近名,必须换成被引文件里真实存在的那个名字。\n```\n\n## 触发它的实例(pi 实测,2026-09-25)\n\n```\npermission.go:397 repo.RelayKeyForMail(r.Context(), **mailID**) ← 实参名是 mailID\nrelay.go:78 func RelayKeyForMail(ctx, **mailID** uuid.UUID) ← 连形参都叫 mailID\nparentMailID 在 permission.go 出现次数 = **0**(只在 mail.go:10 / me.go:4,别的作用域)\n⇒ pi 写的\"语义对、名字错\" —— 他从 mail.go 的作用域借了个名字,贴到 permission.go 的调用上\n★ 而这正是他**上一条判据**(名字不跟载体走)本身 —— 载体是他自己的信\n```\n\n## ★ 机制:为什么代码版比数据版更难自查\n\n```\n数据版的错**会在下一步现形**: 拿错 id 去查 ⇒ 结果不对 ⇒ 复跑就暴露\n代码版**不会**: 引用里那个错名**只是一个字符串** —— 不参与执行、不产生输出差异\n⇒ ⇒ **没有任何运行时反馈** ⇒ 只能靠静态核对(grep 那个名字)\n```\n★ 推论(pi `a68f62d2` 的同一族): 凡是**不会被现实打到的**检查,只能靠**显式动作**补 ——\n有强检查器(编译器)的载体交给它,没有的(散文、信、代码引用)自己补一次。\n\n## ★★ 本轮新增的第三维:名字仍对,但**坐标已漂**(同一格,第二次应验)\n\n复核 pi 那组实测(2026-09-30):\n```\nparentMailID 在 permission.go 仍 = **0** ✓ 结论仍成立\nrelay.go:78 形参仍 = mailID ✓\nrelay.go:81 仍是 `WHERE mail_id = $1` ✓\n★ 但 permission.go:397 **现在** = `}`(不是那行调用)\n permission.go:334 **现在** = `func DecidePermission(...)` 的声明本身\n (09-30 我另测 DecidePermission 已从 318 漂到 334)\n⇒ ⇒ **语义全对、所引的那一行已不对**(不给\"漂 N 行\":无法确定该漂多少)。\n⇒ 这与 `doc-line-ref-decidepermission-drifted-33` 是**同一格**(文档引行号漂 33 行),\n 只是这次落在**代码互引**上 ⇒ 该条 `where` 里\"func DecidePermission 现 in 334\"**也已开始过期**\n★ 结论: ⑩ 说\"实测它出现\"**不够** —— 实测还必须**带取数时刻**,\n 否则**行号**会骗你(名字骗你的那一面,坐标也会)。\n ⇒ 与 ⑧(引用读数附版本/时刻)合并读: **⑩ = 标识符存在性;⑧ = 该读数的时刻与载体版本**\n```\n\n## 与已有条目的区别(别当重复)\n\n```\n已记 `criterion-action-upper-bound-below-claimed-scope`: 判据的动作有上界\n —— ⑩→⑩′→⑩‴ 那条链(跨包可见性:存在 / 导出 / 在构建中)\n ⇒ 那一格问的是\"**够不够得着**\"(编译问题)\n★ 本条问的是\"**名字对不对**\"(语义/作用域问题)+ \"**坐标新不新**\"(时刻问题)\n```\n\n## 可判动作\n\n· 引任何标识符(变量名/形参/字段)前:`grep -c '' <被引文件>`,0 就换名字。\n· 若同时给了行号,**行号也要在同一次核对里验证**(别只验名字不验位置)。\n· 写进文档/信的引用,**优先给名字 + 函数名**,行号只作辅助(`doc-line-ref-…` 那条已定此策)。\n\n## 边界 / 未做\n\n· **没有改任何代码**(本轮只读:sed/grep/SQL)。\n· 上面每个行号都是**今天此刻**的值,且**已证明会过期**:\n pi 09-25 引的 `permission.go:397`(RelayKeyForMail 调用)现在**不是那一行**了。\n ⚠️ 我不写\"漂了 N 行\"—— 我核过:397 与 334 是**两个不同符号**的行,相减没有意义;\n 能确定的只是「**那一行已经不对了**」。(这是我自己那条纪律:宁可少给精度,不给伪精度。)\n· 我**没有**去查 `permission.go` 里真正的 `RelayKeyForMail` 调用现在在哪一行\n (只确认了 pi 那两行已漂、语义结论不变)⇒ 本条不提供新坐标。\n· 未能回给 pi(`send_mail` 被会话闸挡下)。" }, { "id": "failure-report-cycle-suppress-by-relayed-mails-lookup", "count": 1, "due": "桥侧要抑制『报告的报告』时(照此判据实现;**无需整链抑制机制**,逐跳判定自带该性质);或任何人再算这类环的规模时(口径必须是 '%failure:%' 与 '%-failure:%' 的并集)", "where": "relayed_mails.relay_key(最后一段 = 被指向那封的 mail_id)· 桥侧落点 plugins/*-mail-bridge/src/index.ts(出站生成 key 处)· 实测环 e43496ed→2ffb7dbb→84900edd→b3789a21→41ea9a15 · 载荷侧 notify/mail.go grep relay=0(故加字段要改网关)", "kind": "**『报告的报告』的自激环,判据已在元数据里**:本封 relay_key 指向的那封 ∈ relayed_mails ⇒ 该抑制,网关契约一个字不用改(逐跳实测:第 2 跳起 4/4 命中,第 1 跳是根不命中;链长实测最长 5 层但判据逐跳独立 ⇒ 不需要『整链抑制』的配套机制)。本轮修正 pi 一处口径误读——标题启发式在真值口径下 **10/10 全中、漏 0**,问题是它答的是另一个问题(『是失败报告』59 封 vs 『在报告报告』10 封),拿它抑制会误杀 59 封真报告 本轮把 pi 的『暂未发现反例』升级为**四个否证方向逐条实测,三个为空**", "note": "★★★ 2026-09-30 登记。这条在会话里由 pi `2053c8db` 提出(2026-09-25 04:53:14,我 20 分钟后 `148a4220`/`27fd0135` 两封答复),**账本里此前没有**。本轮把它连同**一处口径修正**一起落进来。\n\n## 问题:失败报告的\"环\"(报告的报告)会自激\n\n```\n一条失败报告本身也被 relay(kind=summary、relay_key=`*-failure:<上游id>`)\n⇒ 它的失败回报又生成一条新失败报告 ⇒ 环\n⇒ 抑制点必须在 **ClaimRelay 之前** return(否则占住幂等键,见 `relay-placeholder-leak-…`)\n```\n\n## ★ pi 找出的位:**回查 `relayed_mails`**,网关契约一个字不用改\n\n```\n载荷里**没有** relay 身份(notify/mail.go 全文件 grep relay = **0**;mail.go:39 的 RelayKey\n只用于入站校验、不下发)⇒ 载荷加字段这条路要改网关\n★ 但元数据里**已经完整存在**: 本封的 `relay_key` 最后一段就是**被指向那封的 mail_id**\n 判据 = 「本封的 relay_key 所指向的那封 ∈ relayed_mails」⇒ 这是一封**报告的报告** ⇒ 抑制\n⇒ 桥只要拿本封来信 id 去查一次 relayed_mails 即可,**不依赖标题、不依赖载荷**\n```\n实测逐跳(2026-09-30,pi 那条环 `f76025c9` 的 5 跳):\n```\n第 1 跳 e43496ed target=b3ce9d0f 在 relayed_mails? **False** ← 根,回报合法\n第 2 跳 2ffb7dbb target=e43496ed **True** ← 该抑制\n第 3 跳 84900edd target=2ffb7dbb **True**\n第 4 跳 b3789a21 target=84900edd **True**\n第 5 跳 41ea9a15 target=b3789a21 **True**\n```\n⚠️ `f76025c9` 那封本身在 mails 表里**已查不到**(归档/清理),但环上 5 跳都在,逐跳判定成立。\n\n## ★★ 本轮的实测修正:pi 说\"标题启发式漏失远超 30%\"—— **在真值口径下不成立**\n\n```\nfailure 类 relay 封数 = **113**(口径: relay_key LIKE '%failure:%' OR '%-failure:%')\n ★ 注意口径: 真实键是 `model-failure:` / `zcode-failure:` / `service-failure:`(**连字符**)\n 而 `homeagent:failure:` 才用**冒号** ⇒ 只写 '%failure:%' 只匹配到那 16 个(pi 09-25 已纠正过这点)\n真值(该抑制,元数据判据) = **10** 封\n 标题含「处理失败」的能认出 = **10 / 10** ⇒ ★ **漏 0 封**(标题里 `处理失败:` 逐层叠加)\n标题判据认出的总数 = **59** 封\n```\n⇒ **两个判据回答的不是同一个问题**(这是我预判错的地方):\n```\n标题判据认「这封**是**失败报告」 ⇒ **59** 封 ⇒ 真报告,**该发**\n元数据判据认「这封**在报告**一份报告」 ⇒ **10** 封 ⇒ **该抑制**\n⇒ 用标题去抑制会**误杀 59 封真报告**;标题认得全,但它答的是另一个问题\n⇒ 而 pi 的位在**正确性**上更硬(不依赖标题、不误杀),在**召回**上与标题持平\n★ 所以对 pi 那句的准确修正是: 不是「标题漏得多」,而是\n **「标题答的是另一个问题;拿它去抑制会误杀」**。\n```\n\n## 可判动作 / 修法要点\n\n· 桥侧:`data.mail_id` → 查本封 ∈ `relayed_mails` ? 是 ⇒ 抑制回报;否 ⇒ 正常回报。\n· ⚠️ 抑制必须发生在 **ClaimRelay 之前**,否则又落一个占位行(永占幂等键)。\n· 口径陷阱:判 failure 类要用 `'%failure:%'` **或** `'%-failure:%'` **两者并集**。\n 只写 `'%failure:%'` 只能命中 `homeagent:failure:*` 那 24 个(实测),\n 漏掉全部连字符族(`model-` / `zcode-` / `service-`)。\n ⇒ 只写前者会**漏 89 个**(113 − 24),不是漏 97 —— 我第一版把数算反了,已按实测改。\n\n## 边界 / 未做\n\n· **没有改任何代码**(本轮只读 SQL)。该位**未落地**——落点在桥侧(`plugins/*-mail-bridge/src/index.ts`),\n 且要与\"环整体抑制还是只抑制一跳\"配套决定。\n· 10 / 59 / 113 都是**此刻**的值(瞬时量),引用须带时刻。\n· 我**没有**去核\"标题判据在别的 session 上是否也 10/10\"——样本只此一个环族。\n· 我**没有**判定 pi 那句\"漏失远超 30%\"是在哪个口径下说的(他未附口径)——\n 我只能说在真值口径下它不成立。\n· 未能回给 pi(`send_mail` 被会话闸挡下)。\n\n## ★★ 第⑤条判据的否证清单(2026-09-30 逐条实测,全部为空)\n\npi 那张判决表里第⑤条写的是「**暂未发现反例**」。本轮把它升级为**四个方向逐一实测**:\n\n```\n命中判据的封数 = **10**(口径同前: '%failure:%' ∪ '%-failure:%')\n D1 被指向那封【自身非 failure 键】⇒ 会误杀正常转发 : **0** ✓\n D2 被指向那封【在 mails 表已不存在】⇒ 回查空 ⇒ 漏判 : **0** ✓\n D3 判据与标题不一致(标题不含『处理失败』) : **0** ✓\n D4 链长 >1 层(本封自己也是上一份报告的报告) : **10/10** ⚠️ 见下\n⇒ 前三个方向都没有反例;D4 不是反例,而是它的**工作方式**。\n```\n\n### D4 澄清了一个未决项:要不要\"整链抑制\"?(**不需要**)\n\n```\n10 封的链长实测: 2/2/5/2/3/3/4/3/2/4 ⇒ **最长 5 层**(41ea9a15←b3789a21←84900edd←2ffb7dbb←e43496ed)\n★ 我一度以为\"链长 >1 ⇒ 只抑制一跳会漏、需要整链砍的额外机制\" —— **这个推断是错的**:\n 判据是**逐跳独立**的 —— 每一跳各自回查**自己**的 target,而链上**每一跳**都命中\n ⇒ 链在生成过程中就被**逐跳**砍断,根本长不起来\n⇒ 这 10 封是**判据未上线时的历史遗留**(正是它要消灭的东西)\n⇒ ★ 所以 pi 判决表那条备注「需与『环整体抑制还是只抑制一跳』配套决定」——\n **不成立**: 不需要配套决定,逐跳判定自带这个性质。\n★ 这条是我自己的 ⑬′ 读法派上用场: 我没有只验 D1(最容易被验的那个),\n 而是先把**否证方向列全**再逐条跑 —— 若只跑 D1,我会漏掉 D4,\n 而 D4 恰恰是要我去澄清\"要不要整链机制\"的那一条。\n" }, { "id": "dedup-expectation-must-filter-null-roots", "count": 1, "due": "写任何『抑制/去重后应剩 N 封』的判据时(先定口径、滤掉无根行、把被滤掉的量单独报出来);或再按 (agent, root) 分组时", "where": "relayed_mails.relay_key(service-failure:* 那 32 封 key 不含 UUID ⇒ 非 mail_id)· 实测环 e43496ed/84900edd/41ea9a15(dsh root=b3ce9d0f 同键 3 封)· 同族:failure-suppression-must-not-merge-parallel(别把并列失败当重复)", "kind": "**判据期望值写错 ⇒ 恒假**,且三个人的数都『不算错』只是口径不同(pi 6 / 我 9 / 我 37)。根因:按 `(agent, failroot)` 分组时**没先滤掉 failroot=NULL** —— 那 32 封是 `service-failure:*` 无根的服务级报告,被并成 2 个假重复组。正确口径:113 → 滤 32 → 剩 81 → 6 组 / 抑制 7 封", "note": "★★★ 2026-09-30 登记。pi `b9c5070b`(2026-09-25 04:57:46)提出\"**判据期望值写错会恒假**\",我 3 分钟后 `7b69434c` 认了自己一个错数。本轮把**正确算法**实测出来 —— 三个人的数各不相同,而差别全在**口径**。\n\n## 三个数(同一件事)\n\n| | failure 总数 | 组数 | 抑制封数 | 错在哪 |\n|---|---|---|---|---|\n| pi 09-25 | 98 | 5 | 6 | —— (**当时是旧快照**) |\n| 我 09-25(§三) | 16→7 | — | **9** | 把 `homeagent:failure:` 那 16 封当**全量** |\n| 我 09-30 初测 | 113 | 8 | **37** | ★ **没滤掉 failroot=NULL** |\n| **我 09-30 口径修正后** | **113** | **6** | **7** | —— |\n\n## ★ 关键:`(agent, failroot)` 分组时**必须先滤掉 failroot=NULL**\n\n```\nfailure 报告总数 = **113**\n ★ 其中 failroot = NULL = **32** ← 这些**没有有效根**,不能参与分组\n 有有效根的 = **81**\n⇒ 重复组数 = **6** ⇒ 被抑制封数 Σ(n−1) = **7**\n```\n**那 32 封是什么**(这才是要害):\n```\n它们的 relay_key 形状 = `service-failure:c78f1daac57c4ea08f`、`service-failure:7ffc5c4d…`\n⇒ **既不含 UUID、也不是任何 mail_id** ⇒ 它们是**服务级失败报告**,与具体邮件无关\n⇒ 按 (agent, NULL) 分组,会把**32 封彼此无关的报告**并成 2 个\"重复组\"\n⇒ ★ 我那个 **37** 就是这么来的(逐步算全,别学我跳步):\n NULL 组按 agent 分成 {dsh: 26 封, homeagent: 6 封}\n Σ(n−1) = (26−1) + (6−1) = 25 + 5 = **30**\n 加上有根部分的真实抑制 **7** ⇒ 30 + 7 = **37**\n⇒ 而 pi 的 **9** 是把 16 封那个子集当全量\n⇒ ⇒ **三个数都不算\"算错\",都是各自口径下的真值** ⇒ 差别全在口径,不在算术\n```\n\n## 本轮实测确认的机制(pi 那条最硬的印证,逐字成立)\n\n```\nagent=dsh root=b3ce9d0f 同键 **3** 封 = ['e43496ed','84900edd','41ea9a15']\n⇒ 正是 `f76025c9` 那条环里 dsh 在 hop1/hop3/hop5 **回来三次** ⇒ **收敛成 1 封**(抑制 2)\n⇒ ★ 机制成立: 环在两方交替 ⇒ 同 agent 会回来 ⇒ **PK 撞车** ⇒ 环断在 **hop3**\n```\n其余 5 组(每组 2 封,抑制 1):\n```\ndsh? 无 —— 逐组: pi/f39fd424 2 · zcode/b3ce9d0f 2 · zcode/85624acd 2 ·\n homeagent/85624acd 2 · opencode/9e9d0f50 2\n```\n\n## 判据该写成什么(pi 的建议 + 本轮修正)\n\n```\n① `f76025c9` 那条环:(dsh, model-failure:root) 出现 **3** 次 ⇒ 收敛成 1 封、环在 hop3 断 ✓\n② 全量:failure 报告 113 ⇒ **先滤 failroot IS NULL**(滤掉 32 封 service-* 无根报告)\n ⇒ 剩余 81 封里重复 **6** 组、抑制 **7** 封\n ⚠️ 不是 6 封/5 组(那是 09-25 的快照),也不是 9(那是子集当全量)\n③ ★ 写判据时**期望值必须等于本口径的实测值** —— 否则判据恒假,\n 然后被误读成\"修法无效\"(这正是我们这两天反复吃的那条)\n```\n\n## 可判动作\n\n· 凡按 `(agent, root)` 分组/去重:**先滤掉 root IS NULL**,并把滤掉的量单独报出来\n (\"32 封无有效根\"本身就是要报的数,不是可以静默丢掉的噪声)。\n· 报\"抑制 N 封\"时给三件:口径(N 的定义)、被滤掉的量、剩余基数。\n\n## 边界 / 未做\n\n· **没有改任何代码、没有改判据脚本**(本轮只读 SQL)。\n· 113 / 32 / 81 / 6 / 7 都是**此刻**的值(瞬时量)。pi 09-25 的 98/5/6 当时是对的。\n· `service-failure:*` 那 32 封的**产生条件**我没查(只知道它们的 key 形状与邮件 id 无关)。\n· 我**没有**验证\"滤 NULL\"是否就是当时正确的口径 —— 这只是三者中唯一能让\n \"service-* 报告不参与邮件级去重\"成立的读法;但**约定俗成的口径该由谁定,我不知道**。\n· 未能回给 pi(`send_mail` 被会话闸挡下)。" }, { "id": "root-keying-must-pin-which-chain-and-null-handling", "count": 1, "due": "任何人写『上溯到根』的去重/分组/判据时(钉死:沿哪条链 + None 怎么处理);以及 root-keying 真要落地时(先补『根计算有限步终止 + 超限行为显式』的判据)", "where": "server/internal/repo/thread.go:43,47,65,74,77,81(:43 cap 的用途注释『数据损坏时的兜底』、:47 const descendantDepthCap = 10000、:65 ThreadRootOf 签名、:74 WHERE up.lvl < $2、:77 Scan(&rootID, &lvl)、:81 return rootID, lvl, nil —— ★ 取到 lvl 后从不与 cap 比较)· 实测环 e43496ed/2ffb7dbb/84900edd/b3789a21/41ea9a15 · 同源两笔:thread-depth-cap-signal-read-by-nobody(截断后根不可信却照常上报)、dedup-expectation-must-filter-null-roots(None 组)", "kind": "**『数都对、口径不同』第 4 次**(同一批 113 封:37 / 7 / 108)。根因是判据只钉了 keying、没钉**沿哪条链上溯**与 **None 怎么处理**:沿 failure 链那 5 封根全是 b3ce9d0f,沿完整 parent 链全是 None。附落地风险:上溯在自引用/环上会跑满(当前库 0 例,属将来说不定),须配『有限步终止 + 超限行为显式』判据", "note": "★★★ 2026-09-30 登记。**同一条线上第 4 次出现「数都对、口径不同」**(前三次:7 / 37 / 108 见下表)。pi `e659a655`(2026-09-25 04:58:29)用它自己的脚本撞满上限,我 52 分钟后 `b4de8b50` 认了他的 61/37。\n\n## ★ 口径对照表(每一行都带口径与时刻 = 2026-09-30)\n\n底数:`relayed_mails` 中 `relay_key LIKE '%failure:%'` = **113** 封(pi 09-25 用的 98 **已是旧快照**)\n\n| 口径(分组键) | 组数 | 抑制 | 剩余 |\n|---|---|---|---|\n| `(agent, failroot)` **只沿 failure 链上溯** ← pi 口径 | 8 | **37** | 76 |\n| `(agent, failroot)` 但**先滤掉 failroot IS NULL** ← 我上一轮口径 | 6 | **7** | — |\n| `(agent, 完整 parent 链上溯的根)` | 5 | **108** | 5 |\n\n⇒ ★ **37 与 7 差在\"要不要把无根的算进去\"**;**108 是荒谬值**(把 32 封 `service-failure:*` 无根报告并成组)。\n⇒ 判据**必须钉住\"沿哪条链上溯\"**,而不只是钉 keying:\n```\n沿 failure 链上溯 ⇒ root 是\"最后一个 failure 报告的祖先\",无根者停在它自己\n沿完整 parent 链上溯 ⇒ root 是线程根,无根者落在 None(⇒ 必须滤掉)\n★ 这两个 root 在环上那 5 封里**给出不同的值**(实测):\n 沿 failure 链: 5 封**全部** = b3ce9d0f(pi 的自检,成立)\n 沿完整 parent 链: 5 封**全部** = **None**(我 09-30 实测)\n ⇒ 同一组数据、两个口径、两个根 ⇒ \"全指向同一个根\"这句话**必须带口径**\n```\n\n## ★★ pi 的落地风险发现(★ 这条与本会话已登记的债**同源**)\n\n```\n他第一版脚本\"沿 failure 链上溯 + 上限 50 步\",有一条链**跑满上限** ⇒ 抑制数是假的\n⇒ ★ 机制: 沿 parent/failure 链的递归上溯,在链里有**自引用或环**时会跑满\n⇒ 而\"报告的报告\"这类会话**天然容易造出 parent 环**\n⇒ ⇒ root-keying 落地**必须自带一条判据**: 根计算必须在**有限步内终止**,\n 且**必须明确超限时怎么办**(拒绝注册 / 当作根 / 静默返回错误)\n```\n★ 同一件事我在 2026-09-30 已从另一侧登记(`thread-depth-cap-signal-read-by-nobody`):\n `ThreadRootOf` 用 `descendantDepthCap=10000` 截断递归,\n 但取到 `lvl` 后**从不与 cap 比较**、原样返回并上报 `anchor_depth`\n ⇒ ★ **截断后根不可信,而调用方无从知道** —— pi 说的是\"会不会跑满\",\n 我那条是\"跑满后有没有人说\" ⇒ **同一个洞的两端**,都指向\"落地前必须先有终止/可信判据\"。\n\n## ⚠️ 但\"会跑满\"目前是**理论风险,不是活实例**(先量过再说)\n\n```\n直接自引用 (parent_mail_id = mail_id) = **0**\n200 封随机样本里存在环的数量 = **0**\n⇒ ★ 当前库里**没有**自引用/环 ⇒ pi 那条不构成现网风险\n⇒ 但它描述的**类**是真实的(环在 relay 层存在,见 f76025c9 那 5 封)\n ⇒ 风险在于**将来**: 一旦 mails.parent_mail_id 出现环,root 计算就会静默给出错根\n```\n\n## 可判动作\n\n· 写任何\"上溯到根\"的口径时,**同时写死两件事**:① 沿哪条链(failure 链 / 完整 parent 链)\n ② 无根(None)时**滤掉还是当根**。少任一个,下一个人必得另一组数。\n· 报组数/抑制数时附**口径 + 时刻 + 底数**(113 与 98 都对过)。\n· root-keying 落地时,把\"**根计算在有限步内终止,且超限行为是显式的**\"写成判据,\n 不要只写\"能终止\"(那是⑬ 一族:动作有上界 / 期望值必须等于实测)。\n\n## 边界 / 未做\n\n· **没有改任何代码、没有改判据脚本**(本轮只读 SQL)。\n· 113 / 8 / 37 / 76 / 6 / 7 / 108 都是**此刻**的值。\n· 上表第 2 行的\"滤掉 NULL 组\"我用 `(a,'x')` 占位实现过,**口径本身**与\n `dedup-expectation-must-filter-null-roots` 那条一致;两个条目读到时**不要当成两个口径**。\n· 我**没有**验证\"沿 failure 链上溯\"与\"完整 parent 链\"在**别的**会话里是否也分叉。\n· 未能回给 pi(`send_mail` 被会话闸挡下)。" }, { "id": "harmony-pushkit-contract-landed-blocked-on-real-device", "count": 1, "due": "拿到带华为账号的真机时(取 getToken → POST push-token → 查 push_tokens 行数 > 0);或任何人再动鸿蒙推送路径时(保持 catch 静默、不得让推送成为 SSE 的依赖)", "where": "client/harmony/AppScope/app.json5:3(bundleName=com.jianf.agentmail)· client/harmony/entry/src/main/resources/rawfile/agconnect-services.json(AGC 配置;build/ 下那份是产物)· server/internal/handler/push.go:16,58,119,150(三条端点)+ push_test.go · client/harmony/entry/src/main/ets/api/PushService.ets(433 行、9 处 try+catch、catch 内 0 抛错/提示)· MainPage.ets:1874,1886,1925(通知路由)", "kind": "鸿蒙 Push Kit **契约已全部落地,唯一剩余阻塞是真实设备**:包名(AGC 拒 harmony 保留字)已改、AGC 配置在位、服务端三条端点齐备、客户端『推送是可选通道、不报错不阻塞』逐条查实(catch 内 0 抛错)。但 `push_tokens` **0 行** —— `getToken` 只能在带华为账号的真机上取,无模拟器路径", "note": "★★★ 2026-09-30 登记。pi `1f9ff3b4`(2026-09-15 11:06:06)给了鸿蒙 Push Kit 客户端半边契约 + 包名硬约束,\n**账本里此前没有这条线的任何条目**(`getToken`/`com.jianf.agentmail`/`PushService` 均 0 命中)。\n本轮实测确认:**契约已全部落地,唯一剩余阻塞是真实设备**。\n\n## ① 包名硬约束:已落地(AGC 拒 `harmony` 保留字)\n\n```\nAGC 原话: 应用包名中包含敏感词或者保留字符\"harmony\"\n用户定: **com.jianf.agentmail** (AGC APP ID 6917616450599975320,个人身份)\n实测: client/harmony/AppScope/app.json5:3 \"bundleName\": \"com.jianf.agentmail\" ✓\n 全工程 grep `com.agentmail.harmony` = **0 处** ✓(无残留)\n```\n\n## ② AGC 配置:在位\n\n```\nclient/harmony/entry/src/main/resources/rawfile/agconnect-services.json ← 源(工程约定位置)\nclient/harmony/entry/build/default/intermediates/res/default/resources/rawfile/ ← 构建产物副本\n★ 两者都在; app_id/package_name 已与 AGC 一致\n```\n\n## ③ 服务端契约:三条端点齐备\n\n```\nserver/internal/handler/push.go:16 设备推送登记 —— /api/v1/me/devices/push-token\n :58 POST /api/v1/me/devices/push-token (上报)\n :119 DELETE /api/v1/me/devices/push-token (注销)\n :150 GET /api/v1/me/devices/push-token (查询)\nserver/internal/handler/push_test.go 有对应测试\n```\n\n## ④ ★ 客户端\"必须是可选通道\"契约:已完全落实(本轮逐条查过)\n\npi 的第④条要求: 取不到 token / 没权限 / 上报失败 / 服务端未开推送 ⇒\n**静默跳过,不报错、不阻塞、不弹失败提示**; 主通道仍是 SSE。\n```\nclient/harmony/entry/src/main/ets/api/PushService.ets(433 行)\n 9 处调用全部 try+catch 包裹(:136 :140 :147 :151 :171 :182 :214 :226 :243 :253 :264)\n ★ ★ catch 块里 `throw` / `showToast` / `promptAction` / `console.error` = **0 处**\n ⇒ **全部静默**,与第④条一致\n通知点击跳转(第③条 data 形状):\n MainPage.ets:1874 PushService.setRouteListener(…) ← 收到通知后路由\n MainPage.ets:1925 PushService.pendingRoute ← 冷启动时补取\n MainPage.ets:1886 clearRouteListener() ← 页面销毁清理\n```\n\n## ★ 唯一剩余阻塞:**真实设备**(与本会话既定卡点一致)\n\n```\npush_tokens 表行数 = **0** (2026-09-30 实测)\n卡点: `getToken` 只能在**带华为账号的真机**上取到 —— 无模拟器路径\n⇒ 服务端三条端点、客户端契约、包名、AGC 配置**都已就位**,\n 缺的只是一台真机走一遍: 取 token → POST 上报 → 服务端落一行\n```\n\n## 可判动作 / 下一步\n\n· 拿到真机后:`pushService.getToken()` → `POST /api/v1/me/devices/push-token`\n → 复查 `select count(*) from push_tokens`(应 > 0)。\n· 在此之前**不要**把推送当依赖:任何新代码不得让推送失败影响 SSE 主通道\n (第④条已实现,改动时保持 `catch` 静默)。\n· ⚠️ `build/` 下那份 `agconnect-services.json` 是**构建产物**;\n 提交/同步时以 `entry/src/main/resources/rawfile/` 那份为准。\n\n## 边界 / 未做\n\n· **没有改任何代码、没有跑真机**(本轮只读 grep + SQL + 文件查找)。\n· 上述\"已落地\"是**静态核实**(文件在位、端点存在、catch 静默),\n **不等于**端到端跑通 —— 端到端需要真机,这正是本条的阻塞。\n· 我**没有**核 `api/ApiClient.ets` 与 `LoginPage.ets` 里 Push Kit 的全部调用路径,\n 只核了 `PushService.ets` 的静默性质与 MainPage 的路由三处。\n· 未能回给 pi(`send_mail` 被会话闸挡下)。" }, { "id": "criteria-suite-unwired-stops-everything", "count": 1, "due": "任何人给 `client/electron/test/` 加新 `*.test.mjs` 之后(新增判据者的义务,与「接线是作者的义务」同一条);判据:加完就 `node test/run-all.mjs` 看 `RESULT files=N ran=N`,`ran` 必须等于 `SUITE` 条数", "where": "client/electron/test/run-all.mjs(自检 2「每个 `*.test.mjs` 都要在清单里」,:202 起)+ SUITE(:62)+ package.json 的 `test` 脚本(`run-all && vitest && typecheck`)· 实例:test/inbox-fallback-poll.test.mjs、test/sse-credentials.test.mjs、test/web-comment-only.test.mjs(`aeb1f41` / `2f17f62` 引入,均晚于 run-all.mjs 最后一次改动 `62b7c94`)", "kind": "**判据「存在」与判据「在跑」是两件事,而这里的失效形状是「全停」**。自检 2 在跑任何判据**之前** `process.exit(1)` ⇒ 实测 `RESULT` 行数 = **0**,一条读数都没有。又因 `npm test` 是 `&&` 链,vitest(270 格)与 typecheck **一起不跑**——而两者单独跑都是绿的。失败信息只有一行 stderr,看起来像「环境问题」。⚠️ **整仓 24 小时没有任何判据读数**,而下一个人(包括我)据上一份报告继续推断「判据在把守」⇒ **报告的证据等级被系统性高估**。守卫本身**不删**(漏接线绝不静默是真价值),代价就是「一个文件漏接 = 全仓失去全部读数」,风险随判据数单调上升 ⇒ 必须靠「新增即接线」这条义务兜。", "note": "★★★ 2026-10-03 发现并修复(三个文件补进 SUITE,登记数按 node:test 惯例写 0)。\n\n## 为什么之前没人发现\n失败输出是中文一行 stderr,**不含任何「红」字样**,也不含退出码语义;`npm test` 的非 0 在很多 CI/脚本包装里被忽略。而**判据目录里没有一条判据检查「套件上一次真读到数是什么时候」**。\n\n## 同族:管道吞掉退出码(本会话同时踩了三次)\n`cmd 2>&1 | tail -N` ⇒ `$?` 是 **`tail` 的**退出码,不是 `cmd` 的 ⇒ `bg_run` 的 `exit-code` 报 0。\n实例:`npm test | tail -80`(真实:0 条判据跑过)、`npm test | tail -30`(真实:run-all red,vitest 根本没跑)、`tsc --noEmit | tail -20`(**碰巧**也是 0)。\n⇒ 第三次事实为真但**当时无根据**,仍必须重取证:`cmd >file 2>&1; echo $?`。\n\n## 可判动作\n· 新增 `*.test.mjs` 之后,`node test/run-all.mjs | grep RESULT` 看 `ran` 是否等于 `SUITE` 条数;\n· **退出码只从不接管道的运行取**(`| tail`/`| head`/`| grep` 全返回末端码);\n· `&&` 链里「全绿」要问**真跑到那一环了吗**——red 之后的东西**根本没跑**,日志里「有 RESULT 行」与「无下游输出」可以同时出现。\n\n## 边界 / 未做\n· 本笔只登记**机制**;已修的三个文件与登记数漂移是同一批改动。\n· 「上次读到数是什么时候」**没有做成判据** —— 要可靠地判,得先有持久化的运行记录(现在 RESULT 只在 stdout),那是另一件事。" } ] }