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
@@ -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('只读')
|
||||
|
||||
Reference in new issue
Block a user