@flow-state-dev/orchestration
v0.3.0
Published
Orchestration substrate for flow-state-dev: task collections, dispatchers, the task-board primitive, and the skills runtime.
Maintainers
Readme
@flow-state-dev/orchestration
The orchestration substrate for flow-state-dev. One package, three layers that build on each other:
- Task substrate — the
Taskschema and state machine, the storage-agnosticTaskCollection, and the dispatcher catalog. - Task board — a concurrent drain over a
TaskCollectionwith dependency gating and per-task worker routing. The orchestration primitive thatsupervisor,parallelTasks, andplanAndExecute(in@flow-state-dev/patterns) are built on. - Skills — user-editable
SKILL.mdfolders injected as inline instructions, optionally with anagents:field that installs a private delegation board, thetaskToolssurface, andrunBoard— the skill assigns work as tasks and drains the board.
Layering: core → orchestration → patterns. This package depends only on
@flow-state-dev/core and never imports from patterns or workforce.
pnpm add @flow-state-dev/orchestrationTask substrate
import { taskSchema, type Task } from "@flow-state-dev/orchestration";A Task is the unified work-unit record: id, goal, status, deps, lease,
attempts, retryLedger, optional typed input/output. Status enum:
pending | in_progress | blocked | parked | completed | errored | cancelled.
pending ─┬─→ in_progress ─┬─→ completed
│ ├─→ errored
│ ├─→ in_progress (claim, after a lease runs out)
│ ├─→ pending (reclaim)
│ ├─→ cancelled
│ └─→ parked ─┬─→ completed
│ ├─→ errored
│ ├─→ pending (unpark)
│ └─→ cancelled
├─→ blocked ─┬─→ pending (unblock)
│ └─→ cancelled
└─→ cancelledcompleted, errored, and cancelled are terminal. A move to the status a task
already holds is on the table, so a repeat write doesn't throw. Anything else
throws an IllegalTaskTransitionError carrying taskId, from, and to, and
writes nothing.
retryLedger records the task's standing against the collection's
maxTotalRetries budget:
task.retryLedger; // { granted: 2, deniedByBudget: false } | undefinedgranted counts the failure retries this task was authorized, so it excludes
the re-entries that do not spend the budget (unblock, unpark,
reclaim) and includes a retry that was granted but never picked up.
deniedByBudget turns true once a retry was refused because the collection's
budget was spent; the board's terminationReason reads that flag, so branch on it
rather than parsing the error string.
The field is absent on a task that has never failed, so read it with a single
guard — task.retryLedger?.granted ?? 0 — and treat absent as zero granted, not
denied.
TaskCollection
getOrCreateTaskCollection resolves a TaskCollectionRef over one of
three backings — request-state (the taskBoard default; survives block boundaries
within a request), block-scoped state (per board invocation — backing: "sequencer"
is the common case), or resource-collection (outlives the request: a user's queue, an
org work pool — declare one with defineTaskCollection). Every mutation that
changes a field emits a task-change component item.
Server-only task fields. A task-change item carries the whole post-mutation
row, and that stream is client-visible. A few fields on Task are substrate
bookkeeping that must not reach a browser — claimedBy, the execution coordinate a
claim records — so the factory omits them from the emitted payload via
toEmittedTask, which honours the exported SERVER_ONLY_TASK_FIELDS set. If you
wire a backing's onChange to a client transport yourself, apply toEmittedTask to
event.task before you publish it — a field that is server-only on one boundary is
server-only on every one.
How far claim safety reaches. Both backings are compare-and-swap with retry. The
state backings mutate through atomicState; the resource backing mutates through
ResourceRef.updateState, which chains writes per key within one execution context
and then persists at the version that context read. A claim written against a
stale read is refused and re-applied against the state that won, rather than
overwriting it. Two workers contending for one task cannot both land a write.
The filesystem store is safe inside one process. A durable collection shared across replicas needs SQLite or Postgres. A stale write is refused, not overwritten.
Freshness is scoped to one request. Every ref resolved over the same collection inside a request sees the same tasks, so a task added through any of them is immediately visible through all of them — including a ref a task-board worker resolved before it went idle. On the resource backing, don't rely on a running request seeing a write made by another request. A later request reads it.
Removals reconcile when you resolve. A task the running request removed (an explicit
delete on the resource collection, or a capacity eviction) stops being reported
from the next resolution onward. A ref you are already holding keeps reporting such
a task until it resolves again.
Every lifecycle transition — complete, fail, block, unblock, awaitReview,
unpark, cancel — takes an optional trailing TaskTransitionOptions
argument that makes the write advisory. ifAllowed skips the write when the task is
already settled, or when the transition is one the state machine or the calling verb
refuses. A legal status transition is necessary but not sufficient: a verb that owns
one edge runs only from that edge's source status. unblock runs on a blocked task
and no other, and unpark on a parked task and no other. Like cancel, unpark
declines without options: disallowed for a live task, terminal once settled, and a
second answer to an already-queued task is refused. in_progress → pending sits in the status table too,
but it belongs to reclaim(). claim takes a
TaskClaimTicket (mint one with ticketForClaim(collectionId, claimedTask)) and
skips the write unless the task in front of it is the one that ticket was issued
for, still on that attempt, holding a lease that has not run out. A guard cannot be
raced: the task cannot change between
the check and the write. A declined write is skipped and never throws; the call
reports it on the returned TaskWriteOutcome. Omit the argument and the methods
throw on an illegal transition.
A TaskCollectionRef you write yourself is a supported extension point. The
substrate's own write-backs contain a throw they can attribute to a decline a
conforming store would have made before committing: the late result is dropped and
the drain continues. So a board keeps draining even against a ref that drops the
options argument — a two-parameter complete(id, output) satisfies the interface
structurally. The guarantee is board survival, not equivalence. It fires on a throw,
so a stale write the state machine happens to permit still commits and still
clobbers, and no error a store never raises can be contained. Everything else —
a store outage on a task the worker still holds, a write that committed and then
failed on the way out — propagates unchanged. When the task was already settled
or displaced before the call, the seam cannot tell an outage apart from the decline
a conforming store would have made, and contains it. Don't build an alerting path
on an error the substrate may drop.
Every mutation method except claim and reclaim resolves to a
TaskWriteOutcome: recorded (a field changed and a task-change item was
emitted), unchanged (the task already held the state asked for, nothing written),
or declined with a reason (immutable-assignee / terminal / not-my-task /
disallowed / parked / lost-claim, resolved in that precedence order) and the
status the task was in when the write was refused. immutable-assignee means the
board hands rows off to a child session, where the assignee is fixed at admission; not-my-task
means the ticket names a different task, a different collection, or an id since
reused; parked means the caller passed refuseWhenParked and the task is in
parked — nobody took it, so unlike lost-claim the answer is not to
re-claim and redo; lost-claim means it names the right task but a claim that has
moved on. A
decline never throws, and discarding the return value is supported. cancel is
advisory whether or not options are passed: cancelling a settled task declines
with reason terminal.
setAssignee is the one field mutator that refuses anything — it declines on a
terminal task, and on a durable collection any hand-off board draws from it declines
every reassignment whatever the task's status (see "Handing tasks off through a
dispatcher seat" below).
setPriority, addLabel, removeLabel, and patchMetadata write to
a terminal task, so a post-drain failure audit can label what went wrong; those four
answer only recorded or unchanged (patchMetadata merges rather than compares,
so it answers recorded even for a no-op patch). unchanged is a statement about
the task record — on a resource backing the write reaches the resource either way,
so a resource_change can fire for an unchanged write. A missing task throws on
all eight.
A verdict only reaches a call that returned. For the path where a write commits
and the call then throws, pass write: beginTaskWrite(collection.get(id)) on
the same options argument and ask didWriteLand(collection.get(id), write)
afterwards. It answers true (committed), false (changed nothing), or
undefined (cannot tell — no provenance on the record, the token names a
different incarnation of the task (delete/recreate under the same id), or the
receipt aged out of the task's four-entry log). Mint the token before the write; report undefined
rather than guessing. Correlation is available on the seven methods that take
TaskTransitionOptions. setAssignee — the eighth method able to decline, above
— takes no options object at all, so it and the other four field mutators advance
task.revision but carry no token. A hand-written TaskCollectionRef that
maintains no provenance leaves its callers with undefined, never a false false.
import { getOrCreateTaskCollection } from "@flow-state-dev/orchestration";
const collection = await getOrCreateTaskCollection({ ctx, backing: "request", collectionId: "plan" });
await collection.addTask({ id: "research", goal: "research the topic" });
await collection.addTask({ goal: "draft the post", deps: ["research"] });Leases and recovery
A claim carries a lease: how long the claimant may be gone before the task is
handed to somebody else. A worker the substrate drives pushes that deadline out
while it runs (renewLease), so a lease that runs out means no worker is
renewing it, and the next claim on any host takes it back as a fresh attempt.
Nothing to schedule.
ClaimOptions.leaseDurationMs is the knob, defaulting to two minutes. Shorter
means a stranded job comes back sooner and a merely-slow worker is likelier to
lose its task; longer means the opposite. Values under a second, over about 74
days, or non-finite throw rather than being rounded into range.
Recovery is bounded at three re-dispatches, after which the task settles
errored. Three is fixed, and exported as DEFAULT_MAX_ABANDONMENTS. That
allowance is separate from maxAttempts.
A task you claim by hand gets no renewal. You hold it, so you call
renewLease(id, deadline, { claim }) yourself, or size the lease to cover the
work. Two helpers do it for you: withLeaseRenewal wraps work that is a single
call, and startLeaseRenewal returns a { signal, stop } driver for work that
spans several steps. Both renew in the background while the work runs, keep one
renewal in flight at a time, and stop when the signal you hand them aborts.
Both also give you a second signal that aborts when a renewal comes back
declined. withLeaseRenewal composes it with the signal you passed and hands
run the result; startLeaseRenewal exposes it bare as driver.signal, so
compose that one yourself. Loss is detected at a renewal rather than the instant
it happens, and one goes out every third of the lease, so the signal can lag a
lost claim by about 40 seconds on the two-minute default. It is there to stop you
paying for work you can no longer record; the fence on the settling write is what
keeps the result correct.
Settle the task inside withLeaseRenewal's run: it stops renewing the moment
run returns, so a complete() or fail() issued after it is a fenced write
on a lease nobody is keeping alive.
A claimant that has not started yet can take a lapsed row back.
renewLease(id, deadline, { claim, adoptLapsedLease: true }) renews a row whose
lease has already run out, as long as the ticket still names it and the attempt
is still the row's. That is the queued-worker case — a task handed to a child
that waited longer than the lease with nobody renewing it — and the takeover is
decided by the write: it declines lost-claim the moment a reclaim has moved the
row on, and stays on the same attempt when it lands. It is opt-in because the
substrate cannot tell that caller apart from a stalled worker whose renewal fired
late, and only the first should take the row back. The flag applies to renewals
only; a settlement on a lapsed lease is still declined.
Renew it for committedLeaseSpan(task) — the duration that claim committed to,
which the claim writes onto the row (leaseDurationMs) rather than leaving to be
inferred from leaseUntil - updatedAt. Those two stamps stop agreeing as soon as
anything else writes to the row: setPriority, the label verbs and
patchMetadata all move updatedAt and leave the deadline alone. The renewal
driver reads the same function, so a task's cadence and its takeover cannot
disagree about one lease. A row claimed before that field existed falls back to
the subtraction.
A worker composed as several steps reaches its driver through
currentLeaseRenewal(). Wrap the block that claims the task in
withLeaseRenewalScope(async () => { … }) — as its first statement, before
any await — and call stampLeaseRenewal(driver) inside it once the driver
exists. The scope rides AsyncLocalStorage, which only propagates to later
steps when it is entered before the claiming block awaits anything; stamping
with no scope open throws rather than publishing to nobody.
(openLeaseRenewalScope() is the same thing without the guarantee below, if you
want to manage the failure path yourself.)
The wrapper stops the driver if that block throws after the claim commits. That
window has no other cover: the step that runs the work never starts, so no
recorder and no onSettled fires, and a failed request does not abort its own
signal — the lease would be renewed for a task nobody is working until the host
died, and a live lease is exactly what stops claim() recovering it.
Stop the driver after your fenced write, not before it. complete() and
fail() are fenced on the claim, and the fence refuses a write on a lapsed
lease, so stopping first can get a healthy worker's finished result refused and
its work redone. A renewal in flight across the settlement is harmless: it
writes only leaseUntil, and on a settled row it is declined and the driver
stops itself.
isClaimable(task, lookup, now) is the substrate's admission rule, exported so
a custom TaskCollectionRef can read it rather than restating it. It answers
admission only — whether an admitted row is handed out or settled because its
allowance is spent is claimDisposition(task, now, max), exported alongside it.
Anything that reports which task runs next needs both; anything asking only
whether there is work to do needs the first. Compare leases against
collection.now(), not Date.now(): it is the clock that stamped leaseUntil.
Dispatchers
fifoDispatcher, topologicalDispatcher (the default), priorityDispatcher,
classifierDispatcher({ classify }), and eventDispatcher({ topicFor, topic }). None of
them claims a task whose deps aren't all completed. That eligibility rule lives
on collection.claim, and a dispatcher's own eligibility narrows it, so they
differ only in ordering. fifoDispatcher and
topologicalDispatcher are ordered identically; priorityDispatcher takes the
highest priority first, ties on createdAt.
Task board
import { taskBoard, taskWorkerInputSchema } from "@flow-state-dev/orchestration/task-board";taskBoard({ name, collection, workers, ... }) returns
{ drain, collectionId, capability, backing, boardId, handedOff, hasIdlessInitialTasks, caps }.
Mount board.drain in a sequencer. hasIdlessInitialTasks is true when any
initialTasks entry omits an id; an idless seed re-adds on every drain, which is
why goalSeekLoop rejects such a board when maxIterations > 1. workers is a
single uniform worker or a
{ [assignee]: block } registry; each task's assignee routes it. Config:
defaultWorker (optional fallback for a task whose assignee is unmatched or
omitted — reached only on a miss, declared workers untouched),
concurrency (default 4), dispatcher (default "topological"),
onIdle ("complete-or-blocked" default | "complete" | "wait"),
onReview ("hold" default | "exit" — whether a task parked with awaitReview
keeps the drain open, or is excused from the in-flight counts so the drain returns
and leaves it parked for a later one to claim once it is resumed; "exit" needs a
defineTaskCollection collection, the default onIdle, and ids on initialTasks,
and is refused at construction otherwise; board.unparkAndDrain takes { taskId, feedback },
re-queues the parked task, and drains in the same request, returning the write outcome
and draining only when it was recorded), initialTasks,
onError, maxIterations (per-worker claim-loop cap, default 10000), the two creation caps
maxEnqueuedTasks (default 100 — tasks addable while others are pending,
refreshes on drain) and maxTotalTasks (default 500 — lifetime count incl.
terminal, never refunded), and the retry budget maxTotalRetries (default 50 —
failure retries the board may authorize across every task). The creation caps take a
positive integer or null (explicitly unbounded); maxTotalRetries takes a
nonnegative integer or null, so 0 means "run every task once, never retry".
Omission reapplies the default on all three. They apply only when the board
constructs its own collection — a supplied collection is left alone and passing
any of them is a construction error, so configure caps on
getOrCreateTaskCollection's sequencer/request backing instead. Per-task retries
are set via maxAttempts on each task (TaskInit), not on the board. At the retry
budget the failing task settles terminal errored and the board's completion item
reports terminationReason: "retry-budget-exhausted" alongside counts.retries and
the limit in force. See the
Task board guide.
When the board cannot record a result. Saving a result is a commit followed by a
change announcement, and the announcement can fail on its own. The board's two
recorders correlate every result write (beginTaskWrite / didWriteLand, above) and
report one that landed — or that they cannot account for — on a persisted
task-board-recorder-failure component item carrying { collectionId, taskId,
recorder: "complete" | "fail", verdict: "committed" | "undetermined", error, runId? }.
The drain then fails the run, after every other task has finished, with a
TaskBoardRecorderFailureError (code: "task-board-recorder-failure") naming every
affected task; on a handed-off row, which has no batch to drain, the failure fails that
child run instead. onError is not consulted on this path — it is a policy about a task
going wrong, not about the board's bookkeeping. A write that demonstrably committed
nothing is unchanged and still takes the ordinary error path. verdict: "undetermined"
is the permanent answer on a caller-supplied TaskCollectionRef, and on rows that
predate write provenance; such a row is also released rather than left claimed. If the
report itself cannot be emitted, the run fails immediately with a
TaskBoardReportFailureError instead. task-board-recorder-failure is excluded from
per-task item attribution — it is the substrate's, not the worker's — and is suppressed in
chatAssistantRenderers; an app with its own renderer registry should add
component: { "task-board-recorder-failure": false }, as it already does for task-change.
The recorders themselves (createRecordSuccess / createRecordError) raise a recorder
failure by default; only a composition that supplies a tail able to read the report — which
taskBoard()'s drain does — passes recorderFailure: { onRecorderFailure: "defer" }.
Handing tasks off through a dispatcher seat
A seat under workers is a block. Put a dispatcher({ action, session })
in that position and the seat hands its tasks off: the drain claims a row, sends a
task dispatch to the flow's task.actions[action] entry, and moves on, while the
entry's block runs in a child session of the session that drained — on a request
of its own — and settles the row itself. Any other block in that position runs
inline in the drain. The worker is declared once, on the flow, exactly like an
action. A seat holding anything other than a block (an object such as
{ worker, dispatch } or { block, session }) is refused by name at construction.
import { defineFlow, dispatcher } from "@flow-state-dev/core";
import { defineTaskCollection } from "@flow-state-dev/orchestration/tasks";
import { taskBoard } from "@flow-state-dev/orchestration/task-board";
import { z } from "zod";
const issueLedger = defineTaskCollection({
id: "issues",
scope: "session",
sharedToLineage: true, // the child session addresses the same rows
stateSchema: z.object({ issueKey: z.string() }),
});
const board = taskBoard({
name: "issue-work",
boardId: "issue-work", // required once any seat hands off
collection: issueLedger, // must be a defineTaskCollection()
workers: {
triage: triageBlock, // runs inline
implement: dispatcher({ // hands off to flow.task.actions.implement
name: "hand-off-implement",
action: "implement",
session: "per-task",
}),
},
});
export default defineFlow({
kind: "issues",
actions: { drain: { block: board.drain } },
task: { actions: { implement: { block: implementBlock } } }, // what runs in the child
})();session decides which child session a seat's rows run in:
| session | Child session | Use it when |
|---|---|---|
| "per-task" | one per row, keyed on the task id | rows are independent |
| "per-worker" | one per seat, shared by every row the seat runs | the worker should remember what it already did |
| { key: (task: TaskWorkerInput) => string } | keyed on what the function returns, verbatim | one issue across several phases, or a key shared across seats |
The presets include the board id in the key, so two boards' per-task children stay
apart even when their task ids coincide. A custom key is used as returned, so two
seats (or two boards) that return the same key share one child. A child that runs
several rows runs them under its entry's concurrency policy. An entry a per-worker
or key seat hands off to defaults to queue, so the rows run one at a time; a
per-task seat's entry keeps the ordinary default (the flow's request.concurrency,
else allow). An explicit concurrency on the entry wins:
task: { actions: { implement: { block: implementBlock, concurrency: "allow" } } },Only a named seat hands off: defaultWorker and a uniform workers block have no
seat name and so no assignee to route by.
defineFlow refuses a hand-off whose entry the flow forgot to declare, a task
entry no board hands off to, a task dispatcher no board holds, and two boards
handing off to one entry. board.handedOff lists the seats that hand off, in
declaration order.
The task dispatch carries the claim's identity (boardId, taskId, attempt,
createdAt, incarnationId) and the worker input the drain packed at claim time —
taskDispatchInputSchema / TaskDispatchInput. That input has to be
JSON-serializable; a payload that is not fails the row in the drain. When the dispatch
arrives, the entry re-reads the row and runs the worker only if the claim is still
current: same attempt, same row (not deleted and recreated under the same id), 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. On the drain
side the hand-off block returns { handedOff: true, taskId, sessionId, requestId,
adopted }, and a refused dispatch fails the row through the board's ordinary
error path, throwing the same DispatchRefusedError (with its refused code)
that a dispatcher() block throws. The child settles its own row, and the board's onError reaches it:
"skip" settles the row and lets the child's request complete, "fail" also fails
that request. The one thing onError does not govern is the board failing to record
what it saved — see the recorder-failure note under
taskBoard() — which fails the child run under either setting.
taskBoard() refuses a board that hands off unless all of these hold, naming the
board and the fix:
boardIdis declared. Everytaskdispatch names it, so renaming it orphans children already in flight.- The collection is durable — a
defineTaskCollection()resource backing. The request, sequencer, and factory backings are refused: a handed-off row outlives the request that claimed it. - A session-scoped collection declares
sharedToLineage: true, or the child would resolve an empty ledger and never find its row.userandorgscope need nothing extra; they already span every session the principal touches.
defineFlow adds one more: no handed-off entry block declares
sessionStateSchema, at its root or in a composed child. Keep the block's
state on the task.
A board that hands anything off fixes each task's assignee at admission: the seat
name is what a row is routed by, so setAssignee declines with reason
immutable-assignee. The rule belongs to the collection rather than the board, so a
second board over the same defineTaskCollection value declines too. File a new row
rather than reassigning one.
A claim carries a lease (two minutes by default), and nothing renews it between the hand-off and the child's first step. A child that starts after the lease has lapsed takes the row back before it runs: it renews the lease against the claim it was dispatched with, on the same attempt, and proceeds when that write lands. It refuses only when the renewal is declined, which is the case another drain reclaimed the row first. So a deep queue in front of the child costs nothing, and a genuine reclaim is still the successor's to run. The board's lease is not configurable.
On serverless without a queue adapter, the child runs inside the invocation that started it and is bounded by that function's maximum duration. With a queue adapter it moves to a worker process and is not.
goalSeekLoop
import { goalSeekLoop } from "@flow-state-dev/orchestration/task-board";goalSeekLoop({ name, board, seed?, judge, maxIterations, finalize?, ... }) wraps a
board's drain in an outer, judge-gated loop: seed → drain → judge → (replan) →
repeat → finalize. The judge returns a three-way Verdict
(done/continue/replan); maxIterations is a mandatory finite backstop, and
the loop lands with a typed goal-seek-loop-termination item rather than hanging.
The board must be request- or resource-backed. parallelTasks and planAndExecute
are expressed on it. See the GoalSeekLoop guide.
Skills and delegation
A skill is inline instructions injected into a generator's prompt. Bind skills to
one generator with createSkillsLibrary — no shared bag, no cross-agent bleed —
and configure the binding where the generator is defined:
import { createSkillsLibrary } from "@flow-state-dev/orchestration";
const skills = createSkillsLibrary({ catalog, initialSkills });
// Preload a skill, fails loud on a typo:
generator({ uses: [skills.with({ active: ["detailed-analysis"] })] });
// Let the agent load a skill mid-turn, stored in this generator's block state
// (the binding installs the block-state field for you):
generator({
uses: [skills.with({ allowed: ["deep-research"], dynamicActivation: true })],
});initialSkills also takes a function of the execution, for a catalog that
belongs to the flow copy rather than to the definition — two registered copies
then seed two different sets. Pair it with collectionConfig: { flowIsolation: true }
so each copy reads its own storage. The resolver runs before every step of every
turn, so keep it to an O(1) read of something already resolved; and because there
is no build-time catalog under one, binding a skill by name (active / allowed)
is refused rather than left unvalidated.
Seeding writes a copy, so a later edit to the source does not reach a catalog
that already holds the skill. refreshSeededSkills(collection, sources) is the
deliberate act that pulls one through. It replaces a touched skill's folder whole,
so a supporting file the source has dropped is deleted rather than left reachable;
it skips a name whose manifest is gone, so a deletion stands; and ordinary seeding
stays additive.
importSkillsDirectory({ overwrite: true }) is not the same thing, and picking
the wrong one is how stale instructions survive. Both write a source skill over
one that is already there, but overwrite enumerates nothing — it writes the
files the source has and leaves everything else, so a supporting file the source
has since dropped stays in the folder and stays resolvable through
prompt-ref. refreshSeededSkills prunes it. Reach for overwrite when you mean
"write these on top" (a migration, a test fixture); reach for refresh when you mean
"make this match the source".
A skill that declares an agents: field turns on delegation (or force it on
with delegation: true even with no agents:). An agent is a prompt-driven
teammate — defined inline (prompt / prompt-ref) inside the skill, or referenced
from the registry (agent-ref).
The board's other seats are the skill's tools, and nothing declares them: every
key in its allowed-tools (or the whole catalog, when it declares none) is
assignable by that key. The board calls the tool directly with the task's input
as its arguments — no model turn — and records what it returns. A tool task gets
dependency ordering from deps but not an upstream task's output; a step that must
read one is an agent.
Those seats come from the skill, which is the wrong owner when the host did not
choose the skills it holds. toolSeatFence is the host's ceiling: return the keys a
board worker may be seated with for this execution, and the seats are narrowed to
them — an empty array means none. It only narrows, and it leaves the declared agent
roster alone.
A binding that contributes the catalog contributes all of it, never the subset a
skill's allowed-tools names. It contributes the catalog when it preloads skills
(active), when it installs the load tool (dynamicActivation), and when it
pairs activeState with allowed; an activeState binding with neither
contributes no catalog tools at all. registerCatalogTools: false turns that
grant off while leaving the validation on, for a host that owns tool registration
itself; pair it with toolSeatFence so a held skill's delegated workers cannot
reach past the same fence.
Every delegation board also gets an on-demand default worker: it materializes on demand and runs any task whose assignee is unset, so a task with no named agent still runs, and an empty roster still delegates.
Every tool that writes an assignee checks it: addTask, assignTask,
and updateTask reject a name that is neither a declared agent nor an assignable
tool, returning the available ones so the caller can correct it, instead of letting a
mistyped name fall through to the default worker at drain time. A board with no
agents and an empty catalog has no roster to check and accepts any assignee.
Binding the skill installs a private
task board (own-state, scoped to that generator), the eight taskTools (addTask,
assignTask, completeTask, failTask, blockTask, cancelTask, updateTask,
listTasks), runBoard, and a guidance context. The generator orchestrates by
planning a graph with addTask (assignee, deps, structured input) and calling
runBoard once: the board drains under concurrency with dependency gating and
returns every task's output. There is no per-agent tool the generator calls
directly; draining the board is the sole execution path. Agents materialize at
runtime, so agent-ref agents resolve through the library's
agentRegistry/materializeAgent options and runtime-activated skills contribute
their tools too. With no delegation board resolvable, a stray taskTools call
returns { ok: false, error: "no_delegation_board" } rather than throwing.
The board is bounded by default: addTask is refused past 100 tasks enqueued at once
({ ok: false, error: "enqueued_task_cap_exceeded" } — drain with runBoard to
free slots, though tasks stranded behind a failed dep stay pending and hold
theirs) or 500 over the board's lifetime (total_task_cap_exceeded, never
refunded by draining), tunable via createSkillsLibrary's maxEnqueuedTasks /
maxTotalTasks (null = unbounded). It carries no retry budget: a task created
through the delegation addTask tool takes no maxAttempts, so it runs once and
never retries.
Which surfaces are capped. The caps come from the code that CONSTRUCTS the collection, so they cover boards the skills library installs and boards
taskBoardbuilds itself — not the capability surface on its own. Wiring the exportedtaskToolssingleton by hand (uses: [taskTools]) resolves the host generator's own-state board through a bare, uncapped collection:addTaskthere is unbounded. For a bounded board on that path, build the collection yourself withgetOrCreateTaskCollection({ …, maxTotalTasks, maxEnqueuedTasks })and hand a resolver for it tocreateTaskToolsCapability(resolver). That resolver must target the host generator's own state viactx.parent(each tool runs as a child block, soctx.sequenceris the wrong container) and name the board'sstateKey— see Delegation for the full recipe.
Every taskTools call reports a problem the same way. A status change the
task's current status does not permit is a recoverable tool result too, not a
throw: completeTask on a task that was never started answers
{ ok: false, error: "illegal_status_transition: …" }, naming the task's current
status and the calls actually available from it. So the recoverable set across
the eight tools is no_delegation_board, task_not_found, unknown_assignee,
enqueued_task_cap_exceeded, total_task_cap_exceeded,
illegal_status_transition, and terminal_task_write_declined — a coordinator
rule like "when a tool returns ok: false, re-plan" covers all of them.
Match those by prefix, not equality. no_delegation_board, task_not_found,
enqueued_task_cap_exceeded, and total_task_cap_exceeded are the whole error
string, but unknown_assignee, illegal_status_transition, and
terminal_task_write_declined are followed by : and a sentence of guidance for
the model, so error === "illegal_status_transition" never matches. Use
error.startsWith("illegal_status_transition"). There is no separate structured
code field today.
Only the tool boundary translates a refusal into a result. Driving a collection directly throws, and the error is an exported class you can catch:
import { IllegalTaskTransitionError } from "@flow-state-dev/orchestration";
try {
await collection.complete(taskId, output);
} catch (err) {
if (err instanceof IllegalTaskTransitionError) {
// err.taskId, err.from, err.to — the refused move.
}
throw err;
}Catch it by type, not with a blanket catch: a CAS conflict, a scope-mutation
timeout, or a storage failure is not a task-state problem and should keep
propagating.
The one exception is a call that asked for it. complete and fail accept the
advisory options described under TaskCollection above, and a
refused transition on such a call is a returned declined verdict rather than a
throw, so it never reaches this catch.
At the delegation taskTools boundary a declined verdict does become a tool
result: assignTask, cancelTask, and an updateTask carrying an assignee answer
{ ok: false, error: "terminal_task_write_declined: …" } on a finished task rather
than reporting a success that did not happen. ok: true from those tools means "the
backing reported no decline", not "the write happened" — for the two built-in
backings those coincide, but a custom ref that reports nothing is carried past
rather than having a verdict synthesized for it.
// "research-lead" declares agents: → delegation installs automatically.
generator({ uses: [skills.with({ active: ["research-lead"] })] });An inline agent may set context-supply: conversation to inherit the parent
conversation up to the point it is dispatched (fork-like), bounded to the last 8
whole turns (a turn count, not a token budget), while its own steps
stay out of the host's history (output keeps history: false). Omitting the
field is the default: the agent is isolated and sees only its task input — there
is no isolated value to set. Write it on the skill entry for an inline
prompt:, or in the prompt file's YAML frontmatter for prompt-ref. Setting it
on an agent-ref agent, or on a prompt-ref skill entry, fails loud. See
Context supply.
For a graph fixed in code (seeded initialTasks, custom collection, tuned
dispatcher), put a taskBoard(...).drain or a goalSeekLoop in the generator's
tools: — any block can be a tool, and only the finalized result re-enters the
caller's history.
See Per-generator binding for the
active / allowed / activeState surface and
Delegation for the agents: shape.
skillsManifestSource
import { skillsManifestSource } from "@flow-state-dev/orchestration";skillsManifestSource({ collectionKey?, allowed?, initialSkills? }) is the
skills domain's projection into the agent discovery door
(discoveryTools / createManifestRegistry in @flow-state-dev/core). It
lists what loadSkill will actually accept: enabled, inline-mode, and inside
the binding's allowed set when it declares one — pass the same array the
binding was given. Advertising a skill the loader then refuses sends the agent
somewhere it cannot go, so the two filters are the point rather than a
nicety. A collection key this scope does not hold throws, which the door
reports as a problem on the skills domain while every other domain still
answers.
The ambient catalog listing in the prompt is now the catalogContext
preset, on by default — an app that upgrades sees turn 1 unchanged. Pass
catalogContext: false in the same .with({ ... }) call to take the listing
out and let the agent find skills through the door instead. Preset overrides
replace rather than merge, so put both flags in one call.
resolveCatalogTools
import { resolveCatalogTools } from "@flow-state-dev/orchestration";resolveCatalogTools(agentKey, toolKeys, catalog, logPrefix) resolves an
agent's tools: list against a tool catalog and returns GeneratorTool[].
agentKey is the agent name quoted in the warning text; toolKeys is the
agent's declared tools: list (readonly string[] | undefined); catalog is
the available tools keyed by name (Record<string, GeneratorTool>); and
logPrefix is the bracket tag the warning carries ("skills",
"workforce"). An empty or absent toolKeys returns []. An unknown key
warns ([skills] agent "x": unknown tool "y" — skipped) and is dropped rather
than throwing, so one bad key in a user-authored SKILL.md does not take down
the agent; only own properties count, so a key like constructor misses. It
lives on the package root because the skills worker-materializer and
@flow-state-dev/workforce both need this lookup and the miss path has to stay
identical between them.
Documentation
Running tests
pnpm --filter @flow-state-dev/orchestration test