Files
ycode/docs/子智能体文件租约并发修复-2026-10-18.md
T
xiaohan.yuan 18aaca5843 fix(subagents): queue root tool file leases
- queue root tool calls by declared or default footprint instead of rejecting sibling calls
- keep parent/child cross-boundary write conflicts rejected
- add cancellation, timeout, queue pumping, and regression coverage
2026-10-18 18:10:12 +08:00

6.4 KiB
Raw Blame History

子智能体文件租约:并发工具调用修复(2026-10-18)

用户可见问题

  • 同一回合里模型并发发出多条命令时,其中一条会返回「相关文件正由其他任务修改,请等待子任务或当前文件操作结束后再执行。」,但当时没有任何别的任务在改文件,命令本身也大多是只读的(dir/findstr)。
  • 实测证据:用户会话 39965c03-076b-4267-93dd-3eb45ba1386d,原生转录 %APPDATA%\YCode\omp-sessions\session-39965c03-076b-4267-93dd-3eb45ba1386d.jsonl。
    • 09:00:47.937 findstr /s /n … 启动,09:00:47.938 dir /b src\renderer 启动(相隔 1 ms),后者被判冲突。
    • 09:00:55.663 dir /b /s … | findstr … 与 09:00:55.664 powershell … 同样只有一条成功,而两次里"胜者"相反 → 竞态而非固定顺序。
    • 该回合没有任何 spawn_agent,所以与子智能体无关,是主模型自己在一轮里并行发了多条命令。

原因

  • src/main/agent/omp-turn.ts:149 对每个 host_tool_call 起 fire-and-forget 协程,同一 assistant 消息里的多个宿主工具调用会真正并发执行;备用 HTTP 循环 src/main/agent/loop.ts:144-162 是串行的。
  • 改动前的 src/main/subagents/scheduler.ts(HEAD c7d6c55)把非只读工具的租约路径取成 '.'(工作区根),并把它当独占锁;只要有任意租约与之重叠就返回错误。于是两种情况都会误判:并发兄弟调用互撞;子智能体持有文件租约时主智能体的所有命令被拒(子智能体可存活至 10 分钟超时)。

改动(src/main/subagents/scheduler.ts,scripts/subagent-regression.ts)

  1. leases: Map<string, string> 保持为子智能体声明文件的独占租约(语义与改动前一致);新增 rootIntents: Map<string, number>(带计数)记录主智能体正在写入的具体文件。
  2. 主智能体侧新增按足迹的等待队列(rootLeases / rootWaiters / acquireRootLease):显式写工具(write_file/edit_file)取自身文件路径;其余工具(命令、连接器等)足迹未知,取工作区根。
  3. 冲突语义:
    • root ↔ root:先到先得排队,不再报错。
    • root 显式写 ↔ 子智能体声明文件:仍拒绝,文案改为可执行的「该文件正由子智能体修改,请先 wait_agent 或改用其他文件:」。
    • 子智能体声明文件 ↔ 主智能体正在写该文件(写工具请求时即登记 rootIntents,含审批等待期):spawn_agent 被拒绝「该文件正在被主智能体写入,请等待该工具结束后再分配:」。父子两个方向都互斥。
    • 未知足迹工具 ↔ 子智能体租约:不再互斥(子智能体运行期间主智能体仍可执行命令)。这是有意取舍,见限制。
    • 子智能体 ↔ 子智能体:保持原样,spawn_agent 阶段即拒绝。
  4. 等待有上限(10 分钟,超时按可重试错误返回并带路径),取消或超时出队后会立即推进队列,避免泄漏的占用把后续无冲突工具一起挂住。

独立评审与处置

改动完成后由独立子代理(只读)审阅,发现并已修掉两处:

  • P1(已修):第一版把主智能体租约只放进 rootLeases,导致「父先写、子后到」方向不再互斥(HEAD 原本双向互斥),同文件可能出现两个写入者。现在显式写工具在请求时登记 rootIntents,acquire 一并检查。
  • P2(已修):等待者被取消或超时出队后没有推进队列,队首之后的不冲突等待者会被无谓阻塞。现在 onAbort/超时后都补一次 pumpRootWaiters()。
  • P3(未修,已记录):close()/cancelAll() 不清理模块级 rootWaiters/rootLeases。现调用顺序(先 abort 再 close,见 chat-turn-service.ts:545-547)不会残留;若将来出现先 close 后 abort 的调用,排队者会存活到 10 分钟超时或回合结束后被授予。修复需要给 root 租约加 owner/run 标识。

方向参照(只读调研,非强制对齐):opencode 对同一文件的 edit/patch 采用按路径排队(packages/core/src/file-mutation.ts:59-78),不做拒绝式报错;本次与其方向一致,但保留了 YCode 自己的原子写与写前重读校验。

验证

验证 结果
npm run test:subagents 40 项通过(新增 9 项:未知足迹兄弟调用串行不报错、排队者自动接续、并发三条都成功且同一时刻只有一条在跑、父写期间子智能体不能声明同一文件、父写正常完成、队首冲突时无关等待者排队、取消队首后无关等待者立刻接续、占用期间到来的调用排队、排队调用随取消以 AbortError 结束)
npm run typecheck 通过
npm run test:subagents:app 28 项通过(真实 Electron + 已安装 OMP + 回环模型,0 renderer 错误);报告 .tmp/subagent-app-UpT2kI/report.json
旧场景直接复现 .tmp/lease-repro/repro.ts:同一回合并发 3 条未知足迹工具 + 1 次读取,修复前 2 条 BLOCKED,修复后 4 条全部 OK,命令串行(1207 ms ≈ 3 × 400 ms)
npm run build 通过(含 sandbox 原生构建);out/main/index.mjs 已确认不再包含旧文案

限制与未验证

  • 未重启用户窗口:用户运行中的是 16:56 启动的安装版;安装目录 resources/app.asar 仍是更早的构建,需要重新分发并重启才生效。
  • 未知足迹工具(shell、连接器)与「子智能体正在写的文件」之间不再互斥:主智能体在子智能体运行期间执行会改文件的命令(格式化、git checkout 等)仍可能覆盖子智能体结果,靠 wait_agent 纪律约束;本次未做命令足迹静态分析。
  • close() 对卡死 worker 的等待仍无上限(不在本次改动范围):若某运行时不响应取消,子智能体租约仍可能在进程内残留;等待队列有超时兜底,但租约本身不会自动清除。
  • 快速路径(无需排队时)不再二次检查 signal.aborted:取消恰好发生在进入执行之间时,工具会执行一次、结果被丢弃,与既有 execute() 的行为一致。
  • 未做原版对照:WorkBuddy 没有子智能体文件租约概念,本项不产生 1:1 结论。
  • 未重跑全部历史专项(沙盒等模块当时有并行修改),本轮只跑与本次改动直接相关的三组。