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
+250
-11
No files matched your search
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user