Files
Cyrene_v2/internal/runtime/tool/fileclaim_dispatch_test.go
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

442 lines
16 KiB
Go
Raw Permalink 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 tool
import (
"context"
"errors"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
)
// 本文件是**接线**的回归(design/15 §2.5.7、主人 2026-10-05 裁决 3):工具声明的
// 目标文件在执行前登记、执行后(defer)释放,被另一条在途路占住时把带文件状态的
// ErrFileBusy 直接回灌给模型。
//
// 三条兼容硬要求在这里被钉死:
// - 无路标识(单路 / 既有路径)⇒ 完全不做互斥,行为逐字不变;
// - 工具没实现 FileTargeter(今天所有工具)⇒ 不参与互斥,行为逐字不变;
// - 声明面 panic 也不影响调用本身(声明是可选扩展点)。
// shortToolTimeout 用于把「超时路径必须释放占用」压成一个可断言的短窗口。
const shortToolTimeout = 50 * time.Millisecond
// declaringTool 是一个实现了 FileTargeter 的 sdk.Tool:能声明本次的目标文件,
// 也能阻塞在 Execute 里 —— 使「一路持有、另一路撞上」成为**确定性**事件,不靠
// sleep 抢时序。
type declaringTool struct {
name string
targets func(args any) []FileTarget
declareBoom bool // FileTargets 里 panic(声明面出错)
execBoom bool // Execute 里 panic(执行面出错)
execErr error // Execute 返回错误
entered chan struct{} // 非 nil:进入 Execute 时关闭一次
hold chan struct{} // 非 nil:进入后等它关闭(ctx 取消也返回)
once sync.Once
}
func (d *declaringTool) Name() string { return d.name }
func (d *declaringTool) Description() string { return "declaring mock tool" }
func (d *declaringTool) 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 实现 FileTargeter:声明本次调用要碰的文件。
func (d *declaringTool) FileTargets(args any) []FileTarget {
if d.declareBoom {
panic("declare boom")
}
if d.targets == nil {
return nil
}
return d.targets(args)
}
func (d *declaringTool) Execute(ctx context.Context, _ any) (any, error) {
if d.execBoom {
panic("exec boom")
}
if d.entered != nil {
d.once.Do(func() { close(d.entered) })
}
if d.hold != nil {
select {
case <-d.hold:
case <-ctx.Done():
return nil, ctx.Err()
}
}
if d.execErr != nil {
return nil, d.execErr
}
return "ok", nil
}
// awaitClose 等一个信号通道关闭(不靠 sleep 抢时序)。
func awaitClose(t *testing.T, ch <-chan struct{}, msg string) {
t.Helper()
select {
case <-ch:
case <-time.After(claimProbeTimeout):
t.Fatal(msg)
}
}
// claimTableLen 返回占用表当前的键数(白盒):用于断言「无路标识 / 未声明目标
// 不留痕迹」与「释放后有界」。
func claimTableLen(t *testing.T, d *Dispatcher) int {
t.Helper()
c, ok := d.claimerRef().(*fileClaims)
if !ok {
t.Fatal("Dispatcher 未装配进程内占用表")
}
c.mu.Lock()
defer c.mu.Unlock()
return len(c.held)
}
// invokeLeg 在指定并发路上调用工具。
func invokeLeg(d *Dispatcher, leg, name string, args map[string]any) (any, error) {
return d.Invoke(WithLegID(context.Background(), leg), name, args)
}
// declareWrite 造一个只声明「写某个文件」的声明函数。
func declareWrite(file string) func(args any) []FileTarget {
return func(any) []FileTarget { return []FileTarget{{Op: FileOpWrite, Path: file}} }
}
// TestInvoke_FileBusyIsExplicitAndNonBlocking 钉住主人裁决 3 的核心行为:第二路
// **不执行**、**不等待**,拿到的是能被 errors.Is 识别的 ErrFileBusy,且文案带
// 文件状态(含路径与「正在被另一路写入」),可直接回灌给模型。
func TestInvoke_FileBusyIsExplicitAndNonBlocking(t *testing.T) {
t.Parallel()
d, _ := newDispatcher(t)
file := filepath.Join(t.TempDir(), "notes.txt")
holder := &declaringTool{name: "patch", targets: declareWrite(file), entered: make(chan struct{}), hold: make(chan struct{})}
if err := d.Register(holder); err != nil {
t.Fatalf("Register: %v", err)
}
type result struct {
val any
err error
}
got := make(chan result, 1)
go func() {
val, err := invokeLeg(d, "turn-a", "patch", map[string]any{"path": file})
got <- result{val: val, err: err}
}()
awaitClose(t, holder.entered, "第一路没有进入 Execute")
done := make(chan result, 1)
go func() {
val, err := invokeLeg(d, "turn-b", "patch", map[string]any{"path": file})
done <- result{val: val, err: err}
}()
var second result
select {
case second = <-done:
case <-time.After(claimProbeTimeout):
t.Fatal("第二路阻塞等待了 —— 主人口径是不排队死等,判忙即返回")
}
if !errors.Is(second.err, ErrFileBusy) {
t.Fatalf("第二路错误 = %v,want ErrFileBusy", second.err)
}
if second.val != nil {
t.Fatalf("被判忙的调用绝不该执行(拿到结果 %v)", second.val)
}
msg := second.err.Error()
for _, want := range []string{"请稍后再试", "正在被另一路写入", "notes.txt", "本次写入无法进行"} {
if !strings.Contains(msg, want) {
t.Fatalf("回灌给模型的文案缺少 %q: %s", want, msg)
}
}
// 判忙发生在执行之前、且**不包 ToolError**:同一句话只该出现一次
// (ToolError 的 message+cause 渲染会把它打印两遍,白烧模型的 token)。
if got := strings.Count(msg, "请稍后再试"); got != 1 {
t.Fatalf("面向模型的文案重复渲染了 %d 次: %s", got, msg)
}
close(holder.hold)
select {
case first := <-got:
if first.err != nil {
t.Fatalf("持锁那一路应正常完成: %v", first.err)
}
case <-time.After(claimProbeTimeout):
t.Fatal("第一路没有结束")
}
}
// TestInvoke_DifferentFilesRunParallel 钉住「不是同一个文件 ⇒ 允许并行操作」。
func TestInvoke_DifferentFilesRunParallel(t *testing.T) {
t.Parallel()
d, _ := newDispatcher(t)
dir := t.TempDir()
blocker := &declaringTool{name: "block", targets: declareWrite(filepath.Join(dir, "a.txt")), entered: make(chan struct{}), hold: make(chan struct{})}
other := &declaringTool{name: "other", targets: declareWrite(filepath.Join(dir, "b.txt"))}
for _, tk := range []*declaringTool{blocker, other} {
if err := d.Register(tk); err != nil {
t.Fatalf("Register %s: %v", tk.Name(), err)
}
}
done := make(chan error, 1)
go func() {
_, err := invokeLeg(d, "turn-a", "block", map[string]any{"path": "a.txt"})
done <- err
}()
awaitClose(t, blocker.entered, "第一路没有进入 Execute")
if _, err := invokeLeg(d, "turn-b", "other", map[string]any{"path": "b.txt"}); err != nil {
t.Fatalf("不同文件必须允许并行操作,得到 %v", err)
}
close(blocker.hold)
if err := <-done; err != nil {
t.Fatalf("持锁那一路应正常完成: %v", err)
}
}
// TestInvoke_TwoReadsRunParallel 钉住第 4 条的结论(**两个读允许并行**):同一
// 文件、同一路标识不同,读与读互不阻挡;而写同一文件被判忙。
func TestInvoke_TwoReadsRunParallel(t *testing.T) {
t.Parallel()
d, _ := newDispatcher(t)
file := filepath.Join(t.TempDir(), "notes.txt")
read := func(string) func(args any) []FileTarget {
return func(any) []FileTarget { return []FileTarget{{Op: FileOpRead, Path: file}} }
}
holder := &declaringTool{name: "read-holder", targets: read(file), entered: make(chan struct{}), hold: make(chan struct{})}
peer := &declaringTool{name: "read-peer", targets: read(file)}
if err := d.Register(holder); err != nil {
t.Fatalf("Register: %v", err)
}
if err := d.Register(peer); err != nil {
t.Fatalf("Register: %v", err)
}
done := make(chan error, 1)
go func() {
_, err := invokeLeg(d, "turn-a", "read-holder", map[string]any{"path": file})
done <- err
}()
awaitClose(t, holder.entered, "第一路没有进入 Execute")
if _, err := invokeLeg(d, "turn-b", "read-peer", map[string]any{"path": file}); err != nil {
t.Fatalf("同一文件的两个读必须允许并行,得到 %v", err)
}
close(holder.hold)
if err := <-done; err != nil {
t.Fatalf("读那一路应正常完成: %v", err)
}
}
// TestInvoke_NoLegIDNoMutualExclusion 钉住兼容硬要求③:**无路标识 ⇒ 完全不做
// 互斥**。同一文件在同一时刻被两条无路标识的调用动,两边都成功,占用表里也
// 不留任何痕迹(单路 / 既有路径行为逐字不变)。
func TestInvoke_NoLegIDNoMutualExclusion(t *testing.T) {
t.Parallel()
d, _ := newDispatcher(t)
file := filepath.Join(t.TempDir(), "notes.txt")
blocker := &declaringTool{name: "block", targets: declareWrite(file), entered: make(chan struct{}), hold: make(chan struct{})}
peer := &declaringTool{name: "peer", targets: declareWrite(file)}
for _, tk := range []*declaringTool{blocker, peer} {
if err := d.Register(tk); err != nil {
t.Fatalf("Register %s: %v", tk.Name(), err)
}
}
done := make(chan error, 1)
go func() {
_, err := d.Invoke(context.Background(), "block", map[string]any{"path": file})
done <- err
}()
awaitClose(t, blocker.entered, "无路标识的调用没有进入 Execute")
if _, err := d.Invoke(context.Background(), "peer", map[string]any{"path": file}); err != nil {
t.Fatalf("无路标识时完全不做互斥,得到 %v", err)
}
close(blocker.hold)
if err := <-done; err != nil {
t.Fatalf("无路标识的调用应正常完成: %v", err)
}
if got := claimTableLen(t, d); got != 0 {
t.Fatalf("无路标识不该登记任何占用,残留 %d 个键", got)
}
}
// TestInvoke_NoDeclarationUnchanged 钉住兼容硬要求:没实现 FileTargeter 的工具
// (今天所有工具)即使带路标识也完全不受影响。
func TestInvoke_NoDeclarationUnchanged(t *testing.T) {
t.Parallel()
d, _ := newDispatcher(t)
plain := mockTool{name: "plain", params: map[string]any{"type": "object"}}
if err := d.Register(plain); err != nil {
t.Fatalf("Register: %v", err)
}
for _, leg := range []string{"turn-a", "turn-b"} {
if _, err := invokeLeg(d, leg, "plain", map[string]any{}); err != nil {
t.Fatalf("未声明目标的工具不该参与互斥(leg=%s): %v", leg, err)
}
}
if got := claimTableLen(t, d); got != 0 {
t.Fatalf("未声明目标的工具不该留下占用,残留 %d 个键", got)
}
}
// TestInvoke_ReleaseOnEveryExitPath 钉住释放:成功 / 工具报错 / 工具 panic / 超时
// 四条路径都必须释放占用 —— 否则一个中途报错的路会把文件永久锁住。
func TestInvoke_ReleaseOnEveryExitPath(t *testing.T) {
t.Parallel()
cases := []struct {
name string
tk *declaringTool
}{
{"success", &declaringTool{name: "tk"}},
{"error", &declaringTool{name: "tk", execErr: errors.New("boom")}},
{"panic", &declaringTool{name: "tk", execBoom: true}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
d, _ := newDispatcher(t)
file := filepath.Join(t.TempDir(), "notes.txt")
tc.tk.targets = declareWrite(file)
if err := d.Register(tc.tk); err != nil {
t.Fatalf("Register: %v", err)
}
if _, err := invokeLeg(d, "turn-a", "tk", map[string]any{"path": file}); errors.Is(err, ErrFileBusy) {
t.Fatalf("第一次调用不该被判忙: %v", err)
}
// 另一路立刻接手 ⇒ 说明第一次调用已经把占用还回来了。
if _, err := invokeLeg(d, "turn-b", "tk", map[string]any{"path": file}); errors.Is(err, ErrFileBusy) {
t.Fatalf("前一次调用退场后必须已释放占用,得到 %v", err)
}
if got := claimTableLen(t, d); got != 0 {
t.Fatalf("占用表残留 %d 个键", got)
}
})
}
}
// TestInvoke_ReleaseAfterTimeout 单独钉住超时路径(需要更短的每工具截止时间)。
func TestInvoke_ReleaseAfterTimeout(t *testing.T) {
t.Parallel()
reg := registry.New()
d, err := New(reg, WithDefaultTimeout(shortToolTimeout))
if err != nil {
t.Fatalf("New: %v", err)
}
file := filepath.Join(t.TempDir(), "notes.txt")
blocked := &declaringTool{name: "tk", targets: declareWrite(file), hold: make(chan struct{})}
if err := d.Register(blocked); err != nil {
t.Fatalf("Register: %v", err)
}
if _, err := invokeLeg(d, "turn-a", "tk", map[string]any{"path": file}); !errors.Is(err, ErrTimeout) {
t.Fatalf("阻塞调用应超时(ErrTimeout),得到 %v", err)
}
if _, err := invokeLeg(d, "turn-b", "tk", map[string]any{"path": file}); errors.Is(err, ErrFileBusy) {
t.Fatalf("超时退场后必须已释放占用,得到 %v", err)
}
if got := claimTableLen(t, d); got != 0 {
t.Fatalf("占用表残留 %d 个键", got)
}
}
// TestInvoke_MultiTargetFailureRollsBack 钉住多目标调用的**回滚**:一个目标被判忙
// 时,本次调用已登记的其余目标必须立刻还回去,绝不留下半登记状态。
func TestInvoke_MultiTargetFailureRollsBack(t *testing.T) {
t.Parallel()
d, _ := newDispatcher(t)
dir := t.TempDir()
held := filepath.Join(dir, "held.txt")
free := filepath.Join(dir, "free.txt")
holder := &declaringTool{name: "holder", targets: declareWrite(held), entered: make(chan struct{}), hold: make(chan struct{})}
multi := &declaringTool{name: "multi", targets: func(any) []FileTarget {
// 先声明一个空闲文件,再声明被占住的那个 ⇒ 前者必须被回滚。
return []FileTarget{{Op: FileOpWrite, Path: free}, {Op: FileOpWrite, Path: held}}
}}
for _, tk := range []*declaringTool{holder, multi} {
if err := d.Register(tk); err != nil {
t.Fatalf("Register %s: %v", tk.Name(), err)
}
}
done := make(chan error, 1)
go func() {
_, err := invokeLeg(d, "turn-a", "holder", map[string]any{"path": held})
done <- err
}()
awaitClose(t, holder.entered, "持锁那一路没有进入 Execute")
if _, err := invokeLeg(d, "turn-b", "multi", map[string]any{"path": held}); !errors.Is(err, ErrFileBusy) {
t.Fatalf("多目标里有一个被占住就必须整调用判忙,得到 %v", err)
}
// free.txt 必须已被回滚:占用表里只剩被占住的 held.txt 一个键。
if got := claimTableLen(t, d); got != 1 {
t.Fatalf("占用表应有且仅有 held.txt 一个键(free.txt 已回滚),实际 %d 个", got)
}
close(holder.hold)
if err := <-done; err != nil {
t.Fatalf("持锁那一路应正常完成: %v", err)
}
}
// TestInvoke_DeclarationPanicDegradesSafely 钉住声明面的兜底:第三方工具在
// FileTargets 里 panic,不该连带炸掉调用(降级为「不参与互斥」而不是「调用失败」)。
func TestInvoke_DeclarationPanicDegradesSafely(t *testing.T) {
t.Parallel()
d, _ := newDispatcher(t)
boom := &declaringTool{name: "boom", declareBoom: true}
if err := d.Register(boom); err != nil {
t.Fatalf("Register: %v", err)
}
if _, err := invokeLeg(d, "turn-a", "boom", map[string]any{"path": "a.txt"}); err != nil {
t.Fatalf("声明面 panic 必须降级为「不参与互斥」,而不是让调用失败: %v", err)
}
}
// TestInvoke_FileClaimerDisabled 钉住 WithFileClaimer(nil):显式关闭互斥时,
// 声明了目标的工具也完全不做互斥(装配点的逃生门)。
func TestInvoke_FileClaimerDisabled(t *testing.T) {
t.Parallel()
reg := registry.New()
d, err := New(reg, WithFileClaimer(nil))
if err != nil {
t.Fatalf("New: %v", err)
}
file := filepath.Join(t.TempDir(), "notes.txt")
blocker := &declaringTool{name: "block", targets: declareWrite(file), entered: make(chan struct{}), hold: make(chan struct{})}
peer := &declaringTool{name: "peer", targets: declareWrite(file)}
for _, tk := range []*declaringTool{blocker, peer} {
if err := d.Register(tk); err != nil {
t.Fatalf("Register %s: %v", tk.Name(), err)
}
}
done := make(chan error, 1)
go func() {
_, err := invokeLeg(d, "turn-a", "block", map[string]any{"path": file})
done <- err
}()
awaitClose(t, blocker.entered, "第一路没有进入 Execute")
if _, err := invokeLeg(d, "turn-b", "peer", map[string]any{"path": file}); err != nil {
t.Fatalf("关闭占用表后不做任何互斥,得到 %v", err)
}
close(blocker.hold)
if err := <-done; err != nil {
t.Fatalf("第一路应正常完成: %v", err)
}
}