Skip to main content

leviath_runtime/pipeline/
persist.rs

1//! Per-agent snapshot writing and interaction-status reflection.
2
3use super::*;
4
5// ─── Persistence (per-agent snapshot writing) ────────────────────────────────
6
7/// How long an agent may go without a snapshot before one is written purely to
8/// refresh `updated_at`.
9///
10/// The watermark below debounces on *progress*, which means a run that is busy
11/// but not progressing (one long inference, or a genuinely wedged one) writes
12/// nothing at all. Observers then cannot tell "working" from "dead", because
13/// `updated_at` looks equally old in both cases. A periodic beat makes a stale
14/// timestamp mean something.
15pub(crate) const PERSIST_HEARTBEAT_SECS: i64 = 30;
16
17/// Debounce watermark: the (iteration, stage index, status) last persisted for an
18/// agent. A snapshot is written only when one of these changes, so the world
19/// writes on meaningful progress rather than every tick. `None` until the first
20/// snapshot, so a freshly-spawned agent is always written once.
21#[derive(Component, Default)]
22pub struct PersistWatermark {
23    last: Option<(usize, usize, leviath_core::run_meta::RunStatus)>,
24    /// When the last snapshot was written, for the heartbeat above.
25    last_written_at: Option<i64>,
26    /// When the watermark itself last changed - that is, when the agent last
27    /// actually moved.
28    ///
29    /// `last_written_at` cannot answer that: the heartbeat advances it whether
30    /// or not anything happened, which is the whole point of the heartbeat and
31    /// exactly why `meta.json`'s `updated_at` is not evidence of progress. Issue
32    /// #184 was reported on the strength of a fresh `updated_at`, so this is the
33    /// timestamp `lev ps` ages its rows against.
34    last_progress_at: Option<i64>,
35}
36
37impl PersistWatermark {
38    /// Unix seconds when this agent last made progress (iteration, stage, or
39    /// status changed). `None` before the first snapshot.
40    pub fn last_progress_at(&self) -> Option<i64> {
41        self.last_progress_at
42    }
43
44    /// Move both stamps back to `at`, so a test can reach the heartbeat window
45    /// without sleeping through it.
46    #[cfg(test)]
47    pub(crate) fn backdate(&mut self, at: i64) {
48        self.last_written_at = Some(at);
49        self.last_progress_at = Some(at);
50    }
51}
52
53/// The sending end of the persistence I/O lane (the receiving end is drained by
54/// `persistence_bridge::persistence_worker`).
55#[derive(Resource)]
56pub struct PersistenceStage(pub UnboundedSender<PersistMsg>);
57
58/// Persistence-dispatch system: for each agent carrying run metadata whose
59/// (iteration, stage, status) has changed since its last snapshot, build the
60/// `meta.json` + `context.json` value snapshot and hand it to the persistence
61/// lane. Fire-and-forget - no result to collect; the single-worker lane keeps a
62/// given agent's writes ordered. Agents without [`RunMetadata`] aren't persisted.
63#[allow(clippy::type_complexity)]
64/// Interaction-status reflection system: mirror the shared [`InteractionHub`]'s
65/// open requests into agent status so a blocked agent shows as `Waiting` (and
66/// the dashboard / `lev ps` surface its prompt) instead of a silent `Active`.
67///
68/// An agent's `ask_user_*` / tool-approval / plan-approval call blocks deep in
69/// the async tool lane, invisible to the ECS - which otherwise leaves the agent
70/// `Active` with meta.json written `running`, so the dashboard (gated on
71/// `WaitingInput`) never shows the prompt and the run looks frozen. This system
72/// closes that gap: an agent whose id has an open hub request flips
73/// `Active → Waiting` (tagged [`AwaitingInteraction`]); when the request clears
74/// it flips back `Waiting → Active`. No-op when the world has no hub resource
75/// (test worlds).
76///
77/// Agents parked by the engine rather than by a prompt - fan-out parents
78/// ([`FanOutWaiting`]) and stages holding for sub-agents
79/// ([`WaitingForChildren`]) - are excluded. Their `Waiting` belongs to whoever
80/// set it, and the clearing arm below would otherwise walk them back to `Active`
81/// the moment an unrelated prompt of theirs resolved, un-parking a run whose
82/// children are still going.
83pub fn reflect_interaction_status(
84    hub: Option<Res<InteractionHub>>,
85    mut agents: Query<
86        (Entity, &mut AgentState, Option<&AwaitingInteraction>),
87        (Without<FanOutWaiting>, Without<WaitingForChildren>),
88    >,
89    mut commands: Commands,
90) {
91    crate::tick_scope::clear();
92    let Some(hub) = hub else { return };
93    let pending: std::collections::HashSet<String> =
94        hub.pending().into_iter().map(|(id, _)| id).collect();
95    for (entity, mut state, marked) in agents.iter_mut() {
96        crate::tick_scope::enter(entity);
97        match (pending.contains(&state.agent_id), marked.is_some()) {
98            // Newly blocked on a prompt: surface it as Waiting.
99            (true, false) => {
100                if state.status == AgentStatus::Active {
101                    state.status = AgentStatus::Waiting;
102                    commands.entity(entity).insert(AwaitingInteraction);
103                }
104            }
105            // Request cleared (answered / cancelled): return to Active, unless
106            // the agent has since reached a terminal status.
107            (false, true) => {
108                commands.entity(entity).remove::<AwaitingInteraction>();
109                if state.status == AgentStatus::Waiting {
110                    state.status = AgentStatus::Active;
111                }
112            }
113            _ => {}
114        }
115    }
116}
117
118/// Reconcile a [`StageLedger`]'s per-stage `status` + timestamps against the
119/// agent's current stage index and status: stages before the cursor are
120/// `Complete`, the cursor stage takes the mapped agent status, later stages stay
121/// `Pending`. `started_at`/`ended_at` are stamped once and never overwritten, so
122/// repeated calls are idempotent.
123pub(crate) fn reconcile_stage_ledger(
124    ledger: &mut StageLedger,
125    cursor_index: usize,
126    status: &AgentStatus,
127    now: i64,
128) {
129    use leviath_core::run_meta::StageRunStatus;
130    let active = crate::persistence::stage_status_from(status);
131    for rec in ledger.0.iter_mut() {
132        match rec.index.cmp(&cursor_index) {
133            std::cmp::Ordering::Less => {
134                if rec.started_at.is_none() {
135                    rec.started_at = Some(now);
136                }
137                rec.status = StageRunStatus::Complete;
138                if rec.ended_at.is_none() {
139                    rec.ended_at = Some(now);
140                }
141            }
142            std::cmp::Ordering::Equal => {
143                if rec.started_at.is_none() {
144                    rec.started_at = Some(now);
145                }
146                if active == StageRunStatus::Complete && rec.ended_at.is_none() {
147                    rec.ended_at = Some(now);
148                }
149                rec.status = active.clone();
150            }
151            std::cmp::Ordering::Greater => {
152                rec.status = StageRunStatus::Pending;
153            }
154        }
155    }
156}
157
158#[allow(clippy::type_complexity)]
159pub fn dispatch_persistence(
160    mut agents: Query<(
161        Entity,
162        &RunMetadata,
163        &AgentState,
164        &ContextWindow,
165        &StageCursor,
166        &TokenTotals,
167        &mut PersistWatermark,
168        Option<&mut StageLedger>,
169        Option<&mut StageIoBuffer>,
170        Option<&crate::taint::TaintGate>,
171        Option<&crate::components::ParentRef>,
172        Option<&crate::components::SubAgentChildren>,
173        Option<&crate::fanout::FanOutWaiting>,
174        (
175            Option<&crate::interaction_points::AwaitingInteractionPoint>,
176            Option<&crate::interaction_points::InteractionPointCursor>,
177            Option<&crate::interaction_points::InteractionPointRounds>,
178            Option<&crate::persistence::RunOutcomeFlags>,
179        ),
180    )>,
181    stage: Res<PersistenceStage>,
182    hub: Option<Res<InteractionHub>>,
183    sink: Option<Res<crate::host::WorldEventSink>>,
184) {
185    crate::tick_scope::clear();
186    for (
187        entity,
188        md,
189        state,
190        window,
191        cursor,
192        totals,
193        mut watermark,
194        mut ledger,
195        buffer,
196        taint_gate,
197        parent_ref,
198        children,
199        fan_out_waiting,
200        (awaiting_point, ip_cursor, ip_rounds, outcome_flags),
201    ) in agents.iter_mut()
202    {
203        crate::tick_scope::enter(entity);
204        let now = chrono::Utc::now().timestamp();
205
206        // Reconcile the stage ledger every persist tick so status/timestamps track
207        // the agent regardless of whether the run-level watermark changed.
208        if let Some(ledger) = ledger.as_deref_mut() {
209            reconcile_stage_ledger(ledger, cursor.index, &state.status, now);
210        }
211
212        // Always flush any buffered per-stage output/log lines.
213        let (output_appends, log_appends) = match buffer {
214            Some(mut buf) => (
215                std::mem::take(&mut buf.output),
216                std::mem::take(&mut buf.logs),
217            ),
218            None => (Vec::new(), Vec::new()),
219        };
220        let has_appends = !output_appends.is_empty() || !log_appends.is_empty();
221
222        let status = crate::persistence::run_status_from(&state.status);
223        let current = (state.iteration, cursor.index, status);
224        let watermark_changed = watermark.last.as_ref() != Some(&current);
225        // Beat even when nothing changed, so `updated_at` distinguishes a run
226        // that is slow from one that nothing is driving.
227        let due_for_heartbeat = watermark
228            .last_written_at
229            .is_none_or(|at| now.saturating_sub(at) >= PERSIST_HEARTBEAT_SECS);
230        if !watermark_changed && !has_appends && !due_for_heartbeat {
231            continue; // nothing meaningful changed, nothing buffered, beat not due
232        }
233        if watermark_changed {
234            watermark.last = Some(current);
235            watermark.last_progress_at = Some(now);
236        }
237        watermark.last_written_at = Some(now);
238
239        // Stream each buffered line to WS subscribers as a `Log` event (in
240        // addition to the disk append below). No-op in worlds without the sink
241        // (test / `lev run`); a zero-subscriber `send` error is ignored.
242        if let Some(sink) = &sink {
243            for (_idx, line) in output_appends.iter().chain(log_appends.iter()) {
244                // `Res<T>` derefs to `T` in bevy_ecs 0.19; it is not a tuple struct.
245                let _ = sink.0.send(crate::host::WorldEvent::Log {
246                    run_id: md.run_id.clone(),
247                    agent_id: state.agent_id.clone(),
248                    line: line.clone(),
249                });
250            }
251        }
252
253        // Tree links, for a deterministic restart-time rebuild of the graph.
254        let depth = parent_ref.map(|p| p.depth).unwrap_or(0);
255        let max_child_depth = children.map(|c| c.max_child_depth).unwrap_or(0);
256        let flags = outcome_flags.cloned().unwrap_or_default();
257        // Read the progress stamp *after* the update above, so a write that
258        // carried progress reports `now` and a heartbeat-only write reports
259        // whenever the run last moved. That difference is the whole signal: it is
260        // what lets an observer reading `meta.json` tell a slow run from a wedged
261        // one, which `updated_at` (which is `now` either way) cannot.
262        let meta = build_run_meta(
263            md,
264            state,
265            totals,
266            &flags,
267            cursor.index,
268            now,
269            watermark.last_progress_at(),
270            depth,
271            max_child_depth,
272        );
273        let context = build_context_snapshot(window, &state.current_stage);
274        let stages = ledger.as_deref().map(|l| l.0.clone()).unwrap_or_default();
275        // Persist the taint gate's audit log (per-stage) when it has events, so
276        // security decisions are inspectable after the fact.
277        let taint_audit = taint_gate.filter(|g| !g.audit_log().is_empty()).map(|g| {
278            (
279                cursor.index,
280                serde_json::to_string_pretty(g.audit_log())
281                    .expect("GateEvent slice always serializes"),
282            )
283        });
284        // A parent parked mid fan-out: persist its waiting state so the
285        // split/merge resumes after a restart (removed once it's no longer
286        // waiting - see the writer).
287        let fanout = fan_out_waiting
288            .map(|w| serde_json::to_string(&w.to_state()).expect("FanOutState always serializes"));
289        // An agent parked at a stage-boundary interaction point: persist the open
290        // point (cursor/round + the reviewed document) so a restart re-presents the
291        // same prompt rather than dropping it and re-inferring (issue #38). The
292        // document comes from the open request in the hub - which is present by the
293        // time `reflect_interaction_status` (running just before this system) has
294        // flipped the agent to `Waiting`. If the request isn't registered yet, skip
295        // this tick; the next persist captures it (removing any stale sidecar).
296        let interactions = awaiting_point.and_then(|_| {
297            let request = hub
298                .as_ref()?
299                .pending()
300                .into_iter()
301                .find(|(aid, req)| aid == &state.agent_id && req.id.contains("-point-"))?;
302            let ip_state = crate::interaction_points::InteractionPointState {
303                cursor: ip_cursor.map_or(0, |c| c.0),
304                round: ip_rounds.map_or(0, |r| r.0),
305                body: request.1.body.unwrap_or_default(),
306            };
307            Some(serde_json::to_string(&ip_state).expect("InteractionPointState always serializes"))
308        });
309        let _ = stage.0.send(PersistMsg::Snapshot(Box::new(PersistJob {
310            run_id: md.run_id.clone(),
311            meta,
312            context,
313            stages,
314            output_appends,
315            log_appends,
316            taint_audit,
317            fanout,
318            interactions,
319        })));
320    }
321}