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 上。身份由**网关/编排器**在 // **每一条在途路**上挂载(普通回合 / 并行路 / 队首续跑路,取值 = 该路的 turnID), // 循环的工具执行点(loop/tools.go 的 invokeOne)按 loop.ToolLegIDFrom 读到它并 // 桥接到本键;工具经 Dispatcher.Invoke 的 ctx 读到它,从而知道「我是哪一路」—— // 这是文件级占用登记的唯一来源,绝不从模型输出推断。 // // 它与 checkpoint 归属(loop.WithParallelLeg,只有真并行路才有)是**两条独立 // 通道**:那条只回答「快照该写哪个键」,这条只回答「有没有另一路正在动这个文件」。 // // 空 legID 是空操作:子代理 / 后台思考 / 未接编排器的调用方未挂载,读取侧返回 // 空串,与加入本特性之前逐字相同。 func WithLegID(ctx context.Context, legID string) context.Context { if ctx == nil || legID == "" { return ctx } return context.WithValue(ctx, legIDKey{}, legID) } // LegIDFrom 返回 ctx 上挂的互斥身份;未挂载(子代理、后台思考、未接编排器的 // 调用方)时返回空串。空串 ⇒ 不登记也不检查,行为逐字不变(兼容硬要求)。 // // ⚠️ 空串**不**等于「单路」:接上编排器之后,单条普通回合也有自己的身份(它的 // turnID)—— 同文件的两条**不同**在途路正是靠它才互相判忙,而同一路自己的多次 // 调用凭同一个值互相放行(Claim 对同一 legID 幂等)。 func LegIDFrom(ctx context.Context) string { if ctx == nil { return "" } s, _ := ctx.Value(legIDKey{}).(string) return s }