feat(tools): 真实工具声明文件目标(让文件级互斥真正生效)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

新增 plugins/tools/io/file_targets.go:fileTool 与 cropTool 实现 tool.FileTargeter
(编译期钉住 var _ tool.FileTargeter,声明集中一处可审计)。
file:read/list/search ⇒ 读;其余(含 delete / 未知 / 缺失 action)⇒ 写(保守侧,宁可多挡一次)。
路径取沙箱解析前的声明值(绝对、clean、已确认在 roots 内,纯词法不触盘);沙箱会拒的路径不声明。
search 的缺省 base 与执行路径共用 f.searchBase(避免声明目录 ≠ 实际走查目录)。
image-crop:仅本地路径声明为读(URL/data URI 不落盘);分类与 load 共用一份。
体检清单:file ✅ / image-crop ✅ / document-read ❌(sidecar 走「临时文件 + rename」原子替换,
无撕读风险;且布局属未导出的第二份真相)/ basic、http、knowledge 等不碰盘;
vision·ocr(internal 越界)、terminal(cmd 越界 + 目标不可知)、MCP 外部工具 未做 —— 如实登记为遗留。
测试:单元(action→Op 全表 / 路径归一 / 不声明场景)+ 机制层 6 条(真插件 + 真 Dispatcher + 真占用表,
用闸门工具把并发变确定性事件,不靠 sleep)。
变异 5 条(一切按写 / 未知按读 / 用原始相对串 / crop 不声明 / file 声明被抹掉)。
注:本切片声明正确但生效依赖 ef3dcb6(网关给每条在途路挂互斥身份)。
This commit is contained in:
AskaEth committed 2026-10-05 09:55:29 +08:00
1 parent ef3dcb699b
commit 3589f4b14f
7 files changed
+842 -15

No files matched your search

+208
View File
@@ -0,0 +1,208 @@
package io
import (
"os"
"path/filepath"
"testing"
)
// 本文件是**文件级互斥的机制级回归**(声明面单元见 file_targets_test.go,夹具见
// file_claims_rig_test.go):把**真实插件**注册进真实的 tool.Dispatcher,用可控闸门把
// 「一路持有占用」变成确定性事件(占用登记在 Execute 之前、释放在其后,因此停在闸门里就
// 等于持有),逐条钉住主人口径:
//
// - 同一文件的两个读**并行放行**(检索类并行不该被互斥拖住);
// - 读与写、写与写**判忙**,且回灌给模型的文案带文件名与文件状态;
// - 不同文件**放行**;
// - 未实现 FileTargeter 的工具**完全不受影响**(占用表 0 键)。
//
// 这一层走的是派发器真实的登记 / 释放路径与真实的进程内占用表 —— 声明面一错就红。
// TestFileClaims_TwoReadsOfTheSameFileRunParallel 钉住①:同一文件的两个读并行放行
// (主人「优先考虑性能而不是预算」的口径)。
func TestFileClaims_TwoReadsOfTheSameFileRunParallel(t *testing.T) {
t.Parallel()
rig := newClaimRig(t)
file := rig.seed(t, "notes.txt", "hello from disk")
holder := rig.gated(t, "file", "file-read-holder")
first := runLeg(rig.d, "turn-a", holder.Name(), map[string]any{"action": "read", "path": file})
awaitEnter(t, holder.entered)
val, err := invokeLeg(rig.d, "turn-b", "file", map[string]any{"action": "read", "path": file})
if err != nil {
t.Fatalf("同一文件的两个读必须允许并行,得到 %v", err)
}
if content := toolContent(t, val); content != "hello from disk" {
t.Fatalf("第二路读到的正文 = %q", content)
}
// 两路登记的是**同一个键**(同类别、同路径):这才叫「同一个文件的读读并行」,
// 而不是两路各自登记了不同的键。
targets := rig.rec.targets()
if len(targets) != 2 || targets[0] != targets[1] {
t.Fatalf("两路读应登记同一个目标键,得到 %#v", targets)
}
if got := rig.rec.liveCount(); got != 1 {
t.Fatalf("第二路退场后应只剩持锁那一路的键,实际 %d 个", got)
}
holder.release()
if res := awaitDone(t, first); res.err != nil {
t.Fatalf("持锁那一路应正常完成: %v", res.err)
}
assertNoClaims(t, rig.rec, 2)
}
// TestFileClaims_ReadIsBusyWhileAnotherLegWrites 钉住②:读与写同一文件 ⇒ 判忙,且面向
// 模型的文案带文件名与文件状态(谁在写、本次读不了);持锁那一路退场后立刻可再进。
func TestFileClaims_ReadIsBusyWhileAnotherLegWrites(t *testing.T) {
t.Parallel()
rig := newClaimRig(t)
file := rig.seed(t, "notes.txt", "v1")
holder := rig.gated(t, "file", "file-write-holder")
first := runLeg(rig.d, "turn-a", holder.Name(), map[string]any{"action": "write", "path": file, "content": "v2"})
awaitEnter(t, holder.entered)
_, err := invokeLeg(rig.d, "turn-b", "file", map[string]any{"action": "read", "path": file})
assertBusy(t, err, "notes.txt", "正在被另一路写入", "本次读取无法进行")
holder.release()
if res := awaitDone(t, first); res.err != nil {
t.Fatalf("持锁那一路应正常完成: %v", res.err)
}
if data, err := os.ReadFile(file); err != nil || string(data) != "v2" {
t.Fatalf("持锁那一路的写入没有落盘: %q, %v", data, err)
}
if _, err := invokeLeg(rig.d, "turn-c", "file", map[string]any{"action": "read", "path": file}); err != nil {
t.Fatalf("前一路退场后必须已释放占用,得到 %v", err)
}
// 三次尝试:持锁那一路的写、被判忙的那次读、退场后的第二次读。
assertNoClaims(t, rig.rec, 3)
}
// TestFileClaims_WriteVsWriteIsBusy 钉住②(写写):第二路写同一文件被判忙、**根本没有执行**
// (闸门未被进入),文件里留下的是持锁那一路的内容。
func TestFileClaims_WriteVsWriteIsBusy(t *testing.T) {
t.Parallel()
rig := newClaimRig(t)
file := rig.seed(t, "notes.txt", "seed")
holder := rig.gated(t, "file", "file-write-holder")
peer := rig.gated(t, "file", "file-write-peer")
first := runLeg(rig.d, "turn-a", holder.Name(), map[string]any{"action": "write", "path": file, "content": "held"})
awaitEnter(t, holder.entered)
_, err := invokeLeg(rig.d, "turn-b", peer.Name(), map[string]any{"action": "write", "path": file, "content": "peer"})
assertBusy(t, err, "notes.txt", "正在被另一路写入", "本次写入无法进行")
select {
case <-peer.entered:
t.Fatal("被判忙的调用绝不该执行")
default:
}
holder.release()
if res := awaitDone(t, first); res.err != nil {
t.Fatalf("持锁那一路应正常完成: %v", res.err)
}
if data, err := os.ReadFile(file); err != nil || string(data) != "held" {
t.Fatalf("文件内容应只剩持锁那一路写的 held: %q, %v", data, err)
}
// 两次尝试:持锁那一路的写,以及第二路被判忙的写(判忙那一路也问过表,只是没占到键)。
assertNoClaims(t, rig.rec, 2)
}
// TestFileClaims_CrossToolReadOfAWrittenFileIsBusy 钉住②(跨工具):file 正在写一个文件时,
// image-crop 读**同一个文件**同样判忙 —— 这正是文件级互斥要盖住的场景(读到的会是撕开的
// 内容),也是「同一份路径口径被两个工具共用」的证据。
func TestFileClaims_CrossToolReadOfAWrittenFileIsBusy(t *testing.T) {
t.Parallel()
rig := newClaimRig(t)
file := rig.seed(t, "notes.txt", "v1")
holder := rig.gated(t, "file", "file-write-holder")
first := runLeg(rig.d, "turn-a", holder.Name(), map[string]any{"action": "write", "path": file, "content": "v2"})
awaitEnter(t, holder.entered)
_, err := invokeLeg(rig.d, "turn-b", "image-crop", map[string]any{
"image": file, "x": 0, "y": 0, "width": 1, "height": 1,
})
assertBusy(t, err, "notes.txt", "正在被另一路写入", "本次读取无法进行")
holder.release()
if res := awaitDone(t, first); res.err != nil {
t.Fatalf("持锁那一路应正常完成: %v", res.err)
}
// 两次尝试:持锁那一路的写,以及 image-crop 读同一文件被判忙的那次。
assertNoClaims(t, rig.rec, 2)
}
// TestFileClaims_DifferentFilesRunParallel 钉住③:不是同一个文件 ⇒ 放行(且真的写成功),
// 占用表里两路各占一个键、退场后清空。
func TestFileClaims_DifferentFilesRunParallel(t *testing.T) {
t.Parallel()
rig := newClaimRig(t)
held := rig.seed(t, "a.txt", "a")
other := filepath.Join(rig.dir, "b.txt")
holder := rig.gated(t, "file", "file-write-holder")
first := runLeg(rig.d, "turn-a", holder.Name(), map[string]any{"action": "write", "path": held, "content": "held"})
awaitEnter(t, holder.entered)
val, err := invokeLeg(rig.d, "turn-b", "file", map[string]any{"action": "write", "path": other, "content": "bee"})
if err != nil {
t.Fatalf("不同文件必须允许并行操作,得到 %v", err)
}
if m, ok := val.(map[string]any); !ok || m["size"] != len("bee") {
t.Fatalf("第二路的结果异常: %#v", val)
}
if data, err := os.ReadFile(other); err != nil || string(data) != "bee" {
t.Fatalf("第二路的写入没有落盘: %q, %v", data, err)
}
if got := rig.rec.liveCount(); got != 1 {
t.Fatalf("第二路退场后应只剩持锁那一路的键,实际 %d 个", got)
}
holder.release()
if res := awaitDone(t, first); res.err != nil {
t.Fatalf("持锁那一路应正常完成: %v", res.err)
}
assertNoClaims(t, rig.rec, 2)
}
// TestFileClaims_ToolWithoutDeclarationIsUnaffected 钉住④:未实现 FileTargeter 的工具完全
// 不受影响 —— (a) 它一次都不碰占用表;(b) 真实插件里不碰文件的 web-search 在别的路持有
// 写占用时照常调用,不判忙、不登记。
func TestFileClaims_ToolWithoutDeclarationIsUnaffected(t *testing.T) {
t.Parallel()
rig := newClaimRig(t)
// (a) 两路并发调用一个只实现 sdk.Tool 的工具:都成功,占用表 0 键、登记 0 次。
plain := &plainTool{name: "plain-tool"}
if err := rig.d.Register(plain); err != nil {
t.Fatalf("Register %q: %v", plain.Name(), err)
}
for _, leg := range []string{"turn-a", "turn-b"} {
val, err := invokeLeg(rig.d, leg, plain.Name(), map[string]any{"echo": "hi"})
if err != nil {
t.Fatalf("未声明目标的工具不该受影响(leg=%s): %v", leg, err)
}
if echo, _ := val.(string); echo != "hi" {
t.Fatalf("结果 = %#v", val)
}
}
assertNoClaims(t, rig.rec, 0)
// (b) 另一路持有某个文件的写占用时,未声明目标的真实工具照常可调用。
file := rig.seed(t, "notes.txt", "v1")
holder := rig.gated(t, "file", "file-write-holder")
first := runLeg(rig.d, "turn-a", holder.Name(), map[string]any{"action": "write", "path": file, "content": "v2"})
awaitEnter(t, holder.entered)
_, err := invokeLeg(rig.d, "turn-b", "web-search", map[string]any{"query": "x"})
assertNotBusy(t, err)
if got := rig.rec.claimAttempts(); got != 1 {
t.Fatalf("只有持锁那一路该问占用表,实际问了 %d 次", got)
}
holder.release()
if res := awaitDone(t, first); res.err != nil {
t.Fatalf("持锁那一路应正常完成: %v", res.err)
}
assertNoClaims(t, rig.rec, 1)
}