Example 21: Per-Node Timeout
What It Is
Per-Node Timeout gives one node its own wall-clock budget. The Dispatcher uses it to keep slow model-backed support steps from hanging the browser runner indefinitely.
Set timeout: Timeout.ofMs(n) on a node implementation when that operation should fail fast on its own schedule. The rest of the DAG keeps the normal run-level signal unless the caller also cancels or sets deadlineMs.
How It Works
The engine wraps one node execution in a child signal derived from the run signal. If the timeout fires, the child signal aborts, the engine raises NodeTimeoutError, and the run records a structured interrupted point for that node. The parent run signal stays independent unless the caller also supplied a run-level deadline or abort signal.
That scope is the useful part. A timeout on classify-message means that node exceeded its budget; it does not mean every other in-flight branch must inherit a cancelled run signal.
Diagrams, Examples, and Outputs
DAG registration and diagram
Timeout is node configuration, not a new placement shape. The Dispatcher uses real engine-level node timeouts on its LLM-backed classify-message and ai-compose nodes so slow model calls are bounded in the browser runnable.
support-dispatcher timeout DAG
7 placements{
"@context": {
"@version": 1.1,
"name": {
"@id": "https://noocodec.dev/ontology/dag/name"
},
"version": {
"@id": "https://noocodec.dev/ontology/dag/version"
},
"entrypoints": {
"@id": "https://noocodec.dev/ontology/dag/entrypoints",
"@container": "@index"
},
"nodes": {
"@id": "https://noocodec.dev/ontology/dag/nodes",
"@container": "@set"
},
"outputs": {
"@id": "https://noocodec.dev/ontology/dag/outputs"
},
"node": {
"@id": "https://noocodec.dev/ontology/dag/node"
},
"dag": {
"@id": "https://noocodec.dev/ontology/dag/dag"
},
"body": {
"@id": "https://noocodec.dev/ontology/dag/body"
},
"source": {
"@id": "https://noocodec.dev/ontology/dag/source"
},
"sources": {
"@id": "https://noocodec.dev/ontology/dag/sources",
"@container": "@index"
},
"itemKey": {
"@id": "https://noocodec.dev/ontology/dag/itemKey"
},
"execution": {
"@id": "https://noocodec.dev/ontology/dag/execution"
},
"concurrency": {
"@id": "https://noocodec.dev/ontology/dag/concurrency"
},
"throttle": {
"@id": "https://noocodec.dev/ontology/dag/throttle"
},
"reservoir": {
"@id": "https://noocodec.dev/ontology/dag/reservoir"
},
"gather": {
"@id": "https://noocodec.dev/ontology/dag/gather"
},
"dagReference": {
"@id": "https://noocodec.dev/ontology/dag/dagReference",
"@type": "@id"
},
"DagReference": {
"@id": "https://noocodec.dev/ontology/dag/DagReference"
},
"from": {
"@id": "https://noocodec.dev/ontology/dag/from"
},
"path": {
"@id": "https://noocodec.dev/ontology/dag/path"
},
"candidates": {
"@id": "https://noocodec.dev/ontology/dag/candidates",
"@container": "@set"
},
"candidateDag": {
"@id": "https://noocodec.dev/ontology/dag/candidateDag",
"@type": "@id"
},
"selectedDag": {
"@id": "https://noocodec.dev/ontology/dag/selectedDag",
"@type": "@id"
},
"resultField": {
"@id": "https://noocodec.dev/ontology/dag/resultField"
},
"policy": {
"@id": "https://noocodec.dev/ontology/dag/policy"
},
"reducer": {
"@id": "https://noocodec.dev/ontology/dag/reducer"
},
"outcome": {
"@id": "https://noocodec.dev/ontology/dag/outcome"
},
"phase": {
"@id": "https://noocodec.dev/ontology/dag/phase"
},
"stateMapping": {
"@id": "https://noocodec.dev/ontology/dag/stateMapping"
},
"container": {
"@id": "https://noocodec.dev/ontology/dag/container"
},
"DAG": {
"@id": "https://noocodec.dev/ontology/dag/DAG"
},
"Placement": {
"@id": "https://noocodec.dev/ontology/dag/Placement"
},
"SingleNode": {
"@id": "https://noocodec.dev/ontology/dag/SingleNode"
},
"ScatterNode": {
"@id": "https://noocodec.dev/ontology/dag/ScatterNode"
},
"EmbeddedDAGNode": {
"@id": "https://noocodec.dev/ontology/dag/EmbeddedDAGNode"
},
"GatherNode": {
"@id": "https://noocodec.dev/ontology/dag/GatherNode"
},
"TerminalNode": {
"@id": "https://noocodec.dev/ontology/dag/TerminalNode"
},
"PhaseNode": {
"@id": "https://noocodec.dev/ontology/dag/PhaseNode"
}
},
"@id": "urn:noocodec:dag:support-dispatcher",
"@type": "DAG",
"name": "dag:support-dispatcher",
"version": "1",
"entrypoints": {
"main": "urn:noocodec:dag:support-dispatcher/node/classify-message"
},
"nodes": [
{
"@id": "urn:noocodec:dag:support-dispatcher/node/setup",
"@type": "PhaseNode",
"name": "dag:support-dispatcher/node/setup",
"node": "urn:noocodec:node:dispatcher-setup",
"phase": "pre"
},
{
"@id": "urn:noocodec:dag:support-dispatcher/node/classify-message",
"@type": "SingleNode",
"name": "dag:support-dispatcher/node/classify-message",
"node": "urn:noocodec:node:classify-message",
"outputs": {
"routine": "urn:noocodec:dag:support-dispatcher/node/ai-compose",
"escalate": "urn:noocodec:dag:support-dispatcher/node/park-for-operator",
"off-topic": "urn:noocodec:dag:support-dispatcher/node/decline"
}
},
{
"@id": "urn:noocodec:dag:support-dispatcher/node/ai-compose",
"@type": "SingleNode",
"name": "dag:support-dispatcher/node/ai-compose",
"node": "urn:noocodec:node:ai-compose",
"outputs": {
"drafted": "urn:noocodec:dag:support-dispatcher/node/send-response"
}
},
{
"@id": "urn:noocodec:dag:support-dispatcher/node/park-for-operator",
"@type": "SingleNode",
"name": "dag:support-dispatcher/node/park-for-operator",
"node": "urn:noocodec:node:park-for-operator",
"outputs": {
"parked": "urn:noocodec:dag:support-dispatcher/node/end",
"ready": "urn:noocodec:dag:support-dispatcher/node/send-response"
}
},
{
"@id": "urn:noocodec:dag:support-dispatcher/node/send-response",
"@type": "SingleNode",
"name": "dag:support-dispatcher/node/send-response",
"node": "urn:noocodec:node:send-response",
"outputs": {
"sent": "urn:noocodec:dag:support-dispatcher/node/end"
}
},
{
"@id": "urn:noocodec:dag:support-dispatcher/node/decline",
"@type": "SingleNode",
"name": "dag:support-dispatcher/node/decline",
"node": "urn:noocodec:node:decline",
"outputs": {
"declined": "urn:noocodec:dag:support-dispatcher/node/end"
}
},
{
"@id": "urn:noocodec:dag:support-dispatcher/node/end",
"@type": "TerminalNode",
"name": "dag:support-dispatcher/node/end",
"outcome": "completed"
}
]
}Mermaid source
%%{init: {"flowchart":{"nodeSpacing":92,"rankSpacing":104,"padding":28}}}%%
flowchart TB
%% dag:support-dispatcher (v1)
entry_main(["main"])
entry_main --> urn_noocodec_dag_support-dispatcher/node/classify-message
urn_noocodec_dag_support-dispatcher/node/setup(["dag:support-dispatcher/node/setup (pre)"])
urn_noocodec_dag_support-dispatcher/node/classify-message["dag:support-dispatcher/node/classify-message"]
urn_noocodec_dag_support-dispatcher/node/classify-message -->|routine| urn_noocodec_dag_support-dispatcher/node/ai-compose
urn_noocodec_dag_support-dispatcher/node/classify-message -->|escalate| urn_noocodec_dag_support-dispatcher/node/park-for-operator
urn_noocodec_dag_support-dispatcher/node/classify-message -->|off-topic| urn_noocodec_dag_support-dispatcher/node/decline
urn_noocodec_dag_support-dispatcher/node/ai-compose["dag:support-dispatcher/node/ai-compose"]
urn_noocodec_dag_support-dispatcher/node/ai-compose -->|drafted| urn_noocodec_dag_support-dispatcher/node/send-response
urn_noocodec_dag_support-dispatcher/node/park-for-operator["dag:support-dispatcher/node/park-for-operator"]
urn_noocodec_dag_support-dispatcher/node/park-for-operator -->|parked| urn_noocodec_dag_support-dispatcher/node/end
urn_noocodec_dag_support-dispatcher/node/park-for-operator -->|ready| urn_noocodec_dag_support-dispatcher/node/send-response
urn_noocodec_dag_support-dispatcher/node/send-response["dag:support-dispatcher/node/send-response"]
urn_noocodec_dag_support-dispatcher/node/send-response -->|sent| urn_noocodec_dag_support-dispatcher/node/end
urn_noocodec_dag_support-dispatcher/node/decline["dag:support-dispatcher/node/decline"]
urn_noocodec_dag_support-dispatcher/node/decline -->|declined| urn_noocodec_dag_support-dispatcher/node/end
urn_noocodec_dag_support-dispatcher/node/end((("dag:support-dispatcher/node/end")))Set timeout: Timeout.ofMs(n) on a node's NodeInterface definition to activate engine-level per-node timeout. When the node's execute() call does not resolve within the budget:
- The engine derives a child
AbortControllerfrom the run's signal. - The child signal is aborted after the budget expires.
- The engine throws
NodeTimeoutError, firesonError, and marks the runfailedwithresult.interruptedAt.reason === 'timeout'.
Key difference from run-level deadlineMs (in ExecuteOptions):
timeoutis scoped to one node'sexecute()only. The parent run-level signal is not aborted; other nodes are unaffected.deadlineMsaborts the entire run;timeoutaborts just the node.
In the runnable Dispatcher, the timeout is attached to the node implementation. The JSON-LD placement stays ordinary; timeout behavior belongs to the registered node contract.
Run
npm run docs:devWhat It Lets You Do
Per-node timeout lets applications bound one slow node without aborting the whole run-level signal. Use it when a model call, tool call, API adapter, or parser has its own wall-clock budget and should fail cleanly without cancelling unrelated work.
For product code, this keeps latency policy close to the operation that owns the risk. A support classifier can have a short budget, while a downstream operator hand-off or cleanup phase still follows its own contract.
Code Samples
The node snippets show the timeout on real Dispatcher nodes. The DAG snippet shows that placements remain ordinary; the timeout belongs to the registered node implementation.
/**
* ClassifyMessageNode: mode-switched triage with LLM recovery.
*
* Routes each inbound customer message to one of three outputs:
* 'routine' — AI can handle; routes to ai-compose.
* 'escalate' — human operator needed; routes to park-for-operator.
* 'off-topic' — blank or unrelated; routes to decline.
*
* Fast paths (win regardless of `state.classificationMode`):
* Trolley switch (state.humanMode === true) forces escalation on every
* message before any classification is attempted.
* Empty message → off-topic immediately.
*
* Mode dispatch (`state.classificationMode`):
* `'llm'` — runs `services.llm.classify` exclusively; `services.intent`
* is never consulted.
* `'embedder'` — when `services.intent` is provisioned, classifies via
* cosine similarity against the three intent anchors — no
* LLM round-trip, so trivial messages never risk the
* adapter timeout. If the embedder is confident (above its
* floor), its verdict routes directly. If the embedder is
* unavailable (`services.intent === null`) or unconfident
* (returns `null`), the node runs the LLM path.
*
* LLM recovery and error handling:
* If the LLM call throws, the node escalates with a safety reason rather
* than surfacing an unhandled error — a conservative recovery that keeps
* customers in the flow.
*/
import { Batch, BatchItemExecutor, MonadicNode, NodeOutput } from '@studnicky/dagonizer';
import type { ItemType, NodeContextType, NodeOutputType, RoutedBatchType, SchemaObjectType } from '@studnicky/dagonizer';
import { Timeout } from '@studnicky/dagonizer/runtime';
import type { DispatcherState } from '../DispatcherState.ts';
import type { DispatcherServices } from '../services.ts';
export class ClassifyMessageNode extends MonadicNode<DispatcherState, 'routine' | 'escalate' | 'off-topic'> {
readonly name = 'classify-message';
readonly '@id' = 'urn:noocodec:node:classify-message';
readonly outputs = ['routine', 'escalate', 'off-topic'] as const;
override readonly timeout = Timeout.ofMs(60_000);
readonly #services: DispatcherServices;
constructor(services: DispatcherServices) {
super();
this.#services = services;
}
override get outputSchema(): Record<'routine' | 'escalate' | 'off-topic', SchemaObjectType> {
return {
'routine': { 'type': 'object' },
'escalate': { 'type': 'object' },
'off-topic': { 'type': 'object' },
};
}
override async execute(
batch: Batch<DispatcherState>,
context: NodeContextType,
): Promise<RoutedBatchType<'routine' | 'escalate' | 'off-topic', DispatcherState>> {
const acc = new Map<'routine' | 'escalate' | 'off-topic', ItemType<DispatcherState>[]>();
const results = await BatchItemExecutor.map(batch.items(), async (item) => {
const output = await this.routeItem(item.state, context);
for (const error of output.errors) {
item.state.collectError(error);
}
return { item, output };
}, this.#services.execution, context.signal);
for (const result of results) {
const bucket = acc.get(result.output.output);
if (bucket === undefined) {
acc.set(result.output.output, [result.item]);
} else {
bucket.push(result.item);
}
}
const routed = new Map<'routine' | 'escalate' | 'off-topic', Batch<DispatcherState>>();
for (const [output, items] of acc) {
routed.set(output, Batch.from(items));
}
return routed;
}
private async routeItem(
state: DispatcherState,
context: NodeContextType,
): Promise<NodeOutputType<'routine' | 'escalate' | 'off-topic'>> {
// Trolley switch: force human routing regardless of content.
if (state.humanMode) {
state.escalationReason = 'Human mode active — all messages routed to operator';
return NodeOutput.create('escalate');
}
// Empty message → off-topic without LLM.
if (state.message.trim().length === 0) {
return NodeOutput.create('off-topic');
}
if (state.classificationMode === 'llm') {
return this.classifyViaLlm(state, context);
}
// Embedder mode: cosine similarity against the intent anchors, no LLM
// round-trip, no timeout exposure — routes to the LLM path below
// when the embedder is unavailable or unconfident.
if (this.#services.intent !== null) {
const result = await this.#services.intent.classify(state.message);
if (result !== null) return this.route(state, result.intent);
}
return this.classifyViaLlm(state, context);
}
/** LLM classification with conservative escalation on error. */
private async classifyViaLlm(
state: DispatcherState,
context: NodeContextType,
): Promise<NodeOutputType<'routine' | 'escalate' | 'off-topic'>> {
let intent: 'routine' | 'escalate' | 'off-topic';
try {
intent = await this.#services.llm.classify(state.message, state.conversation, context.signal);
} catch {
state.escalationReason = 'LLM unavailable; escalated for safety';
return NodeOutput.create('escalate');
}
return this.route(state, intent);
}
/** Shared routing for both the embedder and LLM classification paths. */
private route(
state: DispatcherState,
intent: 'routine' | 'escalate' | 'off-topic',
): NodeOutputType<'routine' | 'escalate' | 'off-topic'> {
if (intent === 'escalate') {
state.escalationReason = 'Agent determined this message requires human review.';
return NodeOutput.create('escalate');
}
if (intent === 'off-topic') return NodeOutput.create('off-topic');
return NodeOutput.create('routine');
}
}/**
* AiComposeNode: LLM-powered reply composition for routine support queries.
*
* Calls the LLM to compose a concise, friendly response to the customer's
* message using recent conversation history as context.
*
* Error handling:
* If the LLM call fails, a polite recovery message is set on state so
* the flow can continue to send-response without surfacing a raw error.
*
* Routes 'drafted' on every path.
*/
import { BatchItemExecutor, MonadicNode, RoutedBatch } from '@studnicky/dagonizer';
import type { Batch, NodeContextType, RoutedBatchType, SchemaObjectType } from '@studnicky/dagonizer';
import { Timeout } from '@studnicky/dagonizer/runtime';
import type { DispatcherState } from '../DispatcherState.ts';
import type { DispatcherServices } from '../services.ts';
export class AiComposeNode extends MonadicNode<DispatcherState, 'drafted'> {
readonly name = 'ai-compose';
readonly '@id' = 'urn:noocodec:node:ai-compose';
readonly outputs = ['drafted'] as const;
override readonly timeout = Timeout.ofMs(60_000);
readonly #services: DispatcherServices;
constructor(services: DispatcherServices) {
super();
this.#services = services;
}
override get outputSchema(): Record<'drafted', SchemaObjectType> {
return { 'drafted': { 'type': 'object' } };
}
override async execute(
batch: Batch<DispatcherState>,
context: NodeContextType,
): Promise<RoutedBatchType<'drafted', DispatcherState>> {
await BatchItemExecutor.map(batch.items(), async (item) => {
try {
item.state.response = await this.#services.llm.compose(item.state.message, item.state.conversation, context.signal);
} catch {
item.state.response = 'I apologize — I had trouble composing a reply. Please try again or ask for a human agent.';
}
}, this.#services.execution, context.signal);
return RoutedBatch.create('drafted', batch);
}
}
const setup = new PlaceholderNode<DispatcherState, 'ready'>('urn:noocodec:node:dispatcher-setup', ['ready']);
const classifyMessage = new PlaceholderNode<DispatcherState, 'routine' | 'escalate' | 'off-topic'>('urn:noocodec:node:classify-message', ['routine', 'escalate', 'off-topic']);
const aiCompose = new PlaceholderNode<DispatcherState, 'drafted'>('urn:noocodec:node:ai-compose', ['drafted']);
const parkForOperator = new PlaceholderNode<DispatcherState, 'parked' | 'ready'>('urn:noocodec:node:park-for-operator', ['parked', 'ready']);
const sendResponse = new PlaceholderNode<DispatcherState, 'sent'>('urn:noocodec:node:send-response', ['sent']);
const decline = new PlaceholderNode<DispatcherState, 'declined'>('urn:noocodec:node:decline', ['declined']);
const supportDispatcherDagIri = 'urn:noocodec:dag:support-dispatcher' as const;
const placement = (placementIdentifier: string): string =>
DAGIdentity.placementId(supportDispatcherDagIri, placementIdentifier);
export const supportDispatcherDAG: DAGType = new DAGBuilder(supportDispatcherDagIri, '1')
// Pre-phase: stamps runId before the entrypoint runs.
.phase(placement('setup'), 'pre', setup)
// Entrypoint: classify the inbound message.
.node(placement('classify-message'), classifyMessage, {
'routine': placement('ai-compose'),
'escalate': placement('park-for-operator'),
'off-topic': placement('decline'),
})
// Routine branch: AI composes a canned reply -> send -> done.
.node(placement('ai-compose'), aiCompose, {
'drafted': placement('send-response'),
})
// Escalation branch: HITL suspension point.
.node(placement('park-for-operator'), parkForOperator, {
'parked': placement('end'),
'ready': placement('send-response'),
})
// Shared convergence: both routine and escalated paths flow through send-response.
.node(placement('send-response'), sendResponse, {
'sent': placement('end'),
})
// Off-topic branch: decline and close.
.node(placement('decline'), decline, {
'declined': placement('end'),
})
.terminal(placement('end'), { 'outcome': 'completed' })
.build();Details for Nerds
timeoutonNodeInterface. Setoverride readonly timeout = Timeout.ofMs(n)on the node class to activate the per-node deadline. ImportTimeoutfrom@studnicky/dagonizer/runtime. The engine arms the timer before callingexecute()and cancels it whenexecute()resolves normally.NodeTimeoutError. Thrown by the engine (not the node) when the deadline fires. CarriesnodeNameand the timeout budget for diagnostic reporting.- Child
AbortController. The node'scontext.signalis the child signal derived from the run signal. Aborting the child does not abort the parent run or any other running node. result.interruptedAt.reason. After a per-node timeout,result.interruptedAt.reason === 'timeout'andresult.interruptedAt.nodeNameidentifies the timed-out node. The run lifecycle isfailed.- Contrast with
deadlineMs. Run-leveldeadlineMsabortscontext.signal(the run signal), which propagates to all running nodes. Per-nodetimeoutaborts only the node's child signal.
Related Concepts
- Example 06: Cancellation - run-level deadlineMs and AbortSignal
- Example 07: Retry Flow - retry budget and backoff after a timeout
- Example 22: Backoff strategies - RetryPolicy with each BackoffStrategy via VirtualScheduler
- Reference: Runtime - Scheduler, RetryPolicy, BackoffStrategy