package loop import ( "context" "errors" "path/filepath" "strings" "sync" "testing" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool" ) // 本文件是「互斥身份 → 工具层」这座桥的回归(design/15 §2.5.7、主人 2026-10-05 // 裁决 3):**每一条在途路**(普通回合 / 并行路 / 队首续跑路)都带互斥身份 —— // 网关按 loop.WithToolLegID 把它挂在 ctx 上(取值 = 该路的 turnID),工具执行点 // (tools.go 的 invokeOne)把它桥接成 tool.WithLegID,于是同一文件的第二条路拿到 // tool.ErrFileBusy,并以**回灌给模型的工具结果文本**形式浮出来(显式、可消化)。 // // 它与 checkpoint 归属(loop.WithParallelLeg)是**两条独立通道**,因此这里刻意 // 钉住两个方向:① 只有并发路标识(没有互斥身份)的 ctx **不**带来互斥;② 只有 // 互斥身份的 ctx(普通回合的实际形状)**照样**带来互斥 —— 后者正是「1 普通回合 // + 1 并行路」同文件写的修复目标。 // // 单路口径同样钉住:ctx 上没有互斥身份(子代理 / 后台思考 / 未接编排器)时,同一 // 文件的两条调用照跑 —— 完全不做互斥,行为逐字不变。 // legFileProbeTimeout 是「第二路必须立刻拿到判忙结果」的上界(只用来把阻塞式 // 实现暴露成失败,不参与竞态)。 const legFileProbeTimeout = 5 * time.Second // legFileTool 是实现了 tool.FileTargeter 的 mock 工具:声明本次要写的文件, // 可阻塞执行,并记录它在 Execute 里读到的互斥身份(用来直接钉住桥的存在)。 type legFileTool struct { name string file string entered chan struct{} hold chan struct{} once sync.Once mu sync.Mutex legs []string } func (m *legFileTool) Name() string { return m.name } func (m *legFileTool) Description() string { return "leg file mock tool" } func (m *legFileTool) Parameters() (map[string]any, error) { return map[string]any{ "type": "object", "properties": map[string]any{"path": map[string]any{"type": "string"}}, "required": []any{"path"}, }, nil } // FileTargets 实现 tool.FileTargeter:本次调用要写 file。 func (m *legFileTool) FileTargets(any) []tool.FileTarget { return []tool.FileTarget{{Op: tool.FileOpWrite, Path: m.file}} } func (m *legFileTool) Execute(ctx context.Context, _ any) (any, error) { m.mu.Lock() m.legs = append(m.legs, tool.LegIDFrom(ctx)) m.mu.Unlock() if m.entered != nil { m.once.Do(func() { close(m.entered) }) } if m.hold != nil { select { case <-m.hold: case <-ctx.Done(): return nil, ctx.Err() } } return "ok", nil } // seenLegs 返回 Execute 里读到的路标识快照。 func (m *legFileTool) seenLegs() []string { m.mu.Lock() defer m.mu.Unlock() return append([]string(nil), m.legs...) } // awaitEntered 等工具进入 Execute(确定性同步,不靠 sleep 抢时序)。 func awaitEntered(t *testing.T, ch <-chan struct{}, msg string) { t.Helper() select { case <-ch: case <-time.After(legFileProbeTimeout): t.Fatal(msg) } } // TestInvokeOne_ToolLegIDReachesTool 直接钉住桥:ctx 上挂了**互斥身份** // ⇒ 工具在 Execute 里读到同一个值;没挂(子代理 / 后台思考 / 未接编排器) // ⇒ 读到空串(⇒ 完全不做互斥)。它同时钉住**被解耦的那个方向**:只挂 checkpoint // 归属键(loop.WithParallelLeg)**不**构成互斥身份 —— 两条通道各管各的。 func TestInvokeOne_ToolLegIDReachesTool(t *testing.T) { t.Parallel() holder := &legFileTool{name: "patch", file: filepath.Join(t.TempDir(), "notes.txt")} d := dispatcherWith(t, holder) l := New(Options{Tools: d}) call := agent.ToolCall{Name: "patch", Args: map[string]any{"path": "notes.txt"}} if out := l.invokeOne(WithToolLegID(context.Background(), "turn-x"), d, call); out.Err != nil { t.Fatalf("带互斥身份的调用: %v", out.Err) } if out := l.invokeOne(WithParallelLeg(context.Background(), "turn-x"), d, call); out.Err != nil { t.Fatalf("只挂 checkpoint 归属键的调用: %v", out.Err) } if out := l.invokeOne(context.Background(), d, call); out.Err != nil { t.Fatalf("无身份的调用: %v", out.Err) } if legs := holder.seenLegs(); len(legs) != 3 || legs[0] != "turn-x" || legs[1] != "" || legs[2] != "" { t.Fatalf("工具读到的互斥身份 = %v, want [turn-x ](空串 = 不参与互斥;checkpoint 通道不构成身份)", legs) } } // TestInvokeOne_NormalTurnVsParallelLegSameFileBusy 钉住**本次修复的目标**: // 「1 普通回合 + 1 并行路」的会话里,持锁的是那个**普通回合** —— 它的 ctx 上只有 // 互斥身份(普通回合不挂 checkpoint 归属键),照样必须登记并对同文件的并行路判忙: // 第二条路**不执行**、**不等待**,拿到 tool.ErrFileBusy,且回灌给模型的文本里带中文 // 文件状态(模型据此自己决定稍后重试)。 // // 两条 ctx 刻意按**生产形状**构造:普通回合 = 只有 WithToolLegID;并行路 = 两条 // 通道都有(WithToolLegID + WithParallelLeg,取值同为该路 turnID)。 func TestInvokeOne_NormalTurnVsParallelLegSameFileBusy(t *testing.T) { t.Parallel() file := filepath.Join(t.TempDir(), "notes.txt") holder := &legFileTool{name: "patch-a", file: file, entered: make(chan struct{}), hold: make(chan struct{})} peer := &legFileTool{name: "patch-b", file: file} d := dispatcherWith(t, holder, peer) l := New(Options{Tools: d}) callA := agent.ToolCall{Name: "patch-a", Args: map[string]any{"path": file}} callB := agent.ToolCall{Name: "patch-b", Args: map[string]any{"path": file}} normalCtx := WithToolLegID(context.Background(), "turn-normal") parallelCtx := WithParallelLeg(WithToolLegID(context.Background(), "turn-par"), "turn-par") first := make(chan ToolOutcome, 1) go func() { first <- l.invokeOne(normalCtx, d, callA) }() awaitEntered(t, holder.entered, "普通回合没有进入工具执行") done := make(chan ToolOutcome, 1) go func() { done <- l.invokeOne(parallelCtx, d, callB) }() var outB ToolOutcome select { case outB = <-done: case <-time.After(legFileProbeTimeout): t.Fatal("并行路阻塞等待了 —— 口径是不排队死等,判忙即返回") } if !errors.Is(outB.Err, tool.ErrFileBusy) { t.Fatalf("普通回合持有同一文件时,并行路必须判忙,得到 %v(nil 说明普通回合压根没登记)", outB.Err) } if outB.ErrKind != tool.KindExecution { t.Fatalf("判忙必须归类为执行面失败才能被回灌,得到 %q", outB.ErrKind) } text := toolResultText(callB, outB) for _, want := range []string{"请稍后再试", "正在被另一路写入", "notes.txt"} { if !strings.Contains(text, want) { t.Fatalf("回灌给模型的文本缺少 %q: %s", want, text) } } // 被判忙的调用绝不执行:只有普通回合那一次进了工具。 if legs := peer.seenLegs(); len(legs) != 0 { t.Fatalf("被判忙的并行路绝不该执行工具,实际执行了 %d 次(身份 %v)", len(legs), legs) } close(holder.hold) select { case outA := <-first: if outA.Err != nil { t.Fatalf("持锁的普通回合应正常完成: %v", outA.Err) } case <-time.After(legFileProbeTimeout): t.Fatal("普通回合没有结束") } } // TestInvokeOne_DifferentFilesRunParallel 钉住「不是同一个文件 ⇒ 允许并行操作」 // 在**两条在途路**(普通回合 + 并行路)下同样成立:互斥按文件粒度,绝不能退化成 // 会话级串行。 func TestInvokeOne_DifferentFilesRunParallel(t *testing.T) { t.Parallel() dir := t.TempDir() holder := &legFileTool{name: "patch-a", file: filepath.Join(dir, "a.txt"), entered: make(chan struct{}), hold: make(chan struct{})} peer := &legFileTool{name: "patch-b", file: filepath.Join(dir, "b.txt")} d := dispatcherWith(t, holder, peer) l := New(Options{Tools: d}) first := make(chan ToolOutcome, 1) go func() { first <- l.invokeOne(WithToolLegID(context.Background(), "turn-normal"), d, agent.ToolCall{Name: "patch-a", Args: map[string]any{"path": "a.txt"}}) }() awaitEntered(t, holder.entered, "普通回合没有进入工具执行") out := l.invokeOne(WithParallelLeg(WithToolLegID(context.Background(), "turn-par"), "turn-par"), d, agent.ToolCall{Name: "patch-b", Args: map[string]any{"path": "b.txt"}}) if out.Err != nil { t.Fatalf("不同文件必须允许并行操作,得到 %v", out.Err) } if legs := peer.seenLegs(); len(legs) != 1 || legs[0] != "turn-par" { t.Fatalf("不同文件的调用必须真的执行,且带着自己的身份,得到 %v", legs) } close(holder.hold) select { case outA := <-first: if outA.Err != nil { t.Fatalf("持锁的普通回合应正常完成: %v", outA.Err) } case <-time.After(legFileProbeTimeout): t.Fatal("普通回合没有结束") } } // TestInvokeOne_SingleLegSameFileRunsConcurrently 钉住兼容硬要求③:**无互斥身份 // ⇒ 完全不做互斥**(ctx 上没有 WithToolLegID 的调用方逐字不变)—— 同文件的两条 // 调用同时执行都成功。 // // 「单路」这件事本身不再等于「无身份」:接上编排器之后,单条普通回合也有自己的 // 身份(见 TestInvokeOne_NormalTurnVsParallelLegSameFileBusy),因此它**受保护**; // 这里钉的是**没有身份**的那一类调用方(子代理 / 后台思考 / 未接编排器)。 func TestInvokeOne_SingleLegSameFileRunsConcurrently(t *testing.T) { t.Parallel() file := filepath.Join(t.TempDir(), "notes.txt") blocker := &legFileTool{name: "patch-a", file: file, entered: make(chan struct{}), hold: make(chan struct{})} peer := &legFileTool{name: "patch-b", file: file} d := dispatcherWith(t, blocker, peer) l := New(Options{Tools: d}) ctx := context.Background() first := make(chan ToolOutcome, 1) go func() { first <- l.invokeOne(ctx, d, agent.ToolCall{Name: "patch-a", Args: map[string]any{"path": file}}) }() awaitEntered(t, blocker.entered, "无互斥身份的调用没有进入工具执行") if out := l.invokeOne(ctx, d, agent.ToolCall{Name: "patch-b", Args: map[string]any{"path": file}}); out.Err != nil { t.Fatalf("无互斥身份时完全不做互斥(同文件也应放行),得到 %v", out.Err) } close(blocker.hold) if out := <-first; out.Err != nil { t.Fatalf("无互斥身份的调用应正常完成: %v", out.Err) } } // TestInvokeOne_ParallelLegOnlyOwnsCheckpointNotMutex 钉住解耦的另一个方向:只挂 // **checkpoint 归属键**(WithParallelLeg,没有互斥身份)的 ctx 完全不参与文件级 // 互斥 —— 两条这样的调用同文件同时执行都成功。它挡住「把两个用途又焊回去」的 // 反向回归:checkpoint 归属键绝不是互斥身份。 func TestInvokeOne_ParallelLegOnlyOwnsCheckpointNotMutex(t *testing.T) { t.Parallel() file := filepath.Join(t.TempDir(), "notes.txt") blocker := &legFileTool{name: "patch-a", file: file, entered: make(chan struct{}), hold: make(chan struct{})} peer := &legFileTool{name: "patch-b", file: file} d := dispatcherWith(t, blocker, peer) l := New(Options{Tools: d}) first := make(chan ToolOutcome, 1) go func() { first <- l.invokeOne(WithParallelLeg(context.Background(), "turn-a"), d, agent.ToolCall{Name: "patch-a", Args: map[string]any{"path": file}}) }() awaitEntered(t, blocker.entered, "只挂归属键的调用没有进入工具执行") out := l.invokeOne(WithParallelLeg(context.Background(), "turn-b"), d, agent.ToolCall{Name: "patch-b", Args: map[string]any{"path": file}}) if out.Err != nil { t.Fatalf("只有归属键(无互斥身份)时必须放行,得到 %v", out.Err) } if legs := peer.seenLegs(); len(legs) != 1 || legs[0] != "" { t.Fatalf("只挂归属键的调用不该带互斥身份,得到 %v", legs) } close(blocker.hold) if out := <-first; out.Err != nil { t.Fatalf("只挂归属键的调用应正常完成: %v", out.Err) } }