package tool import ( "context" "fmt" "log/slog" "path/filepath" "runtime" "strings" "sync" "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" ) // 本文件把 legfile.go 预留的文件级互斥通道**实现并接线**(design/15 §2.5.7、 // 主人 2026-10-05 裁决 3):写类工具执行前先在占用表里登记本次要动的文件,被 // **另一条在途路**占住时立刻把「该文件正在被另一路操作」以面向模型的文案返回 // 并提示稍后重试(不静默、不排队死等);不是同一个文件则照常并行。 // // 「本次要动哪个文件」由**工具自己**实现可选的 FileTargeter 接口声明,而不是 // 由本包从 JSON Schema 标注或从参数正文里猜:同一个工具的文件参数会随调用而变 // (内置 `file` 工具的 `path` 在 action=read 时是读、action=write 时是写;还有 // 工具用 `name`、有的收多个路径),只有工具实现方知道本次调用的语义。静态标注 // 要么表达不了「本次是读还是写」,要么逼工具把语义抄两遍;字符串猜文件名则会 // 把正文里的任意文本当成路径(误挡 + 泄漏面)。声明不出来 ⇒ 该工具不参与互斥, // 行为与加入本特性之前逐字相同。 // // 占用键 = **操作类别 + 规范化路径**(见 fileClaimKey):同一文件的两个**读** // 允许并行(主人「优先考虑性能而不是预算」口径:检索类并行不该被互斥拖住), // 读与写、写与写互斥。类别编码进键、而不是给 FileClaimer 多加一对方法,是为了 // 不破坏 legfile.go 已钉死的两方法接口形状(既有用例零改动)。 // FileOp 是一次工具调用对目标文件的操作类别。粒度只到读/写两类:读不改变文件 // 内容,读与读之间没有数据风险;写会改变内容,与任何路的读写都不能重叠。 type FileOp string const ( // FileOpRead 表示**只读**访问目标文件(可与别的读并行)。 FileOpRead FileOp = "read" // FileOpWrite 表示会**修改**目标文件(与其他路的任何读写都互斥)。 FileOpWrite FileOp = "write" ) // FileTarget 是工具为**本次调用**声明的一个目标文件。Op 为空/未知一律按 // FileOpWrite 处理:占用表的方向是宁可多挡一次(让模型稍后重试), // 也不放过两路并发写。 type FileTarget struct { // Op 是本次调用的操作类别(读 / 写)。 Op FileOp // Path 是本次调用要碰的路径;以工具**自己执行时使用的那个路径**为准 // (沙箱解析后的绝对路径最好)。 Path string } // FileTargeter 由需要参与文件级互斥的工具**可选**实现:返回本次调用会碰到的 // 文件(含读/写类别)。未实现它的工具完全不参与互斥。 // // 约定: // - Path 应是工具执行时真正用的路径;占用表只做一次**词法**规范化(清理相对 // 段、Windows 折叠大小写),它不解析符号链接、不统一短名/UNC/映射盘 —— // 别把沙箱解析责任推给占用表。 // - 只声明真正会碰的文件:声明得越宽并行度越低(例如把每次写都声明成同一个 // 目录名,就等于退化成全局串行)。 // - 实现必须是纯函数(无副作用、不阻塞):它在每次工具调用前被调用一次, // 而且它 panic 会被吞掉并降级为「不参与互斥」(见 safeFileTargets)。 type FileTargeter interface { FileTargets(args any) []FileTarget } // fileClaimKeySep 分隔占用键里的操作类别与路径。用 NUL 是因为它在 Windows 与 // POSIX 的文件名里都非法,真实路径不可能与它撞键(loop 的 toolFingerprint 用 // 同一种分隔符表达同样的「不会撞」拼接)。 const fileClaimKeySep = "\x00" // fileClaimKey 拼出占用键(调用方与占用表共用同一个拼法)。 func fileClaimKey(op FileOp, path string) string { return string(normalizeFileOp(op)) + fileClaimKeySep + path } // normalizeFileOp 把空/未知类别收敛为「写」。 func normalizeFileOp(op FileOp) FileOp { if op == FileOpRead { return FileOpRead } return FileOpWrite } // parseFileClaimKey 把占用键拆回(类别,路径)。没有类别前缀的裸路径(外部 // 实现方直接传路径 —— legfile_test 的 fakeFileClaimer 就是这种口径)按「写」 // 处理,与 normalizeFileOp 同一个保守方向。 func parseFileClaimKey(target string) (FileOp, string) { op, path, ok := strings.Cut(target, fileClaimKeySep) if !ok { return FileOpWrite, target } if op == string(FileOpRead) { return FileOpRead, path } return FileOpWrite, path } // canonicalFilePath 把路径收敛成占用表的键。只做**词法**规范化(Abs + Clean + // Windows 折叠大小写),绝不 touch 文件系统:占用判定在每次工具调用之前发生, // 不能引入 I/O,也不该因为目标文件还不存在就放弃互斥。 // // 代价(诚实登记):符号链接、8.3 短名、UNC 与映射盘写法的**同一物理文件**不会 // 被合并 —— 两个别名会被当成两个文件而放行并行。工具按自己执行时的真实落点 // 声明路径即可避免绝大多数别名(见 FileTargeter 的约定)。 func canonicalFilePath(p string) string { p = strings.TrimSpace(p) if p == "" { return "" } if abs, err := filepath.Abs(p); err == nil { p = abs } p = filepath.Clean(p) if runtime.GOOS == "windows" { // Windows 文件系统大小写不敏感:`A.TXT` 与 `a.txt` 是同一个文件,不折叠 // 就会漏判成两个键,互斥形同虚设。 p = strings.ToLower(p) } return p } // fileClaims 是 FileClaimer 的进程内实现。单二进制、单进程 ⇒ 占用状态无需跨 // 进程共享(项目红线:零跨进程 IPC)。 // // 语义是**读共享 / 写独占**(而不是「只登记写、读不登记」):一路在写时另一路 // 读同一文件会读到撕开的内容(file 工具的读不是原子快照),与两路同时写属同一 // 类数据风险,所以读也登记;但多个读互不阻挡,检索类并行不受影响。 type fileClaims struct { mu sync.Mutex held map[string]*fileHolders } // fileHolders 是某个文件当前的占用者集合(键是路标识):写至多一个,读可多个。 // 两个集合都空时该键会被立刻删除,因此占用表随路收尾清空、绝不随会话长度增长。 type fileHolders struct { writers map[string]struct{} readers map[string]struct{} } // NewFileClaimer 构建一份进程内文件占用表。跨会话共享同一份即可:粒度的口径是 // **文件**(主人裁决 3),不是会话 —— 两个会话同时改同一个文件同样是数据风险。 func NewFileClaimer() FileClaimer { return &fileClaims{held: make(map[string]*fileHolders)} } // Claim 实现 FileClaimer:按占用键的类别做读共享/写独占判定,被别的路占住时 // 返回带文件状态的 ErrFileBusy(直接回灌给模型)。它**绝不等待**:判忙即返回, // 由模型稍后重试(主人裁决 3 的「不静默、不排队死等」)。 // // 空 legID 或空目标是空操作:子代理 / 后台思考 / 未接编排器的调用方(ctx 上没有 // 互斥身份)不登记也不检查,行为与加入本特性之前逐字相同(兼容硬要求)。 // // 同一路重复 Claim 同一目标幂等;一路自己先读后写同一文件不会被自己挡住 // (否则一条路自己的多步工具调用会互相锁死)。 func (c *fileClaims) Claim(ctx context.Context, legID, target string) error { if c == nil || legID == "" || target == "" { return nil } op, path := parseFileClaimKey(target) key := canonicalFilePath(path) if key == "" { return nil } c.mu.Lock() defer c.mu.Unlock() h := c.held[key] if op == FileOpWrite { if _, busy := h.otherWriter(legID); busy { return FileBusyStatusError(key, fileBusyStatus(FileOpWrite, FileOpWrite)) } if _, busy := h.otherReader(legID); busy { return FileBusyStatusError(key, fileBusyStatus(FileOpWrite, FileOpRead)) } } else if _, busy := h.otherWriter(legID); busy { return FileBusyStatusError(key, fileBusyStatus(FileOpRead, FileOpWrite)) } if h == nil { h = newFileHolders() c.held[key] = h } if op == FileOpWrite { h.writers[legID] = struct{}{} } else { h.readers[legID] = struct{}{} } return nil } // Release 实现 FileClaimer:无条件摘掉该路在该目标上的占用(读、写两侧都摘), // 最后一个占用者离开时删键。legID 不是占用者时是空操作。 // // 它必须挂在工具执行的生命周期上(defer):报错 / 超时 / panic 的路也绝不把 // 文件永久锁住。 func (c *fileClaims) Release(ctx context.Context, legID, target string) { if c == nil || legID == "" || target == "" { return } _, path := parseFileClaimKey(target) key := canonicalFilePath(path) if key == "" { return } c.mu.Lock() defer c.mu.Unlock() h := c.held[key] if h == nil { return } delete(h.writers, legID) delete(h.readers, legID) if len(h.writers) == 0 && len(h.readers) == 0 { delete(c.held, key) } } // fileBusyStatus 生成回灌给模型的**文件状态**短句:说清此刻是谁在动它, // 模型才知道该等什么、为什么现在不能动(而不是只看到「忙」)。 // 只含操作类别,不含另一路的任何身份或内容。 func fileBusyStatus(want, holder FileOp) string { verb := "写入" if holder == FileOpRead { verb = "读取" } return fmt.Sprintf("该文件正在被另一路%s,本次%s无法进行", verb, opLabel(want)) } // opLabel 把操作类别渲染成中文,供面向模型的文案使用。 func opLabel(op FileOp) string { if op == FileOpRead { return "读取" } return "写入" } // newFileHolders 建一个空占用者集合(两个集合都必须非 nil,Claim 会直接写入)。 func newFileHolders() *fileHolders { return &fileHolders{writers: make(map[string]struct{}), readers: make(map[string]struct{})} } // otherWriter 报告除 exclude 外是否还有路正在写该文件,并返回其中一条(供文案 // 与调试使用)。nil 接收者表示「该文件当前无人占用」⇒ (空串, false),使 Claim // 不必先建表再判定。 // // 判定用**布尔**而不是「返回空串表示没有」:路标识本身可以是空串(外部调用方 // 的口径),用空串当「没有」的哨兵会把它们误判成无人占用。 func (h *fileHolders) otherWriter(exclude string) (string, bool) { if h == nil { return "", false } for leg := range h.writers { if leg != exclude { return leg, true } } return "", false } // otherReader 报告除 exclude 外是否还有路正在读该文件,并返回其中一条。 func (h *fileHolders) otherReader(exclude string) (string, bool) { if h == nil { return "", false } for leg := range h.readers { if leg != exclude { return leg, true } } return "", false } // fileTargetsOf 取工具为本次调用声明的目标文件。工具未实现 FileTargeter、 // 声明为空、或某项路径为空时跳过 —— 声明不出来就不参与互斥(行为不变)。 // 同一次调用里重复的目标只登记一次,避免同一把锁被重复占有/重复释放。 func fileTargetsOf(t sdk.Tool, args any) []FileTarget { targeter, ok := t.(FileTargeter) if !ok { return nil } targets := targeter.FileTargets(args) if len(targets) == 0 { return nil } out := make([]FileTarget, 0, len(targets)) seen := make(map[string]struct{}, len(targets)) for _, tg := range targets { if strings.TrimSpace(tg.Path) == "" { continue } key := fileClaimKey(tg.Op, tg.Path) if _, dup := seen[key]; dup { continue } seen[key] = struct{}{} out = append(out, tg) } return out } // safeFileTargets 是 fileTargetsOf 的抗 panic 包装:声明是**可选**扩展点, // 一个第三方工具在它里面 panic 不该连带炸掉调用(callTool 只在 Execute 里 // recover)。降级方向是「不参与互斥」,而不是「调用失败」。 func safeFileTargets(log *slog.Logger, name string, t sdk.Tool, args any) (out []FileTarget) { defer func() { if r := recover(); r != nil { out = nil log.Warn("file target declaration panicked; skipping file claims", "tool", name, "panic", fmt.Sprint(r)) } }() return fileTargetsOf(t, args) } // claimFiles 为一次工具调用登记它声明的目标文件,并返回释放函数(调用方必须 // 在执行后 defer 调用它:成功 / 报错 / 超时 / panic 都要释放,否则一个中途报错 // 的路会把文件永久锁住)。 // // 返回 nil 释放函数表示本次调用不参与互斥:未装占用表、ctx 上没有互斥身份 // (子代理 / 后台思考 / 未接编排器,兼容硬要求)、或工具没有声明目标文件。 // // 被另一条路占住时**不等待、不排队**,直接把带文件状态的 ErrFileBusy 交给模型 // (主人裁决 3)。注意它**不包成 ToolError**:占用判定发生在执行之前,调用根本 // 没跑,而且 ToolError 的渲染(message + cause)会把同一句话打印两遍 —— 回灌给 // 模型的结果要一句话说清「谁在被谁占、稍后再试」(loop 的 errorKind 把它归为 // execution,与执行面失败的分类一致)。 // // 多目标里任意一个失败时,本次调用**已登记的全部目标立刻回滚**,绝不留下半登记 // 状态。 func (d *Dispatcher) claimFiles(ctx context.Context, name string, t sdk.Tool, args map[string]any) (func(), error) { claimer := d.claimerRef() if claimer == nil { return nil, nil } legID := LegIDFrom(ctx) if legID == "" { return nil, nil } targets := safeFileTargets(d.log, name, t, args) if len(targets) == 0 { return nil, nil } keys := make([]string, 0, len(targets)) for _, tg := range targets { key := fileClaimKey(tg.Op, tg.Path) // 先记账再登记:失败的 Claim 也要进回滚清单(对第三方实现而言, // 「失败即未登记」不是接口保证;多释放一次按契约是空操作)。 keys = append(keys, key) if err := claimer.Claim(ctx, legID, key); err != nil { releaseFileClaims(ctx, claimer, legID, keys) d.log.Info("file claim busy; asking the model to retry later", "tool", name, "leg", legID, "target", key) return nil, err } } return func() { releaseFileClaims(ctx, claimer, legID, keys) }, nil } // releaseFileClaims 释放一批占用(正常收尾与失败回滚共用一条路径)。 func releaseFileClaims(ctx context.Context, claimer FileClaimer, legID string, keys []string) { for _, key := range keys { claimer.Release(ctx, legID, key) } } // claimerRef 返回已装配的占用表;未装配时返回 nil(⇒ 完全不做互斥)。 // 占用表只在 New 的选项里装配一次,此后不再变更。 func (d *Dispatcher) claimerRef() FileClaimer { if d == nil { return nil } d.mu.RLock() defer d.mu.RUnlock() return d.claimer }