openquiver-runtime
The executor: exec tokens, the value store, and run resumption.
How execution works
An exec token walks the white wires from an event node. At each node the runtime resolves that node's inputs, runs it, records the outputs, and continues from the execution output the node chose. Traversal was already done at compile time, so this is a walk over a precomputed plan rather than a search.
step runs exactly one node, and run is a loop over step. That is not a
convenience: a node is the unit a run is interrupted at, and it is also the unit
a debugger steps by, because a breakpoint and a crash leave the journal in
exactly the same shape.
The journal is the state
Nothing is held anywhere else. Each node writes node.entered before it touches
anything and node.exited — with its outputs and the execution output it chose —
afterwards. Resuming replays those records forward: nodes that recorded an exit
are not run again, their outputs are read back from what they wrote, and work
continues from the front.
The vocabulary lives here rather than in openquiver-store, which deliberately does
not know what an event means. That is what lets the set grow without a schema
migration each time.
| kind | payload |
|---|---|
run.started |
workflow |
node.entered |
node |
node.exited |
node, outputs, exec |
print |
text |
run.finished |
— |
An entry whose kind this build does not recognise is refused rather than skipped. Skipping means replaying a journal minus the part you did not understand and continuing from a state that never existed. The same reasoning refuses a journal that never started, one that starts twice, and one that continues past its own end: read leniently, each of those is a run that finished having done nothing, and it would be recorded as a success.
run.started carries a fingerprint of the plan, and resuming refuses a
journal whose fingerprint does not match. Without it, editing a workflow while a
run is suspended is silently destructive: a renamed node fails loudly, but a
deleted wire makes the resumed run stop early and report success.
A session appears in the journal as its handle, never as the object: the agent process does not survive a reboot, so resuming re-opens the conversation from the handle instead of starting a new one. That is why ACP was the right substrate — session resumption is in the protocol rather than bolted on.
The honest limit
A node that died between node.entered and node.exited is in an unknown state:
the issue in the tracker may or may not have been created, and no journal can
tell you which. Every node type therefore declares a recovery policy, and the
safe answer is the default — a type has to claim the cheap one deliberately.
Recovery::Retry— safe to run again. Resuming just runs it.Recovery::Ask— only a human can say. Resuming refuses and names the node; resuming again withInDoubt::Retryruns it, accepting the double effect.
acp.prompt is the one node in this milestone that is not Retry. A turn
interrupted halfway may have been answered, thought about and paid for, and
nothing distinguishes that from a turn the agent never saw.
Retry is not free either, and the tests say so out loud: a repeated io.print
really does print twice. It is chosen where repeating is cheap, not where it is
invisible.
A run stopped by something that will fail identically next time — a graph the
runtime cannot walk, a journal it cannot read — is recorded as failed, not
suspended. Left suspended it would sit in the list of runs to pick up, and every
attempt would append another node.entered to a journal that can never reach an
end.
Where a run stops
Execution ends when a node leaves by an execution output with nothing wired to
it. That is ordinary Blueprint semantics — an unwired false on a Branch means
"do nothing in that case" — but it deserves attention on acp.prompt, whose four
outputs exist precisely so a refusal is not mistaken for an answer. A graph that
wires only end_turn treats a refusal as the end of the workflow. The journal
records which output was taken, so the run is readable; nothing warns you at
compile time yet.
Not guarded against
Two runtimes on one run. resume accepts any run the store calls resumable,
and there is no lease. Two processes resuming the same run will both walk it and
both prompt the agent. Status alone cannot fix this — a crashed run is left
Running, so refusing Running would refuse exactly the case resumption exists
for. The answer is a lease held by the daemon, which is where runs will live.
Determinism is a load-bearing requirement
Because resumption replays the journal, replay must produce the same thing.
Ambient nondeterminism is therefore banned from pure nodes: randomness comes from
a seed, and the clock is a Now node whose result is journaled rather than read
again at replay time. PureNode will enforce this structurally by never handing
out an I/O context.
Not built yet
- Pure nodes and per-tick memoization. Every node this milestone ships is impure, so there is nothing to memoize. It arrives with the first pure node.
- Parallelism. A run has one frontier.
ParallelandJoinnodes fan out and collect explicitly — inferring concurrency from data dependencies would make it impossible to reason about how many agents are running and what they cost — but neither node exists yet, and one frontier is the whole of M1. - Snapshots. Resumption replays the journal from the start, which is free at the length an M1 run reaches. Snapshots every N events are what keeps that free once runs are long.
- Pause and cancel. Suspension already works — it is what a killed process
leaves behind, and what a resumable run is picked up from. Soft pause and
cascading cancellation with
session/cancelarrive with the daemon, which is where a run that nobody is watching will live. - Several event nodes in one graph. A run begins at one, and choosing which is a question for the Run panel.