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

56 lines
6.4 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 子智能体文件租约:并发工具调用修复(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 或改用其他文件:<path>」。
- 子智能体声明文件 ↔ 主智能体正在写该文件(写工具请求时即登记 `rootIntents`,含审批等待期):`spawn_agent` 被拒绝「该文件正在被主智能体写入,请等待该工具结束后再分配:<path>」。父子两个方向都互斥。
- 未知足迹工具 ↔ 子智能体租约:不再互斥(子智能体运行期间主智能体仍可执行命令)。这是有意取舍,见限制。
- 子智能体 ↔ 子智能体:保持原样,`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 结论。
- 未重跑全部历史专项(沙盒等模块当时有并行修改),本轮只跑与本次改动直接相关的三组。