This document traces how a single run executes locally in Phase 1: how the node DAG is walked, how tokens stream from a provider to a node face, how a human gate suspends and resumes a run, and how each node boundary is checkpointed. It is the runtime companion to shared-core-engine.md, which covers the engine's structure. Concrete contracts (the event schema, the YAML format, the IPC surface) are cited from ../reference/ rather than restated here.
Scope. This document covers a workflow run (the
WorkflowEngineentry point). A chat session — the engine's other entry point — runs on the same node, tool, and streaming machinery but has its own lifecycle; it is detailed in agent-sessions.md.
stateDiagram-v2
[*] --> Parsing
Parsing --> Planning: YAML valid
Parsing --> Failed: validation error
Planning --> Running: RunPlan built
Running --> Running: next ready node
Running --> AwaitingGate: human_gate reached
AwaitingGate --> Running: decision received
AwaitingGate --> Failed: gate timeout (on_timeout=reject)
Running --> Checkpointing: node completed
Checkpointing --> Running
Running --> Completed: all nodes done
Running --> Failed: node failed (retries exhausted)
Failed --> Running: retry-from-node
Completed --> [*]
Failed --> [*]
Status: design sourced from the synthesis
dataFlowtrace and the engine sources. Event payloads and field names are the canonical property of ../reference/contracts/sse-event-schema.md.
A run is started identically from any surface — the only difference is the entry point and how events are painted:
- Desktop: the canvas Run button starts the engine, which runs in the WebView's JS runtime (ADR-0018). Only the authenticated LLM egress is delegated to a Rust command.
- CLI:
relavium run <workflow>calls the engine directly and renders with ink. - VS Code: a right-click / command runs the engine in the extension host.
All three call WorkflowEngine.start(workflowId, input). A chat session instead
enters through the AgentSession entry point, which shares the same node, tool,
and streaming machinery described below (see agent-sessions.md).
There is no Relavium server involved in Phase 1.
The engine validates the workflow YAML and compiles it into a DAG run plan
(topological order via Kahn's algorithm), resolving each node's inputs from
{{ node.output }} interpolation against upstream nodes. See
shared-core-engine.md. The accepted
file format is the
workflow YAML spec; the node types
are catalogued in
../reference/shared-core/node-types.md.
Right after run:started, before any node runs, the engine resolves the workflow
context: map once into the immutable ctx.* namespace (the spec's eager-once
context — resolveContext) and threads the frozen result to every node
(NodeExecContext.ctx), so a bare ctx.key read in a condition / transform /
merge_fn expression, in an agent prompt, and in a human-gate message_template /
assignee sees the real value. A context value may
itself interpolate {{ inputs.* }} (and read_file, via an injected resolver
capability); a resolution failure closes the run with run:failed (validation)
rather than running nodes against a partial context. On a cross-process resume ctx
is re-resolved (it is deliberately not carried in the checkpoint).
The engine then dispatches every node whose dependencies are satisfied. Independent branches run concurrently:
- Sequential spine — nodes run in dependency order, each receiving upstream outputs.
- Parallel fan-out — a FanOut node spreads input across N branches that run at once; an Aggregator/merge node joins them with a configured strategy (all-required, first-wins, quorum-of-N, best-of).
- Conditional branches — a Condition node evaluates its expression and activates exactly one downstream path.
Each agent node is handled by the AgentRunner, which streams from
packages/llm (see multi-llm-providers.md).
As the provider streams tokens back, the AgentRunner emits them on the
RunEventBus. The transport differs per surface but the event shape is the same
SSE event schema — the canonical
RunEvent union (node:started, agent:token, node:completed, node:failed,
human_gate:paused/human_gate:resumed, cost:updated, run:completed,
run:failed, …), each carrying a nodeId and a monotonically increasing
sequenceNumber. The event names and payloads are defined there, not restated
here:
- Desktop — the engine and its
RunEventBusrun WebView-side, so run events are produced and consumed in the same JS runtime and routed to the matching ReactFlow node bynodeIdwithout crossing IPC. The only Rust→WebView channel on the LLM hot path is the delegated egress'sChannel<StreamChunk>, which the WebView adapter folds intoagent:tokenevents on that bus (ADR-0018). The IPC surface is defined in ../reference/contracts/ipc-contract.md. - VS Code — events are posted to the WebviewPanel via
postMessage. - CLI — ink re-renders the live node status and token stream in the terminal.
The sequenceNumber lets a surface detect a gap and request a resync, and lets the
desktop renderer batch high-frequency token events without dropping any. The frontend's
token-rendering performance model (the double-buffer that caps re-renders at
60fps) is described in state-management.md.
A human_gate node suspends the run until a human approves, rejects, or edits the
pending decision. While suspended the engine emits human_gate:paused, persists
the gate state to the checkpoint, and waits. The gate is resolved from any surface
that can reach the run:
- Desktop: a
HumanGateOverlayrendered at the root layout. - VS Code: a sidebar / status-bar prompt and a WebviewPanel card.
- CLI: a terminal prompt (
relavium gate).
When a decision arrives — approved, rejected, or input_provided — the engine emits
human_gate:resumed and the run continues: the gate node completes with the decision as its
output, so the author routes on it with a downstream condition (a rejected decision does not
itself fail the run). Because the gate state is checkpointed, resolving it is idempotent across a
reconnect — re-delivering the same decision does not advance the run twice. Parallel branches may
each reach a gate, so multiple gates can be pending at once — each resolves independently with its
own timeout (a run:paused aggregate reflects that ≥1 gate is pending). A gate may carry a timeout
with an on_timeout policy (reject / approve; escalate is reserved in v1.0 — authored in
YAML as timeout_action; see
workflow-yaml-spec.md), armed as a
one-shot timer from the injected clock when the gate parks. The two timeout outcomes differ from a
human decision: approve auto-resolves the gate as approved (decidedBy: 'timeout', the run
continues); reject fails the run with run_timeout (the AwaitingGate → Failed edge above) —
this is what stops a forgotten gate from blocking a run forever. If an automatic approval is
refused, including a stale or reject-only budget quote, that deadline also fails the run with
run_timeout; it does not override the refusal or leave an unresolved gate without a timer.
A decision that arrives first disarms the timer. A competing synchronous decision claim is
preserved even while its pending row remains until durable acknowledgement; neither a queued
timeout rejection nor a late refused auto-approval can replace it. A terminal outcome is also
preserved.
A new frozen budget gate has different semantics: approval requires its exact amount and leaves
the agent pending for a governed dispatch; rejection fails budget_exceeded. A legacy approval
grants no allowance and re-runs under current budget checks. Neither form completes
the agent with a decision object or removes its pre-egress hook. The engine acknowledges durable
authorization before companions or approved work, and an unresolved external effect still refuses
resume. During restart admission, the supplied target decision precedes timer construction;
surviving gates re-arm at their absolute remaining time. The authoritative protocol is in
sse-event-schema.md,
and preparation/debit behaviour in agent-runner.md.
The CLI offers exact-amount inline confirmation and the out-of-band
budget resume command, with strict authority-based
discovery and the existing secret/MCP resume obligations.
The gate event/decision shapes are part of the SSE event schema and the IPC contract.
After every node completes, the engine writes a checkpoint to local SQLite — run
status, per-node states, completed and pending node IDs, and (for an orchestrator)
its message history. This is the foundation for resume and retry; see
shared-core-engine.md. There is no separate
checkpoint table — the checkpoint is reconstructed (by a Checkpointer) from step_executions,
run_events (and messages for an orchestrator's history), all defined in
../reference/shared-core/database-schema.md.
Three run-loop substrate rules make this reliable (ADR-0036):
a node-boundary / terminal event is persisted before it is delivered to consumers, so a crash
between emit and write can never re-run a completed node or lose its output; the
monotonic, gap-free sequenceNumber is assigned at a single producer-side point (one counter per
run/session), so concurrent fan-out branches cannot duplicate or invert numbers; and gate / run / agent
node timeout_ms deadlines are armed as one-shot timers from an injected clock — not a sleep/poll
loop, so the completion-driven scheduler stays event-driven.
The agent-node deadline is the third of those three and the newest
(ADR-0085 §2): it is
absolute across the node — every attempt, every retry backoff and every re-dispatch share one budget —
and it is armed as a deadline-kind timer, a backstop over work already in flight rather than something
the run is parked ON. The same ADR adds a single post-abort grace window: once the run's AbortSignal
fires for any reason, an executor that does not settle within it is abandoned, its vertex closed
node:failed, and the run reaches its terminal. That makes exactly-one-terminal a liveness property with
respect to the EXECUTOR — §6 of that ADR states the half that stays conditional on the store.
The ordered writer reports the actual result of each append internally: persisted, refused or failed. Required realized/conservative money bridges observe that append's acknowledgement; absence of a new run failure is insufficient evidence that their write landed. Expected-head selection and advancement happen inside the ordered writer after ownership admission. A successfully acknowledged terminal advances that head, so a permitted later incurred ledger receipt appends after the same terminal through the same writer without changing its outcome.
A terminal whose persistence is uncertain latches refusal before any subsequent ownership check,
head change or store entry. Later run-event receipts cannot displace its original terminal-outbox
cause or ordering. A post-acknowledgement cosmetic callback cannot revoke persistence truth.
The distinction between bounded terminal publication and retained receipt/fence lifetime lives in
shared-core-engine.md, which also
defines the public RunHandle.depart() result and its independent host acknowledgement.
On the last node the engine writes the final output and its terminal record to SQLite
before delivering run:completed (or run:failed if the run failed). Live cost:updated
events report token counts and realized cost; realized charges are also persisted
per attempt behind the durability barrier of
ADR-0076 and
ADR-0077,
rather than waiting for the run to finish. The exact event and accounting contracts live
in sse-event-schema.md. Cost accounting
is computed in packages/llm; see multi-llm-providers.md.
- Node failure — a failing node retries within its budget (with backoff, optionally adjusting inputs). A required node is never silently skipped.
- Provider failure —
packages/llmwalks the agent's fallback chain before the node is considered failed. - Crash recovery — the engine exposes reconciliation of interrupted, non-resumable runs;
this is an explicit host operation, not an automatic shipping CLI startup guarantee. Recorded
budget rejection retains
budget_exceeded, while an ordinary interruption usesinternal. Interrupted-run discovery refuses corrupt suspension history as a whole. The derived metadata, corruption and durable terminal rules live in the event contract. - Retry and resume with effects — the durable effect journal brackets an effectful dispatch. The guarantee depends on the target (ADR-0080); the shipping tier-3 contract is at-most-once dispatch attempt, with retained-result re-delivery where available and a preflight refusal when the earlier effect cannot be resolved safely. A retry count is not a durable idempotency key and does not prevent double effects. The identities, replay and refusal rules have one canonical home in effect-journal.md. Future loop support requires its own effect-identity decision before implementation.
Everything above describes local execution (Phase 1): the engine runs in the host process (the WebView's JS runtime on the desktop) and LLM calls go from the machine to the provider — directly on the Node-style surfaces, and via the Rust-delegated egress on the desktop (ADR-0018).
The two Phase-2 modes switch at different seams, which is the key framing:
- Cloud mode switches the
ExecutionHost: the whole engine relocates to a cloud worker, and events stream over HTTP SSE instead of the in-process bus — the surfaces see identicalRunEventobjects either way. The transparent switch is described in cloud-phase-2.md. - Managed mode does not move the engine; it keeps the engine running locally
and switches only behind the
LLMProviderseam, redirecting the LLM egress through the Relavium gateway (an egress-only proxy on Relavium's key). The run lifecycle above is unchanged (ADR-0012, ADR-0018, managed-inference.md).
In short: cloud is an ExecutionHost switch (engine relocates); local and
managed are selected behind the LLMProvider seam (engine stays put).
- shared-core-engine.md — engine structure and the run plan.
- state-management.md — how the desktop frontend renders streaming events.
- ../reference/contracts/sse-event-schema.md — the event contract.
- ../reference/contracts/ipc-contract.md — the desktop IPC surface.