> ## Documentation Index
> Fetch the complete documentation index at: https://docs.obversa.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Graph executor

> Run outside graph types through durable decisions and node attempts.

The graph executor turns a stored run into useful work. Your graph still makes
the decisions. The runtime records what happened and runs each node safely.

## How a dispatch runs

1. The executor loads the run definition and frozen plan from storage.
2. It checks that the supplied graph matches the stored definition.
3. It folds the run's `graph:` events into graph state.
4. The graph returns its next command.
5. The executor records the whole dispatch decision before any node starts.
6. It records `node-attempt-started` immediately before each node starts.
7. It runs each node through the safe node-attempt lifecycle.
8. It records `node-completed`, `node-paused`, or `node-failed`, then asks the
   graph again.

Node behaviour is keyed by node id. A data-only node gets a data function. An
engine-backed node gets a prompt builder. The builder receives that dispatch's
typed input, so two positions can produce different prompts. The node then uses
the lane in the stored plan. Engine bindings join that exact plan target to the
full engine identity, including its adapter version and executable path when
present.

The host chooses the order once. The executor tries the effective target and at
most one live fallback from that order. Only a failure that means the lane is
dead, such as bad credentials or a missing model, can move to the fallback. A
rate limit, timeout, policy pause, or denied action does not run again elsewhere.

Each dead-lane event records both the selected identity and the identity the
engine reported. Stored facts and later routing use the same provider and model
key. Later dispatches skip either identity. If every declared target is dead,
no engine runs. The executor records `ENGINE_UNAVAILABLE` as a normal node
failure so the graph can retry, fail, or recover in its usual way.

An action-policy wait records `node-paused` with its reason and request. A
denied action records `node-failed` with `DENIED`. An aborted attempt records
`node-failed` with `ABORTED`. These events stay distinct when the graph folds
the stream. If a result or pause request is too large for the event stream, the
executor records a small `node-failed` event with `RESULT_TOO_LARGE` so the
dispatch does not stay in flight.

If a process stops after `node-attempt-started` and the start record has
`retrySafe: false`, `resume` does not run node code. It records `node-paused`
with this request shape:

```ts theme={null}
{
  kind: 'reconcile-attempt',
  attemptId: started.identity.attemptId,
}
```

`attemptId` is the attempt identity from that start record. The pause reason is
that the previous process stopped after node code started, so the outcome is
uncertain.

An empty decision is valid only when an attempt is already in flight. The
executor rejects a second dispatch record for the same position.

## Resume after a process stops

A fresh executor returns `waiting` when a dispatch has no result. Call
`resume` with one exact position from that result:

```ts theme={null}
const pending = await freshExecutor.run(signal);

if (pending.kind === 'waiting') {
  const position = pending.positions[0];
  if (position === undefined) throw new Error('The waiting result has no position.');
  await freshExecutor.resume(position, signal);
}
```

Call `resume` only after the earlier executor process stops. The executor does
not lock the run between processes.

Resume rebuilds the dispatch input from its event prefix and the graph
decision. It does not add a second dispatch event.

* **Node code did not start.** If no start record exists, the executor runs
  the attempt at its recorded position. It does not write `node-resumed`.
* **A retry is safe.** Set `retrySafe: true` in the node binding. The start
  record saves this rule before node code starts. Resume records `node-resumed`
  for that in-flight position, then runs the same attempt again.
* **The result is not known.** If the saved rule is false, resume records a
  typed pause with a `reconcile-attempt` request and does not call node code.
* **The node is paused.** Resume records `node-resumed` for the exact position
  and offers the same attempt again.

`node-resumed` is recorded for a paused node and for an in-flight attempt that
already has a start record with `retrySafe: true`.

The saved `retrySafe` value controls crash recovery. A changed value in the
live binding does not change an earlier start record.

### Two nodes in flight

A graph can dispatch more than one node at a time. If the process stops with
two dispatches still open, a fresh executor returns `waiting` with both
positions. `resume` takes one position. After that attempt settles, if the
sibling is still unfinished, the result is `waiting` again with the sibling's
position:

```ts theme={null}
const first = await freshExecutor.run(signal);
if (first.kind !== 'waiting') {
  throw new Error('The run has no in-flight dispatch.');
}
const firstPosition = first.positions[0];
if (firstPosition === undefined) {
  throw new Error('The waiting result has no position.');
}
const next = await freshExecutor.resume(firstPosition, signal);
if (next.kind === 'waiting') {
  const sibling = next.positions[0];
  if (sibling === undefined) {
    throw new Error('The waiting result has no position.');
  }
  await freshExecutor.resume(sibling, signal);
}
```

At this stage, the executor checks the stored graph definition and plan. It
does not check general code or node-body fingerprints.

If the graph declares memory as required, `createGraphExecutor` also requires a
live `Memory` object. The executor passes that same object into the node attempt,
and a data-only node receives it in its context. The runtime check covers
TypeScript and plain JavaScript callers.

A data-only node can call another executor and return the child result. This is
how parent and child graphs compose without adding a second execution system.

## Turn-taking example

The outside example alternates a writer and critic for three rounds. Each critic
prompt includes that dispatch's position summary. The example stores the run,
starts the public executor, and uses a real dead-primary path through two
`MockEngine` instances. The first critic attempt records the primary as dead.
The next two critic attempts go straight to the fallback. The critic permits a
crash retry.

```bash theme={null}
pnpm example:turn-taking
```

The command reports six dispatches, one primary call, three fallback calls, and
the final result. The same source compiles and runs from the packed package with
TypeScript 6 and TypeScript 7.
