Task scheduling and dispatch
July 26, 2026 · View on GitHub
runTeam(), runTasks(), runFromPlan(), and restore() execute task DAGs
through the same scheduler and queue. The default executor is event-driven: a
downstream task starts as soon as its dependencies are satisfied; it does not
wait for unrelated tasks that became ready at the same time.
Event-driven execution
The executor maintains a ready set and an in-flight map:
TaskQueueemitstask:readywhen a task has no unresolved dependencies.- The scheduler assigns that one ready task against the current DAG snapshot.
- The dispatch gate checks cancellation, budget state, approval state, and AgentPool capacity.
- The task is dispatched through
AgentPool. - Completion immediately unblocks dependents and wakes the executor.
AgentPool's semaphore remains the concurrency authority, including ephemeral
delegate_to_agent runs. The dispatch gate is an integration seam; it does not
add resource locks or a second concurrency system.
Task failure and skip propagation still belong to TaskQueue. A failed or
skipped task cascades to its dependents immediately, while unrelated branches
continue.
Task results and dependency payloads
TeamRunResult.agentResults remains keyed by agent name and preserves its
existing merge behavior when one agent executes multiple tasks. Task runs also
populate TeamRunResult.taskResults, keyed by stable task ID, so every task's
unmerged AgentRunResult remains available:
const result = await orchestrator.runTasks(team, tasks)
const extractTask = result.tasks?.find(task => task.title === 'Extract')
const extracted = extractTask
? result.taskResults?.get(extractTask.id)?.structured
: undefined
The two indexes reference the same underlying executions. Run-level token usage
and metrics are calculated once from the internal results; exposing
taskResults does not count usage twice.
Direct dependencies still inject raw output by default. An explicit task can
opt into validated structured handoff:
{
title: 'Review',
description: 'Review validated extraction records.',
dependsOn: ['Extract'],
dependencyPayload: 'structured', // 'output' (default) | 'structured' | 'both'
}
structured injects only canonical JSON derived from the dependency's
successful AgentRunResult.structured; narrative text in output is excluded.
both injects labeled raw and structured sections. A missing or non-serializable
structured value fails the dependent task with a machine-readable validation
error—OMA never silently falls back to raw output. Each opt-in dependency
payload is limited to 64 KiB before the consumer agent is invoked. The default
output path is unchanged for 1.x compatibility.
Task role and provenance metadata
assignee identifies the concrete worker instance. role can separately name
the logical business function, and bounded metadata can carry references such
as sourceFile, supplierId, or documentId:
{
title: 'Read supplier reply 01',
description: 'Extract the simulated quote.',
assignee: 'supplier-reader-01',
role: 'supplier-extraction',
metadata: {
sourceFile: 'fixtures/supplier-01.json',
supplierId: 'supplier-01',
},
}
Task metadata allows at most 16 entries. Keys are 1–64 characters, begin with a
letter, and otherwise use letters, digits, ., _, or -; each string is at
most 1024 characters and each homogeneous scalar array at most 16 values.
Credential-like keys and the reserved oma. prefix are rejected.
Credential-like text in allowed string values is redacted before the metadata
enters results, task trace attributes, checkpoint snapshots, or plan artifacts.
TaskExecutionRecord retains role and metadata. Task spans expose
oma.task.role and oma.task.meta.<key>, while legacy task trace events expose
taskRole and taskMetadata. Execution receipts keep the legacy
rolesExecuted assignee semantics and add workerInstancesExecuted plus
taskRolesExecuted so worker replicas are not confused with business roles.
Assignment strategies
Before any task is dispatched, the complete plan is validated against explicit
task requirements. Every scheduling strategy filters to eligible agents first;
the configured strategy only ranks or rotates within that eligible set.
dependency-first and composite order the current ready set by downstream
criticality, round-robin retains its cursor, and least-busy reads current
in_progress load.
Agents may declare description, capabilities, costTier, and
latencyClass; none is inferred when omitted. Explicit runTasks() specs and
coordinator-generated tasks may add requires with requiredTools,
requiredCapabilities, requiredBackend, and requiredProvider. Tool
requirements are checked against the final resolved grant set, after presets,
allowlists, denylists, and framework rails.
Provider requirements are also checked after worker model routing. Incompatible
fallback routes are removed rather than crossing the declared provider boundary.
When no roster candidate satisfies an unassigned task, validation fails with
INVALID_TASK_REQUIREMENTS and an issue code of NO_ELIGIBLE_AGENT. When an
explicit assignee exists but does not satisfy the requirements, the issue code
is ASSIGNEE_REQUIREMENTS_MISMATCH. Both cases fail before worker execution;
hard requirements never fall back to an ineligible agent.
composite maximizes fitWeight * fit + loadWeight * (1 - normalizedCurrentLoad).
schedulingWeights.fit and schedulingWeights.load default to 0.7 and 0.3,
and current load is the agent's in_progress task count normalized within the
roster at scheduling time. Composite load is a snapshot of the supplied DAG
state. Assignments earlier in one scheduler call are not folded into that same
call; in event-driven execution the next ready-task call can observe tasks
already marked in_progress.
Coordinator plans that name an agent outside the roster fail validation before
task execution by default. Set strictAssignees: false only to retain the
legacy behavior of emitting an INVALID_ASSIGNEE warning, clearing that
assignment, and using the configured scheduler.
Approval modes
Two mutually exclusive approval modes are available:
const pipeline = new OpenMultiAgent({
onTaskDispatch: async (task) => approveTask(task),
})
onTaskDispatch runs after a ready task has an assignee and immediately before
dispatch. Returning false, or throwing, stops new dispatches. Tasks already in
flight settle before all remaining tasks are marked skipped.
const rounds = new OpenMultiAgent({
onApproval: async (completedRound, nextRound) =>
approveRound(completedRound, nextRound),
})
Configuring the existing onApproval callback automatically selects legacy
round semantics. Its arguments, callback timing, assignment timing, and batch
barrier remain unchanged. Configuring both callbacks throws a configuration
error.
There is no separate legacyBatchScheduling option. onApproval is already
the compatibility switch required by callers that depend on round boundaries;
a no-op callback returning true retains batch scheduling without introducing
a second overlapping mode flag.
Interruption, budgets, and checkpoints
Abort, budget exhaustion, and approval rejection share one drain-then-skip path:
- stop admitting new tasks;
- wait for every in-flight task to settle;
- mark all remaining pending or blocked tasks
skipped.
This prevents a task from being reported as skipped while its agent continues running. Budget state is checked before dispatch and again after each completion. Crossing a budget stops new tasks; already-started work still settles.
Checkpointing remains per completion. Writes are serialized through the existing save chain, and restore does not rerun tasks already recorded as completed.
Progress-event migration
task_start, agent_start, terminal task events, and their trace spans remain
paired. Their order is no longer grouped into rounds: events from independent
branches can interleave, and a downstream task_start can appear before an
unrelated task's terminal event.
Custom UIs should correlate events by task ID and derive branch state from
task status and dependsOn; do not infer a round boundary from adjacent events.
If a UI must retain round grouping during migration, configure onApproval and
return true.
See
examples/patterns/event-driven-dag.ts
for a no-key deferred-promise demonstration. It shows only the supported claim:
the downstream task starts when its dependency is satisfied, without waiting
for an unrelated task from the same ready set.