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)
|
||||
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
|
||||
if err != nil {
|
||||
phase = model.ToolPhaseError
|
||||
|
||||
Reference in new issue
Block a user