Skip to main content
run(options?) claims the Lock and drives Rounds — Execute → Handoff → Verify → Persist — until a Verdict settles the Loop or something interrupts. It returns a Run handle:
The engine self-drives: a Run runs whether or not you iterate. The iterator is a Client’s view of the event stream — breaking out of it unsubscribes, it does not cancel. Every event carries its own { seq, round, phase }; ignore types you don’t know. Iterating never throws: once the handle exists, everything ends as an exit event.

cancel and done

run.cancel() stops the Run; it resolves to an exit event with cause: "cancel". run.done() resolves to the Exit — how this Run ended:
Unsettled, the Loop stays live for the next Trigger.

RunOptions

Yield slicing: rounds and deadline

rounds and deadline bound this one Run, not the Loop. At the bound the Run exits { settled: false, cause: "yield" } without settling, and the next run() resumes from the disk cursor. The whole-Loop guard is limits.rounds on the config — a different knob.

LoopBusy

run() throws LoopBusy synchronously when the Lock is held by a live owner (a fresh heartbeat). It does not block or queue — a live owner refuses overlap. The error carries the owner’s pid and heartbeatAgeMs so a host can report who holds the Workspace.
force: true claims the Lock anyway. The caller is responsible for having stopped that owner first — the CLI’s loop run --force stops the process before claiming; an embedding host must do its own equivalent.