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 等工具加声明。
This commit is contained in:
1 parent
c0dc435770
commit
354b08a45f
7 files changed
+1367
-12
No files matched your search
@@ -0,0 +1,191 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -299,7 +299,10 @@ func (l *Loop) invokeOne(ctx context.Context, d *tool.Dispatcher, tc agent.ToolC
|
|||||||
}
|
}
|
||||||
_, toolSpan := l.span(ctx, "tool."+tc.Name, trace.SpanKindTool)
|
_, toolSpan := l.span(ctx, "tool."+tc.Name, trace.SpanKindTool)
|
||||||
l.emitToolExecution(ctx, tc, model.StreamKindToolExecutionStart, model.ToolPhaseRunning)
|
l.emitToolExecution(ctx, tc, model.StreamKindToolExecutionStart, model.ToolPhaseRunning)
|
||||||
result, err := d.Invoke(ctx, tc.Name, tc.Args)
|
// 路标识桥(design/15 §2.5.7、主人裁决 3):网关把「本回合属于哪条并发路」经
|
||||||
|
// loop.WithParallelLeg 挂在 ctx 上,工具层按 tool.WithLegID 读它做文件级互斥
|
||||||
|
// (工具侧唯一来源);未挂载 ⇒ 空串 ⇒ 完全不做互斥,行为逐字不变。
|
||||||
|
result, err := d.Invoke(tool.WithLegID(ctx, ParallelLegFrom(ctx)), tc.Name, tc.Args)
|
||||||
phase := model.ToolPhaseDone
|
phase := model.ToolPhaseDone
|
||||||
if err != nil {
|
if err != nil {
|
||||||
phase = model.ToolPhaseError
|
phase = model.ToolPhaseError
|
||||||
|
|||||||
@@ -0,0 +1,441 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,377 @@
|
|||||||
|
package tool
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 本文件把 legfile.go 预留的文件级互斥通道**实现并接线**(design/15 §2.5.7、
|
||||||
|
// 主人 2026-10-05 裁决 3):写类工具执行前先在占用表里登记本次要动的文件,被
|
||||||
|
// **另一条在途路**占住时立刻把「该文件正在被另一路操作」以面向模型的文案返回
|
||||||
|
// 并提示稍后重试(不静默、不排队死等);不是同一个文件则照常并行。
|
||||||
|
//
|
||||||
|
// 「本次要动哪个文件」由**工具自己**实现可选的 FileTargeter 接口声明,而不是
|
||||||
|
// 由本包从 JSON Schema 标注或从参数正文里猜:同一个工具的文件参数会随调用而变
|
||||||
|
// (内置 `file` 工具的 `path` 在 action=read 时是读、action=write 时是写;还有
|
||||||
|
// 工具用 `name`、有的收多个路径),只有工具实现方知道本次调用的语义。静态标注
|
||||||
|
// 要么表达不了「本次是读还是写」,要么逼工具把语义抄两遍;字符串猜文件名则会
|
||||||
|
// 把正文里的任意文本当成路径(误挡 + 泄漏面)。声明不出来 ⇒ 该工具不参与互斥,
|
||||||
|
// 行为与加入本特性之前逐字相同。
|
||||||
|
//
|
||||||
|
// 占用键 = **操作类别 + 规范化路径**(见 fileClaimKey):同一文件的两个**读**
|
||||||
|
// 允许并行(主人「优先考虑性能而不是预算」口径:检索类并行不该被互斥拖住),
|
||||||
|
// 读与写、写与写互斥。类别编码进键、而不是给 FileClaimer 多加一对方法,是为了
|
||||||
|
// 不破坏 legfile.go 已钉死的两方法接口形状(既有用例零改动)。
|
||||||
|
|
||||||
|
// FileOp 是一次工具调用对目标文件的操作类别。粒度只到读/写两类:读不改变文件
|
||||||
|
// 内容,读与读之间没有数据风险;写会改变内容,与任何路的读写都不能重叠。
|
||||||
|
type FileOp string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// FileOpRead 表示**只读**访问目标文件(可与别的读并行)。
|
||||||
|
FileOpRead FileOp = "read"
|
||||||
|
// FileOpWrite 表示会**修改**目标文件(与其他路的任何读写都互斥)。
|
||||||
|
FileOpWrite FileOp = "write"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FileTarget 是工具为**本次调用**声明的一个目标文件。Op 为空/未知一律按
|
||||||
|
// FileOpWrite 处理:占用表的方向是宁可多挡一次(让模型稍后重试),
|
||||||
|
// 也不放过两路并发写。
|
||||||
|
type FileTarget struct {
|
||||||
|
// Op 是本次调用的操作类别(读 / 写)。
|
||||||
|
Op FileOp
|
||||||
|
// Path 是本次调用要碰的路径;以工具**自己执行时使用的那个路径**为准
|
||||||
|
// (沙箱解析后的绝对路径最好)。
|
||||||
|
Path string
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileTargeter 由需要参与文件级互斥的工具**可选**实现:返回本次调用会碰到的
|
||||||
|
// 文件(含读/写类别)。未实现它的工具完全不参与互斥。
|
||||||
|
//
|
||||||
|
// 约定:
|
||||||
|
// - Path 应是工具执行时真正用的路径;占用表只做一次**词法**规范化(清理相对
|
||||||
|
// 段、Windows 折叠大小写),它不解析符号链接、不统一短名/UNC/映射盘 ——
|
||||||
|
// 别把沙箱解析责任推给占用表。
|
||||||
|
// - 只声明真正会碰的文件:声明得越宽并行度越低(例如把每次写都声明成同一个
|
||||||
|
// 目录名,就等于退化成全局串行)。
|
||||||
|
// - 实现必须是纯函数(无副作用、不阻塞):它在每次工具调用前被调用一次,
|
||||||
|
// 而且它 panic 会被吞掉并降级为「不参与互斥」(见 safeFileTargets)。
|
||||||
|
type FileTargeter interface {
|
||||||
|
FileTargets(args any) []FileTarget
|
||||||
|
}
|
||||||
|
|
||||||
|
// fileClaimKeySep 分隔占用键里的操作类别与路径。用 NUL 是因为它在 Windows 与
|
||||||
|
// POSIX 的文件名里都非法,真实路径不可能与它撞键(loop 的 toolFingerprint 用
|
||||||
|
// 同一种分隔符表达同样的「不会撞」拼接)。
|
||||||
|
const fileClaimKeySep = "\x00"
|
||||||
|
|
||||||
|
// fileClaimKey 拼出占用键(调用方与占用表共用同一个拼法)。
|
||||||
|
func fileClaimKey(op FileOp, path string) string {
|
||||||
|
return string(normalizeFileOp(op)) + fileClaimKeySep + path
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeFileOp 把空/未知类别收敛为「写」。
|
||||||
|
func normalizeFileOp(op FileOp) FileOp {
|
||||||
|
if op == FileOpRead {
|
||||||
|
return FileOpRead
|
||||||
|
}
|
||||||
|
return FileOpWrite
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseFileClaimKey 把占用键拆回(类别,路径)。没有类别前缀的裸路径(外部
|
||||||
|
// 实现方直接传路径 —— legfile_test 的 fakeFileClaimer 就是这种口径)按「写」
|
||||||
|
// 处理,与 normalizeFileOp 同一个保守方向。
|
||||||
|
func parseFileClaimKey(target string) (FileOp, string) {
|
||||||
|
op, path, ok := strings.Cut(target, fileClaimKeySep)
|
||||||
|
if !ok {
|
||||||
|
return FileOpWrite, target
|
||||||
|
}
|
||||||
|
if op == string(FileOpRead) {
|
||||||
|
return FileOpRead, path
|
||||||
|
}
|
||||||
|
return FileOpWrite, path
|
||||||
|
}
|
||||||
|
|
||||||
|
// canonicalFilePath 把路径收敛成占用表的键。只做**词法**规范化(Abs + Clean +
|
||||||
|
// Windows 折叠大小写),绝不 touch 文件系统:占用判定在每次工具调用之前发生,
|
||||||
|
// 不能引入 I/O,也不该因为目标文件还不存在就放弃互斥。
|
||||||
|
//
|
||||||
|
// 代价(诚实登记):符号链接、8.3 短名、UNC 与映射盘写法的**同一物理文件**不会
|
||||||
|
// 被合并 —— 两个别名会被当成两个文件而放行并行。工具按自己执行时的真实落点
|
||||||
|
// 声明路径即可避免绝大多数别名(见 FileTargeter 的约定)。
|
||||||
|
func canonicalFilePath(p string) string {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
if p == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if abs, err := filepath.Abs(p); err == nil {
|
||||||
|
p = abs
|
||||||
|
}
|
||||||
|
p = filepath.Clean(p)
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
// Windows 文件系统大小写不敏感:`A.TXT` 与 `a.txt` 是同一个文件,不折叠
|
||||||
|
// 就会漏判成两个键,互斥形同虚设。
|
||||||
|
p = strings.ToLower(p)
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// fileClaims 是 FileClaimer 的进程内实现。单二进制、单进程 ⇒ 占用状态无需跨
|
||||||
|
// 进程共享(项目红线:零跨进程 IPC)。
|
||||||
|
//
|
||||||
|
// 语义是**读共享 / 写独占**(而不是「只登记写、读不登记」):一路在写时另一路
|
||||||
|
// 读同一文件会读到撕开的内容(file 工具的读不是原子快照),与两路同时写属同一
|
||||||
|
// 类数据风险,所以读也登记;但多个读互不阻挡,检索类并行不受影响。
|
||||||
|
type fileClaims struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
held map[string]*fileHolders
|
||||||
|
}
|
||||||
|
|
||||||
|
// fileHolders 是某个文件当前的占用者集合(键是路标识):写至多一个,读可多个。
|
||||||
|
// 两个集合都空时该键会被立刻删除,因此占用表随路收尾清空、绝不随会话长度增长。
|
||||||
|
type fileHolders struct {
|
||||||
|
writers map[string]struct{}
|
||||||
|
readers map[string]struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFileClaimer 构建一份进程内文件占用表。跨会话共享同一份即可:粒度的口径是
|
||||||
|
// **文件**(主人裁决 3),不是会话 —— 两个会话同时改同一个文件同样是数据风险。
|
||||||
|
func NewFileClaimer() FileClaimer {
|
||||||
|
return &fileClaims{held: make(map[string]*fileHolders)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Claim 实现 FileClaimer:按占用键的类别做读共享/写独占判定,被别的路占住时
|
||||||
|
// 返回带文件状态的 ErrFileBusy(直接回灌给模型)。它**绝不等待**:判忙即返回,
|
||||||
|
// 由模型稍后重试(主人裁决 3 的「不静默、不排队死等」)。
|
||||||
|
//
|
||||||
|
// 空 legID 或空目标是空操作:单路 / 子代理 / 后台思考(没有并发路)不登记也不
|
||||||
|
// 检查,行为与加入本特性之前逐字相同(兼容硬要求)。
|
||||||
|
//
|
||||||
|
// 同一路重复 Claim 同一目标幂等;一路自己先读后写同一文件不会被自己挡住
|
||||||
|
// (否则一条路自己的多步工具调用会互相锁死)。
|
||||||
|
func (c *fileClaims) Claim(ctx context.Context, legID, target string) error {
|
||||||
|
if c == nil || legID == "" || target == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
op, path := parseFileClaimKey(target)
|
||||||
|
key := canonicalFilePath(path)
|
||||||
|
if key == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
h := c.held[key]
|
||||||
|
if op == FileOpWrite {
|
||||||
|
if _, busy := h.otherWriter(legID); busy {
|
||||||
|
return FileBusyStatusError(key, fileBusyStatus(FileOpWrite, FileOpWrite))
|
||||||
|
}
|
||||||
|
if _, busy := h.otherReader(legID); busy {
|
||||||
|
return FileBusyStatusError(key, fileBusyStatus(FileOpWrite, FileOpRead))
|
||||||
|
}
|
||||||
|
} else if _, busy := h.otherWriter(legID); busy {
|
||||||
|
return FileBusyStatusError(key, fileBusyStatus(FileOpRead, FileOpWrite))
|
||||||
|
}
|
||||||
|
if h == nil {
|
||||||
|
h = newFileHolders()
|
||||||
|
c.held[key] = h
|
||||||
|
}
|
||||||
|
if op == FileOpWrite {
|
||||||
|
h.writers[legID] = struct{}{}
|
||||||
|
} else {
|
||||||
|
h.readers[legID] = struct{}{}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Release 实现 FileClaimer:无条件摘掉该路在该目标上的占用(读、写两侧都摘),
|
||||||
|
// 最后一个占用者离开时删键。legID 不是占用者时是空操作。
|
||||||
|
//
|
||||||
|
// 它必须挂在工具执行的生命周期上(defer):报错 / 超时 / panic 的路也绝不把
|
||||||
|
// 文件永久锁住。
|
||||||
|
func (c *fileClaims) Release(ctx context.Context, legID, target string) {
|
||||||
|
if c == nil || legID == "" || target == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, path := parseFileClaimKey(target)
|
||||||
|
key := canonicalFilePath(path)
|
||||||
|
if key == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
h := c.held[key]
|
||||||
|
if h == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
delete(h.writers, legID)
|
||||||
|
delete(h.readers, legID)
|
||||||
|
if len(h.writers) == 0 && len(h.readers) == 0 {
|
||||||
|
delete(c.held, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fileBusyStatus 生成回灌给模型的**文件状态**短句:说清此刻是谁在动它,
|
||||||
|
// 模型才知道该等什么、为什么现在不能动(而不是只看到「忙」)。
|
||||||
|
// 只含操作类别,不含另一路的任何身份或内容。
|
||||||
|
func fileBusyStatus(want, holder FileOp) string {
|
||||||
|
verb := "写入"
|
||||||
|
if holder == FileOpRead {
|
||||||
|
verb = "读取"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("该文件正在被另一路%s,本次%s无法进行", verb, opLabel(want))
|
||||||
|
}
|
||||||
|
|
||||||
|
// opLabel 把操作类别渲染成中文,供面向模型的文案使用。
|
||||||
|
func opLabel(op FileOp) string {
|
||||||
|
if op == FileOpRead {
|
||||||
|
return "读取"
|
||||||
|
}
|
||||||
|
return "写入"
|
||||||
|
}
|
||||||
|
|
||||||
|
// newFileHolders 建一个空占用者集合(两个集合都必须非 nil,Claim 会直接写入)。
|
||||||
|
func newFileHolders() *fileHolders {
|
||||||
|
return &fileHolders{writers: make(map[string]struct{}), readers: make(map[string]struct{})}
|
||||||
|
}
|
||||||
|
|
||||||
|
// otherWriter 报告除 exclude 外是否还有路正在写该文件,并返回其中一条(供文案
|
||||||
|
// 与调试使用)。nil 接收者表示「该文件当前无人占用」⇒ (空串, false),使 Claim
|
||||||
|
// 不必先建表再判定。
|
||||||
|
//
|
||||||
|
// 判定用**布尔**而不是「返回空串表示没有」:路标识本身可以是空串(外部调用方
|
||||||
|
// 的口径),用空串当「没有」的哨兵会把它们误判成无人占用。
|
||||||
|
func (h *fileHolders) otherWriter(exclude string) (string, bool) {
|
||||||
|
if h == nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
for leg := range h.writers {
|
||||||
|
if leg != exclude {
|
||||||
|
return leg, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// otherReader 报告除 exclude 外是否还有路正在读该文件,并返回其中一条。
|
||||||
|
func (h *fileHolders) otherReader(exclude string) (string, bool) {
|
||||||
|
if h == nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
for leg := range h.readers {
|
||||||
|
if leg != exclude {
|
||||||
|
return leg, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// fileTargetsOf 取工具为本次调用声明的目标文件。工具未实现 FileTargeter、
|
||||||
|
// 声明为空、或某项路径为空时跳过 —— 声明不出来就不参与互斥(行为不变)。
|
||||||
|
// 同一次调用里重复的目标只登记一次,避免同一把锁被重复占有/重复释放。
|
||||||
|
func fileTargetsOf(t sdk.Tool, args any) []FileTarget {
|
||||||
|
targeter, ok := t.(FileTargeter)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
targets := targeter.FileTargets(args)
|
||||||
|
if len(targets) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]FileTarget, 0, len(targets))
|
||||||
|
seen := make(map[string]struct{}, len(targets))
|
||||||
|
for _, tg := range targets {
|
||||||
|
if strings.TrimSpace(tg.Path) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := fileClaimKey(tg.Op, tg.Path)
|
||||||
|
if _, dup := seen[key]; dup {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = struct{}{}
|
||||||
|
out = append(out, tg)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// safeFileTargets 是 fileTargetsOf 的抗 panic 包装:声明是**可选**扩展点,
|
||||||
|
// 一个第三方工具在它里面 panic 不该连带炸掉调用(callTool 只在 Execute 里
|
||||||
|
// recover)。降级方向是「不参与互斥」,而不是「调用失败」。
|
||||||
|
func safeFileTargets(log *slog.Logger, name string, t sdk.Tool, args any) (out []FileTarget) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
out = nil
|
||||||
|
log.Warn("file target declaration panicked; skipping file claims",
|
||||||
|
"tool", name, "panic", fmt.Sprint(r))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return fileTargetsOf(t, args)
|
||||||
|
}
|
||||||
|
|
||||||
|
// claimFiles 为一次工具调用登记它声明的目标文件,并返回释放函数(调用方必须
|
||||||
|
// 在执行后 defer 调用它:成功 / 报错 / 超时 / panic 都要释放,否则一个中途报错
|
||||||
|
// 的路会把文件永久锁住)。
|
||||||
|
//
|
||||||
|
// 返回 nil 释放函数表示本次调用不参与互斥:未装占用表、未挂路标识(单路 /
|
||||||
|
// 子代理 / 未接线,兼容硬要求)、或工具没有声明目标文件。
|
||||||
|
//
|
||||||
|
// 被另一条路占住时**不等待、不排队**,直接把带文件状态的 ErrFileBusy 交给模型
|
||||||
|
// (主人裁决 3)。注意它**不包成 ToolError**:占用判定发生在执行之前,调用根本
|
||||||
|
// 没跑,而且 ToolError 的渲染(message + cause)会把同一句话打印两遍 —— 回灌给
|
||||||
|
// 模型的结果要一句话说清「谁在被谁占、稍后再试」(loop 的 errorKind 把它归为
|
||||||
|
// execution,与执行面失败的分类一致)。
|
||||||
|
//
|
||||||
|
// 多目标里任意一个失败时,本次调用**已登记的全部目标立刻回滚**,绝不留下半登记
|
||||||
|
// 状态。
|
||||||
|
func (d *Dispatcher) claimFiles(ctx context.Context, name string, t sdk.Tool, args map[string]any) (func(), error) {
|
||||||
|
claimer := d.claimerRef()
|
||||||
|
if claimer == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
legID := LegIDFrom(ctx)
|
||||||
|
if legID == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
targets := safeFileTargets(d.log, name, t, args)
|
||||||
|
if len(targets) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
keys := make([]string, 0, len(targets))
|
||||||
|
for _, tg := range targets {
|
||||||
|
key := fileClaimKey(tg.Op, tg.Path)
|
||||||
|
// 先记账再登记:失败的 Claim 也要进回滚清单(对第三方实现而言,
|
||||||
|
// 「失败即未登记」不是接口保证;多释放一次按契约是空操作)。
|
||||||
|
keys = append(keys, key)
|
||||||
|
if err := claimer.Claim(ctx, legID, key); err != nil {
|
||||||
|
releaseFileClaims(ctx, claimer, legID, keys)
|
||||||
|
d.log.Info("file claim busy; asking the model to retry later",
|
||||||
|
"tool", name, "leg", legID, "target", key)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return func() { releaseFileClaims(ctx, claimer, legID, keys) }, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// releaseFileClaims 释放一批占用(正常收尾与失败回滚共用一条路径)。
|
||||||
|
func releaseFileClaims(ctx context.Context, claimer FileClaimer, legID string, keys []string) {
|
||||||
|
for _, key := range keys {
|
||||||
|
claimer.Release(ctx, legID, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// claimerRef 返回已装配的占用表;未装配时返回 nil(⇒ 完全不做互斥)。
|
||||||
|
// 占用表只在 New 的选项里装配一次,此后不再变更。
|
||||||
|
func (d *Dispatcher) claimerRef() FileClaimer {
|
||||||
|
if d == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
d.mu.RLock()
|
||||||
|
defer d.mu.RUnlock()
|
||||||
|
return d.claimer
|
||||||
|
}
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
package tool
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 本文件钉住文件级互斥(design/15 §2.5.7、主人 2026-10-05 裁决 3)的**占用表
|
||||||
|
// 语义**:
|
||||||
|
// - 同一文件被**另一路**占住 ⇒ 立刻判忙(文案带文件状态,可被 LLM 消化),
|
||||||
|
// **绝不阻塞等待**(不排队死等);
|
||||||
|
// - 不是同一个文件 ⇒ 并行放行;
|
||||||
|
// - 同一文件的两个**读** ⇒ 并行放行(结论见 TestFileClaimsTwoReadsRunParallel);
|
||||||
|
// - 同一路重复占用幂等、且不会被自己挡住(先读后写同一文件);
|
||||||
|
// - 释放后另一路立刻可接手;占用表随路收尾清空(有界);
|
||||||
|
// - **无路标识 ⇒ 不登记也不检查**(单路 / 既有路径逐字不变)。
|
||||||
|
|
||||||
|
// claimProbeTimeout 是「判忙必须立刻返回」的上界。它只把「阻塞等待」这种错误
|
||||||
|
// 实现暴露成失败,不参与任何时序竞态(占用判定本身是同步的)。
|
||||||
|
const claimProbeTimeout = 5 * time.Second
|
||||||
|
|
||||||
|
// newTestFileClaims 返回占用表的具体实现,供白盒断言「表是否有界」。
|
||||||
|
func newTestFileClaims() *fileClaims {
|
||||||
|
return NewFileClaimer().(*fileClaims)
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeTarget / readTarget 按占用键的正式拼法造目标(与 Dispatcher 侧同源)。
|
||||||
|
func writeTarget(path string) string { return fileClaimKey(FileOpWrite, path) }
|
||||||
|
func readTarget(path string) string { return fileClaimKey(FileOpRead, path) }
|
||||||
|
|
||||||
|
// TestFileClaimsSameFileOtherLegBusy 钉住主人口径的核心:同一文件正被另一路
|
||||||
|
// 操作 ⇒ 明确判忙(errors.Is(ErrFileBusy))、**不阻塞等待**、文案里带出文件状态
|
||||||
|
// 与路径,使模型能自己决定「等一会再操作」。
|
||||||
|
func TestFileClaimsSameFileOtherLegBusy(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
c := newTestFileClaims()
|
||||||
|
ctx := context.Background()
|
||||||
|
file := filepath.Join(t.TempDir(), "notes.txt")
|
||||||
|
|
||||||
|
if err := c.Claim(ctx, "turn-a", writeTarget(file)); err != nil {
|
||||||
|
t.Fatalf("第一路占用失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { done <- c.Claim(ctx, "turn-b", writeTarget(file)) }()
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
if !errors.Is(err, ErrFileBusy) {
|
||||||
|
t.Fatalf("同一文件被另一路占用必须判忙,得到 %v", err)
|
||||||
|
}
|
||||||
|
msg := err.Error()
|
||||||
|
for _, want := range []string{"请稍后再试", "正在被另一路写入", "notes.txt"} {
|
||||||
|
if !strings.Contains(msg, want) {
|
||||||
|
t.Fatalf("回灌给模型的文案缺少 %q: %s", want, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case <-time.After(claimProbeTimeout):
|
||||||
|
t.Fatal("第二路在占用表上阻塞等待了 —— 主人口径是不排队死等,判忙即返回")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判忙不改变占用归属:第三路同样判忙,第一路仍然持锁。
|
||||||
|
if err := c.Claim(ctx, "turn-c", writeTarget(file)); !errors.Is(err, ErrFileBusy) {
|
||||||
|
t.Fatalf("第三路也应判忙,得到 %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFileClaimsDifferentFilesRunParallel 钉住「不是同一个文件 ⇒ 允许并行」。
|
||||||
|
func TestFileClaimsDifferentFilesRunParallel(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
c := newTestFileClaims()
|
||||||
|
ctx := context.Background()
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
if err := c.Claim(ctx, "turn-a", writeTarget(filepath.Join(dir, "a.txt"))); err != nil {
|
||||||
|
t.Fatalf("A 占用 a.txt: %v", err)
|
||||||
|
}
|
||||||
|
if err := c.Claim(ctx, "turn-b", writeTarget(filepath.Join(dir, "b.txt"))); err != nil {
|
||||||
|
t.Fatalf("不同文件必须允许并行操作,得到 %v", err)
|
||||||
|
}
|
||||||
|
// 同一目录下的另一个文件同样放行(互斥粒度是文件,不是目录)。
|
||||||
|
if err := c.Claim(ctx, "turn-c", writeTarget(filepath.Join(dir, "sub", "c.txt"))); err != nil {
|
||||||
|
t.Fatalf("不同文件必须允许并行操作,得到 %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFileClaimsTwoReadsRunParallel 钉住第 4 条的结论:同一文件的**两个读允许
|
||||||
|
// 并行**(读不改变内容,没有数据风险;主人「优先考虑性能」口径下检索类并行不该
|
||||||
|
// 被互斥拖住)。而读与写之间仍然互斥 —— 读到「正在被写」的一半内容与两路同时
|
||||||
|
// 写属同一类数据风险。
|
||||||
|
func TestFileClaimsTwoReadsRunParallel(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
c := newTestFileClaims()
|
||||||
|
ctx := context.Background()
|
||||||
|
file := filepath.Join(t.TempDir(), "notes.txt")
|
||||||
|
|
||||||
|
if err := c.Claim(ctx, "turn-a", readTarget(file)); err != nil {
|
||||||
|
t.Fatalf("第一路读: %v", err)
|
||||||
|
}
|
||||||
|
if err := c.Claim(ctx, "turn-b", readTarget(file)); err != nil {
|
||||||
|
t.Fatalf("同一文件的两个读必须允许并行,得到 %v", err)
|
||||||
|
}
|
||||||
|
if err := c.Claim(ctx, "turn-c", writeTarget(file)); !errors.Is(err, ErrFileBusy) {
|
||||||
|
t.Fatalf("有读在途时写入必须判忙,得到 %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(c.Claim(ctx, "turn-c", writeTarget(file)).Error(), "正在被另一路读取") {
|
||||||
|
t.Fatal("写入被判忙时,文案必须说清是「另一路正在读取」")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFileClaimsWriteThenReadBusy 钉住相反方向:写在途时读同一文件判忙,
|
||||||
|
// 且文案说清是「另一路正在写入」。
|
||||||
|
func TestFileClaimsWriteThenReadBusy(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
c := newTestFileClaims()
|
||||||
|
ctx := context.Background()
|
||||||
|
file := filepath.Join(t.TempDir(), "notes.txt")
|
||||||
|
|
||||||
|
if err := c.Claim(ctx, "turn-a", writeTarget(file)); err != nil {
|
||||||
|
t.Fatalf("第一路写: %v", err)
|
||||||
|
}
|
||||||
|
err := c.Claim(ctx, "turn-b", readTarget(file))
|
||||||
|
if !errors.Is(err, ErrFileBusy) {
|
||||||
|
t.Fatalf("写在途时读同一文件必须判忙,得到 %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "正在被另一路写入") {
|
||||||
|
t.Fatalf("文案必须说清是「另一路正在写入」: %s", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFileClaimsSameLegIsIdempotent 钉住同一路的口径:重复占用同一目标幂等,
|
||||||
|
// 且**一路自己先读后写同一文件不会被自己挡住**(否则一条路自己的多步工具调用
|
||||||
|
// 会互相锁死)。同时别的路仍被正确挡住。
|
||||||
|
func TestFileClaimsSameLegIsIdempotent(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
c := newTestFileClaims()
|
||||||
|
ctx := context.Background()
|
||||||
|
file := filepath.Join(t.TempDir(), "notes.txt")
|
||||||
|
|
||||||
|
if err := c.Claim(ctx, "turn-a", readTarget(file)); err != nil {
|
||||||
|
t.Fatalf("自己读: %v", err)
|
||||||
|
}
|
||||||
|
if err := c.Claim(ctx, "turn-a", readTarget(file)); err != nil {
|
||||||
|
t.Fatalf("同一路重复占用必须是幂等的: %v", err)
|
||||||
|
}
|
||||||
|
if err := c.Claim(ctx, "turn-a", writeTarget(file)); err != nil {
|
||||||
|
t.Fatalf("同一路先读后写同一文件不该被自己挡住: %v", err)
|
||||||
|
}
|
||||||
|
if err := c.Claim(ctx, "turn-a", readTarget(file)); err != nil {
|
||||||
|
t.Fatalf("同一路读写并存必须是幂等的: %v", err)
|
||||||
|
}
|
||||||
|
if err := c.Claim(ctx, "turn-b", readTarget(file)); !errors.Is(err, ErrFileBusy) {
|
||||||
|
t.Fatalf("别的路仍必须被挡住,得到 %v", err)
|
||||||
|
}
|
||||||
|
if err := c.Claim(ctx, "turn-b", writeTarget(file)); !errors.Is(err, ErrFileBusy) {
|
||||||
|
t.Fatalf("别的路仍必须被挡住,得到 %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFileClaimsReleaseLetsOtherLegTakeOver 钉住释放:立刻可接手、非占用者释放
|
||||||
|
// 是空操作、最后一个占用者离开后占用表清空(有界 —— 绝不随会话长度增长)。
|
||||||
|
func TestFileClaimsReleaseLetsOtherLegTakeOver(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
c := newTestFileClaims()
|
||||||
|
ctx := context.Background()
|
||||||
|
file := filepath.Join(t.TempDir(), "notes.txt")
|
||||||
|
|
||||||
|
if err := c.Claim(ctx, "turn-a", writeTarget(file)); err != nil {
|
||||||
|
t.Fatalf("A 占用: %v", err)
|
||||||
|
}
|
||||||
|
c.Release(ctx, "turn-a", writeTarget(file))
|
||||||
|
if err := c.Claim(ctx, "turn-b", writeTarget(file)); err != nil {
|
||||||
|
t.Fatalf("释放后另一路应立即接手,得到 %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 非占用者释放是空操作:B 的占用不受影响。
|
||||||
|
c.Release(ctx, "turn-z", writeTarget(file))
|
||||||
|
if err := c.Claim(ctx, "turn-c", writeTarget(file)); !errors.Is(err, ErrFileBusy) {
|
||||||
|
t.Fatalf("B 的占用被非占用者的释放动掉了,得到 %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Release(ctx, "turn-b", writeTarget(file))
|
||||||
|
if got := len(c.held); got != 0 {
|
||||||
|
t.Fatalf("占用表残留 %d 个键,必须随路收尾清空(有界)", got)
|
||||||
|
}
|
||||||
|
// 读一侧的释放同样要摘干净(读与写同属一个键)。
|
||||||
|
if err := c.Claim(ctx, "turn-a", readTarget(file)); err != nil {
|
||||||
|
t.Fatalf("A 读: %v", err)
|
||||||
|
}
|
||||||
|
c.Release(ctx, "turn-a", writeTarget(file))
|
||||||
|
if got := len(c.held); got != 0 {
|
||||||
|
t.Fatalf("释放必须同时摘掉读侧占用,残留 %d 个键", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFileClaimsEmptyLegIsNoOp 钉住兼容硬要求:**无路标识 ⇒ 不登记也不检查**。
|
||||||
|
// 空路(单路 / 子代理 / 未接线)既不会被挡住,也不会在占用表里留下任何痕迹。
|
||||||
|
func TestFileClaimsEmptyLegIsNoOp(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
c := newTestFileClaims()
|
||||||
|
ctx := context.Background()
|
||||||
|
file := filepath.Join(t.TempDir(), "notes.txt")
|
||||||
|
|
||||||
|
if err := c.Claim(ctx, "", writeTarget(file)); err != nil {
|
||||||
|
t.Fatalf("空路标识不做互斥,得到 %v", err)
|
||||||
|
}
|
||||||
|
if err := c.Claim(ctx, "turn-a", writeTarget(file)); err != nil {
|
||||||
|
t.Fatalf("空路标识不该登记占用,真路必须能正常占用: %v", err)
|
||||||
|
}
|
||||||
|
if err := c.Claim(ctx, "turn-b", writeTarget(file)); !errors.Is(err, ErrFileBusy) {
|
||||||
|
t.Fatalf("真路之间仍然要互斥,得到 %v", err)
|
||||||
|
}
|
||||||
|
// 真路占住时,空路标识照样放行(完全不做互斥)—— 这正是「单路逐字不变」。
|
||||||
|
if err := c.Claim(ctx, "", writeTarget(file)); err != nil {
|
||||||
|
t.Fatalf("空路标识不做互斥,得到 %v", err)
|
||||||
|
}
|
||||||
|
// 空目标、nil ctx 也不 panic。
|
||||||
|
if err := c.Claim(ctx, "turn-a", ""); err != nil {
|
||||||
|
t.Fatalf("空目标不做互斥,得到 %v", err)
|
||||||
|
}
|
||||||
|
if err := c.Claim(nil, "turn-x", writeTarget(file)); !errors.Is(err, ErrFileBusy) {
|
||||||
|
t.Fatalf("nil ctx 不该改变判定,得到 %v", err)
|
||||||
|
}
|
||||||
|
c.Release(ctx, "", writeTarget(file))
|
||||||
|
if err := c.Claim(ctx, "turn-c", writeTarget(file)); !errors.Is(err, ErrFileBusy) {
|
||||||
|
t.Fatalf("空路标识的释放必须是空操作,得到 %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFileClaimsPathAliasesCollapse 钉住占用键的**词法规范化**:同一文件的
|
||||||
|
// `sub/../notes.txt` 与 `./notes.txt` 写法必须撞成同一个键,否则互斥形同虚设。
|
||||||
|
// Windows 上还要折叠大小写(`NOTES.TXT` 与 `notes.txt` 是同一个文件)。
|
||||||
|
func TestFileClaimsPathAliasesCollapse(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
c := newTestFileClaims()
|
||||||
|
ctx := context.Background()
|
||||||
|
base := t.TempDir()
|
||||||
|
file := filepath.Join(base, "sub", "..", "notes.txt")
|
||||||
|
alias := filepath.Join(base, ".", "notes.txt")
|
||||||
|
|
||||||
|
if err := c.Claim(ctx, "turn-a", writeTarget(file)); err != nil {
|
||||||
|
t.Fatalf("A 占用: %v", err)
|
||||||
|
}
|
||||||
|
if err := c.Claim(ctx, "turn-b", writeTarget(alias)); !errors.Is(err, ErrFileBusy) {
|
||||||
|
t.Fatalf("同一文件的两种写法必须撞成同一个键,得到 %v", err)
|
||||||
|
}
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
upper := strings.ToUpper(alias)
|
||||||
|
if err := c.Claim(ctx, "turn-b", writeTarget(upper)); !errors.Is(err, ErrFileBusy) {
|
||||||
|
t.Fatalf("Windows 大小写不敏感,必须撞成同一个键,得到 %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFileClaimKeyShape 钉住占用键的形状与「未知即写」的保守方向:裸路径
|
||||||
|
// (外部实现方直接传路径的口径)按**写**处理,与读写都互斥。
|
||||||
|
func TestFileClaimKeyShape(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
if op, path := parseFileClaimKey(fileClaimKey(FileOpRead, "a.txt")); op != FileOpRead || path != "a.txt" {
|
||||||
|
t.Fatalf("读键往返 = (%q,%q),want (read,a.txt)", op, path)
|
||||||
|
}
|
||||||
|
if op, path := parseFileClaimKey(fileClaimKey(FileOpWrite, "a.txt")); op != FileOpWrite || path != "a.txt" {
|
||||||
|
t.Fatalf("写键往返 = (%q,%q),want (write,a.txt)", op, path)
|
||||||
|
}
|
||||||
|
// 空/未知类别与裸路径一律按写(宁可多挡一次,也不放过并发写)。
|
||||||
|
for _, target := range []string{"a.txt", fileClaimKey("", "a.txt"), fileClaimKey("chmod", "a.txt")} {
|
||||||
|
if op, path := parseFileClaimKey(target); op != FileOpWrite || path != "a.txt" {
|
||||||
|
t.Fatalf("目标 %q 应保守按写处理,得到 (%q,%q)", target, op, path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFileClaimsBareTargetBlocksReadAndWrite 把上一条的「未知即写」钉到行为上:
|
||||||
|
// 裸路径占用必须同时挡住读与写。
|
||||||
|
func TestFileClaimsBareTargetBlocksReadAndWrite(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
c := newTestFileClaims()
|
||||||
|
ctx := context.Background()
|
||||||
|
file := filepath.Join(t.TempDir(), "notes.txt")
|
||||||
|
|
||||||
|
if err := c.Claim(ctx, "turn-a", file); err != nil {
|
||||||
|
t.Fatalf("裸路径占用: %v", err)
|
||||||
|
}
|
||||||
|
if err := c.Claim(ctx, "turn-b", readTarget(file)); !errors.Is(err, ErrFileBusy) {
|
||||||
|
t.Fatalf("裸路径按写处理 ⇒ 必须挡住读,得到 %v", err)
|
||||||
|
}
|
||||||
|
if err := c.Claim(ctx, "turn-b", writeTarget(file)); !errors.Is(err, ErrFileBusy) {
|
||||||
|
t.Fatalf("裸路径按写处理 ⇒ 必须挡住写,得到 %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,10 +6,13 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 本文件是**接口预留**(design/15 §2.5.7 与主人 2026-10-05 裁决 3),本切片
|
// 本文件钉住**文件级**互斥(design/15 §2.5.7 与主人 2026-10-05 裁决 3)的对外
|
||||||
// **不实现也不接线**:忙时并行(design/15 §2.5)落地后,同一会话可以有多条在途
|
// 形状:通道(WithLegID / LegIDFrom)、接口(FileClaimer)与错误(ErrFileBusy /
|
||||||
// 回合,而写类工具(执行命令 / 修改文件 / 对外发消息 / 改配置,即 design/11 §4
|
// FileBusyError / FileBusyStatusError)。**实现与接线在 fileclaims.go**(占用表、
|
||||||
// 的敏感清单)在两路之间会真正争抢同一个副作用目标。
|
// 目标声明接口 FileTargeter、Dispatcher 的登记/释放)。忙时并行(design/15 §2.5)
|
||||||
|
// 落地后同一会话可以有多条在途回合,而写类工具(执行命令 / 修改文件 / 对外发
|
||||||
|
// 消息 / 改配置,即 design/11 §4 的敏感清单)在两路之间会真正争抢同一个副作用
|
||||||
|
// 目标。
|
||||||
//
|
//
|
||||||
// 主人口径(裁决 3)是**文件级**互斥,而不是会话级串行:
|
// 主人口径(裁决 3)是**文件级**互斥,而不是会话级串行:
|
||||||
// - 工具先检测目标文件此刻是否正被**别的在途路**操作;
|
// - 工具先检测目标文件此刻是否正被**别的在途路**操作;
|
||||||
@@ -17,11 +20,6 @@ import (
|
|||||||
// LLM,并提示稍后再试(**不静默、不排队死等**);
|
// LLM,并提示稍后再试(**不静默、不排队死等**);
|
||||||
// - **不是同一个文件** ⇒ 允许并行操作。
|
// - **不是同一个文件** ⇒ 允许并行操作。
|
||||||
//
|
//
|
||||||
// 为什么只留接口:写类工具的「目标文件」需要逐工具声明(命令、补丁、配置写入
|
|
||||||
// 各自的目标语义不同),且占用登记要挂在**路**的生命周期上(路一收尾就必须释放,
|
|
||||||
// 否则一个中途报错的路会把那个文件永久锁住)。这两件都属于后续切片,本切片只把
|
|
||||||
// 通道与判定口径钉死,使实现方不必再改工具接口。
|
|
||||||
//
|
|
||||||
// 身份与权限**不走这条通道**:它只回答「此刻有没有另一路正在动这个文件」,与
|
// 身份与权限**不走这条通道**:它只回答「此刻有没有另一路正在动这个文件」,与
|
||||||
// actor 元数据 / 策略引擎无关(项目红线)。
|
// actor 元数据 / 策略引擎无关(项目红线)。
|
||||||
|
|
||||||
@@ -39,8 +37,22 @@ func FileBusyError(target string) error {
|
|||||||
return fmt.Errorf("%w(目标:%s)", ErrFileBusy, target)
|
return fmt.Errorf("%w(目标:%s)", ErrFileBusy, target)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FileBusyStatusError 在 FileBusyError 之上补一句**文件状态**(该文件此刻被
|
||||||
|
// 另一路读还是写、本次想做的操作因此做不了),使回灌给 LLM 的结果说清「为什么
|
||||||
|
// 现在动不了它、该等什么」,而不是只丢一个「忙」。status 为空时退化为
|
||||||
|
// FileBusyError(形状不变)。
|
||||||
|
//
|
||||||
|
// 与 target 一样,status 只描述**状态**:绝不带另一条路的身份、正文或任何文件
|
||||||
|
// 内容(红线:不泄露另一路的内容)。
|
||||||
|
func FileBusyStatusError(target, status string) error {
|
||||||
|
if status == "" {
|
||||||
|
return FileBusyError(target)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%w(目标:%s;%s)", ErrFileBusy, target, status)
|
||||||
|
}
|
||||||
|
|
||||||
// FileClaimer 是按**文件级**粒度登记在途操作的接口(主人裁决 3)。实现方
|
// FileClaimer 是按**文件级**粒度登记在途操作的接口(主人裁决 3)。实现方
|
||||||
// (后续切片)负责:
|
// (进程内实现见 fileclaims.go 的 NewFileClaimer)负责:
|
||||||
// - 以规范化后的目标为键,记录「哪一条路正在操作它」;
|
// - 以规范化后的目标为键,记录「哪一条路正在操作它」;
|
||||||
// - Claim 在目标已被**另一条路**占用时返回 ErrFileBusy(同一路重复 Claim
|
// - Claim 在目标已被**另一条路**占用时返回 ErrFileBusy(同一路重复 Claim
|
||||||
// 同一目标必须是幂等的,否则一条路自己的多步工具调用会互相挡);
|
// 同一目标必须是幂等的,否则一条路自己的多步工具调用会互相挡);
|
||||||
@@ -48,6 +60,9 @@ func FileBusyError(target string) error {
|
|||||||
// 占用者时是空操作。
|
// 占用者时是空操作。
|
||||||
//
|
//
|
||||||
// 实现必须是**有界的**:占用表随路收尾清空,绝不随会话长度增长。
|
// 实现必须是**有界的**:占用表随路收尾清空,绝不随会话长度增长。
|
||||||
|
//
|
||||||
|
// target 的拼法由调用方与本包的 fileClaimKey 统一给出(操作类别 + 规范化路径);
|
||||||
|
// 直接传裸路径的调用方按「写」处理(见 parseFileClaimKey)。
|
||||||
type FileClaimer interface {
|
type FileClaimer interface {
|
||||||
Claim(ctx context.Context, legID, target string) error
|
Claim(ctx context.Context, legID, target string) error
|
||||||
Release(ctx context.Context, legID, target string)
|
Release(ctx context.Context, legID, target string)
|
||||||
|
|||||||
@@ -110,9 +110,10 @@ type Dispatcher struct {
|
|||||||
reg *registry.Registry
|
reg *registry.Registry
|
||||||
dft time.Duration
|
dft time.Duration
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
mu sync.RWMutex // guards timeouts and modes
|
mu sync.RWMutex // guards timeouts, modes and the file claimer
|
||||||
timeouts map[string]time.Duration
|
timeouts map[string]time.Duration
|
||||||
modes map[string]string // tool name -> ExecutionMode* (default parallel)
|
modes map[string]string // tool name -> ExecutionMode* (default parallel)
|
||||||
|
claimer FileClaimer // 文件级互斥的占用表(见 fileclaims.go)
|
||||||
sinkMu sync.RWMutex // guards the soft-judge sink
|
sinkMu sync.RWMutex // guards the soft-judge sink
|
||||||
sink SoftJudgeSink
|
sink SoftJudgeSink
|
||||||
}
|
}
|
||||||
@@ -120,6 +121,11 @@ type Dispatcher struct {
|
|||||||
// New builds a Dispatcher backed by reg, applying opts in order. It returns an
|
// New builds a Dispatcher backed by reg, applying opts in order. It returns an
|
||||||
// error wrapping ErrNilRegistry when reg is nil. The default per-invoke timeout
|
// error wrapping ErrNilRegistry when reg is nil. The default per-invoke timeout
|
||||||
// is DefaultTimeout unless overridden with WithDefaultTimeout.
|
// is DefaultTimeout unless overridden with WithDefaultTimeout.
|
||||||
|
//
|
||||||
|
// 默认装配一份进程内文件占用表(fileclaims.go):它对**未声明目标文件**的工具、
|
||||||
|
// 以及**未挂路标识**(单路 / 子代理 / 未接线)的调用完全无副作用,因此既有行为
|
||||||
|
// 逐字不变;一旦工具实现 FileTargeter 且调用带路标识,写类工具的文件级互斥立即
|
||||||
|
// 生效(无需上层再接线)。
|
||||||
func New(reg *registry.Registry, opts ...Option) (*Dispatcher, error) {
|
func New(reg *registry.Registry, opts ...Option) (*Dispatcher, error) {
|
||||||
if reg == nil {
|
if reg == nil {
|
||||||
return nil, fmt.Errorf("tool: new: %w", ErrNilRegistry)
|
return nil, fmt.Errorf("tool: new: %w", ErrNilRegistry)
|
||||||
@@ -130,6 +136,7 @@ func New(reg *registry.Registry, opts ...Option) (*Dispatcher, error) {
|
|||||||
log: sourcedLog(nil),
|
log: sourcedLog(nil),
|
||||||
timeouts: make(map[string]time.Duration),
|
timeouts: make(map[string]time.Duration),
|
||||||
modes: make(map[string]string),
|
modes: make(map[string]string),
|
||||||
|
claimer: NewFileClaimer(),
|
||||||
}
|
}
|
||||||
for _, opt := range opts {
|
for _, opt := range opts {
|
||||||
if opt != nil {
|
if opt != nil {
|
||||||
@@ -152,6 +159,14 @@ func WithLogger(l *slog.Logger) Option {
|
|||||||
return func(d *Dispatcher) { d.log = sourcedLog(l) }
|
return func(d *Dispatcher) { d.log = sourcedLog(l) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithFileClaimer 替换文件级互斥使用的占用表(设计/实现见 fileclaims.go)。
|
||||||
|
// 传 nil 表示**关闭**文件级互斥(工具声明的目标一律不登记、不检查)—— 供测试
|
||||||
|
// 与需要「完全不做互斥」的装配点使用;新构建的 Dispatcher 默认已带一份进程内
|
||||||
|
// 占用表,通常无需调用它。
|
||||||
|
func WithFileClaimer(c FileClaimer) Option {
|
||||||
|
return func(d *Dispatcher) { d.claimer = c }
|
||||||
|
}
|
||||||
|
|
||||||
// sourcedLog tags l with the module source "runtime/tool"; nil becomes a
|
// sourcedLog tags l with the module source "runtime/tool"; nil becomes a
|
||||||
// discard logger, so the soft judge can always log safely.
|
// discard logger, so the soft judge can always log safely.
|
||||||
func sourcedLog(l *slog.Logger) *slog.Logger {
|
func sourcedLog(l *slog.Logger) *slog.Logger {
|
||||||
@@ -236,6 +251,13 @@ func (d *Dispatcher) PolicyEngine(env restrict.ActorEnv) *restrict.Policy {
|
|||||||
// *ToolError-free soft result: the call still runs, and its value comes back as
|
// *ToolError-free soft result: the call still runs, and its value comes back as
|
||||||
// SoftNotAllowed carrying the not_allowed notice for the model (design/14 §4,
|
// SoftNotAllowed carrying the not_allowed notice for the model (design/14 §4,
|
||||||
// 裁决 14.4.2).
|
// 裁决 14.4.2).
|
||||||
|
//
|
||||||
|
// 参与文件级互斥的工具(实现 FileTargeter,见 fileclaims.go)在目标文件正被
|
||||||
|
// **另一条在途路**操作时,其调用**不会执行**,而是直接返回 ErrFileBusy(带文件
|
||||||
|
// 状态的中文文案)。它**不包 ToolError**:占用判定发生在执行之前,回灌给模型的
|
||||||
|
// 结果就该是一句话(ToolError 的 message+cause 渲染会把同一句话打印两遍);
|
||||||
|
// 循环的 errorKind 仍把它归为 execution,与执行面失败的分类一致。
|
||||||
|
// 无论如何都**不静默、不排队等待**。
|
||||||
func (d *Dispatcher) Invoke(ctx context.Context, name string, args map[string]any) (any, error) {
|
func (d *Dispatcher) Invoke(ctx context.Context, name string, args map[string]any) (any, error) {
|
||||||
if name == "" {
|
if name == "" {
|
||||||
return nil, &ToolError{Name: name, Message: "tool name is empty", cause: ErrUnknownTool}
|
return nil, &ToolError{Name: name, Message: "tool name is empty", cause: ErrUnknownTool}
|
||||||
@@ -272,6 +294,18 @@ func (d *Dispatcher) Invoke(ctx context.Context, name string, args map[string]an
|
|||||||
// 不硬阻断(状态不是能力边界),但必须可观测、可回灌 —— 见 allowset.go。
|
// 不硬阻断(状态不是能力边界),但必须可观测、可回灌 —— 见 allowset.go。
|
||||||
notice := d.softJudge(ctx, name)
|
notice := d.softJudge(ctx, name)
|
||||||
|
|
||||||
|
// 文件级互斥(design/15 §2.5.7、主人 2026-10-05 裁决 3):写类工具执行前先登记
|
||||||
|
// 本次要动的文件;被**另一条在途路**占住时立刻把带文件状态的 ErrFileBusy 交给
|
||||||
|
// 模型(不静默、不排队死等)。释放挂 defer:成功 / 报错 / 超时 / panic 都释放,
|
||||||
|
// 绝不把文件永久锁住。未挂路标识或工具未声明目标 ⇒ 完全不参与互斥,逐字不变。
|
||||||
|
release, cerr := d.claimFiles(ctx, name, tool, args)
|
||||||
|
if cerr != nil {
|
||||||
|
return nil, cerr
|
||||||
|
}
|
||||||
|
if release != nil {
|
||||||
|
defer release()
|
||||||
|
}
|
||||||
|
|
||||||
invokeCtx, cancel := d.invokeCtx(ctx, name)
|
invokeCtx, cancel := d.invokeCtx(ctx, name)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user