新增 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 等工具加声明。
295 lines
12 KiB
Go
295 lines
12 KiB
Go
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)
|
||
}
|
||
}
|