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

+35 -1
View File
@@ -110,9 +110,10 @@ type Dispatcher struct {
reg *registry.Registry
dft time.Duration
log *slog.Logger
mu sync.RWMutex // guards timeouts and modes
mu sync.RWMutex // guards timeouts, modes and the file claimer
timeouts map[string]time.Duration
modes map[string]string // tool name -> ExecutionMode* (default parallel)
claimer FileClaimer // 文件级互斥的占用表(见 fileclaims.go)
sinkMu sync.RWMutex // guards the soft-judge sink
sink SoftJudgeSink
}
@@ -120,6 +121,11 @@ type Dispatcher struct {
// New builds a Dispatcher backed by reg, applying opts in order. It returns an
// error wrapping ErrNilRegistry when reg is nil. The default per-invoke timeout
// is DefaultTimeout unless overridden with WithDefaultTimeout.
//
// 默认装配一份进程内文件占用表(fileclaims.go):它对**未声明目标文件**的工具、
// 以及**未挂路标识**(单路 / 子代理 / 未接线)的调用完全无副作用,因此既有行为
// 逐字不变;一旦工具实现 FileTargeter 且调用带路标识,写类工具的文件级互斥立即
// 生效(无需上层再接线)。
func New(reg *registry.Registry, opts ...Option) (*Dispatcher, error) {
if reg == nil {
return nil, fmt.Errorf("tool: new: %w", ErrNilRegistry)
@@ -130,6 +136,7 @@ func New(reg *registry.Registry, opts ...Option) (*Dispatcher, error) {
log: sourcedLog(nil),
timeouts: make(map[string]time.Duration),
modes: make(map[string]string),
claimer: NewFileClaimer(),
}
for _, opt := range opts {
if opt != nil {
@@ -152,6 +159,14 @@ func WithLogger(l *slog.Logger) Option {
return func(d *Dispatcher) { d.log = sourcedLog(l) }
}
// WithFileClaimer 替换文件级互斥使用的占用表(设计/实现见 fileclaims.go)。
// 传 nil 表示**关闭**文件级互斥(工具声明的目标一律不登记、不检查)—— 供测试
// 与需要「完全不做互斥」的装配点使用;新构建的 Dispatcher 默认已带一份进程内
// 占用表,通常无需调用它。
func WithFileClaimer(c FileClaimer) Option {
return func(d *Dispatcher) { d.claimer = c }
}
// sourcedLog tags l with the module source "runtime/tool"; nil becomes a
// discard logger, so the soft judge can always log safely.
func sourcedLog(l *slog.Logger) *slog.Logger {
@@ -236,6 +251,13 @@ func (d *Dispatcher) PolicyEngine(env restrict.ActorEnv) *restrict.Policy {
// *ToolError-free soft result: the call still runs, and its value comes back as
// SoftNotAllowed carrying the not_allowed notice for the model (design/14 §4,
// 裁决 14.4.2).
//
// 参与文件级互斥的工具(实现 FileTargeter,见 fileclaims.go)在目标文件正被
// **另一条在途路**操作时,其调用**不会执行**,而是直接返回 ErrFileBusy(带文件
// 状态的中文文案)。它**不包 ToolError**:占用判定发生在执行之前,回灌给模型的
// 结果就该是一句话(ToolError 的 message+cause 渲染会把同一句话打印两遍);
// 循环的 errorKind 仍把它归为 execution,与执行面失败的分类一致。
// 无论如何都**不静默、不排队等待**。
func (d *Dispatcher) Invoke(ctx context.Context, name string, args map[string]any) (any, error) {
if name == "" {
return nil, &ToolError{Name: name, Message: "tool name is empty", cause: ErrUnknownTool}
@@ -272,6 +294,18 @@ func (d *Dispatcher) Invoke(ctx context.Context, name string, args map[string]an
// 不硬阻断(状态不是能力边界),但必须可观测、可回灌 —— 见 allowset.go。
notice := d.softJudge(ctx, name)
// 文件级互斥(design/15 §2.5.7、主人 2026-10-05 裁决 3):写类工具执行前先登记
// 本次要动的文件;被**另一条在途路**占住时立刻把带文件状态的 ErrFileBusy 交给
// 模型(不静默、不排队死等)。释放挂 defer:成功 / 报错 / 超时 / panic 都释放,
// 绝不把文件永久锁住。未挂路标识或工具未声明目标 ⇒ 完全不参与互斥,逐字不变。
release, cerr := d.claimFiles(ctx, name, tool, args)
if cerr != nil {
return nil, cerr
}
if release != nil {
defer release()
}
invokeCtx, cancel := d.invokeCtx(ctx, name)
defer cancel()