# Workflows runbook Workflows are DAGs of nodes connected via `next`. Reference for authoring nodes with the `create-workflow` tool. ## Cross-node data access Upstream outputs are **not** available by default. Declare an `inputs` mapping: - Keys are local names for `{{interpolation}}`. - Values are context paths (usually a node ID). - Agent-task output shape is `{ taskId, taskOutput }`, so access via `localName.taskOutput.field`. - For trigger data: `{ "pr": "trigger.pullRequest" }` → `{{pr.number}}`. - For the current run: `{ "runId": "run.id" }` → `{{runId}}` (builtin, like `trigger`/`input` — useful for receipts/audit nodes correlating their output with the run). Without `inputs`, upstream references are unavailable. Ordinary config values still render unresolved tokens as empty strings and report them in `diagnostics.unresolvedTokens`. Executable script source is stricter: disallowed or unresolved workflow tokens fail the node before execution. ## Structured output Schema goes in `config.outputSchema` (not node-level). The agent produces JSON matching it; validated by `store-progress`. ## Large artifact handoffs Agent-task nodes should not pass large raw artifacts directly into later LLM prompts. If a node writes a full debug/audit artifact such as a commit context, trace bundle, scrape result, or report dataset, it should also write a slim prompt artifact and return both paths: ```json { "contextPath": "release-runs/2026-06-08/context.json", "contextSlimPath": "release-runs/2026-06-08/context-slim.json" } ``` Downstream LLM nodes should read the slim path: ```bash agent-fs --org cat {{context.taskOutput.contextSlimPath}} ``` Keep the full path for audit/debugging only. Add an explicit prompt guard such as "Do not read `{{context.taskOutput.contextPath}}` unless a human asks for it." This prevents high-volume weeks from turning a normal structured-output task into a context-overflow failure before the agent can call `store-progress`. Recommended slim commit shape: ```json { "commits": [ { "hash": "abc123...", "shortHash": "abc123", "author": "Name", "date": "2026-06-08", "message": "feat: add workflow run waterfall", "files": ["src/workflows/engine.ts", "apps/ui/src/pages/workflow-runs/[id]/page.tsx"] } ], "commitCountTotal": 70, "commitCountIncluded": 70, "truncated": false } ``` Do not include patch bodies, diff hunks, raw `git log --stat` output, downloaded HTML, or other bulk text in the slim artifact. Cap arrays before prompt ingestion; for release-note workflows, 150 commits is a reasonable default. ## Interpolation `{{path.to.value}}` in any string field inside `config`. Objects get JSON-stringified; nulls become empty strings. ## Agent-task config fields - `template` (required) - `outputSchema` - `agentId` - `routingReason` (optional; a configured `agentId` defaults to `human_pinned`: `skill`, `continuity`, `overflow`, `human_pinned`, or `reroute_fault`) - `routingNote` (optional, maximum 200 characters) - `tags` - `priority` (0–100, default 50) - `offerMode` - `dir` - `vcsRepo` - `model` - `parentTaskId` ## Foreach nodes `foreach` runs its `body` once per array item, waits for every item, then exposes one parent-owned aggregate to downstream nodes. The body can be any registered executor except `foreach`; `sub-workflow` runs one child workflow per item. ```yaml - id: reflect type: foreach inputs: { agents: "gather.result.agents" } config: over: "{{agents}}" itemKey: id body: type: agent-task config: agentId: "{{item.id}}" template: "Reflect for {{item.name}} (index {{index}})" next: critique ``` - `over` must resolve to an array. An exact interpolation token preserves the array value instead of JSON-stringifying it. - `itemKey` names a required non-empty string property whose value is unique across the array. - `body.type` is any registered executor except `foreach` (nested fan-out is rejected, with the node id in the error). `body.config` is that executor's normal config, checked against its schema at authoring, and is interpolated separately for each child with `item` and zero-based `index`. A `script` body keeps the script-source allowlist: pass the item through `args`. - Each child row carries the body's `nodeType`. Children are created `pending` in one transaction, then the foreach driver (`src/workflows/foreach-driver.ts`) starts them. Instant bodies (`script`, `swarm-script`, `raw-llm`, `notify`, ...) run one per item, each against its own watchdog budget, not the parent's. Async bodies (`agent-task`, `sub-workflow`, `human-in-the-loop`, `wait`, `system-one-decision` with `humanReview`) park, and their normal resume path settles the item through `settleForeachChild` (`src/workflows/foreach-join.ts`). - A body's ports do not route. An approval `rejected`/`timeout` or a wait `timeout` still counts as a completed item; its `nextPort` is stored on the child row for display. - Child steps have synthetic IDs `#`. The parent remains waiting until all children are terminal; only the parent aggregate is written to workflow context. - Empty arrays complete synchronously and still route to the successor. - `concurrency` (an integer, 1 or more) caps how many items are in flight. Without it every item starts at once. The child rows are the state: `pending` is queued, `running` or `waiting` is in flight, and only the latest attempt row per item counts. Every item settlement starts the next queued item, and the recovery sweep backs that up after a restart. The in-flight count and the claim share one `BEGIN IMMEDIATE` transaction, so concurrent drives never exceed the cap. - `failurePolicy` is `fail-fast` or `collect-outcomes` (see below). - A `foreach` node in a cycle is rejected in v1. Child steps are scoped to the run, so a later parent iteration cannot safely re-adopt them. The parent output shape is: ```json { "results": [ { "itemKey": "agent-id", "status": "completed", "output": { "taskId": "...", "taskOutput": {} } } ], "okCount": 1, "failedCount": 0 } ``` Only an item that completed without an error counts in `okCount`. Failed, cancelled and skipped items count in `failedCount`; `cancelledCount` is added when any item was cancelled. ### Failure policies `config.failurePolicy` decides what a failed or cancelled item does. Without it, the policy follows the workflow's `onNodeFailure`: `"continue"` means `collect-outcomes`, the default `"fail"` means `fail-fast`. Existing definitions behave as before. - **fail-fast**: the first failed or cancelled item cancels every queued item (`error: "not started: fail-fast after item failed"`) and fails the run. Items already in flight finish and record their outcome on their row; they never revive the run. The parent step stays open, so a run retry resumes the fan-out. - **collect-outcomes**: every item reaches a terminal state, the parent completes with its `failedCount`, and successors route. A failed agent-task item contributes a `failed` result whose output carries the `[FAILED: ]` marker. Item failures do not count against the run's outcome; the parent step is authoritative. ### Retry with preserved siblings `retry-workflow-run` on a failed fan-out adds a new attempt row for each item whose latest row is `failed` or `cancelled`: same child node id and `nodeType`, a fresh step id, `input.attempt: n`, and idempotency key `::`. The fresh step id is a fresh invocation identity for every body type: a new task, a new child run, a new approval or a new wait. Completed items are never rerun, and earlier attempt rows stay as history. A double retry inserts nothing twice. Items are picked when the retry commits, so a sibling that failed late is retried too. A retry whose new rows would reach `WORKFLOW_MAX_STEPS_PER_RUN` is refused before it inserts anything, and the run stays failed: the retry walk itself needs one step of headroom. A collect-outcomes run completes, so it is not retried. To rerun its failed items, add a follow-up wave over `{{w.results}}` filtered to `status != "completed"`, under a new node id. ### Dependency waves Each wave is its own `foreach` node, so its item keys are independent. A script between waves builds the next wave's items from earlier results. The running definition is never patched. ```yaml - id: plan type: swarm-script config: { scriptName: plan-waves } # result: { wave1: [...], wave2: [{ id, dependsOn }] } next: wave1 - id: wave1 type: foreach inputs: { p: "plan" } config: over: "{{p.result.wave1}}" itemKey: id concurrency: 3 failurePolicy: fail-fast body: type: sub-workflow config: { workflowId: "", inputs: { spec: "{{item}}" } } next: prep2 - id: prep2 type: swarm-script inputs: { p: "plan", w1: "wave1" } config: scriptName: prep-wave2 # returns wave2 jobs with `deps` from w1.results[].output args: { wave1: "{{w1}}", wave2: "{{p.result.wave2}}" } next: wave2 - id: wave2 type: foreach inputs: { prep: "prep2" } config: over: "{{prep.result}}" itemKey: id concurrency: 3 body: type: sub-workflow config: workflowId: "" inputs: { spec: "{{item}}", deps: "{{item.deps}}" } ``` A sub-workflow passes a non-string input value to the child as JSON text, so the child parses `trigger.spec`. `src/tests/workflow-foreach-retry.test.ts` runs this definition end to end. ## Human-in-the-loop nodes A `human-in-the-loop` node creates one approval card, pauses the run, and routes on the `approved`, `rejected`, or `timeout` port. Question types: `approval`, `text`, `single-select`, `multi-select`, `boolean`. The run is `rejected` only when an `approval` question is answered with a rejection. `config.questions` is either a static array or one exact interpolation token that resolves to an array built upstream, for example one question per item: ```yaml - id: plan-card type: human-in-the-loop inputs: { t: "triage.taskOutput" } config: title: "Plan for {{t.count}} items" questions: "{{t.questions}}" approvers: { policy: any } next: { approved: execute, rejected: skip, timeout: skip } ``` - The exact token injects the raw array. A token with surrounding text is rejected at authoring time, because string interpolation would JSON-stringify the array. - Resolved questions are validated at execute time with the same schema as static ones. The node fails, and no card is created, when the value is missing, not an array, empty, over 100 questions, has a malformed item (the error names the index and field), repeats an `id`, or has a select question with no options. Unknown fields are stripped. - Resolved questions are display data. They are stored as-is and never re-interpolated, so a `{{token}}` inside upstream text stays literal. - Downstream nodes read answers by question id: `inputs: { decision: "plan-card" }`, then `{{decision.responses.}}`. An optional question the human skipped is absent from `responses`, so give the consumer a default (for example the proposed action). Who may answer (`POST /api/approval-requests/{id}/respond`, permission `approval.respond`): - Only a person. A user token (`aswt_`) or a page session signed for a user answers as that user's id. The shared key with no agent identity answers as `operator`. An agent is refused: an `aseph_` session token, the shared key with `X-Agent-ID`, or a page session with no signed-in user. The body's `respondedBy` is stored as an unverified `claimedRespondedBy`, never as `resolvedBy`. - `approvers.users` entries match a user's id or email; `approvers.roles` entries match the user's `role`. With neither set, any person may answer. The operator may answer any request. - `policy`: a rejection resolves the request at once. `any` resolves on the first approval, `{ min: N }` on N distinct responders, `all` when every listed user has approved (one approval when no users are listed). Until then the request stays `pending` and each answer is kept in `approvals`. A responder answers once. Rendering limits: - The dashboard approval page lists every question as its own card. No cap beyond the 100-question node limit. - On the dashboard, `label` and `description` render as markdown. Fenced code with a language scrolls; without one it wraps. Only `description` turns a bare image URL on its own line (or `![alt](url)`) into an image. Author guidance lives in the `workflow-iterate` skill. - The Slack notification lists question labels in one section block, capped by Block Kit at 3000 characters. When the labels do not fit, the tail becomes `…and N more` and the reviewer answers on the dashboard via the card's button. Labels are escaped, so upstream text cannot add mentions or links. ## Sub-workflow nodes A `sub-workflow` node runs another workflow as a child run and waits for it. ```yaml - id: build type: sub-workflow inputs: { plan: "planner.taskOutput" } config: workflowId: "" inputs: { spec: "{{plan.spec}}" } next: review ``` - `config.inputs` becomes the child's `trigger` data and is validated against the child's `triggerSchema`. The child must exist and be enabled. - Output: `{ runId, outputs, error? }`. `outputs` holds the child's node outputs keyed by node id; `trigger` and `input` (which can hold resolved secrets) are left out. `error` is set when the child completed with a partial failure. Downstream: `inputs: { b: "build" }`, then `{{b.outputs..taskOutput}}`. The node routes on the `success` port. - The child run walks on its own, outside the parent executor's 30s timeout. The step and the parent run stay `waiting` until the child run is terminal, so the parent never completes while its child is running, waiting on a human, or awaiting a retry. - A child that fails, is cancelled, or is skipped (cooldown) fails the node and the parent run, whatever `onNodeFailure` says. Node `retry` is a new attempt: it starts a new child run, and the previous attempt's child can no longer settle the step. - The child run stores the parent step and that step's attempt (`retryCount`) in `workflow_runs.parentStepId` / `parentStepAttempt` (unique together). One attempt has at most one child: a re-executed or recovered step reconnects to it, and concurrent executions get the same child. The child is created in one transaction with its initial context, and only while the parent step's attempt is still `running` in a live run. A step a crash left `running` is re-claimed by recovery under the same step id, so it reconnects instead of starting a second child. The child's terminal status emits `workflow.child.finished` after commit. The engine and the retry poller recheck the child once the step parks, and the recovery sweep resumes any waiting step whose child already finished, so a lost event or a restart does not strand the parent. Only the call that claims the waiting step routes its successors. - Deleting a workflow fails every waiting step that started one of its runs as a child. Recovery also fails a waiting step whose child row is missing. - Recursion is rejected: a workflow cannot invoke itself or any workflow already running in its ancestor runs, and nesting stops at 16 levels below the root run (`MAX_SUB_WORKFLOW_DEPTH`). The checks run when the node executes, not when the definition is saved. - Cancelling a run cancels its child runs too (see [Cancelling a run](#cancelling-a-run)). - Not yet supported (tracked in the sub-workflow issue): pinning the child's definition version (the child runs its live definition) and a per-node timeout. As a `foreach` body, a run retry gives the failed item a new child run (see [Retry with preserved siblings](#retry-with-preserved-siblings)). ## Cancelling a run `cancelWorkflowRun` (`src/workflows/resume.ts`) backs the HTTP route (`POST /api/workflow-runs/{id}/cancel`), the `cancel-workflow-run` MCP tool and the dashboard. An approval cancel and the approval auto-cancel sweep cancel their run through the same `cancelWorkflowRunRows`. - The cancel covers the run and every run below it: children of every attempt of every `sub-workflow` step, through runs that already ended. The tree comes only from stored links (`child.parentStepId` → step → run). Run outputs, names and trigger data are never used. - One `BEGIN IMMEDIATE` transaction reads the tree and, for each run, cancels its open tasks (found by the indexed `agent_tasks.workflowRunId`; a retry detaches only the step link, so tasks of earlier attempts are included), its non-terminal steps, its pending approvals and its queued step retries (`nextRetryAt` is cleared). Every non-terminal run becomes `cancelled` with the reason. A completed, failed or skipped descendant keeps its status, error and output; only its leftover open work is cancelled. - Steps, retries and descendant runs are written with one `UPDATE` per 500 runs, and dependents of the cancelled tasks are looked up in one scan over open tasks, only when the tree has open tasks. Only the root goes through `updateWorkflowRun`, so a root that is itself a child still wakes its parent step. Measured on a laptop: 2017 runs in about 0.3 s, 10k runs in about 1.4 s, about 140 µs per run (mostly the per-task `cancelTask`). - A root that is completed, failed or skipped is refused, as before. A `cancelled` root is walked again, so a repeat cancel finishes descendants an earlier cancel (or an older release) left open. Reasons already written are kept. - A repeat cancel of a tree with nothing left open (no live run, open step, queued retry, open task or pending approval) is answered in a read-only transaction: no write lock, no dependents scan, no cancel writes. The guards below keep a cancelled tree from gaining work, so that answer holds. It still returns the tree's run ids, so Slack posts an earlier cancel could not deliver (an outage releases the claim) are retried; a delivered post is never sent again. Concurrent `cancelWorkflowRun` calls for one run in the API process share one cancel. - There is no size limit: the engine admits any number of children per run, and every tree it admitted stays cancellable. The walk goes level by level and stops at the first run it reaches twice. A cycle in the links or a chain deeper than `WORKFLOW_RUN_TREE_MAX_DEPTH` (32, twice the engine's `MAX_SUB_WORKFLOW_DEPTH`) throws, and nothing commits. Any other error rolls back the whole tree too. - After commit: worker nudges and task lifecycle events (queued with `afterCommit`), then the Slack update of every cancelled approval in the tree whose post is not delivered yet (`listUndeliveredCancelledApprovalRequestIds` in `src/be/db/approvals.ts`, one query per 500 runs). A failed Slack post is logged; it does not undo the cancel, and the next cancel of the tree retries it. After the commit, nothing in the tree starts new engine work. The guard is `getRunLineageBlock(runId)` (`src/be/db/workflow-runs.ts`): it blocks a run that is cancelled, has a cancelled ancestor, or has a broken parent link, a cycle or too deep a chain. Completed and failed ancestors do not block. Each write that starts work checks it inside its own transaction, so a cancel either commits first and is seen there, or sees the new row and cancels it: | Path | Guard | |---|---| | Child run insert | `isParentStepAttemptCurrent`: step attempt `running`, run live, lineage clear | | Workflow task insert (`createTaskExtended` with `workflowRunStepId`) | `getStepExecutionBlock`; throws `WorkflowStepNotExecutableError` | | Approval request linked to an existing run (HITL executor, `POST /api/approval-requests`), with or without a step | created `cancelled` | | Step claim (`executeStep`), foreach item claim | no step is claimed | | Checkpoint fences, `checkpointStepWaiting` | nothing is written | | Task / approval / wait / child-run callbacks, `scheduleTaskStepRetry` | the step is not claimed and nothing routes | | Retry poller claim | the retry is dropped (`nextRetryAt` cleared); a failed run is never revived | | Manual `retryFailedRun` | refused, run stays `failed` | | Run finalizers (walk, `finalizeOrWait`, recovery, retry poller) | the run is not completed or parked | The cancel is a database guarantee. It does not stop a process that is already running (a script, an LLM call, an agent session until the worker sees its task cancelled) and does not undo external side effects that already happened. ## Agent-task steps cut by a deploy A deploy drain or heartbeat remediation fails an in-flight workflow-step task with `superseded_workflow_task` (`SUPERSEDED_WORKFLOW_TASK_REASON`) instead of creating a resume child. `scheduleTaskStepRetry` (`src/workflows/task-step-routing.ts`) turns that cut into a retry: - An `agent-task` node without `retry` gets `SUPERSEDE_RETRY_POLICY`: one retry, `static`, 30s. Only this reason qualifies; any other failure follows `onNodeFailure` as before. - An explicit node `retry` always wins. `retry: { maxRetries: 0 }` is the opt-out for a node with external side effects that must not run twice. - A second cut exhausts the implicit retry and follows `onNodeFailure`. - A foreach child (`#`) with an `agent-task` body gets the same retry. The parent foreach node's `retry` applies to its items (the body carries none), else `SUPERSEDE_RETRY_POLICY` on a cut, so `retry: { maxRetries: 0 }` on the foreach opts every item out. A queued item is not settled: the join stays open and the item keeps its `concurrency` slot. When the retry is due, the retry poller (`src/workflows/retry-poller.ts`) flips the item back to `pending` and drives the fan-out, which starts it with a fresh task; its result settles into the join like any other item. A fail-fast failure of a sibling cancels a queued item with the rest of the queue. A queued item never revives a failed run: a cut whose run already failed is recorded as the item's failure, and when another branch fails the run the poller retires the queued retry instead of running it. `retry-workflow-run` then gives the item one new attempt. Exhausted retries and every other failure follow the fan-out's failure policy as before. ## Script node types There are two script-oriented workflow nodes: - `script` runs inline `bash`, `ts`, or `python` source embedded directly in the workflow definition. - `swarm-script` runs a TypeScript script from the reusable swarm catalog (`scripts` table). Use this when the logic should be shared across agents or reused by multiple workflows. Both executors emit the `success` port. To branch on a check, add a `validation` block and a record `next` with `pass` and `fail` keys. When the node's `next` is a record that does not declare the executor's port, the validation result picks `pass` or `fail`. When `next` does declare the executor's port (for example `next: { success: ... }`), the executor port wins and validation does not reroute. The engine checkpoints the chosen port on the step, and recovery, resume, and retries route from that value. The same rule applies to every executor type (`resolveValidationPort` in `src/workflows/definition.ts`). ### `script` config - `runtime` (required): `bash`, `ts`, or `python`. - `script` (required): inline source to execute. - `args`: optional string arguments passed to the script. - `cwd`: optional working directory. - `timeout`: optional wall-clock timeout in milliseconds, from `1000` through `300000`; defaults to `30000`. This value applies to both the inline script executor and the workflow step watchdog. Inline executable source may interpolate only `input`, `workflow`, `swarm`, and `run` values. It may not splice `trigger` data or declared upstream aliases directly into source. Pass those dynamic values through `config.args`, which the script receives as argv. For `runtime: "bash"`, the engine runs `bash -c