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

+129 -29
View File
@@ -13,20 +13,26 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
)
// 本文件是「路标识 → 工具层」这座桥的回归(design/15 §2.5.7、主人 2026-10-05
// 裁决 3):网关按 loop.WithParallelLeg 把并发路标识挂在 ctx 上,工具执行点
// (tools.go 的 invokeOne)把它转成 tool.WithLegID,于是同一文件的第二条路拿到
// 本文件是「互斥身份 → 工具层」这座桥的回归(design/15 §2.5.7、主人 2026-10-05
// 裁决 3):**每一条在途路**(普通回合 / 并行路 / 队首续跑路)都带互斥身份 ——
// 网关按 loop.WithToolLegID 把它挂在 ctx 上(取值 = 该路的 turnID),工具执行点
// (tools.go 的 invokeOne)把它桥接成 tool.WithLegID,于是同一文件的第二条路拿到
// tool.ErrFileBusy,并以**回灌给模型的工具结果文本**形式浮出来(显式、可消化)。
//
// 单路口径同样钉住:ctx 上没有路标识(普通回合 / 子代理 / 后台思考)时,同文件
// 的两条调用照跑 —— 完全不做互斥,行为逐字不变。
// 它与 checkpoint 归属(loop.WithParallelLeg)是**两条独立通道**,因此这里刻意
// 钉住两个方向:① 只有并发路标识(没有互斥身份)的 ctx **不**带来互斥;② 只有
// 互斥身份的 ctx(普通回合的实际形状)**照样**带来互斥 —— 后者正是「1 普通回合
// + 1 并行路」同文件写的修复目标。
//
// 单路口径同样钉住:ctx 上没有互斥身份(子代理 / 后台思考 / 未接编排器)时,同一
// 文件的两条调用照跑 —— 完全不做互斥,行为逐字不变。
// legFileProbeTimeout 是「第二路必须立刻拿到判忙结果」的上界(只用来把阻塞式
// 实现暴露成失败,不参与竞态)。
const legFileProbeTimeout = 5 * time.Second
// legFileTool 是实现了 tool.FileTargeter 的 mock 工具:声明本次要写的文件,
// 可阻塞执行,并记录它在 Execute 里读到的路标识(用来直接钉住桥的存在)。
// 可阻塞执行,并记录它在 Execute 里读到的互斥身份(用来直接钉住桥的存在)。
type legFileTool struct {
name string
file string
@@ -89,30 +95,40 @@ func awaitEntered(t *testing.T, ch <-chan struct{}, msg string) {
}
}
// TestInvokeOne_LegIDReachesTool 直接钉住桥:ctx 上挂了并发路标识 ⇒ 工具在
// Execute 里读到同一个值;没挂(单路)⇒ 读到空串(⇒ 完全不做互斥)。
func TestInvokeOne_LegIDReachesTool(t *testing.T) {
// TestInvokeOne_ToolLegIDReachesTool 直接钉住桥:ctx 上挂了**互斥身份**
// ⇒ 工具在 Execute 里读到同一个值;没挂(子代理 / 后台思考 / 未接编排器)
// ⇒ 读到空串(⇒ 完全不做互斥)。它同时钉住**被解耦的那个方向**:只挂 checkpoint
// 归属键(loop.WithParallelLeg)**不**构成互斥身份 —— 两条通道各管各的。
func TestInvokeOne_ToolLegIDReachesTool(t *testing.T) {
t.Parallel()
holder := &legFileTool{name: "patch", file: filepath.Join(t.TempDir(), "notes.txt")}
d := dispatcherWith(t, holder)
l := New(Options{Tools: d})
call := agent.ToolCall{Name: "patch", Args: map[string]any{"path": "notes.txt"}}
if out := l.invokeOne(WithToolLegID(context.Background(), "turn-x"), d, call); out.Err != nil {
t.Fatalf("带互斥身份的调用: %v", out.Err)
}
if out := l.invokeOne(WithParallelLeg(context.Background(), "turn-x"), d, call); out.Err != nil {
t.Fatalf("并行路调用: %v", out.Err)
t.Fatalf("只挂 checkpoint 归属键的调用: %v", out.Err)
}
if out := l.invokeOne(context.Background(), d, call); out.Err != nil {
t.Fatalf("单路调用: %v", out.Err)
t.Fatalf("无身份的调用: %v", out.Err)
}
if legs := holder.seenLegs(); len(legs) != 2 || legs[0] != "turn-x" || legs[1] != "" {
t.Fatalf("工具读到的路标识 = %v, want [turn-x ](空串 = 单路不参与互斥)", legs)
if legs := holder.seenLegs(); len(legs) != 3 || legs[0] != "turn-x" || legs[1] != "" || legs[2] != "" {
t.Fatalf("工具读到的互斥身份 = %v, want [turn-x ](空串 = 不参与互斥;checkpoint 通道不构成身份)", legs)
}
}
// TestInvokeOne_ParallelLegSameFileBusySurfacesToModel 钉住端到端的核心行为:
// 两路(不同路标识)同文件 ⇒ 第二路**不执行**、**不等待**,拿到 tool.ErrFileBusy,
// 且回灌给模型的文本里带中文文件状态(模型据此自己决定稍后重试)。
func TestInvokeOne_ParallelLegSameFileBusySurfacesToModel(t *testing.T) {
// TestInvokeOne_NormalTurnVsParallelLegSameFileBusy 钉住**本次修复的目标**:
// 「1 普通回合 + 1 并行路」的会话里,持锁的是那个**普通回合** —— 它的 ctx 上只有
// 互斥身份(普通回合不挂 checkpoint 归属键),照样必须登记并对同文件的并行路判忙:
// 第二条路**不执行**、**不等待**,拿到 tool.ErrFileBusy,且回灌给模型的文本里带中文
// 文件状态(模型据此自己决定稍后重试)。
//
// 两条 ctx 刻意按**生产形状**构造:普通回合 = 只有 WithToolLegID;并行路 = 两条
// 通道都有(WithToolLegID + WithParallelLeg,取值同为该路 turnID)。
func TestInvokeOne_NormalTurnVsParallelLegSameFileBusy(t *testing.T) {
t.Parallel()
file := filepath.Join(t.TempDir(), "notes.txt")
holder := &legFileTool{name: "patch-a", file: file, entered: make(chan struct{}), hold: make(chan struct{})}
@@ -123,25 +139,28 @@ func TestInvokeOne_ParallelLegSameFileBusySurfacesToModel(t *testing.T) {
callA := agent.ToolCall{Name: "patch-a", Args: map[string]any{"path": file}}
callB := agent.ToolCall{Name: "patch-b", Args: map[string]any{"path": file}}
normalCtx := WithToolLegID(context.Background(), "turn-normal")
parallelCtx := WithParallelLeg(WithToolLegID(context.Background(), "turn-par"), "turn-par")
first := make(chan ToolOutcome, 1)
go func() {
first <- l.invokeOne(WithParallelLeg(context.Background(), "turn-a"), d, callA)
first <- l.invokeOne(normalCtx, d, callA)
}()
awaitEntered(t, holder.entered, "第一路没有进入工具执行")
awaitEntered(t, holder.entered, "普通回合没有进入工具执行")
done := make(chan ToolOutcome, 1)
go func() {
done <- l.invokeOne(WithParallelLeg(context.Background(), "turn-b"), d, callB)
done <- l.invokeOne(parallelCtx, d, callB)
}()
var outB ToolOutcome
select {
case outB = <-done:
case <-time.After(legFileProbeTimeout):
t.Fatal("第二路阻塞等待了 —— 口径是不排队死等,判忙即返回")
t.Fatal("并行路阻塞等待了 —— 口径是不排队死等,判忙即返回")
}
if !errors.Is(outB.Err, tool.ErrFileBusy) {
t.Fatalf("第二路错误 = %v, want tool.ErrFileBusy", outB.Err)
t.Fatalf("普通回合持有同一文件时,并行路必须判忙,得到 %v(nil 说明普通回合压根没登记)", outB.Err)
}
if outB.ErrKind != tool.KindExecution {
t.Fatalf("判忙必须归类为执行面失败才能被回灌,得到 %q", outB.ErrKind)
@@ -152,20 +171,67 @@ func TestInvokeOne_ParallelLegSameFileBusySurfacesToModel(t *testing.T) {
t.Fatalf("回灌给模型的文本缺少 %q: %s", want, text)
}
}
// 被判忙的调用绝不执行:只有普通回合那一次进了工具。
if legs := peer.seenLegs(); len(legs) != 0 {
t.Fatalf("被判忙的并行路绝不该执行工具,实际执行了 %d 次(身份 %v)", len(legs), legs)
}
close(holder.hold)
select {
case outA := <-first:
if outA.Err != nil {
t.Fatalf("持锁那一路应正常完成: %v", outA.Err)
t.Fatalf("持锁的普通回合应正常完成: %v", outA.Err)
}
case <-time.After(legFileProbeTimeout):
t.Fatal("第一路没有结束")
t.Fatal("普通回合没有结束")
}
}
// TestInvokeOne_SingleLegSameFileRunsConcurrently 钉住兼容硬要求③:**无路标识
// ⇒ 完全不做互斥**(单路 / 既有路径逐字不变)—— 同文件的两条调用同时执行都成功。
// TestInvokeOne_DifferentFilesRunParallel 钉住「不是同一个文件 ⇒ 允许并行操作」
// 在**两条在途路**(普通回合 + 并行路)下同样成立:互斥按文件粒度,绝不能退化成
// 会话级串行。
func TestInvokeOne_DifferentFilesRunParallel(t *testing.T) {
t.Parallel()
dir := t.TempDir()
holder := &legFileTool{name: "patch-a", file: filepath.Join(dir, "a.txt"), entered: make(chan struct{}), hold: make(chan struct{})}
peer := &legFileTool{name: "patch-b", file: filepath.Join(dir, "b.txt")}
d := dispatcherWith(t, holder, peer)
l := New(Options{Tools: d})
first := make(chan ToolOutcome, 1)
go func() {
first <- l.invokeOne(WithToolLegID(context.Background(), "turn-normal"), d,
agent.ToolCall{Name: "patch-a", Args: map[string]any{"path": "a.txt"}})
}()
awaitEntered(t, holder.entered, "普通回合没有进入工具执行")
out := l.invokeOne(WithParallelLeg(WithToolLegID(context.Background(), "turn-par"), "turn-par"), d,
agent.ToolCall{Name: "patch-b", Args: map[string]any{"path": "b.txt"}})
if out.Err != nil {
t.Fatalf("不同文件必须允许并行操作,得到 %v", out.Err)
}
if legs := peer.seenLegs(); len(legs) != 1 || legs[0] != "turn-par" {
t.Fatalf("不同文件的调用必须真的执行,且带着自己的身份,得到 %v", legs)
}
close(holder.hold)
select {
case outA := <-first:
if outA.Err != nil {
t.Fatalf("持锁的普通回合应正常完成: %v", outA.Err)
}
case <-time.After(legFileProbeTimeout):
t.Fatal("普通回合没有结束")
}
}
// TestInvokeOne_SingleLegSameFileRunsConcurrently 钉住兼容硬要求③:**无互斥身份
// ⇒ 完全不做互斥**(ctx 上没有 WithToolLegID 的调用方逐字不变)—— 同文件的两条
// 调用同时执行都成功。
//
// 「单路」这件事本身不再等于「无身份」:接上编排器之后,单条普通回合也有自己的
// 身份(见 TestInvokeOne_NormalTurnVsParallelLegSameFileBusy),因此它**受保护**;
// 这里钉的是**没有身份**的那一类调用方(子代理 / 后台思考 / 未接编排器)。
func TestInvokeOne_SingleLegSameFileRunsConcurrently(t *testing.T) {
t.Parallel()
file := filepath.Join(t.TempDir(), "notes.txt")
@@ -179,13 +245,47 @@ func TestInvokeOne_SingleLegSameFileRunsConcurrently(t *testing.T) {
go func() {
first <- l.invokeOne(ctx, d, agent.ToolCall{Name: "patch-a", Args: map[string]any{"path": file}})
}()
awaitEntered(t, blocker.entered, "无路标识的调用没有进入工具执行")
awaitEntered(t, blocker.entered, "无互斥身份的调用没有进入工具执行")
if out := l.invokeOne(ctx, d, agent.ToolCall{Name: "patch-b", Args: map[string]any{"path": file}}); out.Err != nil {
t.Fatalf("无路标识时完全不做互斥(同文件也应放行),得到 %v", out.Err)
t.Fatalf("无互斥身份时完全不做互斥(同文件也应放行),得到 %v", out.Err)
}
close(blocker.hold)
if out := <-first; out.Err != nil {
t.Fatalf("无路标识的调用应正常完成: %v", out.Err)
t.Fatalf("无互斥身份的调用应正常完成: %v", out.Err)
}
}
// TestInvokeOne_ParallelLegOnlyOwnsCheckpointNotMutex 钉住解耦的另一个方向:只挂
// **checkpoint 归属键**(WithParallelLeg,没有互斥身份)的 ctx 完全不参与文件级
// 互斥 —— 两条这样的调用同文件同时执行都成功。它挡住「把两个用途又焊回去」的
// 反向回归:checkpoint 归属键绝不是互斥身份。
func TestInvokeOne_ParallelLegOnlyOwnsCheckpointNotMutex(t *testing.T) {
t.Parallel()
file := filepath.Join(t.TempDir(), "notes.txt")
blocker := &legFileTool{name: "patch-a", file: file, entered: make(chan struct{}), hold: make(chan struct{})}
peer := &legFileTool{name: "patch-b", file: file}
d := dispatcherWith(t, blocker, peer)
l := New(Options{Tools: d})
first := make(chan ToolOutcome, 1)
go func() {
first <- l.invokeOne(WithParallelLeg(context.Background(), "turn-a"), d,
agent.ToolCall{Name: "patch-a", Args: map[string]any{"path": file}})
}()
awaitEntered(t, blocker.entered, "只挂归属键的调用没有进入工具执行")
out := l.invokeOne(WithParallelLeg(context.Background(), "turn-b"), d,
agent.ToolCall{Name: "patch-b", Args: map[string]any{"path": file}})
if out.Err != nil {
t.Fatalf("只有归属键(无互斥身份)时必须放行,得到 %v", out.Err)
}
if legs := peer.seenLegs(); len(legs) != 1 || legs[0] != "" {
t.Fatalf("只挂归属键的调用不该带互斥身份,得到 %v", legs)
}
close(blocker.hold)
if out := <-first; out.Err != nil {
t.Fatalf("只挂归属键的调用应正常完成: %v", out.Err)
}
}
+41 -1
View File
@@ -14,7 +14,9 @@ import (
// 真并行时两路各写各的归属键,LoadLatest 因此天然按路隔离 —— 否则两路合写同一个
// key,后写的那份会把前一路的窗口整段顶掉(LangGraph `Send` 扇出的同族教训)。
//
// 单路口径是**逐字不变**的:ctx 上不挂路标识时归属键仍等于 sessionID。
// 单路口径是**逐字不变**的:ctx 上不挂路标识时归属键仍等于 sessionID —— 包括那种
// 「挂了工具互斥身份、但没挂并发路标识」的 ctx(普通回合的实际形状,见
// TestCheckpointOwnerIgnoresToolLegID:两条通道必须保持解耦)。
// keyedCheckpointStore 是按 AgentID 分桶的内存 checkpoint 存储,用来证明归属键
// 真的按路隔离(判据是**键**,而不是「循环怎么调用 store」)。
@@ -60,6 +62,44 @@ func TestCheckpointOwnerSingleLegUnchanged(t *testing.T) {
}
}
// TestCheckpointOwnerIgnoresToolLegID 是**守门断言**(design/15 §2.5.7 与 §2.5.9
// 解耦的守卫):checkpoint 归属键只认 WithParallelLeg,**绝不**认工具互斥身份
// (WithToolLegID)。
//
// 它的存在是为了让「两个用途又被焊回去」变成一条会红的用例:网关给**每一条**在途路
// 都盖互斥身份(普通回合也有),一旦 checkpointAgentID 改读那条通道,单条普通回合的
// 归属键就会从 `sess` 升成 `sess#turn-1` —— 崩溃恢复语义随之改变(旧快照读不到、
// 凭空多出按路隔离)。
func TestCheckpointOwnerIgnoresToolLegID(t *testing.T) {
t.Parallel()
store := newKeyedCheckpointStore()
l := New(Options{Checkpoint: store})
// 普通回合的实际形状:有互斥身份(本路 turnID),**没有**并发路标识。
ctx := WithToolLegID(context.Background(), "turn-1")
window := []model.Message{model.TextMessage(model.RoleUser, "hi")}
l.saveCheckpoint(ctx, "sess", 3, window, agent.StopReasonToolCalls)
if len(store.saved) != 1 {
t.Fatalf("saved = %d, want 1", len(store.saved))
}
if got := store.saved[0].AgentID; got != "sess" {
t.Fatalf("互斥身份绝不能改变归属键:AgentID = %q, want %q(逐字不变)", got, "sess")
}
if got := store.saved[0].ID; got != "sess:3" {
t.Fatalf("互斥身份绝不能改变归属键:checkpoint id = %q, want %q", got, "sess:3")
}
// 续接侧同样只看归属键:LoadLatest 请求的键仍是 sessionID,快照照样能接上。
reader := &recordingLoadStore{inner: store}
l.checkpoint = reader
if _, step, resumed := l.loadCheckpoint(ctx, "sess", window); !resumed || step != 4 {
t.Fatalf("普通回合 resumed=%v step=%d, want true/4(有互斥身份也必须照旧续接)", resumed, step)
}
if len(reader.calls) != 1 || reader.calls[0] != "sess" {
t.Fatalf("LoadLatest 键 = %v, want [sess]", reader.calls)
}
}
// TestCheckpointOwnerIsolatedPerLeg 钉住归属键按路隔离:同一会话的两条路各写各的
// 键,且各自 LoadLatest 只命中自己的那份 —— 任何一路都绝不会把对方的崩溃快照
// 当成自己的续接。
+47 -2
View File
@@ -57,8 +57,11 @@ func loopTurn(ctx context.Context) string {
}
// parallelLegKey 是「本 Run 驱动的是某条并发路(leg)」的 context key。
// 它只影响 checkpoint 归属(design/15 §2.5.9),不参与身份判定,也不改变
// 循环的任何行为。
// 它**只**影响 checkpoint 归属(design/15 §2.5.9),不参与身份判定,也不改变
// 循环的任何行为。工具层的文件级互斥走**另一条独立通道**(toolLegIDKey,见
// 本文件下半部分)—— 两条通道必须分开:这条只有真并行路才有,而互斥身份
// 每条在途路都有。焊在一起会把普通回合的 checkpoint 键从 `sess` 改成
// `sess#turn-x`,连带改变崩溃恢复语义(checkpoint.go 的 checkpointAgentID)。
type parallelLegKey struct{}
// WithParallelLeg 把本回合所属**并发路**的标识挂到 ctx 上(design/15 §2.5.9):
@@ -77,6 +80,9 @@ func WithParallelLeg(ctx context.Context, legID string) context.Context {
// ParallelLegFrom 返回 ctx 上挂的并发路标识;未挂载时返回 ""。它是
// WithParallelLeg 的读侧,供 checkpoint 归属键与网关侧断言使用。
//
// ⚠️ 它**只**服务 checkpoint 归属,绝不是工具互斥身份 —— 工具侧要读
// ToolLegIDFrom(那条通道每条在途路都有)。
func ParallelLegFrom(ctx context.Context) string {
if ctx == nil {
return ""
@@ -84,3 +90,42 @@ func ParallelLegFrom(ctx context.Context) string {
s, _ := ctx.Value(parallelLegKey{}).(string)
return s
}
// toolLegIDKey 是「本 Run 属于哪一条**在途路**」的 context key,**只**服务
// 工具层的文件级互斥(design/15 §2.5.7、主人 2026-10-05 裁决 3)。
//
// 它与 parallelLegKey 刻意分成两条通道,因为二者的**受众与取舍完全不同**:
// - 这条:每条在途路都挂(普通回合 / 并行路 / 队首续跑路),回答「此刻有没有
// **另一条路**正在动这个文件」;它不能改变 checkpoint 归属,否则「1 普通回合
// + 1 并行路」的会话里普通回合的快照键会被升成 `sess#turn-x`,崩溃恢复语义
// 随之改变(硬要求:恢复语义零变化);
// - 那条:只有真并行路才有,回答「这条路的 checkpoint 该写哪个键」。
//
// 把两者焊成一个(复用 ParallelLegFrom)是曾经的真实缺口:普通回合既不登记也不
// 被检查,它与并行路同文件写仍可能并发。
type toolLegIDKey struct{}
// WithToolLegID 把本路的互斥身份挂到 ctx 上(design/15 §2.5.7)。身份由**网关/
// 编排器**在**每一条在途路**上挂载,取值就是该路的 turnID(编排器生成、唯一,
// 普通回合也有)。它只回答「这条工具调用属于哪条在途路」,与 actor 元数据 /
// 策略引擎无关(项目红线:身份识别绝不走这条通道),也不影响 checkpoint 归属。
//
// 空 legID 是空操作:子代理、后台思考、未接编排器的调用方不挂该键 ⇒ 工具层读到
// 空串 ⇒ 完全不做互斥,行为与加入本特性之前逐字相同(兼容硬要求)。
func WithToolLegID(ctx context.Context, legID string) context.Context {
if ctx == nil || legID == "" {
return ctx
}
return context.WithValue(ctx, toolLegIDKey{}, legID)
}
// ToolLegIDFrom 返回 ctx 上挂的互斥身份;未挂载时返回 ""(⇒ 不登记也不检查)。
// 它是 WithToolLegID 的读侧,只被循环的工具执行点读一次(tools.go 的 invokeOne
// 把它桥接成 tool.WithLegID —— 工具侧凭 tool.LegIDFrom 取到)。
func ToolLegIDFrom(ctx context.Context) string {
if ctx == nil {
return ""
}
s, _ := ctx.Value(toolLegIDKey{}).(string)
return s
}
+10 -4
View File
@@ -299,10 +299,16 @@ func (l *Loop) invokeOne(ctx context.Context, d *tool.Dispatcher, tc agent.ToolC
}
_, toolSpan := l.span(ctx, "tool."+tc.Name, trace.SpanKindTool)
l.emitToolExecution(ctx, tc, model.StreamKindToolExecutionStart, model.ToolPhaseRunning)
// 路标识桥(design/15 §2.5.7、主人裁决 3):网关把「本回合属于哪条并发路」经
// loop.WithParallelLeg 挂在 ctx 上,工具层按 tool.WithLegID 读它做文件级互斥
// (工具侧唯一来源);未挂载 ⇒ 空串 ⇒ 完全不做互斥,行为逐字不变。
result, err := d.Invoke(tool.WithLegID(ctx, ParallelLegFrom(ctx)), tc.Name, tc.Args)
// 路标识桥(design/15 §2.5.7、主人裁决 3):网关在**每一条在途路**上把互斥
// 身份经 loop.WithToolLegID 挂在 ctx 上(普通回合 / 并行路 / 队首续跑路都挂,
// 取值 = 该路的 turnID),这里把它桥接成工具层的 tool.WithLegID —— 工具侧
// 凭 tool.LegIDFrom 读到「我是哪一路」做文件级互斥;未挂载 ⇒ 空串 ⇒ 完全
// 不做互斥,行为逐字不变(兼容硬要求)。
//
// 刻意**不**读 ParallelLegFrom:那条通道只有真并行路才有、且只决定 checkpoint
// 归属(checkpoint.go 的 checkpointAgentID),拿它当互斥身份会让普通回合在
// 「1 普通回合 + 1 并行路」时完全不受保护。
result, err := d.Invoke(tool.WithLegID(ctx, ToolLegIDFrom(ctx)), tc.Name, tc.Args)
phase := model.ToolPhaseDone
if err != nil {
phase = model.ToolPhaseError