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 三包全绿;诚实边界含一个既有竞态(同一路并发登记共享,建议后续小切片)。
This commit is contained in:
1 parent
d64b26d499
commit
ef3dcb699b
9 files changed
+673
-53
No files matched your search
@@ -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**:占用判定发生在执行之前,调用根本
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
Reference in new issue
Block a user