Files
Cyrene_v2/internal/runtime/loop/fileclaim_leg_test.go
T
AskaEth 354b08a45f
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(tool): 写类工具文件级互斥(读共享 / 写独占)
新增 tool/fileclaims.go:FileTargeter 可选接口,由工具声明本次调用要碰的文件
(动态声明、不猜正文 —— 同一工具的 path 在读/写语义不同)。
语义:读共享 / 写独占;两个读允许并行(主人「优先性能」口径);未知操作/裸路径按写处理(保守)。
占用键含操作类别(read/write,NUL 分隔),路径 Abs+Clean+Windows 大小写折叠;随最后占用者离场删键(有界)。
Dispatcher 默认装配进程内占用表(无需 cmd 侧接线);Invoke 登记 + defer 释放(成功/报错/超时/panic 四条路径)。
loop/tools.go 唯一接线:tool.WithLegID(ParallelLegFrom(ctx)) 桥接网关路标识。
判忙不阻塞、不排队死等;显式文案回灌(含文件名与状态)让 LLM 稍后重试。
测试 3 个新文件(占用表语义 / 接线确定性并发 / loop 桥与端到端);变异 5 条,另揪出并修一个
真隐患(空串既当「无人占用」哨兵又是合法路标识 ⇒ 改 (leg,busy) 返回)。
诚实边界:①「1 普通回合 + 1 并行路」时普通回合不受保护(网关只给 parallel 路盖 legID;
修法须与 checkpoint 归属一起裁决);② 目前无真实工具实现 FileTargeter ⇒ 机制就绪但占用表恒空,
真实保护待下一片给 file 等工具加声明。
2026-10-05 09:38:12 +08:00

192 lines
6.7 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.WithParallelLeg 把并发路标识挂在 ctx 上,工具执行点
// (tools.go 的 invokeOne)把它转成 tool.WithLegID,于是同一文件的第二条路拿到
// tool.ErrFileBusy,并以**回灌给模型的工具结果文本**形式浮出来(显式、可消化)。
//
// 单路口径同样钉住: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_LegIDReachesTool 直接钉住桥:ctx 上挂了并发路标识 ⇒ 工具在
// Execute 里读到同一个值;没挂(单路)⇒ 读到空串(⇒ 完全不做互斥)。
func TestInvokeOne_LegIDReachesTool(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(WithParallelLeg(context.Background(), "turn-x"), d, call); out.Err != nil {
t.Fatalf("并行路调用: %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) != 2 || legs[0] != "turn-x" || legs[1] != "" {
t.Fatalf("工具读到的路标识 = %v, want [turn-x ](空串 = 单路不参与互斥)", legs)
}
}
// TestInvokeOne_ParallelLegSameFileBusySurfacesToModel 钉住端到端的核心行为:
// 两路(不同路标识)同文件 ⇒ 第二路**不执行**、**不等待**,拿到 tool.ErrFileBusy,
// 且回灌给模型的文本里带中文文件状态(模型据此自己决定稍后重试)。
func TestInvokeOne_ParallelLegSameFileBusySurfacesToModel(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}}
first := make(chan ToolOutcome, 1)
go func() {
first <- l.invokeOne(WithParallelLeg(context.Background(), "turn-a"), d, callA)
}()
awaitEntered(t, holder.entered, "第一路没有进入工具执行")
done := make(chan ToolOutcome, 1)
go func() {
done <- l.invokeOne(WithParallelLeg(context.Background(), "turn-b"), 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, want tool.ErrFileBusy", 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)
}
}
close(holder.hold)
select {
case outA := <-first:
if outA.Err != nil {
t.Fatalf("持锁那一路应正常完成: %v", outA.Err)
}
case <-time.After(legFileProbeTimeout):
t.Fatal("第一路没有结束")
}
}
// TestInvokeOne_SingleLegSameFileRunsConcurrently 钉住兼容硬要求③:**无路标识
// ⇒ 完全不做互斥**(单路 / 既有路径逐字不变)—— 同文件的两条调用同时执行都成功。
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)
}
}