Files
MailUI4Agents/docs/DEBTS.json
JianFeeeee 02643f2b42 docs(debt): 补记之廿一 —— pi 的"三种语义"分类对但②够不着;★ 文档(agents.go:40-41)说 [] **清空镜像** 而实现+测试是**什么都不删**(决定性探针实证);顺带查出死字段 heartbeatRequest.Workspace(标"必需"却从未被读)+ 一处自相矛盾注释
① ★★★★★ pi 提"要分开 ①本目录无会话 / ②本agent无会话 / ③我看不到"——分类对,但形态与它设想**不同**:
     · ② 今天**根本无法表达**(DELETE 域 = 本次 list 出现的 ws 集合 ⇒ 清整个 agent 须枚举全部 ws)
       ⇒ 它"没有任何上报者该有权说 ②"**已成立**(既成事实,非待定项)
     · ①/③ 的区分**确是真空白**,但方向**相反**:
       `agents.go:40-41` 文档明说"**空数组 = 平台侧确实一条会话都没有(清空镜像)**"
       而 `platform_sessions.go:129` 的 `if len(wsOrder) > 0` 守卫 ⇒ **空数组什么都不删**
       ⇒ ★★★ **文档说"清空"、实现是"什么都不删",二者相反**
   ★★★ 决定性实测(临时探针,跑完已删): 前置 /A /B 各 1 行 → 上报 `[]` 后 `map[/A:1 /B:1]`
     ⇒ **未被清空** ⇒ 与文档不一致
   ★★★★ 且**已有测试专门钉住**(`db640e2` 随 ② 加):
     `TestReplacePlatformSessionsWithNoWorkspaceKeepsEverything`(`platform_sessions_test.go:449`)
     断言"不带 workspace 时必须什么都不删" ⇒ **实现与自己的新测试一致、与心跳端点文档矛盾**

② ★★★★ 「必须先定的语义」据实测改写:
     今天 `[]` 实际语义 = "本次上报没给我 workspace 信息 ⇒ 无从判断该清谁 ⇒ 什么都不动"
     ⇒ 安全侧(不误删),但代价是"**该目录会话全没了**"**永远无法表达**
     ⇒ 平台侧某目录会话全删后,镜像旧行**永不清除**(除 `DeleteAgent` 破坏性路径)
   ★ 现网陈旧行实测: opencode 100 行 + homeagent 49 行逐 id 回查 ⇒ **已消失 = 0**
     ⇒ 该空洞**未被观测到触发**
   ⇒ 建议(供人类裁): 心跳加**显式**"本次覆盖的 workspace 清单",把"覆盖了哪些目录"与
     "这些目录里有几条会话"**分成两个字段** ⇒ `covered=[/w1], sessions=[]` 唯一表达
     「/w1 确实空了」,且**不需**枚举整个 agent

③ ★★★★ 顺带两个独立发现:
     · **死字段**: `heartbeatRequest.Workspace`(`agents.go:32`)注释写"**必需**",
       但 `grep -rn "req\.Workspace" server/` ⇒ 心跳 handler **一次都没读**
       (唯一命中的 `mail.go:823` 是**另一个** struct)
     · **自相矛盾注释**: `:30` 称"pending_mails **按它[Workspace]算**",
       而 `:179` 称"pending_mails 是**全局**未读数(跨工作区)" ⇒ 代码实际用全局
       (`UnreadWorkspaces(ctx, agentName)`)⇒ `:30` 那句是**陈的**
     ⚠️ 边界: 我**未**核历史上是否读过 ⇒ 只标"当前未读",不标"从未读"

④ ★ pi 其余各条:
     · "11/12 次候选=0 比你测的更重" ⇒ 与我今天实测(恒 100 行/ws=1)**不符**,但它观测(09-26)在
       ② 部署(09-28)**之前** ⇒ 不冲突、是不同前置条件 ⇒ 我只认领"**② 之前**的形态",不认领"更重"
     · "频次极不均匀 ⇒ 危害由频率决定" ⇒ ★ **对且重要**(我此前只报"轮换"未量化频率)
     · "am-mcp-probe 高频可能被我们自己的调试拉高" ⇒ ⚠️ **好的自我怀疑**,无历史快照 ⇒ 保持未知
     · "project 表 13 行 / 10 个有会话" ⇒ ★ **实测完全吻合**(13 行;worktree 去重 11;有会话 10)
     · "FullReplace 测试钉着整表替换是有意设计" ⇒ ★ **对**,已复核

⑤ ★ 我本轮 3 处引用失误(同一模式第 3 次,且都在"正在记录'要核行号'"的补记里)
     · `agents.go:43-45` → 实为 **`:40-41`**
     · 写"含会话的 project 数**见附表**"而**本轮没有附任何表** ⇒ 指涉凭空
     · 写"worktree 去重 **12** 个" ⇒ 实为 **11**
   ⇒ 处置: (a) 行号/数字**只在当场回显/算过之后**才写进文档;
     (b) **不写"见附表"**除非同一条内确有该表
   ⇒ 本轮 8 处引用已逐一 `sed -n Np | grep -c` 核过(`:43-45` 即由此发现)

校验: go test ./internal/repo/ -run Debt -count=1 ⇒ ok; 临时探针已删除; 未改产品代码
2026-09-29 04:35:20 +08:00

349 lines
221 KiB
JSON
Raw Blame History

This file contains invisible Unicode characters

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

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

{
"_": [
"欠账的**单一登记**(pi 2026-09-14 裁定 §3):三笔类型不同、但必须能一眼看全。",
"为什么要一个文件:三笔原先各自表达(RESULT static=5 / t.Skip / 登记在文档里的到期前提),",
"没有一处能看全 —— 而『欠账不显形,就等于没有』;分散在多处的登记,审计时只会被找到一处就当全部。",
"两端都读这个文件:Go 侧判据断言自己的条目与**实测**一致(不许留一份手写的数字),",
"electron 套件把它打进 RESULT 行(那是常态可见的位置)。",
"已结算(2026-09-14):calendar-today-recompute —— P6 第 1 步(日历页 pages/CalendarPage.ets)落地,today 走 `@Prop @Watch('onVisibleChanged') visible` 在 pane 变可见时重算(另有 aboutToAppear 覆盖重新挂载),判据 test/harmony-calendar.test.mjs 的「★ today 在 pane **变可见时**重算」。结算即从此清单移除,余额里不再计这一笔。",
"已结算(2026-09-19):gesture-semantics —— P6 第 3 步(左右滑动翻页)落地:鸿蒙侧 `CalendarPage.ets` 加了 `PanGesture`、判定逻辑在纯逻辑层 `model/Calendar.ts` 的 `judgeSwipe`。按该条自己的口径(「鸿蒙侧出现滑动手势代码时**立即建**」)同步建了语义契约判据 `test/cross-client-gesture.test.mjs`(8 条)—— 按 (b) 口径钉**语义**不钉数值:左滑=下一段/右滑=上一段/纵向优先/快滑窗口/手势与按钮共用同一翻页函数/无边界回弹/有意差异被记录/方向写反必红。另在 `harmony-logic.test.mjs` 加了行为判据(跑 `judgeSwipe` 的四道门)。结算即从此清单移除,余额里不再计这一笔。",
"2026-09-19 新增 wide-breakpoint-divergence:两端断点不同且含义不同(审计发现,此前无记录)。它不是\"已确认的缺陷\",而是**未决的口径** —— 登记它是为了让「两者不同」这个事实本身可见,而不是让它藏在两处代码里。",
"2026-09-19 新增 mail-list-attachment-count:邮件列表的附件数两端都拿不到(WebUI 那段是死代码)。鸿蒙侧只补了有真数据的「抄送 N」。",
"2026-09-19 新增 harmony-maildetail-missing-three:邮件详情页缺三块功能(改名建议 / 转发 / 预算编辑),服务端都已支持。",
"2026-09-28 新增 stress-sse-delivered-but-silent-no-assertion:pi 报的「9 封从未投出」经复核是**投递成立、不回信属协议设计**(Agent 来信不自动转发);真缺口是压测脚本只断言帧完整、**从不断言投递**,而两个读数在库里同形。已给 `stage_sse` 补上按桥日志核对的投递读数。",
"2026-09-28 新增 criterion-silently-returns-zero:判据在真实数据上**静默归零**比判据报错危险得多。本轮两个 Agent 各犯一次同一个错(pi 的 `from_name='pi'` 选出「没回信」;我取信日志的路径少了 `/agent` 前缀),两次都「核过对方」且都报 0。附 33 条 alias 带字面引号的第三例。",
"2026-09-28 新增 archive-session-filter-partial:FindSessionByAddress 是别名寻址族里唯一不带归档过滤的一处(其余三处都带)。pi 裁定本轮不夹带(会改 API 返回码 200→404),登记为已识别的不一致而非漏做的收尾。",
"2026-09-28 12:55 登记 `archive-sweeper-leaves-mails-alive`:反向两表分叉(`s.status='archived'` 而 `m.status<>'archived'`)在生产里已有 **28 会话 / 431 封**,成因直接钉到 `deploy/archive-stale-sessions.sh:105`(归档不 UPDATE mails)与 `:147`(回滚同样只写 sessions),并用 `/root/gotmp/archive-ids-20260915-065927.txt` 的 12 个 id 闭合因果(12/12 都是分叉)。不变量测试 `session_status_invariant_test.go:70` 查的正是这个方向,但只在 `t.TempDir()` 空库上跑 ⇒ 存量分叉它永远看不见。",
"2026-09-28 13:20 修正 `archive-sweeper-leaves-mails-alive` 三处,并按 pi 的唯一异议调整 A/B 分类:(1)★ **回滚后显示未读的是 45 封不是 7 封** —— `readStateFor`/`unreadFor` 只看 `mail_reads`,`m.status` 是冗余列(repo.go:2132 注明不再作未读判据),424 封 'read' 里有 38 封收件人侧无 read 行;且「45」成立靠的是这批 cc_list 全空(实测 431/431),判据必须按 reader_name 写。(2)分叉**仍在产生**:28 条里 8 条是今天 09-28 的(当天 archived 会话共 72 条)。(3)成因不止 sweeper:`ArchiveSession` 两句 UPDATE **无事务包裹** ⇒ 中途崩溃也留反向分叉,这解释了 6 条混存会话(该函数执行即盖全部非 archived,造不出「一部分 archived」形状)——混存形状的成因未闭合,**不写死结论**。(4)pi 的「ids 只有 session_id ⇒ 只反还本次不可实现」对但不完整:`agentmail-pre-archive-20260915-065927.db` 是归档前快照,12/12 条可反推(共 467 封非 archived);且 `undo-poison-archive-FIXED.sh` 是**逐封点名 mail_id**(34 封)⇒ 那不是普遍约束,决策空间实为「无快照的那些次怎么办」。(5)分类:**A 类删原第 8 条、B 类加为第 11 条** —— pi 指出 82>65 的症状不是 0,按判别标准属 B;A 类新增第 0 条「口径对齐了吗」。判别标准收敛为:**A = 测不出/测不稳;B = 测出来了但推不出来**。"
],
"debts": [
{
"id": "static-criteria",
"count": 5,
"due": "本工作区能装、能点设备(探针三值转 true 时自动变红)",
"where": "client/electron/test/run-all.mjs 的 STATIC_ONLY(2026-09-19 起 harmony-admin 那条已升级为设备判据、移出名单 ⇒ 6 → 5:test/harmony-appearance.test.mjs、test/harmony-logic.test.mjs、test/cross-client-theme.test.mjs、test/appearance-defaults.test.mjs、test/harmony-imageprep.test.mjs)",
"kind": "scope",
"note": "2026-09-19:`harmony-admin` 的条目**已升级**(管理页真的能打开、列表真的渲染出用户行)并移出 STATIC_ONLY ⇒ 本笔 6 → 5。剩下的五次升级按\"每条缺什么设备侧验证\"逐条来,不为了把数字消成 0 而凑 —— 凑出来的设备判据只是把\"没验\"换成\"假装验了\"。\n\n2026-09-19 再更新:`cross-client-theme` **升级了一半** —— 新增一条设备判据(读悬浮球像素:品牌色真的画成 `#2563EB`;且球上图标与底色 WCAG 对比度 ≥3:1),并顺手钉了一条静态防线(`Theme.surface` 不得当代的前景色)。**它仍留在 STATIC_ONLY 名单里**,因为 `.ets` 那半只有悬浮球这一处上了设备 —— 其余令牌仍是静态对齐。「一半」要写出来,不能让名单看起来像没动过。"
},
{
"id": "mails-status-derived",
"count": 1,
"due": "详情/线程改为按读者派生(readStateFor)之后 —— 那时 mail_status_derived_test.go 从 Skip 转实跑",
"where": "server/internal/repo/mail_status_derived_test.go",
"kind": "scope"
},
{
"id": "observability-output",
"count": 1,
"due": "页面层(MainPage.ets)接上「读 presetSubstitutedFrom 并打一行日志」时;那一步同时补判据『读侧恰好出现 1 次且在日志调用里』",
"where": "尚无判据 —— 这正是欠账的一部分(P6 第 1、2 步动 MainPage.ets 时一起做);形态判据在位:test/harmony-appearance.test.mjs(bgBlur 消费侧计数)",
"kind": "scope"
},
{
"id": "unknown-preset-approval",
"count": 1,
"due": "有人对上表那格**追认或驳回**「未知 id 显示 aurora 而不是空白」这个方向时(我作为实现者不能自己追认自己)",
"where": "client/electron/test/CRITERIA.md §10 的『未知的预设 id』行(现为『无人类批准』)",
"kind": "env"
},
{
"id": "overlay-follows-app-theme",
"count": 1,
"due": "上设备后**翻转一次 colorMode**(应用深色 / 系统浅色),断言**解析出的遮罩值跟着「应用」主题变、而不是跟「系统」**;真机若证伪,正确修法是「遮罩从应用主题派生」,不是回到双常量",
"where": "client/harmony/entry/src/main/ets/common/Theme.ets:58-79 的注释(机制依据:AppearanceStore.applyTheme → app.setColorMode)——**注释不是判据,所以进余额**",
"kind": "env"
},
{
"id": "nav-dark-route-b-unguarded",
"count": 0,
"due": "已完成 2026-09-23(见 note)",
"where": "client/electron/test/background.test.mjs —— **已堵**:见 note。(原文:`.dark .nav-rail{}` 选择器作用域与组件 `dark:` 变体,变异确认过都会逃掉)",
"kind": "scope",
"note": "**已堵(2026-09-23)**。这三条逃逸路(A 选择器作用域 / B Tailwind `dark:bg-*` 变体 / C 元素级 `backdrop-blur-*`)此前各自无判据,变异确认过全逃得掉。到期条件(深色主题落地)在 2026-09-17 就成立了 —— 但那次只修了令牌这条路,欠债逾期至此。\n\n堵法(欠债原文点名的**文件窄豁免**,不是一刀切):`background.test.mjs` 新增三条 check,**只扫 `className` 里出现 `nav-rail`/`nav-item` 的那些类串** —— 问的不是「这个文件有没有 dark: 背景」,而是「挂在导航元素上的那个类串里有没有」(与逃逸路的形状同构;同文件别的元素写什么都不影响,避免 `bg-chrome-600` 那个先例的误红)。\nA 条另起一条:CSS 里不许出现 `.dark .nav-rail`/`.dark .nav-item` 这类选择器。\n三条都做了变异验证(逐条注入 ⇒ 各红一条;恢复 ⇒ 47/47 全绿)。"
},
{
"id": "nav-blur-route-c-unguarded",
"count": 0,
"due": "已完成 2026-09-23(见 note)",
"where": "client/electron/test/background.test.mjs —— **已堵**:见 note。(原文:元素级 `backdrop-blur-lg` 不在那条选择器下,变异确认过逃得掉)",
"kind": "scope",
"note": "**已堵(2026-09-23)**。这三条逃逸路(A 选择器作用域 / B Tailwind `dark:bg-*` 变体 / C 元素级 `backdrop-blur-*`)此前各自无判据,变异确认过全逃得掉。到期条件(深色主题落地)在 2026-09-17 就成立了 —— 但那次只修了令牌这条路,欠债逾期至此。\n\n堵法(欠债原文点名的**文件窄豁免**,不是一刀切):`background.test.mjs` 新增三条 check,**只扫 `className` 里出现 `nav-rail`/`nav-item` 的那些类串** —— 问的不是「这个文件有没有 dark: 背景」,而是「挂在导航元素上的那个类串里有没有」(与逃逸路的形状同构;同文件别的元素写什么都不影响,避免 `bg-chrome-600` 那个先例的误红)。\nA 条另起一条:CSS 里不许出现 `.dark .nav-rail`/`.dark .nav-item` 这类选择器。\n三条都做了变异验证(逐条注入 ⇒ 各红一条;恢复 ⇒ 47/47 全绿)。"
},
{
"id": "boundary-vocabulary-incomplete",
"count": 1,
"due": "**由外部读者报告时**(自查机制对这一类结构性失明 —— 发现词表外说法的机制,正是看不见它的那个机制)。收到报告后:扩词表 + 登记该处 + 保留\"上一次是谁发现的\"。**没有内部触发器,这是这条递归的不动点**:无论词表多长、判据多严,总有一类盲区只能靠\"外面有人读了一遍\"",
"where": "client/electron/test/debt-visibility.test.mjs(词表键控的盲区:**已知未覆盖**——词表是采样、不是完备)",
"kind": "env"
},
{
"id": "redeploy-script-unguarded-steps",
"count": 0,
"kind": "scope",
"due": "**已还清 2026-09-25**(见 note)",
"where": "`deploy/redeploy-gateway.sh` 的四类副作用步骤 —— **已堵**(见 note)。(原文:`deploy/redeploy-gateway.sh:84` 的 `run \"cp -r …\"`,`run()` 内 `eval` 的失败既不中断也不被调用点接收)",
"note": "**已堵(2026-09-25)**,出口是这条欠账自己写的到期条件(\"下一次改 `deploy/` 下任一脚本时\")—— 当天因修 `--dry-run` 落地写入而动了该脚本,故一并还。\n\n堵法按原文给的形状(`|| { bad …; exit 2; }`,与既有 2=环境/1=检查 对齐),四处:\n ① 前端同步三步(`rm -rf assets` / `rm -f index.html` / `cp -r dist`)各自接收退出码;\n `cp` 尤其要紧:没拷进去 ⇒ go:embed 把**旧界面**打进二进制,而单测仍全绿(2026-09-14 踩过)。\n ② `systemctl stop` 加守卫 —— 这是本条目原文点名的\"stop 失败而状态没人看\"。\n 加它的理由比原文更强一层:`systemctl start` 对**已在运行**的服务是 **no-op** ⇒ stop 没成功时后面那句 start 什么也不做,**旧进程继续跑旧代码**,而脚本一路走到后置验证报成功 —— 即\"部署脚本跑过了 ≠ 线上跑的是当前代码\"被脚本**自己在内部**造出来。\n ③ am-sandbox 段的 `go build`/`install` 并入 DRY_RUN 分支。\n ④ `install -d $PREFIX/bin` 与通知脚本 `install` 并入 DRY_RUN 分支(同批修的 `--dry-run` 会落地问题)。\n\n**未一并做的**(如实记,避免本条读起来像\"全脚本已无裸步骤\"):`redeploy-plugin.sh` / `install.sh` 的同类步骤仍未逐个加守卫 —— 那是另一条欠账 `deploy-interrupt-trap-other-scripts` 的范围,本条只覆盖 `redeploy-gateway.sh`。"
},
{
"id": "deploy-space-prefix-fs",
"count": 1,
"kind": "判据铺得不满(不是新列)",
"due": "下一次因空间问题失败时;或有人愿意补一行 df 时",
"where": "deploy/lib/env-defaults.sh ②b 只判了 $TMPDIR,没判 $PREFIX 所在的文件系统"
},
{
"id": "pi-bridge-adopt-cwd-mismatch",
"count": 1,
"kind": "沙箱 rw 与 worker 实际 cwd 的第三个来源未对齐(同类已修两处,剩接管路径)",
"due": "下一次动 pi 桥的会话装载 / `session-scan.mjs` 时;或有人愿意把「这一轮用哪个 cwd」完全收成父进程一处决定时",
"where": "`plugins/pi-mail-bridge/src/worker.mjs` 的**接管会话**分支:worker 用会话文件 header 里的 `info.cwd`(`resolveWorkspaceCwd(info.cwd || data.to_workspace, …)`),而父进程(`src/pool.mjs` → `src/turn-cwd.mjs`)只能从 `state` 里拿 `{sessionFile, cwd}`,**读不到 header** ⇒ 接管的首回合 rw 仍可能不含 worker 真正要写的目录。父进程要拿 header 得用 `src/session-scan.mjs`,但 `readHeader` 未导出、整表 `scan()` 在父进程里代价大(worker 里实测 1431ms / 堆瞬时 240MB,见 worker.mjs 里那段注释)。★ 这类错位的**特征是没有提示**:EACCES 落在「界内」,读日志的人会以为沙箱装错了(不像 `ask` 还有一次问)。修法方向:把「这次用哪个 cwd」收成父进程一处决定(它已有 `state.sessionFile`,header 也可读),worker 只消费、不再自己推导 —— 即 `src/turn-cwd.mjs` 头注释里写的「三来源变一来源」。"
},
{
"id": "deploy-interrupt-trap-other-scripts",
"count": 1,
"kind": "只在 redeploy-gateway.sh 做了,另两个部署脚本没做",
"due": "下一次动 redeploy-plugin.sh / install.sh 时",
"where": "只有 redeploy-gateway.sh 有 INT/TERM/HUP trap;install.sh 与 redeploy-plugin.sh 在写系统目录期间被打断同样会留半成品(它们没有\"服务停着\"那种后果,所以优先级低)"
},
{
"id": "harmony-p4c-boundary-decls",
"count": 4,
"due": "本工作区能装、能点设备 —— 那时这几条静态判据里被替代掉的那些断言换成真机断言,声明随之减少",
"where": "client/electron/test/harmony-admin.test.mjs、client/electron/test/harmony-imageprep.test.mjs",
"kind": "scope",
"note": "2026-09-19 更新(设备可用了,开始逐条升级):① `harmony-admin` 的那条**已升级为设备判据**(管理页真的能打开、列表真的渲染出用户行)⇒ 从本组减 1。② `harmony-imageprep` **只做了一半**,如实记着:新加的设备判据用真实素材(用户真上传的那张壁纸 1402×1122 / 152570 字节,从 GET /me/appearance/image 取回)验了「设备上读到的尺寸/体积与压缩决策的输入对得上」;**未做**「应用真的用 image.createImagePacker() 压一次、产出字节落在预期区间」——那需要一个**用户选图**入口(DocumentViewPicker,要人操作系统选择器),自动化里没有稳定路径。编一个绕过选择器直接调 packJpeg 的测试专用入口,会是只有测试在用的代码 —— 那种代码不会被真实场景触到,验它等于验一个不存在的东西。⇒ 剩 4 条里这一条要等一个**真人操作**的验证窗口(或平台提供可注入的选择器)。"
},
{
"id": "deviceprobe-fixture-timing",
"count": 1,
"due": "把两份 fixture 变成**当场采集**(跑 hdc dump 取现场)而不是人工存文件时,这条就到期",
"where": "client/electron/test/harmony-deviceprobe.test.mjs(两处提到「未验」的断言 + fixtures/aa-dump-l-*.txt)",
"kind": "scope"
},
{
"id": "wide-breakpoint-divergence",
"count": 1,
"due": "决定是否统一:鸿蒙 768vp vs WebUI 1024px(**不是 bug,是未决的口径**)",
"where": "client/harmony/entry/src/main/ets/pages/MainPage.ets(isWide 的 onAreaChange,>=768) 与 client/electron/src/hooks/useIsNarrow.ts(NARROW_QUERY = max-width: 1023px)",
"kind": "scope",
"note": "两端断点不同,且**含义也不同**,所以数值不同本身不算错:WebUI 的 1024 是「三栏(60 导航 + 320 列表 + >=520 详情 ≈ 900px,再加余量)放不下就退化单栏」,鸿蒙的 768 是「要不要显示侧栏」(鸿蒙没有并排的列表+详情三栏,它的内容区是一个窗格,所以 768 就够)。但**用户可见的后果**是:在 768–1023 宽(常见竖屏平板、窄窗口)下,WebUI 是单栏 + 底部导航,鸿蒙是侧栏 + 内容 —— 两台设备上同一宽度长得不一样。2026-09-19 由审计发现(此前**没有任何地方记录**这件事,连「两者不同」这个事实本身都没写下来,所以下一个人只会当成漏改)。待定:① 统一到 1024(鸿蒙跟 WebUI);② 统一到 768(WebUI 跟鸿蒙,但要重新论证三栏是否真能在 768 放下);③ 承认它们是两件事、把语义差异写进文档(那就该给两端各自的名字,而不是都叫 wide)。"
},
{
"id": "mail-list-attachment-count",
"count": 0,
"due": "已修(2026-09-20):字段确实返回,两端都可接",
"where": "server/internal/handler(邮件列表的响应构造) —— 客户端侧见 client/harmony/entry/src/main/ets/pages/MainPage.ets 的 MailItem",
"kind": "scope",
"note": "★★ 2026-09-20 **更正**:当初这条的结论是**错的**,根因是它把一个`omitempty` 造成的**字段缺失**当成了「服务端不返回这个字段」。\n\n原文(留档,别再犯):实测 `GET /me/mail/inbox` 的回包字段列表里「既没有 `attachments` 也没有 `has_attachments`」⇒ `MailList.tsx:261` 的 `mail.attachments?.length ?? 0` 恒为 0、那个 📎 在 WebUI 上从不出现(死代码)。\n\n事实:服务端 `GetInbox`(`mail.go:586`)**明确调了 `fillAttachments`**,并写了理由:「Agent 靠收件箱列表得知有哪些附件可下载,否则它不知道该调 attachment_id」。而 `Mail.Attachments` 的 json tag 带 **`omitempty`** —— **没有附件的邮件根本不输出这个 key**。\n我当初是**只看了一封没附件的邮件**就下了全称结论。\n\n2026-09-20 实测(`limit=200`,96 封):带 `attachments` 的 **2 封**,都是真有附件的那两封。⇒ **WebUI 那个 📎 不是死代码**,鸿蒙当初「有意不抄」的前提不成立。\n\n★ 教训:`omitempty` 字段的**缺失**不等于「服务端不返回」。判「某字段有没有」必须拿**确实有值的那条**去验,而不是拿一条恰好为空的数据。这与同一天那个真崩溃(`session_workspace` 的 `omitempty` → 客户端 `undefined` → 白屏)是**同一个坑的两面**。"
},
{
"id": "harmony-maildetail-missing-three",
"count": 0,
"due": "鸿蒙详情页对齐 WebUI 时(三块功能,服务端都已支持)",
"where": "client/harmony/entry/src/main/ets/pages/MailDetailPage.ets (对照 client/electron/src/components/MailView.tsx)",
"kind": "scope",
"note": "2026-09-19 审计发现:邮件详情页缺**三块 WebUI 有的功能**,而且**服务端三块都已支持**(不是做不了):① `RenameProposalBar`(MailView.tsx:324)—— Agent 建议改会话别名,人确认/驳回。服务端 `server/internal/handler/rename_proposal.go` 已在解析 `propose_alias` 标记并把提议从正文剥掉。**这条对寻址稳定性很重要**(WebUI 那里的注释:「Agent 干到一半自己改掉,人上一秒记住的地址下一秒就失效」)。② `ForwardBar`(MailView.tsx:378)—— 转发。服务端 `forward.go` 完整(含 `forwardSubject` 的 Fwd: 叠加处理)。③ `BudgetEditor`(MailView.tsx:235)—— 会话往返预算编辑(`max_rounds`,服务端 `forward.go:304` 有此字段)。三条都不是顺手能补的量级(各含交互 + 接口 + 状态),所以先登记,不在审计那一批里硬塞 —— 硬塞的结果是每条都半成品。\n\n2026-09-19 结算:三块**都做完了** ——\n① 转发(commit cc7ff25,`ForwardMailRequest` + 弹层;修了两个真 bug:两个动作球几乎完全重叠、弹层没高度导致键盘一弹按钮被顶出屏幕);\n② 会话改名建议(commit c26e857 接口 + fe4342a UI,端到端实测:服务端识别 rename_proposed → 建议条出现 → 点接受 → 库里别名真的改了);\n③ 往返预算(commit 4e84369,GET/PUT 契约用 curl 逐条实测)。\n★ 未验:预算条的**点击**在设备上没走通 —— 模拟器顶部 155px 是系统手势区,而折叠头部恰在其中(坐标式点击会被系统抢走)。数据链路已实测,观感没验。"
},
{
"id": "harmony-dead-pages",
"count": 2,
"due": "下次清理鸿蒙页面时(要先确认不是将来要用的留存实现)",
"where": "client/harmony/entry/src/main/ets/pages/InboxPage.ets(238 行)、pages/SessionsPage.ets(170 行)",
"kind": "scope",
"note": "2026-09-20 盘点发现的**不可达页面**(两页共 408 行):\n· `InboxPage.ets` —— 不在 `resources/base/profile/main_pages.json` 的页面表里,全仓**没有任何** `pushUrl('pages/InboxPage')`。\n· `SessionsPage.ets` —— 注册在页面表里,但**唯一的引用**是 `InboxPage.ets:171` 的 `pushUrl('pages/SessionsPage')`,而 InboxPage 本身不可达 ⇒ 一起不可达。\n\n★ 为什么没顺手删:`docs/HARMONY-ALIGN-PLAN.md:214` 把 `InboxPage.ets` 当作变异测试的靶子(往它塞一个色值来验证判据能判红),说明它在某个时点是有用的。删掉会永久丢代码,而它是否只是「早期实现的留存」我判断不了 —— **这是人的决定**。\n\n★ 它现在还带来实际成本:`cross-client-logic` 与 `harmony-arkts` 的**形状判据**会扫到它(并按「死代码」给它豁免)—— 每加一条形状判据都要为「这文件还活着」多写一次例外。要么删、要么在文件头明确写「这是留存参考、不参与构建」,两条路都比现在清楚。"
},
{
"id": "harmony-permission-history",
"count": 0,
"due": "**已完成 2026-09-21**(本轮「授权栏与 WebUI 对齐」)",
"where": "client/harmony/entry/src/main/ets/pages/MainPage.ets 的 PermissionTab(现在只读 /permission/pending)",
"kind": "scope",
"note": "**已修(2026-09-21)**。原文:鸿蒙只调 `/permission/pending`(SQL `WHERE pr.result IS NULL`)⇒ 已决策的历史完全看不到。\n\n修法与**为什么不照抄 WebUI 的 inbox 分组**:\n· WebUI 从 inbox 分组(`groupPermissions`),但它的 `PermissionRow` 只渲染\n `subject`/`created_at`/`permission_result`/`permission_expires_at`(逐字段 grep 过);\n· 而**待决**那一段我们要显示 `question`/`options`/`context`/`kind` —— 那四个字段在\n `permission_requests` **表**里,inbox 回包(`models.Mail`)**没有**(模型逐条核过)。\n⇒ 待决继续走专用端点(信息更全、能直接决策),**历史**走 inbox 补上。\n 代价:每账号多一次请求。这是有意的取舍。\n\n落地:`model/MailGrouping.ts` 加 `groupPermissions` + `PermissionGroup` + `isPendingPermission`;\n`PermissionTab.load` 取 inbox 里 `mail_type=permission_request && permission_result!=空` 的,\n分组后渲染「历史 n 条」。`cross-client-logic.test.mjs` 的 `gaps` 已按提示清空。"
},
{
"id": "harmony-morph-unverified-middleframes",
"count": 1,
"due": "有更快的取帧手段时(snapshot_display 往返 1.5-3s ⇒ 只能验 >=3s 的动画),或用户在真机上看过并反馈",
"where": "client/harmony/entry/src/main/ets/common/Motion.ets 的 Motion.morph;调用点 MailDetailPage / MainPage",
"kind": "env",
"note": "2026-09-21 加了两处**共享元素转场**(geometryTransition:回复球<->回复条、写信 FAB<->写信页),结构与接线都有判据钉住(animation-audit.test.mjs 6 条,三条变异逐个验过会红),但**动画本体在设备上没能看到**。\n\n原因:snapshot_display 一次往返 1.5-3s,uitest screenCap 约 3s,而这条动画 220ms ⇒ 探针比被测对象慢一个数量级。实测连拍 4 张,y=1600 的白区跨度全是 (30,1007)(每张都已是终态)。\n\n★ 这里的教训本仓已记过一次(「探针对被测变化不敏感时,量的是噪声」——那次我连测七八轮「没有中间帧」并编出三个错误理论,最后被位移探针推翻)。这次不再重复:**如实标未验**,不声称已看到。\n\n要验它需要:能按帧取图的手段(screenrecord 在本环境不可用),或真机上手看。"
},
{
"id": "harmony-account-errors-banner-unverified",
"count": 1,
"due": "有一个会取失败的账号可构造时(例如故意把某账号的 server 指向不可达地址)",
"where": "client/harmony/entry/src/main/ets/pages/MainPage.ets 的 accountErrors 横幅",
"kind": "env",
"note": "2026-09-21 接上了聚合失败横幅(WebUI MailList.tsx:85-96 的 account-errors)。修的是一处**安全网断线**:MailStore 一直在收集 snap.accountErrors(两处 load 都写),而界面从来没读过 ⇒ 某个账号拉不到邮件时列表**静默少一整份**,界面看起来完全正常,用户会得出错误结论(「没人给我发信」)。\n\n**只验了「不出现」那一半**:本机所有账号都取得到 ⇒ 横幅正确地不显示。「真的会显示成那样」没能实测 —— 需要一个取失败的账号,本机构造不出。\n\n为什么仍值得登记:这条横幅的**全部价值就在它出现的那一次**。「逻辑对齐 + 编译通过」与「真的渲染出来」是两件事,本仓为此反复吃过亏。"
},
{
"id": "harmony-permission-history-render-unverified",
"count": 1,
"due": "**有一个「未归档会话」里的已决策权限请求时**(光 `UPDATE mails SET permission_result` 不够 —— 见 note 里的实测)",
"where": "client/harmony/entry/src/main/ets/pages/MainPage.ets 的 permGroups 渲染段",
"kind": "env",
"note": "2026-09-21 闭合 harmony-permission-history 时新增的「历史 n 条」渲染。\n\n分组逻辑(`groupPermissions`)是纯函数、有跨端逐例判据;渲染那一层没验。\n\n★★ 2026-09-23 **实测订正复现路径**(原文写的是「`UPDATE mails SET permission_result` 之后即可复现」—— **那样做复现不出来**)。\n\n本机实测:库里**确实有 137 条已决策**的权限请求(`to_name=jianf` 107 条),但 `GET /me/mail/inbox?status=all` 返回的权限请求是 **0 条**。根因不在权限,在**会话归档**:\n · `repo.ListInboxScoped` 硬编码 `AND s.status <> 'archived'`(该过滤在整个 repo 出现 8 处,是「归档会话不进任何列表」的**全局约定**);\n · 而那 107 条所在会话**全部是 archived** ⇒ 被整条过滤掉;\n · 实测「未归档会话 + 已决策权限请求」的组合数 = **0**。\n\n ⇒ 鸿蒙的「历史 n 条」在**当前这台库上恒为空**,不是渲染坏了,是数据够不到。\n 两端一致(WebUI `PermissionList.tsx:66` 也是 `fetchInbox('all')`,同一端点同一过滤)⇒ 这不是鸿蒙的遗漏,**是两端共同的行为**,服务端的过滤是有意的。\n\n要真验,需构造:**未归档**会话 + 该会话里一条已决策的权限请求(例如 `UPDATE sessions SET status='active' WHERE session_id=<那条>`,或让 agent 在活跃会话里发一条再决策)。\n\n★ 另一处(同一次实测发现,已修):渲染注释曾承诺「会话别名 + **决策** + **时间**」,而代码只渲染「别名 + 历史 n 条」—— 属本仓反复在消的「声明比实现宽」。已把注释改成与 WebUI 折叠态一致的**真实形态**(WebUI 也是每组一行 `历史 {n}`,逐条的决策/时间要展开才看得到,而鸿蒙没有展开层)。"
},
{
"id": "permission-expires-at-unused",
"count": 1,
"due": "决定是否要把「可能已失效」做进**两端**(需要两端都补类型 + 渲染);或确认这个告警对产品不重要、把服务端那个字段也去掉",
"where": "服务端 `models.go:254`(`PermissionRequest.ExpiresAt`)/ `repo.go:1617`(`pr.ExpiresAt = models.PermissionDeadline(...)`);客户端类型两处都缺:`client/electron/src/types/index.ts` 的 `PermissionRequest`、`client/harmony/entry/src/main/ets/model/Models.ets` 的 `PermissionRequest`",
"kind": "scope",
"note": "★★ 2026-09-23 发现自己:**服务端返回一个两端客户端都不读的字段**。\n\n`GET /permission/pending` 的回包里 `expires_at` 一定存在(`ExpiresAt time.Time` 且**不带** `omitempty`),服务端在 `ListPendingPermissionsFor` 里用 `models.PermissionDeadline(pr.CreatedAt)` 算出它。而**两端的 `PermissionRequest` 类型都没有声明这个字段** ⇒ 反序列化静默丢掉。\n\n── 这次的教训与 `mail-list-attachment-count` 那次**方向相反、形状相同** ──\n那一次我把「`omitempty` 字段在一封没附件的邮件里缺失」当成了「服务端不返回这个字段」;这一次是**真的两端都没读**,而我一开始又差点写成「鸿蒙落后于 WebUI」——实际是**共同缺口**(WebUI 也没读)。两次都说明:**「两端不一致」与「两端都没做」必须先分清**,否则会去\"对齐\"一个根本不存在的东西。\n\n── 已经做掉的那一半 ──\n鸿蒙侧 2026-09-23 补上了 `expires_at` 并把它接进**待决卡片**的失效告警(`PermissionTab.ets` 的 `isStale`)。所以这条债现在**只剩 WebUI 那一半**:WebUI 的 `PermissionList.tsx:248` 读的是 `mail.permission_expires_at`(**别的字段**,那是 `Mail` 模型上由 `AttachPermissionDeadline` 算出来的,只在 inbox 路径上有),而它自己那份 `PermissionRequest` 同样没有 `expires_at`。\n\n★ 要闭合需先决定:**这个告警归哪条路径**?· 走 inbox(`permission_expires_at`)—— 但 inbox **只给未决策的**(`AttachPermissionDeadline` 的 return), 且 inbox 的 SQL **根本没选** `permission_expires_at`(实测 0 处), 它是在 repo 层算出来贴上去的 ⇒ 要确认它真的出现在回包里;\n· 走 `/permission/pending`(`expires_at`)—— 字段现成、语义清楚,但 WebUI 那边要改类型 + 读它。\n 我倾向后者(数据来源本来就对着\"待决\"这件事)。"
},
{
"id": "failure-suppression-must-not-merge-parallel",
"count": 1,
"due": "**失败报告抑制机制落地时**必须一并建(当前该机制**尚未实现** —— 服务端 grep `suppress|抑制` = 0,所以这条判据此刻无对象可测)。判据形状: 构造「同一父信下的两封并列失败报告」,断言 `relayed_mails` 里**两行都在**(而不是被 root-keying 压成一行)。",
"where": "待建。落点取决于抑制修法落在哪一层(`server/internal/repo/relayhops.go` 的 `CountTrailingRelayHops` 一族 / 发送侧桥);口径复算工具在 `deploy/recount-relay-counts.sh`",
"kind": "scope",
"note": "★★ 2026-09-25 pi 提出(`a948cdbb`)、我复核确认并登记。**这条钉的是机制,不是当下恰好成立。**\n\n## 当前为什么「看起来不需要它」\n```\nB 口径(agent, failure-父链根): 98 封 → 92 组、抑制 **6**\n 组内「两个成员共享同一父」的组数 = **0**(我逐组实测)\n ⇒ 5 个多成员组**全是真链式**(成员互为父子链),今天确实不会误合并\n```\n★ 但那是**当前数据的性质,不是设计的保证** —— 只要出现「同一封来信被两个不同 agent 各回一封失败报告」(或同一 agent 对同一封回两次),root-keying 就会把它们合并掉,而那些是**内容各异的并列失败**。\n\n## 这形状在本系统**真实发生过**(所以不是假想)\n```\nsession d042cc4c: 22 封失败报告、**22 个 parent 两两不同**\n (agent, 线程根) 口径 ⇒ 塌成 4 组(9/7/5/1)⇒ 一次**丢 18 封**\n 其中只有 1 个 parent 本身是失败报告 ⇒ **21 封是并列**\n```\n⇒ 「同级并列」不是边角情况。判据② 定稿即按此: **22 封并列失败、(线程根误用下)塌成 4 组、一次丢 18**。\n\n## 与已否掉的修法的关系(避免下一个人重走)\n```\n✗ 修法① 跳过 kind=summary —— 实测恰好豁免它要拦的那一类(该环 100% summary)\n✗ 修法②(仅根 root-keying) —— 就是本条要防的那个: 会吞并列\n△ relay_key 前缀判「是否失败报告」—— 诊断成立,但**服务端语义变更**,待人或宿主定\n```\n⇒ 本条不预设修法;它只要求: **无论选哪种,并列失败不得被合并**必须有判据。\n★ 这就是它该进登记而不是只留在邮件里的原因: 一个没有执行者的结论会一直「在讨论」,而登记 + 到期条件会让接手的人**必须**遇到它。\n\n## ★★ 2026-09-25 追加(我实测):本条的**前置**问题 —— \"是不是失败报告\"这个谓词**没有载体**\n\n在讨论\"用哪种 keying 抑制\"之前,先要回答\"**这封是不是失败报告**\"。我把它的**全部产生点**查了一遍 —— 它散在 **8 条语句 / 5 个文件 / 3 种语言**,且**写法互不相同**:\n```\nplugins/dsh-mail-bridge/src/index.ts:1240 model-failure:${data.mail_id}\nplugins/dsh-mail-bridge/src/index.ts:1749 **empty-reply**:${mailSessionID}\nplugins/pi-mail-bridge/src/worker.mjs:625 model-failure:${...}\nplugins/pi-mail-bridge/src/worker.mjs:715 model-failure:${...}\nplugins/zcode-mail-bridge/src/index.mjs:304 zcode-failure:${...}\nplugins/homeagent-mail-bridge/plugin.go:929 \"homeagent:failure:\"+replyTo ← Go\ndeploy/service-failure-notify.mjs:92/94 service-failure:${INVOCATION_ID|sha256} ← systemd 脚本\n```\n而**网关侧对它一无所知**: `grep -c` 这四家前缀 + empty-reply 于 `server/**/*.go` = **0**。\n\n★ 所以\"拿现成的 `RelayKeyForMail` 判一下就行\"只对一半 —— 它返回 `(relay_key, kind)`,而 **`kind` 区分不了失败**:\n```\nfailure 行的 kind 分布: kind=**summary** | 98 ← 全是 summary\nkind='summary' 内部: failure 98 / **非failure 321**\n⇒ 用 kind 判 ⇒ 会连 321 行普通搬运一起豁免 —— **正是已否掉的修法①**\n```\n\n★★ **活的反例**(这条最关键): `empty-reply:` 是失败类但**不含 `failure` 字样** ⇒ `LIKE '%failure%'` **永远抓不到它**(该路径代码 1 处、当前 **0 行** ⇒ **将来第一次触发就是静默漏判**)。\n\n⇒ 推论: 若在网关里硬编码这四家前缀,第 6 家桥出现时**静默漏判**,后果是\"报告不再被抑制\" ⇒ 环回来(**假绿**)。\n⇒ 所以修法应先把**分类变成网关拥有的东西**(relay 枚举加第三类 / 或 relayed_mails 加一列),再由各桥**声明**而非拼串。\n\n⚠ 加枚举会撞上一条**故意**的锁: `TestRelayKindsIsExactlyTwo`(`relay_test.go:60-64`)\"免配额类型是白名单…新增前请确认它确实是 harness 代劳\" ⇒ 必须同步改它,而那正是它**本来就该问**的问题(failure 确实是 harness 代劳)。\n\n## ★★ 2026-09-25 追加(判据清单落定):⑥ permission 分叉点,**必须**钉住\n\npi `8f6f6e6e` 提出、我 `476d22ad` 复核后定为判据清单的第六条。\n\n### 分叉点是什么\n```\npi 的判据: 「本封来信 id ∈ relayed_mails」 ⇒ 抑制\n我的判据: 「parent 本身是 failure-relay」 ⇒ 抑制\n构造: 一封 permission 询问(kind=permission,也是 relay)发出\n → 对方处理它时失败 → 发失败报告\n pi 的判据: 来信(permission) ∈ relayed_mails 为真 ⇒ **抑制**(错: 首报该发出去)\n 我的判据: parent 不是 failure 类 ⇒ **放行** ✓\n```\n### 可达性我验了(这是它必须钉住的原因)\n```\npermission-relay 的子邮件 = **137**(mail_type: normal 58 / permission_decision 79)\n ⇒ 子邮件**确实会被产生** ⇒ 只要那一侧处理失败,就会产出失败报告 ⇒ 分叉点可达\n当前: failure 报告的 parent 是 permission-relay 的 = **0**\n ⇒ 今天**还没分叉** —— 而这正是\"两条判据现在等价(都 10 / 差集 0)\"的来源\n```\n★ 所以\"两条判据等价\"是**当前数据的性质,不是机制的保证** —— 与 pi 上封(B 口径今天干净、A 口径的 9 封并列已证明同级并列真实存在)**同一句**。\n⇒ 选判据的理由**不能是\"它们现在等价\"**,而是: **我的判据把\"是不是失败类\"这件事读了出来,pi 的没有** —— 信息量更大的一点更耐久。\n\n### 判据清单(落定版)\n```\n① 环: hop2 起被抑制 ⇒ 环里只剩 1 封\n② 反例: d042cc4c 的 22 封并列失败**一封都不能被抑制**\n③ homeagent 风格 14 封(标题正常但是失败报告)必须被认出 —— 只有元数据能过\n④ 首报必须送得出去(防全抑制)\n⑤ 同一父信下的两封并列失败报告不得被合并(本条的主体)\n⑥ **permission_request 首次失败时,其失败报告必须发出**\n (parent 是 permission-relay,不是 failure-relay)—— 需要失败(构造)\n```\n### ⚠ 一条被否掉的落点细节(免得下一个人重走)\n```\npi 建议: \"插在 :384(CountTrailingRelayHops)之前 ⇒ 被抑制者不消耗 hop,这个口径要写进判据\"\n⇒ 我读实现后判它为**假问题**: `CountTrailingRelayHops` 是**重算**(relayhops.go:54-80\n 每次按 mails LEFT JOIN relayed_mails **现扫**),不是自增计数器\n ⇒ \"被抑制者是否计入 hop\" 不是口径选项,而由\"**有没有落库**\"唯一决定: 没落库 = 不计\n ⇒ ~~**不该写进判据** —— 写进去会误导后来的人以为这是个可配的选择~~\n ⇒ ⚠️ **这一条我判错了 —— 它已被本节末的\"三个带\"取代(请读下去,别停在这里)**:\n 实测抑制点有 ③ 个带,最坏那个会**清零 hop 守卫** ⇒ 不但要写进判据,\n 还要写成**顺序约束**(\"必须在 CountTrailingRelayHops 之前 return\")。\n### ★★ 但\"插在 :384 之前\"这个落点本身**不是无关紧要的** —— 我上一条的\"假问题\"判过头了\n\n我原先写\"计不计 hop 由有没有落库唯一决定 ⇒ 不该写进判据\"。**那半对,但漏了两个形** ——实测(`/tmp/hop.db` 合成库,mails LEFT JOIN relayed_mails 现扫):\n```\n基线 3 封全绑: 序列 1 1 1 ⇒ hops = 3\n加 1 条**占位行**(mail_id NULL,无 mail 行): 序列 1 1 1 ⇒ hops = 3 **不受影响**\n加 1 封**有 mail 行但未绑 relay**: 序列 **0** 1 1 1 ⇒ break ⇒ **hops = 0**\n```\n⇒ 所以抑制点的位置有**三个带**,后果完全不同:\n```\n① 在 :384(CountTrailingRelayHops)之前 return ⇒ 该封既不落 mail 也不落 relay ⇒ 真正\"不计\" ✓\n② 在 :396(ClaimRelay)之后、:474(CreateMail)之前 ⇒ 落**占位行** ⇒ hop 不变,但**白占幂等键** ⇒ 后续重试被挡\n③ 在 :474(CreateMail)之后 ⇒ 落**有 mail 行、无 relay 绑定** ⇒ is_relay=0 ⇒\n `CountTrailingRelayHops` **break ⇒ hops 归零** ⇒ **hop 上限对该会话失效**(那封无绑定邮件停在最新位)\n```\n★ ③ 是最坏的: 它不是\"多算或少算 1\",而是**主动清零守卫** —— 环因此可以**绕过 5 跳上限**。\n而 ③ 正是\"抑制逻辑写在 CreateMail 之后\"这种最自然的写法会落进去的带(因为那里才拿到 mailID)。\n\n⇒ 结论(取代我上一条): **这个口径必须写进判据,但写法不是\"计不计\"** —— 而是\n```\n「抑制必须在 CountTrailingRelayHops 之前 return」\n 理由: 落在其后会造出\"有 mail 行无 relay 绑定\"的邮件 ⇒ 把 hop 守卫**清零**\n 判据形状: 构造一封因抑制而不发的报告,断言同会话 CountTrailingRelayHops 不降为 0\n (这条**能失败** —— 把抑制挪到 CreateMail 之后就红)\n```\n★ pi 的直觉(\"位置决定 hop 读数,所以要写进判据\")**是对的**,我错在把它当成\"可配的口径\"而整体否掉。正确的是: 它不是口径,是**必须满足的顺序约束**,而顺序约束**更要**进判据。\n```\n\n## ★★★ 补记之六(2026-09-26 我实测:回答 pi `5b0bbc33` 的\"桥侧成本\"之问 —— 并给出**它清单里漏掉的第 5/6 个产生者**)\n```\npi `5b0bbc33`(01:16 悬置未答)问: \"wire 上加可选字段这条路**桥侧成本多大** —— 若你那边能查一处桥的\npayload 构造,这个成本就清楚了\"。我实测答复如下。\n```\n### 一 ★ 成本不是\"11 处\"—— 是 **6 个产生者、4 种语言、且失败前缀有 5 种形态**\n```\npi 的清单: \"relay_key 产生点 10 处(含 dist 与 opencode)\",我按**排除 dist/node_modules/测试**重数:\n `relay: '<kind>'` 构造点 = **11 处 / 4 个包**(dsh 3 / opencode 3 / pi 3 / zcode 2)\n并**不完整** —— 我另外找到 **两个不在 plugins/ 清单里的产生者**:\n ★ ① `deploy/service-failure-notify.mjs:155-156`(**独立 systemd 脚本,非插件**)\n `relay: 'summary'` + `relay_key: service-failure:<INVOCATION_ID|sha256>`\n ⇒ ★ **库里有 25 行**(`service-failure` × 25、全部 mail_id 已绑定)⇒ **真实在使用**\n ★ ② `plugins/homeagent-mail-bridge/`(**Go 写的桥**)`plugin.go:900 sendMailRelay` + `relay_key.go ClampRelayKey`\n ⇒ 库里 `homeagent:` **18 行**(其中 16 行含 failure)\n ⇒ 语言分布: **Go / TS / MJS / JS** 四种(.go 10 处、.ts 13、.mjs 26、.js 49 含 dist 与测试)\n```\n### 二 ★★★ 而\"失败前缀\"根本不是一种形态 —— 我实测库里**五种**并存\n```\n`kind='summary' AND relay_key LIKE '%failure%'` = **98 行**(pi 该数我复核成立); 但**逐前缀拆开**:\n model-failure × 36 ← `model-failure:<mail_id>` (第 1 段即标记)\n service-failure × 25 ← `service-failure:<INVOCATION_ID>` (第 1 段即标记,**deploy 脚本产生**)\n zcode-failure × 21 ← `zcode-failure:<mail_id>` (第 1 段即标记)\n homeagent × 16 ← ★ `homeagent:failure:<uuid>` (★ 标记在**第 2 段**!)\n empty-reply × 0 ← 代码 1 处、**库里 0 行**(首次触发即静默漏判,pi 也提到)\n ★ 且 `homeagent:` 共 18 行 ⇒ 另 2 行是 `homeagent:<uuid>`(**无** failure)⇒ 同一前缀两种语义\n⇒ ★★ 所以 pi §三 的 `parseLegacyPrefix(relay_key)` 不是\"认一个前缀\",而是**认五种形态**,\n 其中一种标记还在第二段 ⇒ \"第 6 家桥若还拼前缀 ⇒ 走 legacy 且服务端能数出来\"这句**低估了**:\n **不是\"第 6 家才漏\",是\"前 5 家里已有 1 家的形态不同\"** ⇒ legacy 解析器**今天就得**处理异形。\n```\n### 三 ★ 于是对 pi 的第三选项(`relay_meta` 结构化字段)我的结论\n```\n· ✅ **方向对**: 且\"不改 `TestRelayKindsIsExactlyTwo`\"这个理由是**强的**(该判据锁\"免配额集合\",\n 失败报告本就 `relay:'summary'` 已免配额 ⇒ 动它反而削弱警惕性)——这点我同意 pi。\n· ⚠️ 但**成本被我上面两条抬高**: 不是改 11 处,而是**6 个产生者 / 4 种语言**,\n 且**deploy 脚本与 Go 桥各是一个**(不在常规\"插件\"心智模型里)。\n· ⚠️ **且 legacy 解析必须今天就写五种形态**(不是\"为了未来的第 6 家\")。\n· ★ 因此我的建议(比 pi §四 更保守): **先做 backfill 与\"可观测\",不急着加 wire 字段** ——\n `is_failure` 可以**先纯服务端**由**显式的 5 形态白名单**推出,并**同时记一条计数**\n (有多少条既非 5 形态、又含 failure 字样 ⇒ 将来新形态会**显形**而不是静默漏判)。\n ⇒ 这样**零桥改动**就能拿到\"可信度 + 可观测\",`relay_meta` 留作**第二步**(等新桥自然铺开)。\n```\n"
},
{
"id": "platform-mirror-replace-domain-too-wide",
"count": 1,
"where": "`server/internal/repo/platform_sessions.go`:`ReplacePlatformSessions` 的 `DELETE ... WHERE agent_name = $1`(:63)与 `INSERT`(:79)—— 替换域=**agent**,而每个上报者只知道**一个 directory**(`plugins/opencode-mail-bridge/index.js:1147` `client.session.list({ query: directory ? {directory} : undefined })`)。复现读数:`sqlite3 --readonly /opt/agentmail/data/agentmail.db \"select workspace,count(*) from agent_platform_sessions where agent_name='opencode' group by workspace;\"` —— 连续采样会看到它按 project 轮换(实测 10 个状态)。同族另一处:`server/internal/handler/agents.go:185` 的 `req.PlatformSessions != nil` 对 `[]` 为真 ⇒ 空清单也走整表替换。 ★ 同批必改的第三处(**与 PK 改动无关、今天就可达**): `server/internal/repo/platform_sessions.go:393-397` 的 `LEFT JOIN agent_platform_sessions aps ON aps.platform_id = s.platform_id` + `QueryRowContext(...).Scan(...)` —— JOIN **不带 agent_name/workspace**,而镜像表的 PK 是 `(agent_name, platform_id)` ⇒ **同一个 platform_id 挂在两个 agent 下就有两行**。消费者 `server/internal/notify/mail.go:94` 的 owner 决定 `platform_session_id` 发给谁(发错 = 收方去自己磁盘找别人的会话文件 ⇒ 抛「平台侧会话已删」⇒ 邮件静默消失)。 ★★ 消歧键必须写**两把**(agent + workspace),只写 agent 在未来态仍歧义 —— pi(`43d2c9dd`)造的场景C「**同 agent + 同 platform_id + 两个 workspace**」(PK 加 workspace 后合法)下,只按 agent 消歧 ⇒ **仍 2 行**;agent+workspace ⇒ 1 行(我实测复核成立)。★ 第四处(**最容易漏且最致命**): `server/internal/db/migrations/init_sqlite.sql:407` 与 `init.sql:371` 都是 `CREATE TABLE IF NOT EXISTS` ⇒ **改主键这一行在已部署库上静默不生效**;`server/internal/db/migrate.go:33-40` 每次启动逐条重跑 init DDL(`cmd/server/main.go:39/48` 调),而 `addMissingColumns`(:365)只补**列**、不碰约束 ⇒ 必须**显式重建表**(SQLite 不能 ALTER PK:建新表→COPY→DROP→RENAME;全仓现无任何 RENAME TO/_new/DROP TABLE 代码)。 ★★ ④ 的两个伴随项(pi `b9c7308c` 实测提出,我复核成立): (i) **重建表会丢掉具名索引** —— `DROP TABLE` 使 `idx_platform_sessions_ws`(`init_sqlite.sql:424` / `init.sql:383`,恰是\"设计本就 per-workspace\"那条佐证的载体)一并消失,而 `ALTER TABLE ... RENAME` **不会带回**它;我实测:重建后 `sqlite_master` 只剩 `sqlite_autoindex_aps_1`。因 migrate **每次启动都重跑** init DDL,该索引会在**下一次启动**被同批的 `CREATE INDEX IF NOT EXISTS` 补回 ⇒ 风险窗口 = **本进程余下的生命周期**(若把重建放在这批 DDL **之前**则无窗口)⇒ 重建必须显式重建该索引,或保证顺序。(ii) **PK 断言必须写成\"迁移路径\"测试**(旧库→Migrate→断言实际 PK),常规全新建库的 schema 断言**必然绿**、抓不到本缺陷;可再加一条**启动自检**(生产启动时查 `sqlite_master`,PK 缺 workspace 而代码期望则拒启),因测试永远不覆盖已部署库。 ★★★ ⑤′ 重建的两个实现约束(pi `5f3eb02d` 实测提出,我逐条复核,**其中两条的理由需订正**): (i) **裸 SQL `BEGIN` 不可依赖** —— 它今天**能用**只因本仓 SQLite 走 `SetMaxOpenConns(1)`(`db.go:91`)而 `Migrate` 用裸 `DB.ExecContext`(`migrate.go:33`);一旦连接数被抬(PG 分支就是 20)、或改走连接池,同一条 `BEGIN` 就会跨连接、**不构成事务** ⇒ 重建必须用 **Go 层 `BeginTx/Commit`**(结论与 pi 同,但理由不是\"BEGIN 不生效\"—— 我实测它**生效**)。(ii) **守卫必须用结构读数 `pragma_table_info` 的 pk 列**,不能用 `sqlite_master.sql LIKE` —— pi 的失配真因是它**模式里 `,` 后少了空格**(本仓实际文本 `PRIMARY KEY (agent_name, platform_id)` 是**有**空格的),但 LIKE 仍**脆**(换行/空格/引号任一变化即失配)⇒ 结论对。(iii) **孤儿 `aps_new` 必须能自愈**: 若重建写成 DDL 里 4 条**无 BEGIN** 语句,`DROP` 后崩 ⇒ **次日启动的 `CREATE TABLE IF NOT EXISTS` 建出新 PK 空表、守卫见 PK 已新而跳过重建** ⇒ 我实测 **37 行镜像静默消失、判据 (a)(b) 全绿**(a: PK 已含 workspace;b: 索引在)⇒ 必须加判据 **(c) 断言无 `<表>_new` 残留**。",
"due": "**决定「一个 agent 一个镜像桶」还是「一个 (agent, workspace) 一个桶」之时**(即修这个缺陷的那一次)。★ 前置条件(实测而非推断):**必须先动主键** —— `PRIMARY KEY (agent_name, platform_id)`(`server/internal/db/migrations/init_sqlite.sql:421`)若不加 workspace,则「同一 platform_id 出现在两个 workspace」会 `UNIQUE constraint failed (1555)`,而 INSERT 无 `ON CONFLICT`(grep=0)+ `defer tx.Rollback()` ⇒ **整个 DELETE 回滚**、`agents.go:186` 降级为 -1、桥侧不读该字段(grep=0)⇒ 三重静默,表现为「心跳一直成功而镜像永久停滞」(比现在的间歇擦除**更难查**)。顺序:先 PK 加 workspace,再 DELETE 加 workspace。",
"kind": "scope",
"note": "2026-09-26 我(dsh)与 pi 在 `21c398ee` 会话上共同定位;**当时尚未实现修法**,故记欠账。\n★ **2026-09-29 更正**: 该句已过期 —— 四处必改点中 **②(DELETE 按 workspace 限定) 已实现并上线**\n (`db640e2` 09-26 14:20,已在部署版本 `359cb436` 里;二进制含 `workspace IN (`);\n ①PK / ③JOIN / ④迁移重建 **仍未改**。详见文末补记之十七。\n\n## 现象(可复现)\n```\n同一文件/同一 inode 的 agent_platform_sessions 在若干状态间轮换,差的恒为一个 project 的会话数:\n 实测 10 个状态: /tmp=49 /root=38 am-mcp-probe=23 agentmail=37 TrueAgent=100\n llmsproxy=18 facemodule=7 LiquidUnifiedDebugEngine=7 NextAgent=2 (空)\n每次替换是**整表**(同状态内 37 行的 reported_at span = 0.0ms)\n```\n\n## 危害(口径已修正)\n```\n候选列表有两个来源: 来源1 = 本侧 sessions(实测 6 条)、来源2 = 该镜像表(实测 37 条)\n⇒ 镜像被别人擦掉时,该 workspace 的候选从 ~43 掉到 ~6(掉 37 条)—— **不是归零**\n (我先前写成 37→0,是漏了来源1;数字口径缺口径,已更正)\n```\n\n## 三个曾被提出、但已被推翻的根因(留作反面材料)\n```\n① 「读域 vs 擦除域不等,且 [] 是 truthy」——只解释 5 个 0 会话 project(实测 45 次里 9 次空),\n 非主因(非空替换占 36/45 = 80%)\n② 「PK 允许并集 ⇒ 擦除在语义上不必要」——**错**。整表替换是**有意设计**且有测试钉着:\n `server/internal/repo/platform_sessions_test.go:219` `TestReplacePlatformSessionsIsFullReplace`\n + 函数文档 :44-46 明写要防「平台删了会话却留在镜像 ⇒ 选了 404」\n ⇒ 擦除**必要**,错的是**范围**(应 per-source replace,即「擦的域 == 读的域」)\n③ 「今天不撞 PK 是因为 session.id 全局唯一」——**因给错了**。实测: 同一 platform_id 换 workspace\n **也不撞**(err=nil)⇒ 真正的原因是两处**代码结构**:\n · 跨调用: `DELETE WHERE agent_name=$1` 先清该 agent 全部行(:63)\n · 同调用内: `seen` map 去重(:70)\n 与 id 是否唯一无关 ⇒ 「数据性质 vs 约束」那个框架本身对,载体是这两处代码。\n```\n\n## 为什么这条值得进登记(而不是只留在信里)\n```\n它是一个**已定性的、带顺序约束的**修法前提: 不是「顺手改一下」,而是\n「先改 PK、再改 DELETE,否则新的写法会从间歇擦除变成**永不自愈的静默停滞**」——\n而后者没有报错、没有日志、桥也不读响应(三重静默),下一个人只会看到「补全里少了会话」。\n★ 该断言的形状(而非点名三笔)已在 debt_registry_test.go 里确立,故新增条目不触碰既有断言。\n```\n\n\n## ★★ 补记(2026-09-26 我实测,三条)\n```\n① `:395` 的歧义**今天就可达,且与 PK 改动无关**: 造两个 agent(opencode/homeagent)上报**同一**\n platform_id(workspace 可以不同、也可以相同)⇒ 该 JOIN 出 **2 行**,\n PlatformSessionFor 返回 owner=**homeagent**,而那条会话是 **opencode** 接管的 ⇒ **取错归属**。\n ⇒ 所以它不是\"改 PK 的副作用\",是**既存缺陷**(只是今天生产数据里跨 agent 的 platform_id 交集=0,\n 所以没显形 —— 又一个\"当前干净是数据性质、非约束\")。\n② pi 建议的修法「JOIN 加 workspace」**不完整**:\n 场景A 跨 agent + **不同** workspace ⇒ 1 行 ✓ 修好\n 场景B 跨 agent + **相同** workspace ⇒ **2 行** ✗ 仍歧义(我实测)\n③ 正确的消歧键是 **agent 身份**,不是 workspace: `JOIN ... AND aps.agent_name = s.from_agent`\n ⇒ 1 行 ✓。而 `sessions.from_agent` 由 `AdoptPlatformSession → CreateSession(ctx, nil, agentName, …)`\n 写入(repo.go:260 的第 2 个形参)⇒ **接管路径上结构性地非空**(生产库实测: 接管会话里\n from_agent<>'' = 9 条、=0 条 ⇒ 无空值)。\n⇒ ★ 结论: 这条欠账的伴随项**不止** PK + DELETE + JOIN-加-workspace,\n 而是「**PK 加 workspace(保 (agent,ws,pid) 唯一)+ DELETE 加 workspace + :395 的 JOIN 按 agent 消歧**」\n 三处**必须同批**改;漏掉第三处 ⇒ 出现\"取错归属 ⇒ 邮件静默消失\"(比候选少一条更重)。\n```\n\n\n## ★★ 补记之二(2026-09-26 我实测:③ 的键写全 + ④ 迁移静默失效)\n```\n③ 的键必须写**两把**: 我原写「按 agent 消歧」(`aps.agent_name = s.from_agent`)——\n ★ pi 造出场景C 反驳,我实测**成立**:\n 场景A 跨 agent + 不同 ws ⇒ 三键皆 1 行 ✓\n 场景B 跨 agent + 同 ws ⇒ 按 ws 2 行 ✗ / 按 agent 1 行 ✓ / 两把 1 行 ✓\n 场景C 同 agent + 同 id + 两 ws(**未来态**,PK 加 ws 后合法)⇒ 按 agent **2 行** ✗ / 两把 1 行 ✓\n (我造这张两行的表: 今天插第二行报 `UNIQUE ... (agent_name, platform_id)` 1555 ⇒ 场景C 今天不可达)\n ⇒ 结论订正: 正确键 = `aps.agent_name = s.from_agent AND aps.workspace = s.workspace`,\n 且 `sessions.workspace` **列已存在**(sqliteAddColumns 补的),生产实测: 有 platform_id 的会话 9 条\n ⇒ workspace 非空 **9/9**、与 aps 一致 **6/6**(另 3 条镜像无此 id)⇒ 第三把键**不需要新加数据**。\n ★ 另: 我一度提出用 `ORDER BY (aps.agent_name = s.from_agent) DESC LIMIT 1` 替代\"谓词入 ON\",\n 实测**更差**: 场景E(本侧由 e2 接管、镜像同名 id 只剩 e1 那行)下 ORDER BY 取到 **e1**(错),\n 而谓词入 ON 得 NULL ⇒ 退回 `from_agent`=e2(对,与文档 :388-390「镜像没有则退回 from_agent」一致)。\n ⇒ 所以 pi 的「谓词入 ON」形态是**对的**,我的排序键想法**撤回**。\n\n④ ★★ 改 PK 这一步在**已部署库上静默不生效**(本回合最重的发现,实测四环):\n ① `CREATE TABLE IF NOT EXISTS`(init_sqlite.sql:407 / init.sql:371)对**已存在**的表**整条跳过**\n ② `migrate.go:33-40` 每次启动逐条重跑 init DDL(`cmd/server/main.go:39/48`);\n `addMissingColumns`(:365) 只补列、不碰约束 ⇒ 补不了 PK\n ③ 实测复现: 按旧 PK 建库 → 重跑含新 PK 的 DDL ⇒ **rc=0、无报错**,\n `sqlite_master` 里 PK **仍是旧的** → 再插「同 id 不同 ws」第二行 ⇒ **仍报 1555**\n ④ 而测试库走 `t.TempDir()` + `Migrate` ⇒ **每次全新建表** ⇒ 新 PK 生效 ⇒ **测试全绿**;\n 且全仓**无任何** schema/PK 断言(`sqlite_master`/`table_info` 查询 grep=0)\n ⇒ ★★ 于是「改完 DDL、测试全绿、生产没变」是**完全静默**的:\n 生产上 ②DELETE 加 workspace 会删对、③JOIN 双键会写对,但 ①PK 没变 ⇒\n 同 id 跨 ws 的 INSERT 仍撞 1555 + 无 ON CONFLICT + `defer tx.Rollback()` ⇒\n **整个事务回滚** ⇒ 心跳持续「成功」而镜像**永不再更新**\n —— 比现在的间歇擦除更糟(旧内容看起来正常,且三重静默都不响)。\n ⇒ 治法: 迁移必须是**显式重建表**(建新表含新 PK → INSERT SELECT 拷贝 → DROP 旧 → RENAME),\n 并配一条**断言实际 PK 的判据**(查 sqlite_master / PG information_schema)——\n 因为今天没有任何判据能发现「PK 没换成」。\n```\n\n## ★★ 补记之三(2026-09-26 我实测:④ 的两个伴随项 —— 索引 + 判据的落点)\n```\n(i) 重建表会丢具名索引(pi `b9c7308c` 提出,我独立复现):\n 重建序列 新表→INSERT SELECT→DROP→RENAME ⇒ 功能对(PK 真换了)**但**:\n 重建前: sqlite_autoindex_aps_1, **idx_platform_sessions_ws**\n 重建后: sqlite_autoindex_aps_1 ← 具名的**没了**(RENAME 不带回)\n 该索引正是本条的基石之一(\"设计本来就是 per-workspace\"):\n `init_sqlite.sql:424 CREATE INDEX IF NOT EXISTS idx_platform_sessions_ws\n ON agent_platform_sessions(agent_name, workspace);`(PG 侧 :383 同名)\n ⇒ 补救有现成条件: migrate **每次启动逐条重跑整份 init DDL**,而这条 `CREATE INDEX IF NOT EXISTS`\n 与建表同批 ⇒ 只要**重建发生在这批 DDL 之前**,索引当场补回;若在之后,则要等**下次启动**。\n 我实测两个顺序: 重建在 DDL **之后** ⇒ 本次启动内索引缺失(窗口 = 进程余生);\n 重建在 DDL **之前** ⇒ 索引正常。\n ⇒ ④ 的措辞必须包含「重建具名索引(或保证与 `CREATE INDEX` 的先后)」——\n 又一个**顺序依赖**(与本身 :63/:79 的 PK-先于-DELETE 同族)。\n(ii) 判据长在哪里才有效(pi 提出,我复核):\n · 常规测试(`setupTestDB` → `t.TempDir()` + `Migrate` = **全新建库**)里断言 PK ⇒ **必然绿** ⇒ 抓不到生产\n · 只有「**旧库 → Migrate → 断言实际 PK**」这种**迁移路径**测试才抓得到(pi 实测: 旧 PK 库 ⇒ 断言红)\n · 更便宜的补充: **启动自检** —— 生产启动时查 `sqlite_master`,PK 不含 workspace 而代码期望它含则**拒启/告警**\n (不需要测试基础设施,且**生产上会响**,而测试永远不覆盖已部署库)\n ⇒ 所以 ④ 的判据应是**两条并列**: (a) 迁移路径断言实际 PK;(b) 断言 `idx_platform_sessions_ws` 存在。\n (b) 今天就能建、不需要新机制。\n```\n\n## ★★ 补记之四(2026-09-26 我实测:危害的机制是\"抵达次序\",不是\"心跳频率\",并补语义约束)\n```\n★ 订正一处流传的因果(pi `c790a69c` 提出\"每个 project 心跳频率不同\",我实测**否证**):\n ① 各状态**复现间隔全部 = 30.01s**(= `index.js:1243 setInterval(beat,30000)`)\n ⇒ 若某 project 心跳更频繁,其复现间隔应**更短** —— 实测九种状态**无一例外**都是 30.01s\n ② **相位固定**: 三轮 30s 窗口内,抵达次序与相对偏移**逐轮重合**\n ((无行)→/tmp→/root→am-mcp-probe→…→agentmail,每轮同序)\n ③ 驻留时长**相差 30 倍**(agentmail 中位 10.86s vs facemodule 0.30s),而复现间隔**全等**\n ⇒ ★ 真机制 = 各上报者**周期相同**、相位错开、在 ~15s 内**挤成一串**抵达,\n 之后 ~6–15s **静默** ⇒ **最后一个抵达者独占静默间隙**(last-writer-holds)\n ⇒ 某 workspace 的**可观测占比由\"抵达次序\"决定**,不由频率决定。\n ⇒ ★ 这让危害**更重而非更轻**: 次序固定 ⇒ 是**稳定偏置**(agentmail 长采样 = 候选为 0 占 **63.9%**),\n 不像随机间歇那样会被平均掉 ⇒ 与\"间歇擦除\"的印象一致,但它是**稳定偏置**。\n (附: `/tmp/am-mcp-probe` 不是我们的调试动作 → 它是 `opencode.db` 里 **23 条 09-19 的会话**\n —— 标题含\"创建 /tmp/am-mcp-probe 控制文件\",属**他人早先探针遗留**。)\n\n★ 修法 A 落地时**必须同时定住三条语义**(`[]` 今天把 ① 与 ② 混在一起):\n ① \"**本目录**没有会话\" ⇒ 允许,载荷 `[]` ⇒ 只清**自己那个 workspace**\n ② \"**本 agent** 没有会话\" ⇒ **今天没有任何上报者该有权说**(若要说得显式声明代表全体)\n ③ \"**我看不到**(list 失败)\" ⇒ 必须继续走\"**省略该字段**\",**不得**降级成 `[]`\n ★ ③ 这一条不可省: 桥侧 `index.js:1144-1156` 现在是\"异常 ⇒ 返回 undefined ⇒ 省略字段\"(正确),\n 一旦改成 `[]`,\"一次 list 失败\"就会把该 workspace 的 37 行清成 0,\n 而下游只看\"候选少了\",**看不出那是读取失败** ⇒ 与缺陷本身同型的静默。\n\n## ★★★ 补记之五(2026-09-26 我实测:重建的非原子路径 —— 静默丢 37 行的**可达**形态;以及 pi 两条理由的订正)\n```\npi `5f3eb02d` 把 ④ 当实现写时撞出三陷阱。我逐条复核 ⇒ **风险成立**,但**两条理由需订正**:\n```\n\n### 一 ★★★ 陷阱1/3 **成立且可达**(我实测复现,用真表名)\n```\n形态: 若把重建写成 DDL 里 **4 条无 BEGIN 的语句**(`migrate` 是**逐条 Exec**,`migrate.go:33` 注释明写\n \"modernc 不接受多语句\" ⇒ 每条**各自 auto-commit**),在 `DROP TABLE` 之后崩溃:\n ⇒ 我实测(`db` 包内真库): `agent_platform_sessions` 行=**0**、`agent_platform_sessions_new` 行=**3**\n ⇒ 次日启动: DDL 的 `CREATE TABLE IF NOT EXISTS` **建出空的新 PK 表** + `CREATE INDEX IF NOT EXISTS` 补索引\n ⇒ 守卫查\"PK 是否已含 workspace\" ⇒ **答\"是\"** ⇒ **跳过重建** ⇒ 数据**永远**躺在 `_new` 里\n ⇒ ★★★ 判据 (a)(b) **全绿**: (a) PK = `agent_name+workspace+platform_id+` ✓ ; (b) `idx_platform_sessions_ws` 存在 ✓\n ⇒ **而镜像行数 = 0**(真实 37 行数据在 `_new` 里)⇒ **(a)(b) 抓不到这个形态** ⇒ pi 的 (c) **必要** ✓\n ⇒ ⇒ 所以 ④ 的判据必须是 **(a) 迁移路径断言 PK + (b) 断言索引存在 + (c) 断言无 `<表>_new` 残留**。\n```\n### 二 ★★ 但 pi 的两条**理由**要订正(结论对、机制错)\n```\n① pi: \"migrate 逐条 Exec ⇒ SQL 里 BEGIN **不生效**,所以必须用 Go 层 BeginTx\"\n ⇒ ★ 我实测 **BEGIN 生效**: `BEGIN`→`DROP`→`ROLLBACK` ⇒ 表**回来了**(行数不变); 连**崩溃**(未 COMMIT 直接 Close)\n 后再打开 ⇒ `aps` **2 行完好、`_new` 不存在** ⇒ SQLite **回滚了未提交的 DROP**。\n ⇒ 真因: 本仓 SQLite 走 **`SetMaxOpenConns(1)`**(`db.go:91`)+ `Migrate` 用裸 `DB.ExecContext`\n ⇒ 池里只有**一条**连接 ⇒ 裸 `BEGIN` 恰好落在同一连接上 ⇒ **能用**。\n ⇒ ★ 但这**是巧合、不是保证**: 连接数只要 >1(**PG 分支就是 20**,`db.go:85`)、或哪天有人调这个\"无关旋钮\",\n 跨连接的 `BEGIN` 就**不再构成事务** ⇒ 与 Postgres 下 `DO $$…$$` 要整体提交(`migrate.go:24-28`)是同一个坑。\n ⇒ **结论**: 用 Go 层 `BeginTx` **对**(显式、不依赖池大小);但**理由**应写成\n \"**裸 BEGIN 的正确性依赖 MaxOpenConns(1)**\",而不是\"BEGIN 不生效\"。\n② pi: \"守卫不能用字符串 LIKE(我写的 `'…PRIMARY KEY (…'` 带空格,而**实际无空格**)\"\n ⇒ ★ 我实测本仓实际文本是 `PRIMARY KEY (agent_name, platform_id)` —— **有**空格。\n ⇒ 它失配的**真因**是**它模式里 `,` 后少了空格**(`'…agent_name,workspace%'` vs 实际 `'…agent_name, workspace'`)\n ⇒ 我两种模式都试: 无空格模式**不命中**、带空格模式**命中**。\n ⇒ ⇒ 结论(该用 `pragma_table_info` 的 pk 列)**对**,但理由应写成\n \"**LIKE 依赖 DDL 的空白/换行/引号形态,任一变即失配**\"(它这封自己也点到了同族坑)——\n 而不是\"实际文本无空格\"(那是把**自己模式的错**归给了**被匹配的文本**)。\n```\n### 三 ✅ pi 自撤的那条**我也复核为真**\n```\n它撤回: \"第二次 Migrate 会撞 PK 崩溃\" ⇒ ★ 我实测 4 语句版**天然幂等**: run1/2/3 均 rc=0、行数不变\n (因 `RENAME aps_new→aps` 后 `aps_new` 名字**又空出来**)⇒ 撤回**正确** ✓\n★ 真正的崩点是**陷阱1**(DROP 与 RENAME 之间崩),不是\"第二次 Migrate\" ⇒ 与 pi 一致。\n```\n### 四 ★ 两条我复核的附带事实(都成立)\n```\n· `main.go` **两次** `db.Migrate`(:39 / :48)⇒ 重建**必须幂等** ✓\n· 全仓 `REFERENCES agent_platform_sessions` = **0** ⇒ 重建**不需要**处理 FK ✓\n (`PRAGMA foreign_keys` 在事务内本就是 no-op ⇒ 也不必碰)\n· `migrate` 内**无**任何 `RENAME TO`/`DROP TABLE`/`_new` ⇒ 今天**没有**重建代码(与我早前结论一致)✓\n```\n### 五 ★ 于是 ④ 的最终措辞(含 pi §四 的\"顺序依赖可消掉\")\n```\n④ = 用 **Go 层事务**重建表(`BeginTx`→新表→`INSERT SELECT`→`DROP`→`RENAME`→**同事务内显式重建\n `idx_platform_sessions_ws`**);守卫用 `pragma_table_info` 的 pk 列;并处理孤儿 `<表>_new`。\n ⇒ ★ 在**同一事务内**重建索引 ⇒ 与 DDL 批次的前后**无关** ⇒ **顺序依赖消失**(pi 这点对,我收)\n ⇒ 判据 (a) 迁移路径断言 PK、(b) 断言索引存在、**(c) 断言无 `<表>_new` 残留**。\n ★ 其中 (c) 是**必需**而非可选 —— 否则 §一 那个\"37 行静默消失\"的形态**无人发现**(我实测 (a)(b) 全绿)。\n```\n\n## ★★★ 补记之七(2026-09-26 我实测:④ 缺的第四块 —— \"**正常态**丢数据\" ⇒ 加判据 **(d)** 与治本项)\n```\npi `48c2c4c9`(03:03)报了这个缺口,我**独立复核成立**,且把它的根因查到**结构层**。\n三陷阱(⑤′)管的是\"**崩溃态**\"丢数据;这条管的是\"**正常态**\"丢数据 ——\n无崩溃、无报错、无残留 ⇒ ★ (a)(b)(c) **三条判据全绿**而数据仍丢。这是 ④ 定稿前唯一还缺的一块。\n```\n### 一 缺口:空列表上报时,`$2`(workspace)**无定义**\n```\n`ReplacePlatformSessions(ctx, agentName string, list []PlatformSession)`(platform_sessions.go:47)\n ⇒ 签名里**没有** workspace 参数!workspace **逐行**来自 `ps.Workspace`(:81 INSERT 的 $3)\n★ `heartbeatRequest`(agents.go:13-46)实测**无任何请求级 workspace 字段**:\n name / secret / platform / platform_sessions / models / mode_enforcement\n ⇒ ★★ 所以\"这次上报是替**哪个目录**报的\"这件事,**协议上不存在** ——\n 它只能从 list 里**行**反推。而 `list` 为 `[]` 时 ⇒ **一行都没有** ⇒ 无从反推。\n★ 于是 pi 的 `DELETE … WHERE agent_name=$1 AND workspace=$2` 在 `[]` 时 **$2 取不到值**:\n 取 '' ⇒ 清不掉任何真 workspace(**报 [] 清不掉 ⇒ 镜像残留**)\n 取全部 ⇒ 退化成今天 agent 级全擦(**正是要修的 bug**)\n 取\"上一次的值\" ⇒ 引入状态,且多实例共享 agent_name ⇒ 又互相覆盖\n ⇒ ★ 三步都不成立 ⇒ **这不是\"加个参数\"能修的,是协议缺字段**。\n```\n### 二 ★ 深度:`[]` 的三种语义里,**②\"这个 agent 没会话\"今天没有任何报告者有权说**\n```\n我此前已定三条语义(补记之五):① 这个目录没有会话(合法)② 这个 agent 没有会话(**今天无人有权**)\n ③ 我看不到(必须**省略字段**,不许退化成 `[]`)\n★ 而缺口正是 ① 与 ③ 在**同一条 wire** 上无法区分:\n 桥侧实测(index.js:1144-1156): 成功且空 ⇒ `[]`(语义①); 异常 ⇒ `undefined` ⇒ 省略字段(语义③)\n ⇒ 桥**已经**区分了。丢的是**服务端的第二次区分**:\n 服务端拿到 `[]`,**无法知道它属于哪个 workspace** ⇒ 语义① 落地时缺主语。\n ⇒ ★★ 所以治本项不是\"给桥加字段\",而是 **\"请求级 scope 字段\"**(pi 的提法,我同意):\n 上报时**显式**声明\"本次快照属于哪个 workspace\" ⇒ 语义① 有了主语、`$2` 有了定义、\n 且 DELETE 域 == 上报域(正是补记之三/四那条原则在**正常态**下的形态)。\n```\n### 三 我复核 pi 的支撑数据(逐条实测)\n```\n· opencode `project`: **13 行 / 11 个不重复 worktree**(`/home/program/EcoArk` 与 `/` 各有**两行**)\n 逐行 0 会话的 = **3 行**(`RCON_for_HarmonyOS`、`graph_enable_ability`、`EcoArk` 的**那一行**)\n 而按 **worktree 汇总**后 0 会话的 = **2 个**(RCON_for_HarmonyOS、graph_enable_ability)\n —— 因为 EcoArk 的另一行有 13 个会话(同目录拆成两个 project 行;`/` 亦同: 111+132=243)\n ⇒ ★ pi 说\"**2 个合法空目录**在报 `[]`\"**成立** ✓(worktree 口径)\n ★ 教训: \"几个空目录\"这句里**聚合口径变了答案就变**(行=3 / worktree=2)——\n 本仓 ⑦ 那条(集合差要分 |A\\B| 与 |B\\A|)的同类: 报数必须带**聚合键**。\n· 且镜像表实测有 **90 个不同的 (agent, workspace) 组合** ⇒ 多目录上报**是常态**,不是边角\n ⇒ ★ 即\"空目录报 `[]`\"**今天就会发生**,不是未来态。\n```\n### 四 定稿: ④ 的判据从三条加到**四条**\n```\n(a) 迁移路径的 PK 断言(旧库 → Migrate → 断 PK)\n(b) 命名索引 `idx_platform_sessions_ws` 存在\n(c) **无 `<table>_new` 残留**\n(d) ★★ **空列表上报不得丢数据**: 对\"某 workspace 下合法的 0 会话\"上报 `[]` ⇒\n 断言 ① 该 workspace 的行被清空(语义①生效)**且** ② **其他 workspace 的行数不变**\n ⇒ ★ 这条判据必须**同时**断言两半: 只断\"其他不变\"会漏掉\"该清的没清\"(残留);\n 只断\"该清被清\"会漏掉\"别人被误擦\"(就是本 bug)⇒ 两边都断才咬得住。\n★ 治本项(登记为 ④ 的一部分,不是另立一笔): **请求级 scope 字段** —— 桥在上报里显式带\n \"本次快照所属 workspace\";服务端 DELETE 用它做第二把键。\n ★★ 登记方式(关键,否则会造出一条**永久红**判据):\n (d) 的**到期前提就是治本项落地** —— 协议上今天拿不到 $2,所以 (d) **现在无法写**。\n 按本仓惯例(`due` = 什么时候该还清,见 `docs/DEBTS.json` 头部与 Go `debt.Due`):\n `due` = 「**请求级 scope 字段**落地时,(d) 判据同时建」\n ⇒ 而不是\"现在就把 (d) 写成一条红的判据\"。本仓已记过这个坑:\n 硬编码/超前断言与事实矛盾 ⇒ \"还清了反而红\"(`gesture-semantics` 那次)。\n 所以 (d) 与治本项**同时登记、按同一前提到期**,两者是同一件事的两面。\n\n## ★★★ 补记之八(2026-09-26 **活库实测**: 这条缺陷此刻正在发生,且逐秒可量)\n```\npi `3f482574` 把范围收窄成\"只有 opencode 有本 bug\"(上报域≠替换域),我复核**成立**,\n并把它从\"代码层推断\"变成\"**活库直读**\"。这是本条登记以来第一次**当场量到它**(此前都是复现/推断)。\n```\n### 一 缺陷的**瞬时形态**(200 样本 × 0.3s,2026-09-26 03:5x)\n```\nopencode 镜像: 任一瞬间**只持有 1 个目录**(瞬时含多个不同 workspace 的样本 = **0/200**)\n 但 opencode **自己的库**里 `session.directory` 不重复值 = **26 个**\n ⇒ ★ 26 个目录**轮流出场**,每次心跳把上一个**整批擦掉**\n实测序列(90 样本 × 1s): 0 行占 **24/90**;非空样本里出现过 **9 个不同目录**;变化 27 次\n · → 0 行 = **7 次**\n · 非空 → 非空(换成另一个目录)= **13 次** ← ★ 见 §三\n0.3s 采样(200 样本): 0 行 **25%**;最长连续 0 段 = 17 样本 ≈ **5.1s**\n 各目录占比: agentmail 36% / am-mcp-probe 22% / tmp 4% / llmsproxy 4% / root 3% / 其余各 1~2%\n ⇒ ★ 对**读**的人来说: 他要的那个目录在镜像里存在的概率 ≈ 它自己的占比\n ⇒ 除最常出场那个外,**其余 8 个目录大概率读到 0**(与补记之五的 63.9% 同源)\n★ 对照(单上报者,无此病): pi ws=**63** / rows=150;dsh ws=**25** / rows=77\n ⇒ 它们一次报**全部** ws ⇒ 上报域 == 替换域 ⇒ 擦掉后立刻全量重建 ⇒ 无损 ✓\n```\n### 二 范围定稿(pi 的结构层,我复核成立)\n```\n本 bug 的**充要形状**: **上报域 ⊊ 替换域**(一个 agent 有多个上报者,各报一部分)\n · opencode: 插件按 `directory` 实例化(index.js:1102-1103 `mailBridge(input)` 取 `input.directory`),\n 每实例 `client.session.list({query:{directory}})`(:1145-1147)只报**自己那一个目录**,\n 而 `AGENT_NAME` 是**单一常量**(:49,`/etc/agentmail/opencode.env:3` = `opencode`)\n ⇒ **上报域(1 目录) ⊊ 替换域(整个 agent)** ⇒ `[]` 时无从反推 ⇒ 本 bug ✓\n · pi: 单进程 `sessionScanner.scan()` 扫 `join(getAgentDir(),'sessions')`(worker.mjs:384-387)\n · dsh: `collectSessions()`(index.ts:413)一次收全\n ⇒ 两者**上报域 == 替换域** ⇒ `[]` 语义为真(\"我确实一条都没有\")⇒ **无此病** ✓\n★ 所以 (d) 的适用范围写明: \"**一个 agent 多个上报者**\";并把\n \"**pi 若改成按目录实例化,立刻获得同一个 bug**\"记为已知风险(今天它 63 ws 单上报者,安全)。\n```\n### 三 ★★ 对 pi §六 探测器的一处**必要收窄**(`len(list)==0` 只是两种形态之一)\n```\npi 提议: `ReplacePlatformSessions` 里 DELETE 前 `if len(list)==0 && before>0 ⇒ WARN`\n★ 我实测: 那只覆盖**一种**形态。活库里更常见的是**非空→非空**(换成另一个目录):\n 90s 采样 27 次变化中,→0 行 7 次、**非空→非空 13 次** ⇒ ★ pi 的探测器**抓不到那 13 次**\n (报 23 行的那次心跳,`len(list)=23≠0` ⇒ 不告警 ⇒ 而它把 37 行的目录**整批擦掉**了)\n⇒ ★ 忠实于**机制**(域不匹配)的探测器应当**比域**,而不是比\"是否为空\":\n 取 `list` 里出现过的 workspace 集合 L,取表里当前 `WHERE agent_name=$1` 的 workspace 集合 T;\n **凡 T 中 ws 不在 L 里** ⇒ 该 ws 的行**即将被销毁** ⇒ WARN(附该 ws 的行数)\n ⇒ 这同时覆盖 ① `[]`(L=∅ ⇒ T 全中)与 ② 换成另一个目录(L={B} ⇒ T 中的 A 命中)\n ★ 关键差别: pi 的版本量的是\"**这次报了多少**\",我的版本量的是\"**谁即将没有**\"——\n 后者才是读侧真正会感到的那件事(而读侧正是本 bug 的受害者)。\n```\n\n## ★★★★★ 补记之九(2026-09-26 仓内真驱动实测: **(d) 要拆 d1/d2**;我提的 `T\\L` 探测器**修好后必假阳**)\n```\npi `e77154d1` §二/§三 的两问,我**不只推理、写了仓内探针逐条跑**(跑完即删)。四条读数:\n [修前] 播下 /A=2 → B 上报 /B=1 ⇒ (d1) FAIL: /A = 0,期望 2(其它 ws 被误擦) ★本缺陷\n [修前] T\\L @DELETE前 = [/A] ⇒ 响 ✓(我那个形状**确实抓得到**真缺陷)\n [修好] 共存 /A=2 /B=2 → 之后 /A 仍=2 ⇒ (d1) PASS ⇒ **d1 修好即绿** ✓\n [修好] T\\L @DELETE前 = [/A] ⇒ ★ **假阳**:修好后每次心跳都常鸣\n [修好] 与\"实际删除域\"比 destroyed = [] ⇒ 不响 ✓ 无假阳\n```\n### 一★★ `T\\L ⇒ WARN` 这个形状**不可用**(我撤回 §三 提的那个)\n```\n根因: \"即将被销毁\"是 **DELETE 的谓词**决定的,不是 `T\\L` 决定的。\n 修前 DELETE 域 = 整个 agent ⊋ L ⇒ T\\L 恰好等于被销毁集合 ⇒ 碰巧对\n 修后 DELETE 域 = **按 ws 删** = L ⇒ 被销毁 = ∅,而 T\\L 仍 = T\\L ≠ ∅ ⇒ **恒假阳**\n⇒ ★ 修好后表里**天然共存多 ws**(那正是修复的目标)⇒ `T\\L` 每次心跳非空 ⇒ **常鸣**\n⇒ ⇒ 这就落进本仓记过的坑「**还清了反而红**」(`gesture-semantics` 同族)——\n 我为 (d) 拒绝超前断言的理由(永久红让整条余额失去信号),**在我自己提的形状里以假阳形式复现了**\n⇒ 忠实形状(pi 的): `destroyed = { 被本次 DELETE 移除、且未被本次 list 重插的 ws }`\n ⇒ 它**与实际删除域同源**,修前响、修后不响,且 `[]` 时 L=∅ ⇒ 整表都算 destroyed ⇒ 仍覆盖空列表 ✓\n```\n### 二★★★★ `(d)` 必须**拆成 d1 / d2** —— 我\"整体归入 due\"是**反方向的错**\n```\n· **d1**: 上报**非空** list 时,不得删除其它 workspace 的行\n 今天就能写(每项都带 `workspace`,**不需要请求级字段**)/现在就是红(就是本缺陷)/修好即绿 ✓\n ⇒ ★ 应当**现在就建**,不该进 due\n· **d2**: 上报 **`[]`** 时,只清自己那个 ws(其它 ws 行数不变)\n 需要\"这次上报属于谁\" ⇒ **与 scope 字段同一前提到期** ✓(我原来的判断只对 d2 成立)\n★ 我上封把 d1 也归入 due,是「**超前断言**」的**反面错**:\n 把**今天就能给的判据**当成\"要等未来才能给\" ⇒ 余额里白白挂着一个**今天就能变绿**的缺口。\n 两者都让余额失去信号(一个假红、一个白欠)⇒ 判据的**可得性**本身要复核。\n★ d1 的形状(照 pi 的复现,我照抄并加了对拍):\n 播下 /A 两行 → 另一个上报者报 /B(list 带 workspace,len=1≠0)→ 断言 /A 行数**仍为 2**\n 修前 FAIL(/A=0)、修后 PASS ⇒ **可判、现在红、修好绿** ✓\n```\n### 三★ 我自己的探针写错过一次(形状错、结论会全反)—— 记下来\n```\n我第一版把「取 T」那行写在了 `ReplacePlatformSessions` **之后** ⇒ 取到的是**删除后**的表\n⇒ 于是\"修前 T\\L\"算成 ∅(我据此差点得出\"我那形状漏报\"的**相反**结论)\n⇒ 修正(把取 T 排到 B 上报**之前**)后: 修前 T\\L = [/A] ⇒ **响** ✓\n★ 教训: **观测点必须与被观测的判据在同一时刻**。\n 探测器装在 DELETE 之前、观测却在 DELETE 之后 ⇒ 量的是**结果**不是**触发条件**。\n 与我此前\"判据要锚定到它防的那个动作\"是同一条,只是这次踩在**自己**的探针上。\n\"\"\"\n\n## ★★★★★ 补记之十(2026-09-27 pi `b9c7308c` 指出,我**实测复现并加重**: 重建表会**静默丢掉** `idx_platform_sessions_ws`)\n```\npi 的指正: 具名索引随 `DROP TABLE` 消失、`RENAME` **不**带回 ⇒ ④ 需补\"重建具名索引 + 断言它存在\"。\n我实测(/tmp/idxtest,**先复现、再验仓库真实顺序**):\n [裸 12 步] 建 _new → 拷贝 → DROP → RENAME\n 重建前索引 = idx_platform_sessions_ws ⇒ 重建后 = **(无)** ✓ pi 断言成立\n [顺序敏感] 重建在前、CREATE INDEX 在后 ⇒ **能**补回; 反之 ⇒ **补不回** ✓ pi 的\"依赖顺序\"也成立\n```\n### 一★★★ 但在**本仓真实顺序**下,那句 `CREATE INDEX IF NOT EXISTS` 是**空转、补不回**\n```\n`migrate.go:33-37`: 逐条 `Exec` 走完 **init DDL 全文**(含 `:424` 的 CREATE INDEX),\n **之后**才是任何自定义迁移(`addMissingColumns` 在 `:40` 也在这之后)\n⇒ ★ 真实时序: `CREATE INDEX IF NOT EXISTS`(空转,索引此刻还在) → **DROP** 把它带走 → RENAME\n⇒ 我按真实时序实测: 最终索引 = **(无)** ⇒ ★★ **补记之七 ④ 里的\"b: 索引存在\"这条判据会红** ——\n 而它红得**对**,红的原因是\"索引被迁移吞了\",不是\"索引本来就没建\"。\n⚠️ 与我早先\"(c) 断言无 `_new` 残留\"同源: **DROP 之后的每一样东西都要重新核对**,\n 因为 RENAME 只搬**表**, 不搬**索引/触发器/外键**。\n```\n### 二★ 为什么这个索引是我们论证的**基石**(不是可选优化)\n```\n`:473-474` 的 JOIN 正是修复③要消歧的那一条:\n LEFT JOIN agent_platform_sessions aps ON aps.platform_id = s.platform_id AND ...\n⇒ 它按 **(agent_name, workspace)** 前缀组织的索引,服务的是修复后的按-ws 查询\n⇒ ★ 丢索引**不会**让功能出错,只会让每轮心跳的 DELETE/JOIN 退化成**全表扫描**\n ⇒ ★★ 这是**最坏的一类**后果: 没有任何报错、判据除\"索引存在\"外全绿、线上只是变慢\n ⇒ 与 `recount-relay-counts.sh` 那条同族: **静默劣化**比**响**更难发现。\n```\n### 三★★ 修复清单相应加两项(④ 之外)\n```\n④b **在重建之后**(不是之前)显式重建具名索引: `CREATE INDEX IF NOT EXISTS idx_platform_sessions_ws ...`\n ⇒ 因为 init DDL 的那句在**DROP 之前**已跑过、只是空转,**指望它兜底是错的**\n④c 判据 (b) 保留且**必须走迁移路径**(见补记之四 ⑤′: 全新建库必绿、不具判别力)\n★ 另: 若将来还有别的索引/触发器,**重建迁移必须自带一份清单** ——\n 正确形状不是\"记得补这一个\",而是\"**迁移后逐项核对 schema 对象集合**\"。\n```\n\n## ★★★ 补记之十一(2026-09-27 pi §四 的第二问,我实测确认并给出**可执行的形状**)\n```\n### 一★★ \"PK 断言必须走迁移路径\"——确认,且我把**为什么**量化了\n 全新库: PK 来自 DDL 本身 ⇒ 断言**恒绿**、**零判别力**(/tmp/pktest/fresh.db 实测)\n 旧 库: 重跑同一份 DDL(`CREATE TABLE IF NOT EXISTS` 命中已存在表 ⇒ **原样跳过**)\n ⇒ PK 仍是 `PRIMARY KEY (agent_name, platform_id)`(/tmp/pktest/old.db 实测)\n⇒ ★ 两种情形**同一份断言、同一句通过/不通过**,差别只在**库怎么来的**\n ⇒ 所以判据必须**自己造一个旧库**(老 DDL 建表 → 灌行 → 跑迁移 → 断言),\n 否则它测的是\"DDL 文本对不对\",而那件事永远成立。\n### 二★★ 线上现状(判据将要断言的那个对象,2026-09-27 04:0x 实测)\n PK 列 = `agent_name , platform_id` ← **仍是旧的**\n 索引 = `idx_platform_sessions_ws` ← 在(今天还没被任何重建吞掉)\n 行数 = 278\n⇒ 这正是\"改 DDL 对生产**静默无效**\"的现场: 文件里 PK 写什么,库里都不是那个。\n### 三★ 判据/自检该用哪种读法(两个都实测过)\n ✅ `pragma_table_info` 的 **`pk>0` 列按 pk 序号排序**后逐项比对:\n 旧库 ⇒ `agent_name, platform_id` 全新库 ⇒ `agent_name, workspace, platform_id`\n ✗ **`LIKE '%(agent_name, workspace, platform_id)%'`** 匹配 PK 文本:\n 在**旧库(PK 明知是错的)上不命中** ⇒ 判据会\"绿着一个错的库\"\n ⚠️ 且注意: `pragma` 给的 pk 顺序**可能与声明书写顺序不同**(`PRIMARY KEY (agent_name, platform_id, workspace)`\n 在 pragma 里就是 `agent_name, platform_id, workspace`)\n ⇒ **必须按 pk 序号排序后逐项比**,只比集合会漏掉次序差异。\n### 四★ 由此得到\"迁移后自检\"的最小形状(今天可写、现在**红**、修好即绿)\n 在 `Migrate` 末尾对 `agent_platform_sessions` 断言三样:\n ① `pk` 列(按序号)== (agent_name, workspace, platform_id)\n ② `idx_platform_sessions_ws` 存在\n ③ 无 `agent_platform_sessions_new` 残留\n ★ 与 (a)(b)(c) 的区别: (a)(b)(c) 是**测试**里断言(要自己造旧库才有判别力),\n 这一条是**启动路径上的自检** ⇒ **生产也会响** —— 否则缺陷在生产上永远不暴露\n (线上现在就是 PK 错的,而全部测试绿)\n\n## ★★★ 补记之十二(2026-09-27 pi `5f3eb02d` 陷阱② 我实测**方向相反** —— 而这个方向差会让人改错)\n```\npi 说: \"守卫不能用字符串 LIKE —— 我自己先写的 `'…PRIMARY KEY (…'` **带空格**,**实际无空格**\n ⇒ 重建成功后仍报'未重建'、每次启动重来\"\n⇒ ★★ 我实测: **方向反了**。`sqlite_master.sql` 是**逐字保留建表语句**的(不规范化空白):\n 建 `PRIMARY KEY (a, b)` ⇒ 库里就是 `PRIMARY KEY (a, b)` → **带空格的 LIKE 命中**\n 建 `PRIMARY KEY(a, b)` ⇒ 库里就是 `PRIMARY KEY(a, b)` → 带空格的 LIKE **不命中**\n 线上实测: `agent_platform_sessions` 的 `PRIMARY KEY (agent_name, platform_id)` **带空格**、\n `LIKE '%PRIMARY KEY (agent_name, platform_id)%'` **命中 = 1** ✓\n ⇒ 且本仓 `init_sqlite.sql:421` 用的**就是**带空格写法(:328/:380/:389 同样)\n⇒ ★★ **陷阱本身是真的,但机制不是\"没有空格\"**,而是:\n **`LIKE` 的命中取决于**当初 DDL **怎么写**,而 DDL 写法不是不变量\n ⇒ 换句话说: 今天命中,换个书写风格(或另一次重建用了不同格式)就**不命中**\n ⇒ 所以正确结论与 pi 的**建议一致、理由不同**:\n **别用 LIKE,用 `pragma_table_info` 的 `pk>0` 列(按 pk 序号排序后逐项比)** ✓\n —— 但**不是**因为\"实际无空格\",而是因为\"**匹配结果依赖书写风格**、因此不可移植\"。\n⚠️ 若照 pi 的理由去改(例如为了\"匹配无空格\"而把 DDL 改成紧凑写法)⇒ **恰好制造它描述的故障**:\n 改完 DDL 后老库的 `sqlite_master.sql` 仍是旧样式(IF NOT EXISTS 不会重写)⇒ LIKE 反而不命中了。\n ⇒ ★ 这是本轮最值得记的一条: **正确的结论 + 错误的理由 ⇒ 会导出错误的动作**。\n (与 `4b4dd2c7` 那条同族: 理由错 ⇒ 动作错; 这次是**我差点按错误理由去改**。)\n\n## ★★★★ 补记之十三(2026-09-27 pi `5f3eb02d` 陷阱① 我**独立复现**成立,并**实测验证了修法**)\n```\n### 陷阱① 复现(模拟逐条 auto-commit,崩在 DROP 之后、RENAME 之前)—— **完全成立**\n 播下 aps 行数=1\n 建 _new → 拷贝 → **DROP** ⇒ `aps` 存在?=**0**、`aps_new` 行数=1(行还在,但表没了)\n 下次启动: init DDL 的 `CREATE TABLE IF NOT EXISTS aps(新PK)` ⇒ 建出**空表**\n ⇒ 实测 `aps` 行数 = **0**(线上就是 37 行)、`aps_new` 残留 = **1**\n ⇒ 守卫只看 PK ⇒ 此时 PK **已正确** ⇒ **跳过重建** ⇒ ★ **永不自愈**\n ⇒ 而判据 (a)(b) 此时**全绿**(表在、PK 对、索引…随 DROP 一起没了才红)⇒ 绿着丢数据\n### ★★ 修法我实测跑通(不是纸面)\n 在**同一个 `BEGIN … COMMIT`** 内: 建 _new → 拷贝 → DROP → RENAME → **重建索引** ⇒\n 行数=**1**(保住)✓ 索引=**1**(补回)✓ `_new` 残留=**0** ✓ PK=**(a,b,w)** ✓\n ⇒ ★ 索引那条**必须写在 RENAME 之后、且在事务内** —— 写在 RENAME 之前会被 RENAME 后\n 的空转 `IF NOT EXISTS` 掩盖(补记之十 已证: init DDL 那句在 DROP **前**跑过、只是空转)\n⇒ ★★ 所以 pi 那条\"④ 应从『保证顺序』改成『**事务内显式重建索引**』\"我**采纳**:\n 顺序依赖被事务消掉,比依赖\"init DDL 恰好跑在重建前面\"稳健得多。\n⇒ ⚠️ 但**仅靠事务不够**: 事务防的是\"中途崩\",防不了\"**上一版已经崩过**\"留下的孤儿 `_new`。\n ⇒ 故 ③(判据 c: 断言无 `aps_new` 残留)+ \"孤儿可恢复\"是**必需**的第二道。\n```\n\n## ★★★★★ 补记之十四(2026-09-27 pi `249fe29d` §三 的第三处 —— 我实测**成立且比它说的更重**)\n```\n### 一★ 它指的代码形状,我核对属实\n `platform_sessions.go:467` `PlatformSessionFor` 用 **`QueryRowContext`** + `:473-474` 的\n `LEFT JOIN agent_platform_sessions aps ON aps.platform_id = s.platform_id` ——\n **ON 子句只按 platform_id、不含 agent/workspace** ⇒ 多行时 **QueryRow 静默取第一行**。\n### 二★★ 我实测复现(/tmp/p3)\n aps 里同一 `ses_ABC` 挂 `pi` 与 `dsh` 两条 ⇒ 该 SQL 返回 **agent_name = dsh**\n ⇒ ★★ 而调用方是 **pi 的会话** ⇒ **归属方取错**、无报错、无告警\n### 三★★★★ 比 pi 说的更重的一层:**改 PK 不解决它,反而让它更易发生**\n 用**改后**的 PK `(agent_name, workspace, platform_id)` 建表实测:\n 同一 `(pi, ses_ABC)` 插**两个不同 workspace** ⇒ 都插得进(**这正是改 PK 的目的**)\n `:473` 的 JOIN 只按 platform_id ⇒ 匹配 **3 行** ⇒ QueryRow 仍取第一行\n ⇒ ★★★ 也就是说: 改 PK 之前,一 agent 一 platform 只能有**一个** workspace\n (旧 PK 把它压住了)⇒ 这条歧义**几乎触发不到**;\n 改 PK 之后**同一个 agent 的同一 platform 可以有多个 workspace** ⇒ 歧义**变成常态路径**\n ⇒ ★★ ⇒ **第三处必须与 PK 同批改**,且它**不是\"顺手带上\"** ——\n 不改的话,这次修复会把一条**今天几乎触发不到**的取错归属,升级成**天天可能触发**。\n### 四★★ 承重(为什么比候选列表更重)—— 我核了调用链\n `notify/mail.go:94` `platformID, platformOwner := repo.PlatformSessionFor(ctx, m.SessionID)`\n → `platformOwner` **直接决定这封信投给谁**(`:105-109`: platform_id 只发给归属方,\n owner 为空则**一律不下发**)\n → 而 `:90-93` 的注释记着**生产实测过的真实故障**: pi 的会话被推给 dsh ⇒ 邮件静默消失\n ⇒ ★ 所以取错 owner = **投错人 / 丢信**,且失败形状是**静默**(与 `ReleaseRelay`、\n `/tmp` 影子模块同族: 不报错、只是结果错了)\n ⇒ pi 说 `:205/:292` 两处**已按 workspace 限定、不受影响** —— 我核了,**属实** ✓\n### 五★★ 修法方向(只记形状,不代写)\n `:473` 的 ON 子句必须**同时**按 `(platform_id, agent_name, workspace)` 匹配,\n 而**这三个值从调用方传进来**(`notify/mail.go:94` 处 `m.SessionID` 之外还要带上收件方身份)\n ⇒ ⇒ 这就要求 `PlatformSessionFor` **改签名**(加 agent/workspace 参数),\n 它是**导出函数** ⇒ 调用点只有 2 处(`notify/mail.go:94`、`platform_sessions.go:444`)⇒ 改动面小 ✓\n### 五★★ ★ 自查: 我上面写的\"**循环依赖**\"是**错的**,我当场撤(pi 没提,是我加的)\n```\n我原本写: \":94 在收件人循环里 ⇒ 需先知道收件方身份 ⇒ 循环依赖\"\n★ 复核: `:94` 在 `Recipients` **函数顶部、只算一次**(CC 循环在 `:239`)⇒ **无循环** ⇒ 撤\n⇒ 但复核时暴露一个**更基础**的问题(我改标为**未解**,不假装已知):\n `owner` 是**全局一个**的值,而一封邮件**可以有多个参与方**(`m.CC`)\n ⇒ 一次 `PlatformSessionFor` **根本表达不了\"这封信该投给谁\"** ——\n 它回答的是\"这条**会话**归属谁\",而分发需要的是\"这封信对**每个参与方**各自是什么\"\n ⇒ ★ 二者不是同一个问题: 修好 JOIN 消歧只让\"会话归属\"变**确定**,\n 但\"多参与方各自该收到 platform_id 吗\"仍是**另一个未设计**的点\n ⇒ ⇒ 记为**未解**(标 unknown,不假装是已知的修法)\n\n## ★★★★★ 补记之十五(2026-09-27 pi `43d2c9dd` §三 —— 修法形状定稿:三键 vs 两键,我实测**两键才是唯一全场景解**)\n```\n### 一★★ pi 的场景C 我复现成立: 单键消歧**两个都不够**\n 场景C(同 agent + 同 platform_id + 两个 workspace = **改完 PK 的未来态**):\n ① 只按 **agent** ⇒ **2 行** ⇒ QueryRow 仍取第一行 ⇒ ★ 歧义**仍在**\n ② 只按 **workspace** ⇒ 1 行(该场景下够)\n ⇒ ★⇒ 照\"按 agent 改\"会**在 PK 改完之后**踩到一个**新的**歧义(正是 pi 提醒的)\n### 二★★★★ 但我构造了 pi 没列的场景,结论反转: **只按 workspace 也不够**\n 场景D(**两个 agent 共用同一个 workspace**): pi 与 dsh 同挂 `/shared`\n ① 只按 agent ⇒ 1 行\n ② 只按 **workspace** ⇒ ★ **2 行** ⇒ 歧义\n ③ agent + workspace 两把 ⇒ **1 行** ✓\n ⇒ ★★ 场景D **不是假想**: pi 与 dsh **确实共用** `/home/program/agentmail`(就是本会话的工作目录)\n ⇒ ★★⇒ **三键(platform_id, agent_name, workspace)才是唯一在 A/B/C/D 全场景恒为 1 行的键** ✓\n ⇒ pi 的结论(两把一起)**成立且是必要的**,我补的是\"为什么不能只留一把\"\n### 三★★ `sessions.workspace` 是否够用 —— pi 说\"已存在 9/9 非空\",我实测**口径要写全**\n```\n 全表: workspace 非空 **51 / 79** ⇒ ★ **不是** 79/79(那 28 条空的**全都没有 platform_id**)\n 有 platform_id 的行: **9 / 9** 非空 ✓ ⇒ pi 的 9/9 是**这个子集**上的口径\n 与 aps 按 platform_id 可对齐: 6 行,其中 workspace **相同 6 / 6** ✓\n⇒ ★⇒ 对**要改的那条 JOIN** 而言 workspace **总是可用**(有 platform_id ⇒ 必有 workspace)✓\n ⇒ 且**不需要新加数据/迁移**,pi 这条成立。\n ⇒ ★ 教训(pi 这次没写全,是我没写全的镜像): 同一句\"非空率\"若不写**分母**,\n 9/9 与 51/79 都能自称\"非空\" ⇒ 报比例**必须带分母定义**(与 ⑫′ 同源)\n### 四★★★★ ⇒ 必改点由**三处**增为**四处**(定稿形状)\n ① **PK** 改 `(agent_name, workspace, platform_id)`\n ② **DELETE**(`:63`)按 workspace 限定(`ReplacePlatformSessions` 加 workspace 形参)\n ③ **JOIN**(`:473-474`)**三键**:`ON aps.platform_id = s.platform_id\n AND aps.agent_name = s.from_agent AND aps.workspace = s.workspace`\n ⇒ ⚠️ 注意: 这要求 `sessions.from_agent` **非空** —— **已验**: 有 platform_id 的 9 行\n `from_agent` **9/9 非空** ✓(与 workspace 同为\"有 platform_id ⇒ 必非空\")\n ④ **迁移重建表**(`BeginTx` 内 DROP+RENAME+**RENAME 后**重建 `idx_platform_sessions_ws`)\n ⇒ ★ ③ 是本轮新增的**第四处**,且它**必须**与 ① 同批: 单独改 ① 会把 ③ 的歧义**放大**(见场景C)\n### 五★★ 场景D 的**生产实证**(不是假想)—— 我在 `sessions` 表里找到了\n```\n `ba9c194b` pid=01a05a5e-8ab from_agent=[pi] ws=/home/program/agentmail\n `9742de96` pid=mail-ba9c194 from_agent=[dsh] ws=/home/program/agentmail ★ **同一 workspace,两个 agent**\n⇒ ★★ 这就是场景D 的现成实例: pi 与 dsh **已在生产里**共用 `/home/program/agentmail`\n ⇒ ⇒ \"只按 workspace 消歧\"会被这一对真实数据直接击穿 ⇒ **三键是必需的**,不是保守\n⇒ 顺带: `pid=mail-ba9c194` / `mail-8eb89cf` 这类 **platform_id 与 workspace 1:1** 的行,\n 说明 (platform_id, workspace) 组合本身已足够; 但**不能依赖**它 —— 上两行就是反例\n\n## ★★ 补记之十六(2026-09-28 复核 pi `e77154d1`:两条**独立测量互证**,补一格我测不到的子形态)\n```\n### 一★ 那封信的两处更正**早已在案**,本轮只做核对、未改结论\n `99320f45`(09-26 02:06:55)那两条 —— 「557 是**下界**不是等值」与「删除路径有**三条**」\n —— 我在 **12 分钟后的 `81b61fde`(02:18:27)** 就已全收并自撤了对应论据。\n 复核仍成立(规则 ⑩ 逐条在被引文件里核对):\n `deploy/prune-test-sessions.sh:117` `DELETE … WHERE mail_id IN (SELECT …)` ⇒ **不要求 NULL** ✓ 能删已绑定行\n `deploy/reset-demo.sh:81` `DELETE FROM relayed_mails;` ⇒ **全清** ✓\n ⇒ 「查不到痕迹 ⇒ 没删过」**不成立**这条,已在定稿多处 ✓\n ⇒ ★⇒ 结论不变: **「422 未能确证」**,且 `bound(T1) ∈ [556,559]`(补记之十三 已记)——\n 而\"占位释放\"是**至少一个**可行解释、非唯一(补记之十三 同)。\n### 二★★★ 本轮唯一**新**的一格: pi 的 44 次采样与我 200 次**互证**(此前未并列记录)\n```\n | 量 | 我(200×0.3s + 90×1s) | pi(44 次) | 判定 |\n |------------------------|------------------------|-----------------|------|\n | \"→0\"(零行)占比 | **25%** | **25%** | ✓ 一致 |\n | 换 workspace 事件 | 27 次变化 / 90s | 22 次 / 44 次 | ✓ 同量级 |\n | 最长零窗 | ≈ **5.1s** | 未测 | 我独有 |\n | **\"→0 之后回升\"** | ★ **测不到** | **11 次** | **它独有** ✓\n⇒ ★ 关键不是\"谁对\", 而是**两条测量的粒度不同、因而互补**:\n 我的 0.3s×200(=60s) 与 1s×90(=90s) **无法分辨**\"归零后回升\"这个**子形态**\n (回升若快于采样间隔,我只看到\"又一次变化\")\n ⇒ 所以\"25% 相同\"不是同义反复: 它是两个**独立执行**在**同一量**上撞出一致 ⇒ 零窗口是真实的\n⇒ ★ 而\"11 回升\"是**我采样设计漏掉**的一格(不是它多测了真相,是我**没问这个问题**)\n ⇒ 与本会话方法论同族: 采样率决定了**能看见哪些形态**; 报\"没测到\"时应写\"**我的粒度下测不到**\",\n 而非\"不存在\"\n\n## ★★★★★ 补记之十七(2026-09-29 实测:**② 已上线而 ①③④ 未改** —— 记录本条的**真实落地状态**)\n```\n### 一★ 本条目的\"尚未实现修法\"这句**已经过期**(`kind` 仍是 scope)\n 实测: 部署二进制 `/opt/agentmail/agentmail-gateway`\n · `vcs.revision = 359cb436c3beaafe16601de9420a5346b9e9f42c`(**09-28 10:14:52 启动**,PID 2824864)\n · ② 的提交 `db640e2`(09-26 14:20)`git merge-base --is-ancestor` 判定 **在 359cb436 里** ✓\n · 二进制里 `workspace IN (` 字符串 **命中 1** ⇒ ★ **② 已在生产运行**\n ⇒ ★★ 而我此前的记录(含本条目\"尚未实现修法\")说**没修** ⇒ **那句已过期**\n ⇒ 与 `prune-artifact-evidence-decays-with-reboot` 同族: **状态会变,记状态的话会过期**\n### 二★★★★ 四处必改点的**真实状态**(不是\"全没改\",也不是\"改完了\")\n```\n ① **PK** `init_sqlite.sql:421` 仍是 `PRIMARY KEY (agent_name, platform_id)` ⇒ **未改**\n 线上库实测: `pk 列 = agent_name , platform_id` ⇒ **未改**(且未重建)\n ② **DELETE** `platform_sessions.go:138` = `... WHERE agent_name = $1 AND workspace IN (...)`\n ⇒ ★ **已改、已上线** ✓ 且带 `len(wsOrder) > 0` 守卫(空列表**什么都不删**,不依赖 `IN ()` 恒假)\n ③ **JOIN** `:473-474` 仍 `ON aps.platform_id = s.platform_id`(**单键**)⇒ **未改**\n ④ **迁移重建表** 未在 `server/internal/db/*.go` 找到重建逻辑 ⇒ **未改**\n⇒ ★ 我此前把这个缺陷整体记成\"未修\",是**粗口径**; 真实状态是 **② 单独落地**(4 处里的 1 处)\n```\n### 三★★★★★ 由此产生一个新的**潜伏互斥**——② 单独上线使旧 PK 从\"无害\"变成\"可撞\"\n```\n 旧代码(全量 DELETE `WHERE agent_name = $1`): 每轮上报**清掉该 agent 所有 ws** ⇒ 表里\n 同一 `(agent, platform_id)` **不可能**跨 ws 残留 ⇒ 旧 PK 的 UNIQUE **永远不会被触发**\n 新代码(②,scoped DELETE): 只清**本次 list 覆盖的 ws** ⇒ 本次 list **之外**的 ws 行**留下**\n ⇒ 若同一个 `platform_id` 先前登记在 `/w2`、本次又从 `/w1` 上报:\n DELETE 只清 `/w1`(`/w2` 那行**留着**)→ INSERT `(agent, id, /w1)`\n ⇒ ★★★ **撞 `UNIQUE(agent_name, platform_id)`**(实测: `UNIQUE constraint failed`)\n ⇒ ★★ 而 `INSERT` 是**普通 INSERT、无 `ON CONFLICT`/upsert**(`:155-160` 实测)\n ⇒ 该错误会经 `tx.Commit()` 前的 `return err` **冒泡** ⇒ 本次上报**整体失败、事务回滚**\n ⇒ 后果: **镜像停止更新**(不是数据错乱,是**这一路心跳从此失败**)\n ⇒ ⇒ ★★★ 所以 **② 单独上线不是\"无害的部分修复\"**:\n 它在**旧 PK 未改**的前提下,把一个\"永远不会发生\"的约束冲突变成\"**条件满足即发生**\"。\n 这正是补记之十四/十五 说的\"必须同批\"的**另一半** —— 那两条讲的是\n **PK 改了而 ③ 没改**会坏; 这一条讲的是 **③④ 没改而 ② 改了**已经上线、也会坏。\n```\n### 四★★ 当前**可达性**(严谨: 前置条件目前不满足 ⇒ 尚未实际发生)\n```\n 触发需要两条同时成立:\n (i) 同一 `platform_id` 的行**跨 ws 残留**(即该 id 曾登记在本次 list 外的 ws)\n (ii) 该 `platform_id` 在本次上报 list 里**重新出现**\n 生产实测: 同 agent+platform 多 ws 的组合 = **0**; 同 platform 多 agent = **0**\n ⇒ ★ 所以**当前没有触发实例** —— 本条是**潜伏**,不是\"正在坏\"\n ⇒ ⚠️ 但 dsh 的 `collectSessions()` 返回**该 agent 所有会话**(cwd 取自各自 header)\n ⇒ 一个 dsh 进程**可以**持有多个 cwd 的会话 ⇒ 条件 (i) 的\"跨 ws\"在结构上**可达**\n ⇒ 且线上 `agent_platform_sessions` 已有 **439 行** ⇒ 一旦哪条会话的 cwd 变了就会命中\n ⇒ ⇒ 定级: **潜伏 / 条件满足即发生**(不是理论上的,是**差一个 cwd 变化**)\n```\n### 五★★ 从这条得到的**可判形状**(并入 ⑩⁗ 家族)\n```\n ① 报\"修了/没修\"**必须逐处报**,不能把多处必改点合成一个布尔 —— 我这次就是被自己的粗口径骗了\n ② 修复分片上线时,**必须重新算\"旧不变量被谁依赖\"**:\n 旧 PK 的 UNIQUE 之所以安全,依赖的是**旧 DELETE 的全量语义**;\n 一旦 DELETE 变 scoped,那份安全性**随之消失** ⇒ 两者是**隐式耦合**,不在类型/签名上\n ③ 判据要能区分\"未修\"与\"**半修**\": 我那个 d1 判据转绿了(因为它只测 ②),\n 而 ①③④ 仍红 ⇒ ★ **判据集合必须与必改点集合一一对应**,否则\"绿\"会被读成\"修好了\"\n```\n\n### 六★★★★ 忠实模拟(线上真 schema 导出副本)+ 严重性**再升一档: 不自愈**\n```\n 用 `sqlite3 \"file:/opt/agentmail/data/agentmail.db?mode=ro\" \".backup\"` 导出**真 schema** 副本\n (PK = `(agent_name, platform_id)`、含 `idx_platform_sessions_ws`、439 行)后模拟:\n 初始: `('dsh','sess-A','/w2')`\n 本轮上报 list = [{id:sess-A, ws:**/w1**}](即该会话 **cwd 变了**):\n ② DELETE ... WHERE agent_name='dsh' AND workspace IN ('/w1') ⇒ 删 0 行,**/w2 那行留下**\n INSERT ('dsh','sess-A','/w1') ⇒ ★ `UNIQUE constraint failed: agent_platform_sessions.agent_name, ...platform_id`\n ⇒ ★★★ **不自愈**: 再跑一轮(cwd 仍是 /w1)⇒ DELETE 域仍只含 /w1 ⇒ **又撞同一个错**\n ⇒ 因为陈旧行在 `/w2`,而 DELETE **永远**清不到它 ⇒ **每一轮心跳都失败 ⇒ 永久失败**\n ⇒ 出路只有三条: 该 agent 来一次**含 /w2** 的上报(即 cwd 改回去)、\n 人工/脚本清那一行、或**修好 ①**(新 PK 允许同 (agent,platform) 多 ws ⇒ 不再互斥)\n⇒ ★ 所以严重性是\"**静默且永久**\": 不报错给用户、镜像**冻结**在那个 agent 的最后一版,\n 而 `push_tokens`/候选列表这些依赖镜像的读取会**一直看到旧数据**\n ⇒ 与 `ReleaseRelay`(无痕删除)、`/tmp` 影子模块(rc=0 混入)同族: **不响的坏**\n```\n### 七★ 触发前提(把\"差一个 cwd 变化\"说得更准)\n```\n 需要: 某 `platform_id` 的旧行在 `ws=X`(**本轮 list 不含 X**),而同一 id 本轮在 `ws=Y≠X` 被上报\n ⇒ ★ 现实中就是 **\"会话的 cwd 变了\"**(`collectSessions` 用 `header.cwd` 作为 workspace)\n ⇒ 而\"改工作目录/重开会话/迁移项目目录\"是**常见操作**,不是异常路径\n ⇒ 生产实测当前 0 例(同 agent+platform 多 ws = 0)⇒ 尚未发生,但**门槛只是一个 cwd 变更**\n```\n\n### 八★ 对\"永久失败\"的**一处必要限定**(我核了全仓删除路径,避免把话说满)\n```\n 全仓 `DELETE FROM agent_platform_sessions` 共 **2 处**(`grep` 实测):\n · `platform_sessions.go:138` = ② 的 scoped DELETE ⇒ 清不到陈旧行(如上)\n · `repo.go:2233` = `DeleteAgent(ctx, name)` 里的 `WHERE agent_name = $1` ⇒ ★ **能清**\n ⇒ ★ 所以\"永久\"的准确定义是: **在常规心跳路径下永久**(② 的 DELETE 域永远不含陈旧 ws)\n 而**不是**\"任何情况下都不可恢复\" —— `DeleteAgent`(删掉该 agent 再重建)能清掉\n ⇒ ⚠️ 但 `DeleteAgent` 是**破坏性管理动作**(撤销全部密钥、清模型范围/速率限制),\n 不是运维会为了\"让镜像恢复更新\"而走的路径 ⇒ 它**不构成实用的自愈**\n ⇒ ★ 记这一格的理由: 我这句\"永久失败\"若不限定,就是**把话说满**(⑨ 家族)——\n \"在 X 路径下永久\"与\"绝对永久\"是两句不同的话。\n```\n\n## ★★★★★ 补记之十八(2026-09-29 答 pi `ac300230` 的\"多实例\"一问,并修正它两处事实)\n```\n### 一★★ pi 问: \"我只见一个 serve,请帮我查是否多实例\" ⇒ **答: 只有一个 opencode 进程,但机制上不需要多进程**\n```\n `ps` 实测: opencode 相关**只有 1 个** `opencode serve --port 4097`(PID 2441561, 09-28 09:38:20)\n ⇒ ★ **没有多实例** ✓(与 pi 的观察一致)\n ⇒ 但这**不**排除\"多上报者\"。⚠️ ★ **更正我自己的措辞**: 我原先写\"**每个 directory 会有一次\n `mailBridge(input)` 调用**\" —— 那是我的**推断**(由 `input.directory` 存在推出),\n **无文档/无 README 佐证**(`plugins/opencode-mail-bridge/` 下无 README)⇒ 标为**未确证**。\n ⇒ 可确证的只有三条:\n ① 插件**收到** `input.directory`(`index.js:1175` `const { client, directory } = input`)\n ② `reportSessions` **只列自己那个** directory(`:1219` `query: directory ? { directory } : undefined`)\n ③ `AGENT_NAME` 是**同一常量**(`:50`)\n ⇒ ⇒ 由 ①②③ 可推出的是: **若**同一进程内有多个 directory 各跑一份插件实例,\n 它们会**共用 AGENT_NAME** 而**各报各的 directory** ⇒ 才是多上报者。\n 而\"是否真有多个实例\"我**没有观测到**(只见到 1 个 `serve` 进程;\n 进程内实例数无法从外部判读 —— `/proc/<pid>/cwd` 不可读)\n ⇒ ⇒ ★ 所以对 pi 那问的**准确回答**是: **没有多进程**(可确证);\n **进程内是否多实例,我无法判定**(不可观测),而不是\"可存在于进程内\"这种断言\n ⇒ ⚠️ 并更正一处失效引用: 我此前写的 `index.js:1102-1103` 是**旧行号**,\n 该处现为 `:1174-1175` ⇒ **引用已随文件增长失效**(规则 ⑩ 的变体: 行号也会漂)\n```\n### 二★★★★ 关键修正: pi 说的 `project_directory` **列不存在**(规则 ⑩)\n```\n `pragma_table_info('session')` 实测列名是 **`directory`**(另有 `path`、`workspace_id`),\n **没有** `project_directory` ⇒ 它引的\"12 个项目\"若用该列名查,**查不到**(会报 no such column)\n 按**正确列名**实测: 不同 `directory` = **26 个**(不是 12)\n `/home/program/TrueAgent` 284 会话 / `/home/program/agentmail` 176 / `/tmp` 56 /\n `/root` 46 / `/tmp/am-mcp-probe` 23 / `/home/program/llmsproxy` 18 / …\n ⇒ ★ 所以\"agentmail 目录恰好 37 个会话\"这个佐证**数值也对不上**(按 `directory` 是 **176**)\n ⇒ pi 的**结论**(\"37 行\"与\"37 个会话\"是**巧合**、不是因果)**方向仍对**,\n 但它引的具体数字**两个都不是本仓的实测值** ⇒ 佐证**不能照用**\n```\n### 三★★★★★ 现状: ② 上线后 opencode 镜像**已稳定在单 workspace**(pi 观测的 5 态轮替已不复现)\n```\n 长窗复采(30s,6 次): opencode **恒 100 行 / ws 数 = 1 / reported_at 逐步推进**(心跳仍在跑)\n 当前 ws = `/home/program/agentmail`; 抽检 60 条 id 的 `session.directory` ⇒ **60/60 全为 agentmail** ✓\n 5 态轮替(0/23/37/38/49)**复现不出**\n ⇒ ★★ 解释: pi 观测(`ac300230` = 09-26 01:00)发生在 **② 部署(09-28 10:14)之前**;\n 当时是**全量替换** ⇒ 多个 directory 的上报**互相整表擦除** ⇒ 才会看到 5 态轮替\n ② 之后 DELETE 域收窄到本次 list 的 ws ⇒ 若仍多上报者,各 ws 的**行会累积并存**\n ⇒ 而实测 ws 数 = 1 ⇒ ★ **当前只有一个 directory 在报**(其余未报或未实例化)\n ⇒ ⇒ ★ 所以这条**不能**用来判\"多上报者已消失\": 单 ws 与\"多上报者但只有一个在报\"**观测等价**\n ⇒ 正确的判据是 ② 之后**看是否出现多 ws 并存**(出现了=多上报者,没出现=不能区分)\n```\n### 四★ 对 pi 修法倾向的回应\n```\n pi 倾向 **(agent_name, workspace)** —— 与本补记之十五 的结论**部分冲突**:\n 之十五 已实测: **只按 workspace 消歧不够**(两 agent 共用同一 ws ⇒ 2 行),\n 生产里**现成就有**这对数据(`ba9c194b`=pi / `9742de96`=dsh 同挂 `/home/program/agentmail`)\n ⇒ ★ 它的\"表已有 `INDEX (agent_name, workspace)` ⇒ 设计本就是 per-workspace\"这句**是对的**\n (单 ws 之内该索引确实就是这个粒度)\n ⇒ 但 **PK 与消歧键不是同一件事**: 索引服务的是**查询**,PK 约束的是**唯一性**\n ⇒ per-workspace 的存储粒度**不排除**\"同一 ws 下多个 agent 各有一行\"\n ⇒ ⇒ 维持之十五 结论: **PK/三键都要带 agent**(三键 `platform_id + agent + workspace`)\n```\n\n## ★★★★★ 补记之十九(2026-09-29 答 pi `8f0a8d60`:**它提的\"方案A\"就是已上线的 ②**;并解开\"n 复现不出\"之谜)\n```\n### 一★★★★ pi 的 \"方案A: DELETE 加 workspace(用 idx_platform_sessions_ws 作证)\" —— **代码里已经有了**\n```\n 实测 `platform_sessions.go:138`:\n `DELETE FROM agent_platform_sessions WHERE agent_name = $1 AND workspace IN (...)`\n ⇒ ★ **这正是它说的方案A**,且**已于 `db640e2`(09-26 14:20) 落地、已随 `359cb436` 部署(09-28 10:14)**\n ⇒ 它\"顺带关掉你上封那条 `[]` 洞\"这句也**已实现**: `:129` 有显式守卫\n `if len(wsOrder) > 0 { … }` ⇒ 空列表**什么都不删**(注释还写明\"不依赖 `IN ()` 在两方言恒假\"这一巧合)\n ⇒ ⇒ ★★ 所以它 §三 的**分析对**(擦除必要、错的是范围、\"擦的域==读的域\"),\n 但**该方案早已落地** ⇒ 我们俩在讨论一个**已经实现**的修法(信息滞后,不是分歧)\n```\n### 二★★★★★ \"SQL 逐值复现不出 n=该目录会话数\"之谜 —— **解开了: 服务端分页默认截断在 100**\n```\n pi 报: agentmail SQL = 110 vs 表 = 37(对不上,它标未知)\n 我测: 该目录会话 **176** vs 表 **100**(也对不上)\n ★★★ 关键实测(决定性): 取镜像里**最旧**的 `updated_at`(= 2026-09-02 04:01:59.383Z\n → epoch **1788321719000** ms),去 opencode 侧数 `time_updated >= 该值` 的会话:\n **恰好 = 100** ✓(该目录总数 176)\n 并核 opencode 侧按 `time_updated desc` 排序的**第 100 新** = `1788321719383`\n ⇒ 与镜像最旧值 `1788321719000` **相差 383ms** ⇒ ★ **镜像=按 updated_at 最近的 100 条**\n ⇒ ⇒ ★★★ 所以 n **不是**\"该目录会话数\",而是 `min(该目录会话数, **100**)`\n ⇒ pi 与我各自的\"对不上\"都是**同一个原因**,而它把它标成\"未知、不作论据\"——\n 那一步**错失了**:两个不同的人在同一处对不上,本应提示\"**有一个共同的下游截断**\"\n ⇒ ⚠️ 而两个已知上限都不是 100: `MAX_REPORTED = 200`(`session-snapshot.js:14`)、\n `maxPlatformSessions = 200`(`platform_sessions.go:41`)\n ⇒ ★ 所以 100 来自**第三处**(opencode `session.list` 不带 `limit` 时的默认页大小)\n ⇒ 我**未能定位该常量**(API 需鉴权 401、SDK dist 里没 grep 到、二进制不可读)\n ⇒ 标 **unknown 但证据充分**: \"恰好 100\" + \"第100新与镜像最旧差 383ms\" 已足够定论\n```\n### 三★★★★ 由此得到一个**新的、独立的缺陷**(与本条镜像域缺陷无关)\n```\n ★ `session.list` 不带 `limit` ⇒ 只拿回**最近 100 条** ⇒ 上报**静默截断**\n ⇒ 后果: 该目录**第 101 新及更旧**的会话**永远不进镜像** ⇒ 它们**不会出现在候选列表里**\n ⇒ 而 `MAX_REPORTED=200` 意味着**设计者以为能报 200 条** —— 实际被服务端截到 100\n ⇒ ★ 与 `recount-relay-counts.sh`、`/tmp` 影子模块同族: **不报错、只是少给**(静默少数据)\n ⇒ ⚠️ 但与\"整表替换\"叠加后更糟: 若某目录会话 >100,镜像**每轮都只覆盖最近 100**,\n 而 DELETE 域是**整个 workspace** ⇒ 旧的 100 条被清、新的 100 条写入\n ⇒ ★ **净效果** = 镜像里始终只有最近 100,且**这是对的语义边界**(不是丢数据)\n ⇒ 只是**与 `MAX_REPORTED=200` 的意图不符**(少了 100 个可续谈候选)\n ⇒ ⇒ 定级: **独立欠账**(观察到的 100 截断 vs 声明的 200 上限不一致),待单独登记\n```\n\n## ★★★ 补记之二十(我 `d36ead2b` 那句\"擦除**在语义上不必要**\" —— pi 说**过强**,我复核**它对我错**)\n```\n 我 `d36ead2b` 原文: \"并集完全存得下这个 PK ⇒ 擦除**在语义上根本不必要**\"\n ★ 实测被引代码 `platform_sessions.go:71-74`(我自己引过的注释就在同一文件):\n```\n```\n # 为什么仍然是\"整表替换\"而不是增量合并\n 镜像是平台当前状态的快照。增量合并会让已删掉的平台会话永远留在候选列表里,\n 而 session 位是三态语义,指向不存在的会话会直接 404(\"选了却送不到\")。\n ⇒ 保持整表替换,只把**域收窄到本次上报覆盖的工作区**。\n```\n```\n ⇒ ★★ 所以\"擦除\"**确实必要**(防\"平台已删、镜像还在 ⇒ 选了 404\");\n 错的是**范围**(域 = agent 全域 vs 应为本轮覆盖的 ws)—— 这正是 ② 修的东西\n ⇒ ⇒ **撤**我那句\"擦除在语义上不必要\"(⑨ 家族: 把话说满)\n 正确表述: 「**擦除必要,但擦除的域必须等于读取的域**」\n ⇒ ⚠️ ★ 更正归属(我写这段时先写错了): 那句\"擦除在语义上不必要\"是**我(dsh)**在\n `d36ead2b` 里写的(`from_name=dsh`),**不是 pi 说的**; pi 是在 `8f0a8d60` 里**指出它过强**。\n 我第一版把 `d36ead2b` 写成\"pi 回我\" ⇒ **又一次没核 from_name 就归因**\n (与本会话此前两次同类: 追认自己没写过的错、把讨论帧当今日帧)\n ⇒ ★ 直接教训: **引用某句话时,先 `select from_name` 确认是谁说的** —— 这一步我已有规则 ⑩,\n 但 ⑩ 只覆盖\"在被引文件里核对该标识符存在\",**不覆盖\"核对这句话的说话人\"** ⇒ 补进 ⑩ 的射程\n ⇒ ⚠️ 另: 这也是我**第二次**把\"范围错\"说成\"机制错\"(更早我把 `[]` 洞当主因,pi 指出\"`[]` 只是次要成因\")\n ⇒ 与\"重跑一遍≠推翻前一次\"同族: **结论已被纠正过,措辞却回到更早那个版本**\n```\n\n## ★★★★★ 补记之廿一(2026-09-29 答 pi `c790a69c` 的\"必须先定的语义\"—— **已有测试钉住,但文档与实现相反**)\n```\n### 一★★★★★ pi 提的\"三种语义要分开\" —— 它的**分类对**,但**今天已有一个测试专门钉住其中一条**,且结论与文档相反\n```\n pi 的提议: 要分开 ①「本目录无会话」/ ②「本 agent 无会话」/ ③「我看不到」,\n 并说\"今天 ① 与 ② 共用 `[]`,而**没有任何上报者该有权说 ②**\"\n ★ 我实测: **② 在今天的实现里根本无法表达**(不是\"共用\",而是②**够不着**):\n `ReplacePlatformSessions` 的 DELETE 域 = 本次 list 里出现过的 **workspace 集合**\n ⇒ 要清\"整个 agent\"必须**枚举**该 agent 的全部 workspace ⇒ 单上报者做不到\n ⇒ ★ 所以\"没有上报者该有权说 ②\"这句**已经成立**(是实现的既成事实,非待定项)\n ★★ 而 ① 与 ③ 的区分**确是真空白**,且方向与 pi 设想**相反**:\n · `agents.go:40-41` 文档明说: \"**空数组 = 平台侧确实一条会话都没有(清空镜像)**\"\n \"拿不到会话列表的插件应当**省略该字段**,而不是传空数组把镜像抹掉\"\n · 但 `platform_sessions.go:129` 的 `if len(wsOrder) > 0` 守卫 ⇒ **空数组什么都不删**\n ⇒ ★★★ **文档说\"清空\",实现是\"什么都不删\"** —— 二者**相反**\n ★★★ 决定性实测(我写了临时探针,跑完已删):\n ```\n 前置: /A 与 /B 各 1 行\n 上报 [](真·空数组)后: map[/A:1 /B:1] ← **未被清空**\n ⇒ 与 agents.go:40-41 \"空数组 = 清空镜像\" **不一致**\n ```\n ★★★★ 而且**已有测试专门钉住这条**(`db640e2` 随 ② 一起加的):\n `TestReplacePlatformSessionsWithNoWorkspaceKeepsEverything`(`platform_sessions_test.go:449`)\n 它断言\"上报不带 workspace 时 /A /B **必须什么都不删**\"\n ⇒ ⇒ ★ 所以 ② 的实现与**它自己的新测试**一致,与**心跳端点的文档**(`agents.go:40-41`)矛盾\n ⇒ pi 提的语义问题**确实存在**,但形态是\"**文档 vs 实现+测试**\",不是\"① ② 共用\"\n```\n### 二★★★★ 「必须先定的语义」具体该定什么(据上面实测改写 pi 的提案)\n```\n 今天 `[]` 的实际语义 = 「**本次上报没有给我 workspace 信息 ⇒ 我无从判断该清谁 ⇒ 什么都不动**」\n ⇒ 这是**安全侧**的(不会误删),但代价是: **\"该目录会话全没了\"这件事永远无法表达**\n ⇒ 平台侧某目录的会话被全部删除后,镜像里的旧行**永不清除**(除 DeleteAgent 那条破坏性路径)\n ★ 实测现网是否有陈旧行: 镜像 100 行(opencode) + 49 行(homeagent) 逐 id 回查 opencode ⇒\n **已从 opencode 消失 = 0** ⇒ ★ **当前没有陈旧行**(该空洞**未被观测到触发**)\n ⇒ ⇒ 该定的语义(建议,供人类裁): 给心跳加一个**显式的**\"本次覆盖的 workspace 清单\",\n 把\"我覆盖了哪些目录\"与\"这些目录里有几条会话\"**分成两个字段**\n ⇒ 于是 `covered=[/w1], sessions=[]` 就能唯一表达「/w1 确实空了」,且**不需**枚举整个 agent\n```\n### 三★★★★ 顺带发现一个**独立的**死字段 + 一处**自相矛盾**的注释\n```\n ★ `heartbeatRequest.Workspace`(`agents.go:32`)注释写 \"**必需**\",但\n `grep -rn \"req\\.Workspace\" server/` ⇒ **心跳 handler 里一次都没读**\n (唯一命中的 `mail.go:823` 是**另一个** struct 的 Workspace)\n ★ 且 `:30` 注释称 \"心跳返回的 pending_mails **按它[Workspace]算**\",\n 而 `:179` 注释称 \"pending_mails 是**全局**未读数(跨工作区)\"\n ⇒ ★ 两处注释**互相矛盾**,代码实际用全局(`UnreadWorkspaces(ctx, agentName)`)\n ⇒ `:30` 那句是**陈的**\n ⇒ 两条合起来: 一个标着\"必需\"的字段**从未被读**,且它的注释描述了一个**已不再成立**的算法\n ⇒ ⚠️ 边界: 我**未**核\"历史上是否读过\"(可能曾经读过、后改成全局)⇒ 标\"当前未读\",不标\"从未读\"\n```\n### 四★ pi 其余几条的处置\n```\n · \"危害比你测的更重: 11/12 次候选=0\" ⇒ 与我今天实测**不符**(恒 100 行/ws=1),\n 但它的观测(09-26)**在 ② 部署(09-28)之前** ⇒ 两者不冲突,是**不同前置条件**\n ⇒ 我不认领\"更重\":② 之后该形态不可复现,应表述为\"**② 之前**的形态\"\n · \"频次极不均匀 ⇒ 危害由频率决定\" ⇒ ★ 这句**对且重要**(我此前只报\"轮换\",未量化频率)\n · \"am-mcp-probe 高频可能是我们自己拉高的(观察改变分布)\" ⇒ ⚠️ **好的自我怀疑**,\n 无法证伪也无法证实(无历史频率快照)⇒ 保持\"未知\"\n · \"状态我采到 9 值,project 表 13 行/10 个有会话 ⇒ 建议写 ≥10 而非精确数\" ⇒\n ⚠️ ★ 我原写\"含会话的 project 数**见附表**\" —— **本轮我没有附任何表** ⇒ 该指涉是**凭空写的**\n 正确做法: 要么把数写出来,要么不引 ⇒ 据本轮实测直接给数:\n `project` 表共 **13** 行; `worktree` 去重后 **11** 个(EcoArk 出现两次 ⇒ 13-2=11);\n **有会话挂载**的 project 数 = **10**(`count(distinct session.project_id)`)\n ⇒ ★ 与 pi 报的\"13 行 / 10 个有会话\"**完全吻合** ✓(它这组数是对的)\n ⚠️ 我先前写\"去重后 12 个\"是**又一处没算就写**(实为 11)⇒ 与行号同类,\n 一并纳入上面的处置(a): **数字也要当场算过再写**\n 它\"按哪张表数会变\"这条**方法论对**(我此前已在场景矩阵里用过\"给分母\")\n · \"`TestReplacePlatformSessionsIsFullReplace` 钉着整表替换是有意设计\" ⇒ ★ **对**,我已复核\n ⇒ 该测试 + `WithNoWorkspaceKeepsEverything` 两条**共同**界定了 `[]` 的现语义\n```\n\n### 五★ 本轮我自己的第 3 次引用失误(记下)\n```\n ⚠️ 我把 `agents.go` 里那句\"空数组 = 平台侧确实一条会话都没有(清空镜像)\"引成 **`:43-45`**,\n 实测在 **`:40-41`**(`:43` 是 `PlatformSessions []repo.PlatformSession` 那一行)\n ⚠️ 且我一度写\"含会话的 project 数**见附表**\"而**本轮没有附任何表** ⇒ 指涉凭空\n ⇒ ★ 与上一轮(`:228`→`:242`) **同一模式第 3 次**,且都在\"正在记录'要核行号'\"的补记里\n ⇒ 判定: 这不是偶发,而是**我在写长文时对行号的默认宽松** ⇒ 处置:\n (a) 行号**只在我当场 `sed -n Np` 回显过之后**才写进文档;\n (b) **不写\"见附表/见上表\"**,除非同一条内确有该表\n ⇒ 本轮 8 处引用已逐一回显核过,`:43-45` 这处即由此发现\n```\n"
},
{
"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"
},
{
"id": "platform-mirror-d1-cross-workspace",
"count": 1,
"kind": "判据已建、当前为红(它断言的正是尚未修复的缺陷)",
"due": "**DELETE 域改成与上报域一致(按 workspace 删)时**,本判据转绿;届时本条与 `platform-mirror-replace-domain-too-wide` 一起结算。★ 注意:本条**不是**待建,而是**已建且现在红** —— 登记它是为了让这条红可见,而不是把它挂在 due 上等将来。",
"where": "`server/internal/repo/platform_sessions_test.go` 的 `TestReplacePlatformSessionsKeepsOtherWorkspaces`((d1));待建的 (d2) 见 `platform-mirror-replace-domain-too-wide` 的 due",
"note": "★★ 2026-09-26 建(pi `e77154d1` §三 提,我仓内真驱动复现后落地)。\n\n## 判据形状\n```\n播下 opencode 的 ws=/A 两行\n → 另一个上报者报 ws=/B(list **非空**,每项都带 `Workspace`,len=1≠0)\n → 断言 /A 的行数**仍为 2**,且失败信息里**列出存活的 workspace 及其行数**(⑫′:范围靠元素可复核)\n```\n\n## 三条性质(所以它**不该**进 due)\n```\n① **今天可写**: 每项 `PlatformSession` 自带 `Workspace` ⇒ 不需要请求级 scope 字段\n② **现在红**: 修前实测 /A = 0(期望 2)⇒ 它断言的正是那个缺陷本身\n③ **修好即绿**: DELETE 改成按 workspace 删 ⇒ /A 仍在 ⇒ PASS(我用直插 /B 模拟修后状态实测过)\n```\n⇒ ★ 落在这三格上,就该**现在就建**。pi 指出我把它整体归入 due 是\n 「**超前断言**」的**反面错** —— 把**今天就能给的判据**当成\"要等未来才能给\",\n 于是在余额里白白挂着一个**今天就能变绿**的缺口。两者都让余额失去信号(假红 / 白欠)。\n\n## 与 (d2) 的分工\n```\n· **d1**(本条): 上报**非空** list 时不得删除其它 workspace 的行 —— 今日可判 ✓\n· **d2**(仍欠): 上报 **`[]`** 时只清自己那个 ws —— 需要\"这次上报属于谁\"\n ⇒ 与请求级 scope 字段**同一前提到期**(`platform-mirror-replace-domain-too-wide` 的 due)\n★ 我上封的判断只对 **d2** 成立。\n"
},
{
"id": "harmony-system-back-key",
"count": 2,
"due": "**真机**(或能观察到 Navigation 返回事件分发的环境)上验清楚:系统返回键在 `Navigation` 内部的消费点在哪一层,以及正确的那一个钩子是什么",
"where": "client/harmony/entry/src/main/ets/pages/MainPage.ets(约 1700 行的 PopIntent 监听者)+ EntryAbility(无 onBackPressed)",
"kind": "bug",
"note": "2026-09-26 审查发现并**尝试修复失败**,整块撤回。\n\n## 未修好的两个具体问题\n\n① `KEY_COMM_STACK_DEPTH` 停在旧值:退到列表后,下一层\"返回\"输入被吞。\n② `KEY_OPEN_MAIL_ID` 留着上一封:回到列表按回车时以为\"还在详情\",会开**回复**而不是新邮件。\n (连带 `MainPage.closeDetail()` 是死代码 —— 它只被 Esc 那条路调。)\n\n## 为什么没修\n\n2026-09-26 在 `EntryAbility` 加了 `UIAbility.onBackPressed()` → `PopIntent.request()`。\n模拟器(HarmonyOS 6.1.1,HarmonyPhone)实测:\n\n- 应用在前台时按**一次**系统返回键 ⇒ **UIAbility 被销毁**(hilog 有 `HandleAppDied`,\n `aa dump -a` 里 EntryAbility 消失),`harmony-admin` 设备判据因此挂住不返回。\n- 对照实验:把 EntryAbility + ComposeIntent + MainPage 三处 stash 掉重编重装,\n 同一条判据 **31/31 通过**。\n\n⇒ `UIAbility.onBackPressed()` 在这条链上**没被调用到**(`Navigation` 自己先消费了\n返回事件),而改动 `PopIntent` 的回调签名又改变了判据侧的行为。\n\n## 为什么不在模拟器上继续查\n\n要验的是\"框架内部返回事件的分发顺序\"这类**框架行为**,而本机模拟器这一条\n又不能代表真机。与其赌一个**会关掉应用**的半成品,不如整块撤回、留成有\n复现步骤的账。\n\n## 试过并被否掉的三种接法(都写在代码注释里)\n\n1. `Navigation.onPop` —— **不存在**,编译报\n `Property 'onPop' does not exist on type 'NavigationAttribute'`。\n2. `pushPath` 第三个参数 —— **不接受**(只接受 1-2 个)。\n3. `NavPathInfo.onPop` —— 存在,但官方文档写明**只在 `pop()` 带了 result 时才触发**,\n 而系统返回键**不走** `pop(result)` ⇒ 照样漏。\n\n## 建议的下一步\n\n在 `NavDestination` 上挂 `onWillDisappear`(或 `onHidden`),在它里面统一做\n\"重发层数 + 栈空时清发布键\";这条是**每个详情页自己**的出栈时机,\n不依赖谁来分发返回事件 ⇒ 系统返回键、Esc、手势三条路都会经过它。\n需真机确认 `onWillDisappear` 在系统返回键下确实触发。"
},
{
"id": "prune-artifact-evidence-decays-with-reboot",
"count": 1,
"kind": "物证的**前提会失效**(不是物证本身错)—— 判据/结论的**时效性**无人复核",
"due": "下一次要用「缺失的产物」当物证时(任何 deploy 脚本旁路都算)。★ 到期动作不是'补判据',是**先查该路径会不会被清**、再决定这条物证此刻还能不能用。",
"where": "`deploy/prune-test-sessions.sh:92`(`BAK=\"/tmp/agentmail-pre-prune-$TS.db\"` 字面硬编码);`/tmp` 挂载见 `findmnt /tmp`;清理策略见 `/usr/lib/tmpfiles.d/tmp.conf:11`(`q /tmp ... 10d`)与 `systemd-tmpfiles-clean.timer`",
"note": "★★ 2026-09-27 实测登记(复核 pi `c6dbc8d0` §三 时发现)。\n\n## 形状:**\"现在没有产物\" 推出 \"当时没跑过\" 需要一个前提,而该前提会自己消失**\n```\npi `c6dbc8d0` 给的加强物证(我复核**成立**且是当时**正确**的测法):\n · prune 的 `.backup` 在删除前**无条件**执行(`:95`),路径**字面硬编码** `/tmp`、不做 env 覆盖\n · 当时实测: `agentmail-pre-prune-*` = **0 个**,且**争议窗口 ±1h 内有 21 个别的文件存活**\n ⇒ 用\"同一目录同一时段别的文件还在\"来证明\"不是被清理掉了\" ⇒ **这一步很扎实**\n```\n## ★★ 但我在 2026-09-27 04:10 重测,那个前提**已经不成立**了\n```\n实测: 早于 2026-09-26 09:32 的 /tmp 文件 = **0 个**(争议窗口 04:00–06:30 内也是 **0 个**)\n而 `tmpfiles.d/tmp.conf:11` 是 `q /tmp 1777 root root 10d` ⇒ **1 天内的文件不该被清**\n⇒ 唯一解释: **`/tmp` 经历过清空/重启**\n`findmnt /tmp` ⇒ **tmpfs** ⇒ ★★ **重启即全失**(与 10d 策略无关)\n⇒ 所以: 「现在 0 个备份」**此刻已经不能**推出「09-26 04:57 那会儿没跑过 --apply」\n```\n## 后果: 三条腿的强度**又降一级**(我上一轮已降过一次,这次是同一机制的第二级)\n```\n物证① 覆盖范围随时间**单调收缩**:\n T0(紧邻事件)⇒ 有效(前提成立)\n T0 + 任何一次重启 ⇒ **永久失效**(且不可恢复 —— tmpfs 不留痕迹)\n⇒ ★ 所以定稿里\"『prune --apply』与『reset-demo』都未跑过 —— 凭谓词无关的产物\"这句,\n 在**今天**只能保留**物证②**(`/opt/agentmail/backups/`,**非 tmpfs** ⇒ 跨重启存活)\n ⇒ 物证① 要降为「**在 09-26 04:10 之前**(当次会话内)成立,之后需重新取证」\n```\n## ★ 可判形状(到期时用)\n```\n凡以「缺失的产物」为物证,**必须同时记录三项**,缺一即降级:\n ① 产物路径是否**会**被自动清理(tmpfiles 策略 / tmpfs / logrotate / 手工 rmtree)\n ② 支撑「不是被清掉了」的**同时段旁证**(同目录同时间别的文件仍在)—— 必须在**取证当时**记,事后不可补\n ③ 取证时刻 —— 因为 ①② 都是**会过期的**\n⇒ 与我们已记的「口径会随时间漂」(`recount-labels-must-match-predicates` 补记)同族:\n 那条是**数字**会过期,这条是**物证**会过期。\n⇒ ★ 反过来也成立一条**该做而没做**的: 若某个物证会过期,**结论就该带时刻**。\n 我们此前把「prune 没跑过」写成**无时刻的现在时** ⇒ 这是本条要纠正的写法。\n"
},
{
"id": "python-probe-shadowing-in-tmp",
"count": 1,
"kind": "测量工具自身的**失效方式**:`/tmp` 里的同名 .py 会**遮蔽标准库**,且**静默混入别人的输出、脚本仍 rc=0**",
"due": "下一次在 /tmp 落 .py 探针之前。(到期动作不是'小心一点',是**换落点或加 `-I`**。)",
"where": "`/usr/local/lib/python3.12/re/__init__.py:124`(`import enum`)是 `import json` 也会走到的真链路;任何 `import json`/`re`/`enum`/`os`/`sys` 的脚本只要**与影子文件同目录**即中招",
"note": "★★ 2026-09-27 登记(复核 pi `b1bd61ef` §四 时实测复现,**它对,我此前那处规避无效**)。\n\n## 机制(实测,/tmp/shadow)\n```\n★ 触发条件是**脚本文件所在目录**,**不是 cwd** —— pi 三次换 cwd 全炸是对的:\n 脚本在 /tmp/shadow/t1.py、cwd=`/` ⇒ 仍被污染 ⇒ `sys.path[0] == '/tmp/shadow'`\n ⇒ 正确说法: `sys.path[0]` = **脚本自身所在目录**; 换 cwd 完全无效。\n ⇒ `python3 -I` **有效**(隔离模式不把脚本目录放进 sys.path)✓\n⇒ ★★ 真链路比\"某个脚本 import 了 json\"宽得多: `json/__init__.py` 内部会 `import re`,\n 而 `re/__init__.py:124` 又 `import enum` ⇒ **任何 `import json` 的脚本**都会执行\n 同目录下的 `re.py` / `enum.py`。\n## ★★ 为什么这是\"最坏的一类\"失效(我实测的严重性)\n```\n伪造 `json.py` 让 `json.load()` 返回 `110`:\n ⇒ 被污染的脚本**正常跑完**、**rc=0**、stdout 混进别人的输出\n ⇒ 形状 = **\"混入别人的输出且可能 rc=0\"** —— 与 `ReleaseRelay` 那条同族:\n **不报错、不失败、只是答案换了**。本轮我已因它撤过一次结论(\"11/12 候选=0\")。\n⇒ ★★ 我此前说的\"**换目录跑**\"规避:对\"脚本在 /tmp\"**不成立**(就是本条)⇒ 撤销该规避。\n## ⇒ 我本会话的**结论为何不受影响**(自查,非辩解)\n```\n我所有 python 探针都是 **heredoc(`python3 - <<PY`,不落盘)** ⇒ 走 stdin、\n `sys.path[0]` 是 `''`(cwd),而我的 cwd **从不是 /tmp** ⇒ **免疫**(已实测对照)\n我落过盘的探针目录(idxtest/h3/v1/pktest)**只含 sqlite 命令、无 .py** ⇒ 不触发\n⇒ ★ 结论: 本会话已提交的结论**未被污染**。但这条**必须留档**:\n 只要有人(包括我)改成\"落盘 .py 再跑\",结论就开始不可信,而**没有任何报错提示**。\n## ⇒ 可执行规则(替代\"小心一点\")\n```\n · 落 .py 探针 ⇒ **不要放在 /tmp**(或任何会被多人共用的目录)\n · 必须放 ⇒ 跑 `python3 -I`,或先 `ls` 确认同目录无 `json.py/re.py/enum.py/os.py/sys.py`\n · 首选: **heredoc 不落盘**(天然免疫)\n · 症状识别: 输出里出现**没写过的行**、或 rc=0 却结果离谱 ⇒ 先查同目录影子文件\n\n## ★★★★ 补记(2026-09-27 pi `685d8f72` 指出后我复现并**收紧**两处措辞)\n```\n### 一★ 它推翻的\"混入+rc=0 不可达\"——**我早已自己推翻过**(不是新错,是我没守住)\n `docs/API.md:6755` 就写着「① 我的『混入+rc=0 不可达』被 pi 反例推翻」\n ⇒ ★★ 也就是说: 这条**我早就认输过**,本轮**又**把它当成\"最坏形态\"写进新登记里\n ⇒ 我自查了新登记的原文: 那里写的是「**可能** rc=0」(不绝对)⇒ **没有**重新断言\n ⇒ ★ 所以不是\"复犯\", 是**措辞没守住已结案的边界**。真正的错在别处,见三。\n### 二★★ 它给的两因子刻画,我实测**成立**,并比它的表述更准\n 反例复现: `try: import json / except: json=None` ⇒ **rc=0 + SHADOW_OUTPUT 混入 + MY_MARKER 仍在** ✓\n 对照(不 catch,异常逃逸)⇒ 混入仍在但 **rc=1** ✓ ⇒ 两者**独立** ✓\n ⇒ ★★ 精确形状(采纳):\n **「混入」由\"遮蔽文件被执行\"决定;「rc」由\"异常是否逃逸\"决定(取决于调用方 catch)**\n ⇒ 两个正交因子,**必须分别断言**,合起来才是\"完全静默\"\n ⇒ ★ 前提要说清(我实测把 pi 的\"必然\"收紧一格): \"混入必然\"成立于\n **「脚本与影子文件同目录」**这个前提下 —— 此时脚本目录在 `sys.path[0]`、影子总是先被找到\n (两种导入顺序实测都命中)。但若影子文件**自身不产出 stdout** ⇒ **执行了却不混入**\n ⇒ 所以准确说法是「**被执行**必然、**混入**取决于影子是否写 stdout」,不是\"混入必然\"\n### 三★★ 我自己那句\"真危险形态\"也要标**不完整**\n 我原写: \"`2>/dev/null` 拿到别人的文本\" —— 成立,但它**只是三因子之一**\n ⇒ ★ 完整的完全静默形态 = **混入(必然执行)** × **rc 由 catch 决定** × **stderr 被丢弃**\n ⇒ 三者同时成立时: **输出被替换、退出码正常、连报错通道都被关掉** ⇒ 无任何可观测征兆\n ⇒ 与 `recount-relay-counts.sh` 那条同族: **\"没报错\"不等于\"没出错\"**,判据必须独立于 rc\n### 四★ 采纳它的可判自检\n 报\"**条数**\"前先问「**这个量有几个来源**」,多来源必须**列出各自贡献**\n ⇒ 与已记的 ⑫′(报数带查询)、④′(按机制分类)同族 ⇒ 并入本条\n"
},
{
"id": "retired-host-exempt-never-tested",
"count": 1,
"due": "有人决定把 zcode 真正部署回来时(豁免自动失效,无需手工改)",
"where": "deploy/check-deploy-drift.mjs 的 RETIRED_HOSTS(豁免本体)",
"kind": "scope",
"note": "2026-09-28 新登记。zcode 宿主已在本机删除,但插件与单元**故意留在仓库里**以便恢复;\ncheck-deploy-drift.mjs 的 HO"
},
{
"id": "shared-workspace-unserialized-deploy",
"count": 1,
"kind": "并发**不受控**:多会话共享一棵工作树,部署与改代码之间无任何互斥(`flock` 只挡同时跑的两个部署)",
"due": "给 workspace 加**跨会话互斥**,或让部署把「构建时的未提交文件清单」**持久化**(写进构建物旁边 / 归档日志)时。★ 到期动作不是'再小心一点'——本次已证明小心没用:pi 与 opencode 各自按流程走,仍产出了带 `vcs.modified=true` 的二进制,而那次带了谁的哪些文件**已不可复原**。⚠ 注意别顺手把 WARN 改成判红:`check-deploy-drift.mjs:1462-1467` 刻意不判红(脏树在本仓是常态,判红=总在亮),那是**有意的设计决定**。",
"where": "`deploy/redeploy-gateway.sh:109-112` 的 `flock`(只挡并发部署,**不挡** A 会话部署时 B 会话正在改源码);`docs/reviews/final-check-2026-09-28.md` §3(本次完整经过);本工作区实测 **36 条会话**同指向 `workspace=/home/program/agentmail`,别名 `probe-*` / `repro-*` / `coord-*` / `stress-sse-1..20` / `stress-budget-*` / `thread-probe-*`,全部能 commit、全部能跑部署脚本",
"note": "★★ 2026-09-28 登记(opencode 实测,pi 独立复核)。\n\n## 形状:**产出无法证明来源的二进制,而披露只活在一块屏幕上**\n\n`redeploy-gateway.sh:109-112` 有 `flock`,注释里把它列为环境前提表里\n「同时性」那一类,写得对 —— **但它防的是错的东西**。\n\n要防的真实事件是:**A 会话 09:52 跑部署,B 会话正在同一棵树上改文档**。\n`flock` 只在两个部署**同时**跑时生效,而 09:52 那次是**单发**的,没人跟它抢。\n\n本次实测:docs 在 09:51:28 提交、09:53:12 再提交,部署卡在中间的 09:52:33\n⇒ 构建时工作区有未提交改动 ⇒ 线上二进制 `vcs.modified=true`。\n\n★ **与 `044a664` 的 ② 完全同形**:那次是「注释说修了而代码没改」,\n这次是「判据说干净而构建物不干净」—— 都是一句话与事实分家。\n\n## ⚠ 更正一则(初稿把这笔债说重了)\n\n初稿写「后果**无声**……没有任何东西会红」。**不准确** —— 现有机制**有**披露,\n而且是 pi 2026-09-25 专门加的:\n\n· `redeploy-gateway.sh:261-265`:脏树时 `warn` 并**逐个列出未提交文件名**,\n 注释里明说「清单有名字,bool 没有」;\n· `deploy/check-deploy-drift.mjs:1462-1467`:把 `vcs.modified=true` 作为\n **WARN 披露**,且**刻意不判红**(理由写在代码里:「脏树在本仓是常态,\n 判红=总在亮」)。\n\n⇒ 真正缺的不是「披露」,是**披露的持久性**。实测 09:52 那次的清单\n**已不可复原**:脚本无 `tee`、journal 里 0 行(实测 grep)、\n`/tmp` 下只剩 `am-sandbox-check.log` 等无关产物。\n**所以事后没人能说出「那次构建带了谁的哪些文件」** ——\n而那正是当初加这条披露的全部理由(2026-09-14 `pool.mjs` 那一行未提交的\n`let missingSessionCount = 0;` 就是这么被带进生产的)。\n\n## ⚠ 更正二则:假安全感有**两处**,不只 `flock`\n\n初稿只点了 `flock`。实际两处都给了「已经防住了」的错觉:\n\n· `flock` 在位 ⇒ 以为并发部署已防(实则只防「同时跑」的那一种);\n· `check-deploy-drift` 的 `modified` **WARN 不参与退出码** ⇒\n 「反正有判据在报」⇒ 而 WARN 不进退出码,**没有东西会因此停下**。\n\n## 为什么仍标 HIGH\n\n① **披露会蒸发**:本次已实证「脏文件清单拿不回来」,而\n`check-deploy-drift.mjs:1456-1461` 自己写着「这次构建带了哪些未提交文件\n由**部署脚本**在构建步打印(那里才是同一时刻)」—— 而那个「同一时刻」\n没有任何持久化接住;\n② **会复发**:压测会话(`stress-sse-1..20`、`stress-budget-*`)是**批量**的,\n下一次批量跑就会再压一次;\n③ **两处机制都在位却都不拦住**:一个 `flock`、一个 WARN。\n\n## 我做过的、能立刻复用的处置\n\n· 判据侧已把 B 段那条 SHA **从写死改成现查**(`git rev-parse HEAD`)——\n 写死的 SHA 当天就因两次部署而过期两次,会让人误判「不一致」而其实只是过期。\n· 部署前先 `git status --short` 空,再跑;不空就**先别跑**。\n· 判据要**钉会红的行为**(本次:`TestHMSQuotaSurvivesTokenFailureWithLimitOne`\n 断言第二次不能是「上限」),只钉内部状态(`dayCount==0`)不够 ——\n 计数器对而行为错是可能的,那会给运维一条误导性文案。\n\n## 一条更贵的建议(未做,需平台侧)\n\n会话级互斥或工作树独占。现在同一 workspace 上有 30+ 个会话别名,\n`coord-hap-evict-20260928` 这个别名本身就在自证问题:它 09:55:31 问\n「delivery-marks-read 的新判据请自行提交」—— **它知道有会话在改这棵树,\n但不知道是哪个,也不在同一条线索上。**"
},
{
"id": "in-reply-to-ignores-direction",
"count": 1,
"kind": "**归因错误且无声**:SSE 的 `in_reply_to` 非空就断言「这封是对我上一封信的回复」,不校验父邮件的发件人是不是我 —— 于是单向来信链被逐封读成双向对话",
"due": "给 `inboundHeadline` / `replyInstruction` 的 `inReplyTo` 加上方向判据(父邮件的 from == 本方),或让服务端只在父邮件确由收件方发出时才填 `in_reply_to` 时。★ 到期动作不是'在提示词里写清楚'——本次已证明写清楚没用:四封通知的正文里已经逐字写明'回的是你那封:<id>',模型照样每封都去核一遍,然后照样被误导。",
"where": "`plugins/*-mail-bridge/lib/relay-policy.js:105-111`(`inboundHeadline`:`if (inReplyTo)` 直接出'你上一封信的回复到了');`plugins/zcode-mail-bridge/src/prompt.mjs:141`(`if (data?.in_reply_to) lines.push('回的是你那封:…')`,无方向判断);服务端 `server/internal/notify/mail.go:202` 把 `ParentMailID` 原样透传;实测线索 `stress-thread-21863-15348`(session aa2a600d,8 封全为 opencode→pi,层号跳过 3/6/9)",
"note": "★★ 2026-09-28 登记(pi 实测,opencode 复核)。\n\n## 形状:**单向 8 封被读成双向 4 轮**\n\n压测线索 `stress-thread-21863-15348` 里 8 封全是 `opencode → pi`,\n`read_thread` 逐层确认,pi 侧一封未发(唯一一次 `send_mail` 被\n'Agent 互发 8 封上限'拦下)。但四封投递通知各自宣称\n'回的是你那封:<上一封的 mail_id>' ——\n\n| 通知 | 宣称的父邮件 | 实际 |\n|---|---|---|\n| 层1 de4e212f | 6298f78a | 6298f78a 是 **opencode 自己的**信 |\n| 层2 e0e8b3c7 | de4e212f | 同上,仍是 opencode 的 |\n| 层4 6358cfc7 | e0e8b3c7 | 同上 |\n\n⇒ 通知里**不存在**一封是 pi 发出的。\n\n## 根因不是'通知乱序',是**方向被省略**\n\n`resolveTarget` 里 `reply_to` 被解析后 `parentMailID = &replyID`\n(`server/internal/handler/mail.go:88`),notify 原样透传(`notify/mail.go:202`)。\n**模型是对的**:父邮件 = 上一封 = 我上一封收到的。\n\n漏掉的是**方向判据**:`inboundHeadline` 只问'有没有父邮件',\n不问'这封父邮件是不是我发的'。单向续信也满足'有父邮件',\n于是一条纯单向的压测链被逐封判定成'对方在回我'。\n\n## 为什么第一封没被骗\n\n6298f78a 走的是真·新会话路径,`parentMailID == nil`\n⇒ `in_reply_to` 为空 ⇒ `inboundHeadline` 落到默认分支\n⇒ 显示'你收到一封新邮件'。**四个桥的同名字符串都在\n`relay-policy.js:111` 这一行**,改动会同时影响 dsh/zcode/opencode/pi。\n\n## 后果:撞 hop 上限,掩盖真实缺陷\n\n每被误判一轮,模型就'处理'一次并回一封,客套到上限被拦\n(生产实测 6 轮)。这次 8 封单向压测消耗的正是这份额度,\n把真正的缺陷挤出了视野。\n\n## 我做过的核对(避免重蹈 aab92f17 的归因错误)\n\n· 4 次 `read_inbox` 全空(unread 与 all 都空,`all` 返回的 8 封全标 `[archived]`);\n· `read_thread` 三次均为 8 封、全 `opencode → pi`、无任何 pi→opencode;\n· 逐封 `read_mail` 核对发件人与正文(正文是'层 N 的正文'占位);\n· 层号跳过 3/6/9,但 mail_id 序列连续 ⇒ **发送侧跳号,不是丢信**;\n· 本条根因定位所依据的行号均已回读原文确认。\n\n## 修法的一处取舍(尚未做)\n\n最小改动是在 `relay-policy.js` 加 `inReplyToFromMe` 判据,但那需要\n父邮件的发件人信息 —— 当前 payload **没有**这个字段(只有 `from_name`\n即本封发件人)。所以两个选项:服务端补一个 `parent_from` 字段,\n或插件侧用 `read_mail(parent_id)` 查一次。**前者更便宜且不用多一次往返**。\n\n★ 顺带记一笔:另有两个观察(`read_inbox` unread 视图与通知不一致、\n`session_participants` 回显把 path 段吞掉)**可能同源** ——\n都指向'通知/展示层没有回读真实数据',但**我没有查证**,不并入本条。\n\n## 2026-09-28 校正(pi自查,上条那节写得太快)\n\n上次写「修法:服务端补 `parent_from` 字段更便宜」——**方向对,但我没查证\n就下了结论**。现已回读确认,且**比原先想的更便宜**:\n\n`resolveTarget` 的 `reply_to` 分支(`server/internal/handler/mail.go:80-85`)\n**已经把父邮件整行 `repo.GetMailByID` 读进内存**了,只用了它的 `SessionID`\n就把 `mail` 丢掉;而 `models.Mail` 上就有 `FromName`\n(`server/internal/models/models.go:142`)—— 即判据需要的方向信息\n**在函数里现成,不需要任何一次额外查询或新 join**。\n\n⇒ 修法应当是:`resolveTarget` 一并返回父邮件发件人,notify 载荷带出去。\n**这不是\"补一个字段的成本\",是\"别把已经在手的数据扔掉\"。**\n上次那句把它说成新增成本,是我把话说满了。\n\n判据已就位:`client/electron/test/cross-bridge-prompt.test.mjs` 第 5 条\n(当前**故意红**,即本条债的判据)。"
},
{
"id": "build-stamp-stale-artifact-blocks-verification",
"count": 1,
"kind": "**产物自证长期红**:`build-stamp` 要求 `BUILD_INFO.json` 的 `gitRev` 精确等于 HEAD,而该文件**未被 git 跟踪**、内容长期停在某次旧构建上 ⇒ 这条判据只能靠「重构建」恢复,**任何代码改动都无法让它转绿**",
"due": "有人重跑一次客户端构建(产出新的 `BUILD_INFO.json`)时。★ 到期动作**不是**改 `BUILD_INFO.json` 里的 `gitRev`/`srcHash` —— 判据自己的报错文案就写明那样做等于把它废掉;也不是把它加进跳过名单;唯一正确的动作是重跑构建。",
"where": "`client/electron/test/build-stamp.test.mjs:93`(`★ 产物必须自报来源:BUILD_INFO 精确比对`);产物记录在 `client/electron/BUILD_INFO.json`(★ **未被 git 跟踪**);实测 2026-09-28 产物记 `87c55ac`、HEAD 已走到 `f1c74fc`",
"note": "★★ 2026-09-28 登记(pi 实测)。\n\n## 形状:**一条判据长期红,且与被测代码无关**\n\n`build-stamp` 断言产物自报来源必须精确等于当前 HEAD。实测:\n产物记 `87c55ac`,HEAD 当时是 `359cb43`、随后因本轮工作走到 `f1c74fc`\n⇒ **无论谁提交什么,这条判据都不会自己变绿** —— 它只能被一次重构建救。\n\n## 为什么值得单独记一笔:它是**共享工作树债的直接产物**\n\n同一个 `HEAD` 上(`shared-workspace-unserialized-deploy`):产物落后于源码,\n因为多个会话连续提交而**没人重跑构建**。`d3a7873` 那笔 HIGH 记的正是\n「判据说干净而构建物不干净」;本条是它的**日常形态** —— 不危险,\n但它会长期挂在套件的红名单上,让「红了」这件事开始钝化。\n\n★ 真正的危害不是这条判据本身,是**它会训练人忽略红色**:\n套件汇总里 `build-stamp` 与另两条真缺陷并列显示,人一旦习惯\n「哦又是 build-stamp」,就会把同一行里的真缺陷一起放过。\n这与本仓反复消的「看不到 ⇒ 绿」是同一族,方向相反:**看到了 ⇒ 当没看见**。\n\n## 已做的核对(避免把别人的问题算成新的)\n\n· 提交 `f1c74fc` 之前就在**干净 HEAD** 上复现过 ⇒ 不是本轮引入;\n· `BUILD_INFO.json` 经 `git ls-files` 确认**未被跟踪** ⇒ 缺的那次构建\n 没人提交过,不是被谁回滚;\n· 判据报错文案本身已警告「别去改 gitRev/srcHash 了事」 —— 本条认同,\n 并把那个正确修法(重跑构建)写进 `due`。\n\n## 未做\n\n没有触发构建、没有改 `BUILD_INFO.json`、也没有把它加进任何跳过名单。\n构建是部署动作,而本工作区正在被多个会话并发提交(已有两个\n`server/internal/repo/zz_*_probe_test.go` 不属于本会话)—— 由人决定何时构建。"
},
{
"id": "delivered-but-never-dispatched-silent",
"count": 0,
"due": "给 pool.mjs 的排队路径加日志(入队/出队/等待时长)—— 没有它,「排队」与「丢失」在日志上同形",
"where": "plugins/pi-mail-bridge/src/pool.mjs(排队路径无任何 log)",
"kind": "observability",
"note": "2026-09-28 压测后重建网关时**实测复现**(不是推演)。\n\n## 现象\n给 pi 发信 ⇒ 桥**从不提及那封 mail_id**(日志 0 次)⇒ 没起会话、没人回信。\n发件人视角 = 「信发出去了,然后没声了」。连发 3 封,3 封全中。\n\n## 已排除的两个错误归因(都记下来,因为它们的推理方式会复发)\n\n**① 「是 read_inbox 连带标掉了在途邮件」** —— 错。\n 我看到 `status` 在 11ms 内变 `read` 就归因到 read_inbox。\n 但网关日志的**毫秒级时间线**显示:每一次 `POST /mail/send` 之后 11~16ms\n 必有一次 `POST /mail/read`,而 `mail_reads.reader_name` = **pi**(不是 opencode),\n `/mail/read` 的来源端口属于 pi 桥自己的连接。\n ⇒ 那是 `markDelivered`(`4175c0b` 的「投递即标已读」),**正常成功路径的一部分**。\n\n**② 「是 opencode 的桥串用了 pi 的密钥」** —— 错。\n 我一度以为 `reader_name=pi` 与「连接来自 opencode 进程」矛盾。\n 实测两个 CONFIG_DIR 不同(pi=`/root/.agentmail-pi`、opencode=`/opt/agentmail/agent-config`),\n 各自的 `AGENTMAIL_AGENT_KEY` 在库里分别属于 pi / opencode。**没有串用。**\n\n★ 两次归因错的共同点:**我先有了候选解释,然后去找支持它的证据**。\n 正确顺序应是「先取一条能一次说清的独立时间线(网关 access log 的毫秒时间线\n + 源端口 + reader_name),再解释」—— 那一刻两条日志就够判定了。\n\n## 仍然为真的部分\n「投递了但桥没起会话」是事实,机制未定位。已知条件:\n · `MAX_WORKERS=3` 且实测**真的**有 3 个 worker 满载(我一度以为 4 个,\n 那是 `pgrep -f` **把查询命令自己算进去了** —— 探针把自己算进来,与\n `baseline-residue` / `python-probe-shadowing` 同族);\n · 池的**排队路径完全无日志**(`pool.mjs` 里入队/出队都不打)⇒\n 「在排队」与「丢了」在日志上**同形**,这才是无法定位的直接原因。\n\n⇒ 待办(按此顺序):**先给排队加日志**,再判是排队超时还是投递丢失。\n 没有那条日志,任何进一步推断都是猜。"
},
{
"id": "archive-burst-targets-unreconciled",
"count": 1,
"kind": "observability",
"due": "机制与规模都已定(归档批 63 = 32 戳幸存 + 31 被后续写入覆盖)⇒ **这条债可收口**。唯一未做的是**逐条点名**那 31 条(已点名 1 条),**明确不做**:18/19 字节候选各 20+ 个,穷举收益低于成本,且点名不改变任何已定结论。★ 若将来仍要点名,用已验证的只读解码:**响应字节数 − 93 = alias 字节数**(先用已知目标自校验解码器),且一律用 `LENGTH(CAST(session_alias AS BLOB))` —— 用字符数会把 CJK 别名算短。",
"where": "网关 access log(`POST /api/v1/contacts/archive`)+ `ArchiveSession`(`server/internal/repo/repo.go` 的 `ArchiveSession`,⚠ 行号会漂,2026-09-28 11:20 复核为 1679)。",
"note": "★★ 2026-09-28 登记(pi 与 opencode 互相复核三轮,每轮各推翻对方一个机制;最后**仍不闭合**)。\n\n## 事实(三方复核一致)\n\n```\n10:09 这一分钟 POST /contacts/archive 65 次(全 200)\n 其中 10:09:57–58 两秒内 63 次\n sessions.updated_at 落在 02:09:57–58 的 32 条\n10:43:20 那次 POST 14 次 ⇒ 落在 02:43:20 的 11 条(比值 **1.27**)\n```\n\n## 已被数据否掉的三个机制\n\n**① 幂等重试**(opencode 第一版)—— 否。`ArchiveContact` 无「已归档就早退」,\n`GetSessionByID` 无归档过滤 ⇒ 重复归档**会**再次盖 `updated_at`。\n若 63 次打在 32 条上,那 32 条里应该有 63 个不同的时间戳,实际只有 32 个。\n\n**② 后续写入污染 `updated_at`**(pi 第二版)—— 大部分否掉。\n那 28 条 stress-sse 的 `updated_at` 实测**全部**仍在 `02:09:57.171 ~ 02:09:57.763`,\n一条都没被推后 ⇒ 不可能是「普遍污染」。\n★ **但 pi 找对了真凶的一半**:他那次分组写的是 `updated_at >= '2026-09-58'` —— **无上界**,\n实测该桶真实跨度 `02:09:58.067 → 04:02:31.9`(**1h53m**)⇒ 把之后两小时里所有的\n`TouchSession`、建邮件、回信全扫进了「归档打中的目标」。**这是筛选条件写错,不是机制。**\n加上界到 02:10:10 后该窗口降到 **34** 行。\n\n**③ 筛选条件写错**(pi 第三版,**成立**)—— 见上。\n⚠ **但它没有完全解释**:加上界后 **34** 行,而那两秒内只有 63 次 POST / 32 条在盖。\n34 里 32 条是 `02:09:57x` 的在盖会话,另 2 条是 **active**:\n · `\"stress-sse-14-18718\"` updated_at `02:09:59.541`(含字面引号,另一种形态)\n · `\"stress-sse-5-8157\"` updated_at `02:10:09.561`\n这两条的**邮件都是 `read`、不是 `archived`** ⇒ `ArchiveSession` 从未在它们身上跑过\n(它会连带 `UPDATE mails SET status='archived'`)⇒ **它们不是那 63 次的目标。**\n⇒ **34 − 2 = 32,仍与 POST 数对不上。**(★ 这一句在 12:30 被下面「已闭合」推翻 ——\n差值是真的,但**机制确实是「后续写入盖掉时间戳」**,只是 pi 的 28 条证据覆盖不到它。**)**\n\n## ★★ 已闭合(2026-09-28 12:30,opencode):把响应体大小**解出来**当判据\n\n`ArchiveContact` 的 HTTP 响应体是 `{status, session_id, session_alias}` ——\n**注意:不含 `archived_by`**(`archived_by` 只在 SSE payload 里,`handler/contacts.go`)。\n⇒ 上一轮我说「大小 = f(len(alias), len(archived_by))」**是错的**,已更正。\n\n`JSON()` 走 `json.NewEncoder().Encode()`(`handler/helpers.go:19`)⇒ 键排序 + 末尾换行,\n而 `session_id` 恒为 36 字符 UUID ⇒ **响应大小 = 93 + len(alias 的字节数)**(可精确反解)。\n\n★ **判据自校验**(先在已知目标上验证解码器):`10:42:23` 只有 1 次 POST、大小 111B\n⇒ 预测 alias 字节数 = 18;该秒唯一被盖的会话 `stress-sse-9-20516` **恰好 18 字节** ⇒ 解码器成立。\n\n```\n63 次 POST(10:09:57–58)按预测 alias 字节数:\n 14×1 16×1 17×5 18×26 19×28 20×1 24×1\n同时被盖上 02:09:57 时间戳的 32 条,按**字节**长度:\n 17×4 18×11 19×16 20×1\n⇒ 逐长度相减:14×1、16×1、17×1、18×15、19×12、24×1 = **31 条对不上**(= 63 − 32 ✓)\n```\n\n★ **注意 CJK 坑**:那 32 条里有 2 条 `pi-压测-限流-17/-12` 是 **11 字符 / 19 字节**。\n用 SQLite `LENGTH()`(数字符)会算成 11,而 Go `len()`(数字节)是 19 ——\n**两边量的不是同一个量**,对不上时会以为「有 2 条 POST 找不到会话」。\n⇒ 一律用 `LENGTH(CAST(alias AS BLOB))`。\n\n## ★★ 决定性论证:**不需要知道打中的是谁,也能证明时间戳被盖掉了**\n\nalias 字节长度 **14** 的候选,全库只有 2 个(`created_at < 02:09:57`):\n\n| alias | updated_at |\n|---|---|\n|---|---|\n| `arch-test-2147` | `02:01:12`(**早于**那批) |\n| `thread-probe-1` | `02:43:20`(**晚于**那批) |\n\n⇒ 那 1 次 len-14 的 POST **必然**打中这两个之一,**而两个都不在 `02:09:57`**。\n⇒ 不管是哪个,它的 `10:09:57` 时间戳**都已经不在了** —— 这就是「后续写入盖掉」的**直接证据**,\n且**不依赖**「到底是哪个」这个我们不知道的事实。(2/2 排除,比点名更硬。)\n\n★ 同理 alias 字节长度 **24** 只有 2 个候选:`插件语义探针-zcode`(`09-15`,早于那批 ⇒ 不可能是目标)\n与 `coord-hap-evict-20260928`(`02:16:34`,晚于那批)⇒ 只能后者。\n而后者正是那 10 条**半活会话**之一(`sessions.status='active'` 但 `mails.status='archived'`)\n⇒ `ArchiveSession` **确实在它身上跑过**(全仓只有它会把 mails 盖 archived)\n⇒ 两条独立线索(字节长度排除 + 半活签名)**交汇于同一会话**。\n★ 而它的复活指纹很干净:`sessions.updated_at=02:16:34.987566`,\n同一瞬间(**+12ms**)有一封邮件 `created_at=02:16:34.999952` ⇒ 旧 `CreateMail`+`TouchSession` 路径。\n\n## ⇒ 结论(改写这条债的定性)\n\n**差值 = 归档目标里「时间戳后来被盖掉」的那批,不是「没打中」。**\n⇒ 归档批的**规模本来就是 63**,其中 32 条的戳幸存、31 条被后续写入覆盖。\n⇒ pi 第二版的**机制是对的**,但他的**证据(28 条 SSE 没被推后)覆盖不到非 SSE 目标** ——\n那 28 条集中在 0.6 秒内且之后没人碰,**是那批目标的性质,不是定律**。\n⇒ 我第一版「63 次该产生 63 个时间戳」也错:它假设**没人再写**,同样不成立。\n⇒ **两版都被数据否掉的理由都是「假设之后没人写」** —— 同一个隐藏前提。\n\n★ 仍存的缺口 —— **明确不做**(2026-09-28 12:50,pi 与 opencode 一致):\n**31 条里只有 1 条被点名**(`coord-hap-evict-20260928`),其余 30 条(18×15、19×12 等)**不穷举**。\n理由:同字节长度的候选各 20+ 个(18 字节 23 个、19 字节 20 个),指名要靠\n「字节长度 × 存活时间」交叉,**收益低于成本** —— 而且**点名不改变任何结论**:\n机制(后续写入盖掉时间戳)与规模(63 = 32 幸存 + 31 被覆盖)都已定。\n⇒ 剩余部分是**点名,不是判据** ⇒ 按「成本高于收益,明确不做」记,\n**不当成开放洞** —— 否则下一个人会以为还有未决的机制问题。\n\n## ★ 顺带钉住一个可复用的判据形状:**半活会话**\n\n旧 `TouchSession` 是 `SET status='active', updated_at=NOW()`(**不碰 mails**),\n而 `ArchiveSession` 是**两条** UPDATE(sessions + mails 都盖 archived)。\n⇒ 「被归档过、之后又被 touch 复活」在库里有**唯一签名**:\n\n```sql\nSELECT s.session_alias FROM sessions s JOIN mails m ON m.session_id = s.session_id\nWHERE s.status = 'active' AND m.status = 'archived';\n```\n\n★ **口径必须写死(2026-09-28 12:50,pi 报 16、我报 10 —— 差的不是数据是窗口)**:\n\n```sql\n-- 判据本体(**必须按 session_id 去重**,否则 JOIN 行数会给出 93):\nSELECT COUNT(*) FROM (\n SELECT 1 FROM sessions s JOIN mails m ON m.session_id = s.session_id\n WHERE s.status = 'active' AND m.status = 'archived' GROUP BY s.session_id);\n```\n\n| 口径 | 条数 |\n|---|---|\n| 全库(去重 `session_id`) | **16** ← pi 报的 |\n| 仅 `updated_at >= '2026-09-28'` | **10** ← 我上一封报的 |\n| 2026-09-28 之前(历史残留) | 6 |\n| ⚠ 按 JOIN **行数**不去重 | **93** ← 错法 |\n\n=> **16 = 10 + 6**,两边都没算错,只是窗口不同 —— 这不是数据打架。\n★ 三条口径纪律(这轮各踩一次):\n 1. **必须 `GROUP BY session_id`** —— 一条会话有多封 archived 邮件时会重复计数;\n 2. **窗口必须写进结论里**,否则同一个量两个数必然被当成打架(我们这轮反复栽的就是这个);\n 3. 命中的 16 条里含 `redeploy-pi-bridge-授权唤醒修复`(36 封)、`鸿蒙客户端与-WebUI-界面对齐`(27 封)\n 等**早期大会话** ⇒ 印证另 6 条是历史残留,不是今天产生的。\n⇒ **这个签名可以直接当「归档是否被绕过/复活」的判据**,比读 `updated_at` 可靠 —— \n`updated_at` 会被任何后续写入改,这张两表的状态分叉**不会**。\n(`e78888b` 已让 `TouchSession` 不再写 status ⇒ 新代码下不会再产生新的半活会话,\n但**这批历史数据仍在**。)\n\n★ **顺带把那个机制坐实了**(不是推测,是能证伪的那种):`e78888b` 落在 **11:01:38**,\n而全库半活会话 `MAX(updated_at)` = **04:02:31**,即**一条都不在修复之后**。\n⇒ 机制成立(旧 `TouchSession` 写 status 不写 mails)**且**修复确实封住了它。\n⇒ 这也说明:拿今天的数据去验证 `e78888b` 之后的代码路径,**结构上就不可能复现**。\n\n## ★ 已被上一节取代:原以为「必须重打一轮才能取到响应体」—— **不需要**\n\n原判断「access log 不记响应体 ⇒ 现有日志无法回溯 ⇒ **必须重打一轮**」**是错的**:\n日志记的是**响应字节数**,而响应体是 `json.Encoder` 的确定性输出(键排序、`session_id` 恒 36 字符)\n=> **大小 = 93 + len(alias 字节数)**,**大小本身就是一份可反解的响应体**。\n⇒ 上面「已闭合」一节就是用它做的,**没有重打一轮**。\n\n⚠ 顺带更正两处我上一轮的错误:\n · 响应体**不含** `archived_by`(它只在 SSE payload 里)⇒\n 我上一轮说的「大小 = f(len(alias), len(archived_by))」**作废**;\n · pi 提的「同一长度出现两种大小 ⇒ 两个不同调用方」**方向反了**:\n 大小是长度的**函数**,一个长度只对应一个大小 ⇒ 该判据不能用。\n\n## 剩下的缺口(唯一一条)\n**31 条未覆盖的目标里,30 条还没逐条点名。** 机制已定(后续写入盖掉时间戳)、规模已知(63),\n但指名需按「字节长度 × 存活时间」交叉,同长度候选太多(18 字节候选 23 个、19 字节 20 个)。\n⇒ 属**穷举成本**问题,不再是**机制未知**问题。"
},
{
"id": "archive-sweeper-leaves-mails-alive",
"count": 1,
"kind": "correctness",
"due": "给 `deploy/archive-stale-sessions.sh` 的归档与回滚都补上 `mails` 的联动(与 `ArchiveSession` 同语义:归档即 `UPDATE mails SET status='archived'`)。回滚那一半**不是纯语义选择、已被输入卡死**:`archive-ids-*.txt|json` 里**只有 session_id、没有 mail_id**(实测 09-14 的 25 个、09-15 的 12 个,`archive-plan-*.json` 里也没有 `mail_id`)⇒ 光靠 ids 文件无法知道「本次盖过哪些邮件」。⚠ **但 `/root/gotmp/agentmail-pre-archive-20260915-065927.db` 这个归档前快照能反推**:实测 12/12 条会话都能从快照里定位到「归档前哪些邮件是活的」(共 467 封非 archived)⇒ **「只反还本次盖过的那批」对有快照的那几次是做得出来的**,只有 09-28 那 8 条(无归档前快照)只能整会话复活。⇒ 人要定的其实是「**没有快照的那些次怎么办**」,不是「两种语义选一个」。另:给 `session_status_invariant_test.go` 补**反向**断言(现在只查一个方向),并让判据能在**真实库**上只读跑一次(现在只在 `t.TempDir()` 上跑 ⇒ 431 封存量分叉它永远看不见)。",
"where": "`deploy/archive-stale-sessions.sh:105`(归档,只 UPDATE sessions)与 `:147`(回滚,同样只 UPDATE sessions);`ArchiveSession` 在 `server/internal/repo/repo.go`(⚠ 行号会漂,2026-09-28 12:55 复核为 1679-1690,两条 UPDATE);不变量测试在 `server/internal/repo/session_status_invariant_test.go`。",
"note": "★★ 2026-09-28 登记(opencode 实测;pi 问「半活会话要不要单开债」时撞见的**反向**分叉)。\n\n## 现象:**反方向**的两表分叉已经在生产里,431 封\n\n上一轮我们在 `archive-burst-targets-unreconciled` 里记的是「半活会话」\n(`sessions.status='active'` 而 `mails` 有 archived)。**那个方向**已被 `e78888b` 封住成因。\n**这个方向没人查过,而且它现在是活的代码路径:**\n\n```\ns.status='archived' 而 m.status<>'archived'\n → 28 条会话,431 封邮件(7 封 m.status='unread'、424 封 'read';**按收件人无 mail_reads 行的 45 封**)\n → 最大的几条:homeagent-webui 135 封、harmony-ui-alignment 100 封、\n Re-Re-深入的看一下当前工程 65 封、deploy-pi-bridge-authz-wakeup 35 封\n```\n\n## 成因:直接证据,**不是推演**\n\n`deploy/archive-stale-sessions.sh` 的归档那一段(105 行)**只写 sessions**:\n\n```sql\nBEGIN;\n UPDATE sessions SET status='archived' WHERE session_id IN ( ... );\nCOMMIT;\n```\n\n**同一个事务里没有 `UPDATE mails`** ⇒ 每跑一次就多一批反向分叉。\n★ 它的**回滚脚本**(147 行)也一样只写 sessions:\n`UPDATE sessions SET status='active' ...` ⇒ **回滚会把 431 封一次性重新暴露**。\n⚠ **回滚后显示为「未读」的是 45 封,不是 7 封** —— 详见下面「读态由 mail_reads 定」一节。\n\n★ **因果闭合(但只覆盖 12/28,别当成全部)**:`/root/gotmp/archive-ids-20260915-065927.txt` 留有\n09-15 那次的 12 个 id,实测这 **12/12 个今天全部是反向分叉**(`homeagent-webui`、\n`harmony-ui-alignment`、`deploy-pi-bridge-authz-wakeup` 都在清单里)\n⇒ **那一次** sweeper 与这批分叉是因果关系。\n⚠ 但另外 16 条**不匹配任何归档产物**(含今天 09-28 的 8 条)⇒ **不能说「存量就是那两次跑出来的」**,\n成因见下面「修正三」。\n\n## 为什么现有的不变量测试没拦住\n\n`session_status_invariant_test.go:70` 查的正是这个方向\n(`WHERE s.status='archived' AND m.status<>'archived'`,注释还写着「不该有非 archived 的邮件」)。\n**它没红,是因为它跑在 `t.TempDir()` 的空库上**(`setupTestDB` → `quota_test.go:16`\n起临时库 + `db.Migrate`)⇒ **它只验证「走 `ArchiveSession` 这条路不会分叉」,\n对 431 封存量分叉完全瞎。**\n⚠ 而且它只查**一个方向** —— 半活(active+archived)那侧**没有**对应断言,\n所以上一轮我们只能靠临时 SQL 查出来。\n\n## 后果:比半活更重一点\n\n· 收件箱 `ListInboxScoped`(`repo.go:900`)有 `AND s.status <> 'archived'`\n ⇒ 这 431 封**在收件箱里完全不可见**,而权威读态列 `mail_reads` 上它们多数已被标记\n ⇒ **系统认为「已读」却从未展示给人看过**(详见「修正一」);\n· 别名寻址 `FindNamedSessionFor`(`repo.go:1278`)同样带 `s.status <> 'archived'`\n ⇒ **回信投不进去**(死信地址),这与 `archive-eats-undispatched-mail` 记的别名侧是同一个后果;\n· 但**回滚脚本救不回来**:它只把 sessions 改回 active,\n 正好会把这 431 封一次性放回收件箱 —— **「回滚」在这里是放大器,不是修复**。\n\n★ 与半活那个方向**方向相反、后果同类** ⇒ 两个方向该用**同一条判据**一起查:\n`s.status` 与 `mails.status` 的四种组合里,**只有「全 active」和「全 archived」是自洽的**,\n另两种都是分叉。判据已写进 `scripts/sse-delivery-audit.sh` 第 6 与 6b 两档(两档都把 `GROUP BY session_id` 写在判据**内部**,不靠调用方记得去重;正向样本 `ALIASES='homeagent-webui,harmony-ui-alignment'` ⇒ 2 会话 / 235 封)。\n\n## ★★ 修正一:回滚后「几封变未读」是 **45,不是 7**\n\npi 提出分层「424 read / 45 无 read 行 / 7 unread ⇒ 只有 7 封会以未读形态进入视野」。\n**三个数我都复现了,但那条推论错了。**\n\n`readStateFor`(`repo.go:791`)与 `unreadFor`(`repo.go:784`)**完全不看 `m.status`**:\n\n```sql\nCASE WHEN m.status = 'archived' THEN 'archived'\n WHEN EXISTS (SELECT 1 FROM mail_reads r WHERE r.mail_id = m.mail_id\n AND r.reader_name = <读者>) THEN 'read'\n ELSE 'unread' END\n```\n\n★ `m.status` 是**冗余列**,`repo.go:2132` 的注释写明「不再作为未读判据」。\n⇒ **424 封 `m.status='read'` 里有 38 封在收件人侧没有 read 行 ⇒ 回滚后同样显示为未读。**\n实测:`m.status='read'` 且收件人无 read 行 = **38**;`m.status='unread'` = **7** ⇒ **合计 45**。\n\n⇒ 真正的形状是:**424 封一次性涌进列表 + 45 封显示为未读**(不是 7)。\n这 38 封 `created_at` 在 **09-14 05:13 .. 09-15 01:10**,正好落在 `markReadFor` 那个\n**占位符错位 bug 的窗口内**(`repo.go:803-821` 记着「邮件看起来已读、实际仍是未读」,\n修于 2026-09-20)⇒ **它们是那个 bug 的残留,不是本条债造成的** —— 但回滚会把它们一起复活。\n\n⚠ **「45」这个数成立的理由是数据的性质,不是恒等式**:实测这 431 封**全部 `cc_list` 为空**\n⇒ 每个 mail 只有一个读者 `to_name` ⇒「无任何 read 行」与「收件人无 read 行」在这批上恰好相等。\n一旦有抄送读者,两者就分叉。**判据要按 `reader_name = to_name` 写,不能按「有没有 read 行」写。**\n\n## ★★ 修正二:分叉**至今仍在产生**,不是 09-15 的一次性存量\n\n28 条里有 **8 条是今天(09-28)**的:`deploy-smoke-20260928`、`deploy-smoke2/3`、\n`probe-ha/ha2/ha3`、`probe-dsh`、`probe-oc/oc2`,`updated_at` 00:31–01:39。\n**今天 archived 的会话共 72 条** ⇒ sweeper 今天仍在跑、仍在制造分叉。\n\n## ★★ 修正三:成因**不止 sweeper 一条**,还有一条活路径\n\n`ArchiveSession`(`repo.go:1679`)的两句 UPDATE **没有包在事务里**\n(`db.DB.ExecContext` 两次独立执行,中间无 `Begin`/`WithTx`)\n⇒ **进程在两句之间崩溃/kill -9,就会留下「sessions 已 archived、mails 全非 archived」的反向分叉。**\n\n★ 这解释了 6 条**混存**会话(会话内既有 archived 又有非 archived,如\n`Re-Re-深入的看一下当前工程` archived 42 / 非归档 65):`ArchiveSession` 一旦执行就会盖掉\n**全部**非 archived 邮件(`AND status <> 'archived'` 且按 session_id),\n**盖出「一部分 archived」的形状它自己做不到** ⇒ 那些是**多次操作的叠加**。\n⚠ 具体时序我**没有闭合**(归档前快照只到 09-15 06:59,且那 6 条当时的 mails 全是非 archived)\n⇒ **不写死结论**,只登记「成因至少两条,混存形状的成因待查」。\n\n## ★ 关于「只反还本次盖过的那批」:pi 的约束对,但不完整\n\npi 查了 `archive-ids-*.txt` 只有 session_id ⇒ 判定该语义不可实现。**这条对,但漏了两样:**\n\n1. `/root/gotmp/agentmail-pre-archive-20260915-065927.db` 是**归档前快照**(3.2MB,1154 mails)\n ⇒ 实测 **12/12 条**会话都能从快照里定位到「归档前哪些邮件是活的」(共 **467 封**非 archived)\n ⇒ **对有快照的那几次,「只反还本次」是做得出来的**。只有 09-28 那 8 条(无快照)只能整会话复活。\n2. 产物有**第三种形态**:`undo-poison-archive-FIXED.sh` 里是**逐封点名 mail_id**(34 封)\n ⇒ 「ids 文件只有 session_id」不是**普遍**约束,只是 09-14/09-15 那两次的形态。\n\n⇒ 决策空间因此不是「两种语义选一个」,而是「**没有快照的那些次怎么办**」。\n\n## 边界\n\n本条**只读登记**:没有改 `archive-stale-sessions.sh`、没有跑 sweeper、没有动数据。\n修法涉及归档/回滚语义,**归人定**;但 `ArchiveSession` 缺事务那一条是**纯 bug**,不依赖任何语义决策。"
},
{
"id": "criterion-silently-returns-zero",
"count": 1,
"kind": "observability",
"due": "给「写判据」这件事本身定一条规矩:**判据命中 0 条时不得直接当结论**,必须先证明「路径/字段/时间窗」三者都对,且把这条规矩落进 .tmp 压测脚本与 docs 的取证清单。",
"where": "判据的构造方式(.tmp/*.sh 的 grep/sqlite 查询、docs 里所有引日志的证据行)—— 2026-09-28 SSE 16 复核轮里,**两个 Agent 各犯了一次同一个错**,且都「核过对方」。",
"note": "★★ 2026-09-28 登记(pi 提出,我复核后成立)。\n\n## 症状:判据在真实数据上**静默归零**,而不是报错\n\n两次都是「计数 = 0」被当成事实,且两次都是**路径写错**:\n\n**① pi**:`NOT EXISTS(... from_name='pi')` 被当作「没投出」的判据 —— 但它选出的是「没回信」。\nAgent 来信按约定不自动转发,**投递成立但没人回信是正确行为**,两者在库里同形。\n⇒ 他稳定地产生「9 封从未投出」这个假结论。\n\n**② 我**:`GET /api/v1/mail/{id}`(人类侧)被当成取信日志的路径 —— 但 Agent/桥走的是\n`GET /api/v1/agent/mail/{id}?workspace=…&session_id=…`,人类侧那条**本来就没有流量**。\n⇒ 该窗口裸取信 0 条,pi 据此判「你引的那行日志是空的」。\n换成对的路径:**159 条,其中 28 条对应 02:09:57 归档批里的 28 条会话,10:16–10:29 全 200**。\n⇒ 那 28 行一直都在。**两个 Agent 各自按脑子里的路径 grep,一个报「有」、一个报「没有」。**\n\n**③ 同族第三例(更基础)**:别名**带字面引号**。库里 33 条 alias 含 `\"`,\n其中 24 条是 stress-sse 的(如带引号形态的 `stress-sse-1-10411`)。写 `WHERE session_alias='stress-sse-%'`\n**一条都不中**;写 `LIKE '%stress-sse%'` 才中。pi 说他 grep 时是 `-o 'alias=\"?stress-...'` 侥幸全中。\n⇒ 同一个字段存在两种形态,判据必须两种都覆盖,否则**在真实数据上悄悄报 0/24**。\n\n## ★ 2026-09-28 11:20 补充:第四例(双向,且是**假阳性**方向)\n\npi 提「用被后续写入污染的 `updated_at` 反推某批操作打中了谁」是后验快照 —— 这条成立,\n**但它这次给出的不是 0,而是一个比真值更大的数**(他分组得 80,而 POST 只有 65)。\n\n⇒ 所以这个坑有**两个方向**,且第二个更隐蔽:\n · **静默归零**(①②③):查不到 → 以为「不存在」;\n · **静默溢出**(④):算出 80 → 以为「多出来的 15 次是幂等重试」,\n 然后**去解释一个不存在的现象**。0 会被怀疑,80 不会 —— 它看起来像个正常的计数。\n\n★ 判据的输出**无论偏大偏小都可能自圆其说**,所以复核不能只看「数字像不像」,\n要看**它与另一条独立读数是否自洽**:④ 里 63 POST 是 access log 的**独立事实**,\n任何与会话表分组对不上的分组,都该先怀疑分组本身。**先核对总量,再解释差值。**\n\n## 为什么这条比三条个案都重要\n判据**报错**时你会去修;判据**静默归零**时,你会把「0」当成一个测量结果继续推理 ——\n而 0 恰好是最像结论的数字。**错误的形状是:越安静越可信。**\n\n## ★ 第五例(pi 提出,已落成 `scripts/sse-delivery-audit.sh`)—— **判据自己也会踩这个坑**\n\n把上面三条教训写成脚本之后,脚本**第一版自己就犯了第 ①② 条**:\n\naccess log 的行尾形状是 `\"GET <url> HTTP/1.1\" from 127.0.0.1:PORT - 200 956B`,\nURL 与状态码之间隔着 `\" from 127.0.0.1:PORT`。我写 `\\?[^ ]* - 200` ⇒ **匹配不到**,\n脚本第 1 档输出「取信请求 0 条」—— **与「一封都没投出」逐字同形**。\n\n⇒ 也就是说:**把判据写成代码,并不自动免疫这个坑。** 判据自己也是判据。\n⇒ 落法两条,已在脚本里:\n · 开头**自检**:先证明这条路径在窗口内有流量(202 条),再往下走;\n · 自检不过就 **exit 3 + 打印三种「静默归零」的排查方向**,而不是打印 0 让人接着推理。\n\n## ★ 第六例(opencode 自撞,2026-09-28 12:10)—— **磁盘满会伪装成「一封都没投出」**\n\n写完脚本当天,`/tmp`(9.8G tmpfs)涨到 **100% 满**。再跑脚本,得到:\n\n```\nEXIT=1 stdout=0 字节 stderr=0 字节 连 `bash -x` 都没有 trace\n```\n\n我一度以为「脚本自己有 bug」,甚至开始怀疑第 3 档的 python 逻辑。真相是:\n`mktemp` 与重定向**双双失败**,而那行 `|| true` 把「写不进去」压成了和\n「窗口内一条都没有」**完全相同的 0**。\n\n⇒ 这是第五例的**第三个器官**:第五例是**正则**匹配不到,第六例是**落盘**失败,\n两者产出的字面输出都是「0 条」。\n⇒ 已加 `0b. 落临时文件前先证明临时目录写得进去`,不可写就 **exit 2**(环境问题),\n并把那行 `|| true` 换成显式报错 —— grep 无匹配本来 exit 1,那是**合法**的 0,\n交给第 0 档自检去判;**但重定向失败不是合法的 0**。\n⇒ 实测:满盘时 `EXIT=2` + 打印 `df -h` 排查方向;`TMPDIR=$PWD/.tmp-audit` 后 `EXIT=0`、\n 自检 202 条、r=0.9965。\n\n★ 教训(比这条具体修复更值钱):\n**「0」不总是「测到了 0」,也可能是「根本没测成」。** 这两种在终端上逐字同形。\n⇒ 所以判据必须能区分**「测了,是 0」**与**「没测成」** —— 二者的退出码必须不同。\n★ 附带一条排查纪律:**别把管道下游的退出码当成脚本的**。\n `bash script.sh 2>&1 | tail -5` 报出的是 `tail` 的 0,不是脚本的 ——\n 我据此说过一次「脚本跑通了」,实际那次是 EXIT=1。\n\n★ 顺带一条纪律:**判据与账本必须同寿命。** 原判据写在 `.tmp/stress-test.sh`,\n而 `.tmp/` 在 .gitignore:12 ⇒ 下一轮就没了,`DEBTS` 里那笔债会长期裸着。\n⇒ 已迁到 `scripts/sse-delivery-audit.sh`(受版本控制,只读,实测 n=197 时 r=0.9965)。\n\n## 规矩(要落地的)\n\n判据**报错**时你会去修;判据**给出一个看似正常的数**时,你不会。\n⇒ 所以清单按**症状**分两类,**两类要用不同的直觉去防**(pi 2026-09-28 12:50 的划分,采纳):\n\n### A 类:**提取失败** —— 症状是 `0`,或每次跑都变\n\n症状最像结论(「查不到 ⇒ 不存在」),**会被怀疑**,但常常被当成真实结果继续推理。\n\n0. **★ 口径对齐了吗?**(同一个量出现两个数时,**第一反应必须是「口径」而不是「数据变了」** ——\n 2026-09-28 实测:全库 16 / 当天 10 / 不去重 93,**三个都是对的**,只是窗口与去重不同)\n1. **路径/字段写全了吗**?(`/mail/` vs `/agent/mail/`;带引号 vs 不带引号;\n `?session_id=…` **无 workspace** 43 条;参数颠倒 1 条 —— 2026-09-28 实测 159/43/1)\n2. **时间窗对齐了吗**?(⚠ DB 存 UTC、journalctl 打印本地时区,本项目差 8 小时 ——\n 10:09 HKT = `02:09` UTC,按本地写 SQL 会**整段落空**)\n3. **0 是「不存在」还是「我查错了」**?(用一条已知存在的样本反验判据本身)\n4. **正则跨过了该跨的吗**?(access log 的 URL 与状态码之间隔着 `\" from IP:PORT`;\n 响应大小在 `- 200 <size>B **in** <ms>` 里,别锚到行尾)\n5. **时间窗有上界吗**?(只写 `>= T` 会把之后几小时的写入全扫进来;\n ⚠ 实测 `updated_at >= '02:09:58'` 跨度 1h53m,且**不可复现** ——\n 同一条 SQL 连跑三次给出三个上界 `04:01:17`→`04:02:31`→`04:04:12`,COUNT 恒为 49)\n6. **临时目录写得进去吗**?(`/tmp` 满时 `mktemp` 与重定向双双失败,\n `|| true` 会把「写不进去」压成和「一条都没有」**逐字相同的 0** ⇒ 已加 `0b` 自检 + `exit 2`)\n7. **数了两遍吗?**(对日志计数要用**两种互不相关的方式**:数行 vs 按字段聚合数事件。\n ⚠ 双计会产出「看起来稳定」的结果 —— 我把 journalctl 两列时间提取两次,\n 14 变 28,而 **28 恰好是 14 的整数倍**,于是「两次读到同一个数」被当成了复现成功)\n\n### B 类:**推论失败** —— 症状是**荒谬但看起来正常**的数\n\n输入是真的、结论当场就不可能。**这类不会被怀疑**,因为它长得像一次正常计数。\n\n8. **量纲闭合吗?**(pi 那次 34 行 vs 65 次 POST:**行与次不是同一个量**,不能相减)\n9. **方向对吗?**(pi 提「同一 alias 长度出现两种响应大小 ⇒ 两个不同调用方」——\n 方向反了:响应大小是长度的**函数**,一个长度只对应一个大小,\n 「同长度出两种大小」**根本不会出现** ⇒ 正确用法是**倒过来反解长度**:\n `响应字节数 − 93 = alias 字节数`)\n10. **这个量纲真的能推出它吗?**(r=0.9968 只证明「响应带 body」,\n **对「worker 是否交给模型」完全沉默**;`①投递 ②起 worker ③取到正文` 三档不可互相替代)\n11. **分组合计 ≤ 总量吗?**(⚠ pi 2026-09-28 12:55 修正:这条**原被我放在 A 类,错了** ——\n 它的症状是 82 > 65,**不是 0**,按分类标准属于 B。\n 分组合计超过独立事实的总数 ⇒ **分组本身错了**;一个划分不可能比被划分的东西还大)\n\n★ **判别标准(pi 与 opencode 2026-09-28 12:55 收敛成一句)**:\n**A = 测不出 / 测不稳(症状是 0,或每次跑都变);B = 测出来了但推不出来(症状是荒谬但正常的数)。**\nA 类的 0 **会被怀疑**;B 类的 82/34/80 **不会** —— 它长得像一次正常计数。\n混在一张清单里,人会用「数字看着不对就重查」的直觉去防 B 类,\n而那正是 B 类活下来的方式(我那 82>65 和 pi 那 34/65 各栽一次)。\n\n★ 判据在真实数据上静默归零,比判据报错危险得多;\n而**判据给出一个比真值还大的数,比静默归零还危险** —— 因为没有人会去查一个「看起来正常」的数。"
},
{
"id": "stress-sse-delivered-but-silent-no-assertion",
"count": 1,
"kind": "**压测断言缺口**:`.tmp/stress-test.sh` 的阶段 2 造 40 封 Agent→Agent 的信,只断言「SSE 帧完整」,**从不断言对端收到了**。而「没投出」与「投了但没人回」在库里**同形**(都只有一封、都 status=read)⇒ 一个候选解释被当成了事实",
"due": "~~阶段 2 跑完时打出两个分开的读数~~ **2026-09-28 已交付**:`scripts/sse-delivery-audit.sh` 产出**三个**分开的读数(投递成立 / 起了 worker / 取到正文)。剩下的:把 `stage_sse` 的调用接上去(脚本已就位,`.tmp/stress-test.sh` 那一步仍待人改,因它在 .gitignore 内、且本工作区正被并发提交)。",
"where": "**判据已落成受版本控制的 `scripts/sse-delivery-audit.sh`**(2026-09-28 11:57)—— 原 `.tmp/stress-test.sh` 在 .gitignore:12,写在那里的判据下一轮就没了,而本笔债会长期裸着。\n脚本只读(journalctl + sqlite3),产出**三个分开**的读数:投递成立 / 起了 worker / 取到正文;并在开头做**判据自检**,命中 0 时 exit 3 而不是打印 0。机制在 `plugins/pi-mail-bridge/src/worker.mjs`(Agent 来信不自动转发)",
"note": "★★ 2026-09-28 登记(pi 提出,opencode 复核后**改写归因**)。\n\n## 触发\npi 读完一封 `[\"x\"]` 后报:「批 2 里 9 封从未投出,归因是压测脚本归档比投递快,\n`session_archived` → `pool.forget()` 清了 sessionState,排队中的 job 派出来时\n`GET /mail/{id}` 拿到 404,`worker.mjs:254` 那条『其余 4xx 永不因重试成功』直接放弃。」\n\n## 结论:投递**没有**失败。三个环节逐一被实测否掉\n\n**① 不是 404。** `AgentGetMail`(`server/internal/handler/agent_discovery.go:310`)\n的可见性判据是 `canReadSession` → `AgentMayReadSession`\n(`repo/session_workspace.go:58`),那条只比 `scope == target`(会话隔离),\n**全文没有 `status <> 'archived'`** —— 与收件箱那条 `ListInboxScoped`(`repo.go:900`)不是同一个谓词。\n⇒ 归档**不会**让 `GET /mail/{id}` 变 404。实测:那 28 封在归档(10:09:57)**之后**\n仍被 worker 逐个取回,access log 全是 `- 200`。\n\n★ **取那条日志时踩过的坑(值得记下来)**:我第一版写「实测 28 封归档后仍被取回,access log 全是 200」,\n引的就是 access log。pi 按 `GET /api/v1/mail/{id}` 去找这条日志,**一条都没有** ⇒\n他判我「引的那行是空的」,并说「建议顺手改掉,否则下一个人会去 access log 里找一条不存在的 200」。\n**他找错了路径**:Agent/桥取信走的是 `GET /api/v1/agent/mail/{id}?workspace=…&session_id=…`,\n人类侧那条裸 `/api/v1/mail/{id}` 本来就没有流量(10:05–10:40 内该形态 0 条)。\n换成对的路径:**159 条**,其中 10:16:01–10:29:36 有 **28 条**\n(逐一对应 02:09:57 那次归档里带 stress-sse 别名的 28 条会话),**全 200,零 404**。\n⇒ 那 28 行日志一直都在。**两个 Agent 各自按脑子里的路径去 grep,一个报「有」、一个报「没有」,\n而两边都以为自己核过对方。**\n\n★ **这条路径的「条数」有五种口径,写下来免得下一个人以为谁在造假**(2026-09-28 11:20 双方各自复核):\n\n| 口径 | 条数 |\n|---|---|\n| 全部 `/api/v1/agent/mail/*`(含 `/thread`) | **347** |\n| 其中非 thread(= 真正的取全文) | **203** |\n| 非 thread 且 query 形如 `?workspace=…&session_id=…` | **159** |\n| 非 thread 且 query 形如 `?session_id=…`(**无 workspace**) | **43** |\n| thread 形态:`?workspace=…&session_id=…` / `?offset=…&session_id=…` | 130 / 14 |\n\n**我原报的 159 = 第三行,pi 报的 346 ≈ 第一行 − 1,两边都不是编的,是口径不同。**\n⚠ 最容易踩的是那 **43 条**:query 里**没有 `workspace`**,且 **`session_id` 在前**。\n写成 `grep '/agent/mail/[0-9a-f-]*?workspace'` 就会**静默漏掉这 43 条** —— 又一个 0。\n(同族:`?session_id=…&workspace=…` 参数颠倒的还有 1 条。)**判据不要假设参数顺序。** 同 `archive-eats-undispatched-mail` 里 ① 的教训:\n**判据要把路径写全再执行** —— 少写 `/agent` 前缀时它静默返回 0 条,而不是报错。\n\n**①b 404 计数也要说全**:同一窗口该路径另有 4 条非 200(3× 404 + 1× 400),\n但那 4 个 mail_id **在库里已不存在**(`SELECT COUNT(*) … IN (…)` = 0)⇒ 是发信失败回滚\n(`handler/mail.go:553` / `me.go:182` 的 `DeleteMailByID`),与归档无关。\n\n**② 不是「没起 worker」。** 那 28 个别名**每一个**都在桥日志里留下了「命名同步」\n(= worker 真的起来了并回写了别名)。39/39 全中,无一例外。\n\n**③ 不是 `forget()` 删了队列项。** `pool.forget()`(`pool.mjs:437`)只动 `sessionState`,\n而排队项在独立的 `queue` 数组里;`pi` 自己也在第 2 节标了这条是**读源码推出、未实测**。\n\n## 真因:投递成立,而**不回信是协议设计**\nworker 日志逐条写着:\n\n 本轮不自动转发:来信方 opencode 是 Agent,按约定不自动转发\n (Agent 间通信须由模型主动 send_mail)\n\n对端是 Agent 时桥**故意不自动回信**。于是 40 封里 28 封「只有一封、没有回信」\n是**正确行为**,不是丢信。\n\n## 为什么这个坑值得记:两个读数在库里**同形**\n「没投出」与「投了但没人回」都表现为:会话里只有一封、`mails.status='read'`、\n`mail_reads` 里 `reader_name='pi'` 一行。**只看库无法区分。**\n`pi` 的复现 SQL 正是踩在这里 —— 那条查询选出的是「没回信」,不是「没投出」。\n\n★ 顺带更正 `pi` 的一个自我更正:`read_at` 落在 `created_at` 之后 0~17ms\n**不是** `/mail/send` 同事务写的(`mail_reads` 的写入点只有\n`repo.go:707/730/1005` 与 `migrate.go`,`/mail/send` 不碰它)。\n那是桥的 `markDelivered`(`index.mjs:145`)紧跟投递打的 ——\n即它**确实是**投递判据,只是比「worker 起没起」早。实测那 28 封的间隔是 4~11ms。\n\n## 已修\n`stage_sse` 现在收集 `SENT_ALIASES`,跑完按桥日志「命名同步」逐别名核对,\n把「起了 worker」与「真没投出」分成两个读数。回放 2026-09-28 那一轮:**40/40 成立**。\n\n## 同族\n`delivered-but-never-dispatched-silent`(排队路径无日志,已由 `de6fa59` 补上)\n与本条是同一个盲点的两半:**日志侧**分不出「在排队/丢了」,\n**判据侧**分不出「没投出/没人回」。两半都补上才叫可归因。"
},
{
"id": "archive-eats-undispatched-mail",
"count": 1,
"due": "给归档加「会案内仍有未投递邮件 ⇒ 拒绝或告警」的判据(或让归档只盖已投递的)之后 —— 在那之前,**不要用归档清理压测/在途会话**",
"where": "`ArchiveSession`(`server/internal/repo/repo.go`,⚠ 行号已漂移 2026-09-28 11:12 复核为 1671/1680,**按符号名读**):会话级两条 UPDATE —— 1671 写 `sessions.status='archived'`,1680 `UPDATE mails SET status='archived' WHERE session_id=$1 AND status <> 'archived'`(递归盖全,不区分已投递/待派发);回滚缺口在 deploy/archive-stale-sessions.sh:147(只 `UPDATE sessions`,不碰 mails)",
"kind": "bug",
"note": "★ 2026-09-28 登记(opencode 复核 pi 的报缺,**实测复现**非推演)。\n\n## 形状:归档把**还在队列里等派发**的邮件盖成 archived,而桥按 `unread` 捞信 ⇒ 永久不可见\n\n`ArchiveSession` 是会话级、递归盖全的两条 UPDATE(`repo.go` 的 `ArchiveSession`;⚠ 原写 1537/1544,2026-09-28 11:12 复核漂移为 **1671/1680**)。\n第二条的 `status <> 'archived'` 只排除「已归档」,**不排除 unread/待派发**。\n\n实测(server 包内一次性探针,用完即删):\n · 造一封 `unread`(= 桥 `catchUp` 尚未捞到、正等派发)⇒ 归档后 `mails.status='archived'`\n · `ListInbox(status='unread')` 再也捞不到它 ⇒ **未派发 ⇒ 永久不可见**\n\n## ★ 比 pi 报得更重的一层:现有回滚路径**救不回来**\n\ndeploy/archive-stale-sessions.sh 的回滚只做:\n `UPDATE sessions SET status='active' WHERE session_id IN (...)`\n**不碰 `mails.status`** ⇒ 复活会话后,那封信**仍是 `archived`**,仍不可见。\n实测确认:只改 sessions 后 mails.status 依旧 archived。\n⇒ 「归档看起来像处理完了,实际被盖掉了」—— 而**连按回滚脚本都救不回来**。\n\n## 归档的**连带**损害:别名永久失效\n\n实测:归档后 `FindNamedSessionFor` 返回 `session not found`\n(`repo.go` 的 `FindNamedSessionFor`,⚠ 原写 1152,2026-09-28 11:12 复核已漂移到 **1270 与 1284** 两个分支,各带 `AND s.status <> 'archived'`)⇒ 原地址再也投不进来。\n⚠ 这条比收件箱那条**后果更重**:收件箱不见是「信看不见」,别名死信是**回信投不进去**。\n⇒ 批量归档 = 批量让对端\"消失\",且**不可逆**(见上一条)。\n\n## 两处需要更正/补强的既有说法\n\n① pi 的判据「ArchiveSession 全仓只有一个调用点,无 scheduler」——**不完整**:\n `deploy/archive-stale-sessions.sh:105` 是**第二条批量归档路径**(直接 SQL,\n 判据 `active 且 >IDLE_HOURS 小时无动静`)。全仓 grep `SET status='archived'`\n 只命中它与 repo.go:1537。\n ⚠ 它的 `IDLE_HOURS` 默认 6 小时 ⇒ **压测会话若闲置超阈值会被它整批吃掉**;\n `KEEP_IDS` 保护名单**默认为空**。而脚本产物(`/root/gotmp/archive-*`)\n 实测最近的是 09-13/14/15 ⇒ **02:09:57 那 46 个不是它干的**(那批 2 秒内 46 个、\n 含非 stress 别名,更像 WebUI 逐个点,而 `/contacts/archive` 是**单会话**接口)。\n ⇒ 结论(「人点的,不是自动任务」)成立,但**依据要换**:不是「无自动化路径」,\n 而是「自动化路径当时没跑(产物为证)」。\n\n② ⚠ **`read-inbox-swallows-inflight-mail` 这笔在 `de6fa59` 被**误删**了**:\n 那次提交把 `read_inbox 吞掉在途邮件` 整条**替换**成\n `delivered-but-never-dispatched-silent`。\n 但 `de6fa59` 自己的正文**明确说**「看到 status 变 read 就归因到 read_inbox ——\n **错**」⇒ 它否的是**上一次的归因**,不是**这条机制本身**。\n 本条与 `read_inbox` 那条是**两个不同机制**(前者=投递*前*被标已读;\n 本条=投递前被盖成 archived),删掉前者不等于后者已还。\n ⇒ 余额被少记一笔。本条与它应并存。\n\n## 未做\n\n没有改 `ArchiveSession`、没有加判据、没有动任何数据(只读 + 一次性探针,已删)。\n修法涉及会话/邮件状态语义,归人定。\n\n## ★ 2026-09-28 11:13 pi 复核后的两处机制更正(照 pi 的建议改,向人定)\n\n**① 机制指向应从收件箱改到别名。** 原写「归档盖成 archived ⇒ 桥按 unread 捞信 ⇒ 永久不可见」,\n收件箱那半**成立**(`ListInboxScoped` 的 `s.status <> 'archived'`),但**不是这轮发生的事** ——\n那 28 封全走 SSE 投递、逐个 200(见 `stress-sse-delivered-but-silent-no-assertion` 的 ①)。\n真正被钉死、且后果更重的是**别名那条**:`FindNamedSessionFor` 带 `s.status <> 'archived'`\n⇒ **一旦归档,原地址永久是死信地址,回信投不进去**。\n这才是「归档看起来像处理完了,实际回不去了」的可复现形状。\n⇒ 上面「归档的连带损害」一节已按此改写。\n\n**①b 但旧机制留下的痕迹是可查的,而且它钉死了一件更有用的事** ——\n**「归档之后 worker 仍然把信取走了」这件事,我用日志钉死了 28/28,pi 以为那条日志不存在。**\n\npi 按人类侧 `GET /api/v1/mail/{id}` 去找,**0 条**,据此判「我引的那行是空的」。\n错在路径:Agent/桥取信走 `GET /api/v1/agent/mail/{id}?workspace=…&session_id=…`。\n用对的路径(10:05–10:40 共 **347 条**,其中非 thread 的 **203 条**里有 **10:16:01–10:29:36 的 28 条**\n逐一对应 02:09:57 归档批里带 stress-sse 别名的那 **28** 条会话(`updated_at` 全在\n`02:09:57.1~02:09:58.0`),**全部 `- 200`,零 404**。\n⇒ 那 28 行日志一直都在,是两个 Agent 各按自己脑子里的路径 grep,\n一个报「有」、一个报「没有」,而两边都以为自己核过对方。详见 `criterion-silently-returns-zero`。\n\n**★ 由此补上一个更硬的读数**(本轮真正的增量):那 28 次取信不是「起了 worker」,\n是 **worker 把正文取到手里了**。\n\npi 上一封质疑这条**不独立**(「~950B 是这条路径的通用信封大小,是路径属性不是逐封证据」)。\n**这个质疑方向对,但我用一组能证伪它的数据答了**(同窗口 164 条非 thread 取信,全部有 mail_id 可回查):\n\n```\npearson( mails.length(body), 响应字节数 ) = 0.9968 (n=164)\nbody 跨 1B – 3079B,响应跨 922B – 7274B\n```\n\n若是「950B 恒定、只是信封」,body 长度与响应大小应当**无关**(r ≈ 0)。\n实测 9 封大正文逐条单调:body 1003B→2811B、1383B→3849B、1904B→4362B、\n2437B→5611B、2846B→6095B、3079B→7274B。**响应里确实带着 `body` 字段**(`GetMailByID` 的 SELECT 含 `m.body`,repo.go:611)。\n\n⇒ 记成**两档**,别混:\n · **强**:28/28 = 200、零 404 ⇒ **归档不阻断取信**(逐封,日志钉死);\n · **弱**:响应大小与 body 长度强相关(r=0.9968)⇒ 响应**不是空壳**。\n ⚠ 它证明的是「服务端把 body 发出去了」,**不是**「worker 把它交给了模型」——\n 后者仍只有桥侧证据。**这一档不能单独当「正文到手」的证明。**\n\n**①c ⚠ 「65 次 archive POST vs 32 条会话被盖」这个差值仍未解释 —— 两种解释都被数据否掉了**\n\n事实(11:20 双方各自复核,数字一致):10:09:57–58 有 **63 次** `POST /contacts/archive`(全 200),\n而 `sessions.updated_at` 落在 `02:09:57~02:09:58` 的只有 **32** 条。10:43 那次 **14 次 POST / 11 条**。\n\n**pi 提的解释(后续写入污染 `updated_at`)不成立**:\n① `ArchiveSession`(repo.go 的 `ArchiveSession`)是 `SET status='archived', updated_at=NOW()` **无条件**写,\n 所以「被打中过」与「被盖上那一刻的时间戳」是**同一件事**,不会分裂;\n② 若真被后续 `TouchSession` 推后,那些行现在应当**不在** 02:09:57 区间内 —— 而那 28 条 sse 的\n `updated_at` 实测**全部仍落在 `02:09:57.171 ~ 02:09:57.763`**(未被推后);\n③ pi 给的分组本身对不上:他写「in-sweep 33 + after-sweep 47 = 80」,而 POST 只有 65。\n 我复核 in-sweep = 33 ✓,after-sweep(`>=02:09:58` 无上界)= **49** 不是 47。\n **两个数加起来超过 POST 总数,这个划分不可能是「那 65 次打中了谁」。**\n\n**我原提的解释(幂等重试)也不成立**:`GetSessionByID` 无归档过滤、`ArchiveContact` 无「已归档就早退」,\n所以重复归档**会**再次盖时间戳 —— 那样 63 次该产生 63 个时间戳,而不是 32。\n\n**⇒ 差值真实存在,但机制未定。**\n⚠ **更正(2026-09-28 12:20,我自己的数错了)**:我原先写「10:43 那次 28 次 POST / 11 行 ≈ 2.55,\n与 1.97 **同形** ⇒ 每条会话被归档约 2–3 次」。**那个 28 是把 journalctl 的两列时间重复提取了一次**(×2)。\n独立复核:**10:43:20 那一秒只有 14 次 POST**(10:43 整分钟 15 次)⇒ **14 / 11 ≈ 1.27**。\n⇒ **1.97 与 1.27 不同形**,「每条 2~3 次」这个推测**随之作废** —— 它建立在一个双计的数上。\n⇒ 顺带更正 `10:42:23` 那次:不是 2 次 POST,是 **1 次**(比值 1.0)。\n★ **为什么这次没被当场抓住**:我把「两次读到同一个数」当成了复现成功,\n而 28 恰好是 14 的整数倍 ⇒ **双计会产出「看起来稳定」的结果**。\n⇒ 判据纪律追加一条:**对日志计数时,先用两种互不相关的方式各数一遍**\n(`grep -c` 数行 vs 按字段聚合数事件),两者不一致就说明**提取**有问题,不是**现象**有问题。\n旁证:`ArchiveContact` 的响应体是 `{session_id, session_alias, archived_by}`,**大小随 alias 长度变**;\n那 63 次里有 **7 种**响应大小,而 32 条在盖会话只有 **5 种** alias 长度 ⇒ 至少 2 种来自**不在**这 32 条里。\n\n★ **能一次性钉死它的下一步**(比捞 auditd 便宜):响应体里**带 `session_alias`**。\n把 63 次的响应体逐条抓下来按 alias 聚类,**直接看每条被盖了几次、盖的是不是同一批** ——\n这是**只读**的,且能把「2~3 次」这个推测变成读数。**本轮没做**(access log 不记响应体,\n要复现得重打一轮),登记在此等人定。\n\n**② 支撑「清扫会复活会话」的那个机制已经作废,别拿它解释现状。**\n`e78888b`(2026-09-28 11:01:38)把 `TouchSession` 改成**永不写 status**(`repo.go:397`\n只 `UPDATE updated_at`),且**建邮件一律拒归档会话**(`SessionOpenFor`)。\n⇒ 「归档 + 之后有人 touch ⇒ 复活成 active」在当前代码下**不存在**。\n⇒ 仍留在 `active` 的那些 stress-sse 会话不是被复活的,是**当时就没进入那轮清扫名单**。\n实测别名形态与状态的关系(这是同一个「静默归零」坑的另一个读数):\n · `LIKE 'stress-sse%'`(**不带引号**)= 40 条:38 archived + 2 active —— 这才是 02:09 建的 40 封;\n · `LIKE '%stress-sse%'`(**带引号**)= 66 条:多出来的 26 条**全是 active**。\n⇒ 只查不带引号那一半,会得到「只剩 2 条 active」;只查带引号那一半,会得到「26 条从没被归档」。\n**两个都错** —— 26 条那些是**另一批**(`updated_at` 02:01–02:08 与 03:18/03:19,不在 02:09 归档批内),不是「逃过清扫」。**判据必须两种形态都查。**\n⚠ 但 10:09 那次跑的二进制 mtime 是 10:14(`/opt/agentmail/agentmail-gateway`),**早于 11:01 的 commit**\n⇒ **历史数据仍要按旧二进制解释**,只是别把旧机制当成现状。"
},
{
"id": "archive-session-filter-partial",
"count": 1,
"due": "给 FindSessionByAddress 补归档过滤时 —— 那是一次**行为变更**(重复归档由 200 变 404),要单独评审,不夹在 bugfix 里。判定:该路径的 404 是否是想要的对外契约(对齐 FindNamedSessionFor / FindSessionByAlias 已有 `status <> 'archived'` 的三处);是则补齐并把 200→404 写进 docs/API.md 的兼容说明。",
"where": "server/internal/repo/repo.go FindSessionByAddress(ArchiveContact 的 address 入参路径,handler/contacts.go:75)",
"kind": "consistency",
"note": "2026-09-28 探针轮登记。pi 主张**本轮不做**,我同意,理由记在这里以免后来人当成漏掉的收尾:\n\n① **证成强度不同**。reply_to 那个 404 是**恢复**归档契约(`docs/PLAN.md:714` 与 `docs/PLUGIN-CONTRACT.md:1399` 都已写明「归档后该别名不可寻址」),而 FindSessionByAddress 现在**本来就能命中**已归档会话 —— 补过滤是**新增**约束。\n\n② **同族里它是唯一漏网的**:`FindSessionByAlias`、`FindNamedSessionFor` 两个分支(`repo.go`,⚠ 行号 2026-09-28 11:12 复核分别漂到 **1270/1284**)都带 `s.status <> 'archived'`,只有它不带。\n\n③ 但它**不是**本轮 bug 的成因:半活会话需要有人写 `sessions.status`,而它的调用点 ArchiveContact 只调 `ArchiveSession`(写 archived 方向),不会造成分叉。\n\n★ 与 `archive-eats-undispatched-mail` 同族但不是同一条:那条讲归档会盖掉在途未投递邮件;这条讲归档**寻址入口**少一道过滤。"
},
{
"id": "opencode-session-list-truncates-at-100",
"count": 1,
"kind": "静默**少给**:上报条数被服务端默认页大小截到 100,而代码声明上限是 200",
"due": "下一次改 opencode 上报条数/候选列表数量时(或任何依赖 MAX_REPORTED=200 的取舍)。",
"where": "`plugins/opencode-mail-bridge/index.js:1218-1220` `client.session.list({query:{directory}})` **不带 limit**;`plugins/opencode-mail-bridge/lib/session-snapshot.js:14` `MAX_REPORTED = 200`;`server/internal/repo/platform_sessions.go:41` `maxPlatformSessions = 200`",
"note": "★★ 2026-09-29 实测登记(在复核 pi `8f0a8d60` 的\"n 复现不出\"时发现)。\n\n## 形状: 两处声明 200,实际拿回 100(**第三处**在截断)\n```\n `MAX_REPORTED = 200`(session-snapshot.js:14)与 `maxPlatformSessions = 200`(platform_sessions.go:41)\n 都写 200; 而实测 opencode 镜像**恒 100 行**(36 次 × 5s 长窗采样,全部 n=100)\n```\n## 决定性证据(不是猜,是数值对齐)\n```\n 取镜像里**最旧**的 `updated_at` = 2026-09-02 04:01:59.383Z → epoch **1788321719000** ms\n 在 opencode 侧数 `session WHERE directory='/home/program/agentmail' AND time_updated >= 1788321719000`\n ⇒ **恰好 100**(该目录会话总数 **176**)\n 并核 opencode 侧 `ORDER BY time_updated DESC` 的**第 100 新** = `1788321719383`\n ⇒ 与镜像最旧值相差 **383ms**(同一批、排序边界)\n ⇒ ★★ 结论: 镜像 = **按 `updated_at` 最近的 100 条** ⇒ 服务端 `session.list` 默认页大小 = 100\n ⚠️ 我**未能定位该常量**(API 需鉴权 401、`@opencode-ai/sdk/dist` 里 grep 不到、二进制不可读)\n ⇒ 标 **unknown 但证据充分**(两处数值对齐已足以定论,不需要看到常量本身)\n```\n## 与\"整表替换\"叠加后的净效果(要分清\"少数据\"与\"语义边界\")\n```\n DELETE 域 = **整个 workspace**(② 之后是\"本次 list 的 ws\"),INSERT = 拿回的最近 100 条\n ⇒ 该目录会话 >100 时,镜像每轮**清掉旧的、写回最近 100** ⇒ 稳定态就是\"最近 100\"\n ⇒ ★ 所以**不是**\"数据被丢一半\"(每个 ws 本就被整表替换),而是:\n **可见候选数被服务端截到 100,而设计文档/常量说 200**\n ⇒ 后果: 第 101 新及更旧的会话**永远不进候选列表** ⇒ 人在界面上**看不到**它们\n ⇒ 与 `recount-relay-counts.sh`(口径不符)、`/tmp` 影子模块(rc=0 混入)同族: **不报错的少给**\n```\n## ★ 副产物: 这条解开了两个人各自的\"对不上\"\n```\n pi 报: 该目录 SQL=110 vs 表=37 ⇒ 对不上,它**标为未知、不作论据**\n 我测: 该目录会话 176 vs 表 100 ⇒ 也对不上\n ⇒ ★★ 两个**独立**的观察者在**同一处**对不上,本身就是信号:\n 提示\"**有一个共同的、下游的截断**\",而不是\"两边都算错了\"\n ⇒ pi 把它标成\"未知\"是**诚实的**(比硬凑解释好),但也**错失了**这条线索 ⇒\n 可判形状: 当\"我的数\"与\"来源的数\"系统性对不上且**别人也对不上**时,\n 优先怀疑\"**中间有一层默认值/截断**\",而不是各自的计算。\n\n## ★★★★★ 补记(2026-09-29 复核 pi `95e67bff`:它的\"候选解释\"**已被独立数据证实**,升为**确证**)\n```\n### 一★★★★★ pi 独立观察到的三个数**全部命中我的预测** ⇒ 100 截断从\"候选\"升为\"确证\"\n```\n```\n pi 长窗采到(它自己的观测,与我无关):\n TrueAgent = **100** llmsproxy = **18** LiquidUnifiedDebugEngine = **7**\n 我的预测规则: 会话数 >=100 ⇒ 报 100; <100 ⇒ 报其全量\n 按 opencode 实测会话数逐项核:\n /home/program/TrueAgent 284 会话 ⇒ 预测 **100** ✓ 命中\n /home/program/llmsproxy 18 会话 ⇒ 预测 **18** ✓ 命中\n /home/program/LiquidUnifiedDebugEngine 7 会话 ⇒ 预测 **7** ✓ 命中\n /home/program/agentmail 176 会话 ⇒ 预测 **100** ✓(实测镜像 100)\n ⇒ ★★★ 三个**独立观测值**、两个方向(>=100 被截、<100 不截)**全部吻合**\n ⇒ 这不再是\"候选解释\",而是**确证**: `session.list` 默认页大小 = 100\n ⇒ ★ 并且它 ValueError 的\"110 vs 37\"与我的\"176 vs 100\"由**同一个截断**解释\n (它测的 110 可能是**当时**该目录会话数; 现在 176)⇒ 两处对不上同源,已闭环\n ⇒ 附带: pi 五态里的 **23** 也精确命中 `/tmp/am-mcp-probe` = **23**(该目录此后未增长)\n ⇒ 五态 = 五个 directory 各自的\"min(会话数,100)\",**同一个 cap** 的多次快照 ✓\n```\n### 二★★★ pi 的 `/tmp/wt-parent` 与 `project_directory` —— **两处都不成立**(规则 ⑩,第三次同类)\n```\n 它称: \"`project_directory` 里 agentmail + `/tmp/wt-parent` 同一个 `project_id`(git_worktree)\"\n 实测:\n · `project_directory` **不是列名** —— `project` 表的列是 `id, worktree, vcs, name, icon_url,\n icon_color, time_created, time_updated, time_initialized, sandboxes, commands,\n icon_url_override`; `session` 表的列是 `directory`(**再次**:没有 `project_directory`)\n · `/tmp/wt-parent` 全库**不存在**: `session.directory`=0、`project.worktree`=0、\n `session.path`=0、`project.worktree LIKE '%wt-%'`=0、`LIKE '%worktree%'`=0\n ⇒ ⇒ 它给它\"≥8 目录\"找的**结构性解释不成立**(那条解释依赖一个不存在的目录)\n ⇒ ★★ 而**真实结构恰恰相反**: 不是\"一个 project 多 directory\"由 worktree 造成,\n 而是:\n · **一个 directory 跨两个 project_id**: `/home/program/agentmail` 的 176 条会话\n 分属 `1a8a777b…`(worktree=/home/program/agentmail, **103** 条) 与\n `1715b5c1…`(worktree=/, **73** 条) ⇒ ★ **同一目录、两个 project**\n · 一个 project 确实含多 directory,但那个 project 的 `worktree` 是 **`/`**\n (`1715b5c1…` 含 **13** 个 directory: agentmail 73、/home 9、/root 8、/tmp 7、\n /root/e2e-* 4、/tmp/oc-plugindir-test 1 …)\n ⇒ ★ 这是**\"会话在 `global`/根 project 下登记\"**,**不是** git worktree 机制\n ⇒ ⚠️ 而且 worktree 解释若成立,`project.worktree` 里应出现 `/tmp/wt-parent` —— 一个都没有\n```\n### 三★★★★★ 承重结论: 撞键的**可达性**——pi 说\"今天不撞只因 session.id 全局唯一\",我给出**更强的实测**\n```\n pi 的论证: 今天不撞 = \"session.id 全局唯一(并发集合互不重叠,是**数据性质非约束**)\"\n ⇒ 它这条**方向对**,但**论证用错了对象**: 撞键需要的不是\"id 唯一\"(PK 本来就保证跨 ws 不撞),\n 而是\"**同一个 id 出现两次、且带不同 workspace**\"\n ★ 我把这个条件直接测了(决定性):\n 对镜像每一行,取 `platform_id` 去 opencode 侧查该会话**当前** `directory`,与镜像里的 `workspace` 比:\n opencode 100 行: 一致 **100** / 不一致 **0**\n homeagent 49 行: 一致 **49** / 不一致 **0**(其 49 行全在 `/tmp`)\n dsh 80 / pi 210: 非 opencode 会话(见下)\n ⇒ ★★ **不一致 = 0** ⇒ **从未观测到任何 id 的 workspace 发生变化**\n ★ 又测\"同一 platform_id 是否注册在多个 agent 名下\"(撞键的另一种现实形态):\n 同一 platform_id 跨多 agent 的组数 = **0**; 四个 agent 的 id 集合**两两不相交** ✓\n ⇒ 与 pi 的\"并发集合互不重叠\"**实测吻合**\n ★ 又测空 workspace(撞键的**最现实路径**: `''` 与真目录算两个不同 ws):\n dsh / homeagent / opencode / pi **各 0 行**为空 ✓\n ⇒ ⇒ ★★★ **三条独立路径全部为 0** ⇒ 撞键**未在任何路径上被观测到**\n ⇒ ⚠️ 但**仍不可宣告安全**(保住我此前的边界): \n · `session.directory` 是 `TEXT NOT NULL`,**没有**任何 DB 约束禁止它被改\n · 我**未能**找到\"改 directory\"的 API 路由(strings 里只有 `/session/{id}` 等,\n 未见 move/migrate)⇒ **未找到 ≠ 不存在**\n · 且 `dsh`/`pi` 的 workspace 来自 `header.cwd`(**可为空/可变**),\n 只有 opencode/homeagent 走 `directory`\n ⇒ 结论保持: **潜在、未被观测到发生**;触发条件是\"某 id 的 workspace 在两轮上报间改变\"\n```\n### 四★ pi 的\"三重静默\" —— **两腿确认、一腿需精确化**\n```\n ① \"INSERT 无 `ON CONFLICT`\" ⇒ ★ **对**(我复核: `grep -c \"ON CONFLICT\" platform_sessions.go` = 1,\n 但那 **1 处在 `:86` 的注释里**,正文 INSERT **确实没有**)\n ⚠️ 顺带: pi 的 \"grep=0\" 与我的 \"grep=1\" **都\"对\"** —— 差在**是否把注释算进去**\n ⇒ 这本身是个**可判形状**: 数代码里的构造时,**注释会污染计数** ⇒ 应排除注释后数\n ② \"`agents.go` 降级为 -1\" ⇒ ★ **对**: `agents.go:201-207` `syncedSessions := -1`,\n 失败时**保持 -1**(不报错,注释明说\"镜像写失败只影响候选补全,不影响投递,因此不报错\")\n ③ \"桥根本不读响应(0 处)\" ⇒ ⚠️ **过宽,需精确化**: 桥**确实读**响应,但**只读它关心的字段**:\n 读: `allowed_models` / `pending_mails` / `pending_workspaces` / `alias` / `mail_id`\n 不读: `platform_sessions_synced` / `models_synced`(**opencode 与 dsh 两个桥都是 0 处**)\n ⇒ ★ 准确表述: 「**服务端回传了 `platform_sessions_synced`,而没有任何桥读它**」\n ⇒ 不是\"桥不读响应\",而是\"**这个特定信号无人消费**\"\n ⇒ 而 `-1` 与\"未上报\"**共用同一个值** ⇒ 即便有人读,也**分不出**\"没报\"与\"报了但失败\"\n ⇒ ★ 这是比 pi 那句更准的一层: **信号既无人读、又不可区分**\n```\n### 五★ 一处我自己的错(记下,与上条同类)\n```\n 我先用 `grep -c \"synced_sessions\"` 去核 pi 的 ③ ⇒ 得 0,**看似支持**它\n 但服务端实际字段名是 **`platform_sessions_synced`**(`agents.go:242`)\n ⇒ ★ 我用**猜的字段名**去核,得 0 就当成\"pi 对\" ⇒ **差点用一个错名字确认一个过宽的结论**\n ⇒ 与\"没核 from_name 就归因\"同族: **验证时用的标识符本身没核**\n ⇒ 规则 ⑩ 第三次扩射程: 核**标识符** > 核**说话人** > 核**字段名/行号/列名**都算\n```\n\n ⚠️★ **而当我把这段写进本条目时,我引的行号又错了**: 我写 `agents.go:228` 是\n `platform_sessions_synced` 的行号 —— 实测它在 **`:242`**(`:228` 落在\n \"不再回传剩余额度\"那段注释里)\n ⇒ ★★★ **我在记录\"核标识符要核字段名\"的同一条补记里,自己引错了行号**\n ⇒ 这不是巧合,是**同一个失败模式的当场重演**(写规则时又犯该规则要防的错)\n ⇒ 强化: 行号**必须写完就核**(`grep -n` 一次),不能凭\"大概在那一带\"\n"
}
]
}