docs: clarify onApproval contract and add missing test scenarios
- Document skip() cascade semantics, skipRemaining() in-flight constraint, and onApproval trigger conditions / mutation warning - Add concurrency safety comment on completedThisRound - Note task_skipped as breaking union addition on OrchestratorEvent - Add 3 test scenarios: single-batch no-callback, mixed success/failure batch, and onProgress task_skipped event relay
This commit is contained in:
parent
cdeeba91d2
commit
44ed096e8b
|
|
@ -306,6 +306,8 @@ async function executeQueue(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track tasks that complete successfully in this round for the approval gate.
|
// Track tasks that complete successfully in this round for the approval gate.
|
||||||
|
// Safe to push from concurrent promises: JS is single-threaded, so
|
||||||
|
// Array.push calls from resolved microtasks never interleave.
|
||||||
const completedThisRound: Task[] = []
|
const completedThisRound: Task[] = []
|
||||||
|
|
||||||
// Dispatch all currently-pending tasks as a parallel batch.
|
// Dispatch all currently-pending tasks as a parallel batch.
|
||||||
|
|
|
||||||
|
|
@ -161,7 +161,9 @@ export class TaskQueue {
|
||||||
* Marks `taskId` as `'skipped'` and records `reason` in the `result` field.
|
* Marks `taskId` as `'skipped'` and records `reason` in the `result` field.
|
||||||
*
|
*
|
||||||
* Fires `'task:skipped'` for the skipped task and cascades to every
|
* Fires `'task:skipped'` for the skipped task and cascades to every
|
||||||
* downstream task that transitively depended on it.
|
* downstream task that transitively depended on it — even if the dependent
|
||||||
|
* has other dependencies that are still pending or completed. A skipped
|
||||||
|
* upstream is treated as permanently unsatisfiable, mirroring `fail()`.
|
||||||
*
|
*
|
||||||
* @throws {Error} when `taskId` is not found.
|
* @throws {Error} when `taskId` is not found.
|
||||||
*/
|
*/
|
||||||
|
|
@ -180,6 +182,11 @@ export class TaskQueue {
|
||||||
*
|
*
|
||||||
* Used when an approval gate rejects continuation — every pending, blocked,
|
* Used when an approval gate rejects continuation — every pending, blocked,
|
||||||
* or in-progress task is skipped with the given reason.
|
* or in-progress task is skipped with the given reason.
|
||||||
|
*
|
||||||
|
* **Important:** Call only when no tasks are actively executing. The
|
||||||
|
* orchestrator invokes this after `await Promise.all()`, so no tasks are
|
||||||
|
* in-flight. Calling while agents are running may mark an in-progress task
|
||||||
|
* as skipped while its agent continues executing.
|
||||||
*/
|
*/
|
||||||
skipRemaining(reason = 'Skipped: approval rejected.'): void {
|
skipRemaining(reason = 'Skipped: approval rejected.'): void {
|
||||||
// Snapshot first — update() mutates the live map, which is unsafe to
|
// Snapshot first — update() mutates the live map, which is unsafe to
|
||||||
|
|
|
||||||
15
src/types.ts
15
src/types.ts
|
|
@ -313,7 +313,12 @@ export interface Task {
|
||||||
// Orchestrator
|
// Orchestrator
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/** Progress event emitted by the orchestrator during a run. */
|
/**
|
||||||
|
* Progress event emitted by the orchestrator during a run.
|
||||||
|
*
|
||||||
|
* **v0.3 addition:** `'task_skipped'` — consumers with exhaustive switches
|
||||||
|
* on `type` will need to add a case for this variant.
|
||||||
|
*/
|
||||||
export interface OrchestratorEvent {
|
export interface OrchestratorEvent {
|
||||||
readonly type:
|
readonly type:
|
||||||
| 'agent_start'
|
| 'agent_start'
|
||||||
|
|
@ -346,7 +351,13 @@ export interface OrchestratorConfig {
|
||||||
* to start next. Return `true` to continue or `false` to abort —
|
* to start next. Return `true` to continue or `false` to abort —
|
||||||
* remaining tasks will be marked `'skipped'`.
|
* remaining tasks will be marked `'skipped'`.
|
||||||
*
|
*
|
||||||
* Not called after the final round (when no tasks remain to start).
|
* Not called when:
|
||||||
|
* - No tasks succeeded in the round (all failed).
|
||||||
|
* - No pending tasks remain after the round (final batch).
|
||||||
|
*
|
||||||
|
* **Note:** Do not mutate the {@link Task} objects passed to this
|
||||||
|
* callback — they are live references to queue state. Mutation is
|
||||||
|
* undefined behavior.
|
||||||
*/
|
*/
|
||||||
readonly onApproval?: (completedTasks: readonly Task[], nextTasks: readonly Task[]) => Promise<boolean>
|
readonly onApproval?: (completedTasks: readonly Task[], nextTasks: readonly Task[]) => Promise<boolean>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -353,4 +353,112 @@ describe('onApproval integration', () => {
|
||||||
const titles = completedTasks.map((t: Task) => t.title).sort()
|
const titles = completedTasks.map((t: Task) => t.title).sort()
|
||||||
expect(titles).toEqual(['task-1', 'task-2'])
|
expect(titles).toEqual(['task-1', 'task-2'])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('single batch with no second round — callback never fires', async () => {
|
||||||
|
const approvalSpy = vi.fn().mockResolvedValue(true)
|
||||||
|
const { orchestrator, team } = setup(approvalSpy)
|
||||||
|
|
||||||
|
const result = await orchestrator.runTasks(team, [
|
||||||
|
{ title: 'task-1', description: 'first', assignee: 'agent-a' },
|
||||||
|
{ title: 'task-2', description: 'second', assignee: 'agent-b' },
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
// No second round → callback never called
|
||||||
|
expect(approvalSpy).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('mixed success/failure in batch — completedTasks only contains succeeded tasks', async () => {
|
||||||
|
const approvalSpy = vi.fn().mockResolvedValue(true)
|
||||||
|
const agentA: AgentConfig = { name: 'agent-a', model: 'mock', systemPrompt: 'A' }
|
||||||
|
const agentB: AgentConfig = { name: 'agent-b', model: 'mock', systemPrompt: 'B' }
|
||||||
|
const agentC: AgentConfig = { name: 'agent-c', model: 'mock', systemPrompt: 'C' }
|
||||||
|
|
||||||
|
const orchestrator = new OpenMultiAgent({
|
||||||
|
defaultModel: 'mock',
|
||||||
|
onApproval: approvalSpy,
|
||||||
|
})
|
||||||
|
|
||||||
|
const team = orchestrator.createTeam('test', {
|
||||||
|
name: 'test',
|
||||||
|
agents: [agentA, agentB, agentC],
|
||||||
|
})
|
||||||
|
|
||||||
|
const mockAgents = new Map<string, Agent>()
|
||||||
|
mockAgents.set('agent-a', buildMockAgent(agentA, 'A done'))
|
||||||
|
mockAgents.set('agent-b', buildMockAgent(agentB, 'B done'))
|
||||||
|
mockAgents.set('agent-c', buildMockAgent(agentC, 'C done'))
|
||||||
|
|
||||||
|
// Patch buildPool so that pool.run for agent-b returns a failure result
|
||||||
|
;(orchestrator as any).buildPool = () => {
|
||||||
|
const pool = new AgentPool(5)
|
||||||
|
for (const [, agent] of mockAgents) pool.add(agent)
|
||||||
|
const originalRun = pool.run.bind(pool)
|
||||||
|
pool.run = async (agentName: string, prompt: string, opts?: any) => {
|
||||||
|
if (agentName === 'agent-b') {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
output: 'simulated failure',
|
||||||
|
messages: [],
|
||||||
|
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
||||||
|
toolCalls: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return originalRun(agentName, prompt, opts)
|
||||||
|
}
|
||||||
|
return pool
|
||||||
|
}
|
||||||
|
|
||||||
|
// task-1 (success) and task-2 (fail) run in parallel, task-3 depends on task-1
|
||||||
|
await orchestrator.runTasks(team, [
|
||||||
|
{ title: 'task-1', description: 'first', assignee: 'agent-a' },
|
||||||
|
{ title: 'task-2', description: 'second', assignee: 'agent-b' },
|
||||||
|
{ title: 'task-3', description: 'third', assignee: 'agent-c', dependsOn: ['task-1'] },
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(approvalSpy).toHaveBeenCalledTimes(1)
|
||||||
|
const completedTasks = approvalSpy.mock.calls[0][0] as Task[]
|
||||||
|
// Only task-1 succeeded — task-2 failed, so it should not appear
|
||||||
|
expect(completedTasks).toHaveLength(1)
|
||||||
|
expect(completedTasks[0].title).toBe('task-1')
|
||||||
|
expect(completedTasks[0].status).toBe('completed')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('onProgress receives task_skipped events when approval is rejected', async () => {
|
||||||
|
const progressSpy = vi.fn()
|
||||||
|
const agentA: AgentConfig = { name: 'agent-a', model: 'mock', systemPrompt: 'A' }
|
||||||
|
const agentB: AgentConfig = { name: 'agent-b', model: 'mock', systemPrompt: 'B' }
|
||||||
|
|
||||||
|
const orchestrator = new OpenMultiAgent({
|
||||||
|
defaultModel: 'mock',
|
||||||
|
onApproval: vi.fn().mockResolvedValue(false),
|
||||||
|
onProgress: progressSpy,
|
||||||
|
})
|
||||||
|
|
||||||
|
const team = orchestrator.createTeam('test', {
|
||||||
|
name: 'test',
|
||||||
|
agents: [agentA, agentB],
|
||||||
|
})
|
||||||
|
|
||||||
|
const mockAgents = new Map<string, Agent>()
|
||||||
|
mockAgents.set('agent-a', buildMockAgent(agentA, 'A done'))
|
||||||
|
mockAgents.set('agent-b', buildMockAgent(agentB, 'B done'))
|
||||||
|
;(orchestrator as any).buildPool = () => {
|
||||||
|
const pool = new AgentPool(5)
|
||||||
|
for (const [, agent] of mockAgents) pool.add(agent)
|
||||||
|
return pool
|
||||||
|
}
|
||||||
|
|
||||||
|
await orchestrator.runTasks(team, [
|
||||||
|
{ title: 'task-1', description: 'first', assignee: 'agent-a' },
|
||||||
|
{ title: 'task-2', description: 'second', assignee: 'agent-b', dependsOn: ['task-1'] },
|
||||||
|
])
|
||||||
|
|
||||||
|
const skippedEvents = progressSpy.mock.calls
|
||||||
|
.map((c: any) => c[0])
|
||||||
|
.filter((e: any) => e.type === 'task_skipped')
|
||||||
|
|
||||||
|
expect(skippedEvents).toHaveLength(1)
|
||||||
|
expect(skippedEvents[0].data.status).toBe('skipped')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue