缺口:「1 普通回合 + 1 并行路」时普通回合既不登记也不被检查 —— 因为互斥身份取自 loop.ParallelLegFrom,而网关只给 parallel=true 那一路盖它。直接给普通回合盖 WithParallelLeg 会把它的 checkpoint 键从 sess 变成 sess#turn-x(改崩溃恢复语义),故改为两条独立通道: - 工具互斥身份:loop.WithToolLegID / ToolLegIDFrom(新增),网关在 beginLegLocked 内【无条件】盖, 取值 = 本路 turnID ⇒ 普通回合 / 队首续跑路 / 并行路 三条路都覆盖 - checkpoint 归属:WithParallelLeg 一行未改,仍只有真并行路 ⇒ 恢复语义零变化 守门断言 TestCheckpointOwnerIgnoresToolLegID:普通回合 ctx 下 AgentID 必须逐字仍是 sess。 变异 3 条:互斥身份回退只认 ParallelLegFrom(判忙失效)/ 顺手把 checkpoint 改读新通道 (守门断言红,证明确实解耦)/ 普通回合不盖身份(判忙失效)。 另修 race:-race -count=2 三包全绿;诚实边界含一个既有竞态(同一路并发登记共享,建议后续小切片)。
105 lines
5.6 KiB
Go
105 lines
5.6 KiB
Go
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
|
||
}
|