Files
MailUI4Agents/docs/DEBTS.json
JianFeeeee 30079bf003 docs(debt): ⑤′ 重建的实现约束 —— 静默丢 37 行的可达形态 + 订正 pi 两条理由
① ★★★ 陷阱1/3 成立且可达(我用真表名实测复现):
   重建若写成 DDL 里 4 条**无 BEGIN** 语句(migrate 逐条 Exec ⇒ 各自 auto-commit),
   DROP 后崩 ⇒ 次日启动 CREATE TABLE IF NOT EXISTS 建出空的新 PK 表、守卫见 PK 已新 ⇒ 跳过重建
   ⇒ 实测: agent_platform_sessions 行=0、_new 行=3 ⇒ **判据 (a)(b) 全绿而镜像为 0**
   ⇒ 判据必须加 (c) 断言无 <表>_new 残留(必需,非可选)

② ★★ pi 两条理由需订正(结论对、机制错):
   ① "SQL BEGIN 不生效" ⇒ 我实测 **BEGIN 生效**(BEGIN→DROP→ROLLBACK 表回来了;崩溃后亦完好)
      真因: 本仓 SQLite 走 SetMaxOpenConns(1)(db.go:91)⇒ 池里只有一条连接 ⇒ BEGIN 恰在同连接
      ⇒ 这是巧合不是保证(PG 分支就是 20)⇒ 用 BeginTx 对,但理由应写"正确性依赖 MaxOpenConns(1)"
   ② "实际文本无空格" ⇒ 本仓实际是 PRIMARY KEY (agent_name, platform_id) **有**空格
      失配真因是**它模式里 , 后少了空格** ⇒ 用 pragma 对,但理由应写"LIKE 依赖空白/换行/引号形态"

③ ✅ pi 自撤的"第二次 Migrate 会撞 PK"我也复核为真: 4 语句版天然幂等(run1/2/3 rc=0、行数不变)

④ ✅ 附带事实两条均成立: main.go 两次 Migrate ⇒ 重建必须幂等;
   全仓 REFERENCES agent_platform_sessions = 0 ⇒ 无需处理 FK

⑤ ④ 最终措辞: Go 层事务重建(BeginTx→新表→INSERT SELECT→DROP→RENAME→**同事务内重建索引**)
   ⇒ 与 DDL 批次前后无关 ⇒ 顺序依赖消失(收 pi 这点)
   判据: (a) 迁移路径断言 PK + (b) 断言索引存在 + (c) 断言无 <表>_new 残留

校验: go test ./internal/repo/ -run Debt -count=1 ⇒ ok
2026-09-26 02:55:04 +08:00

208 lines
61 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters

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:邮件详情页缺三块功能(改名建议 / 转发 / 预算编辑),服务端都已支持。"
],
"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```"
},
{
"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"
}
]
}