fix(repo): platform_sessions 整表替换的域是 (agent, workspace) 而非 agent
修 `DEBTS.json` 里记的 `platform-mirror-replace-domain-too-wide`
(pi `b9c7308c` 报的,当时只做了定位未修)。
# 缺陷
`ReplacePlatformSessions` 的 DELETE 域是 `agent_name` 单列,而**每个上报者
只知道自己一个 directory**:
plugins/opencode-mail-bridge/index.js:1147
client.session.list({ query: directory ? {directory} : undefined })
⇒ A 工作区的桥上报一次就把 B 工作区上报过的镜像全擦掉,下个工作区的桥
再上报又擦掉 A 的。表现为「镜像按 project 轮换」。
# 生产实测(不是推断)
sqlite3 agent_platform_sessions GROUP BY workspace:
dsh 77 条散在 **25** 个工作区(/home/program/agentmail 25、/tmp 20 …)
pi 151 条散在 **62** 个工作区
# 后果已在生产数据上可见
镜像被擦 ⇒ `notify/mail.go` 的 `PlatformSessionFor` 查不到 ⇒
`sessions.platform_id` 留空。实测 **18 条活跃会话里 17 条 `platform_id` 为空**。
空 platform_id 不止"少个跳转":`notify/mail.go:94` 用它决定
`platform_session_id` 发给谁,owner 取错就抛「平台侧会话已删」⇒ 邮件静默消失。
# 修法
DELETE 域收窄到**本次上报覆盖的那些工作区**(wsOrder,去重保序)。
一次上报跨多个工作区 ⇒ 那些各自整表替换;本次没出现的一律不动。
仍然是"整表替换"而非增量合并 —— 镜像是平台快照,增量合并会让已删会话永远
留在候选里,而 session 位是三态语义、指向不存在的会话直接 404("选了却送不到")。
## ★ 一条判据覆盖不到的分支,单独补了判据
`if len(wsOrder) > 0` 这个守卫(wsOrder 为空 ⇒ 什么都不删)**既有判据碰不到**:
所有既有用例传进来的 list 都带 workspace。实测把守卫改成 `>= 0`(空清单也按
agent 清,退回缺陷),**全部既有判据仍然绿**。
补 `TestReplacePlatformSessionsWithNoWorkspaceKeepsEverything`:
一次不带 workspace 的上报后,`/A` 与 `/B` 的镜像都必须还在。
变异验证:该判据能抓住这个变异(而既有判据抓不住)。
# 关于"空 IN ()"
守卫去掉会拼出 `workspace IN ()`。SQLite 与 PostgreSQL **都**是恒假(不报错),
所以行为上等价 —— 但那是依赖两个数据库的隐式巧合,不是读代码能看出来的保证。
守卫保留,并在注释里写明这一点。
# 生产验证
部署后用 opencode 的真 key 打一次带 `workspace=/ZZZ` 的心跳:
· 写入 opencode /ZZZ 1 行
· **dsh 的 25 条 /home/program/agentmail 镜像一行没少** ✓
(修前这次上报会把它们全擦掉。已 DELETE 掉测试行)
注:三个桥本次心跳都没带 `platform_sessions`(opencode 的 `reportSessions`
在 `directory` 为空且拉取失败时返回 `undefined`,服务端按 nil 跳过替换),
所以"三次采样镜像不变"**不能**作为修复生效的证据 —— 上面那次主动打心跳才是。
# 未解决(DEBTS 那条的后半)
`agent_platform_sessions` 主键仍是 `(agent_name, platform_id)`:
同一个 platform_id 出现在两个 workspace 会撞 UNIQUE ⇒ 无 ON CONFLICT +
defer Rollback ⇒ 整个 DELETE 回滚 ⇒ 镜像永久停滞。
本改动只消除"擦错别人",没消除"同 id 跨 ws 撞约束"。要不要给 PK 加 workspace
仍未决(涉及 SQLite 需重建表 + 具名索引会丢 + 孤儿 _new 表自愈,见 DEBTS 原文)。
This commit is contained in:
@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@ -39,11 +40,55 @@ type PlatformSession struct {
|
||||
// 插件按最近活跃排序后上报前 N 条即可。
|
||||
const maxPlatformSessions = 200
|
||||
|
||||
// ReplacePlatformSessions 整表替换某 Agent 的平台会话镜像。
|
||||
//
|
||||
// 整表替换而非增量合并:镜像是平台当前状态的快照。增量合并会让已经删掉的
|
||||
// 平台会话永远留在候选列表里,而那正是「选了却送不到」的来源
|
||||
// —— session 位是三态语义,指向一条不存在的会话会直接 404。
|
||||
/*
|
||||
ReplacePlatformSessions 替换某 Agent 在**本次上报所覆盖的那些工作区**里的镜像。
|
||||
|
||||
# 「整表替换」的域是 (agent, workspace),不是 agent(2026-09-26 修)
|
||||
|
||||
原先的 DELETE 域是 `agent_name` 单列 —— 而**每个上报者只知道自己一个 directory**:
|
||||
|
||||
plugins/opencode-mail-bridge/index.js:1147
|
||||
client.session.list({ query: directory ? {directory} : undefined })
|
||||
|
||||
于是 A 工作区的桥上报一次,就把 B 工作区上报过的镜像全擦掉;下个工作区的桥
|
||||
再上报,又擦掉 A 的。表现为「镜像按 project 轮换」。
|
||||
|
||||
# 生产实测(不是推断)
|
||||
|
||||
sqlite3 agent_platform_sessions 按 workspace 分组:
|
||||
dsh 77 条散在 **25** 个工作区(/home/program/agentmail 25、/tmp 20 …)
|
||||
pi 151 条散在 **62** 个工作区
|
||||
|
||||
# 后果已在生产数据上可见
|
||||
|
||||
镜像被擦 ⇒ `notify/mail.go` 的 `PlatformSessionFor` 查不到 ⇒
|
||||
`sessions.platform_id` 留空 ⇒ 收方拿不到平台会话 id。
|
||||
实测 **18 条活跃会话里 17 条 `platform_id` 为空**。
|
||||
|
||||
而空 platform_id 的去向不止"少一个跳转":`notify/mail.go:94` 用它决定
|
||||
`platform_session_id` 发给谁,owner 取错就抛「平台侧会话已删」⇒ **邮件静默消失**。
|
||||
|
||||
# 为什么仍然是"整表替换"而不是增量合并
|
||||
|
||||
镜像是平台当前状态的快照。增量合并会让已删掉的平台会话永远留在候选列表里,
|
||||
而 session 位是三态语义,指向不存在的会话会直接 404("选了却送不到")。
|
||||
⇒ 保持整表替换,只把**域收窄到本次上报覆盖的工作区**。
|
||||
|
||||
# 上报跨多个工作区时的语义
|
||||
|
||||
一次上报里出现多个 workspace ⇒ 那些工作区**各自**整表替换;
|
||||
**本次没出现的**工作区一律不动。
|
||||
|
||||
# 域里含 workspace 的必要性
|
||||
|
||||
`agent_platform_sessions` 的主键是 `(agent_name, platform_id)`。
|
||||
同一个 platform_id 出现在两个 workspace 会撞 `UNIQUE constraint failed`,
|
||||
而这里没有 `ON CONFLICT` + `defer tx.Rollback()` ⇒ **整个 DELETE 回滚**、
|
||||
`agents.go` 降级为 -1 ⇒ 表现为「心跳一直成功而镜像永久停滞」。
|
||||
⇒ 该主键是否也要加 workspace,见 DEBTS.json 的 platform-mirror-replace-domain-too-wide
|
||||
|
||||
(**那一项未决**:本函数只消除了"擦错别人",没有消除"同 id 跨 ws 撞约束")。
|
||||
*/
|
||||
func ReplacePlatformSessions(ctx context.Context, agentName string, list []PlatformSession) error {
|
||||
agentName = strings.TrimSpace(agentName)
|
||||
if agentName == "" {
|
||||
@ -53,15 +98,47 @@ func ReplacePlatformSessions(ctx context.Context, agentName string, list []Platf
|
||||
list = list[:maxPlatformSessions]
|
||||
}
|
||||
|
||||
// 本次上报覆盖了哪些工作区 —— DELETE 域就按它圈定。
|
||||
//
|
||||
// 用 map 去重且**保序**:同一工作区在 list 里出现多次只算一次(下面的
|
||||
// seen 也会按 id 去重,但 DELETE 域要先算出来)。
|
||||
wsSeen := map[string]bool{}
|
||||
var wsOrder []string
|
||||
for _, ps := range list {
|
||||
ws := strings.TrimSpace(ps.Workspace)
|
||||
if ws != "" && !wsSeen[ws] {
|
||||
wsSeen[ws] = true
|
||||
wsOrder = append(wsOrder, ws)
|
||||
}
|
||||
}
|
||||
|
||||
tx, err := db.DB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`DELETE FROM agent_platform_sessions WHERE agent_name = $1`, agentName); err != nil {
|
||||
return err
|
||||
// 只清本次上报覆盖的工作区。wsOrder 为空(上报里一项 workspace 都没有)
|
||||
// ⇒ **什么都不删**:那说明这次上报不带任何工作区信息,无从判断该清谁,
|
||||
// 按 agent 清等于回到那个缺陷。
|
||||
//
|
||||
// `len(wsOrder) > 0` 这个守卫不是可有可无的:空列表会拼出 `IN ()`,
|
||||
// 两种方言都恰好恒假(SQLite 与 PostgreSQL 都是),所以**行为上**与
|
||||
// "什么都不删"相同 —— 但那是依赖两个数据库的一条隐式巧合,而不是
|
||||
// 写代码的人读得出来的保证。显式守卫把意图摆在语句上。
|
||||
if len(wsOrder) > 0 {
|
||||
ph := make([]string, len(wsOrder))
|
||||
args := make([]any, 0, len(wsOrder)+1)
|
||||
args = append(args, agentName)
|
||||
for i, w := range wsOrder {
|
||||
ph[i] = fmt.Sprintf("$%d", i+2)
|
||||
args = append(args, w)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`DELETE FROM agent_platform_sessions WHERE agent_name = $1 AND workspace IN (`+
|
||||
strings.Join(ph, ",")+`)`, args...); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
@ -113,7 +190,6 @@ func SuggestSessionCandidates(ctx context.Context, forUser, peerName, path strin
|
||||
return suggestSessionCandidates(ctx, forUser, peerName, path, nil)
|
||||
}
|
||||
|
||||
|
||||
func suggestSessionCandidates(ctx context.Context, forUser, peerName, path string, onlyWorkspace *string) ([]SessionCandidate, error) {
|
||||
out := []SessionCandidate{}
|
||||
seen := map[string]int{} // alias -> out 下标
|
||||
|
||||
@ -423,3 +423,55 @@ func TestSuggestSessionCandidatesExcludesArchived(t *testing.T) {
|
||||
t.Fatalf("归档会话不该出现,实际 %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
★ 2026-09-26 补:一次上报**不带任何 workspace** 时,不得清掉任何工作区。
|
||||
|
||||
# 为什么这条独立于 TestReplacePlatformSessionsKeepsOtherWorkspaces
|
||||
|
||||
那条判据测的是"报了 /B 不动 /A"。而本次修的 DELETE 域计算里有一条**只在
|
||||
wsOrder 为空时生效**的分支(`if len(wsOrder) > 0`)—— 现有判据**碰不到它**:
|
||||
所有既有用例传进来的 list 都带 workspace。
|
||||
|
||||
实测:把那行改成 `if len(wsOrder) >= 0`(即空清单也按 agent 清,退回缺陷),
|
||||
**全部既有判据仍然绿**。这就是"判据覆盖不到的分支"的样子。
|
||||
|
||||
# 为什么空清单必须"什么都不删"
|
||||
|
||||
上报方(`client.session.list({query: directory ? {directory} : undefined})`)
|
||||
在拿不到目录时可以回一个**没有 workspace 字段**的列表。此时无从判断该清谁。
|
||||
按 agent 清 ⇒ 回到缺陷本身(清掉所有工作区);什么都不清 ⇒ 最坏是
|
||||
这个工作区的镜像陈旧,而陈旧的后果是"候选里多了已删的会话"(点下去 404),
|
||||
比"清错别人的"轻得多。
|
||||
|
||||
方向与整表替换那条一致:**宁可少清,不可错清。**
|
||||
*/
|
||||
func TestReplacePlatformSessionsWithNoWorkspaceKeepsEverything(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
seedPlatformAgent(t, "opencode")
|
||||
ctx := context.Background()
|
||||
|
||||
// 先有 /A 与 /B 两处镜像
|
||||
for _, ws := range []string{"/A", "/B"} {
|
||||
if err := ReplacePlatformSessions(ctx, "opencode", []PlatformSession{
|
||||
{PlatformID: ws + "-1", Workspace: ws, Slug: "s"},
|
||||
}); err != nil {
|
||||
t.Fatalf("%s 上报: %v", ws, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 一次**不带 workspace** 的上报(一项都没有)
|
||||
if err := ReplacePlatformSessions(ctx, "opencode", []PlatformSession{
|
||||
{PlatformID: "x", Slug: "no-ws"},
|
||||
}); err != nil {
|
||||
t.Fatalf("无 workspace 上报: %v", err)
|
||||
}
|
||||
|
||||
got := workspacesOf(t, ctx, "opencode")
|
||||
for _, ws := range []string{"/A", "/B"} {
|
||||
if got[ws] != 1 {
|
||||
t.Errorf("上报不带 workspace 时 %s 的镜像被清了(现存 %v)——"+
|
||||
"此时无从判断该清谁,必须什么都不删", ws, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user