缺口:「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 三包全绿;诚实边界含一个既有竞态(同一路并发登记共享,建议后续小切片)。
292 lines
12 KiB
Go
292 lines
12 KiB
Go
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)
|
||
}
|
||
}
|