From a6ee355089bc3ef31925283a7dc01f6b211c3ad8 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sat, 26 Sep 2026 03:53:01 +0800 Subject: [PATCH] =?UTF-8?q?docs(debt):=20=E8=A1=A5=E8=AE=B0=E4=B9=8B?= =?UTF-8?q?=E5=85=AB=20=E2=80=94=E2=80=94=20=E6=B4=BB=E5=BA=93=E5=BD=93?= =?UTF-8?q?=E5=9C=BA=E9=87=8F=E5=88=B0=E8=AF=A5=E7=BC=BA=E9=99=B7=EF=BC=88?= =?UTF-8?q?26=20=E7=9B=AE=E5=BD=95=E8=BD=AE=E6=B5=81=E4=B8=8A=E5=9C=BA/25%?= =?UTF-8?q?=20=E8=AF=BB=E5=88=B0=200=EF=BC=89=EF=BC=8C=E5=B9=B6=E6=94=B6?= =?UTF-8?q?=E7=AA=84=20pi=20=E7=9A=84=E6=8E=A2=E6=B5=8B=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ① ★★★ 首次**当场量到**(此前都是复现/推断): opencode 镜像任一瞬间只持有 1 个目录(瞬时含多 ws 的样本 = 0/200), 而它自己库里有 **26** 个 directory ⇒ 轮流出场、每次心跳整批擦掉上一个 90×1s: 0 行 24/90;非空样本出现过 9 个不同目录;变化 27 次(→0 共 7 次、非空→非空 13 次) 200×0.3s: 0 行 25%;最长连续 0 段 17 样本 ≈ 5.1s 各目录占比 agentmail 36% / am-mcp-probe 22% / 其余 1~4% ⇒ 除最常出场者外大概率读到 0 对照: pi ws=63 rows=150、dsh ws=25 rows=77(单上报者 ⇒ 上报域==替换域 ⇒ 无损) ② 范围定稿(pi 结构层,我复核成立): 充要形状 = **上报域 ⊊ 替换域** opencode 按 directory 实例化(index.js:1102-1103 / :1145-1147)而 AGENT_NAME 是单一常量(:49) pi(worker.mjs:384-387)/dsh(index.ts:413)单上报者 ⇒ 上报域==替换域 ⇒ 无此病 ⇒ (d) 适用范围写明"一个 agent 多个上报者";记已知风险"pi 若改成按目录实例化立刻得同一 bug" ③ ★★ 收窄 pi §六 的探测器: `len(list)==0 && before>0` 只覆盖两种形态之一 实测 27 次变化里 →0 仅 7 次、**非空→非空 13 次**(报 23 行的心跳也会擦掉 37 行那个目录,而它 len≠0) ⇒ 忠实于机制(域不匹配)的探测器应当**比域**: L=list 里的 ws 集合、T=表中该 agent 现有 ws 集合, **T 中不在 L 里的 ws ⇒ 即将被销毁 ⇒ WARN(附行数)** ⇒ 同时覆盖 ① `[]`(L=∅)与 ② 换成另一个目录(L={B} ⇒ T 中 A 命中) ★ 差别: pi 版量"这次报了多少",比域版量"**谁即将没有**" —— 后者才是读侧真正感到的事 校验: go test ./internal/repo/ -run Debt -count=1 ⇒ ok --- docs/DEBTS.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/DEBTS.json b/docs/DEBTS.json index ddbe40a..aae67e3 100644 --- a/docs/DEBTS.json +++ b/docs/DEBTS.json @@ -201,7 +201,7 @@ "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) **无 `_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" + "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) **无 `
_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" }, { "id": "recount-labels-must-match-predicates",