Files
Cyrene_v2/internal/runtime/loop/fileclaim_leg_test.go
T
AskaEth ef3dcb699b
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(gateway,loop,tool): 解耦「工具互斥身份」与「checkpoint 归属」(修普通回合不受保护)
缺口:「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 三包全绿;诚实边界含一个既有竞态(同一路并发登记共享,建议后续小切片)。
2026-10-05 09:53:08 +08:00

292 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}