fix(gateway,loop,tool): 解耦「工具互斥身份」与「checkpoint 归属」(修普通回合不受保护)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

缺口:「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 三包全绿;诚实边界含一个既有竞态(同一路并发登记共享,建议后续小切片)。
This commit is contained in:
AskaEth committed 2026-10-05 09:53:08 +08:00
1 parent d64b26d499
commit ef3dcb699b
9 files changed
+673 -53

No files matched your search

+4 -4
View File
@@ -151,8 +151,8 @@ func NewFileClaimer() FileClaimer {
// 返回带文件状态的 ErrFileBusy(直接回灌给模型)。它**绝不等待**:判忙即返回,
// 由模型稍后重试(主人裁决 3 的「不静默、不排队死等」)。
//
// 空 legID 或空目标是空操作:单路 / 子代理 / 后台思考(没有并发路)不登记也不
// 检查,行为与加入本特性之前逐字相同(兼容硬要求)。
// 空 legID 或空目标是空操作:子代理 / 后台思考 / 未接编排器的调用方(ctx 上没有
// 互斥身份)不登记也不检查,行为与加入本特性之前逐字相同(兼容硬要求)。
//
// 同一路重复 Claim 同一目标幂等;一路自己先读后写同一文件不会被自己挡住
// (否则一条路自己的多步工具调用会互相锁死)。
@@ -318,8 +318,8 @@ func safeFileTargets(log *slog.Logger, name string, t sdk.Tool, args any) (out [
// 在执行后 defer 调用它:成功 / 报错 / 超时 / panic 都要释放,否则一个中途报错
// 的路会把文件永久锁住)。
//
// 返回 nil 释放函数表示本次调用不参与互斥:未装占用表、未挂路标识(单路 /
// 子代理 / 未接线,兼容硬要求)、或工具没有声明目标文件。
// 返回 nil 释放函数表示本次调用不参与互斥:未装占用表、ctx 上没有互斥身份
// (子代理 / 后台思考 / 未接编排器,兼容硬要求)、或工具没有声明目标文件。
//
// 被另一条路占住时**不等待、不排队**,直接把带文件状态的 ErrFileBusy 交给模型
// (主人裁决 3)。注意它**不包成 ToolError**:占用判定发生在执行之前,调用根本
+17 -8
View File
@@ -68,15 +68,20 @@ type FileClaimer interface {
Release(ctx context.Context, legID, target string)
}
// legIDKey 是「本回合属于哪条并发路」的 context key。
// legIDKey 是「本回合属于哪条在途路(并发路)」的 context key。
type legIDKey struct{}
// WithLegID 把本回合所属的并发路标识挂到 ctx 上。循环在 Run 顶部按 ctx 上挂的
// 路标识播种一次(单路 / 子代理 / 后台思考为空串 ⇒ 不挂载),工具经
// Dispatcher.Invoke 的 ctx 读到它,从而知道「我是哪一路」——这是文件级占用
// 登记的唯一来源,绝不从模型输出推断。
// WithLegID 把本路(在途回合)的互斥身份挂到 ctx 上。身份由**网关/编排器**在
// **每一条在途路**上挂载(普通回合 / 并行路 / 队首续跑路,取值 = 该路的 turnID),
// 循环的工具执行点(loop/tools.go 的 invokeOne)按 loop.ToolLegIDFrom 读到它并
// 桥接到本键;工具经 Dispatcher.Invoke 的 ctx 读到它,从而知道「我是哪一路」——
// 这是文件级占用登记的唯一来源,绝不从模型输出推断。
//
// 空 legID 是空操作:单路时未挂载,读取侧返回空串,与加入本特性之前逐字相同。
// 它与 checkpoint 归属(loop.WithParallelLeg,只有真并行路才有)是**两条独立
// 通道**:那条只回答「快照该写哪个键」,这条只回答「有没有另一路正在动这个文件」。
//
// 空 legID 是空操作:子代理 / 后台思考 / 未接编排器的调用方未挂载,读取侧返回
// 空串,与加入本特性之前逐字相同。
func WithLegID(ctx context.Context, legID string) context.Context {
if ctx == nil || legID == "" {
return ctx
@@ -84,8 +89,12 @@ func WithLegID(ctx context.Context, legID string) context.Context {
return context.WithValue(ctx, legIDKey{}, legID)
}
// LegIDFrom 返回 ctx 上挂的并发路标识;未挂载(单路、子代理、未接线)时返回
// 空串。空串表示「本回合不与其他路并行」,此时文件级互斥**无需生效**。
// LegIDFrom 返回 ctx 上挂的互斥身份;未挂载(子代理、后台思考、未接编排器的
// 调用方)时返回空串。空串 ⇒ 不登记也不检查,行为逐字不变(兼容硬要求)。
//
// ⚠️ 空串**不**等于「单路」:接上编排器之后,单条普通回合也有自己的身份(它的
// turnID)—— 同文件的两条**不同**在途路正是靠它才互相判忙,而同一路自己的多次
// 调用凭同一个值互相放行(Claim 对同一 legID 幂等)。
func LegIDFrom(ctx context.Context) string {
if ctx == nil {
return ""