① ★ 那封信(09-26 02:06:55)的两条更正**我 12 分钟后就已全收**(`81b61fde`, 02:18:27)并自撤了对应论据
⇒ 本轮只做**核对、未改结论**。规则 ⑩ 逐条在被引文件里复核,仍成立:
`deploy/prune-test-sessions.sh:117` `DELETE … WHERE mail_id IN (SELECT …)` ⇒ **不要求 NULL** ✓ 能删已绑定行
`deploy/reset-demo.sh:81` `DELETE FROM relayed_mails;` ⇒ **全清** ✓
⇒ 「查不到痕迹 ⇒ 没删过」不成立,已在定稿多处 ✓
⇒ 结论不变: **「422 未能确证」**、`bound(T1) ∈ [556,559]`、占位释放是**非唯一**可行解释
② ★★★ 本轮唯一**新**的一格 —— pi 的 44 次采样与我 200 次**互证**(此前未并列记录):
| 量 | 我(200×0.3s+90×1s) | pi(44 次) | 判定 |
|-----------------|--------------------|--------------|------|
| "→0" 零行占比 | **25%** | **25%** | ✓ 一致 |
| 换 ws 事件 | 27 次变化 / 90s | 22 次 / 44 次| ✓ 同量级 |
| 最长零窗 | ≈ **5.1s** | 未测 | 我独有 |
| "→0 之后回升" | ★ **测不到** | **11 次** | **它独有** |
③ ⇒ ★ 重点不是"谁对"而是**两条测量粒度不同、因而互补**:
我的 0.3s×200(=60s) 与 1s×90(=90s) **无法分辨**"归零后回升"这个**子形态**
(回升快于采样间隔时,我只看到"又一次变化")
⇒ "25% 相同"不是同义反复: 两个**独立执行**在**同一量**上撞出一致 ⇒ 零窗口是真实的
⇒ "11 回升"是**我采样设计漏问**的一格(不是它多测了真相)
⇒ ★ 方法论同族: 采样率决定**能看见哪些形态**; 报"没测到"必须写"**我的粒度下测不到**",
而非"不存在"
校验: go test ./internal/repo/ -run Debt -count=1 ⇒ ok; 未改产品代码
248 lines
114 KiB
JSON
248 lines
114 KiB
JSON
{
|
||
"_": [
|
||
"欠账的**单一登记**(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:邮件详情页缺三块功能(改名建议 / 转发 / 预算编辑),服务端都已支持。"
|
||
],
|
||
"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\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"
|
||
},
|
||
{
|
||
"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` 内部的消费点在哪一层,以及正确的那一<E982A3><E4B880><EFBFBD>钩子是什么",
|
||
"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"
|
||
}
|
||
]
|
||
}
|