Files
Cyrene_v2/internal/runtime/tool/legfile.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

96 lines
4.9 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"
"fmt"
)
// 本文件钉住**文件级**互斥(design/15 §2.5.7 与主人 2026-10-05 裁决 3)的对外
// 形状:通道(WithLegID / LegIDFrom)、接口(FileClaimer)与错误(ErrFileBusy /
// FileBusyError / FileBusyStatusError)。**实现与接线在 fileclaims.go**(占用表、
// 目标声明接口 FileTargeter、Dispatcher 的登记/释放)。忙时并行(design/15 §2.5)
// 落地后同一会话可以有多条在途回合,而写类工具(执行命令 / 修改文件 / 对外发
// 消息 / 改配置,即 design/11 §4 的敏感清单)在两路之间会真正争抢同一个副作用
// 目标。
//
// 主人口径(裁决 3)是**文件级**互斥,而不是会话级串行:
// - 工具先检测目标文件此刻是否正被**别的在途路**操作;
// - **同一文件**正在被操作 ⇒ 把「该文件正在被操作」**显式**作为工具结果返回给
// LLM,并提示稍后再试(**不静默、不排队死等**);
// - **不是同一个文件** ⇒ 允许并行操作。
//
// 身份与权限**不走这条通道**:它只回答「此刻有没有另一路正在动这个文件」,与
// actor 元数据 / 策略引擎无关(项目红线)。
// ErrFileBusy 报告目标文件此刻正被另一条在途路操作。它不是执行失败:调用方
// (写类工具)应把它**显式**返回给 LLM,让模型稍后重试,而不是静默吞掉或排队
// 死等(主人裁决 3)。错误文案面向模型,因此写清「同一文件被另一路占用」与
// 「请稍后再试」,绝不泄露另一路的内容。
var ErrFileBusy = errors.New("tool: 目标文件正在被另一条并行回合操作,请稍后再试")
// FileBusyError 把 ErrFileBusy 与具体目标绑成一条可直接交给 LLM 的错误,
// 同时保持 errors.Is(err, ErrFileBusy) 成立(%w 包装)。target 是**服务端
// 规范化后**的文件标识,调用方不得把用户原始输入当作它(那会把路径穿越或
// 隐私内容带进模型上下文)。
func FileBusyError(target string) error {
return fmt.Errorf("%w(目标:%s)", ErrFileBusy, target)
}
// FileBusyStatusError 在 FileBusyError 之上补一句**文件状态**(该文件此刻被
// 另一路读还是写、本次想做的操作因此做不了),使回灌给 LLM 的结果说清「为什么
// 现在动不了它、该等什么」,而不是只丢一个「忙」。status 为空时退化为
// FileBusyError(形状不变)。
//
// 与 target 一样,status 只描述**状态**:绝不带另一条路的身份、正文或任何文件
// 内容(红线:不泄露另一路的内容)。
func FileBusyStatusError(target, status string) error {
if status == "" {
return FileBusyError(target)
}
return fmt.Errorf("%w(目标:%s;%s)", ErrFileBusy, target, status)
}
// FileClaimer 是按**文件级**粒度登记在途操作的接口(主人裁决 3)。实现方
// (进程内实现见 fileclaims.go 的 NewFileClaimer)负责:
// - 以规范化后的目标为键,记录「哪一条路正在操作它」;
// - Claim 在目标已被**另一条路**占用时返回 ErrFileBusy(同一路重复 Claim
// 同一目标必须是幂等的,否则一条路自己的多步工具调用会互相挡);
// - Release 无条件释放该路在该目标上的占用(路收尾时的兜底),legID 不是
// 占用者时是空操作。
//
// 实现必须是**有界的**:占用表随路收尾清空,绝不随会话长度增长。
//
// target 的拼法由调用方与本包的 fileClaimKey 统一给出(操作类别 + 规范化路径);
// 直接传裸路径的调用方按「写」处理(见 parseFileClaimKey)。
type FileClaimer interface {
Claim(ctx context.Context, legID, target string) error
Release(ctx context.Context, legID, target string)
}
// legIDKey 是「本回合属于哪条并发路」的 context key。
type legIDKey struct{}
// WithLegID 把本回合所属的并发路标识挂到 ctx 上。循环在 Run 顶部按 ctx 上挂的
// 路标识播种一次(单路 / 子代理 / 后台思考为空串 ⇒ 不挂载),工具经
// Dispatcher.Invoke 的 ctx 读到它,从而知道「我是哪一路」——这是文件级占用
// 登记的唯一来源,绝不从模型输出推断。
//
// 空 legID 是空操作:单路时未挂载,读取侧返回空串,与加入本特性之前逐字相同。
func WithLegID(ctx context.Context, legID string) context.Context {
if ctx == nil || legID == "" {
return ctx
}
return context.WithValue(ctx, legIDKey{}, legID)
}
// LegIDFrom 返回 ctx 上挂的并发路标识;未挂载(单路、子代理、未接线)时返回
// 空串。空串表示「本回合不与其他路并行」,此时文件级互斥**无需生效**。
func LegIDFrom(ctx context.Context) string {
if ctx == nil {
return ""
}
s, _ := ctx.Value(legIDKey{}).(string)
return s
}