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

+103 -9
View File
@@ -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,8 +197,13 @@ 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) {
if (owner !== entry.view.id && overlaps(owned, path)) throw new Error('文件已分配给另一个子智能体,请等待完成后再分配:' + path)
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)
}
@@ -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)