fix(gateway,loop,tool): 解耦「工具互斥身份」与「checkpoint 归属」(修普通回合不受保护)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

缺口:「1 普通回合 + 1 并行路」时普通回合既不登记也不被检查 —— 因为互斥身份取自
loop.ParallelLegFrom,而网关只给 parallel=true 那一路盖它。直接给普通回合盖 WithParallelLeg
会把它的 checkpoint 键从 sess 变成 sess#turn-x(改崩溃恢复语义),故改为两条独立通道:
- 工具互斥身份:loop.WithToolLegID / ToolLegIDFrom(新增),网关在 beginLegLocked 内【无条件】盖,
  取值 = 本路 turnID ⇒ 普通回合 / 队首续跑路 / 并行路 三条路都覆盖
- checkpoint 归属:WithParallelLeg 一行未改,仍只有真并行路 ⇒ 恢复语义零变化
守门断言 TestCheckpointOwnerIgnoresToolLegID:普通回合 ctx 下 AgentID 必须逐字仍是 sess。
变异 3 条:互斥身份回退只认 ParallelLegFrom(判忙失效)/ 顺手把 checkpoint 改读新通道
(守门断言红,证明确实解耦)/ 普通回合不盖身份(判忙失效)。
另修 race:-race -count=2 三包全绿;诚实边界含一个既有竞态(同一路并发登记共享,建议后续小切片)。
This commit is contained in:
AskaEth committed 2026-10-05 09:53:08 +08:00
1 parent d64b26d499
commit ef3dcb699b
9 files changed
+673 -53

No files matched your search

+41 -1
View File
@@ -14,7 +14,9 @@ import (
// 真并行时两路各写各的归属键,LoadLatest 因此天然按路隔离 —— 否则两路合写同一个
// key,后写的那份会把前一路的窗口整段顶掉(LangGraph `Send` 扇出的同族教训)。
//
// 单路口径是**逐字不变**的:ctx 上不挂路标识时归属键仍等于 sessionID。
// 单路口径是**逐字不变**的:ctx 上不挂路标识时归属键仍等于 sessionID —— 包括那种
// 「挂了工具互斥身份、但没挂并发路标识」的 ctx(普通回合的实际形状,见
// TestCheckpointOwnerIgnoresToolLegID:两条通道必须保持解耦)。
// keyedCheckpointStore 是按 AgentID 分桶的内存 checkpoint 存储,用来证明归属键
// 真的按路隔离(判据是**键**,而不是「循环怎么调用 store」)。
@@ -60,6 +62,44 @@ func TestCheckpointOwnerSingleLegUnchanged(t *testing.T) {
}
}
// TestCheckpointOwnerIgnoresToolLegID 是**守门断言**(design/15 §2.5.7 与 §2.5.9
// 解耦的守卫):checkpoint 归属键只认 WithParallelLeg,**绝不**认工具互斥身份
// (WithToolLegID)。
//
// 它的存在是为了让「两个用途又被焊回去」变成一条会红的用例:网关给**每一条**在途路
// 都盖互斥身份(普通回合也有),一旦 checkpointAgentID 改读那条通道,单条普通回合的
// 归属键就会从 `sess` 升成 `sess#turn-1` —— 崩溃恢复语义随之改变(旧快照读不到、
// 凭空多出按路隔离)。
func TestCheckpointOwnerIgnoresToolLegID(t *testing.T) {
t.Parallel()
store := newKeyedCheckpointStore()
l := New(Options{Checkpoint: store})
// 普通回合的实际形状:有互斥身份(本路 turnID),**没有**并发路标识。
ctx := WithToolLegID(context.Background(), "turn-1")
window := []model.Message{model.TextMessage(model.RoleUser, "hi")}
l.saveCheckpoint(ctx, "sess", 3, window, agent.StopReasonToolCalls)
if len(store.saved) != 1 {
t.Fatalf("saved = %d, want 1", len(store.saved))
}
if got := store.saved[0].AgentID; got != "sess" {
t.Fatalf("互斥身份绝不能改变归属键:AgentID = %q, want %q(逐字不变)", got, "sess")
}
if got := store.saved[0].ID; got != "sess:3" {
t.Fatalf("互斥身份绝不能改变归属键:checkpoint id = %q, want %q", got, "sess:3")
}
// 续接侧同样只看归属键:LoadLatest 请求的键仍是 sessionID,快照照样能接上。
reader := &recordingLoadStore{inner: store}
l.checkpoint = reader
if _, step, resumed := l.loadCheckpoint(ctx, "sess", window); !resumed || step != 4 {
t.Fatalf("普通回合 resumed=%v step=%d, want true/4(有互斥身份也必须照旧续接)", resumed, step)
}
if len(reader.calls) != 1 || reader.calls[0] != "sess" {
t.Fatalf("LoadLatest 键 = %v, want [sess]", reader.calls)
}
}
// TestCheckpointOwnerIsolatedPerLeg 钉住归属键按路隔离:同一会话的两条路各写各的
// 键,且各自 LoadLatest 只命中自己的那份 —— 任何一路都绝不会把对方的崩溃快照
// 当成自己的续接。