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:
{ 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:
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.