Task board
Task board is the building block underneath Parallel Tasks, Supervisor, and Plan and Execute. It runs a pool of workers that pull from a shared TaskCollection, respects task dependencies, and drains until the collection is finished: either every task completed, or nothing left can run.
Most users reach for one of the wrapper patterns. Reach for the board directly when none of those fit: a custom worker registry, a session-scoped board that accepts tasks from external actors, or a termination policy the wrappers don't expose.
When to use a task board
- You need a long-running board that accepts new tasks from outside the initial seed list (Parallel Tasks decomposes once and stops).
- You need a custom dispatcher or termination predicate that none of the higher-level wrappers expose.
- You're building a new coordination pattern and want a tested concurrent-drain substrate underneath it.
When NOT to use one
Use the higher-level wrappers when their shape fits:
- Parallel Tasks — known-upfront fan-out, no review loop, one drain.
- Supervisor — per-task quality review before write-back.
- Plan and Execute — re-planning across drains based on partial results.
- Round Robin — fixed-roster turn-taking.
- Debate — paired adversarial contributors.
Drop to the board only when none of those fit.
Block composition
seedCollection (write initialTasks into the TaskCollection)
↓
boardMetaActive (emit "started" status item)
↓
forEach worker (concurrency=N)
↓
┌─ claimTask (claim a ready task, or report empty)
│ ↓
│ workerBody (run the task's worker block, recordSuccess / recordError)
│ ↓
│ checkBoard (decide: continue, or exit with a reason)
│ ↓
│ loopBack until checkBoard says stop
↓
boardMetaCompleted (emit "completed" status item with counts + terminationReason)
Each worker runs its own claim/run/check loop. A claim is a single atomic compare-and-set, so two workers never run the same task: one wins, the other moves straight on to the next eligible task.
Basic usage
import { handler } from "@flow-state-dev/core";
import { taskBoard, taskWorkerInputSchema } from "@flow-state-dev/orchestration/task-board";
import { z } from "zod";
const worker = handler({
name: "echo",
inputSchema: taskWorkerInputSchema,
outputSchema: z.object({ result: z.string() }),
execute: (input) => ({ result: `did ${input.goal}` }),
});
const board = taskBoard({
name: "echo-board",
collection: { collectionId: "echo" },
workers: worker,
initialTasks: [
{ id: "a", goal: "a" },
{ id: "b", goal: "b", deps: ["a"] },
],
});
// `board.drain` plugs into a parent sequencer as a normal step.
Defaults: request-scoped storage (collection is optional), concurrency 4, dispatcher: "topological", onIdle: "complete-or-blocked", onError: "skip".
Termination: onIdle modes
A board needs a rule for "when do we stop." That rule is onIdle. Three values:
"complete-or-blocked" (default)
Exits when one of the following is true on a worker's checkBoard iteration:
- Drained — no
pending,in_progress, orawaiting_reviewtasks remain. - Blocked — no task is
in_progressorawaiting_review, and nopendingtask has all of itsdepscompleted. Nothing is claimable, and no in-flight work is left to change the dep graph.
Both checks ignore tasks sitting in awaiting_review when the board sets onReview: "exit".
The final task-board-meta item carries a terminationReason field saying which case it was:
"all-completed"— every task reachedcompleted(or the board started empty)."blocked-by-failures"— at least one task did not reachcompleted. Could beerrored,cancelled, orpendingwith unresolvable deps."retry-budget-exhausted"— the board refused a retry becausemaxTotalRetrieswas spent. See Bounding the retries."handed-off"— every task still outstanding is running in a Workstream. The board finished its own part and the work continues in the background, so this is a success, not a stall. Only a board with a seat that hands off reports it, andcounts.in_progressis how many are still running."parked-for-review"— the board stopped because the work it has left is waiting on a person. Like"handed-off", it is neither a success nor a failure: nothing went wrong, and nothing is finished. Only a board withonReview: "exit"reports it.counts.awaiting_reviewis how many tasks are parked.
Order matters when a board ends up in more than one of these states at once. "blocked-by-failures" wins over "parked-for-review" when a task errored, was cancelled, was moved to blocked, or is pending behind a dep that will never complete. Answering the review would not clear any of those. A task waiting on the parked task itself is not that case, and the board still reports "parked-for-review". A refused retry outranks all of them.
A delegation board's runBoard tool reports a status of its own, and the two count different things. terminationReason asks whether every task succeeded. runBoard's status asks whether any task is still outstanding, so a board whose only problem is one errored task reads "blocked-by-failures" here and "drained" there. See Delegation for the coordinator's side.
// On the final task-board-meta item:
{
component: "task-board-meta",
data: {
collectionId: "echo",
status: "completed",
terminationReason: "all-completed", // or "blocked-by-failures" | "retry-budget-exhausted"
// | "handed-off" | "parked-for-review"
maxTotalRetries: 50,
counts: {
total: 2,
completed: 2,
errored: 0,
cancelled: 0,
blocked: 0,
awaiting_review: 0,
in_progress: 0,
pending: 0,
retries: 0,
},
},
}
The choice between "all-completed" and "blocked-by-failures" comes from the counts (completed === total), so in "wait" mode a shouldExit that fires while tasks are still running reports "blocked-by-failures" even though nothing failed. Read counts when you override termination. The other three are not count comparisons: "retry-budget-exhausted" appears only when a retry was actually refused, "handed-off" only when every outstanding task is one a Workstream is holding, and "parked-for-review" only when the board stopped because it was told not to wait on a review.
"complete"
Exits only when no pending, in_progress, or awaiting_review tasks remain. Use it when a pending task with a non-completed dep is a transient state: something outside the worker pool will eventually mark the dep complete (an external service, an HITL approval pumping a queue).
A board in this mode never decides on its own that it is stuck. If a dep will never resolve, each worker keeps cycling until it hits maxIterations (default 10000, counted per worker). Pick the mode when the board really is supposed to wait.
A task parked for review keeps this mode's loop alive, and onReview: "exit" cannot change that. A board that sets both is refused when you build it.
"wait"
Never auto-exits. The loop runs until your shouldExit predicate returns true (or maxIterations trips). Use it for session-scoped boards that accept tasks from outside actors indefinitely.
const board = taskBoard({
// ...
onIdle: "wait",
shouldExit: (collection) => collection.count() >= 100, // your call
});
shouldExit is ignored in both "complete" and "complete-or-blocked" modes.
When to override the default
Most boards leave onIdle alone. Override when:
- You're modeling a board that legitimately waits on an external pump (use
"complete"). - You're building a session-scoped board that lives across many drains (use
"wait"+shouldExit).
Waiting on a person: onReview
A worker can park a task with awaitReview when it needs a human to look at something. By default the board treats that task the way it treats any other unfinished work: the drain stays open, and so does the request that started it, waiting for someone to move the task out of awaiting_review.
That is the right default when the answer arrives in seconds. It is the wrong one when it arrives tomorrow, and the way it goes wrong is worth knowing. Nothing shortens the wait, but it does end: each worker stops after maxIterations (default 10000), which on the default poll interval is most of a day. The task is left parked, and the board reports terminationReason: "blocked-by-failures" — on a board where nothing failed. The same item's counts.awaiting_review says a task is parked, so the payload contradicts itself, and a monitor watching the reason sees a failure every time somebody is asked a question.
onReview: "exit" says the board should not wait:
const board = taskBoard({
name: "reviews",
collection: reviewLedger, // defineTaskCollection — required for this mode
workers,
onReview: "exit",
});
With that set, a task in awaiting_review is not counted as work the drain waits on. Once parked tasks are the only thing left, the drain finishes, the request that started it returns, and the completion item says terminationReason: "parked-for-review". The task itself is untouched: parked, on the board, and durable.
Picking it back up
A resume moves the task back to pending. tasks is the board's task list, which any block reaches through the board's capability. See Commanding the board with its capability:
const tasks = await ctx.cap.reviews.tasks();
await tasks.resumeFromReview("draft-42", "approved, ship it");
The second argument is feedback for whoever picks the task up. It reaches the worker as input.feedback on the next attempt. A task that has never been reviewed has no feedback, so a worker can branch on it.
A resume re-queues the task. It does not start anything. Nothing is watching the board on your behalf, so a resumed task sits in pending until something drains the board again: a later turn from the user, a scheduled action, or a background job. If you resume a task and nothing happens, this is why. Run the drain:
// Later, in a new request:
await runAction({
flow,
actionName: "drain-reviews",
input: {},
userId,
sessionId, // the session whose task list holds the parked task
stores,
runtimeConfig: {},
});
The later drain has to reach the same task list, and that is the part this
call does not make obvious. A collection declared scope: "session" lives in one
session, so the drain has to name it. Leave sessionId out and nothing
complains: the runtime starts a fresh session, the drain resolves an empty task
list, reports that it drained, and never sees the parked task. A user- or
org-scoped collection spans every session that principal has, so it does not
need this.
flow and stores are the ones you already built; Running a flow by
hand covers assembling them outside the
HTTP transport, which is where a scheduled or background drain runs.
Whichever drain gets there first claims the task and runs it to completion, exactly as if it had been queued that moment.
What the mode requires
Every requirement below is checked when you build the board. Get one wrong and taskBoard() throws, naming the problem and the change to make:
- A durable collection — one built with
defineTaskCollection. The parked task has to outlive the drain that let go of it, or there is nothing for a later drain to come back to. - The default
onIdle."complete"and"wait"are both refused. If you need a wait-mode board to stop on parked tasks, put that rule inshouldExit. - An explicit
idon every entry ininitialTasks. This mode makes a second drain the normal case, and each drain re-runs the seed step. Seed entries with ids are matched against what is already on the board and skipped; an entry without one is added again every time, so the board grows a duplicate task on every pass.
What it does not change
The task lifecycle is the same under either setting. A parked task sits in awaiting_review, awaitReview and resumeFromReview move it in and out, and onReview decides only whether the drain counts it while it sits there.
The setting is board-wide. A board cannot park one task as "release the request" and another as "hold it".
Cascade-skipping dep-blocked tasks
"complete-or-blocked" ends the drain when pending tasks can no longer run, but it leaves those tasks pending. To fold them into a terminal status, .tap() the createCascadeSkipDependents building block after board.drain:
import { sequencer } from "@flow-state-dev/core";
import { taskBoard, createCascadeSkipDependents } from "@flow-state-dev/orchestration/task-board";
const board = taskBoard({ name: "research", collection: { collectionId: "research" }, workers });
const cascadeSkip = createCascadeSkipDependents({ name: "research" });
sequencer({ name: "research-run" })
.step(board.drain)
.tap(cascadeSkip); // transitively cancels pendings whose deps errored
It walks the dependency graph from every errored task, cancelling each pending whose deps include a failed task, and repeats to a fixed point so multi-level chains (a → b → c) drain in one pass. Cancelled tasks are stamped with a "skipped" label. It resolves the board's request-backed collection from name, so name must match the board's collectionId and the board must be on the default request backing. planAndExecute and supervisor wire this in for you.
Dispatcher modes
The dispatcher decides which pending task gets claimed next. No dispatcher claims a task whose deps aren't all completed; that rule lives on the collection's claim. So the built-in modes differ only in how they order the tasks that are already ready:
"topological"(default) — earliest-added ready task first."fifo"— the same ordering. The name reads better for a flat fan-out with no deps."priority"— highest-priorityready task first, ties break on earliest-added. An unsetprioritycounts as 0.
Those three strings are the only names dispatcher accepts. It also takes any TaskDispatcher instance, and @flow-state-dev/orchestration exports five: the three above plus classifierDispatcher and eventDispatcher, which are factories that need config and so have no string name. Pass one of those, or your own, in place of the string. See Task substrate → Dispatchers for what each one picks, and Flow policy for the observation ledger, priorWork shaping, and tool-result caching.
Dependency cycles are not rejected at add time. Avoiding them is the caller's responsibility when you build the deps graph passed to addTask/addTasks or initialTasks. A board that declares a deps cycle still runs, but those tasks never become claimable: the drain ends blocked (under "complete-or-blocked") or idles until its iteration cap.
Worker registry
Two ways to provide workers:
- Single uniform worker — one block runs every claimed task. Pass it directly as
workers. - Registry — a
{ [assignee]: block }map. Each task carriesassignee: "name"; the substrate dispatches to the matching worker.
const board = taskBoard({
name: "research",
collection: { collectionId: "r" },
workers: {
"market-analyst": marketAnalyst,
"financial-analyst": financialAnalyst,
synthesizer: synthesizer,
},
initialTasks: [
{ id: "m", goal: "market", assignee: "market-analyst" },
{ id: "f", goal: "financial", assignee: "financial-analyst" },
{ id: "s", goal: "synthesize", assignee: "synthesizer", deps: ["m", "f"] },
],
});
Assignee resolution: a matched assignee runs on its own worker; an unmatched or omitted assignee falls to defaultWorker if one is configured; with no defaultWorker, the task fails per onError.
const board = taskBoard({
name: "research",
collection: { collectionId: "r" },
workers: { "market-analyst": marketAnalyst },
// Optional fallback: any task whose assignee is unset or unmatched runs here
// instead of failing. Reached only on a miss — declared workers are untouched.
defaultWorker: genericWorker,
});
There is no defaultWorker unless you pass one. The skills delegation surface always passes one, which is how every delegation board gets an on-demand default worker; a plain taskBoard opts in.
A delegation board catches a bad assignee earlier than that. Its roster is the skill's declared agents plus the tools it allows, and addTask with an assignee naming neither returns { ok: false, error: "unknown_assignee: …" } and writes nothing, so a typo is refused at creation rather than quietly landing on the default worker. The check needs a roster to check against. A delegation board with no agents and an empty catalog has none, and neither does a taskBoard you wire yourself, so on those boards every assignee is accepted and an unmatched one takes the fallback path above.
A registry seat can also run its tasks somewhere other than the request that claimed them. See Seats that hand off.
Seats that hand off
A seat in the registry normally runs its tasks inline: the drain claims a row, runs the worker, records the result, claims the next. A seat can instead send each claimed task to a Workstream, a child session of the one draining, and move on. The drain finishes with the row still in_progress, and the Workstream settles it when the worker is done.
Both shapes below need the same board setup, and a board can mix them with inline seats.
A detached worker wraps the block: { worker, dispatch: { mode: "detached" } }. The worker runs in the Workstream exactly as it would inline, and which tasks share a Workstream is decided by the task's metadata.topic. See Which tasks share a workstream.
A dispatcher seat is a dispatcher({ type: "task" }) in the seat's position. The worker is declared once on the flow, under task.actions, and the seat names it by target:
import { defineFlow, dispatcher } from "@flow-state-dev/core";
import { taskBoard } from "@flow-state-dev/orchestration/task-board";
import { defineTaskCollection } from "@flow-state-dev/orchestration/tasks";
import { z } from "zod";
const issues = defineTaskCollection({
id: "issues",
scope: "session",
sharedToWorkstream: true,
stateSchema: z.object({ issueKey: z.string() }),
});
const board = taskBoard({
name: "issue-work",
boardId: "issue-work",
collection: issues,
workers: {
triage: triageBlock, // runs inline, in the drain
implement: dispatcher({ // hands off to flow.task.actions.implement
name: "hand-off-implement",
type: "task",
target: "implement",
session: "per-task",
}),
},
});
export default defineFlow({
kind: "issues",
actions: { drain: { block: board.drain } },
task: {
actions: {
implement: { block: implementBlock }, // what runs in the Workstream
},
},
})();
implementBlock receives the same TaskWorkerInput an inline worker would (taskId, goal, input, metadata, and so on). A task entry accepts the same fields as an action, inputSchema, concurrency, onCompleted, onErrored, and the rest, minus the client-facing description and mcp. No client can call it; the seat is the only way in.
Which Workstream a task runs in
session on the dispatcher decides, per row:
session | Workstream | Reach for it when |
|---|---|---|
"per-task" | one per task | tasks are independent |
"per-worker" | one per seat, shared by every task the seat runs | the worker should remember what it already did |
{ key: (task: TaskWorkerInput) => string } | one per distinct key | one issue across several seats, or a key you compute from the task |
The two presets fold boardId into the key, so two boards' per-task Workstreams stay apart even when their task ids coincide. A custom key is used as returned: two seats, or two boards, that return the same string share one Workstream. A key function that returns an empty string fails that task.
import type { TaskWorkerInput } from "@flow-state-dev/orchestration/tasks";
implement: dispatcher({
name: "hand-off-implement",
type: "task",
target: "implement",
session: { key: (task: TaskWorkerInput) => (task.input as { issueKey: string }).issueKey },
}),
A Workstream that runs several tasks runs them under its entry's concurrency policy. The entry a per-worker or key seat hands off to defaults to "queue", so those tasks run one at a time; a per-task seat's entry keeps the flow's default. An explicit concurrency on the entry wins:
task: { actions: { implement: { block: implementBlock, concurrency: "allow" } } },
What the board requires
taskBoard() throws, naming the board and the seat, unless all of these hold for a board with any seat that hands off (either shape):
boardIdis set. It is part of every Workstream's identity, so renaming it orphans work already in flight.- The collection is a
defineTaskCollection(). The request, sequencer, and factory backings are refused: the Workstream settles its row after the request that claimed it is gone. - A
session-scoped collection declaressharedToWorkstream: true. Without it the Workstream resolves an empty ledger and never finds its row.userandorgscope need nothing extra. - The seat is a named registry entry. A uniform
workersblock anddefaultWorkerhave no assignee to route by, so neither can be a dispatcher.
defineFlow() throws for a dispatcher seat whose target the flow does not declare under task.actions, for a task.actions entry no board hands off to, for a dispatcher({ type: "task" }) reachable from an action without sitting on a board, for two boards handing off to the same entry, and for an entry block that declares sessionStateSchema, at its root or in any composed child. Keep a handed-off worker's state on the task.
A board with any seat that hands off fixes each task's assignee at admission: setAssignee declines with reason immutable-assignee. The rule belongs to the collection, so a second board over the same defineTaskCollection value declines too.
What the drain reports
board.handedOff lists the dispatcher seats in declaration order, each with its name, label (assignee:<name>), block, and dispatch address. It is empty on a board with no dispatcher seat; detached workers are listed separately, on board.detachedWorkers.
The drain's final task-board-meta item reports terminationReason: "handed-off" when every outstanding task is running in a Workstream, with counts.in_progress saying how many. The drain returned; the work did not finish. See Termination.
The hand-off block itself returns { handedOff: true, taskId, sessionId, requestId, adopted }, where sessionId is the Workstream and requestId the run in it. The task's worker input has to survive a JSON round-trip; a payload carrying a Date, a Map, a class instance, or undefined in object position fails the task in the drain, naming the offending path. A refused dispatch fails the task through the board's ordinary error path, with the same DispatchRefusedError a dispatcher() block throws, so a .rescue() can read its refused code either way.
When the dispatch arrives, the Workstream re-reads the row and runs the worker only if the claim is still current: same attempt, same row, still in_progress, still routed to this seat. Otherwise it throws StaleTaskClaimError (code: "stale-task-claim") and writes nothing; the row stays in_progress until its lease runs out and the next drain reclaims it.
Nothing renews the row's lease while the dispatch waits in the host's queue, so a Workstream that starts more than a lease later finds a row the queue already counts as free. It takes that row back rather than refusing it: the claim is renewed on the same attempt, and the run proceeds if that write lands. It refuses only when the renewal is declined, which is the case another drain has already reclaimed the row and is running it elsewhere. The board claims with the collection's default two-minute lease and exposes no setting for it, so a deep queue in front of the Workstream costs waiting and nothing else.
The board's onError reaches the Workstream. "skip" settles the row with the error and lets the Workstream's run complete; "fail" also fails that run. See What status tells you for how that reads from the listing.
Concurrency and error handling
concurrency— max parallel workers. Default4.onError: "skip" | "fail"—"skip"records the error on the offending task; siblings continue."fail"rethrows; the board fails. Default"skip".maxAttempts(per task) — set on a task'sTaskInit, not on the board. Whileattempts < maxAttempts, a failed task is re-dispatched instead of left errored.maxTotalRetries(default50) — how many failure retries the board may authorize in total, across every task. See Bounding the retries.maxIterations— safety cap on how many times a single worker loops back to claim again, not a cap across the board. Default10000.
onError reaches a seat that hands off too, where there is no board run left to fail. "fail" fails the Workstream that worker is running in, so it reports failed. "skip" leaves it reporting completed, with the error on the task as usual. See What status tells you.
A worker's result is not always the last word on its task. A coordinator can cancel the task while the worker runs. The worker can mark the task done itself partway through. The claim can expire and another worker can pick the task up. In each case the worker comes back with a result for a task that has already moved on.
The board drops those results. A cancel stays cancelled, output the worker recorded for itself stays, and a second worker's claim is left alone. The drop is silent and affects exactly one task: the rest of the board keeps draining, and under onError: "fail" the error that surfaces is the worker's own rather than a conflict on the write-back.
A task can also keep returning to pending without ever settling. maxAttempts bounds ordinary retries, because attempts climbs on every claim until the budget runs out. The paths that re-pend a task without advancing attempts (reclaim(), unblock, resumeFromReview) never consume that budget, so if one of them runs in a loop against a worker that keeps failing, the task is re-dispatched each cycle instead of settling. A task handed back out because its worker died is not one of those paths: it is bounded by its own allowance and settles errored once that runs out.
maxTotalRetries bounds what the board spends: it counts failure retries across every task, and at the bound the next failing task settles instead of re-dispatching. maxIterations bounds how long a worker loops, per worker, including idle polls that claim nothing — at concurrency: 4 a board can spend four times maxIterations before every worker has tripped. Neither reclaim() nor unblock spends the retry budget, so on a board looping through those, maxIterations is what ends it.
Bounding how much work a board takes on
concurrency paces how many tasks run at once. It says nothing about how many can be created, so a coordinator that plans badly can queue far more work than anyone intended. The board's bounds:
maxEnqueuedTasks(default100) — how many tasks may be added while others are still waiting. Checked when a task is created, against the resultingpendingcount, so a slot comes back when its task leavespendingby completing, erroring, or being cancelled. A task that cannot run, such as one stranded behind a failed dependency, stayspendingand keeps its slot however long the board drains.maxTotalTasks(default500) — how many tasks the board may ever hold, completed and cancelled ones included. Never refunded by draining, so it also catches a board that keeps draining and re-queueing.maxTotalRetries(default50) — how many failure retries the board may authorize in total, across every task.concurrency(default4) — how many run at the same time.
Creating a task past maxEnqueuedTasks or maxTotalTasks throws a TaskCapExceededError carrying cap ("enqueued" or "total"), limit, and attempted. Nothing is written. A batch addTasks is all-or-nothing: if the batch would cross a bound, none of it lands. On a delegation board the model-facing addTask tool returns a soft { ok: false, error: "enqueued_task_cap_exceeded" } or "total_task_cap_exceeded" instead of throwing. Draining frees enqueue slots, but only for tasks that can actually run, and it gives nothing back against the lifetime bound. What a coordinator should do about each is in Delegation.
The enqueue bound applies only when a task is created. Tasks also return to pending through the lifecycle, via a retry under maxAttempts, an unblock, a resumeFromReview, or a reclaimed lease, and none of those paths is bounded. So pending can sit above maxEnqueuedTasks for a while. maxTotalTasks is the hard ceiling.
Bounding the retries
The two bounds above count tasks the board creates. A retry does not create a task, it re-runs one that already exists, so a task that keeps failing keeps costing model calls while both counts hold still. maxTotalRetries is the bound on that.
const board = taskBoard({
name: "research",
workers,
maxTotalRetries: 200,
});
It counts failure retries across the whole board. When the count reaches the bound, the next task that fails goes to errored instead of back to pending, with an error naming the board's budget, and its error reads:
worker timed out — not retried: collection "research" has spent its retry budget of 200 (maxTotalRetries). Raise it, or pass null to opt out.
The task is settled, not parked: the drain counts it as resolved and the board finishes normally. Set null for no bound at all, or 0 to run every task once and never retry. A first attempt is never refused, at any value.
Only failure retries count. reclaim(), unblock, and resumeFromReview also return a task to pending, and none of them spends the budget.
The budget is spent when a retry is granted, not when it runs. If a re-dispatched task is never picked up again because its worker died or its lease expired, the retry still counts.
On the durable (resource-backed) backing, retries are counted but the bound is not enforced. The completion item reports maxTotalRetries: null there.
Every task carries its own record of this in task.retryLedger:
const task = collection.get(id);
task.retryLedger; // { granted: 2, deniedByBudget: false }
granted is how many retries this task was authorized. deniedByBudget is true once one was refused because the board's budget was spent. The field is absent on a task that has never failed, so read it as task.retryLedger?.granted ?? 0.
When a board's completion item reports terminationReason: "retry-budget-exhausted", the budget is what stopped it:
// task-board-meta, status: "completed"
{
terminationReason: "retry-budget-exhausted",
maxTotalRetries: 200,
counts: { total: 12, completed: 9, errored: 3, retries: 200 },
}
maxTotalRetries on that item is the limit the board's collection actually enforced, and null means none was. A board whose retry count happens to equal its limit but never refused a retry reports "blocked-by-failures", the ordinary reason for a board that exited with unfinished tasks.
How long the counts last
No count is a stored counter. All three are read off the board's stored task map at the moment the bound is checked: the total is that map's size, the enqueue count is how many of its tasks are pending, and the retry count is the sum of every task's retryLedger.granted. The two creation counts are read when a task is created; the retry count is read when a task fails. All three last exactly as long as the map does, which depends on the backing:
- Request-backed (the default) — the tasks live on the request, so a new request starts empty and all three counts start from zero.
- Sequencer-backed, resumed from a checkpoint — the sequencer restores its whole state on resume, and the task map is part of that state. All three counts come back with it, retries included, so work after a resume is checked against the tasks that were already there, not against an empty board.
- Durable (resource-backed) — no bound is enforced. What the resource layer gives you instead is
maxInstancesondefineTaskCollection, and that is a capacity limit rather than a lifetime ceiling: it caps how many task instances the collection holds at once, and creating one past it throws. Deleting an instance through the resource collection frees the slot again, so a board that deletes and re-queues can create more tasks over its life thanmaxInstancesever allows at one moment. Creation here also goes one instance at a time, so a batch that crosses the limit stops partway and the tasks made before it stay; the all-or-nothing behavior above belongs to the request and sequencer backings only.
backing: "sequencer" names the shape of the state reference the tasks are stored in, not the kind of block it hangs off. Any block that holds its own state can supply one, and only a sequencer block checkpoints. See Block State → The durability boundary.
A delegation board is where the two come apart: it uses the sequencer backing, but its tasks live on the coordinator generator's own state rather than a sequencer's. It does not checkpoint, so its tasks and counts start from zero after a resume.
One writer, or hand every writer the bounds
The bounds are carried by the collection reference the board resolved. Resolving the same storage a second time gives you a different reference, and it enforces only what it was built with. So a block that calls getOrCreateTaskCollection itself, against a board's collectionId, writes past the board's bounds unless it is given them:
const board = taskBoard({ name: "research", workers });
// This second reference is unbounded, even though the board has bounds.
const loose = await getOrCreateTaskCollection({ ctx, backing: "request", collectionId: "research" });
// Hand it the board's own resolved bounds and it enforces them.
const bounded = await getOrCreateTaskCollection({
ctx,
backing: "request",
collectionId: "research",
...board.caps,
});
board.caps is on the handle for exactly this. Most code never needs it: reaching the board through board.capability (or letting the board's own seed and drain do the writing) is already bounded. It matters when you resolve the collection yourself.
createApplyReplan is one of the blocks that can land on either side of that line, and it takes two shapes:
- With
capability: board.capability, it reads and writes through the board's own reference, so the board's bounds apply. - With only
name, it resolves a request-backed collection under that id and enforces no bounds, because nothing in its options identifies which board it is writing to.
Pass the capability when you want replanned tasks to respect the board's bounds. The bundled patterns do.
Where the bounds apply
The bounds belong to the collection, so the board applies them only to a collection it builds itself: the request default and the sequencer opt-in. Per the previous section, they also reach only writers that go through the board's own reference. If you supply a collection (a defineTaskCollection, or a factory), the board applies nothing and checks nothing; that collection carries whatever bounds it was built with and stays the sole authority. Passing the cap options alongside a supplied collection throws at taskBoard() construction, because a board cannot retrofit limits onto a collection it did not construct. Configure them where the collection is created instead. Here that is a block running inside the sequencer that owns the tasks slot, so ctx.sequencer is that container:
const tasks = await getOrCreateTaskCollection({
ctx,
backing: "sequencer",
collectionId: "my-board",
sequencer: ctx.sequencer!,
maxTotalTasks: 2000,
});
Which state ref to pass depends on where your code runs, and getting it wrong fails quietly rather than loudly: you get a working collection over the wrong slot. From a block inside the sequencer, pass ctx.sequencer. From a tool running as a child of a generator that owns the board, pass ctx.parent (see wiring a bounded board by hand).
The cap options exist on the sequencer and request backing specs only. Passing maxTotalTasks or maxEnqueuedTasks with backing: "resource" is a TypeScript error, not a ceiling that quietly does nothing.
If the defaults are too low for your board
A board that needs to create more than 500 tasks in a run, or hold more than 100 pending at once, is refused the task that crosses the line. Raise the bound, or turn it off with null:
// Raise it.
const board = taskBoard({ name: "big", workers, maxTotalTasks: 5_000 });
// Or opt out of one axis entirely.
const unbounded = taskBoard({ name: "streaming", workers, maxEnqueuedTasks: null });
Omitting an option is not an off switch; it reapplies the default. null is the off switch. Otherwise each option takes a positive integer, and 0, a negative, a fraction, NaN, Infinity, or an enqueue bound above the lifetime ceiling all throw when the board is constructed.
Stream items emitted
A board run produces two item streams:
task-change— one item per task transition (added,claimed,completed,errored,cancelled, and more). Keyed by${collectionId}/${taskId}, so the latest change for a task replaces the previous one.task-board-meta— board-level state, keyed bycollectionId. Emitted twice per run, once withstatus: "active"at start and once withstatus: "completed"at end. The completed item carriesterminationReasonand thecountssnapshot.
Renderers like <TaskPlan /> subscribe to both: task-board-meta for the board-level status header, task-change for per-task rows.
Commanding the board with its capability
You pick where a board stores its tasks once, on taskBoard({...}). After that, the only thing other blocks touch is board.capability. List it in a block's uses and the board's tasks are on ctx.cap.<name>, the board name verbatim. Hyphenated names work through bracket access (ctx.cap["my-board"]).
const board = taskBoard({ name: "research", workers });
const enqueue = handler({
name: "enqueue-more",
inputSchema: z.unknown(),
uses: [board.capability],
execute: async (_input, ctx) => {
await ctx.cap.research.addTask({ goal: "check competitors" });
const open = await ctx.cap.research.countTasks({ status: "pending" });
return { open };
},
});
The accessor has addTask, addTasks, getTask, listTasks, countTasks, and tasks() (the full TaskCollectionRef when you need a method the sugar doesn't cover).
A sibling or outer step can add tasks before board.drain runs, and the board picks them up on its first pass. It can also add them while the board is draining: an idle worker takes the new task promptly rather than waiting out its poll interval. Both work on all three backings, as long as the add and the drain happen in the same request.
Each sugar call re-resolves the collection, so reads always reflect the latest state. That costs something per call on every backing. When you need several reads in a row with no writes between them, grab the ref once with const tasks = await ctx.cap.<name>.tasks() and read from it.
Collection backing
A board stores its tasks in one of three places. You choose once; nothing downstream restates it.
- Request (default) — tasks live on
ctx.requestand survive every block boundary in the request, including re-entry across an outer loop (Plan and Execute replans this way) and adds from sibling steps before or during the drain. Omitcollectionentirely, or pass{ collectionId }to name it (the id defaults to the board name). - Durable (resource-backed) — tasks outlive the request. Declare the collection with
defineTaskCollectionand pass it ascollection; the board registers and resolves it for you. Don't count on a running request seeing a write made by another request; a later request reads it. - Sequencer — tasks live on the board's own sequencer state, which lasts one
board.draininvocation. Opt in with{ backing: "sequencer", collectionId }. Calling the board twice gives two independent collections.
// Request default — nothing to restate.
const board = taskBoard({ name: "research", workers });
// Sequencer opt-in — single-invocation, per-call state.
const board = taskBoard({
name: "one-shot",
collection: { backing: "sequencer", collectionId: "one-shot" },
workers,
});
For a custom or externally-managed store, pass a factory (ctx) => TaskCollectionRef as collection.
If you write that ref by hand, complete and fail should accept and honor the optional TaskTransitionOptions third argument. TypeScript won't catch it if you don't: a two-argument complete(id, output) satisfies the interface structurally, and JavaScript drops the extra argument without a word. The board passes those options on every write-back, so a result landing on a task someone else already settled is declined rather than thrown.
A ref that ignores them throws instead, and the board contains that throw: it drops the late result and keeps draining. One misbehaving write-back costs one task, not every task the board hadn't claimed yet.
Containment is not a substitute for the guards, though, and it is worth being clear about why. It fires on a throw. A stale write the state machine happens to permit — a worker reporting success on a task another worker has since taken over — doesn't throw. It commits, and it overwrites the result the current holder is about to record. Nothing outside your store can catch that, because the decision belongs inside the write. So honor the guards for the sake of your data; the board's survival is already covered. See recording a result that may no longer apply.
Write provenance is the one part you can skip. Maintaining it correctly means reproducing a bounded receipt log and its eviction flag, and the mutator that does that is internal to the two built-in backings — not a documented extension point today. A hand-written ref that leaves revision, writeLog, and writeLogTruncated unset is not wrong for it: callers asking whether their write landed get undefined, which means "cannot tell", not "your write did not land".
Durable boards that survive across turns
When a board's tasks must persist past the request, say a user's standing to-do list or an org-wide work queue, declare a durable collection with defineTaskCollection and hand it to the board. The tasks live as resource instances at the scope you name (session, user, or org).
import { taskBoard } from "@flow-state-dev/orchestration/task-board";
import { defineTaskCollection } from "@flow-state-dev/orchestration/tasks";
import { z } from "zod";
const todos = defineTaskCollection({
id: "todos",
scope: "user",
stateSchema: z.object({ topic: z.string() }), // the task `input` payload
});
const board = taskBoard({ name: "todos", collection: todos, workers });
id names the collection (it forms the resource pattern and the board's collectionId), scope sets its lifetime, and stateSchema types each task's input payload. The rest of the task envelope is validated for you. The board installs the collection on both its own drain and board.capability, so a sibling action that lists board.capability in uses reads and writes the same durable tasks.
See also
- Configuration — every
taskBoardfield, including defaults. - Task substrate — the
Taskrecord, the status state machine, and the collection API underneath. - GoalSeekLoop — a config-driven, judge-gated loop over the board's drain.
- Block State — the primitive behind the board's sequencer-scoped task collection; see The durability boundary for what survives a resume.
- Parallel Tasks — single-pass fan-out wrapper on top of the board.
- Supervisor — per-task review wrapper.
- Plan and Execute — replan-loop wrapper.
- Flow policy — the observation ledger,
priorWorkshaping, and tool-result caching. - Patterns Overview — when to use which pattern.