Skip to main content

mlua_swarm/store/run/
mod.rs

1//! `RunStore` — persistence for `Run` records (one kick of a `Task`).
2//!
3//! Part of the issue #13 ID-hierarchy reconciliation: Blueprint -> Task ->
4//! Run -> Step -> Attempt. A [`RunId`](crate::types::RunId) is minted
5//! server-side each time a [`crate::store::task::TaskRecord`] is kicked; it
6//! carries a lightweight trace of the steps dispatched during that kick
7//! ([`StepEntry`]) for observability, plus its own outcome status
8//! independent of the owning Task's coarser status. A single Task can have
9//! N `Run`s over its lifetime (`list_by_task`).
10//!
11//! Current scope:
12//!
13//! - [`InMemoryRunStore`] — process-volatile default.
14//! - [`SqliteRunStore`] — file-backed persistence via `rusqlite-isle`.
15//!   `step_entries` is a JSON column, not normalized into its own table —
16//!   this is a trace/observability artifact, not something queried
17//!   relationally.
18//! - Other persistent backends (Git / mini-app / …) are future carries.
19
20use crate::blueprint::BindingDigest;
21use crate::store::replay::{ReplayCursor, ReplayStore};
22use crate::types::{RunId, StepId, TaskId};
23use async_trait::async_trait;
24use serde::{Deserialize, Serialize};
25use std::collections::{BTreeMap, HashMap};
26use std::sync::{Arc, Mutex};
27use thiserror::Error;
28
29pub mod inmemory;
30pub mod sqlite;
31pub use inmemory::InMemoryRunStore;
32pub use sqlite::SqliteRunStore;
33
34// ──────────────────────────────────────────────────────────────────────────
35// RunStatus / StepEntry / RunRecord
36// ──────────────────────────────────────────────────────────────────────────
37
38/// Lifecycle status of a [`RunRecord`] — the outcome of one specific kick
39/// of a Task.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
41#[serde(rename_all = "snake_case")]
42pub enum RunStatus {
43    /// Minted, not yet dispatched.
44    Pending,
45    /// Steps are currently being dispatched for this Run.
46    Running,
47    /// The Run completed successfully.
48    Done,
49    /// The Run failed.
50    Failed,
51    /// The Run was still `Running` when the server process restarted
52    /// (issue #35 ST2 boot-time recovery sweep). Terminal — in-flight
53    /// `EngineState` is process-local and unrecoverable; this variant
54    /// records the fact without attempting to reconstruct or resume it.
55    Interrupted,
56    /// A cancel request landed on the Run (via `POST /v1/runs/:id/cancel`
57    /// / `mse_cancel` / `swarm_cancel`). Terminal — the current wiring
58    /// records the intent + trace event; live in-flight abort of the
59    /// still-dispatching flow remains a v3 carry, so a Run that reaches
60    /// its Ok outcome after this marker keeps its terminal `result_ref`,
61    /// but the Cancelled marker itself is observable via
62    /// `swarm_status.cancel_requested` and `core.cancel_requested` on
63    /// the trace stream.
64    Cancelled,
65}
66
67/// One worker-reported degradation entry — a worker fell back to a
68/// substitute behavior instead of failing outright (e.g. a tool call errored
69/// and the worker used a cached/default value). Independent channel from
70/// [`StepEntry`]/`result_ref`: degradations never flow through step OUTPUT
71/// or the fold path (GH #32; sibling of the GH #34 audit sidecar — both
72/// keep observational signal off the BP-chain value). Reported via `POST
73/// /v1/worker/degradation`; the server injects `step_ref`/`attempt`/`at`
74/// before persisting.
75#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
76pub struct DegradationEntry {
77    /// The tool (or capability) the worker attempted to use.
78    pub tool: String,
79    /// The error that triggered the fallback, in the worker's own words.
80    pub error: String,
81    /// What the worker substituted instead of failing.
82    pub fallback: String,
83    /// Optional free-form context from the worker.
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub note: Option<String>,
86    /// The Blueprint step ref (`Step.ref`) this degradation was reported
87    /// under, if known. Server-injected metadata, not worker-supplied.
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub step_ref: Option<String>,
90    /// The attempt number this degradation was reported under, if known.
91    /// Server-injected metadata, not worker-supplied.
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub attempt: Option<u32>,
94    /// Unix epoch seconds — when this entry was recorded. Server-injected.
95    pub at: u64,
96}
97
98/// One entry in a Run's step trace — appended as the engine dispatches
99/// (and finishes) each step. Purely observational: no field here is
100/// consulted for flow control.
101///
102/// The per-step stats extension (started/completed timestamps, duration,
103/// token usage, model, worker kind, variant-specific `adapter_data`) is
104/// additive: every field is `Option` + `#[serde(default)]` so rows
105/// written before the extension deserialize unchanged, and a dispatch
106/// where no boundary reported stats still appends a valid entry. The
107/// entry stays **write-once** — in-flight visibility belongs to the
108/// sibling [`crate::store::trace::TraceEvent`] stream, never to
109/// in-place updates here.
110#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
111pub struct StepEntry {
112    /// The step this entry traces.
113    #[schemars(with = "String")]
114    pub step_id: StepId,
115    /// The Blueprint step ref (`Step.ref`) that was dispatched, if known.
116    pub step_ref: Option<String>,
117    /// Free-form status label for this step at the time the entry was
118    /// recorded (e.g. `"dispatched"`, `"passed"`, `"blocked"`).
119    pub status: Option<String>,
120    /// Immutable Runner/Agent/Context snapshot digest used for this step.
121    /// `None` for rows created before BoundAgent launch wiring.
122    #[serde(default, skip_serializing_if = "Option::is_none")]
123    pub binding_digest: Option<BindingDigest>,
124    /// Unix epoch seconds — when this entry was recorded.
125    pub at: u64,
126    /// The attempt number the stats below describe (the LAST attempt the
127    /// dispatch ran), when a worker boundary reported one.
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub attempt: Option<u32>,
130    /// Unix epoch milliseconds — when the dispatcher began this step.
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    pub started_at_ms: Option<i64>,
133    /// Unix epoch milliseconds — when the step reached its outcome.
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub completed_at_ms: Option<i64>,
136    /// Wall-clock dispatch duration in milliseconds (dispatcher-measured,
137    /// worker-kind independent).
138    #[serde(default, skip_serializing_if = "Option::is_none")]
139    pub duration_ms: Option<u64>,
140    /// Milliseconds the step waited for a slot of its agent's
141    /// `max_concurrency` before it started. Not part of `duration_ms`.
142    /// `None` when the agent declares no limit.
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub queued_ms: Option<u64>,
145    /// Worker kind label (`"agent_block"` / `"subprocess"` / `"operator"`
146    /// / …) as reported by the worker boundary.
147    #[serde(default, skip_serializing_if = "Option::is_none")]
148    pub worker_kind: Option<String>,
149    /// The model that served the attempt, when known.
150    #[serde(default, skip_serializing_if = "Option::is_none")]
151    pub model: Option<String>,
152    /// Normalized token usage, when a worker boundary reported one.
153    #[serde(default, skip_serializing_if = "Option::is_none")]
154    pub usage: Option<crate::store::trace::TokenUsage>,
155    /// Number of LLM turns the attempt ran, when reported.
156    #[serde(default, skip_serializing_if = "Option::is_none")]
157    pub num_turns: Option<u32>,
158    /// Worker-kind-specific raw payload (size-capped, engine-opaque).
159    #[serde(default, skip_serializing_if = "Option::is_none")]
160    pub adapter_data: Option<serde_json::Value>,
161}
162
163impl StepEntry {
164    /// Construct an entry with only the pre-stats fields set — the shape
165    /// every pre-extension writer produced. Stats fields default to
166    /// `None`; the dispatcher's fold fills them when available.
167    pub fn basic(
168        step_id: StepId,
169        step_ref: Option<String>,
170        status: Option<String>,
171        binding_digest: Option<BindingDigest>,
172        at: u64,
173    ) -> Self {
174        Self {
175            step_id,
176            step_ref,
177            status,
178            binding_digest,
179            at,
180            attempt: None,
181            started_at_ms: None,
182            completed_at_ms: None,
183            duration_ms: None,
184            queued_ms: None,
185            worker_kind: None,
186            model: None,
187            usage: None,
188            num_turns: None,
189            adapter_data: None,
190        }
191    }
192
193    /// Fold a boundary-reported [`crate::store::trace::WorkerStats`]
194    /// into this entry (the dispatcher's outcome-time fold). `None`
195    /// fields in `stats` leave the entry untouched; `adapter_data` is
196    /// size-capped via [`crate::store::trace::cap_payload`].
197    pub fn with_worker_stats(mut self, stats: crate::store::trace::WorkerStats) -> Self {
198        self.worker_kind = stats.worker_kind.or(self.worker_kind);
199        self.model = stats.model.or(self.model);
200        self.usage = stats.usage.or(self.usage);
201        self.num_turns = stats.num_turns.or(self.num_turns);
202        self.adapter_data = stats
203            .adapter_data
204            .map(crate::store::trace::cap_payload)
205            .or(self.adapter_data);
206        self
207    }
208}
209
210/// Who currently holds one **slot** of a Run — the model's `Assignee`
211/// (`{ op, desc, gen }`), persisted as one value of the
212/// [`RunRecord::current`] map.
213///
214/// A "slot" is a Blueprint-declared Operator seat: the `operator_ref` an
215/// agent names (`Blueprint.operators[].name`). A Blueprint may declare
216/// several, so a Run has as many slots as its Blueprint declares, each
217/// with its own holder over time.
218///
219/// Invariants this type carries (model §4.3):
220///
221/// - **A1** `|Run.current| ≤ 1` **per slot** — expressed as the map keyed
222///   by slot on [`RunRecord::current`]: one key cannot hold two values, so
223///   a seat cannot have two holders. The slot is the map key, never a
224///   field of this struct.
225/// - **A3** [`Self::gen`] is immutable for the lifetime of an instance.
226///   Re-assignment never mutates an existing `Assignee`; the store mints a
227///   fresh instance with the next generation (**Q3**). Nothing in this
228///   crate takes `&mut Assignee`.
229/// - **A9** [`Self::desc`] is mandatory. The store rejects an empty (or
230///   whitespace-only) `desc` with
231///   [`RunStoreError::AssigneeDescRequired`]; the HTTP layer maps that to
232///   `400`. The store itself never decides a status code.
233/// - **A10** this is the one place a slot's current holder is recorded and
234///   the one place it is read from — the destination is not baked into any
235///   sibling field.
236///
237/// The `Assignee` does not cross the SAP boundary (model §4.7 T1): the
238/// primitives below the boundary carry an `operator`, never an assignee or
239/// a generation.
240#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
241pub struct Assignee {
242    /// Who holds the slot — the model's `OperatorId`, which is the key
243    /// space of the engine's operator registry. Session ids (`S-<hex>`)
244    /// and role aliases (`main-ai`) share that one key space (the WS login
245    /// path registers both), so this stays a plain `String` rather than
246    /// narrowing to a session id.
247    pub op: String,
248    /// Why this holder was assigned — the human-readable record of the
249    /// assignment. Required (**A9**); an empty value is rejected at the
250    /// store boundary rather than stored as `""`.
251    pub desc: String,
252    /// The generation stamped on this holder at acquire time (**A4**:
253    /// `G` after the increment). Immutable for the lifetime of the
254    /// instance (**A3**) — a later acquire produces a NEW `Assignee` with
255    /// a higher `gen` instead of rewriting this one.
256    ///
257    /// `G` is a single counter per **Run**, not per slot: an assignment to
258    /// any slot advances the one counter, so two holders of different
259    /// slots can be ordered against each other by `gen` alone.
260    pub gen: u64,
261}
262
263/// What [`RunStore::vacate_assignee`] did — it releases a seat only while
264/// that seat still holds the generation the caller observed, so "released"
265/// and "someone else got there first" are two answers, not one.
266///
267/// # Why a release has to name a generation
268///
269/// A release is issued by a caller that *read* the holder earlier and then
270/// decided it should go: **A7** reads a holder, asks its adapter for
271/// `T-ALIVE`, and releases on `Disconnected`; **O8**'s cascade reads a
272/// holder, matches it against a deleted operator's names, and releases.
273/// Both decisions are about the `Assignee` that was read, and both have
274/// `.await` points between the read and the write, during which an
275/// `acquire` (which never excludes — **A8**) can seat somebody else.
276///
277/// Addressed at `(run, slot)` alone, such a release would delete whatever
278/// holder happened to be there — a holder whose liveness was never asked
279/// for and whose deletion no premise in the model supports. That is a lost
280/// update, not **A8**: the acquirer was answered `200` with its generation
281/// (**Q4**) and has no channel to learn it was undone. Carrying the
282/// observed generation turns the write back into the decision that was
283/// actually made.
284///
285/// `gen` alone identifies the holder because `G` is Run-wide and advances
286/// on every assignment event, so a generation is never reused — a seat
287/// holding `expected_gen` is holding the very instance the caller read,
288/// including when a re-acquire put the *same* operator back (**A8**).
289#[derive(Debug, Clone, PartialEq, Eq)]
290pub enum VacateOutcome {
291    /// The seat still held the observed generation, so it was released and
292    /// the slot is now `Vacant`.
293    Released {
294        /// The Run-wide counter `G` after this event (**A4**: a `Vacant`
295        /// advances it exactly like an `Assign` does).
296        generation: u64,
297        /// The holder that was released — the instance the caller read.
298        released: Assignee,
299    },
300    /// The seat did not hold the observed generation, so **nothing was
301    /// written**: no holder removed, `G` not advanced, `updated_at`
302    /// untouched.
303    ///
304    /// The caller's reading is stale — either an `acquire` moved the seat
305    /// on (**A8** already decided that contest, and the newer holder
306    /// stands) or the seat was released by someone else in between. Either
307    /// way the release is not re-issued against the new state: the
308    /// decision behind it was made about a holder that is no longer there.
309    Stale {
310        /// Who holds the seat now, for the message the caller reports.
311        /// `None` = the slot is already `Vacant`.
312        current: Option<Assignee>,
313    },
314}
315
316/// One persisted `Run` row — one kick of a [`crate::store::task::TaskRecord`].
317#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
318pub struct RunRecord {
319    /// Run identifier.
320    #[schemars(with = "String")]
321    pub id: RunId,
322    /// The Task this Run was kicked from.
323    #[schemars(with = "String")]
324    pub task_id: TaskId,
325    /// Current lifecycle status.
326    pub status: RunStatus,
327    /// Trace of dispatched steps, in append order.
328    pub step_entries: Vec<StepEntry>,
329    /// Worker-reported degradations, in append order (GH #32). Independent
330    /// channel from [`Self::step_entries`]/[`Self::result_ref`] — see
331    /// [`DegradationEntry`]'s doc for the invariant. `[]` (the default) =
332    /// no degradations reported — every pre-#32 Run is unaffected.
333    #[serde(default, skip_serializing_if = "Vec::is_empty")]
334    pub degradations: Vec<DegradationEntry>,
335    /// Operator session id bound to this Run, if any (WS operator
336    /// correlation).
337    ///
338    /// This is the **launch-time snapshot** of who was pinned when the Run
339    /// was kicked — not the live holder. The live holder is
340    /// [`Self::current`]; nothing resolves a dispatch destination from this
341    /// field, so the two are not competing destinations (**A10**).
342    pub operator_sid: Option<String>,
343    /// The Run's live holders, keyed by **slot** — the model's
344    /// `Run.current` (§4.3).
345    ///
346    /// A slot is a Blueprint-declared Operator seat (`operator_ref` =
347    /// `Blueprint.operators[].name`): a Blueprint may declare several, and
348    /// each agent picks the one it dispatches through. The cardinality is
349    /// therefore `Run 1 : N Operator` and `Operator 1 : 1 Assignee` (at a
350    /// time), hence `Run 1 : N Assignee` — with **A1** reading "at most one
351    /// holder **per slot**", which is exactly what a map expresses. An
352    /// absent key is that slot's `Vacant`; an empty map is a Run with no
353    /// slot held at all.
354    ///
355    /// **R2** — a `Vacant` slot does not stop the Run; only a dispatch that
356    /// needs *that* slot's holder is affected, and dispatches through other
357    /// slots are untouched. **R6** — this travels with the Run row, so a
358    /// restart does not drop the assignments.
359    ///
360    /// Only [`RunStore::acquire_assignee`] / [`RunStore::vacate_assignee`]
361    /// write it, both scoped to one slot, and both mint a fresh
362    /// [`Assignee`] rather than mutating a stored one (**Q3**).
363    ///
364    /// [`BTreeMap`](std::collections::BTreeMap) rather than `HashMap`: the
365    /// map is serialized into a persisted column and into observation
366    /// payloads, and key-sorted output keeps those bytes stable across
367    /// processes. Additive with `#[serde(default)]` so rows serialized
368    /// before the assignment axis existed decode unchanged (as an empty
369    /// map = every slot Vacant).
370    ///
371    /// # An empty map is written out, not skipped
372    ///
373    /// This field used to carry `skip_serializing_if =
374    /// "BTreeMap::is_empty"`, so a Run holding nothing had no `current`
375    /// key on the wire at all. That made "nobody holds anything on this
376    /// Run" and "this response does not report holders" the same bytes,
377    /// and §4.3 asks for the opposite (*居なければ居ないと分かる* — when
378    /// nobody is there, it must be possible to tell that nobody is there).
379    /// `"current": {}` says it. The per-seat form of the same answer, which
380    /// also names the seats nobody holds, is
381    /// [`crate::handover::run_assignees`].
382    #[serde(default)]
383    pub current: BTreeMap<String, Assignee>,
384    /// The Run's generation counter — the model's `G` (**A4**).
385    ///
386    /// `0` at launch. Every assignment event (`Assign` **or** `Vacant`)
387    /// increments it by one **before** stamping, so the first `Assign`
388    /// yields `gen == 1`; the counter therefore holds the generation of
389    /// the most recent event, and the next event will use this value `+ 1`.
390    /// The bump is unconditional — re-acquiring for the SAME `op` still
391    /// increments, because the counter counts events, not state changes.
392    ///
393    /// **One counter per Run, shared by every slot.** An `Assign` to slot
394    /// `b` advances the same `G` that a preceding `Assign` to slot `a`
395    /// advanced, so any two holders — of the same slot or of different
396    /// ones — can be ordered by `gen`. Per-slot counters would buy nothing
397    /// and would make that comparison meaningless.
398    ///
399    /// **A2** (`current = Assigned(a) ⟹ a.gen ≤ G`) holds on two legs, not
400    /// one. On the write path it holds by construction: every `current`
401    /// value's `gen` is stamped from this counter at the moment it is
402    /// bumped, so an acquire can never leave a holder above it. On the way
403    /// **in** it is checked — [`RunStore::create`] takes a caller-supplied
404    /// record with both fields public, so a record that arrives already
405    /// violating A2 is refused with
406    /// [`RunStoreError::AssigneeGenerationAhead`] rather than stored (see
407    /// [`RunRecord::validate_assignment_generations`]). Left unchecked, that
408    /// record would stay violated: the next acquire stamps generation 1,
409    /// below the seeded incumbent, and ordering two holders by `gen` — the
410    /// whole reason `G` is Run-wide — would silently invert.
411    ///
412    /// Additive with `#[serde(default)]` (pre-existing rows read back `0`).
413    #[serde(default)]
414    pub next_generation: u64,
415    /// The Run's terminal result payload, set once by
416    /// [`RunStore::set_result`]. `None` while the Run is in flight.
417    #[schemars(with = "Option<serde_json::Value>")]
418    pub result_ref: Option<serde_json::Value>,
419    /// Opaque JSON snapshot of the launch input this Run was kicked with
420    /// (blueprint / init_ctx / operator injection / ttl / …). The server
421    /// serializes its own launch-input struct into this string at Run
422    /// creation time so an `Interrupted` Run can be resumed under the SAME
423    /// `run_id` without re-deriving the input from a since-stale request
424    /// body. The store treats it as an opaque blob — the schema is owned by
425    /// the caller (the server crate). `None` = no snapshot recorded (older
426    /// rows predating resume support, or a caller that never opts in); such
427    /// a Run cannot be resumed. Additive with `#[serde(default)]` so
428    /// pre-existing serialized rows deserialize unchanged.
429    #[serde(default, skip_serializing_if = "Option::is_none")]
430    pub input_json: Option<String>,
431    /// Unix epoch seconds — creation time.
432    pub created_at: u64,
433    /// Unix epoch seconds — last update time.
434    pub updated_at: u64,
435}
436
437impl RunRecord {
438    /// **A2** as a check: every holder in [`Self::current`] must have been
439    /// stamped at or below [`Self::next_generation`]
440    /// (`current = Assigned(a) ⟹ a.gen ≤ G`).
441    ///
442    /// [`RunStore::create`] calls this on the record it is handed, and every
443    /// [`RunStore`] implementation is expected to — an out-of-tree backend
444    /// that skips it accepts records the two in-tree backends refuse.
445    /// Nothing else needs it: `acquire_assignee` stamps `gen` from the
446    /// counter it has just bumped, so no write path in this crate can
447    /// produce a record this rejects.
448    ///
449    /// Reports the first offending seat in [`Self::current`]'s key order,
450    /// which is stable (`BTreeMap`), so the same bad record always names the
451    /// same seat.
452    pub fn validate_assignment_generations(&self) -> Result<(), RunStoreError> {
453        for (slot, assignee) in &self.current {
454            if assignee.gen > self.next_generation {
455                return Err(RunStoreError::AssigneeGenerationAhead {
456                    slot: slot.clone(),
457                    gen: assignee.gen,
458                    next_generation: self.next_generation,
459                });
460            }
461        }
462        Ok(())
463    }
464}
465
466/// Filter/paging parameters for [`RunStore::list`] — the `GET /v1/runs`
467/// collection query. All filters AND together; results are newest-first
468/// (`created_at` descending, ties broken by insertion order where the
469/// backend tracks one).
470#[derive(Debug, Clone, Default)]
471pub struct RunListFilter {
472    /// Only Runs kicked from this Task.
473    pub task_id: Option<TaskId>,
474    /// Only Runs currently in this status.
475    pub status: Option<RunStatus>,
476    /// Page size cap. `None` = no cap.
477    pub limit: Option<usize>,
478    /// Skip the first N matching rows (after ordering).
479    pub offset: Option<usize>,
480}
481
482/// Errors surfaced by a [`RunStore`] implementation.
483#[derive(Debug, Error)]
484pub enum RunStoreError {
485    /// No Run exists for the given id.
486    #[error("run not found: {0}")]
487    NotFound(RunId),
488
489    /// `create` was called with an id that is already stored.
490    #[error("run already exists: {0}")]
491    Duplicate(RunId),
492
493    /// **A9**: [`RunStore::acquire_assignee`] was called without a `desc`.
494    /// The record is mandatory, so the acquire is refused rather than
495    /// stored with an empty one. The store deliberately does not name an
496    /// HTTP status — the caller maps this to `400`.
497    #[error("assignee desc is required")]
498    AssigneeDescRequired,
499
500    /// **A2**: a record handed to [`RunStore::create`] carries a holder
501    /// whose generation is above the Run's counter `G`
502    /// (`current[slot].gen > next_generation`), so it would be stored
503    /// already violating `current = Assigned(a) ⟹ a.gen ≤ G`.
504    ///
505    /// The acquire path cannot produce this — it stamps `gen` from the
506    /// counter it just bumped — but `create` accepts a caller-built
507    /// [`RunRecord`] with both fields public, and that is a published
508    /// surface. Refused rather than stored: the violation is permanent
509    /// (the next acquire stamps a *lower* generation than the incumbent's,
510    /// inverting the ordering `G` being Run-wide exists to provide) and
511    /// invisible afterwards. Callers map this to `400`, same as
512    /// [`Self::AssigneeDescRequired`].
513    #[error(
514        "assignee generation is ahead of the run's counter: current['{slot}'].gen = {gen} > \
515         next_generation = {next_generation}"
516    )]
517    AssigneeGenerationAhead {
518        /// The seat whose holder is ahead of the counter.
519        slot: String,
520        /// That holder's generation.
521        gen: u64,
522        /// The Run counter `G` it was measured against.
523        next_generation: u64,
524    },
525
526    /// An assignment event named no slot. `Run.current` is keyed by slot,
527    /// so an `Assign` (or a `Vacant`) with an empty slot names no seat to
528    /// write — it is refused rather than collapsed onto a `""` key that
529    /// no `operator_ref` can ever resolve to. Callers map this to `400`,
530    /// same as [`Self::AssigneeDescRequired`].
531    #[error("assignee slot is required")]
532    AssigneeSlotRequired,
533
534    /// Backend-specific failure not covered by the other variants.
535    #[error("other: {0}")]
536    Other(String),
537}
538
539/// The provenance of a Run snapshot's `bound_agents` array.
540///
541/// Persisted as the [`BOUND_AGENTS_ORIGIN_KEY`] sibling of `bound_agents`
542/// inside the opaque [`RunRecord::input_json`] blob. This is Run-store
543/// metadata, **not** a schema-crate Blueprint wire type: it never enters
544/// [`crate::blueprint::BoundAgent`], `BoundAgentDigestInput`, or any digest
545/// computation. It lives here beside [`RunContext`] — rather than in
546/// `crate::service::task_launch` — because both the domain launch service
547/// (which writes it) and the server crate's bindings-explain handler (which
548/// reads it) consume it, and both already depend on this module; parking it
549/// in the service module would force the server crate to reach into a
550/// service-private type.
551#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
552#[serde(rename_all = "snake_case")]
553pub enum SnapshotOrigin {
554    /// `bound_agents` were resolved and pinned at the Run's initial launch —
555    /// the binding identity is the launch-time pin.
556    Launch,
557    /// `bound_agents` were backfilled from the current Blueprint when a
558    /// pre-binding-snapshot Run was resumed or reran. The binding identity
559    /// carries no launch-time pin guarantee.
560    ResumeBackfill,
561}
562
563/// JSON key the [`SnapshotOrigin`] is persisted under, beside `bound_agents`,
564/// in [`RunRecord::input_json`].
565pub const BOUND_AGENTS_ORIGIN_KEY: &str = "bound_agents_origin";
566
567impl SnapshotOrigin {
568    /// Read the origin marker from a decoded launch snapshot. An absent (or
569    /// unparseable) [`BOUND_AGENTS_ORIGIN_KEY`] maps to
570    /// [`SnapshotOrigin::ResumeBackfill`] — the safe side: a snapshot whose
571    /// `bound_agents` were persisted before this marker existed cannot prove
572    /// they were pinned at launch, so it must not be reported as a launch pin
573    /// and (on the replay axis) must not have binding digests mixed into its
574    /// replay keys. Only test artifacts hit this case in practice — the
575    /// strict-binding series is unreleased, so no real snapshot predates the
576    /// marker.
577    pub fn from_snapshot(snapshot: &serde_json::Value) -> Self {
578        snapshot
579            .get(BOUND_AGENTS_ORIGIN_KEY)
580            .and_then(|v| serde_json::from_value::<SnapshotOrigin>(v.clone()).ok())
581            .unwrap_or(SnapshotOrigin::ResumeBackfill)
582    }
583}
584
585/// GH #76 error surface: single-slot breadcrumb the dispatcher writes when a step
586/// aborts the flow (currently: [`crate::core::state::DispatchOutcome::Blocked`]),
587/// so the surrounding [`crate::service::task_launch::TaskLaunchService::launch`]
588/// `map_err` closure can lift `failed_step` + `verdict_value` off the eval
589/// boundary into the structured [`crate::service::task_launch::TaskLaunchError::FlowEval`]
590/// variant. Sibling to `step_entries` (append-only per-step trace) — this
591/// slot is last-write-wins because only ONE aborting step matters for the
592/// eval's terminal error envelope, and flow-ir stops dispatching further
593/// steps after `EvalError::DispatcherError`.
594#[derive(Debug, Clone)]
595pub struct LastFailure {
596    /// The `StepId` (dispatch-time tid) the dispatcher assigned to the
597    /// aborting step.
598    pub step_id: StepId,
599    /// The Blueprint `Step.ref` that dispatched the aborting step, if
600    /// known (dispatcher fills this from its own `ref_` param — never `None`
601    /// on the current write path, but modeled `Option` because the
602    /// `LastFailure` shape is a public read surface and future breadcrumb
603    /// writers may not have a ref in hand).
604    pub step_ref: Option<String>,
605    /// The verdict value the aborting step carried
606    /// (e.g. `DispatchOutcome::Blocked(v)`'s `v`, cloned by the dispatcher
607    /// before mapping the outcome to `EvalError::DispatcherError`).
608    pub verdict_value: serde_json::Value,
609}
610
611/// Pairs a [`RunId`] with the [`RunStore`] used to persist its trace.
612///
613/// Threaded from the server entry points (`POST /v1/tasks`, `POST
614/// /v1/tasks/:id/runs`) down through `TaskApplication::handle_with_run` /
615/// `TaskLaunchService::launch` / `EngineDispatcher` (issue #13 run_id
616/// propagation) so every step the dispatcher runs can be appended to
617/// `RunRecord.step_entries` and the run's id exposed to workers via
618/// `Ctx.meta.runtime["run_id"]`. Kept as a distinct type — rather than a
619/// new field on `TaskApplicationInput` — so the pre-existing exhaustive
620/// struct literal in `mlua-swarm-cli`'s MCP adapter (`TaskApplicationInput
621/// { .. }`, no `run_ctx`) keeps compiling unchanged: callers that don't
622/// care about run tracing keep calling `TaskApplication::handle` /
623/// `TaskLaunchService::launch`, which pass `None` through internally.
624#[derive(Clone)]
625pub struct RunContext {
626    /// The Run this dispatch's steps should be traced into.
627    pub run_id: RunId,
628    /// Where to append [`StepEntry`] rows as steps are dispatched.
629    pub run_store: Arc<dyn RunStore>,
630    /// Optional [`ReplayStore`] the engine will append a Ctx-snapshot +
631    /// step-output row to after every completed step (see
632    /// [`crate::store::replay`] for the primitive). `None` (the default)
633    /// disables logging entirely — pre-replay callers keep their behavior
634    /// byte-for-byte.
635    pub replay_store: Option<Arc<dyn ReplayStore>>,
636    /// Optional [`ReplayCursor`] the engine consults BEFORE dispatching
637    /// each step. When present and the cursor has a matching row for
638    /// `(step_ref, input_hash, occurrence)`, the engine returns the
639    /// stored `DispatchOutcome::Pass(value)` verbatim and skips the
640    /// Adapter spawn — this is the replay-hit path. `None` (the default)
641    /// disables replay entirely.
642    pub replay_cursor: Option<Arc<Mutex<ReplayCursor>>>,
643    /// Run-pinned replay identity component, keyed by logical agent name.
644    pub binding_digests: Arc<HashMap<String, BindingDigest>>,
645    /// Whether this dispatch is a resume / rerun-from of an existing Run
646    /// rather than an initial launch. `false` (the default) marks an initial
647    /// launch. Set to `true` ONLY by the server's resume and rerun-from
648    /// handlers — it is the sole, explicit signal that decides a backfilled
649    /// snapshot's [`SnapshotOrigin`] (never inferred from replay-cursor or
650    /// step-entry state, whose wiring is free to change).
651    pub resume: bool,
652    /// GH #76 error surface: shared single-slot breadcrumb the dispatcher writes when
653    /// a step aborts the flow (`DispatchOutcome::Blocked` → `EvalError`).
654    /// Read by the enclosing [`crate::service::task_launch::TaskLaunchService::launch`]
655    /// `map_err` closure to populate the structured
656    /// [`crate::service::task_launch::TaskLaunchError::FlowEval`] variant's
657    /// `failed_step` / `verdict_value` fields. `None` (the default) means
658    /// no aborting step was recorded — either the run succeeded, or an
659    /// error path fired that does not go through the dispatcher's Blocked
660    /// arm (e.g. `EvalError` raised by flow-ir itself before dispatch).
661    /// Behind `std::sync::Mutex` to match the `replay_cursor` sibling
662    /// (same crate-level convention — dispatcher writes are short critical
663    /// sections, no `.await` held across).
664    pub last_failure: Arc<Mutex<Option<LastFailure>>>,
665    /// Optional [`crate::store::trace::TraceHandle`] bound to this Run —
666    /// the write port for the per-Run [`crate::store::trace::TraceEvent`]
667    /// stream. When present the dispatcher appends `core.*` events
668    /// around every step and registers the handle with the engine
669    /// (`Engine::trace_handle`) so middlewares/workers can append their
670    /// own kinds. `None` (the default) disables the trace rail entirely
671    /// — pre-trace callers keep their behavior byte-for-byte.
672    pub trace: Option<crate::store::trace::TraceHandle>,
673}
674
675impl RunContext {
676    /// Construct a `RunContext` with just the RunStore wired — the same
677    /// shape all pre-replay callers use (`replay_store` / `replay_cursor`
678    /// both `None`). Preserved as a convenience so a caller that never
679    /// opts into replay can keep constructing `RunContext` positionally.
680    pub fn new(run_id: RunId, run_store: Arc<dyn RunStore>) -> Self {
681        Self {
682            run_id,
683            run_store,
684            replay_store: None,
685            replay_cursor: None,
686            binding_digests: Arc::new(HashMap::new()),
687            resume: false,
688            last_failure: Arc::new(Mutex::new(None)),
689            trace: None,
690        }
691    }
692
693    /// Builder-style setter: attach a
694    /// [`crate::store::trace::TraceHandle`] so the dispatcher appends
695    /// `core.*` trace events around every step and exposes the handle
696    /// to middlewares/workers via the engine.
697    pub fn with_trace(mut self, trace: crate::store::trace::TraceHandle) -> Self {
698        self.trace = Some(trace);
699        self
700    }
701
702    /// GH #76 error surface: write the aborting-step breadcrumb (last-write-wins).
703    /// Called by [`crate::blueprint::EngineDispatcher::dispatch`]'s Blocked
704    /// arm BEFORE it maps the outcome to `EvalError::DispatcherError`.
705    /// Silently succeeds if the mutex is poisoned — this is an
706    /// observability breadcrumb, not a load-bearing invariant, and a
707    /// poisoned mutex here must never prevent the primary abort error
708    /// from propagating (same fail-open convention as the sibling
709    /// `append_step_entry` warn-and-swallow at
710    /// `EngineDispatcher::dispatch`).
711    pub fn set_last_failure(&self, failure: LastFailure) {
712        if let Ok(mut slot) = self.last_failure.lock() {
713            *slot = Some(failure);
714        }
715    }
716
717    /// GH #76 error surface: reconstruct a partial-ctx snapshot from the step-entry
718    /// trace persisted so far — the in-tree substitute for a full
719    /// `storage.snapshot()` from flow-ir (upstream carry).
720    ///
721    /// Shape: `{ "steps": { "<step_id>": { "step_ref": ..., "status": ...,
722    /// "binding_digest": ..., "at": ... } } }` — a JSON object keyed by
723    /// each dispatched `StepId` with its recorded [`StepEntry`] metadata.
724    /// This is metadata-level, NOT value-level (no `StepEntry` carries the
725    /// step's actual OUTPUT value; that requires upstream mlua-flow-ir
726    /// support to expose `storage.snapshot()` on error). Consumers who
727    /// need value-level partial ctx must wait for the upstream carry —
728    /// see the FlowEval `partial_ctx` field rustdoc.
729    ///
730    /// Returns `Value::Null` if the store lookup fails (e.g. the row was
731    /// deleted between dispatch and error surfacing) — the caller's
732    /// `partial_ctx: Option<Value>` field wraps this so `Null` is
733    /// distinguishable from "no snapshot attempt at all".
734    pub async fn snapshot_partial_ctx(&self) -> serde_json::Value {
735        let record = match self.run_store.get(&self.run_id).await {
736            Ok(r) => r,
737            Err(_) => return serde_json::Value::Null,
738        };
739        let mut steps = serde_json::Map::new();
740        for entry in &record.step_entries {
741            let mut fields = serde_json::Map::new();
742            if let Some(ref_) = &entry.step_ref {
743                fields.insert(
744                    "step_ref".to_string(),
745                    serde_json::Value::String(ref_.clone()),
746                );
747            }
748            if let Some(status) = &entry.status {
749                fields.insert(
750                    "status".to_string(),
751                    serde_json::Value::String(status.clone()),
752                );
753            }
754            if let Some(digest) = &entry.binding_digest {
755                fields.insert(
756                    "binding_digest".to_string(),
757                    serde_json::Value::String(digest.to_string()),
758                );
759            }
760            fields.insert("at".to_string(), serde_json::Value::Number(entry.at.into()));
761            steps.insert(entry.step_id.to_string(), serde_json::Value::Object(fields));
762        }
763        let mut out = serde_json::Map::new();
764        out.insert("steps".to_string(), serde_json::Value::Object(steps));
765        serde_json::Value::Object(out)
766    }
767
768    /// Builder-style setter: attach a [`ReplayStore`] to log every
769    /// completed step's Ctx snapshot + output into.
770    pub fn with_replay_store(mut self, store: Arc<dyn ReplayStore>) -> Self {
771        self.replay_store = Some(store);
772        self
773    }
774
775    /// Builder-style setter: attach a [`ReplayCursor`] the dispatcher
776    /// consults for a hit before dispatching each step.
777    pub fn with_replay_cursor(mut self, cursor: Arc<Mutex<ReplayCursor>>) -> Self {
778        self.replay_cursor = Some(cursor);
779        self
780    }
781
782    /// Attach immutable binding digests so replay keys distinguish the same
783    /// step/input executed under different Runner/Agent/Context snapshots.
784    pub fn with_binding_digests(mut self, digests: HashMap<String, BindingDigest>) -> Self {
785        self.binding_digests = Arc::new(digests);
786        self
787    }
788
789    /// Builder-style setter: mark this dispatch as a resume / rerun-from of
790    /// an existing Run (see [`Self::resume`]). Called only by the server's
791    /// resume and rerun-from handlers; every other construction site leaves
792    /// the default `false` (initial launch).
793    pub fn with_resume(mut self) -> Self {
794        self.resume = true;
795        self
796    }
797}
798
799impl std::fmt::Debug for RunContext {
800    // `dyn RunStore` carries no `Debug` bound (backend implementations
801    // shouldn't be forced to derive it just to satisfy this struct's
802    // `Debug`); render `run_store` as its `name()` instead, same idiom as
803    // `WorkerInvocation`'s manual `Debug` for its `Arc<dyn OutputSink>`
804    // field.
805    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
806        f.debug_struct("RunContext")
807            .field("run_id", &self.run_id)
808            .field("run_store", &self.run_store.name())
809            .field(
810                "replay_store",
811                &self.replay_store.as_ref().map(|s| s.name()),
812            )
813            .field("replay_cursor", &self.replay_cursor.is_some())
814            .field("binding_digests", &self.binding_digests.len())
815            .field("resume", &self.resume)
816            .field(
817                "last_failure",
818                &self.last_failure.lock().ok().and_then(|slot| slot.clone()),
819            )
820            .field("trace", &self.trace.is_some())
821            .finish()
822    }
823}
824
825// ──────────────────────────────────────────────────────────────────────────
826// RunStore trait
827// ──────────────────────────────────────────────────────────────────────────
828
829/// Persistence interface for `Run` records — one kick of a Task, in the
830/// issue #13 ID hierarchy.
831#[async_trait]
832pub trait RunStore: Send + Sync {
833    /// Backend name — for diagnostics/logging.
834    fn name(&self) -> &str;
835
836    /// Create a new Run row. Returns `Duplicate` if `record.id` is already
837    /// stored.
838    ///
839    /// This is the one door into the store that carries a caller-built
840    /// [`RunRecord`], so it is where the assignment axis is checked rather
841    /// than assumed: a record whose `current` holds a generation above
842    /// `next_generation` is refused with
843    /// [`RunStoreError::AssigneeGenerationAhead`] (**A2**, see
844    /// [`RunRecord::validate_assignment_generations`]). Implementations must
845    /// run that check before persisting anything. The rest of the record —
846    /// `step_entries`, `status`, timestamps — is still trusted as given.
847    async fn create(&self, record: RunRecord) -> Result<(), RunStoreError>;
848
849    /// Fetch a Run by id.
850    async fn get(&self, id: &RunId) -> Result<RunRecord, RunStoreError>;
851
852    /// List every Run kicked from `task_id`, ascending by `created_at`
853    /// (oldest kick first).
854    async fn list_by_task(&self, task_id: &TaskId) -> Result<Vec<RunRecord>, RunStoreError>;
855
856    /// Append one step-trace entry to a Run's `step_entries`, bumping
857    /// `updated_at` to now.
858    async fn append_step_entry(&self, id: &RunId, entry: StepEntry) -> Result<(), RunStoreError>;
859
860    /// Append one worker-reported degradation to a Run's `degradations`
861    /// (GH #32), bumping `updated_at` to now. Independent of
862    /// [`Self::append_step_entry`] — degradations never flow through step
863    /// OUTPUT/fold.
864    async fn append_degradation(
865        &self,
866        id: &RunId,
867        entry: DegradationEntry,
868    ) -> Result<(), RunStoreError>;
869
870    /// Update a Run's status, bumping `updated_at` to now.
871    async fn update_status(&self, id: &RunId, status: RunStatus) -> Result<(), RunStoreError>;
872
873    /// Atomically transition a Run's status from `from` to `to`, bumping
874    /// `updated_at` to now — the compare-and-set primitive the resume path
875    /// (`POST /v1/runs/:id/resume`) uses to guard against a double resume
876    /// racing the same `Interrupted` Run into `Running` twice.
877    ///
878    /// Returns `Ok(true)` when a row with this `id` AND current status
879    /// `from` was found and flipped to `to`; `Ok(false)` when the row's
880    /// current status was not `from` (a concurrent transition already won,
881    /// or the Run is absent). Never a hard error for the status-mismatch /
882    /// absent case — the boolean is the caller's race signal.
883    async fn try_transition(
884        &self,
885        id: &RunId,
886        from: RunStatus,
887        to: RunStatus,
888    ) -> Result<bool, RunStoreError>;
889
890    /// Assign this Run's `slot` to `op` — the model's `Assign` event
891    /// (§4.3).
892    ///
893    /// `slot` is the Blueprint-declared Operator seat (`operator_ref`) the
894    /// assignment applies to; only that key of
895    /// [`RunRecord::current`] is touched, so assigning one seat never
896    /// disturbs another's holder.
897    ///
898    /// Bumps the Run's generation counter `G` by one and stamps the new
899    /// value onto a **freshly minted** [`Assignee`], which replaces
900    /// `current[slot]` (**A4** / **Q3**: the previously stored `Assignee`
901    /// is returned untouched, never rewritten in place). `G` is Run-wide,
902    /// so this advances the same counter every other slot's events advance.
903    /// `updated_at` is bumped to now.
904    ///
905    /// **A8**: this succeeds regardless of who holds the slot — a live
906    /// holder is displaced (last writer wins); there is no exclusion and
907    /// no rejection path for a contended slot. The only refusals are
908    /// **A9** (an empty or whitespace-only `desc` returns
909    /// [`RunStoreError::AssigneeDescRequired`]) and an empty `slot`
910    /// (returns [`RunStoreError::AssigneeSlotRequired`]); an unknown `id`
911    /// returns [`RunStoreError::NotFound`].
912    ///
913    /// The read of `G` and the write of both columns happen atomically, so
914    /// two concurrent acquires can never read the same `G` and hand out a
915    /// duplicate generation — including when they name different slots.
916    ///
917    /// Returns `(new generation, the holder this call displaced from this
918    /// slot)` — the caller needs both to tell whether it took over from
919    /// someone and under which generation it now dispatches.
920    async fn acquire_assignee(
921        &self,
922        id: &RunId,
923        slot: &str,
924        op: &str,
925        desc: &str,
926    ) -> Result<(u64, Option<Assignee>), RunStoreError>;
927
928    /// Release the holder of this Run's `slot` — the model's `Vacant`
929    /// event (§4.3) — **but only while that seat still holds the
930    /// generation the caller observed**. Other slots keep their holders.
931    ///
932    /// `expected_gen` is the `gen` of the [`Assignee`] the caller read and
933    /// decided about. The comparison and the write happen in one critical
934    /// section (the same transaction / lock `acquire_assignee` uses), so a
935    /// concurrent `acquire` either lands before the check — and the
936    /// release becomes a no-op — or after the write, which is an ordinary
937    /// **A8** takeover of an already-Vacant seat. There is no window in
938    /// which a stale reader deletes a newer holder. See [`VacateOutcome`]
939    /// for why the generation has to travel with the call at all; this is
940    /// the only release verb, because both production callers (**A7** at
941    /// `AssigneeRouter::execute` and **O8**'s cascade) are stale readers,
942    /// and an unconditional sibling would exist only to be picked by
943    /// mistake.
944    ///
945    /// **A4**: a release that actually happens bumps the Run-wide
946    /// generation counter exactly like `Assign` does; it just mints no
947    /// [`Assignee`]. A subsequent [`Self::acquire_assignee`] — on this slot
948    /// or any other — therefore continues from the bumped value rather than
949    /// reusing the generation the released holder had. `updated_at` is
950    /// bumped to now. A [`VacateOutcome::Stale`] result is **not** an
951    /// assignment event and writes nothing at all: the counter counts
952    /// events, and a release that did not release is not one.
953    ///
954    /// An already-Vacant slot therefore answers
955    /// `Stale { current: None }` rather than burning a generation — no
956    /// generation can match an absent holder. An empty `slot` returns
957    /// [`RunStoreError::AssigneeSlotRequired`] and an unknown `id` returns
958    /// [`RunStoreError::NotFound`].
959    async fn vacate_assignee(
960        &self,
961        id: &RunId,
962        slot: &str,
963        expected_gen: u64,
964    ) -> Result<VacateOutcome, RunStoreError>;
965
966    /// Set a Run's terminal `result_ref`, bumping `updated_at` to now.
967    async fn set_result(
968        &self,
969        id: &RunId,
970        result_ref: serde_json::Value,
971    ) -> Result<(), RunStoreError>;
972
973    /// Replace the opaque launch snapshot after pre-dispatch binding has
974    /// enriched it (for example with immutable `bound_agents`).
975    async fn set_input_json(&self, id: &RunId, input_json: String) -> Result<(), RunStoreError>;
976
977    /// List every Run currently `Running` (issue #35 ST2 boot sweep +
978    /// ST4 occupancy check reuse this). No ordering guarantee.
979    async fn list_running(&self) -> Result<Vec<RunRecord>, RunStoreError>;
980
981    /// List Runs matching `filter`, newest-first (`created_at`
982    /// descending) — the `GET /v1/runs` collection read.
983    async fn list(&self, filter: &RunListFilter) -> Result<Vec<RunRecord>, RunStoreError>;
984
985    /// Delete a Run row (the `DELETE /v1/runs/:id` retention operation).
986    /// The caller is responsible for pruning the sibling trace stream
987    /// ([`crate::store::trace::RunTraceStore::delete_run`]) — the two
988    /// stores are deliberately uncoupled at the trait level.
989    async fn delete(&self, id: &RunId) -> Result<(), RunStoreError>;
990}
991
992// ──────────────────────────────────────────────────────────────────────────
993// Shared inner state used by the InMemory backend.
994// ──────────────────────────────────────────────────────────────────────────
995
996#[derive(Default)]
997pub(crate) struct Inner {
998    /// Insertion order — used as a stable tie-break under `list_by_task()`.
999    pub(crate) order: Vec<RunId>,
1000    pub(crate) records: HashMap<RunId, RunRecord>,
1001}
1002
1003pub(crate) type SharedInner = Mutex<Inner>;