Files
Cyrene_v2/plugins/tools/io/file_claims_test.go
T
AskaEth 3589f4b14f
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(tools): 真实工具声明文件目标(让文件级互斥真正生效)
新增 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(网关给每条在途路挂互斥身份)。
2026-10-05 09:55:29 +08:00

209 lines
9.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package 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)
}