diff --git a/server/internal/repo/platform_sessions.go b/server/internal/repo/platform_sessions.go index d59cc43..a6d60bb 100644 --- a/server/internal/repo/platform_sessions.go +++ b/server/internal/repo/platform_sessions.go @@ -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 下标 diff --git a/server/internal/repo/platform_sessions_test.go b/server/internal/repo/platform_sessions_test.go index 87c2e07..b53d558 100644 --- a/server/internal/repo/platform_sessions_test.go +++ b/server/internal/repo/platform_sessions_test.go @@ -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) + } + } +}