# ADR-0019: per-turn messageID 从共享工具字段改 context 透传 -- 消并发 Run 预存 data race (范式级, 非加锁) - **Status**: Accepted (PM 拍板 2026-06-17, "直接修") - **Date**: 2026-06-17 - **Deciders**: PM (产品经理) + Flyto Agent core team - **Related code**: `core/pkg/engine/engine.go` (runLoop), `core/pkg/tools/tool.go` (`WithMessageID` / `MessageIDFromContext` / `MessageIDAware`), `core/pkg/tools/builtin/fileedit.go` (`resolveTurnMessageID`), `core/pkg/tools/builtin/filewrite.go`, `core/pkg/engine/concurrent_run_messageid_race_test.go` - **Related ADRs**: ADR-0003 (SessionStore 平台化 -- 单 engine 并发 Run 正是平台化后暴露的并发面), ADR-0005 (旧 `SetMessageID` 对位的 `SocketAware` iterate+type-assert 接线 / 引擎中性化精神连续) - **Commit chain**: `b96919b` (fix: ctx 透传 + race 判别测试) + `841dbc8` (并发多轮 Run race 佐证, test-only) - **Tag**: `core/v0.5.0-alpha.28` (本 fix 供消费者更新) --- ## 1. 背景 / Context ### 1.1 触发 -- 平台单 engine 并发 Run 成为常态 ADR-0003 SessionStore 平台化 + 计划队列 (跨步上下文串联 `908d4f7` + 并行 ready steps `1b6763c`, tag `core/v0.5.0-alpha.27`) 落地后, 一个 `Engine` 实例同时服务多个 Run 从边角情况变成常态: - 平台单 engine 同时服务 `/agent/run` REST 流 + 计划队列消费. - 并行 plan worker 在同一 engine 上 fork 多个 ready step. 这把一个此前 "一个 engine 同一时刻只跑一个 Run" 假设下无害的共享写, 暴露成真 data race. ### 1.2 race 的精确机制 主 `runLoop` 要给 file-history 快照按 "当前轮次" 打键 -- `Engine.Rollback("turn-N")` 要凭这个键既找到文件快照又找到 `OperationLog` 条目 (二者必须同键). 旧打键方式: ``` 每轮: for t := range e.tools.All() { if ma, ok := t.(MessageIDAware); ok { ma.SetMessageID(turnKey) } } 工具 Execute: t.fileHistory.BeforeEdit(path, t.messageID) // 读同一共享字段 ``` `FileEdit` / `FileWrite` 是**共享工具实例**, `SetMessageID` 写 `t.messageID`, `Execute` 读 `t.messageID`. 一个 engine 跑并发 Run 时: 1. **data race**: Run-A 第 i 轮的 `SetMessageID` WRITE 与 Run-B 的 `Execute` READ 重叠在同一字段. 2. **逻辑错 (即使不 race)**: 并发 Run 互相 clobber turn id -- Run-A 把字段设成 `turn-3`, Run-B 的 `Execute` 读到 `turn-3` 给自己的快照打了错键, `Rollback` 找不回. 第 2 点是关键: 这不是 "并发访问需要同步" 的普通 race, 是**共享单槽存 per-Run-per-turn 值**的设计错位 -- 加锁治不了 (见 §3 A). ### 1.3 判别确认 (见红才算数) 新增 `TestConcurrentRun_FileWrites_NoMessageIDRace` (N=8 并发 `Engine.Run` 同一 engine 各经主 runLoop 写不同文件). stash 修复跑 PRE-fix 实测 `WARNING: DATA RACE` 正在 `FileWriteTool/FileEditTool.SetMessageID()` 且 FAIL. 不是凭推理断言 race, 是 `-race` 见红坐实. --- ## 2. 决策 / Decision 把 per-turn id 从**共享实例状态**改 **per-call context 透传**. - **tools 包加 ctx 通道**: `WithMessageID(ctx, id)` / `MessageIDFromContext(ctx)`, 私有 `messageIDKey struct{}` (镜像 engine 的 `eventEmitterKey` 模式, 外部代码无法经 `context.WithValue` 伪造或撞 key). 无 import 环 -- tools 包本就拥有 `MessageIDAware` 接口. - **runLoop 改注入点**: 移除对 `e.tools.All()` 的逐个 `SetMessageID` 写, 改在每轮给 `toolCtx` 注入 `tools.WithMessageID(toolCtx, turnKey)`. 透传链 `ExecuteBatch -> executeSingle -> tool.Execute(ctx)` 已坐实到位. - **工具改读点**: `FileEdit` / `FileWrite` 经共享 helper `resolveTurnMessageID(ctx, t.messageID)` 优先读 ctx, 字段作 fallback. 每个 Run 的 ctx 带自己的 id, 无共享实例写 -> race-free 且逻辑正确 (不再 clobber). `MessageIDAware.SetMessageID` 字段 + 方法保留, 降级为不透传 context 的直接调用者的 fallback (向后兼容, 见 §4). --- ## 3. 替代方案 / Alternatives | 方案 | 描述 | 裁决 | |---|---|---| | **A. 给共享字段加锁** | 给 `t.messageID` 套 mutex, 读写都过锁 | **否决**. "安全地错" -- race 没了但并发 Run 仍互相 clobber turn id (Run-A 第 3 轮的 `SetMessageID` 覆盖 Run-B 正在读的值), 快照照样打错键. 治了症状 (race detector 不报) 没治病 (值串了). 这正是 ctx 透传胜出的核心理由: per-turn-per-Run 值天然该 per-call scope, 不该进共享单槽. | | **B. per-Run clone 工具实例** | 每个 Run 起一套独立工具实例 | **否决**. 工具实例可能持昂贵/有状态资源 (`guard` / `fileHistory` / socket), 按 Run clone 成本高且生命周期语义复杂. 共享实例 + per-call ctx 是 Go 惯用法 -- `context` 就是为 request-scoped per-call 值设计的. | | **C. context 透传 (本 ADR)** | per-turn id 经 ctx 流到工具, 共享实例无 per-Run 状态 | **选定**. 范式级 (改值的归属, 不打锁补丁), race-free + 逻辑正确 + 向后兼容. | --- ## 4. 影响 / Consequences **正面**: - 并发 Run 的 file-history 打键 race-free 且正确, 平台单 engine 多 Run 安全 (ADR-0003 平台化的并发面被覆盖). - `MessageIDAware.SetMessageID` 降级为 LEGACY / fallback, godoc 写明 "工具应优先 `MessageIDFromContext(ctx)`". 实现该接口仍可选, 无破坏性变更 -- 不透传 context 的直接调用者照旧能用字段. - 修法可推广: 任何 "engine 每轮要喂给工具的 per-turn 值" 都该走 ctx 而非共享字段. **负面 / 边界**: - **范围精确**: 消灭的是 messageID 这一条 data race. 未声称穷尽所有 工具 x 引擎特性 的并发维度 (并发 compaction / sub-agent spawn / MCP 未逐一压). - sub-agent worker 仍不设 messageID (file-history 回滚对 worker 本就未 wire, 行为一致, 非回归). --- ## 5. 验证 / Validation 双 gate (race 证安全但证不了正确; 需正确性 gate 证值没静默消失): - **安全 gate** = `TestConcurrentRun_FileWrites_NoMessageIDRace` POST-fix 绿 (25x `-cpu=8` 加压); PRE-fix stash 实测 DATA RACE + FAIL. - **正确性 gate** = 既有 `TestEngineRollback_RestoresFileEndToEnd` / `file_history_rollback_wire_test` 驱动真 runLoop 断言 `CanRollback("turn-1")` -- id 经 ctx 流动后仍正确 key 快照, 没静默消失. - 全 `engine` + `tools` + `tools/builtin` `-race` 绿 + platform build 过. - staging (m2max, provider deepseek-v4-pro) parallel + serial 计划经真 provider 跑到 done. --- ## 6. 触发重新评估的条件 / Trigger conditions - 若未来 per-turn 要透传的状态多于一个 id (如 per-turn budget / trace span), 评估升级为一个 per-turn struct 经单 ctx key 透传, 而非堆多个 context key. - 若引入 "按 Run clone 有状态工具实例" 的强隔离需求, 重审 §3 B. - 若并发维度暴露新 race (并发 compaction / sub-agent spawn / MCP), 用 `TestConcurrentRun_*` 现有并发测试范式压, 按需扩. --- ## 7. 工程量 / Engineering footprint - `b96919b`: `engine.go` (runLoop 删 5 行共享写 + 注入 1 行 ctx) + `tool.go` (+~55 行 `WithMessageID` / `MessageIDFromContext` / `messageIDKey` + `MessageIDAware` godoc 升级 LEGACY 说明) + `fileedit.go` (+`resolveTurnMessageID` helper, 2 处读改 ctx) + `filewrite.go` (1 处读改 ctx) + 判别测试 114 行. - `841dbc8`: 并发多轮 Run race 佐证 (test-only, 无生产改动, 静态扫 Run 路径其余共享写已收敛). --- ## 8. 修订记录 / Revision history - **2026-06-17 v1 Accepted**: 初版. ctx 透传修法归档 (PM "直接修" 落地后补 ADR 8 节体例; 此前修法已在 CHANGELOG + `tools.MessageIDAware` godoc + commit message 讲清, 本 ADR 是正式架构归档).