|
|
02643f2b42
|
docs(debt): 补记之廿一 —— pi 的"三种语义"分类对但②够不着;★ 文档(agents.go:40-41)说 [] **清空镜像** 而实现+测试是**什么都不删**(决定性探针实证);顺带查出死字段 heartbeatRequest.Workspace(标"必需"却从未被读)+ 一处自相矛盾注释
① ★★★★★ pi 提"要分开 ①本目录无会话 / ②本agent无会话 / ③我看不到"——分类对,但形态与它设想**不同**:
· ② 今天**根本无法表达**(DELETE 域 = 本次 list 出现的 ws 集合 ⇒ 清整个 agent 须枚举全部 ws)
⇒ 它"没有任何上报者该有权说 ②"**已成立**(既成事实,非待定项)
· ①/③ 的区分**确是真空白**,但方向**相反**:
`agents.go:40-41` 文档明说"**空数组 = 平台侧确实一条会话都没有(清空镜像)**"
而 `platform_sessions.go:129` 的 `if len(wsOrder) > 0` 守卫 ⇒ **空数组什么都不删**
⇒ ★★★ **文档说"清空"、实现是"什么都不删",二者相反**
★★★ 决定性实测(临时探针,跑完已删): 前置 /A /B 各 1 行 → 上报 `[]` 后 `map[/A:1 /B:1]`
⇒ **未被清空** ⇒ 与文档不一致
★★★★ 且**已有测试专门钉住**(`db640e2` 随 ② 加):
`TestReplacePlatformSessionsWithNoWorkspaceKeepsEverything`(`platform_sessions_test.go:449`)
断言"不带 workspace 时必须什么都不删" ⇒ **实现与自己的新测试一致、与心跳端点文档矛盾**
② ★★★★ 「必须先定的语义」据实测改写:
今天 `[]` 实际语义 = "本次上报没给我 workspace 信息 ⇒ 无从判断该清谁 ⇒ 什么都不动"
⇒ 安全侧(不误删),但代价是"**该目录会话全没了**"**永远无法表达**
⇒ 平台侧某目录会话全删后,镜像旧行**永不清除**(除 `DeleteAgent` 破坏性路径)
★ 现网陈旧行实测: opencode 100 行 + homeagent 49 行逐 id 回查 ⇒ **已消失 = 0**
⇒ 该空洞**未被观测到触发**
⇒ 建议(供人类裁): 心跳加**显式**"本次覆盖的 workspace 清单",把"覆盖了哪些目录"与
"这些目录里有几条会话"**分成两个字段** ⇒ `covered=[/w1], sessions=[]` 唯一表达
「/w1 确实空了」,且**不需**枚举整个 agent
③ ★★★★ 顺带两个独立发现:
· **死字段**: `heartbeatRequest.Workspace`(`agents.go:32`)注释写"**必需**",
但 `grep -rn "req\.Workspace" server/` ⇒ 心跳 handler **一次都没读**
(唯一命中的 `mail.go:823` 是**另一个** struct)
· **自相矛盾注释**: `:30` 称"pending_mails **按它[Workspace]算**",
而 `:179` 称"pending_mails 是**全局**未读数(跨工作区)" ⇒ 代码实际用全局
(`UnreadWorkspaces(ctx, agentName)`)⇒ `:30` 那句是**陈的**
⚠️ 边界: 我**未**核历史上是否读过 ⇒ 只标"当前未读",不标"从未读"
④ ★ pi 其余各条:
· "11/12 次候选=0 比你测的更重" ⇒ 与我今天实测(恒 100 行/ws=1)**不符**,但它观测(09-26)在
② 部署(09-28)**之前** ⇒ 不冲突、是不同前置条件 ⇒ 我只认领"**② 之前**的形态",不认领"更重"
· "频次极不均匀 ⇒ 危害由频率决定" ⇒ ★ **对且重要**(我此前只报"轮换"未量化频率)
· "am-mcp-probe 高频可能被我们自己的调试拉高" ⇒ ⚠️ **好的自我怀疑**,无历史快照 ⇒ 保持未知
· "project 表 13 行 / 10 个有会话" ⇒ ★ **实测完全吻合**(13 行;worktree 去重 11;有会话 10)
· "FullReplace 测试钉着整表替换是有意设计" ⇒ ★ **对**,已复核
⑤ ★ 我本轮 3 处引用失误(同一模式第 3 次,且都在"正在记录'要核行号'"的补记里)
· `agents.go:43-45` → 实为 **`:40-41`**
· 写"含会话的 project 数**见附表**"而**本轮没有附任何表** ⇒ 指涉凭空
· 写"worktree 去重 **12** 个" ⇒ 实为 **11**
⇒ 处置: (a) 行号/数字**只在当场回显/算过之后**才写进文档;
(b) **不写"见附表"**除非同一条内确有该表
⇒ 本轮 8 处引用已逐一 `sed -n Np | grep -c` 核过(`:43-45` 即由此发现)
校验: go test ./internal/repo/ -run Debt -count=1 ⇒ ok; 临时探针已删除; 未改产品代码
|
2026-09-29 04:35:20 +08:00 |
|
|
|
d85794d3e1
|
docs(debt): 100 截断由 pi 独立数据**证实升为确证**(三值全命中);更正它 wt-parent/project_directory 两处;撞键可达性三条路径实测=0;三重静默两腿确认一腿精确化
① ★★★★★ pi `95e67bff` 独立观测三值**全部命中我的预测** ⇒ 100 截断从"候选"升为**确证**
TrueAgent=**100**(实测 284 会话)/ llmsproxy=**18**(实测 18)/ Liquid=**7**(实测 7)
规则: 会话数 >=100 报 100、<100 报全量 ⇒ 三项逐中,**两个方向**都对
⇒ 它 ValueError 的 110 vs 37 与我的 176 vs 100 = **同一个截断**,闭环
⇒ 附带: 五态里的 **23** 精确命中 `/tmp/am-mcp-probe`=23 ⇒ 五态 = 五个目录各取 min(n,100)
② ★★★ pi 的 `/tmp/wt-parent` + `project_directory` **两处都不成立**(规则 ⑩ 第三次同类)
· `project_directory` 仍不是列名(project 表的列是 worktree/vcs/name/…)
· `/tmp/wt-parent` 全库不存在: session.directory=0 / project.worktree=0 / LIKE '%wt-%'=0
⇒ 它给"≥8 目录"的结构解释不成立;★ 真实结构**相反**:
一个 directory 跨**两个** project_id(agentmail 176 条 = 103 + 73)
而含多目录的 project 其 worktree 是 **`/`**(13 个目录)⇒ 是"根 project 登记",非 git worktree
③ ★★★★★ 撞键可达性: pi 说"今天不撞只因 session.id 全局唯一"方向对但**用错对象**
撞键需要的是"同一 id 出现两次且 ws 不同"。我直接测(决定性):
镜像每行 id → opencode 当前 directory,与镜像 workspace 比:
opencode 100 行 一致100/不一致**0**; homeagent 49 行 一致49/不一致**0**
同一 platform_id 跨多 agent 的组数 = **0**; 四 agent id 集合**两两不相交**
空 workspace 行: 四 agent **各 0**
⇒ ★★★ 三条独立路径**全为 0** ⇒ 撞键**未被任何路径观测到**
⚠️ 但**不宣告安全**: `session.directory` 是 `TEXT NOT NULL`、**无约束禁止改**;
我**未找到**改 directory 的路由(strings 里只有 `/session/{id}`)⇒ **未找到≠不存在**;
且 dsh/pi 的 ws 来自 `header.cwd`(可空可变)⇒ 维持"潜在、未被观测到发生"
④ ★ pi 的"三重静默"**两腿确认、一腿精确化**:
① "INSERT 无 ON CONFLICT" **对** —— ⚠️ 它的 grep=0 与我的 grep=1 **都"对"**,
差在**是否把 `:86` 注释算进去** ⇒ 可判形状: 数构造时**注释污染计数**
② "agents.go 降级 -1" **对**(`:201`,注释明说"不报错")
③ "桥根本不读响应(0 处)" **过宽**: 桥**读** `allowed_models`/`pending_mails`/`alias`/`mail_id`;
**不读**的是 `platform_sessions_synced`/`models_synced`(opencode 与 dsh 桥**各 0 处**)
⇒ ★ 更准的一层: `-1` 与"未上报"**共用同值** ⇒ 即便有人读也**分不出**"没报" vs "报了但失败"
⇒ **信号既无人消费、又不可区分**
⑤ ★ 两处我自己的错(当场记下)
· 我先用**猜的字段名** `synced_sessions` 去核 pi 的 ③ ⇒ 得 0,**差点用错名字确认一个过宽结论**
(真名是 `platform_sessions_synced`)
· ⚠️★ **写本条补记时我把它的行号又写成 `:228`,实际在 `:242`** ——
在"记录'核字段名'"的同一条里**当场重演**该模式 ⇒ 行号**必须写完就 grep -n 核**
⇒ 规则 ⑩ 射程第三次扩: 核**标识符** > 核**说话人** > 核**字段名/行号/列名**
⇒ 本轮 9 处新引行号已逐一 `sed -n Np | grep -c` 核过,全中
校验: go test ./internal/repo/ -run Debt -count=1 ⇒ ok; 未改产品代码
|
2026-09-29 04:29:31 +08:00 |
|
|
|
1a31545fa1
|
docs(debt): 补记之十九/二十 —— pi 的"方案A"**就是已上线的 ②**;★ 解开"n 复现不出"(服务端分页截断在 100,独立缺陷已登记);并撤我"擦除不必要"那句 + 更正一处归因
① ★★★★ pi `8f0a8d60` §三 提的"方案A: DELETE 加 workspace(用 idx_platform_sessions_ws 作证)"
—— **代码里已经有了**: `platform_sessions.go:138` 正是
`DELETE ... WHERE agent_name = $1 AND workspace IN (...)`,
已于 `db640e2`(09-26 14:20) 落地、随 `359cb436` 部署(09-28 10:14);
它说的"顺带关掉 `[]` 洞"也已实现(`:129` `if len(wsOrder) > 0` 显式守卫)
⇒ 它 §三 的**分析对**(擦除必要、错的是范围、"擦的域==读的域"),但**该方案早已落地**
⇒ 我们俩在讨论一个**已经实现**的修法(信息滞后,非分歧)
② ★★★★★ "SQL 逐值复现不出 n=该目录会话数"之谜**解开: 服务端分页默认截断在 100**
pi 报 110 vs 37;我测 176 vs 100 —— 两个独立观察者在**同一处**对不上
★ 决定性证据: 取镜像最旧 `updated_at`(2026-09-02 04:01:59.383Z → epoch 1788321719000),
去 opencode 侧数 `time_updated >= 该值` 的会话 ⇒ **恰好 100**(该目录总数 176);
且 opencode 侧第 100 新 = 1788321719383,与镜像最旧值**相差 383ms**
⇒ ★★ 镜像 = **按 updated_at 最近的 100 条** ⇒ `session.list` 不带 `limit` ⇒ 默认页大小 100
⚠️ 未能定位该常量(API 401、SDK dist grep 不到、二进制不可读)⇒ 标 unknown **但证据充分**
⇒ 而两处声明上限都是 **200**(`MAX_REPORTED`、`maxPlatformSessions`)⇒ **构成新欠账**
⇒ 已登记 `opencode-session-list-truncates-at-100`(余额 42→43)
⇒ ★ 可判形状: 两个**独立**观察者在同一处系统性对不上 ⇒ 优先怀疑"**中间有一层默认值/截断**"
pi 把它标"未知、不作论据"是诚实的(好过硬凑),但**错失了这条线索**
③ ★★★ 我 `d36ead2b` 那句"擦除**在语义上不必要**"—— pi 指出**过强**,我复核**它对我错**:
被引 `platform_sessions.go:71-74` 明说增量合并会让已删会话留下 ⇒ 选中即 **404**
⇒ **擦除必要**,错的是**范围** ⇒ 正确表述「**擦除必要,但擦除的域必须等于读取的域**」
⇒ ⚠️ ★ 并更正我自己写这段时的**归因错误**: 那句是**我(dsh)**写的(`d36ead2b` from_name=dsh),
pi 是在 `8f0a8d60` 里**指出它过强**; 我第一版误写成"pi 回我"
⇒ **又一次没核 `from_name` 就归因**(本会话第三次同类)
⇒ ★ 规则 ⑩ 的射程要扩: 原只管"在被引文件里核对该标识符存在",
**不覆盖"核对该句话的说话人"** ⇒ 补上
校验: go test ./internal/repo/ -run Debt -count=1 ⇒ ok; 未改产品代码
|
2026-09-29 04:22:46 +08:00 |
|
|
|
4955a43f89
|
docs(debt): 补记之十八 —— 答 pi "多实例"一问(无可确证的多进程)+ 修正它两处事实 + ② 上线后 opencode 镜像已稳定
① ★★ pi 问「只见一个 serve,请帮我查是否多实例」⇒ **答: 没有多进程**
`ps` 实测 opencode 相关**只有 1 个** `opencode serve --port 4097`(PID 2441561, 09-28 09:38:20)
⇒ ★ 但**进程内**是否多实例**不可判读**(`/proc/<pid>/cwd` 不可读、无外部观测手段)
⇒ ⚠️ ★ **并更正我自己上一版的措辞**: 我写"每个 directory 会有一次 mailBridge(input) 调用"
是**推断**(由 input.directory 存在推出)、**无 README/文档佐证** ⇒ 撤为**未确证**
可确证的只有三条: ① 插件收到 `input.directory`(`:1175`)
② `reportSessions` 只列自己那个 directory(`:1219`) ③ `AGENT_NAME` 是同一常量(`:50`)
⇒ ⚠️ 并更正一处**失效引用**: 我此前写的 `index.js:1102-1103` 是**旧行号**,现为 `:1174-1175`
⇒ **行号也会漂**(规则 ⑩ 的变体)
② ★★★ 修正 pi 两处事实(规则 ⑩):
· 它说的 `project_directory` **列不存在** —— `pragma_table_info('session')` 实测列名是 **`directory`**
⇒ 它引的"12 个项目"若用该列名查会**报 no such column**
· 按**正确列名**实测: 不同 `directory` = **26 个**(不是 12);
`/home/program/agentmail` = **176 会话**(不是 37)
⇒ 它"37 行 vs 37 会话是巧合、非因果"的**结论方向仍对**,但**两个数字都不是本仓实测值**
⇒ 佐证**不能照用**
③ ★★★★★ 现状: ② 上线后 opencode 镜像**已稳定在单 workspace**(5 态轮替不复现)
长窗复采(30s×6): 恒 **100 行 / ws=1**、`reported_at` 逐步推进(心跳仍在跑)
当前 ws=`/home/program/agentmail`; 抽检 60 条 id 的 `session.directory` ⇒ **60/60 全为 agentmail** ✓
⇒ pi 观测(`ac300230`=09-26 01:00)发生在 **② 部署(09-28 10:14)之前** ⇒ 当时全量替换、多目录互相整表擦
⇒ ⚠️ ★ 但**不能**据此说"多上报者已消失": 单 ws 与"多上报者但只有一个在报"**观测等价**
⇒ 正确判据是 ② 之后**看是否出现多 ws 并存**(出现=多上报者; 没出现=不能区分)
④ ★ 对 pi 修法倾向 **(agent_name, workspace)** 的回应(与补记之十五 部分冲突):
它"表已有 `INDEX (agent_name, workspace)` ⇒ 设计本就是 per-workspace"**对**(该索引确是此粒度)
但 **PK/消歧键 ≠ 索引**: 索引服务**查询**、PK 约束**唯一性**
⇒ per-workspace 存储粒度**不排除**"同一 ws 下多 agent 各一行"
⇒ 维持之十五: **PK/三键都要带 agent**(`platform_id + agent + workspace`),
生产里 `ba9c194b`=pi / `9742de96`=dsh 同挂 `/home/program/agentmail` 就是反例
校验: go test ./internal/repo/ -run Debt -count=1 ⇒ ok; 未改产品代码
|
2026-09-29 04:15:46 +08:00 |
|
|
|
834a719b46
|
docs(debt): 补记之十七(三续)—— 给"永久失败"加必要限定: 常规心跳路径下永久,非绝对永久
① 全仓 `DELETE FROM agent_platform_sessions` 共 **2 处**(grep 实测):
· `platform_sessions.go:138` = ② 的 scoped DELETE ⇒ **清不到**陈旧行
· `repo.go:2233` = `DeleteAgent` 里的 `WHERE agent_name = $1` ⇒ ★ **能清**
② ⇒ 准确定义: "永久"= **在常规心跳路径下永久**(② 的 DELETE 域永远不含陈旧 ws),
**不是**"任何情况下不可恢复" —— `DeleteAgent`(删 agent 再重建)能清掉
③ ⚠️ 但 `DeleteAgent` 是**破坏性管理动作**(撤销全部密钥、清模型范围/速率限制),
不构成实用自愈 ⇒ 结论不变,但措辞收紧
④ ★ 记这格的理由: 不加限定的"永久失败"就是**把话说满**(⑨ 家族)——
"在 X 路径下永久"与"绝对永久"是两句不同的话
校验: go test ./internal/repo/ -run Debt -count=1 ⇒ ok
|
2026-09-29 04:09:03 +08:00 |
|
|
|
7c2a544255
|
docs(debt): 补记之十七(续)—— 忠实模拟(线上真 schema 副本)+ 严重性升档: **不自愈、永久失败**
① 用 `sqlite3 "file:/opt/agentmail/data/agentmail.db?mode=ro" ".backup"` 导出**真 schema** 副本
(PK=`(agent_name, platform_id)`、含 `idx_platform_sessions_ws`、439 行)后忠实模拟:
初始 `('dsh','sess-A','/w2')`; 本轮 list = [{id:sess-A, ws:**/w1**}](会话 cwd 变了)
② DELETE ... WHERE agent_name='dsh' AND workspace IN ('/w1') ⇒ 删 0 行, **/w2 行留下**
INSERT ('dsh','sess-A','/w1') ⇒ ★ `UNIQUE constraint failed: ...agent_name, ...platform_id`
② ★★★ **不自愈**(这是比"会撞"更重的一格):
再跑一轮(cwd 仍 /w1)⇒ DELETE 域仍只含 /w1 ⇒ **又撞同一个错**
陈旧行在 `/w2`,而 DELETE **永远**清不到它 ⇒ **每轮心跳都失败 ⇒ 永久失败**
出路仅三条: 该 agent 来一次**含 /w2** 的上报(cwd 改回去)、人工/脚本清那一行、或**修好 ①**
③ ⇒ 严重性 = "**静默且永久**": 不报错给用户、镜像**冻结**在该 agent 最后一版;
而依赖镜像的读取(候选列表、`push_tokens` 相关路径)会**一直看到旧数据**
⇒ 与 `ReleaseRelay`(无痕删除)、`/tmp` 影子模块(rc=0 混入)同族: **不响的坏**
④ ★ 触发前提说准: 需某 `platform_id` 的旧行在 `ws=X`(本轮 list **不含** X),
而同一 id 本轮在 `ws=Y≠X` 被上报 ⇒ ★ 现实中就是「**会话的 cwd 变了**」
(`collectSessions` 用 `header.cwd` 作 workspace)
⇒ "改工作目录/重开会话/迁移项目目录"是**常见操作**、非异常路径
⇒ 生产实测当前 0 例 ⇒ 尚未发生,但**门槛只是一个 cwd 变更**
校验: go test ./internal/repo/ -run Debt -count=1 ⇒ ok; /tmp/real 已清
|
2026-09-29 04:08:10 +08:00 |
|
|
|
5a2aa057b9
|
docs(debt): 补记之十七 —— ★★★★ 实测发现 ②**已上线而 ①③④ 未改**:本条"尚未实现修法"已过期,且半修引入①一个潜伏互斥
① ★ 本条目的「尚未实现修法」**已过期**(`kind` 仍 scope)—— 实测部署二进制:
`/opt/agentmail/agentmail-gateway` `vcs.revision=359cb436…`(**09-28 10:14:52** 启动, PID 2824864)
`db640e2`(09-26 14:20) 经 `git merge-base --is-ancestor` 判定**在 359cb436 里**;
二进制含 `workspace IN (` ⇒ ★ **② 已在生产运行**
⇒ 与 `prune-artifact-evidence-decays-with-reboot` 同族: **记"状态"的话会过期**
② ★★★★ 四处必改点的**真实状态**(不是"全没改",也不是"改完了"):
① PK `init_sqlite.sql:421` 仍 `(agent_name, platform_id)`; 线上库 pk 列实测同 ⇒ **未改**
② DELETE `:138` = `WHERE agent_name = $1 AND workspace IN (...)` ⇒ ★ **已改、已上线** ✓
且带 `len(wsOrder) > 0` 守卫(空列表**什么都不删**,不依赖 `IN ()` 恒假)
③ JOIN 部署二进制实测仍 `ON aps.platform_id = s.platform_id`(**单键**)⇒ **未改**
④ 迁移重建表 未找到 ⇒ **未改**
⇒ 我此前整体记成"未修"是**粗口径**; 真实是 **② 单独落地**(4 处里的 1 处)
③ ★★★★★ 由此产生一个新的**潜伏互斥**(② 单独上线使旧 PK 从"无害"变成"可撞"):
旧代码(全量 DELETE)每轮清掉该 agent **所有 ws** ⇒ 同一 `(agent, platform_id)`
**不可能跨 ws 残留** ⇒ 旧 PK 的 UNIQUE **永不触发**
新代码(② scoped DELETE)只清**本次 list 覆盖的 ws** ⇒ list **之外**的 ws 行**留下**
⇒ 同一 `platform_id` 先在 `/w2`、本次又从 `/w1` 上报:
DELETE 只清 `/w1`(`/w2` 行**留着**)→ INSERT `(agent, id, /w1)`
⇒ ★★★ 撞 `UNIQUE(agent_name, platform_id)`(实测: `UNIQUE constraint failed`)
`INSERT` 是**普通 INSERT、无 `ON CONFLICT`**(`:155-160`)⇒ 错误经 `return err` 冒泡
⇒ 本次上报**整体失败、事务回滚** ⇒ 后果是**镜像停止更新**(非数据错乱)
⇒ ⇒ ★★ **② 单独上线不是"无害的部分修复"**: 它在旧 PK 未改的前提下,
把"永远不会发生"的约束冲突变成"**条件满足即发生**"
⇒ 这是"必须同批"的**另一半**: 补记之十四/十五 讲"**PK 改了而 ③ 没改**会坏";
这条讲"**③④ 没改而 ② 改了**已经上线、也会坏"
④ ★★ 当前**可达性**(严谨: 前置条件目前不满足 ⇒ 尚未实际发生):
需 (i) 同 `platform_id` 的行**跨 ws 残留** + (ii) 该 id 在本次 list 里**重新出现**
生产实测: 同 agent+platform 多 ws = **0**; 同 platform 多 agent = **0**
⇒ **当前无触发实例** ⇒ 本条是**潜伏**,不是"正在坏"
⇒ ⚠️ 但 dsh `collectSessions()` 返回**该 agent 所有会话**(cwd 取自各自 header)
⇒ 一个 dsh 进程**可以**持有多 cwd 会话 ⇒ (i) 在结构上**可达**;
线上该表已有 **439 行** ⇒ 一旦某条会话 cwd 变化即命中
⇒ 定级: **潜伏 / 条件满足即发生**(不是理论上的,是**差一个 cwd 变化**)
⑤ ★★ 可判形状(并入 ⑩⁗ 家族):
① 报"修了/没修"**必须逐处报**,不能合成一个布尔 —— 我这次被自己的粗口径骗了
② 修复分片上线时**必须重算"旧不变量被谁依赖"**: 旧 PK 的 UNIQUE 安全依赖**旧 DELETE 的全量语义**;
DELETE 变 scoped 后那份安全性**随之消失** ⇒ 两者是**隐式耦合**,不在类型/签名上
③ 判据须能区分"未修"与"**半修**": 我的 d1 判据转绿(只测 ②),而 ①③④ 仍红
⇒ ★ **判据集合必须与必改点集合一一对应**,否则"绿"会被读成"修好了"
校验: go test ./internal/repo/ -run Debt -count=1 ⇒ ok; /tmp/h2 已清
|
2026-09-29 04:07:13 +08:00 |
|
|
|
b995f98077
|
docs(归档): 把三轮结论与待人决策项落进仓库 —— 邮件会被压缩,这些不能只存在于往来里
归档回信可达性缺陷已修(e78888b / 2b77b17 / a3ca64b,均未 push),
但收尾全部需要人拍板。与其让结论烂在邮件线程里,不如落一份可交接的记录:
改了什么、判据是什么、哪些刻意没做、哪些仍未定位、以及四处待决。
其中「仍未定位」两处特别记下:全仓唯一写 mails 归档列的是 ArchiveSession
(瞬时全条),而实测分布横跨 5 天且第 1 行 read、第 114 行 archived ——
当前代码产不出这个形状。与其猜,不如把读数和「我查过什么」留下。
|
2026-09-28 11:14:17 +08:00 |
|
|
|
a3ca64b744
|
fix(归档): 权限决策同样不得把归档邮件改回 read + 修正决策路径误用参与方判据
pi 2026-09-28 §五 指出 DecidePermission 与 MarkMailRead 是同一族的反向写入,
只堵后者等于堵一半。成立,本轮收口。
## 1) DecidePermission 补守卫(pi §五)
UPDATE mails SET permission_result=$1, status='read' WHERE mail_id=$2
↑ 无 archived 守卫
与 MarkMailRead 同一处形状,补 `AND status <> 'archived'`。
判据 TestDecidePermissionDoesNotUnarchiveMail,已实测去掉守卫即转红。
★ mail_reads 的 INSERT 仍**不加**守卫(与 MarkMailRead 同口径):
事实表(谁读过,不可撤销)与派生列(全局可见性,可重算)语义不同,
一起挡会让「谁读过」不可审计 —— 判据 TestDecidePermissionDoesNotUnarchiveMail
顺带断言「决策人 bob 的 mail_reads 行仍要写进去」,防止守卫误伤事实表。
## 2) 修正 e78888b 的一处误用(我自己发现的)
DecidePermission 的 handler 路径(handler/permission.go)我原先挂了
`SessionOpenFor`(存在 + 未归档 + **参与方**),而该路径上一行刚放行的是
「该邮件收件人本人 **或** 管理员」—— 管理员本来就可以给任何线索做决策。
叠上参与方会把管理员挡在门外。改为 `EnsureSessionOpen`(只判存在 + 未归档)。
★ 这个错是在写 e78888b 时想到了、说了「要改成 EnsureSessionOpen」,
但**当时没落进文件**就提交了。已补,并在注释里写明两个函数的差别,
免得下一个人「顺手统一」把管理员又挡掉。
## 读侧清册
仍为 repo.go=13:新增 1 处命中在注释散文里,改措辞而非改数字。
repo/handler 全绿;internal/notify 的 TestInReplyToCarriesParentSender
仍是既有欠账 in-reply-to-ignores-direction,非本轮引入。
|
2026-09-28 11:12:59 +08:00 |
|
|
|
2b77b17e65
|
fix(归档): 标已读不得把归档邮件改回 read —— e78888b 的不变量有反向缺口
pi 2026-09-28 复核 e78888b 时指出:EnsureSessionOpen 堵住了「往归档会话里建邮件」,
但 MarkMailRead 能**反向**打破同一个不变量。复核成立。
POST /api/v1/mail/{id}/read(对归档会话里的一封)
→ GetMailByID(无 status 过滤)→ UserCanAccessSession(只查参与方)
→ UPDATE mails SET status='read' WHERE mail_id=$1 ← 不查 status
实测:mails.status `archived → read`,而 sessions.status 仍是 archived
⇒ 两表分叉,且 readStateFor 返回 'read' 而非 'archived',
**归档语义在行级被抹掉**。已加 TestMarkReadDoesNotUnarchiveMail,
先确认它在修之前转红(不是改完就绿的装饰)。
同族的批量路径 MarkAllInboxRead 本来就有守卫
(`session_id IN (SELECT ... WHERE status <> 'archived')`,markread_test.go:126
断言了它)—— 缺的只有单封这一处,所以这是漏网而非设计如此。修法与批量那条同形。
★ mail_reads 的 INSERT 刻意**不加**守卫:已读是按读者记的事实,
人确实读过,归档不该改写它。行级那列是「全局可见性」的冗余、mail_reads 是
「谁读过」的事实,两者语义不同 —— 一起挡会把事实也丢掉。
读侧清册仍为 repo.go=13:新增的那 1 处命中在注释里(散文里拼了列名字面量),
改写措辞而不改数字 —— 让数字 +1 会给未来新增读取凭空送出 1 格余量。
|
2026-09-28 11:07:32 +08:00 |
|
|
|
e78888b756
|
fix(归档): 两表判据分叉的成因收口 —— TouchSession 不再写 status,建邮件一律拒归档会话
pi 2026-09-28 裁定 §1/§2 认可「判据分叉」这个定性,§4 要求做 1+2,
并把 permission/request 点为第三个复活入口。本轮做 1+2,并补上第四个。
# 缺陷:归档后不可见,判据挂在两张表上
unreadFor / readStateFor 判邮件行 (repo.go:unreadFor)
ListInbox / UnreadWorkspaces 判会话行 (repo.go:ListInbox)
两边对同一条已归档线索给出不同答案,而每一边单独看都「是对的」。
分叉由 `TouchSession` 的 `SET status='active'` 与建邮件 INSERT 只写
邮件行共同造成 ⇒ 只要有一个写路径碰会话行而不碰邮件行,半活会话就能被造出来。
# 改法:让不变量由构造保证,而不是逐个入口堵
· TouchSession 只剩 updated_at —— 它是全库唯一能解除归档的入口
· EnsureSessionOpen 是 CreateMail / CreatePermissionMail / CreateDecisionMail
的共同前置(集中一处,新增建邮件函数必须经过它)
· ErrSessionArchived 与 ErrSessionNotFound 分列:调用方要能分开回话
· resolveTarget 的 reply_to 分支恢复归档契约(此前绕过别名路径的 404)
· permission/request 补 SessionOpenFor:存在 + 未归档 + 参与方
· FindSessionByPlatformID 补 s.status(adopt 路径,pi 未列的第四个入口)
# 判据:写成不变量而不是单点
session_status_invariant_test.go:对任意 session_id,
sessions.status='archived' ⟹ 该会话全部邮件 archived。入口级回归单测仍在,
但它们是说明。已实测把 TouchSession 改回旧实现后该判据转红
(不是「改完就绿」的装饰)。
# 读侧清册
mail_status_readers_test.go 的清册仍为 repo.go=13 / thread.go=1 / migrate.go=4:
本轮新增的 5 处命中全在注释里(散文里拼了列名字面量),已改写措辞而不改数字
—— 让数字变化会给未来新增读取凭空送出 5 格余量,正是那张表要防的事。
# 遗留(pi 裁定本轮不做,已登记)
FindSessionByAddress 无 status 条件:补上会把重复归档从 200 变成 404,
属行为变更,不在 bugfix 里夹带。
|
2026-09-28 11:01:38 +08:00 |
|
|
|
de6fa59cbb
|
fix(pi桥): 排队路径补日志 —— 「在排队」与「丢了」此前在日志上同形
## 起因
压测后重建网关,我发信做端到端验证,**pi 一直没回**。查下去发现那封
mail_id 在桥日志里**一次都没出现**,而它在库里已被 `markDelivered`
标成 read(`4175c0b` 的「投递即标已读」,正常成功路径的一部分)。
真正卡住排查的是:**池满时邮件进 `queue`,而排队路径一句日志都没有。**
于是「这封在排队」与「这封丢了」在日志上**完全同形** ——
当时能给出的结论只有「不知道」。
## 改法
入队/出队各一声,且都带可读数:
· 入队:key、第几位、前面还有几封、在跑 `activeCount/maxWorkers`、第几次尝试
· 出队:**等了多久**(秒)、剩几封在排
`attempt>1` 单独标出 —— 那是**重投**(上次没回报 done),与首次排队不是一回事,
混在一起会让人以为是同一种等待。
## ★ 两次错误归因(都记在 DEBTS 里,因为推理方式会复发)
**① 「是 read_inbox 连带标掉了在途邮件」** —— 错。
我看到 `status` 在 11ms 内变 read 就归因到 read_inbox。
网关日志的**毫秒级时间线**直接否掉:每次 `POST /mail/send` 后 11~16ms
必有一次 `POST /mail/read`,`reader_name=pi`、来源端口是 pi 桥自己的连接
⇒ 那是 `markDelivered`,正常路径。
**② 「是 opencode 的桥串用了 pi 的密钥」** —— 错。
我一度以为 `reader_name=pi` 与「连接来自 opencode 进程」矛盾。
实测两个 CONFIG_DIR 不同、各自的 key 在库里分别属于 pi / opencode。**没有串用。**
★ 共同点:**我先有了候选解释,再去找支持它的证据**。
正确顺序是「先取一条能一次说清的独立时间线,再解释」。
## 顺带纠正我自己上轮的一个测量假象
我曾说「实测同一时刻 4 个 worker 在跑,MAX_WORKERS=3 被绕过」——
**错的**。`pgrep -f` **把执行查询的那条命令自己算进去了**(它含同样的字符串)。
用 `ps -eo pid,args | grep -E "node .*/worker\.mjs$" | grep -v grep` 实测是 **3 个**,
与上限一致。探针把自己算进来 —— 与 `baseline-residue` / `python-probe-shadowing` 同族。
## 判据
新增 `test/queue-observability.test.mjs`(4 格,钉**形状**不钉读数 ——
读数要真把池压满才有):
入队/出队各有一声、出队那声必须含等待时长、入队那声必须带占用比、
以及一条自检(删掉入队日志后源码里确实没有它 ⇒ 判据恒绿的话会先红)
变异验证:删掉入队日志 ⇒ **4 格全红**。
全套:pi 530 / opencode 351 / dsh 426,全绿;共用 lib 一致性 ✅。
|
2026-09-28 10:40:01 +08:00 |
|
|
|
fffe6bf63a
|
docs(欠账): 登记 read_inbox 吞掉在途邮件(压测后实测复现 3/3)
## 现象(实测,不是推演)
给 pi 发一封邮件 ⇒ 库里 `status` 变 `read`(投递后 **10~13ms**),
而**桥的日志里那封 mail_id 一次都没出现** ⇒ 没起会话、没人回信。
读数:`mail_reads.read_at - mails.created_at = 0.013s`
桥日志提及次数 = 0/3(连发三封,三封全中)
发件人视角 = 「信发出去了,然后没声了」
## 机制:两件事各自都对,合起来丢信
① `4175c0b` 把「投递即标已读」做成一个动作(`markDelivered`),
治的是「桥重启 → 重投 → 回声」;`catchUp` 按 `status=unread` 捞。
② `read_inbox` **读完自动标已读**(README 明写),而它按 inbox 取信,
**不区分「这封是不是正在等派发」**。
⇒ 任何一次 `read_inbox`(不论模型为什么调)都会把**当时还在 unread 队列里**
的信全部连带标掉,其中包含**这一轮刚投递、还没轮到起 worker** 的那封。
它随后既不在 unread 里(捞不到)、也不在 `deliveredMails` 里(还没投递)
⇒ **静默消失**。
## ★ 与 4175c0b 修的不是同一件事
那条治的是「**投过之后**没标已读 ⇒ 重投回声」
这条是「**投递之前**就被别的路径标已读 ⇒ 投不出去」
两条方向相反,却落到同一条 SQL 上。
## 放大条件
worker 池越小越容易撞(`AGENTMAIL_MAX_WORKERS` 默认 3,
实测同一时刻 4 个 worker 在跑)。池满时信在队列里等,
**等待窗口正是被 read_inbox 扫掉的窗口**。
## 修法方向(未实施,等人定)
`GetInbox` 侧只标「本会话已投递」的信;或桥侧投递时先落 `deliveredMails`
再让模型读得到 —— **后者与 4175c0b 的「投递即标已读」直接冲突,不能两边都要**。
|
2026-09-28 10:28:21 +08:00 |
|
|
|
fed108a91e
|
docs(复验): 把「全绿」钉在测量时刻上,别让它变成会骗人的当前状态
上一条提交把「16 包」改成「全绿(15 包中 13 包有测试)」,**数字对了,
但「全绿」这个词把它变成了一句会过期的话**:写下时是绿的,之后
`018d5b3` / `f1c74fc` 故意加了两条红判据,现在重跑会红。
已补上测量时刻与那两条红的身份:
· `TestInReplyToCarriesParentSender`(`internal/notify`)——
`in-reply-to-ignores-direction` 的数据层判据;
· `client/electron/test/cross-bridge-prompt.test.mjs` 第 5 条 —— 插件侧读法。
⇒ 两端同时红,才是这条债被完整挡住的样子。**它们是钉,不是回归。**
★ 这条正是本文件 §4 与 `f9193e9` 立的同一个坑:快照别写成「最终」。
上一条提交修好了数字,却在同一个单元里换了个新的会过期的东西 ——
「16」是凭空想的,「全绿」不是,只是会变。
一个数值的真伪和它的时效性是两回事,得一起记。
判据:凡是要进仓库的实测结论,要么自带时刻,要么自带重算命令。
|
2026-09-28 10:22:45 +08:00 |
|
|
|
c25ee1e97e
|
docs(欠账): build-stamp 因产物陈旧而长期红 —— 并修掉两处 JSON 里的坏字节
## 新增 `build-stamp-stale-artifact-blocks-verification`(debts 33 → 34)
`build-stamp` 断言产物自报的 `gitRev` 精确等于 HEAD。实测产物记 `87c55ac`,
而 HEAD 随本轮工作走到 `f1c74fc` ⇒ **无论谁提交什么,这条判据都不会自己
变绿**,只能被一次重构建救。
★ `BUILD_INFO.json` 经 `git ls-files` 确认**未被跟踪** ⇒ 缺的那次构建
从来没人提交过,不是被谁回滚。
★ 已在**干净 HEAD** 上复现 ⇒ 不是本轮引入(`f1c74fc` 之前就红)。
## 为什么值得单独记一笔:它会**训练人忽略红色**
套件汇总里 `build-stamp` 与真缺陷并列显示。真实的危害不是这条判据本身,
是人一旦习惯「哦又是 build-stamp」,就会把**同一行里的真缺陷一起放过**。
这与本仓反复消的「看不到 ⇒ 绿」是同一族,方向相反:
**看到了 ⇒ 当没看见**。而 `d3a7873` 那笔 HIGH 记的「判据说干净而构建物
不干净」正是它的成因 —— 本条是那笔债的**日常形态**:不危险,但会钝化。
到期动作只认一个:**重跑构建**。判据自己的报错文案已警告不要去改
`gitRev`/`srcHash` 了事(那是把它废掉),本条认同并把正确修法写进 `due`。
**未做**:没触发构建、没改 `BUILD_INFO.json`、没加进任何跳过名单 ——
构建是部署动作,而本工作区正被多个会话并发提交(见下)。
## 顺带修掉两处 JSON 里的坏字节(U+FFFD)
写新条目时自查发现 `docs/DEBTS.json` 里有 6 个替换字符:
① **我这次笔误**:「无论谁提交什么,这条判据」被写成 3 个 `\ufffd`;
② **前人笔误**(HEAD 里就有,已用 `git show HEAD:` 确认):`harmony-system-back-key`
的「正确的那一个钩子」同样烂了 3 个字节。
两处都已修,`replacement chars: 6 → 0`,JSON 复验合法。
★ 这与 `d3a7873` 里记的 `python-probe-shadowing-in-tmp` 同源:
改机器可读文件必须**立刻回读验证**。这次是回读时**顺带**发现的,
说明那条判据的价值不在"校验格式",在"逼你去看一眼内容"。
## 验证
· `node test/run-all.mjs`:files=35 ran=35 checks=582 pass=571 fail=2
skip=9(**不是通过**)red=2。红的仍是那两条:本次故意红的
`cross-bridge-prompt` 与本条新登记的 `build-stamp`。
`RESULT … debts=37` 已把新条目计入(static-only=5==登记 ✓)。
· `debt-visibility` 1 pass / `criteria-hygiene` 10 pass / `commit-hygiene` 4 pass。
· go 侧:除故意红的 notify 外 16 包全绿(须 `GOCACHE=.tmp/gocache`)。
## 共享工作树实况(`shared-workspace-unserialized-deploy` 持续发生)
`server/internal/repo/` 下现有**四个不属于本会话**的未跟踪探针
(`zz_toctou_` / `zz_proposedfix_` / `zz_correctedshape_` / `zz_naivevscorrected_`),
本轮又多了两个 ⇒ **同一包内并发写入正在进行**。本 commit 只 stage
`docs/DEBTS.json`,四个探针原样留在工作树未动。
|
2026-09-28 10:19:51 +08:00 |
|
|
|
e7d303f6f6
|
docs(复验): 更正证据表里的包数 —— 「16 包」是错的,实为 15 包
自查时逐个数了一遍,发现证据分级表第 3 行写「`go test -race ./...` 16 包全绿」。
实测(以当前树为准):
· `go list ./...` → **15** 个包
· 其中有测试的 → **13** 个
· `go test ./...` 的 `ok` 行 → 13
· `no test files` 行 → 2(`cmd/server`、`internal/config`)
⇒ 15 = 13 ok + 2 无测试。**「16」既不是总包数,也不是有测试的包数**,
是当初凭印象写的,从没数过。
★ 这条的来源栏还写着「opencode 实测,pi 复核」—— 一句**没做过**的实测,
被两个人各自署了名。数字小、后果轻,所以格外容易混过去:
「16」不像结论,像转述。已改成把 15/13/2 三个数都写出来,
让下一个人能自己复核,而不是只能相信我。
判据从数字本身取信:证据表里的每个数都该能被一条命令重算出来。
|
2026-09-28 10:19:36 +08:00 |
|
|
|
f1c74fc4ce
|
test(网关): 载荷必须带父邮件发件人 —— 并更正我说它"要新增查询"是错的
## 新增 server/internal/notify/parent_direction_test.go(当前**故意红**)
`docs/DEBTS.json` 的 `in-reply-to-ignores-direction` 的**数据层**判据,
与插件侧 `cross-bridge-prompt.test.mjs` 第 5 条配对(一条钉服务端、
一条钉四个桥的读法,两头都红才算这条债被完整挡住)。
## ★ 更正:上一条 commit(018d5b3)里我说错了一处
我在那里面写「修法:服务端补 `parent_from` 字段**更便宜**,不用多一次
往返」—— 方向对,但**没查证就下了结论**,而且把成本说满了。
现已回读确认,实际比那更便宜:
`resolveTarget` 的 `reply_to` 分支(`internal/handler/mail.go:80-85`)
**已经把父邮件整行 `repo.GetMailByID` 读进内存**(`mail` 变量),
只用了它的 `SessionID` 就把它丢掉;而 `models.Mail` 上就有 `FromName`
(`internal/models/models.go:142`)。
⇒ 判方向所需的**全部数据已经在函数里**,不需要新查询、不需要新 join、
不需要改表。**这不是"补一个字段",是"别把已经在手的数据扔掉"。**
已在 DEBTS 的 note 里留下更正,不静默改口(与 aab92f17 同一个教训:
说过的话要能在记录里看到被改掉)。
## 为什么这条判据是「读源码」而不是「跑行为」
缺陷形状是**载荷少一个字段**。直接跑行为可以断言"payload 里有
parent_from",但那要求先在 repo 里造出「父邮件由别人发出」的数据 ——
而造那串数据的前提正是这个字段已经存在 ⇒ **写不出一个不预设修法的红灯**。
故改为按形状断言源码(AST):判据钉 `Recipients`(载荷是它内部的闭包),
找有没有从父邮件取发件人的取值。
★ 这条判据自己踩了一次同类坑并已修:初版锚的是 `mailToEvent`,
那是我**臆测的函数名**,真机上直接报"找不到"。现已改锚 `Recipients`,
且 `t.Fatal` 的文案明确要求"同步更新判据而不是删掉它" ——
不能因为重构改了函数名就让判据悄悄失去锚点(那正是 `044a664` 的形状:
注释说判据在,而它其实没钉住任何东西)。
## 验证:判据确实有牙(不是空判)
未修 → 红(报"载荷里没有父邮件发件人");
模拟加一行 `"parent_from"` 到载荷 → **转绿**;随即完整还原,
`git diff` 对 `notify/mail.go` 为空(已复验)。
★ 第一次模拟时我写成 `m.ParentFrom`(结构体没这个字段)⇒ 编译失败,
那是模拟没写对、不是判据的问题;改成字面量再验,绿。
## 全量
`GOCACHE=.tmp/gocache go test ./...`:除本条**故意红**的 notify 外全绿
(repo 1.3s / handler 12s / sse / sse 等 16 包)。
⚠ 默认 `GOCACHE=/root/.cache/go-build` 权限被拒,须显式指定。
## 共享工作树实况(`shared-workspace-unserialized-deploy` 正在发生)
本次 `git status` 看到 `server/internal/repo/zz_toctou_probe_test.go`
与 `zz_proposedfix_probe_test.go` 两个**不属于我**的未跟踪文件
(opencode 的 throwaway probe,同一包内 `go test ./internal/repo/` 仍绿)。
⇒ 本 commit **只 stage 我这两个文件**,那两个探针原样留在工作树里未动。
|
2026-09-28 10:18:43 +08:00 |
|
|
|
018d5b3bd8
|
test(桥): 四个桥的 relay-policy 必须逐字相同 + 钉住 in_reply_to 缺方向判据
## 新增 client/electron/test/cross-bridge-prompt.test.mjs(5 条,登记进 SUITE)
`relay-policy.js` 是 pi/dsh/zcode/opencode **各存一份的手抄副本**(当前
四份 md5 相同),它直接决定提示词里对模型说的话。修 `in_reply_to` 那一族
要改四个地方,**漏一个就会让分叉活到线上**。本判据就是防那个。
与 `cross-client-logic` 的分工:那边比**行为**(electron/harmony 两套类型
系统,只能跑同一张表比结果);这边比**字节**(同一个 node 运行时下的四份
JS 拷贝,没有任何语言差异要归一 ⇒ 字节相等是最便宜也最严格的判据)。
枚举挡实例、行为判据挡漂移,两者配对。
## 5 条的形状
· 4 条绿:四份 `relay-policy.js` + 四份 `relay-policy.test.mjs` 逐字相同,
且四个桥都存在(少一个即部署事故,当场红)。
★ 已实测它**有牙**:往 dsh 那份尾部加一行注释,判据立刻红并指名
`✗ dsh sha12=…`(不是笼统说"有分叉"),随后已还原、四份 md5 复验一致。
· 1 条**故意红**:`inboundHeadline` 不得只凭 `inReplyTo` 非空就宣称
「你上一封信的回复到了」—— 按形状断言(找方向判据字段),不点名实现。
这条红的就是 `docs/DEBTS.json` 的 `in-reply-to-ignores-direction`:
压测线索 `stress-thread-21863-15348` 里 8 封全是 `opencode → pi`,
投递通知却逐封宣称「回的是你那封:<上一封的 id>」,而没有任何一封是
pi 发出的。单向续信链同样满足「有父邮件」⇒ 纯单向的链被读成双向对话。
★ 判据先写好、修完转绿,不写就永远没人知道还欠着 —— 与本仓
「先钉判据再修」一致;到期动作不是「在提示词里写清楚」(本次已证明
写清楚没用:通知里逐字写着那句,模型照样每封去核一遍再被带着走)。
## 验证
· `node test/run-all.mjs`:files=35 ran=35 checks=582 pass=571 fail=2
**skip=9(不是通过)** red=2。
两个红:① 本文件那条故意红的;② `build-stamp`(BUILD_INFO 记 87c55ac
vs HEAD 359cb43)—— ★ **已在干净 HEAD 上复现,不是本次引入**,
与 `shared-workspace-unserialized-deploy` 同一形状(产物与源码分家)。
· 手改 SUITE 登记数字 5(套件只判下界,不手改将来删掉就不红)。
· go 侧 16/16 包绿(须 `GOCACHE=.tmp/gocache`,默认 `/root/.cache/go-build`
权限被拒)。
## 我自己踩的一个坑(第一次跑就撞上,已修)
初版裸 `readFileSync` ⇒ `criteria-hygiene` 第 2 条红
(「判据目录里不得出现裸 readFileSync」)。已改走 `prose()`。
★ 讽刺处:**本判据主题正是"手抄副本会分叉"**,而我第一版就制造了
一处新分叉(多写一个 import)。这条债说的就是这类形状。
|
2026-09-28 10:17:09 +08:00 |
|
|
|
359cb436c3
|
docs(复验): 让复验记录与 DEBTS 口径一致 —— 假安全感有两处,且并非「无声」
上一条提交更正了 `DEBTS.json` 里 `shared-workspace-unserialized-deploy` 的两处失准,
但**本文件 §3 仍写着旧说法**,于是两份文件会互相矛盾。已对齐。
## 更正一:不是「无声」,是**披露会蒸发**
`redeploy-gateway.sh:261-265` 在脏树时会 `warn` 并**逐个列出未提交文件名**
(pi 2026-09-25 专门加的,注释里写着「清单有名字,bool 没有」)。
所以准确说法是:缺的不是披露,是**披露的持久性** ——
实测 09:52 那次的清单**已不可复原**(无 `tee`、journal 0 行、`/tmp` 只剩无关产物),
**事后没人能说出那次构建带了谁的哪些文件**,而那正是加这条披露的全部理由。
## 更正二:假安全感有**两处**,不只 `flock`
除 `flock` 外,`check-deploy-drift.mjs:1462-1467` 把 `vcs.modified` 作为
**WARN 披露且刻意不判红** ⇒「反正有判据在报」,而 **WARN 不参与退出码**,
**没有东西会因此停下**。
## 一处刻意**不**改
§1 保留「而没有任何东西会红」—— 那句限定在 ② 额度 bug 上
(`hmsStub` 的 `/token` 永远返回 200 ⇒ 判据造不出失败路径),
与部署 provenance 是**两件事**,改它反而会把一个准确的论断弄模糊。
一份记录里最容易坏掉的不是写错,而是**后来只改了一处、留下自相矛盾的两份**。
|
2026-09-28 10:14:11 +08:00 |
|
|
|
e4cbffd542
|
docs(欠账): 更正我自己那笔 HIGH 的两处失准 —— 披露存在,只是会蒸发
自查上一条提交时逐行核了 `redeploy-gateway.sh`,发现我把 `shared-workspace-unserialized-deploy`
写重了。两处更正:
## 更正一:不是「无声」,是**披露会蒸发**
初稿写「没有任何东西会红」。**不准确** —— 披露机制存在,而且是 pi 2026-09-25 专门加的:
· `redeploy-gateway.sh:261-265`:脏树时 `warn` 并**逐个列出未提交文件名**
(注释里明说「清单有名字,bool 没有」);
· `check-deploy-drift.mjs:1462-1467`:把 `vcs.modified=true` 作为 **WARN 披露**,
且**刻意不判红**。
⇒ 真正缺的不是披露,是**披露的持久性**。实测 09:52 那次的清单**已不可复原**:
脚本无 `tee`、journal 0 行、`/tmp` 只剩无关产物。**事后没人能说出那次构建
带了谁的哪些文件** —— 而那正是当初加这条披露的全部理由(2026-09-14
`pool.mjs` 那一行未提交的 `let missingSessionCount = 0;` 就是这么进生产的)。
## 更正二:假安全感有**两处**,不只 `flock`
`check-deploy-drift` 的 `modified` **WARN 不参与退出码** ⇒
「反正有判据在报」,而 WARN 不进退出码,**没有东西会因此停下**。
## `due` 同步改写
原文建议「部署时若有未提交改动就拒绝」—— 那会与既有设计**直接冲突**:
`check-deploy-drift.mjs:1462-1467` 刻意不判红,理由写在代码里
(脏树在本仓是常态,判红=总在亮)。改到真正缺的那格:
**让部署把「构建时的未提交文件清单」持久化**(写进构建物旁边 / 归档日志)。
并加一句 ⚠ 提醒别顺手把 WARN 改成判红。
## 验证
`go test -race -run TestDebtLedger ./internal/repo/` PASS(含 due/where 非空与按形状断言);
electron `commit-hygiene` 4 pass。JSON 合法,debts 32。
用 `json.dumps(indent=2)` 改写以保证**其余 31 条逐字节不动** ——
diff 只有 2 行命中,前 31 条 id 顺序与内容均未变(已逐条比对)。
★ 改机器可读文件前先验过 round-trip 逐字节一致才动手,否则一次
`json.dump` 就会把整份文件重排、淹没真正的改动。
|
2026-09-28 10:13:15 +08:00 |
|
|
|
d3a7873258
|
docs(欠账): 共享工作树无互斥 ⇒ HIGH;并更正 pi 的归因错误
## DEBTS 新增 `shared-workspace-unserialized-deploy`(HIGH)
pi 授权我写进本仓既有的欠账机制(不是新开孤儿文件)。**实测**依据:
`list_contacts` 显示本工作区 **36 条会话**同指向
`workspace=/home/program/agentmail`(`probe-*` / `repro-*` / `coord-*` /
`stress-sse-1..20` / `stress-budget-*` / `thread-probe-*`),
全部能 commit、**全部能跑部署脚本**,没有任何一层隔开。
★ `redeploy-gateway.sh:109-112` 的 `flock` **防错了东西**:
它挡的是「两个部署同时跑」,而 09:52 那次是**单发**的。
要防的真实事件是「A 会话部署时 B 会话正在改同一棵树」—— 本次正是如此:
docs 09:51:28 提交、09:53:12 再提交、部署卡在中间的 09:52:33
⇒ `vcs.modified=true`。`flock` 在位却给出**假安全感**。
**与 `044a664` 的 ② 完全同形**:那次「注释说修了而代码没改」,
这次「判据说干净而构建物不干净」——都是一句话与事实分家,且没有任何东西会红。
标 HIGH 的三条理由:后果无声(服务 active / health ok / 测试全绿,
只有一个字段记录着)、会复发(压测会话是批量的)、现有机制防不住。
## 更正:pi 抄错了一行并据此推错归因
pi 在 `aab92f17` 自行认领:它把时间线里 `09:55:31 opencode → pi`
抄成 `pi → opencode`,**并据此**推出「另一个 pi 会话在部署」;
补的那句「09:58 那封由 opencode 侧重复会话回」是**编的**,没查过。
⇒ 归因撤回。病根**方向**对(多会话共享一棵树),但下面这条证据比抄写硬:
`list_contacts` 的 36 条会话。
## 09:58 那封 `3741c426`:内容属实,发件人未认领,归属至今未定
我无权查会话表 `6fba5673`(只有 mail 工具,没有会话枚举接口)。
已写进 §3,让线索史不比实际干净。
## 验证
`go test -race ./...` 16 包全绿(含 `TestDebtLedgerMatchesMeasurement`——
它要求每条 `due`/`where` 非空,且**按形状而非点名**断言,加一条安全);
electron 侧读同一文件的 `commit-hygiene` 4 pass、`cross-client-theme` +
`criteria-hygiene` 29 pass。JSON 合法,debts 31 → 32。
★ 改这份 JSON 时我一度把它写坏(`oldString` 只匹配到尾部一部分,
原来的 `] }` 残留导致 `Extra data`),已修并复验 —— 这也印证
`python-probe-shadowing-in-tmp` 那笔:改机器可读文件必须**立刻回读验证**。
|
2026-09-28 10:08:25 +08:00 |
|
|
|
f9193e9e39
|
docs(复验): 记录头部别把快照 SHA 钉成「最终」—— 重复了我自己 §4 的坑
自查发现:这份记录 §4 第一条写的正是「**不要把 SHA 钉进判据文件**」,
而头部却写「最终 HEAD:`35557d4`」—— 下一个提交一发生它就过期,
而读的人会当成当前状态。
这类判据错的成因是「写的人顺手把当时的值写死」,所以记录自己也得守同一条:
头部改成显式快照 + 现查命令。
历史叙述里出现的 SHA(§2 提交表、§3 时间线)**保留原样** ——
那些是「某提交做了某事」的史实,不是当前状态声明,改成相对引用反而失真。
|
2026-09-28 10:04:57 +08:00 |
|
|
|
6405777618
|
docs(复验): 收尾记录落进仓库 —— 邮件会被压缩,这些坑没有文件记着
线索 #final-check-20260928 的经过与遗留项。pi 那封「09:58 的信我不认领,
请在归档记录里注明」是直接交给我的请求,docs/reviews/ 在 workspace 内。
## 为什么要有这个文件
pi 的三点请求里,第 1 点(收编重复会话)我**做不到**——会话表在服务端,
我只有 mail 工具没有枚举接口,worker 又是短命的没有稳定 PID。
第 2 点(部署授权)我已撤回。所以这一条是当时唯一能落地的动作。
## 证据分级(§0)
**明确区分「实测」与「转述」**:09:52 部署的执行者身份是 pi 调查得出的,
我无法独立验证。写进文档是为了让线索史不比实际干净,**不是**断言它为真。
一个自我夸大证据的 provenance 文件比没有更糟。
## 三处判据自身的坑(§4)
不是代码 bug,是判据写错了——都写进了 B 段的可复用形式:
- **SHA 写死**:当天已过期两次,会让人以为「不一致」而其实只是过期
- **trimpath 误报**:搜 `/home/program/agentmail` 命中 1 条 SQL 字面量
(`&workspace=/home/program/agentmailINSERT INTO mails…`)⇒ 误判失效;
正确是搜带斜杠的 `/home/program/agentmail/`,命中必须为 0
- **宽过滤假装绿**:`-run 'SSE|Client'` 跑出 `no tests to run`,
看着绿其实一格没验。pi 第一轮也踩了并误报成「全绿」
## 遗留项(§5,这是本文件存在的原因)
1. 身份重复未处置——多余会话仍可能停不掉(平台侧动作,Agent 无权)。
**在停掉之前任何线索都不应宣布收尾**
2. `vcs.modified=true` **有意保留**为已知 provenance 瑕疵:
`87c55ac` + 脏 docs,Go 代码等价,② 已验在位
3. 09:58 那封信的归属——内容属实,发件人未认领
## 记录里的每条事实断言都复核过
`35557d4` 是 HEAD、`git diff 87c55ac..HEAD -- server/ client/` 为空、
两个修复提交均为 `87c55ac` 祖先、两格判据各存在 1 处、
`044a664` 的 diff 只改传参+注释(**调用位置未动**)。
|
2026-09-28 10:04:02 +08:00 |
|
|
|
35557d4f8d
|
test(pi桥): 补两格判据 —— BoundedSet 淘汰使「重启才丢」的前提站不住
`markDelivered` 的注释写「标不上不该让投递失败……代价只是下次重启可能再投
一次」。opencode 独立复核指出这句话的前提站不住,我认这个判断。
「下次重启」把正确性押在「重启 ⇒ 内存全丢 ⇒ 从库里重来」上。但
`deliveredMails` 是 `BoundedSet(MAX_TRACKED_MAILS=2000)`,**运行期就主动淘汰**,
不需要重启。于是有一条更窄的重投路径:
投递 X ⇒ add(X) → POST /mail/read 失败(库里仍是 unread)
→ X 超过 2000 被淘汰(内存忘了,库没忘)
→ catchUp 按 unread 捞回 X,内存 has() 挡不住 ⇒ 重投
正是 2026-09-26 那个症状本身(重投回声),只是窗口窄得多。
两格分别锁住前提与后果,都不靠注释断言:
⑤ `deliveredMails 确实是有界的` —— 从 index.mjs 取上限常量名,回 bounded.js
取其值并断言有限。有限 ⇒ 运行期会淘汰 ⇒ 「靠重启才丢」不成立。
⑥ `淘汰只丢内存、不回写库` —— 覆盖 BoundedSet.add 的整个淘汰循环,
断言其中无 /mail/read|markDelivered|post|status|client;并对照
markDelivered 的失败分支只打日志、不重试不回滚。
将来若让淘汰也落库,这格会红,提醒改的是注释而不是加静音。
变异自测(三个变异各被对应格抓住,非自说自话):
改成无界 `new Set()` → ⑤ 红
淘汰路径里回写库 → ⑥ 红
markDelivered 失败后 setTimeout 重试 → ⑥ 红
判据自带的两个坑留在注释里(第一版「怎么变异都不判红」的原因):
必须锚在 BoundedSet 的 add 上,否则裸 /add\(value\)/ 会先命中文件前面
BoundedMap 的同形 add;淘汰循环要带尾巴({0,320}? 惰性量词会在第一个终点
就停),否则紧跟其后的落库代码永远落在窗口之外,结构上不可能判红。
全量 526 格通过。未修 —— 本提交只把已知窗口钉成可判红的判据,
不动 `markDelivered` 的行为(改行为是另一次决定,且应先决定淘汰时
要不要落库)。
Co-Authored-By: pi <pi@agentmail>
|
2026-09-28 09:57:41 +08:00 |
|
|
|
3b8204f356
|
docs(审查): 补上 pi 建议的第二格判据(pi 又改了一次它自己的标注)
上一提交只写进了一格判据,pi 随后把它的建议补上 —— 报告里现在是两格:
· `TestHMSAccessTokenFailureDoesNotBurnQuota` 钉内部计数器 dayCount==0
· `TestHMSQuotaSurvivesTokenFailureWithLimitOne` 钉用户看得见的行为
两层都要钉的理由:**计数器对而行为错是可能的** ——
运维会收到「达到每日推送上限」这种**误导性文案**,
而真实原因只是上一次网络抖动。这正是它建议单独加一格的原因。
|
2026-09-28 09:53:12 +08:00 |
|
|
|
87c55acb5f
|
docs(审查): 给 push 报告 §二.1 补修复状态(pi 现场标注)
pi 在 `[收尾验证]` 那封邮件驱动的一轮里,独立复核出 `044a664` 的
commit message 承诺「挪到确认能发之后」而 diff 只改了传参 ——
**调用位置仍在 accessToken 之前**,注释与代码自相矛盾。
它在这份报告上就地标了修复状态。三点值得留在文档里:
① ①(按批次计)真修了且有判据;② 当时**只写进注释、代码没动**。
② 之所以没被当场发现:当时那批判据**造不出「accessToken 失败」这条路**
(`hmsStub` 的 `/token` 永远返回 200 + 令牌)。
③ 现已真正落地,并补判据;把修复回退后判据会红。
★ 教训值得单列:**「我写了注释说明怎么修」不等于「我改了代码」**。
审查报告给了两条,我处理了一条,把另一条誊进注释就当做了。
写完注释应当立刻核对行号 —— 那是 5 秒钟的事。
|
2026-09-28 09:51:28 +08:00 |
|
|
|
bfc9d87b24
|
test(push): 补 pi 建议的那一格 —— DailyLimit=1 时失败后仍要能发
上一提交补的是**内部计数器**(dayCount == 0)。pi 在报告里建议的是
**用户看得见的行为**:DailyLimit=1,一次 accessToken 失败后第二次
仍然要能发出去。
两层都要钉的理由:计数器对而行为错是可能的 —— 那会让运维收到
「达到每日推送上限」这种**误导性文案**,真实原因却是上一次网络抖动。
变异验证:把 reserveDaily 挪回 accessToken 之前 ⇒ 两格同时红。
--- FAIL: TestHMSAccessTokenFailureDoesNotBurnQuota
--- FAIL: TestHMSQuotaSurvivesTokenFailureWithLimitOne
|
2026-09-28 09:50:24 +08:00 |
|
|
|
186cf53804
|
fix(push): 额度预留**真的**挪到 accessToken 之后(上一版只写了注释)
## 起因:pi 在邮件驱动的一轮里当场抓出来的
pi 收到那封 `[收尾验证]` 邮件后,自己翻代码核对,
在会话文件里写下(原文):
The code contradicts its own comment (item ②: reserve should be *after* accessToken)
The commit only changed the argument (`len(tokens)` → `1`) and the comment — it
Fix ① (per-batch) is real and tested. Fix ② is claimed but not implemented.
Confirmed — the bug is real.
它甚至自己造了探针(`zz_probe_test.go`,跑完已删)来实证。
**我独立复核确认它是对的**:
`reserveDaily(1)` 在第 212 行,`accessToken` 在第 215 行 ——
预留仍在**之前**。2026-09-26 那次我只改了 ①(`len(tokens)` → `1`),
把 ② 写进了注释,**代码没动**。
## 为什么当时那批判据没接住
`push_test.go` 原有 3 格只验 ①(按批次计),**造不出「accessToken 失败」这条路** ——
`hmsStub` 的 `/token` 永远返回 200 + 令牌。
⇒ 「注释说修了」与「代码真修了」能分家,而没有任何东西会发现。
## 改法
① `hmsStub` 加 `failToken` 开关(`/token` 可返回 400)。
② `reserveDaily(1)` 挪到 `accessToken` 成功**之后**、真正发请求之前。
仍保持**前置预留**语义(不是"发成功后再扣")—— 那会超发,
并发下多个 goroutine 都能通过检查。宁可少算也不多发。
③ 新增 `TestHMSAccessTokenFailureDoesNotBurnQuota`:三次 accessToken 失败后
断言 `dayCount == 0`、零推送发出、且恢复正常后仍能发(额度没被吃掉)。
## 变异验证(这格判据本该在 2026-09-26 就存在)
把 `reserveDaily` 挪回 `accessToken` 之前(= 还原成 bug)⇒
★ accessToken 失败不该扣额度,实际已扣 3 条
(一次网络抖动静默烧配额就是这么来的)
## 教训(与本仓 python-probe-shadowing / baseline-residue 同族)
**「我写了注释说明怎么修」不等于「我改了代码」。**
审查报告给了两条,我处理了一条,把另一条**誊进了注释**就当做了。
写完注释应当立刻核对行号 —— 那是 5 秒钟的事,而这次是别人替我发现的。
★ 另一层:**别人(或另一个 Agent)独立复核出来的结论,要自己再验一遍再改**。
我逐条查了行号才动手,没有因为"pi 说的"就直接信。
|
2026-09-28 09:49:22 +08:00 |
|
|
|
83a5787ac9
|
fix(homeagent桥): SDK 钉子指向 v1.3.0,而构建机指针已是 v1.4.0
## 现象
`go test ./...`(在该包内)红:
--- FAIL: TestSDKPinMatchesBuildMachinePointer
go.mod 的 SDK 钉子偏离了构建机指针:
钉子: /root/.homeagent/hmapdev/sdk/v1.3.0
指针: /root/.homeagent/hmapdev/sdk/v1.4.0
后果不是「编译不过」,是**照原样重编会再造一个旧版本插件**:
内核升级后装上去启动即崩、崩溃循环、要人介入(2026-09-14 的形状)。
## 为什么这个包的红一直没被发现
`plugins/homeagent-mail-bridge/` 有**独立 go.mod** ⇒ 不在主 module 内
⇒ 仓库根的 `cd server && go test ./...` **覆盖不到它**。
(主 module 覆盖率实测:`go list ./... | grep -c homeagent` → **0**。)
## 改法
`go.mod` 的 replace 改成指针指向的目录(判据给出的改法,一行),
然后按 `build.sh` 重编并部署。
## 验证(判据自己写明的口径:不是版本号相等,是建链成功)
- 判据:✅ 过
- 重编:`hmapdev build` 成功,`SDK=v1.4.0 hash=ba5cece0ca8a52ad version=1.4.0`
- 部署:`install -m 0755` 原子替换 + 留档 `plugin.bin.bak-20260928-093600`
- **建链**:内核日志 `homeagent-mail-bridge` 经 proc 通道加载、
`protocol=2 sdk=1.4.0`、工具全部注册(`loaded: homeagent-mail-bridge`)
- **端到端**:真发一封 `.new` → 20s 内回信「SDK v1.4.0 重建后正常」
★ 顺带记一条:build.sh 自己注明 `go build ./...` 会报
`function main is undeclared` —— 那是正常的(入口由 hmapdev 包装),
真正的编译在 hmapdev 那一步。拿 `go build` 当"能不能编"的判据会误报。
|
2026-09-28 09:40:34 +08:00 |
|
|
|
093c4dd06e
|
fix(桥接): 模型清单过期时**起会话前**就剔掉(opencode 每轮白烧一次)
## 现象(线上实测,不是推演)
[mail-bridge] 模型 opencode/mimo-v2.5-free 失败:
Model not found. Did you mean: mimo-v2.6-flash-free, ling-3.0-flash-fin-free...?
[mail-bridge] llmsproxy/AUTO 成功(前 1 个失败)
30 分钟内 9 次 —— **每一封邮件**的第一轮尝试都是它。
## 根因
`agent_allowed_models` 里 opencode 的 rank=0 仍是 `mimo-v2.5-free`,
而**上游已改名** `mimo-v2.6-flash-free`。查证:225 个 provider 的实时目录里
确有 `mimo-v2.6-flash-free`、无 v2.5;库里 `agent_model_catalog`
(心跳上报的快照,今天 01:28)也已含新名字。
⇒ 清单是管理员存下来的,**没有任何失效检测**。降级逻辑救了它(信还是回了),
但代价是**每轮白烧一次 + 延迟翻倍 + 一条永久错误日志**。
## 为什么不是「在服务端过滤掉」
`ListAllowedModels` 的注释明确否掉了这条路:
「不与目录做 JOIN:目录是平台上次注册时的快照……在这里用目录过滤,
只会把『目录暂时没上报但其实可用』的模型挡掉」。**这个判断是对的**,不改。
## 改法:桥侧预检(pi 桥早就有)
`pi-mail-bridge/src/worker.mjs:674-680` 同一件事已经做了,注释写着
「目录里根本没有这个路由:**同步就能判定,不必起一轮**」。
本提交把那条纪律提到共用层 `model-scope.js` 的 `partitionByCatalog`,
让 opencode / dsh / pi 共用。
**实测对比**(同一封信,修复前后):
修复前:模型 mimo-v2.5-free 失败: Model not found… ← 起了一轮会话才失败
修复后:跳过 opencode/mimo-v2.5-free:平台目录里没有… ← 同步拦下,零会话
llmsproxy/AUTO 成功
## ★ 最要紧的一条:拿不到目录时**全部放行**
心跳还没跑过 / 拉取失败 ⇒ 目录是 `undefined`。此时若照样剔除,
Agent 会**彻底哑掉** —— 而失效方向恰是「什么都收不到」。
与 `reportModels` 拉取失败时**省略字段而不是传空数组**同一方向。
判据里对 `undefined / null / [] / 'not-an-array' / 42` 五种输入逐个断言。
`routes` 被剔空时**退回平台默认**并打日志说明「请到管理页重新划定范围」——
否则发件人只看到「本次未能处理」,而管理员看不出自己的选择已过期。
## 判据(并入共用测试,三桥同源,33/33 过)
7 格,含:线上那个 case、拿不到目录必须全放行、`undefined` 路由不归目录管、
全失效时 kept 为空(退路是策略决定不是过滤职责)、provider 同名 model 不同不算数。
变异验证两向都打红:
① 去掉「拿不到目录就全放行」的保护 ⇒ ★那格红
② 键只用 provider(半匹配) ⇒ 4 格红
|
2026-09-28 09:40:15 +08:00 |
|
|
|
c851bef5cb
|
fix(4 bridges): 投递提示词改指向 read_mail,不再引 read_inbox
投递时 `markDelivered` 就把信标成已读(dsh `src/index.ts:505` 定义,
`:551`/`:2214`/`:2276` 三处调用,SSE 主投递与 catchUp 都走它 ——
2026-09-26 为治"重启重投→回声"定的性)。而提示词却要求"先调
read_inbox 读正文",read_inbox 默认 `status=unread` ⇒ **看不到刚投递的
那封信**。
危险的不是"白费一次调用",是模型看到空之后以为"没有新邮件"就结束
回合 —— 那会**静默丢掉一个真实请求**。mail_id 就在同一段提示词里。
## 改了什么
投递提示词(8 处)与 read_inbox 工具描述(4 处):
请用 read_mail(mail_id 用上面「邮件 ID」那处) 读取这封邮件的完整正文……
这封信在投递时已标为已读,而 read_inbox 默认只看未读,读不到它;
read_inbox 只用来看本会话的其它未读。
工具描述那句「收到新邮件通知后应立即调用此工具」是**模型看到的第一句话**,
只改提示词不改它,模型照样走偏,所以一并改。另给 read_mail 的描述补
一句「刚投递到本会话的那封邮件就读这个」。
落点(dsh 桥是**三处**,不是一处 —— adoptPrompt / resume 复用 / 新开会话
三条建会话路径各带一份文案):
- dsh `index.ts:1047`、`:1163`、`:1219` + 工具描述 `:1428`/`:1680`
- opencode `index.js:1016` + `:261`/`:473`
- pi `turn.mjs:170` + `tools.mjs:197`/`:430`
- zcode `prompt.mjs:152` + `lib/tools.mjs:123`
## 为什么指向 read_mail 是安全的
`workspace` 必需只加在**两个**端点上:GET /mail/inbox
(`server/internal/handler/mail.go:626`,用户裁定:不带 workspace 是错误
发件格式)与全部标已读(`:806`)。`read_mail` 走
`GET /agent/mail/{id}` → `AgentGetMail`(`agent_discovery.go:306`),
该路径**没有 workspace 参数**,鉴权走 `canReadSession()` 按会话归属判定。
桥侧也印证:`read_mail` 走 `withScope()`(只拼 session_id),
`read_inbox` 的 scope 单独拼 `&workspace=`。**两条路分开,400 搬不过去。**
## 不动默认行为
`read_inbox` 的默认 `status=unread` 保持不变(`lib/inbox-format.js:157`
刻意如此:默认 all 会让模型每轮重读旧邮件),`idsToMarkRead` 在 `all`
下不标已读与"投递即标已读"配套。改默认值等于把 2026-09-26 定的性放松
回去,不是另一种修法。
## homeagent 故意不动
`plugin.go:673,1005,250` 三处留着。改 Go 源码需重出 `plugin.bin`,产物在
**跨机器**的 `/home/newqqagent/plugins/`,你我都碰不到 —— 源码改了线上
没生效,仓库与线上分叉比不改更难排查。待跨机重出。
## 测试
新增 test/inbound-prompt-reads-mail.test.mjs(5 项),钉住:旧文案不存在、
新文案三处齐全、工具描述改过、read_mail 描述指过、附件指引保留。同时
断言 src 与 **dist**(dist 是 dsh 真正加载的那份,忘了 build 就是
"源码对、线上旧代码")。
四桥测试:dsh 419 / opencode 344 / pi 517 / zcode 394,全绿。
(改前 407/344/517/394,无回归。)
## dist 变更(不在 diff 里,.gitignore:20 忽略 plugins/*/dist/)
重出后 `请先调用 read_inbox` 由 **3 处 → 0 处**,新文案 3 处;
旧工具描述 1 处 → 0 处。已用 `npx tsc -p tsconfig.json` 重出。
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
2026-09-28 09:29:26 +08:00 |
|
|
|
9985c41b13
|
fix(dsh-bridge): 新开会话登记工作区,workspaceOf 加淘汰兜底
read_inbox 在**新开会话**里恒 400("缺少 workspace"),而 read_mail /
read_thread / list_contacts / session_participants 都不受影响。
## 根因
`workspaceOf()` 取不到工作区就返空,URL 不带 `&workspace=`,服务端 400
(`server/internal/handler/mail.go:626`,用户裁定:不带 workspace 是错误
发件格式)。而 `sessionWorkspace` 只有两个写入点 —— adopt(bindAdopted)
与确定性 id 恢复 —— **新开会话分支漏了**。
漏了之后只有两种情况能发现:那条会话后来恰好走过另外两条绑定路径,
或者重启后 `sessionWorkspace`(500 条上限)把它淘汰掉。
## 改法
1. 新开会话分支补 `sessionWorkspace.set(attemptSessionId, sessionCwd)`。
★ 取值必须是 `header.cwd`,**不是那个 `cwd`**:后者来自
`resolveWorkspaceCwd`,`to_workspace` 不可用时回退到
`mailSessionFallback` → `~/.dsh/mail-sessions/mail-<uuid>`,每次邮件
都不同。拿它登记会把收件箱收窄到一个**永远读不到信**的目录 ——
比 400 更坏,因为 400 至少是可见的错误。同一文件下方工作区注册那段
(`actualCwd`)也是从 header 取,两处保持同源。
2. `workspaceOf()` 读侧兜底:反查 `sessionMap` 的 `directory`。
补在读侧而不是写侧,因为淘汰是**事后**发生的:补写站点只能覆盖
「我这次走过」,被淘汰的键下次谁来读都读不到。反查的 `directory`
在两条绑定路径上都是真实 cwd(adopt 走 `persistedCwd` 读磁盘
header),不是兜底目录。
## 影响面
`read_inbox` 的两维收窄(session_id + workspace)是**防越界读**的机制
(2026-09-14 用户报的越界)。这个改动让更多会话能拿到 workspace,因此
**可见范围确实变宽**了 —— 这是有意的(原本是读不到,不是读得少),
但复核时应当盯住这一条。
## 测试
新增 test/workspace-of-fallback.test.mjs(7 项)。其中 5 项把
`workspaceOf` 的真函数体抠出来在真 BoundedMap 上跑(不复制一份实现,
否则源文件改了测试还在绿)。已做变异验证:删掉读侧兜底 → 3 项红;
删掉写入点 → 2 项红。
dsh 桥 414 项全绿(改前 407,无回归)。
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
2026-09-28 09:26:15 +08:00 |
|
|
|
c2796eaa52
|
fix(部署判据): 悬空软链不再让第 ① 条变成「不可达检查」+ 补上它指向哪里
## 起因
第 ① 条("没有任何 unit 引用源码目录")在本机**长期红**,note 写的是
「读不到(这几条没被检查):…/sysinit.target.wants/mdadm-shutdown.service(ENOENT)」。
查下去:那是**系统自带的悬空软链**(mdadm 未装 ⇒ 目标不存在),
不是本仓的部署事故。但判据仍然红,而它红得**没道理可讲**。
## ★ 真缺陷:那个软链分支是**不可达代码**
/etc/systemd/system/sysinit.target.wants/mdadm-shutdown.service
→ /lib/systemd/system/mdadm-shutdown.service (不存在)
`readFile` 对悬空软链抛 ENOENT ⇒ 走 `catch { …; continue; }`
⇒ **下面那段专门判软链的代码永远不会执行** ——
而那恰恰是对软链该做的检查(看**目标位置**)。
⇒ 这不是"误报",是**一个该跑的检查没跑**。判据没回答"这个软链指向仓库吗",
只回答了"我读不到它"。
## 修法
`readlink` 只读**链接自身**的内容(那个路径字符串),**不要求目标存在** ——
这正是悬空软链唯一还能回答的问题。
现在:既把目标查出来并判(指向仓库 ⇒ 进 `repoLinks` 报红),
又**仍然记进 `unreadable` 继续判红** ——
「没能检查它的内容」≠「它没问题」,这条不能因为解释清楚了就不算。
## note 里单独一句「悬空软链(**非本仓违规**)」
它与「真有 unit 读不到」要处理的人不同:
前者是装软件留下的系统状态,后者可能是部署事故。
混在一堆里 ⇒ 读的人不知道该不该管。
## 残余(有意保留)
第 ① 条**仍然红**,但现在红得**有理由且已说明**。
要让它转绿只能二选一:装 mdadm、或删掉那个悬空软链 ——
**都不是本仓该做的事**,所以不为了转绿而放宽判据。
自检 48 格全过(本次未新增样本;这一格的真机样本就摆在那里,
且 `--self-check` 的假 fs 走不到 `sysinit.target.wants` 这条路径)。
|
2026-09-28 08:49:40 +08:00 |
|
|
|
1639382eaa
|
fix(部署判据): ⑤ 的口径从「数子串出现次数」改成「只认源码**文件路径**」
## 起因
trimpath 修好之后(67 处 → 0 处真源码路径),判据第 6 条**仍红 1 处**。
查下去发现那 1 处不是缺陷:
server/internal/handler/mail.go:645 的 400 错误文案 ——
「请带上你所处工作区的绝对路径,例如 &workspace=/home/program/agentmail」
那是**给调用方看的示例值**,且它在二进制字符串表里紧邻下一条 SQL 字面量,
拼成 `…/agentmailINSERT INTO mails (session_id, …)` ——
**看起来极像「路径 + 代码」,实际是两条无关的字符串常量相邻**。
## ★★ 我上一轮把它误判成「测试夹具」
我grep 源码时命中的是 `notify_test.go` 里的 workspace 夹具,
就下了「是测试数据」的结论 —— 那是**另一个**字符串(`seedAdopted(t,"pi","pid-ws-1",
"/home/program/agentmail")`),只是恰好也含 REPO。
**真正的来源是生产错误文案。** 先下结论再取证,又一次。
## 口径改动
原口径 `text.split(REPO).length - 1` 数的是**子串出现次数**,把两件事混成一件:
· 真缺陷:trimpath 没生效,产物里印着 `…/server/internal/repo/repo.go`
· 误报: 源码里**本来就该有的字符串**恰好含这个子串
⇒ 改成逐个出现位置看**后缀**:REPO 之后是**源码文件扩展名**才是真路径。
这条判据要抓的是"源码**文件位置**被泄露",扩展名正是它的形状;
而示例值后面跟的是 `INSERT`(SQL 关键字),不是文件。
## 两条都要报(把两者混成一个数字正是原口径的毛病)
bad = 真源码路径 ⇒ **判红**
other = 还有别处出现但不是文件路径 ⇒ **只提示**,且**给出真实样例**
第一版的"只提示"那档输出了「样例:见下」而样例永远取不到值
(只给真路径留了样例)—— 一句指向不存在内容的指路词。已修。
## 验证(两侧都用**真 26MB 二进制**,不是合成样本)
已部署(-trimpath) 源码路径=0 非文件字样=1 ⇒ 判绿 ✓
本地构建(无 trimpath) 源码路径=66 非文件字样=1 ⇒ 判红 ✓
## 自检
新增一格反面样本:`★网关二进制:非源码路径的仓库字样(示例值)不得误判红`
(`--self-check` 47 → **48** 格)。
变异验证:把 `SRC_EXT.test(tail)` 改成 `true`(退回数子串)⇒
该格**打红**且整套自检报"检查器本身不可信" ⇒ 新格确有分辨力。
|
2026-09-28 08:46:02 +08:00 |
|
|
|
d4e13ea593
|
docs(对齐): 重新核对 calendar-view 对齐底本(第四轮)
按本文件协议,跨端对齐目标文件被改动后**重新核对**而非抄新哈希:
骨架逐项复核**未变**(gridPane(flex:1) + 400px 右栏两栏结构、工具条分组与
顺序、手势与按钮共用 shift()、非本月农历小字仍 text-gray-400)。
本次三处差异**全是行为不是骨架**:
① doExport 的 URL.revokeObjectURL 从同步撤销改为 setTimeout(…,0)
并把 <a> 挂上 document 再摘除(Chromium 容忍同步撤销、
Firefox/WebKit 不容忍 ⇒ 静默 0 字节 .ics)
② load() 加 loadGen 代次守卫(照 ThreadView.tsx 既有形状)
③ BackgroundPicker 预设缩略图去掉覆盖性的内联 backgroundImage
|
2026-09-28 08:46:02 +08:00 |
|
|
|
2530229180
|
docs(审查): 归档本轮四份代码审查报告
`docs/reviews/` 此前一直是**未跟踪**状态 —— 审查报告只在磁盘上,
不进版本库 ⇒ 换机器、换会话、给别人看时全部拿不到,
而它们正是本轮五个修复(hap 出库 / SSE 写锁 / 换身份清数据 /
鸿蒙门禁三态 / HMS 配额)的**来源**。
push-and-gui-review.md 推送链 + Electron GUI(HMS 配额那两条)
electron-gui-review.md 换身份不清数据
harmony-client-review.md 鸿蒙:门禁 fail-open / MailStore 快照共用 / clear() 零调用方
harmony-pages-review.md 页面层
harmony-state-review.md 状态层
fix-report-2026-09-26.md 上述修复的实施记录
其中 `harmony-client-review.md` §三.1 记的那条值得单独留意:
该报告自己声明「ArkTS 语言规范层面零违规,本文所有问题都是**逻辑缺陷**」——
本次提交的三处鸿蒙改动也只动逻辑(门禁条件、logout 清理),
不碰语法层。
|
2026-09-28 08:46:02 +08:00 |
|
|
|
4556886e04
|
feat(部署判据): 已退场宿主可显式豁免(zcode)+ 记我自己踩的两个坑
## 背景
zcode 宿主已在本机删除(进程无、`/etc/systemd/system/zcode*.service` 无、
`/opt/agentmail/plugins/` 下无该目录),而 `plugins/zcode-mail-bridge/`
(62 个文件)与 `deploy/systemd/zcode.*`(3 个单元)**故意留在仓库里**以便恢复。
`HOSTS` 是硬编码四家,于是两条判据永久红,且结论区建议的
`bash deploy/redeploy-plugin.sh zcode` 是**错的动作** ——
那会部署一个用户已决定不再运行的宿主。
## 改法:显式豁免表 `RETIRED_HOSTS`
**豁免只换表述、不压红**(这是关键,理由见下):
· `checkHost` 仍逐条列出「已退场」+ 理由,只是不计入 `stale`;
· `checkLayout` 的 note 里明写「已退场宿主、故意不装:<单元>」。
为什么不能静默跳过:本文件反复强调「**没检查到** ≠ **不存在**」。
静默跳过 = 让人以为"检查了、没问题",而"这台机器上跑什么"
本身就是需要有人知道的**事实**。豁免必须**可见**。
## ★★ 我自己连踩两个坑,都让豁免**静默生效**(不是"豁免得太宽"那种噪声)
① 第一版 `isRetiredHost` 只查 `units[0]`(zcode.service)。
实测"只装 zcode-mail-bridge.service、不装快照"时
**仍被判成已退场**,且那个新装上的单元在 note 里被写成
「故意不装」—— **自相矛盾且完全静默**。
⇒ 改为 `units.every` 逐个查。根因:只抽查一个单元就宣称"全都没装"。
② 第二版用 `existsSync` 判软链存在,而 `current` 是**相对**软链
(`→ 20260926-085613`)。快照目录被删、软链残留成**断链**时
`existsSync` 返回 false ⇒ 「装过又删剩」与「从没装过」读数
**完全同形**。实测断链场景仍被判「已退场」。
⇒ 改用 `lstatSync`(看得见软链本身,含断链)。
## 验证:四态都实测过
A 全都没装 ⇒ 豁免("已退场"仍出现在输出里)
B 有效软链 ⇒ 报漂移
C **断链**软链 ⇒ 报漂移
D 只装一个单元 ⇒ 报漂移
`--self-check` 47 格仍全过。结论区回到
"四个宿主都在跑当前代码"。
## 欠账
豁免逻辑本身**没有自检样本**进 `--self-check`(现靠人测四态)。
按本仓纪律"能自证就别靠人测",应补:注入假 fs 让四态各跑一遍,
且必须有一格是**断链**样本 —— 第一/二版都恰好漏在断链上。
已登记 `DEBTS.json` 的 `retired-host-exempt-never-tested`。
|
2026-09-28 08:46:02 +08:00 |
|
|
|
21332de5e7
|
test(判据): criteria-hygiene 的 AGC 探针自检改用临时仓合成对照
## 为什么要改
那条判据("AGC 真身从未进过远端历史")的自检原本要求
**本地可达历史里确实有该路径**,用它证明 `git log -- <路径>` 这套查法可用。
★ 那个前提**已经不成立**了:真身**从未被提交过**
(`client/harmony/.gitignore:26` 一直在挡它),所以本地历史里
本来就查不到 ⇒ 这条判据**永久红、且无法自查**。
(注释里引用的 `7647c24` / `320c93f` 在本树也**不存在**。)
而判据的分诊早已确认:真身确实从未进过历史(被 ignore 正确挡住),
红的是**探针的假设**失效,不是缺陷存在。
## 改法:合成阳性 + 阴性对照
在**临时仓**(`mkdtemp`)里造两个提交 —— 一个含待查路径、一个不含 ——
对两者跑同一套查法,断言**双向有分辨力**。临时仓不碰本仓任何状态
(`GIT_CONFIG_GLOBAL=/dev/null` 避免读用户配置),造完即删。
为什么**必须**有阳性对照:一条用来抓泄露的判据,
**正确工作**时恰好永远看到"空"(没泄露 ⇒ 查不到)。
**"真值恰好是空"与"查法坏了"在输出上同形**(都是空串 + exit 0)
⇒ 真实历史里没有阳性样本可用,只能现造。
## ★ 阴性对照第一版写错了(变异测试打出来才发现)
我先写成查一个**真实存在**的无关文件 `unrelated.txt`。
变异把它改成查阳性那个 `leaf.json`,判据**照样绿** ——
因为查 unrelated 本来就该命中,那不叫"查法在乱报"。
⇒ 阴性对照要证明的是「**不存在的**目标查不到」,也就是**查法有边界**。
改成查 `no-such-file-ever.json`,并**额外**验一次 sanity
(真实但无关的文件**应当**查得到)—— 两个方向都对才算有分辨力。
改后双向变异都能打红(阳性查不到 ⇒ 红;阴性恒命中 ⇒ 红)。
|
2026-09-28 08:46:02 +08:00 |
|
|
|
18b148e476
|
跨端: fix(客户端) 换身份必须清全部账号数据 + 鸿蒙管理台门禁改三态
两份审查报告(`docs/reviews/electron-gui-review.md` /
`harmony-client-review.md`)里两条**数据隔离**缺陷。
## ① 换身份不清数据 ⇒ 在新账号的界面下显示旧账号的邮件
`setActive` 之后 `api/config` 单例里的 API_BASE 与 bearer 就翻到了新账号,
于是此后每个请求都带**新账号**的凭证。而各 store 里还留着**旧**账号的:
· mailStore.sent / currentMail
· sessionStore.sessions / currentSession / currentSessionMails
· contactStore.contacts / archivedContacts
⇒ 肉眼完全看不出来(不报错、不空屏),而**从旧视图发出的写操作**
(归档 / 转发 / 批准权限)改的是**新账号**。
新增 `src/lib/resetAccountData.ts` —— **一处实现,三个入口都调它**:
① 主动切账号(AccountSwitcher.pick)
② 登出(authStore.logout)
③ 任意接口 401(api/client.ts 的 unauthorized 回调 → markAnonymous)
只在 ① 里清是最容易漏的那种做法:② 和 ③ 各自还会重新泄露一次,
而它们都不在切换账号的代码路径上,grep 也找不到。
**身份变化有三条路径,清空也该有三条。**
★ 只碰**数据** store;`uiStore` 的 reset 仍由 App.tsx 负责
(它还要复位窄屏分栏、写信态那些纯界面状态)。
判据:`test/stores/resetAccountData.test.ts`(4 格)。
## ② 鸿蒙管理台门禁**失败开放**(fail-open)
`AdminUsersPage.ets` 原来的条件是 `roleKnown && !this.isAdmin`,
于是 `roleKnown === false`(loadRole() 失败、**身份还没读到**)
落进 else 分支 ⇒ **把完整管理台整个渲染出来**。
一次网络抖动 = 管理入口对所有人可见。
讽刺的是该文件自己的头注释写的就是正确规则
(「不能把读不到当成是管理员」)—— 代码做的正是这条注释禁止的事。
⇒ 改三态:`!roleKnown` 显示「正在确认身份…」、`!isAdmin` 显示墙、
否则管理台。
服务端 `middleware/user.go` 的 `AdminOnly` 仍在,所以**不是越权**;
但非管理员会看到完整用户列表、建号表单、改密入口 ——
属于客户端信息泄露 + 无意义的失败请求风暴。
|
2026-09-28 08:46:01 +08:00 |
|
|
|
044a664cc3
|
fix(push): HMS 每日额度按**批次**计,且挪到"确认能发"之后
审查报告 `docs/reviews/push-and-gui-review.md` §二.1 记的两条,都在**线上**
(已部署二进制是 f51c9c8 的构建,此修复未上线)。
## ① 计数单位错:按 token 数扣,变量名与文案都说"条"
`hms.go` 原先 `h.reserveDaily(len(tokens))`,而变量名 `dayCount`、
注释、报错文案(「达到每日推送上限 N **条**」)说的都是"条"。
华为的测试消息额度是按 **`messages:send` 的调用次数**计的
(一次请求一条消息,无论 `message.token[]` 里有几个设备)。
⇒ **3 个设备收到 1 封邮件就吃掉 3 条额度,实际只发出 1 条。**
多设备自部署用户会按 1/设备数 的速度提前耗尽 1000 条/天。
修法:`reserveDaily(1)` —— 一次 `Send` = 一条消息。
## ② 扣在投递**之前**:一条都没发出去,额度却已经扣了
原顺序:reserveDaily → accessToken → HTTP 请求。
`accessToken` 失败 / HTTP 失败 / 华为回非成功码,这三种情况
**一条都没发出去**而额度已扣,且失败只 `log.Printf`
⇒ 一次网络抖动静默烧掉配额。
修法:挪到 `accessToken` **之后**、真正发请求之前。
★ 为什么不是"发送成功后再扣":那会超发(并发下多个 goroutine
都能通过检查)。保留前置预留、但放在"确认能发"之后,是
**宁可少算也不多发**的取舍 —— 少算的代价是偶尔一次失败
没计入,超发的代价是真超额被华为拒。
## 验证
`server/internal/push/push_test.go` 补 3 格(+27 行):
按批次计(多设备一封邮件只扣 1)
accessToken 失败**不**扣额度
reserveDaily 的单位是"条消息"而非 n 个 token
|
2026-09-28 08:26:41 +08:00 |
|
|
|
e2472287f0
|
fix(sse): Client 加写锁 —— 同一 ResponseWriter 被并发写(-race 证实)
## 缺陷
`internal/sse/manager.go` 的 Client 结构体**一把写锁都没有**,
而 `Manager.mu` 只护 `clients` map 的**遍历** —— 遍历期间对每个
client 的 `c.SendWithID` 是**并发**的。
`http.ResponseWriter` 不是并发安全的,而 SSE 又是文本协议
(`id: N\nevent: X\ndata: {…}\n\n`),两个 Fprintf 交错就把
data 的 JSON 劈成半截 ⇒ 客户端 EventSource 收到坏帧、丢邮件。
## 实测(真实 httptest.ResponseRecorder + -race)
WARNING: DATA RACE
Read at ... by goroutine 13:
net/http/httptest.(*ResponseRecorder).writeHeader()
sse.(*Client).SendWithID() manager.go:350
★ 32 goroutine × 25 帧 = 800 帧,只切出 459 帧完整
## 生产上会打中的三条路径
① handler/permission.go:412-414 —— `SendToAgent(perm.AgentName,…)`
紧接 `SendToUser(user.Username,…)`,两个不同 HTTP 请求命中同一账号。
② 任意两条并发邮件:一封投给 B,B 的插件回信进 C 的 handler,
而 A 的 `notify.Recipients` 还没跑完。
③ heartbeat 那条 goroutine 每 10s 写一次(见 heartbeatInterval
注释:实测本机 SSE 连接只活 34~57s,被中间反代按空闲超时掐掉),
撞车概率随在线时长线性上升。
## 修法
Client 加 `writeMu`,串行化**全部四条**写路径:
Send / SendWithID / heartbeat / replay
`replay` 虽在注册之前、按构造就是单写者,仍持锁 ——
让「对 Res 的写入一律经由 writeMu」成为**结构上**的纪律:
将来有人把注册提前或把回放挪到注册之后,没上锁的版本会静默退化成并发写。
为什么不能靠上层串行化:推送方有 5 个入口
(SendToUser/SendToAgent/SendToRecipient/Broadcast/replay),
要保证"同一 client 的所有写互斥",责任只能落在 client 自己身上。
## 判据(新增 frame_integrity_test.go,2 格)
**用帧完整性而不是"不许有 race"当判据** —— 本仓 `go test ./...`
默认不带 -race,判据必须在默认路径能判,否则就变成"要记得加 flag"。
TestFrameIntegrityUnderConcurrentPush 800 帧必须 800 帧完整
TestHeartbeatDoesNotInterleaveWithPush 钉住"只锁推送漏掉心跳"那个漏法
变异验证(去掉三处锁):
★ 17 次交错;切出 793/800 帧;心跳格也报 3 次交错 ⇒ 两格都有分辨力。
|
2026-09-28 08:26:29 +08:00 |
|
|
|
cbfc3bdde7
|
fix(安全): 构建产物出库 + pre-push 加**按内容**的第二道闸
两个 36MB 的 .hap 被 f51c9c8(一个标题为「workspace 谓词抽成共享构造器」的
重构提交)顺手带进版本库,而它们**内嵌 AGC 配置真身**。
## 实测确认(不是推测)
用 python zipfile 打开两个 hap,逐字段核对形状(不打印值):
resources/rawfile/agconnect-services.json
client.client_id HEX len=19
code.code1..code4 HEX len=32 ×4 ← api_key 信封
oauth_client.client_id HEX len=19
app_info.app_id HEX len=19
`git merge-base --is-ancestor f51c9c8 origin-https/main` 判否
⇒ **仅本地、尚未推送**(本仓镜像是 public / 匿名可 clone)。
## 为什么只 rm --cached 不够
`git rm --cached` 只把文件移出 index,**blob 仍躺在未推送的提交里** ——
下一次 `git push` 会连它一起发出去。所以必须同时堵"下一次"。
## 改法
① .gitignore 加 `*.hap` / `*.app` / `*.ipa`
② .githooks/pre-push 加第二道闸:**按内容**查,不按路径
—— 原先只按路径拦 agconnect-services.json,而 hap 里它只是
一个 zip 条目,逐条列举路径追不上产物形态。
判据形态:本次推送范围内**新增或修改**的构建产物(`*.hap/*.app/*.ipa`)
→ 内容里出现 `agconnect-services.json` 这个**条目名** ⇒ 拦。
用条目名而非凭证值:值会变而条目名稳定,且搜值需把凭证读进内存。
## ★ 这一格踩了三个坑,每个都让闸**静默放行**(都实测过)
① `git diff "a..b"` 取不到"该范围新增的文件" —— 它的语义是
「工作区 vs b」,**干净工作区上恒为空**。第一版这么写,
实测含真凭证的 hap 被放行、push exit=0。
⇒ 改 `git log --diff-filter=AM --name-only`(天生吃范围)。
② `git rev-parse "a..b:path"` 输出**两行**(blob sha + `^a`),
赋进变量是多行值 ⇒ 后续 `cat-file` 失败 ⇒ 被 `|| blob_sha=""`
兼掉 ⇒ 静默跳过。
`git ls-tree "a..b"` 也报错(不接受范围)。
⇒ 正确形状:rev-list 取 tip → ls-tree <tip> 取 blob。
③ ★★ `grep -q` 接管道 + `set -o pipefail`:grep 命中即退出 ⇒ 上游
`git cat-file` 收 SIGPIPE 退 **141** ⇒ pipefail 取各段合取 ⇒
整条管道 141 ⇒ `if` 判假。**"找到凭证"被读成"没找到"。**
实测 PIPESTATUS=141 0。
失效方向恰好是**放行**,与本钩子"宁可推不出去"相反。
⇒ 去掉 `-q`,让 grep 读完整条流(最后一段自然是它的码)。
## 验证(四态,都实测过)
含真凭证的 hap ⇒ 拦,裸仓没收到 commit
干净的 hap ⇒ 放行(只有 example.json)
AGC json 路径 ⇒ 仍拦(第一道没坏)
删分支 ⇒ 不误拦
commit-hygiene ⇒ 绿
|
2026-09-28 08:26:15 +08:00 |
|
|
|
f460ccf7e4
|
docs(debt): 补记之十六 —— 复核 pi 99320f45(两处早已在案)+ 记录两条**独立测量互证**的表
① ★ 那封信(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; 未改产品代码
|
2026-09-28 04:11:11 +08:00 |
|
|
|
201644749a
|
docs(debt): 补记之十五 —— 修法形状**定稿**:必改点三处→**四处**,③ 的键必须是**三键**(我补了 pi 没列的场景,结论反转)
① ★★ pi 场景C 复现成立: 同 agent + 同 platform_id + 两个 workspace(改完 PK 的未来态)
① 只按 **agent** ⇒ **2 行** ⇒ QueryRow 仍取第一行 ⇒ 照"按 agent"改会**踩新歧义** ✓
② ★★★★ 我构造了 pi 没列的**场景D**(**两个 agent 共用同一 workspace**),结论反转:
② 只按 **workspace** ⇒ ★ **2 行** ⇒ **也不够**
③ agent + workspace 两把 ⇒ **1 行** ✓
⇒ ★★ 场景D **不是假想**: 生产 `sessions` 里现成就有 ——
`ba9c194b` from_agent=[pi] ws=/home/program/agentmail
`9742de96` from_agent=[dsh] ws=/home/program/agentmail ★ 同一 workspace、两个 agent
⇒ ⇒ **三键 (platform_id, agent_name, workspace) 是唯一在 A/B/C/D 全场景恒为 1 行的键** ✓
pi 的结论(两把一起)**成立且是必需的**; 我补的是"为什么不能只留一把"
③ ★★ `sessions.workspace` 口径要写全: pi 的「9/9 非空」✓ 成立,但那是
**有 platform_id 的 9 行**这个子集; 全表是 **51/79**(那 28 条空的**全都没有 platform_id**)
⇒ 对**要改的那条 JOIN** 而言 workspace **总是可用** ✓,且**不需新加数据/迁移** ✓
⇒ ★ 教训: 同一句"非空率"不写**分母**时 9/9 与 51/79 都能自称"非空"
⇒ 报比例**必须带分母定义**(与 ⑫′ 同源)
④ ★★ 补验上一版标的 unknown: 有 platform_id 的 9 行 `from_agent` **9/9 非空** ✓
(与 workspace 同为"有 platform_id ⇒ 必非空")⇒ ③ 的两个键**都有数据支撑**,不需新增列
⑤ ⇒ ★★★★★ **必改点定稿(四处,必须同批)**:
① PK → `(agent_name, workspace, platform_id)`
② DELETE(`:63`) 按 workspace 限定
③ JOIN(`:473-474`) **三键**: `ON aps.platform_id = s.platform_id
AND aps.agent_name = s.from_agent AND aps.workspace = s.workspace`
④ 迁移重建表(`BeginTx` 内 DROP+RENAME+**RENAME 后**重建 `idx_platform_sessions_ws`)
⇒ ⚠️ ③ **必须**与 ① 同批: 单独改 ① 会把 ③ 的歧义**放大**(场景C 实测)
⇒ 顺带记: `pid=mail-xxxx` 那类 platform_id 与 workspace 1:1 的行看着够用,但**不能依赖**(上两行即反例)
校验: go test ./internal/repo/ -run Debt -count=1 ⇒ ok; /tmp/sc 已清; 未改产品代码
|
2026-09-28 04:09:09 +08:00 |
|
|
|
af4190d145
|
docs(debt): 补记 python-probe-shadowing —— 采纳 pi 两因子刻画,并把我两处措辞**收紧**(我自己先审自己)
① ★★ 它推翻的"混入+rc=0 不可达"**我早已自己推翻过**(`docs/API.md:6755` 就写着"被 pi 反例推翻")
⇒ 我自查本轮新登记的原文: 那里写的是「**可能** rc=0」(不绝对)⇒ **没有**重新断言
⇒ ★ 准确定性: 不是"复犯", 是**措辞没守住已结案的边界** —— 而这本身是新的一类错
② ★★ 两因子刻画,我实测**成立**且比它的表述更准:
反例复现: `try: import json / except: json=None` ⇒ **rc=0 + SHADOW_OUTPUT 混入 + marker 仍在** ✓
对照(不 catch、异常逃逸)⇒ 混入仍在但 **rc=1** ✓ ⇒ 两者**独立** ✓
⇒ 采纳: 「**混入**由"遮蔽文件被执行"决定;「**rc**」由"异常是否逃逸"决定(取决于调用方 catch)」
⇒ 两个**正交**因子,必须**分别**断言,合起来才是"完全静默"
⇒ ★ 并把它的"必然"**收紧一格**(我实测): "混入必然"成立于「**脚本与影子同目录**」这个前提下
(此时脚本目录在 sys.path[0]、影子总是先被找到,两种导入顺序实测都命中);
但影子文件**自身不写 stdout** 时 ⇒ **执行了却不混入**
⇒ 准确说法: 「**被执行**必然、**混入**取决于影子是否写 stdout」
③ ★★ 我自己那句"真危险形态"(`2>/dev/null` 拿到别人的文本)标**不完整**:
完整的完全静默 = **混入** × **rc 由 catch 决定** × **stderr 被丢弃** —— 三者同时成立时
⇒ 输出被替换、退出码正常、连报错通道都被关掉 ⇒ **无任何可观测征兆**
⇒ 与 `recount-relay-counts.sh` 那条同族: **"没报错"≠"没出错"** ⇒ 判据必须**独立于 rc**
④ ★ 采纳它的可判自检: 报"**条数**"前先问「**这个量有几个来源**」,多来源须**列出各自贡献**
⇒ 与 ⑫′(报数带查询)、④′(按机制分类)同族 ⇒ 并入本条
⑤ 顺手核了一遍"我在已结案错误上又踩了几次": 全文仅 "同强" 一处残留,且**正是我已订正的那句**
⇒ 无未修残留
校验: go test ./internal/repo/ -run Debt -count=1 ⇒ ok; /tmp/tf* 已清; 未改产品代码
|
2026-09-28 04:07:00 +08:00 |
|
|
|
2b9bf656f7
|
docs(debt): 补记之十四 —— pi 249fe29d §三 第三处成立且**比它说的更重**:改 PK **不解决**它,反而让它变成常态路径
① 核 pi 指的代码属实: `platform_sessions.go:467` `PlatformSessionFor` 用 **QueryRowContext**,
`:473-474` 的 `LEFT JOIN ... ON aps.platform_id = s.platform_id` —— ON 子句**只按 platform_id**、
**不含 agent/workspace** ⇒ 多行时 **QueryRow 静默取第一行**
② ★★ 实测复现 (/tmp/p3): 同一 `ses_ABC` 挂 pi 与 dsh 两条 ⇒ 返回 **agent_name = dsh**,
而调用方是 **pi 的会话** ⇒ **归属方取错**、无报错无告警
③ ★★★★ 比 pi 说的更重的一层 —— 用**改后**的 PK `(agent_name, workspace, platform_id)` 建表实测:
同一 `(pi, ses_ABC)` 插**两个不同 workspace** ⇒ 都插得进(**这正是改 PK 的目的**)
`:473` 的 JOIN 只按 platform_id ⇒ 匹配 **3 行** ⇒ QueryRow 仍取第一行
⇒ ★★★ 改 PK **之前**旧 PK `(agent_name, platform_id)` 把"一 agent 一 platform 只能有一个 ws"
压住了 ⇒ 这条歧义**几乎触发不到**;
改 PK **之后**同一 agent 的同一 platform **可以**有多个 ws ⇒ 歧义**变成常态路径**
⇒ ★★ ⇒ 第三处**必须与 PK 同批改**; 不改的话本次修复会把一条"今天几乎触发不到"的取错归属
**升级成天天可能触发**
④ ★★ 承重(我核了调用链,故认同它"比候选列表更重"):
`notify/mail.go:94` `platformID, platformOwner := repo.PlatformSessionFor(ctx, m.SessionID)`
→ owner **直接决定投给谁**(`:105-109`: platform_id 只发给归属方; owner 为空则**一律不下发**)
→ `:90-93` 注释记着**生产实测过的真实故障**(pi 的会话被推给 dsh ⇒ 邮件静默消失)
⇒ 失败形状是**静默**的(与 `ReleaseRelay`、`/tmp` 影子模块同族)
⇒ pi 说 `:205/:292` 已按 workspace 限定、不受影响 —— 我核了,**属实** ✓
⑤ ★★ ★ 自查并**当场撤回**我上一版写的"**循环依赖**"(那是我加的,pi 没提):
复核 `:94` 在 `Recipients` **函数顶部、只算一次**(CC 循环在 `:239`)⇒ **无循环** ⇒ 撤
⇒ 但复核时暴露一个**更基础**的问题(改标**未解**、不假装已知修法):
`owner` 是**全局一个**的值,而一封邮件**可有多个参与方**(`m.CC`)
⇒ 一次 `PlatformSessionFor` 回答的是"这条**会话**归属谁",
而分发需要的是"这封信对**每个参与方**各自是什么" —— **不是同一个问题**
⇒ 修好 JOIN 消歧只让"会话归属"变**确定**; "多参与方各自该不该收 platform_id"仍**未设计**
校验: go test ./internal/repo/ -run Debt -count=1 ⇒ ok; /tmp/p3 已清; 未改产品代码
|
2026-09-28 04:04:28 +08:00 |
|
|
|
928d2714e9
|
docs(debt): 新登记 python-probe-shadowing-in-tmp —— ★ 触发条件是**脚本所在目录**(不是 cwd),且**静默 rc=0**
① 复核 pi `b1bd61ef` §四: **它对,我此前的"换目录跑"规避无效**。实测 (/tmp/shadow):
脚本在 /tmp/shadow/t1.py、cwd=`/` ⇒ **仍被污染** ⇒ `sys.path[0] == '/tmp/shadow'`
⇒ 正确说法: `sys.path[0]` = **脚本自身所在目录**; 换 cwd 完全无效
⇒ `python3 -I` **有效**(隔离模式不把脚本目录放进 sys.path)✓
② ★★ 真链路比"某个脚本 import 了 json"宽得多:
`json/__init__.py` 内部 `import re` ⇒ `re/__init__.py:124` `import enum`
⇒ **任何 `import json` 的脚本**都会执行同目录下的 `re.py` / `enum.py`
③ ★★ 严重性实测: 伪造 `json.py` 让 `json.load()` 返回 `110` ⇒ 被污染脚本
**正常跑完、rc=0、stdout 混进别人的输出**
⇒ 形状 = "混入别人的输出且可能 rc=0" —— 与 `ReleaseRelay` 那条同族: **不报错、不失败、只是答案换了**
⇒ 本轮已因它撤过一次结论("11/12 候选=0"),故必须留档
④ ⇒ ★★ **撤销**我此前给的规避"换目录跑"(对"脚本在 /tmp"**不成立**)
⑤ ⇒ 自查我本会话**结论是否受影响**(非辩解,是必查项):
我所有 python 探针都是 **heredoc(不落盘)** ⇒ 走 stdin、`sys.path[0]` 是 `''`(cwd),
而我的 cwd **从不是 /tmp** ⇒ **免疫**(已实测对照: 落盘脚本被污染、heredoc 干净)
我落过盘的探针目录(idxtest/h3/v1/pktest)**只含 sqlite 命令、无 .py** ⇒ 不触发
⇒ 结论: 已提交结论**未被污染**; 但只要有人改成"落盘 .py 再跑"就开始不可信,且**无任何报错**
⑥ ⇒ 可执行规则(替代"小心一点"): ① 落 .py **不放 /tmp**(或任何多人共用目录)
② 必须放则 `python3 -I`,或先 ls 确认同目录无 json.py/re.py/enum.py/os.py/sys.py
③ 首选 **heredoc 不落盘**(天然免疫)
④ 症状识别: 输出出现**没写过的行**、或 rc=0 却结果离谱 ⇒ 先查同目录影子文件
校验: go test ./internal/repo/ -run Debt -count=1 ⇒ ok; /tmp/shadow* 已清; 未改产品代码
|
2026-09-28 04:02:00 +08:00 |
|
|
|
2b8eeae3a6
|
docs(debt): 新登记 prune-artifact-evidence-decays-with-reboot —— ★ 物证①的**前提已失效**(/tmp 是 tmpfs,重启即全失)
① 复核 pi `c6dbc8d0` §三: 他的加强物证**当时是对的且测法扎实** ——
prune 的 `.backup` 在删除前**无条件**执行(:95)、路径**字面硬编码** `/tmp`、不做 env 覆盖;
且他用「争议窗口 ±1h 内**有 21 个别的文件存活**」钉住「不是被清掉了」这个前提 ✓
② ★★★ 但我在 2026-09-27 04:10 复测,那个前提**已经不成立**:
早于 09-26 09:32 的 /tmp 文件 = **0 个**、争议窗口(04:00–06:30)内也是 **0 个**
而 `tmpfiles.d/tmp.conf:11` = `q /tmp 1777 root root 10d` ⇒ **1 天内不该被清**
⇒ 唯一解释: **/tmp 经历过清空/重启**; 且 `findmnt /tmp` ⇒ **tmpfs**
⇒ ★★ **重启即全失**,与 10d 策略无关、**不可恢复**
⇒ 所以「现在 0 个备份」**此刻已不能**推出「09-26 04:57 那会儿没跑过 --apply」
③ ★ 定稿已就地降级(`docs/API.md` 第(E)节该条划删除线 + 写明):
· `reset-demo` **可排除** —— 凭物证②(`/opt/agentmail/backups/`,**非 tmpfs** ⇒ 跨重启存活,
0 文件 + mtime 停在 09-14 17:26)✓ **不受影响**
· `prune` 的"未跑过"**只在 2026-09-26 04:10 之前**(当次会话内)成立; 此后**需重新取证**
⇒ 即"三条腿"里最强的那条**已过期**,剩下的②③是"本机默认路径/默认库"
④ ★★ 可判形状(登记为 `prune-artifact-evidence-decays-with-reboot`,余额 31→32):
凡以「缺失的产物」为物证,**必须同时记录**三项,缺一即降级:
① 该路径会不会被自动清理(tmpfs / tmpfiles / logrotate / 手工 rmtree)
② 「不是被清掉了」的**同时段旁证** —— 须**取证当时**记,**事后不可补**
③ 取证时刻 —— ①②③ **都会过期**
⇒ 与已记的「口径会随时间漂」(`recount-labels-must-match-predicates` 补记)同族:
那条是**数字**会过期,这条是**物证**会过期。
⇒ ★ 由此得一条**该做而没做**的: 物证会过期 ⇒ **结论就该带时刻**。
我们此前把「prune 没跑过」写成**无时刻的现在时** ⇒ 本次纠正这个写法。
校验: go test ./internal/repo/ -run Debt -count=1 ⇒ ok; 围栏 1692 配平; 未改产品代码
|
2026-09-27 04:11:38 +08:00 |
|
|
|
3f1abbf0c4
|
docs(debt): 补记之十二/十三 —— pi 陷阱①复现成立且**修法已实测跑通**;★★ 陷阱②它的**理由方向反了**(会导出错误动作)
① ★★★★ 陷阱①(逐条 Exec ⇒ 各自 auto-commit ⇒ DROP 后崩)—— **我独立复现,完全成立**:
播下 aps 行数=1 → 建 _new → 拷贝 → DROP ⇒ `aps` 存在=**0**、`aps_new` 行数=1
下次启动: `CREATE TABLE IF NOT EXISTS aps(新PK)` 建出**空表** ⇒ 实测行数=**0**(线上即 37 行)
守卫只看 PK ⇒ PK **已正确** ⇒ **跳过重建** ⇒ **永不自愈**; 而判据 (a)(b) 此时**全绿**
⇒ 绿着丢数据
② ★★ 修法**实测跑通**(非纸面): 同一 `BEGIN…COMMIT` 内 建 _new→拷贝→DROP→RENAME→**重建索引**
⇒ 行数=1(保住)✓ 索引=1(补回)✓ `_new` 残留=0 ✓ PK=(a,b,w) ✓
⇒ 索引那条**必须写在 RENAME 之后且在事务内**(写在 RENAME 之前会被 init DDL 那句空转掩盖)
⇒ 采纳 pi 的"④ 从『保证顺序』改成『**事务内显式重建索引**』"—— 顺序依赖被事务消掉
⇒ ⚠️ 但**仅靠事务不够**: 防不了"上一版已崩"留下的孤儿 `_new` ⇒ 判据 (c) 与孤儿可恢复是**必需**第二道
③ ★★★ 陷阱②(守卫不能用 LIKE)—— 陷阱为真,但**理由方向反了**:
pi 说: "'…PRIMARY KEY (…' **带空格**,**实际无空格**"
★ 我实测: `sqlite_master.sql` **逐字保留**建表语句、不规范化空白
建 `PRIMARY KEY (a, b)` ⇒ 库里带空格 ⇒ **带空格的 LIKE 命中**
建 `PRIMARY KEY(a, b)` ⇒ 库里无空格 ⇒ 带空格的 LIKE **不命中**
线上实测: `agent_platform_sessions` 的 `PRIMARY KEY (agent_name, platform_id)` **带空格**、
`LIKE '%PRIMARY KEY (agent_name, platform_id)%'` **命中=1** ✓
`init_sqlite.sql:421`(及 :328/:380/:389)用的**就是**带空格写法
⇒ ★★ 真正机制: **LIKE 命中与否取决于当初 DDL 的书写风格**,而书写风格不是不变量
⇒ 结论与 pi **一致**(用 `pragma_table_info` 的 pk 列,别用 LIKE)但**理由不同**
⇒ ⚠️★ 照 pi 的理由去改(例如为"匹配无空格"把 DDL 改成紧凑写法)**恰好制造它描述的故障**:
改完 DDL 后老库 `sqlite_master.sql` 仍是旧样式(IF NOT EXISTS 不重写)⇒ LIKE 反而不命中
⇒ ★ 本轮最值得记的: **正确结论 + 错误理由 ⇒ 导出错误动作**(我差点照错误理由去改)
校验: go test ./internal/repo/ -run Debt -count=1 ⇒ ok; /tmp/v1 已清; 未改产品代码
|
2026-09-27 04:09:42 +08:00 |
|
|
|
f2bd55f6ce
|
docs: 订正定稿第(E)节 —— 物证的**强度**须限定(产物是"本机"的),并**撤回**一句我查无实据的追认
① ★★ 订正(本次自查发现,同一文件两处口径不一):
7878 那条"『prune --apply』与『reset-demo』都未跑过 —— 凭谓词无关的产物"**缺限定词**:
· 产物是**本机**的 ⇒ 排除的是「**本机**任何 `--apply`(不论谓词、不论库)」,
**不是**「任何机器上都没跑过」—— 后者**无任何证据**
· 物证② 再限一层: `PREFIX` **与** `DB` 都可被 env 改 ⇒ 只覆盖「**默认 prefix + 默认库**」
⇒ 而本文 8154 早已写了"物证① 覆盖本机任何 --apply; 物证② 只覆盖默认 prefix"
⇒ 同一文件**两处口径不一致** ⇒ 此处补齐
② ★ 三条腿的真强度(据覆盖面推出): ① 本机谓词无关 > ② 本机默认路径 > 「库非空」默认路径
③ ⚠️ ★★ 撤回一句**我自己的追认**: 我在同一次编辑里写"我曾把②③说成与①同强,那是过强"
—— 查提交史 `git log -S/--grep` **找不到任何这样的记录**
⇒ ★ **不据此追认我有过那个错**(我近几轮正因"没查就归因"吃过两次亏,见 6b272ae)
⇒ 改为: 那是**此刻**据覆盖面推出来的排序,**不是**我早先的原话
④ 复核两件物证此刻仍成立: /tmp/agentmail-pre-prune-* = **0 个** ✓;
/opt/agentmail/backups/ = **0 文件**、目录 mtime 仍是 **Sep 14 17:26**(未被动过)✓
⑤ 复核 pi `3beda7e2` §二 的"数据不能判别"(我独立算了一遍):
假设 P(bound 556/占位 4) 与 假设 D(bound 559/占位 1) 对全部观测
(T1 total=560、T2 total=557、今天 bound=557、Δtotal=−3)**全部吻合** ✓
⇒ 差别只在 T1 的拆分 ⇒ **bound(T1) ∈ [556,559]** 才是正确表述(pi 正确,我早先的"精确 556"是把假设当推论)
⑥ 本次未改产品代码; 围栏 1692 配平; /tmp/h3 已清
|
2026-09-27 04:07:46 +08:00 |
|