feat(tool): 写类工具文件级互斥(读共享 / 写独占)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

新增 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:
AskaEth committed 2026-10-05 09:38:12 +08:00
1 parent c0dc435770
commit 354b08a45f
7 files changed
+1367 -12

No files matched your search

+294
View File
@@ -0,0 +1,294 @@
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)
}
}