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:
1 parent
b2d37472f0
commit
18aaca5843
4 files changed
+249
-10
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 结论。
|
||||
- 未重跑全部历史专项(沙盒等模块当时有并行修改),本轮只跑与本次改动直接相关的三组。
|
||||
@@ -1,5 +1,7 @@
|
||||
# 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 沙盒 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 证据。
|
||||
|
||||
@@ -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)
|
||||
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('parent write and shell cannot collide with a child lease', !(await state.call('write_file', { path: 'owned.txt', content: 'parent' })).ok
|
||||
&& !(await state.call('execute_command', { command: 'echo no' })).ok)
|
||||
const blockedWrite = await state.call('write_file', { path: 'owned.txt', content: 'parent' })
|
||||
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)
|
||||
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)!()
|
||||
@@ -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')
|
||||
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 reader = await state.spawn('只读')
|
||||
|
||||
@@ -38,13 +38,87 @@ interface Entry {
|
||||
turns: number
|
||||
}
|
||||
const schedulers = new Map<string, SubagentScheduler>()
|
||||
/**
|
||||
* 子智能体按声明文件持有的独占租约;主智能体工具调用按真实足迹排队,
|
||||
* 不再把同一回合里并发发出的命令当成"别的任务正在修改同一个文件"。
|
||||
*/
|
||||
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
|
||||
const MAX_ACTIVE = 5
|
||||
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 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 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> {
|
||||
const root = await realpath(rootInput)
|
||||
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]
|
||||
},
|
||||
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) {
|
||||
if (SUBAGENT_TOOL_NAMES.has(name)) return scheduler.execute(name, args, ctx)
|
||||
return scheduler.options.capabilities.execute(name, args, ctx)
|
||||
@@ -123,9 +197,14 @@ export class SubagentScheduler {
|
||||
if (!this.enabled) throw new Error('智能体团队管理已禁用,请由主智能体继续处理')
|
||||
}
|
||||
private acquire(entry: Entry): void {
|
||||
for (const path of entry.paths) for (const [owned, owner] of leases) {
|
||||
for (const path of entry.paths) {
|
||||
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)
|
||||
}
|
||||
private release(entry: Entry): void {
|
||||
@@ -147,16 +226,31 @@ export class SubagentScheduler {
|
||||
}
|
||||
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()
|
||||
const explicit = WRITE_TOOLS.has(name)
|
||||
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) } }
|
||||
if ([...leases.keys()].some(owned => overlaps(owned, path))) return { ok: false, content: '相关文件正由其他任务修改,请等待子任务或当前文件操作结束后再执行。' }
|
||||
const owner = randomUUID()
|
||||
leases.set(path, owner)
|
||||
// 只有声明了具体文件的调用才能判定真冲突:子智能体正在改这个文件就拒绝,并给出可执行的下一步。
|
||||
if (explicit) {
|
||||
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() }
|
||||
finally { if (leases.get(path) === owner) leases.delete(path) }
|
||||
finally { release(); if (explicit) removeRootIntent(path) }
|
||||
}
|
||||
private workerTools(entry: Entry): CapabilityToolSnapshot {
|
||||
const allowed = (name: string): boolean => READ_TOOLS.has(name) || entry.view.access === 'write' && WRITE_TOOLS.has(name)
|
||||
|
||||
Reference in new issue
Block a user