ADR-0069: Reactive flow steering and recovery¶
- Status: Accepted
- Kind: Retrospective
- Area: scheduling-control-plane
- Date: 2026-07-09
- Relations: supersedes v0-0085, v0-0088
Context¶
li play is command sugar for li o flow -p <name>, so playbook runs and planned reactive flows use the same planning engine, dependency-aware executor, live control poller, and checkpoint writer. The executor may accept SpawnRequest values when reactive expansion is enabled, subject to a role filter and operation budget.
Live control and cross-process recovery are separate mechanisms. Live control writes session_controls rows from one process and requires a poller plus in-memory executor in the original process. Recovery writes checkpoint.json into the run directory and reconstructs a new executor in a later process. Neither mechanism is a durable job queue or a universal runner handle.
This ADR answers five concrete problems:
- P1 — A separate CLI process needs to steer a live executor. It cannot hold an in-memory Python reference, so requests need a durable transport and a single consumer.
- P2 — Pause cannot safely interrupt an arbitrary provider call. The implemented safe boundary is immediately before an operation acquires execution capacity and invokes its provider.
- P3 — Operator messages can be duplicated or missed. Message application and DB stamping have different crash semantics from idempotent pause/resume, and rendering must happen before an operation's last provider-call boundary.
- P4 — Process death loses the in-memory DAG. Planned topology, results, context, and configuration need an independently readable checkpoint.
- P5 — Reactive topology and branch conversation are not reconstructible from the current checkpoint. Resume must refuse or explicitly degrade rather than call a partial replay exact.
| Concern | Decision |
|---|---|
| Control addressing and transport | D1: Queue ordered pause, resume, and message rows only for live flow/play sessions. |
| Cooperative pause semantics | D2: Gate new operation starts at the executor boundary; never preempt an in-flight provider call. |
| Context-mode steering | D3: Apply messages at most once to shared flow context and render them once into a pending operation instruction. |
| Checkpoint persistence | D4: Atomically replace a versioned full-state JSON checkpoint after operation outcomes. |
| Resume and reactive-growth bounds | D5: Replay the recorded plan without planning; refuse spawned topology and gate degraded inherited context. |
Out of scope:
- Terminal cancellation is not shipped;
stopis reserved in the table but has no CLI producer or executor consumer. - Operation-node message injection is not shipped; only context-mode
msgexists. - Mid-provider interruption, provider fallback, usage quotas, and model changes are not control verbs.
- Fixed WorkflowDef and scheduled-run controls are not implied; those paths do not run this poller.
- Checkpoints are not queue leases, worker heartbeats, or exactly-once job persistence.
- Full filesystem crash durability is not claimed: replacement is atomic to readers, but the current writer does not issue an explicit file or directory
fsync.
Decision¶
D1 — Ordered session-control rows are the live transport¶
The public writers are:
# lionagi/cli/orchestrate/_control.py
def run_ctl_pause(args: argparse.Namespace) -> int: ...
def run_ctl_resume(args: argparse.Namespace) -> int: ...
def run_ctl_msg(args: argparse.Namespace) -> int: ...
async def _enqueue_control_inner(
*, entity_id: str, verb: str, payload: dict[str, Any] | None
) -> tuple[str, int]: ...
Their persisted contract is:
-- lionagi/state/schema.sql
CREATE TABLE session_controls (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
verb TEXT NOT NULL CHECK(verb IN ('pause', 'resume', 'message', 'stop')),
payload JSON,
created_at REAL NOT NULL,
applied_at REAL,
result TEXT
);
CREATE INDEX idx_session_controls_pending
ON session_controls(session_id, applied_at) WHERE applied_at IS NULL;
Publicly accepted verbs are pause, resume, and message. stop is schema-reserved and is rejected by the current poller as unsupported if inserted by another caller. Pause and resume use payload=NULL; message uses {"text": <string>}. StateDB.insert_session_control() creates a UUID4-hex id, a current epoch timestamp, and leaves applied_at and result null.
Exact addressing and queue semantics:
- The CLI accepts a session, invocation, or play id, or a short prefix.
- Prefix search is ordered by
sessions, theninvocations, thenplays. Two matches in the first matching table are rejected as ambiguous; a full-length id skips the prefix ambiguity check. - A missing state database, unknown id, non-
runningsession, or invocation kind outside{"flow", "play"}fails without inserting a row. - The insert is bounded by
_DB_BUSY_TIMEOUT_S = 10.0; timeout reports a busy database. The value mirrors the status surface and prevents an unbounded CLI hang; no empirical rationale for ten seconds is recorded in source. - Success means only “queued.” The message says application is expected within roughly two seconds, but the writer does not wait for application.
- The poller wakes every
_CONTROL_POLL_INTERVAL = 2.0seconds. This is the control latency floor when the event loop is healthy; the source records no measured rationale for the exact value. - Pending rows are read oldest-first by
(created_at, id). The id tie-break makes rapid enqueues deterministic. - A transient list failure is retried on the next tick and does not fail the run.
- If a successful effect cannot be terminally stamped, the poller stops that tick so a later control cannot overtake the unstamped row.
Terminal result values are applied, applying, or rejected:<reason>. The poller records a compact control log in session node_metadata, but session_controls remains the authoritative request/application row.
Why this way: a database row lets a separate CLI process address a live flow without a process-wide handle registry. Restricting insertion to running flow/play sessions prevents controls from sitting pending forever against runners with no consumer.
Code anchors: lionagi/cli/orchestrate/_control.py; lionagi/cli/orchestrate/flow.py (_control_poll_loop, _apply_session_control); lionagi/state/db.py (session-control methods); lionagi/state/schema.sql.
D2 — Pause and resume are idempotent operation-boundary effects¶
The executor contract is:
# lionagi/operations/flow.py
class DependencyAwareExecutor:
def pause(self) -> None: ...
def resume(self) -> None: ...
async def _execute_operation(
self, operation: Operation, limiter: CapacityLimiter
): ...
pause() installs an unset ConcurrencyEvent only when no pause event exists. resume() sets the current event and clears the reference; calling resume while unpaused is a no-op. Before a ready operation enters the concurrency limiter, _execute_operation() loops while a pause event exists, emits NodePaused, and waits for that particular event.
Exact semantics:
- Pause twice: both controls apply; the second call retains the same gate. There is no nested pause count.
- Resume twice: the first releases and clears the gate; the second is a no-op.
- Resume followed by pause: a fresh event is installed, so operations reaching the boundary after the later pause wait on the new gate.
- Operation already past the boundary: it continues through
operation.invoke(); pause never cancels or interrupts its provider/tool activity. - Operation waiting on dependencies: it encounters the gate only after edge checks and dependency completion.
- Skipped or already-terminal operation: it completes its executor bookkeeping without waiting on the pause gate.
- Run exits while paused: the poller dies with the executor; queued future controls have no consumer. The current code has no terminal cancel-on-pause behavior.
- Poller crash after effect but before stamp: pause/resume are safe to reapply.
_finalize_appliedretries the terminal stamp twice, then leaves the row pending for a later tick.
The latency bound is cooperative, not real-time: control-poll delay plus time until an operation reaches the boundary. A long provider call can finish after pause acknowledgement because it was already in flight.
Why this way: an operation boundary has clear state and does not require provider-specific cancellation guarantees. The tradeoff is latency; immediate preemption would need compensating semantics for tools, partial provider streams, and branch persistence that the current executor does not define.
D3 — Operator messages use at-most-once context steering¶
The persisted input is:
{"verb": "message", "payload": {"text": "operator correction"}}
The applied workspace entry is:
{"ts": time.time(), "text": payload.get("text", "")}
Unrendered entries become one instruction prefix:
[OPERATOR STEER]
A human operator sent these live corrections while this flow is running.
Attend to them before continuing. Most recent last.
- <UTC timestamp>: <text>
[/OPERATOR STEER]
<original instruction>
The apply sequence differs deliberately from pause/resume:
- If the row already has
result='applying', leave it untouched. - Stamp
result='applying'whileapplied_atremains null. - Confirm that at least one Operation in the current graph still has
PENDINGstatus. - Append the entry to
executor.context.content["operator_messages"]. - Finalize the row as
applied.
Exact semantics:
- No pending operation: finalize as
rejected:no-pending-ops; completed/in-flight operations are not rewritten. - Unknown verb: finalize as
rejected:unsupported-verb:<verb>. - Apply exception: finalize as
rejected:error:<message>truncated to 500 characters. A failure to finalize returns the unstamped sentinel and stops later controls for the tick. - Crash before
applying: the next poll may try normally. - Crash after
applying: the row remains visibly mid-apply and is never reapplied automatically. This chooses at-most-once delivery over possible duplication; it can lose a message whose effect had not landed. - Rendering:
_prepare_operation()copies shared context and renders pending entries at dependency-render time._render_pending_operator_steers()checks the canonical shared queue again immediately beforeoperation.invoke()to close the window where a message arrives after preparation. - Consume once: rendered entries receive
rendered_into_op=<operation-id>and are skipped by later operations. Theoperator_messageskey is removed from the per-operation context so raw control JSON is not separately sent to the model. - Several pending messages: they are rendered together in stored order, with the most recent last.
- Empty text: the current payload accessor produces an empty list item; the CLI requires a text argument but the poller itself does not reject an empty string.
Why this way: context steering changes the next usable instruction without mutating graph topology. Stamp-before-apply is the honest crash choice for a non-idempotent append. Operation-node injection was not taken because it would require graph placement, dependencies, branch selection, and checkpoint replay semantics that do not exist.
Code anchor: lionagi/operations/flow.py (_render_operator_messages, _render_pending_operator_steers); lionagi/cli/orchestrate/flow.py (_apply_session_control).
D4 — Checkpoints are full JSON snapshots replaced atomically¶
The writer is a run-local dataclass:
# lionagi/cli/orchestrate/_checkpoint.py
CHECKPOINT_VERSION = 1
@dataclass
class CheckpointWriter:
path: Path
session_id: str
prompt: str
plan: list[dict]
config: dict[str, Any]
flow_context: dict[str, Any] = field(default_factory=dict)
ops: dict[str, dict[str, Any]] = field(default_factory=dict)
spawned: list[dict] = field(default_factory=list)
_lock: asyncio.Lock = field(default_factory=asyncio.Lock, repr=False, compare=False)
_seq: int = field(default=0, repr=False, compare=False)
def to_dict(self) -> dict[str, Any]: ...
async def record(
self,
agent_id: str,
*,
status: str,
response: Any,
flow_context: dict[str, Any] | None = None,
) -> None: ...
async def record_spawned(
self,
node_id: str,
*,
status: str,
response: Any,
flow_context: dict[str, Any] | None = None,
) -> None: ...
Wire shape:
{
"version": 1,
"session_id": "...",
"prompt": "...",
"plan": [{"agent_id": "...", "dep_indices": [], "assignee": "..."}],
"flow_context": {},
"ops": {
"planned-agent-id": {
"agent_id": "planned-agent-id",
"status": "completed|failed",
"response": "..."
}
},
"spawned": [
{"node_id": "operation-uuid", "status": "completed|failed", "response": "..."}
],
"config": {"model_spec": "...", "reactive_spec": "...", "max_ops": 0}
}
Exact persistence semantics:
- A writer is created only when checkpoint configuration is provided. Fresh and resumed CLI flows construct one; unit seams that omit configuration do not.
flush()writes an initial snapshot before execution.- Planned nodes key
opsby stable agent id. Spawned nodes use their operation UUID in a separate list so a cloned branch name cannot overwrite a planned entry. - Re-recording a spawned node replaces the matching list entry; otherwise it appends.
- Completion observers schedule writes, and
_execute_dag()awaits all scheduled checkpoint tasks before returning. - The
asyncio.Lockserializes full snapshots. Each write uses a uniquecheckpoint.<sequence>.tmp, serializes withjson.dumps(..., default=str), then callsos.replace(tmp, checkpoint.json). A reader therefore sees the old or new complete JSON file, not a partially overwritten target. - The latest operation completion replaces the shared
flow_contextsnapshot. It is an accumulation, not per-operation history. - Serialization failures leave the previous target in place; the current caller suppresses some checkpoint task exceptions at drain time, so checkpoint freshness is not guaranteed when a write fails.
load_checkpoint()performsjson.loads(path.read_text()). The current resume path does not reject an unsupportedversionfield explicitly; version 1 is descriptive, not a complete migration gate.
CHECKPOINT_VERSION = 1 marks the initial shipped shape. No rationale beyond initial format versioning is recorded.
Why this way: full snapshots are simple to inspect and avoid a partial event-log replay protocol. Atomic replacement protects readers from torn target files. The cost is write amplification after every completion and the lack of fsync-level durability or schema migration enforcement.
D5 — Resume replays the original plan and refuses unfaithful topology¶
Resolution and replay contracts are:
async def resolve_checkpoint_target(target: str) -> tuple[RunDir, dict[str, Any]]: ...
def _apply_checkpoint_precompletion(
env: OrchestrationEnv,
plan_result: _PlanResult,
dag_state: _DagState,
checkpoint_ops: dict[str, dict],
*,
allow_degraded_context: bool,
checkpoint_spawned: list[dict] | None = None,
) -> None: ...
async def _resume_flow(
target: str,
*,
allow_degraded_context: bool = False,
dry_run: bool = False,
show_graph: bool = False,
notify: str | None = None,
) -> tuple[str, str]: ...
Exact resolution semantics:
- An exact run directory is preferred. Otherwise
_find_run_dir_by_id()chooses the most recently modified directory matching the supplied prefix; it does not report multiple prefix matches. - If no run directory checkpoint is found, the target is resolved through session/invocation/play storage, then through the session's
node_metadata.run_id. - Missing target, missing backing session, missing run id, or missing
checkpoint.jsonraisesFlowResumeError. dry_run,show_graph, and an explicit notify override come from the new invocation. Model, prompt, playbook, operation cap, reactive policy, and other execution settings come from the checkpoint.
Exact replay semantics:
- The persisted
planis reconstructed directly; the planner is not called. - An empty plan is rejected.
- Previously completed nodes are pre-marked
COMPLETEDwith their response. Previously failed nodes are pre-markedFAILED; resume does not guess that retrying their possibly side-effecting operation is safe. - Pending nodes run normally and receive restored shared result context.
- If any pending node requested
inherit_context, resume refuses because predecessor conversation messages are not restored.--allow-degraded-contextpermits those named nodes to run with an empty branch while retaining result context. - Any non-empty
spawnedcheckpoint list refuses resume. The current writer records spawned outcomes, but the plan does not contain their position or branch inheritance, so replay would silently drop work. - A resumed run writes its own checkpoint and records
resumed_from=<original-session-id>.
Reactive growth itself is bounded by the live executor:
async def flow(
session: Session,
graph: Graph,
*,
reactive: bool = False,
spawn_type: type | None = None,
node_builder: Any = None,
max_spawn: int = 50,
executor_ref: dict[str, Any] | None = None,
...,
) -> dict[str, Any]: ...
The CLI parses --reactive off as disabled, all/truthy spellings as all roles, and any other comma-separated value as an allowed role set. When --max-ops N is positive, initial planned nodes and spawns share the cap: max_spawn=max(0, N-len(plan)). Without a cap the CLI uses a conservative default of 20 spawned nodes to bound cost; the generic executor default remains 50. The CLI source explains the 20-node value only as a conservative guard against costly uncapped fan-out; no measured tuning record exists. Plans above 200 assignments are truncated to 200 as a runaway-planner defense; the numeric threshold likewise has no recorded empirical rationale.
Spawn attempts reject and record builder_error, null_child, cycle, max_spawn_exceeded, or duplicate. Accepted nodes receive a branch clone, an optional dependency edge from the emitter, and a NodeSpawned signal. These live semantics do not make their topology recoverable by the version-1 checkpoint.
Why this way: replaying a stable planner output avoids paying for or drifting through a second plan. Refusal is preferable to silently dropping spawned work or conversational inheritance. The explicit degraded flag makes the one supported fidelity loss visible to the caller.
Consequences¶
- A separate CLI process can steer a live flow through the database without an in-process handle registry.
- Pause is safe at operation boundaries but can lag behind long in-flight calls.
- Context steering is at-most-once; a crash in the
applyingwindow may lose a message and leaves a visible row requiring diagnosis. - Full checkpoint snapshots are inspectable and atomically visible, but are not a transactional log and do not guarantee fsync-level persistence.
- Resume is planner-free and preserves completed/failed outcomes, but refuses checkpoints containing reactive spawn outcomes and requires an explicit flag to lose inherited conversation state.
- Contributors extending control must understand verb-specific apply/stamp ordering; treating a message like idempotent pause/resume would introduce duplicate steering.
- Reversing D1-D3 requires a new control transport and compatibility for pending rows. D4's JSON format can be versioned, while D5's refusal rules may be relaxed only when the missing state is actually persisted and replayed.
Current-vs-ideal delta¶
| # | Delta | Size | Issue |
|---|---|---|---|
| 1 | Add a terminal reactive-flow cancel control; acceptance: after acknowledgement, no new operation starts and the run persists a cancelled terminal state. | M | (filled at issue-open time) |
| 2 | Replay reactively spawned nodes from checkpoints or reject affected checkpoints before execution; acceptance: resume never returns a partially replayed spawned DAG as successful recovery. | M | #2015 |
| 3 | Persist predecessor conversation state needed by inherit_context; acceptance: eligible resumed operations receive the same inherited conversation without requiring degraded mode. | L | #2015 |
| 4 | Enforce checkpoint-version compatibility and report unsupported versions before graph construction; acceptance: a checkpoint with an unknown version fails with a typed migration/compatibility error. | S | (filled at issue-open time) |
| 5 | Add operator recovery for controls stranded in applying; acceptance: status distinguishes definitely-applied, definitely-not-applied, and indeterminate messages without automatic duplicate injection. | M | (filled at issue-open time) |
Alternatives considered¶
Process-local runner handle registry¶
A registry could map run ids to executor objects and make pause/resume direct method calls. It would buy lower latency and simpler stamping, but a separate li process could not reach it without another IPC service, and a restart would erase every handle. It lost because the shipped control surface is explicitly cross-process.
Mid-provider cancellation¶
Cancelling the coroutine immediately would buy faster stop/pause response. It lost because provider calls and tool actions may have external side effects and no uniform compensation contract. The operation boundary is the implemented point where “has not started” is unambiguous.
Operation-node message injection¶
A message could become a first-class graph node with its own branch and dependencies. That would buy topology-level observability and targeted placement. It lost because the current design has no contract for where to attach the node, how to replay it, or how it should inherit context. Context mode meets the steering need without pretending those questions are solved.
Apply message then stamp¶
This ordering would avoid messages stranded in applying. It lost because a crash after append but before stamp would cause the next poll to append the same instruction again. The chosen stamp-then-apply order accepts a visible at-most-once loss window instead of silent duplication.
Incremental checkpoint event log¶
Appending one event per completion would reduce full-file write amplification and could support more exact replay. It lost for the current implementation because it needs log framing, compaction, corruption recovery, schema migration, and a deterministic fold. A full JSON snapshot is much smaller operational machinery for current flow sizes.
Replan on resume¶
Calling the planner again could reconstruct a complete graph without storing as much topology. It lost because planner output is not deterministic: dependencies, roles, and node count could change, invalidating completed results and provenance.
Silently skip unsupported spawned nodes or inherited conversation¶
This would maximize the number of checkpoints that appear resumable. It lost because the resulting run is not faithful. The code now refuses spawned checkpoints and requires an explicit degraded flag for inherited conversation.
Use the durable task queue as the live-control channel¶
Controls could be tiny queue jobs, buying one generic transport. It lost because task admission and executor-local steering have different ownership and terminal semantics. A worker claiming a pause job would still need the live executor reference this design already exposes directly to the poller.
Notes¶
The schema reserves stop, but reservation is not implementation. Current public vocabulary is only pause | resume | message. Checkpoint recovery is a reactive-flow feature and must not be described as durable task persistence.