openquiver-runtime 0.1.0

Event-sourced executor: exec tokens, journal, run resumption.
Documentation

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 with InDoubt::Retry runs 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. Parallel and Join nodes 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/cancel arrive 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.