← stitchkitDocumentation

ADR 0174 — Runtime operations are durable run facts

Context

running begins before prompt projection, tool preparation and the provider call. A UI seeing only that state cannot truthfully distinguish local work from a model request that is waiting for its first output. Transient deltas reveal the end of that wait, but reconnect loses when it began. Consumer middleware around the model would create a second lifecycle with different identities and no canonical snapshot.

Assistant persistence had the inverse problem. checkpointEveryEvents bounded ordinary stream writes, but tool calls, tool results, approvals and step ends were not boundaries of their own. A consumer that needed recoverable tool history therefore set the cadence to one and rewrote the growing assistant for every metadata and delta part.

Decision

AgentRun.lastOperation is the one durable current/last operation projection. Its kind is model-request or compaction; its phase is started, first-output, completed, failed or cancelled. It carries an operation identity, the model step when applicable, and the original observed timestamps. AgentRuntimeStore.recordRunOperation replaces that projection under the same owner, fencing-token and revision checks as other run mutations.

Every successful mutation publishes a durable run-operation event. For a concrete model, the AI SDK callback supplies its call identity but does not own durable admission: callback failures are notification failures and the SDK suppresses them. Awaited step preparation gates the causal step, and model middleware records request start before it invokes provider doStream; a rejected write therefore prevents the provider invocation. A global provider model ID cannot be wrapped before the SDK resolves it, so step preparation admits that path with a runtime-generated request identity instead. The boundary is not run acquisition and does not claim that an HTTP request crossed the network. Every identity is unique within the run and includes the zero-based step. The first-output phase requires the first non-empty text, reasoning or streaming tool-argument delta, or a complete parsed tool call. Metadata, stream-start, usage, files and sources do not qualify. Terminal phases never carry provider error text.

Compaction starts immediately before the configured compact callback and finishes from that invocation’s result, cancellation or error. It means “compaction callback / context preparation is running”; a not_needed result does not claim that a summary was produced or stored. No event is invented when no compactor is configured. A snapshot retains the last phase and its initial timestamp, so reconnect never substitutes connection time.

Timestamps are wall-clock observations, not a causal clock and not durations. NTP or an operator may move that clock backwards; schemas therefore validate their form and phase presence but do not order them. Duration measurement, when needed, uses the runtime’s existing monotonic performance clock.

Assistant checkpoints retain their configured batching cadence for ordinary parts. Independently, a tool call, tool result/error/denial, approval request/response or step finish forces a checkpoint and resets the batch counter. All live deltas are still published individually.

The structural checkpoint is awaited when the runtime observes the normalized stream boundary. It is not a persist-before-effect guarantee: the AI SDK may execute a tool before the consumer loop receives its result. The managed tool fence remains the pre/post-effect ownership boundary, and application idempotency remains necessary for crash-safe effects.

The next model request is a separate boundary: its middleware waits until the previous step-finish checkpoint has committed before recording the next request and entering doStream. It does not move tool execution behind persistence; it orders only the following provider request.

That ordering is between steps, and it is not by itself enough. Within one step the assistant checkpoint and the model-request admission are still two owned mutations issued by two independent schedules, and each one used to name the revision it read before its own await. A store that answers on a later tick — any store crossing a worker, a socket or a real database — therefore let the second write name a revision the first had already spent: the loser threw a store conflict out of wrapStream, and the run reported provider_failure without the provider having been called. Every owned mutation of a run now takes its turn in one per-run queue and reads the current revision inside that turn, so the revision a mutation names is the one its predecessor produced. The queue is per run, so independent runs still proceed in parallel; the terminal commit keeps its bounded retry, which suits a mutation whose repetition the store recognises as a duplicate.

Consequences

  • A live surface can distinguish compaction, provider wait and streaming using the same schema it reloads after reconnect.
  • One model run with tools has a distinct request identity per step without a second consumer event engine.
  • Failed lifecycle storage cannot be hidden by an SDK notification callback or followed by an unrecorded provider invocation.
  • Default batching writes proportional to batched stream parts plus structural boundaries, rather than one growing assistant record per delta.
  • Custom AgentRuntimeStore implementations must add recordRunOperation; normalized driver implementations built through createAgentRuntimeStore receive the reference reducer automatically.
  • A failure is named by whoever owned it. The lifecycle’s own conflicts end a run storage_conflict rather than provider_failure, so the durable record of a lost compare-and-set does not read as an upstream outage.
  • The record is a latest-operation projection, not an audit log or a provider trace. Durable history beyond the latest fact remains an application outbox concern.