Files
Cyrene_v2/internal/runtime/tool/legfile.go
T
AskaEth ef3dcb699b
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(gateway,loop,tool): 解耦「工具互斥身份」与「checkpoint 归属」(修普通回合不受保护)
缺口:「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 三包全绿;诚实边界含一个既有竞态(同一路并发登记共享,建议后续小切片)。
2026-10-05 09:53:08 +08:00

105 lines
5.6 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 上。身份由**网关/编排器**在
// **每一条在途路**上挂载(普通回合 / 并行路 / 队首续跑路,取值 = 该路的 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
}