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
This commit is contained in:
xiaohan.yuan committed 2026-10-18 18:10:12 +08:00
1 parent b2d37472f0
commit 18aaca5843
4 files changed
+250 -11

No files matched your search

@@ -0,0 +1,55 @@
# 子智能体文件租约:并发工具调用修复(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 结论。
- 未重跑全部历史专项(沙盒等模块当时有并行修改),本轮只跑与本次改动直接相关的三组。
+2
View File
@@ -1,5 +1,7 @@
# YCode 接手状态(2026-10-15) # YCode 接手状态(2026-10-15)
> 2026-10-18 子智能体文件租约并发修复:[实现、证据与限制](子智能体文件租约并发修复-2026-10-18.md)。修复同一 assistant 回合内并发宿主工具调用被误判为「相关文件正由其他任务修改」的问题:主智能体侧新增按足迹的先到先得等待队列(显式写工具取文件路径,命令等未知足迹取工作区根),root↔root 排队而非报错,等待有 10 分钟上限并随取消/超时推进队列;root 显式写↔子智能体声明文件、子智能体声明文件↔主智能体正在写的文件双向仍拒绝并给出可执行文案,子智能体↔子智能体保持 spawn 拒绝。用户会话 `39965c03` 原生转录显示两条命令相隔 1 ms 同时启动、后者被判冲突,属竞态。独立子代理评审发现并修掉父→子方向互斥缺失(P1)与出队后不推进队列(P2);`close()` 不清理 root 队列(P3)已记录未修。`npm run test:subagents` 40 项、`npm run test:subagents:app` 28 项(真实 Electron/OMP,0 renderer 错误)、typecheck、完整 build 通过。未重启用户安装版;未知足迹工具与子智能体租约不再互斥为有意取舍;`close()` 对卡死 worker 的等待仍无上限。
> 2026-10-18 v0.01 Windows测试分发包:[构建与限制](分发包-v0.01-2026-10-18.md)。用户指定文件名版本v0.01,内部SemVer改0.0.1;NSIS按用户安装、可选目录、保留卸载数据,产物`release/YCode-v0.01-Setup-x64.exe`(140.3MiB),附说明和SHA256SUMS。完整dist:installer及实际包内容/正式exe隔离启动10项通过,0未捕获renderer异常,已查看截图;报告`.tmp/release-v001-M4MP5b/report.json`。未签名、未测干净设备安装卸载/GPU全路径;OMP仍外部探测,个人配置/专家不随包供应。保留其他工作区修改,未提交、未外部发布。 > 2026-10-18 v0.01 Windows测试分发包:[构建与限制](分发包-v0.01-2026-10-18.md)。用户指定文件名版本v0.01,内部SemVer改0.0.1;NSIS按用户安装、可选目录、保留卸载数据,产物`release/YCode-v0.01-Setup-x64.exe`(140.3MiB),附说明和SHA256SUMS。完整dist:installer及实际包内容/正式exe隔离启动10项通过,0未捕获renderer异常,已查看截图;报告`.tmp/release-v001-M4MP5b/report.json`。未签名、未测干净设备安装卸载/GPU全路径;OMP仍外部探测,个人配置/专家不随包供应。保留其他工作区修改,未提交、未外部发布。
> 2026-10-18 沙盒 Git 一次性对齐:[完整实现与限制](沙盒Git完整接入-2026-10-18.md)、[功能验收矩阵](沙盒Git接入验收矩阵-2026-10-18.md)。组件升级至 0.7.0 / protocol 1:内置 82 文件 MinGit,接入宿主普通仓库/linked worktree 历史和索引审批导入、本地常用 Git、项目+`.git` 原生组事务写回/恢复、HTTPS/SSH、DPAPI scoped 凭证、clone/fetch/pull/push/reconcile、维护 CLI 与生产 IPC。覆盖拒绝、并发、真实主进程死亡恢复、unknown push、TLS/host-key 校验、取消/策略撤销;本地29/29、安全25/25、远程40/40、生产 Electron/OMP 52/52、最终包内 Git20/20、旧兼容专项314项重新回归;`npm run build` 通过,总计480项。构建验证摘要见 `.tmp/git-complete-validation.json`,代码/日志保留在本工作区,未提交、未改用户真实仓库/远程或生产沙盒状态、未重启用户窗口。明确限制仍有标准用户/安装发布/独立安全审查、Git 大仓库/部分高级特性和原版 Git 运行时 1:1 证据。 > 2026-10-18 沙盒 Git 一次性对齐:[完整实现与限制](沙盒Git完整接入-2026-10-18.md)、[功能验收矩阵](沙盒Git接入验收矩阵-2026-10-18.md)。组件升级至 0.7.0 / protocol 1:内置 82 文件 MinGit,接入宿主普通仓库/linked worktree 历史和索引审批导入、本地常用 Git、项目+`.git` 原生组事务写回/恢复、HTTPS/SSH、DPAPI scoped 凭证、clone/fetch/pull/push/reconcile、维护 CLI 与生产 IPC。覆盖拒绝、并发、真实主进程死亡恢复、unknown push、TLS/host-key 校验、取消/策略撤销;本地29/29、安全25/25、远程40/40、生产 Electron/OMP 52/52、最终包内 Git20/20、旧兼容专项314项重新回归;`npm run build` 通过,总计480项。构建验证摘要见 `.tmp/git-complete-validation.json`,代码/日志保留在本工作区,未提交、未改用户真实仓库/远程或生产沙盒状态、未重启用户窗口。明确限制仍有标准用户/安装发布/独立安全审查、Git 大仓库/部分高级特性和原版 Git 运行时 1:1 证据。
+90 -2
View File
@@ -94,8 +94,16 @@ function setup(enabled = true, hold = true, timeoutMs = 2000, resultText?: strin
&& !(await executeTurnTool('spawn_agent', { name: 'nested', task: 'nested' }, workerCtx, policy)).ok) && !(await executeTurnTool('spawn_agent', { name: 'nested', task: 'nested' }, workerCtx, policy)).ok)
check('writer cannot modify another file', !(await executeTurnTool('write_file', { path: 'outside.txt', content: 'no' }, workerCtx, policy)).ok) check('writer cannot modify another file', !(await executeTurnTool('write_file', { path: 'outside.txt', content: 'no' }, workerCtx, policy)).ok)
check('same-file sibling assignment is denied', !(await state.call('spawn_agent', { name: '冲突', task: 'x', access: 'write', files: ['owned.txt'] })).ok) check('same-file sibling assignment is denied', !(await state.call('spawn_agent', { name: '冲突', task: 'x', access: 'write', files: ['owned.txt'] })).ok)
check('parent write and shell cannot collide with a child lease', !(await state.call('write_file', { path: 'owned.txt', content: 'parent' })).ok const blockedWrite = await state.call('write_file', { path: 'owned.txt', content: 'parent' })
&& !(await state.call('execute_command', { command: 'echo no' })).ok) check('parent write to a child-leased file is still denied', !blockedWrite.ok)
check('the denial names the file and the next step instead of blaming another task', blockedWrite.content.includes('子智能体') && blockedWrite.content.includes('owned.txt'))
// 同一回合里并发发出的命令必须都能跑完:以前后到的一条会被判成"文件正由其他任务修改"。
const [slow, sibling] = await Promise.all([
state.call('execute_command', { command: 'ping -n 2 127.0.0.1 >nul && echo first' }),
state.call('execute_command', { command: 'echo second' })
])
check('same-round shell calls both succeed while a child holds a file lease', slow.ok && sibling.ok
&& slow.content.includes('first') && sibling.content.includes('second'))
const childWrite = await executeTurnTool('write_file', { path: 'owned.txt', content: 'child' }, workerCtx, policy) const childWrite = await executeTurnTool('write_file', { path: 'owned.txt', content: 'child' }, workerCtx, policy)
check('declared full-access child file write really reaches disk', childWrite.ok && await readFile(join(root, 'owned.txt'), 'utf8') === 'child') check('declared full-access child file write really reaches disk', childWrite.ok && await readFile(join(root, 'owned.txt'), 'utf8') === 'child')
state.release.get(writer.id)!() state.release.get(writer.id)!()
@@ -113,6 +121,86 @@ function setup(enabled = true, hold = true, timeoutMs = 2000, resultText?: strin
check('rewind includes child writes before later parent writes', changes.length === 1 && changes[0].before === null && changes[0].after === 'parent-after') check('rewind includes child writes before later parent writes', changes.length === 1 && changes[0].before === null && changes[0].after === 'parent-after')
await state.scheduler.close() await state.scheduler.close()
} }
{
// 同一回合里并发发出的未知足迹工具(命令、连接器等)必须排队串行,而不是互相判成文件冲突。
const id = 'root-queue-' + Math.random().toString(36).slice(2)
const controller = new AbortController()
const started: string[] = [], finished: string[] = []
const gates = new Map<string, () => void>()
let active = 0, peak = 0
const tool = (name: string) => ({ type: 'function', function: { name, description: '排队用例的假工具', parameters: { type: 'object', properties: {} } } })
const capabilities: CapabilityToolSnapshot = {
specs: [tool('custom_command'), tool('write_file')],
async execute(_name, args) {
const key = String((args as { key?: unknown; path?: unknown }).key ?? (args as { path?: unknown }).path ?? '')
started.push(key); active++; peak = Math.max(peak, active)
await new Promise<void>(done => gates.set(key, done))
active--; finished.push(key)
return { ok: true, content: 'done:' + key }
}
}
const scheduler = new SubagentScheduler({ runId: id, workspaceRoot: root, modelName: 'queue-model', enabled: true,
signal: controller.signal, emit: () => {}, capabilities,
worker: async () => { throw new Error('排队用例不创建子智能体运行时') } })
const ctx: ConversationToolContext = { runId: id, toolCallId: 'queue', sessionId: 'parent-' + id, workspaceRoot: root,
permissionMode: 'full-access', agentMode: 'craft', signal: controller.signal, emit: () => {} }
const run = (key: string) => executeTurnTool('custom_command', { key }, ctx, scheduler.tools)
const write = (path: string, context = ctx) => executeTurnTool('write_file', { path }, context, scheduler.tools)
const startedKey = async (count: number): Promise<string> => {
for (let i = 0; i < 200 && started.length < count; i++) await pause(5)
return started[count - 1]
}
const waitForKey = async (key: string): Promise<void> => {
for (let i = 0; i < 200 && !started.includes(key); i++) await pause(5)
}
const calls = [run('a'), run('b'), run('c')]
const first = await startedKey(1)
check('sibling root tools with unknown footprints are serialized, not rejected', started.length === 1 && peak === 1)
gates.get(first)!()
const second = await startedKey(2)
check('the next sibling starts by itself once the holder finishes', started.length === 2 && peak === 1)
gates.get(second)!()
gates.get(await startedKey(3))!()
const results = await Promise.all(calls)
check('every concurrently issued root call succeeds exactly once in first-come order', results.every(result => result.ok)
&& new Set(results.map(result => result.content)).size === 3 && finished.length === 3 && peak === 1)
// 父写文件期间到达的 spawn_agent 不能声明同一文件:父→子方向也必须互斥。
const pendingWrite = write('parent.txt')
await waitForKey('parent.txt')
const late = await executeTurnTool('spawn_agent', { name: 'late', task: 'same file', access: 'write', files: ['parent.txt'] }, ctx, scheduler.tools)
check('a child cannot declare the file the parent is writing right now', !late.ok && late.content.includes('主智能体'))
gates.get('parent.txt')!()
check('the pending parent write completes normally', (await pendingWrite).ok)
// 队首被取消后,后面与持有者不冲突的等待者必须立刻接续。
const blocker = write('block.txt')
await waitForKey('block.txt')
const headController = new AbortController()
const head = write('block.txt', { ...ctx, signal: headController.signal })
await pause(50)
const tail = write('queue-only.txt')
await pause(30)
check('a conflicting waiter holds the queue and an unrelated one waits behind it', !started.includes('queue-only.txt'))
headController.abort()
const headRejection = await head.then(() => undefined, (error: Error) => error)
await waitForKey('queue-only.txt')
check('cancelling the head lets the next non-conflicting waiter start at once',
headRejection?.name === 'AbortError' && started.includes('queue-only.txt'))
gates.get('queue-only.txt')!()
await tail
gates.get('block.txt')!()
await blocker
const holder = run('hold')
await waitForKey('hold')
const queued = run('waiting')
await pause(30)
check('a sibling arriving while the footprint is held waits instead of failing', !started.includes('waiting'))
controller.abort()
const rejection = await queued.then(() => undefined, (error: Error) => error)
check('a queued root call is cancelled instead of hanging when the turn aborts', rejection?.name === 'AbortError')
gates.get('hold')!()
await holder
await scheduler.close()
}
{ {
const state = setup() const state = setup()
const reader = await state.spawn('只读') const reader = await state.spawn('只读')
+103 -9
View File
@@ -38,13 +38,87 @@ interface Entry {
turns: number turns: number
} }
const schedulers = new Map<string, SubagentScheduler>() const schedulers = new Map<string, SubagentScheduler>()
/**
* 子智能体按声明文件持有的独占租约;主智能体工具调用按真实足迹排队,
* 不再把同一回合里并发发出的命令当成"别的任务正在修改同一个文件"。
*/
const leases = new Map<string, string>() const leases = new Map<string, string>()
/** 主智能体正在写入的具体文件(写工具请求时即在册),让并发的 spawn_agent 也能看到父子同文件冲突。 */
const rootIntents = new Map<string, number>()
/** 主智能体侧的活动足迹:显式文件路径,或未知足迹工具(命令、连接器等)的工作区根。 */
const rootLeases = new Set<string>()
interface RootWaiter {
path: string
signal?: AbortSignal
grant(release: () => void): void
fail(error: Error): void
onAbort?(): void
timer?: ReturnType<typeof setTimeout>
}
const rootWaiters: RootWaiter[] = []
const ROOT_LEASE_WAIT_TIMEOUT_MS = 10 * 60_000
let activeWorkers = 0 let activeWorkers = 0
const MAX_ACTIVE = 5 const MAX_ACTIVE = 5
const WRITE_TOOLS = new Set(['write_file', 'edit_file']) const WRITE_TOOLS = new Set(['write_file', 'edit_file'])
const READ_TOOLS = new Set(['list_dir', 'read_file', 'ycode_load_skill', 'ycode_read_skill_resource', 'ycode_expert_resource']) const READ_TOOLS = new Set(['list_dir', 'read_file', 'ycode_load_skill', 'ycode_read_skill_resource', 'ycode_expert_resource'])
const normalize = (path: string): string => process.platform === 'win32' ? path.toLowerCase() : path const normalize = (path: string): string => process.platform === 'win32' ? path.toLowerCase() : path
const overlaps = (left: string, right: string): boolean => left === right || left.startsWith(right + sep) || right.startsWith(left + sep) const overlaps = (left: string, right: string): boolean => left === right || left.startsWith(right + sep) || right.startsWith(left + sep)
const addRootIntent = (path: string): void => { rootIntents.set(path, (rootIntents.get(path) ?? 0) + 1) }
const removeRootIntent = (path: string): void => {
const count = (rootIntents.get(path) ?? 0) - 1
if (count > 0) rootIntents.set(path, count)
else rootIntents.delete(path)
}
function releaseRootLease(path: string): void {
rootLeases.delete(path)
pumpRootWaiters()
}
/** 先到先得:队首不再冲突才授予足迹,同一批并发调用因此得到确定的串行顺序。 */
function pumpRootWaiters(): void {
while (rootWaiters.length) {
const next = rootWaiters[0]
if ([...rootLeases].some(held => overlaps(held, next.path))) return
rootWaiters.shift()
rootLeases.add(next.path)
if (next.timer) clearTimeout(next.timer)
if (next.signal && next.onAbort) next.signal.removeEventListener('abort', next.onAbort)
next.grant(() => releaseRootLease(next.path))
}
}
function acquireRootLease(path: string, signal?: AbortSignal): Promise<() => void> {
if (!rootWaiters.length && ![...rootLeases].some(held => overlaps(held, path))) {
rootLeases.add(path)
return Promise.resolve(() => releaseRootLease(path))
}
return new Promise<() => void>((grant, fail) => {
const waiter: RootWaiter = { path, signal, grant, fail }
const remove = (): number => {
const index = rootWaiters.indexOf(waiter)
if (index >= 0) rootWaiters.splice(index, 1)
if (waiter.timer) clearTimeout(waiter.timer)
return index
}
waiter.onAbort = () => {
if (remove() < 0) return
fail(Object.assign(new Error('回合已取消'), { name: 'AbortError' }))
pumpRootWaiters()
}
// 泄漏的占用不能把后续工具永久挂住:等待有上限,超时按可重试错误返回。
waiter.timer = setTimeout(() => {
if (remove() < 0) return
if (signal && waiter.onAbort) signal.removeEventListener('abort', waiter.onAbort)
fail(new Error('等待其他工具释放文件占用超时,请稍后重试:' + path))
pumpRootWaiters()
}, ROOT_LEASE_WAIT_TIMEOUT_MS)
waiter.timer.unref?.()
rootWaiters.push(waiter)
if (signal) {
if (signal.aborted) { waiter.onAbort(); return }
signal.addEventListener('abort', waiter.onAbort, { once: true })
}
pumpRootWaiters()
})
}
async function resolveWorkspacePath(rootInput: string, input: string): Promise<string> { async function resolveWorkspacePath(rootInput: string, input: string): Promise<string> {
const root = await realpath(rootInput) const root = await realpath(rootInput)
const target = resolve(root, input.trim() || '.') const target = resolve(root, input.trim() || '.')
@@ -91,7 +165,7 @@ export class SubagentScheduler {
return [...scheduler.options.capabilities.specs.filter(tool => !SUBAGENT_TOOL_NAMES.has(tool.function.name)), ...controls] return [...scheduler.options.capabilities.specs.filter(tool => !SUBAGENT_TOOL_NAMES.has(tool.function.name)), ...controls]
}, },
metadata: name => SUBAGENT_TOOL_NAMES.has(name) ? { capability: 'subagent' } : scheduler.options.capabilities.metadata?.(name), metadata: name => SUBAGENT_TOOL_NAMES.has(name) ? { capability: 'subagent' } : scheduler.options.capabilities.metadata?.(name),
aroundExecute: (name, args, _ctx, execute) => scheduler.rootExecute(name, args, execute), aroundExecute: (name, args, ctx, execute) => scheduler.rootExecute(name, args, ctx, execute),
async execute(name, args, ctx) { async execute(name, args, ctx) {
if (SUBAGENT_TOOL_NAMES.has(name)) return scheduler.execute(name, args, ctx) if (SUBAGENT_TOOL_NAMES.has(name)) return scheduler.execute(name, args, ctx)
return scheduler.options.capabilities.execute(name, args, ctx) return scheduler.options.capabilities.execute(name, args, ctx)
@@ -123,8 +197,13 @@ export class SubagentScheduler {
if (!this.enabled) throw new Error('智能体团队管理已禁用,请由主智能体继续处理') if (!this.enabled) throw new Error('智能体团队管理已禁用,请由主智能体继续处理')
} }
private acquire(entry: Entry): void { private acquire(entry: Entry): void {
for (const path of entry.paths) for (const [owned, owner] of leases) { for (const path of entry.paths) {
if (owner !== entry.view.id && overlaps(owned, path)) throw new Error('文件已分配给另一个子智能体,请等待完成后再分配:' + path) for (const [owned, owner] of leases) {
if (owner !== entry.view.id && overlaps(owned, path)) throw new Error('文件已分配给另一个子智能体,请等待完成后再分配:' + path)
}
for (const owned of rootIntents.keys()) {
if (overlaps(owned, path)) throw new Error('该文件正在被主智能体写入,请等待该工具结束后再分配:' + path)
}
} }
for (const path of entry.paths) leases.set(path, entry.view.id) for (const path of entry.paths) leases.set(path, entry.view.id)
} }
@@ -147,16 +226,31 @@ export class SubagentScheduler {
} }
return { files: [...new Set(files)], paths: [...new Set(paths)] } return { files: [...new Set(files)], paths: [...new Set(paths)] }
} }
private async rootExecute(name: string, args: Record<string, unknown>, execute: () => Promise<ConversationToolResult>): Promise<ConversationToolResult> { /**
* 主智能体工具入口。只有声明目标文件的工具才知道真实足迹,其余工具(命令、连接器等)按工作区整体排队。
* 同一回合里并发发出的兄弟调用会依次执行,而不是被判成"文件正由其他任务修改"。
*/
private async rootExecute(name: string, args: Record<string, unknown>, ctx: ConversationToolContext, execute: () => Promise<ConversationToolResult>): Promise<ConversationToolResult> {
if (SUBAGENT_TOOL_NAMES.has(name) || READ_TOOLS.has(name) || ['browser', 'todo_write', 'create_plan', 'ask_user_question', 'ask_followup_question'].includes(name)) return execute() if (SUBAGENT_TOOL_NAMES.has(name) || READ_TOOLS.has(name) || ['browser', 'todo_write', 'create_plan', 'ask_user_question', 'ask_followup_question'].includes(name)) return execute()
const explicit = WRITE_TOOLS.has(name)
let path: string let path: string
try { path = normalize(await resolveWorkspacePath(this.options.workspaceRoot, WRITE_TOOLS.has(name) ? String(args.path ?? '') : '.')) } try { path = normalize(await resolveWorkspacePath(this.options.workspaceRoot, explicit ? String(args.path ?? '') : '.')) }
catch (error) { return { ok: false, content: error instanceof Error ? error.message : String(error) } } catch (error) { return { ok: false, content: error instanceof Error ? error.message : String(error) } }
if ([...leases.keys()].some(owned => overlaps(owned, path))) return { ok: false, content: '相关文件正由其他任务修改,请等待子任务或当前文件操作结束后再执行。' } // 只有声明了具体文件的调用才能判定真冲突:子智能体正在改这个文件就拒绝,并给出可执行的下一步。
const owner = randomUUID() if (explicit) {
leases.set(path, owner) const held = [...leases.keys()].find(owned => overlaps(owned, path))
if (held) return { ok: false, content: '该文件正由子智能体修改,请先 wait_agent 或改用其他文件:' + path }
addRootIntent(path)
}
let release: () => void
try { release = await acquireRootLease(path, ctx.signal) }
catch (error) {
if (explicit) removeRootIntent(path)
if ((error as Error).name === 'AbortError') throw error
return { ok: false, content: error instanceof Error ? error.message : String(error) }
}
try { return await execute() } try { return await execute() }
finally { if (leases.get(path) === owner) leases.delete(path) } finally { release(); if (explicit) removeRootIntent(path) }
} }
private workerTools(entry: Entry): CapabilityToolSnapshot { private workerTools(entry: Entry): CapabilityToolSnapshot {
const allowed = (name: string): boolean => READ_TOOLS.has(name) || entry.view.access === 'write' && WRITE_TOOLS.has(name) const allowed = (name: string): boolean => READ_TOOLS.has(name) || entry.view.access === 'write' && WRITE_TOOLS.has(name)