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

295 lines
12 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 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)
}
}