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

+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)
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('只读')