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/// Longest log line the event broadcast carries; the on-disk stage logs keep
18/// the full line. 8 KB shows any tool banner or error whole while keeping the
19/// (never-shrinking) broadcast ring's worst-case floor at ring-size x this.
20pub(crate) const BROADCAST_LOG_LINE_MAX_BYTES: usize = 8 * 1024;
21
22/// Clone `line` for the event broadcast, truncated to
23/// [`BROADCAST_LOG_LINE_MAX_BYTES`] on a char boundary with a marker so a
24/// reader knows to fetch the stage log for the rest.
25fn truncate_log_line(line: &str) -> String {
26    if line.len() <= BROADCAST_LOG_LINE_MAX_BYTES {
27        return line.to_string();
28    }
29    let cut = leviath_core::text::floor_char_boundary(line, BROADCAST_LOG_LINE_MAX_BYTES);
30    format!(
31        "{} [truncated {} bytes]",
32        line.split_at(cut).0,
33        line.len() - cut
34    )
35}
36
37/// Debounce watermark: the (iteration, stage index, status) last persisted for an
38/// agent. A snapshot is written only when one of these changes, so the world
39/// writes on meaningful progress rather than every tick. `None` until the first
40/// snapshot, so a freshly-spawned agent is always written once.
41#[derive(Component, Default)]
42pub struct PersistWatermark {
43    last: Option<(usize, usize, leviath_core::run_meta::RunStatus)>,
44    /// When the last snapshot was written, for the heartbeat above.
45    last_written_at: Option<i64>,
46    /// When the watermark itself last changed - that is, when the agent last
47    /// actually moved.
48    ///
49    /// `last_written_at` cannot answer that: the heartbeat advances it whether
50    /// or not anything happened, which is the whole point of the heartbeat and
51    /// exactly why `meta.json`'s `updated_at` is not evidence of progress. Issue
52    /// #184 was reported on the strength of a fresh `updated_at`, so this is the
53    /// timestamp `lev ps` ages its rows against.
54    last_progress_at: Option<i64>,
55    /// The taint audit already on disk, as `(stage index, event count)`.
56    ///
57    /// The audit file is only rewritten when the gate recorded a new event.
58    /// Without it every snapshot re-serialized the whole (append-only) log,
59    /// an O(events) allocation per tick that grew with the run.
60    last_taint: Option<(usize, usize)>,
61}
62
63impl PersistWatermark {
64    /// Unix seconds when this agent last made progress (iteration, stage, or
65    /// status changed). `None` before the first snapshot.
66    pub fn last_progress_at(&self) -> Option<i64> {
67        self.last_progress_at
68    }
69
70    /// The run status the last dispatched snapshot carried, if any - the proof
71    /// that a given status has reached the persistence lane. Unloading
72    /// decisions key on this: an entity may only be slimmed or paged out once
73    /// the state being dropped is known to be on its way to disk.
74    pub(crate) fn persisted_status(&self) -> Option<leviath_core::run_meta::RunStatus> {
75        self.last.as_ref().map(|(_, _, status)| status.clone())
76    }
77
78    /// Move both stamps back to `at`, so a test can reach the heartbeat window
79    /// without sleeping through it.
80    #[cfg(test)]
81    pub(crate) fn backdate(&mut self, at: i64) {
82        self.last_written_at = Some(at);
83        self.last_progress_at = Some(at);
84    }
85
86    /// Stamp the watermark as though a snapshot with `status` was dispatched,
87    /// so unload tests can drive [`Self::persisted_status`] without running the
88    /// full persistence schedule.
89    #[cfg(test)]
90    pub(crate) fn stamp_status(&mut self, status: leviath_core::run_meta::RunStatus) {
91        self.last = Some((0, 0, status));
92    }
93}
94
95/// The sending end of the persistence I/O lane (the receiving end is drained by
96/// `persistence_bridge::persistence_worker`).
97#[derive(Resource)]
98pub struct PersistenceStage(pub UnboundedSender<PersistMsg>);
99
100/// What `reflect_interaction_status` selects.
101///
102/// `&'static` is bevy's `WorldQuery` convention, not a claim about
103/// lifetimes: the borrow is bound when the query is fetched.
104type ReflectInteractionStatusQuery = (
105    Entity,
106    &'static mut AgentState,
107    Option<&'static AwaitingInteraction>,
108);
109
110/// Persistence-dispatch system: for each agent carrying run metadata whose
111/// (iteration, stage, status) has changed since its last snapshot, build the
112/// `meta.json` + `context.json` value snapshot and hand it to the persistence
113/// lane. Fire-and-forget - no result to collect; the single-worker lane keeps a
114/// given agent's writes ordered. Agents without [`RunMetadata`] aren't persisted.
115/// Interaction-status reflection system: mirror the shared [`InteractionHub`]'s
116/// open requests into agent status so a blocked agent shows as `Waiting` (and
117/// the dashboard / `lev ps` surface its prompt) instead of a silent `Active`.
118///
119/// An agent's `ask_user_*` / tool-approval / plan-approval call blocks deep in
120/// the async tool lane, invisible to the ECS - which otherwise leaves the agent
121/// `Active` with meta.json written `running`, so the dashboard (gated on
122/// `WaitingInput`) never shows the prompt and the run looks frozen. This system
123/// closes that gap: an agent whose id has an open hub request flips
124/// `Active → Waiting` (tagged [`AwaitingInteraction`]); when the request clears
125/// it flips back `Waiting → Active`. No-op when the world has no hub resource
126/// (test worlds).
127///
128/// Agents parked by the engine rather than by a prompt - fan-out parents
129/// ([`FanOutWaiting`]) and stages holding for sub-agents
130/// ([`WaitingForChildren`]) - are excluded. Their `Waiting` belongs to whoever
131/// set it, and the clearing arm below would otherwise walk them back to `Active`
132/// the moment an unrelated prompt of theirs resolved, un-parking a run whose
133/// children are still going.
134pub fn reflect_interaction_status(
135    hub: Option<Res<InteractionHub>>,
136    mut agents: Query<
137        ReflectInteractionStatusQuery,
138        (Without<FanOutWaiting>, Without<WaitingForChildren>),
139    >,
140    mut commands: Commands,
141) {
142    crate::tick_scope::clear();
143    let Some(hub) = hub else { return };
144    let pending: std::collections::HashSet<String> =
145        hub.pending().into_iter().map(|(id, _)| id).collect();
146    for (entity, mut state, marked) in agents.iter_mut() {
147        crate::tick_scope::enter(entity);
148        match (pending.contains(&state.agent_id), marked.is_some()) {
149            // Newly blocked on a prompt: surface it as Waiting.
150            (true, false) => {
151                if state.status == AgentStatus::Active {
152                    state.status = AgentStatus::Waiting;
153                    commands.entity(entity).insert(AwaitingInteraction);
154                }
155            }
156            // Request cleared (answered / cancelled): return to Active, unless
157            // the agent has since reached a terminal status.
158            (false, true) => {
159                commands.entity(entity).remove::<AwaitingInteraction>();
160                if state.status == AgentStatus::Waiting {
161                    state.status = AgentStatus::Active;
162                }
163            }
164            _ => {}
165        }
166    }
167}
168
169/// Reconcile a [`StageLedger`]'s per-stage `status` + timestamps against the
170/// agent's current stage index and status.
171///
172/// The cursor stage takes the mapped agent status and is marked entered. Every
173/// other stage is judged on whether it has *ever* been entered, not on where it
174/// sits relative to the cursor: one the run has been in and left is `Complete`,
175/// one it has not is `Pending` while the run is live and
176/// [`Skipped`](leviath_core::run_meta::StageRunStatus::Skipped) once the run is
177/// over.
178///
179/// Position used to stand in for "has run", which is only true of a linear
180/// blueprint. A graph reaches its stages in whatever order its edges describe,
181/// so every branch the run went past without taking was filed as `Complete`
182/// with an empty `region_tokens` - and since that map holds the high-water mark
183/// each region reached rather than what the stage itself added, an empty one in
184/// the middle of the sequence made the next real stage appear to have written
185/// every region from nothing (#372).
186///
187/// `started_at`/`ended_at` are stamped once and never overwritten, so repeated
188/// calls are idempotent.
189pub(crate) fn reconcile_stage_ledger(
190    ledger: &mut StageLedger,
191    cursor_index: usize,
192    status: &AgentStatus,
193    now: i64,
194) {
195    use leviath_core::run_meta::StageRunStatus;
196    let active = crate::persistence::stage_status_from(status);
197    let run_is_over = matches!(
198        status,
199        AgentStatus::Complete | AgentStatus::Error { .. } | AgentStatus::Cancelled
200    );
201    for rec in ledger.0.iter_mut() {
202        if rec.index == cursor_index {
203            rec.entered = true;
204            if rec.started_at.is_none() {
205                rec.started_at = Some(now);
206            }
207            if active == StageRunStatus::Complete && rec.ended_at.is_none() {
208                rec.ended_at = Some(now);
209            }
210            rec.status = active.clone();
211            continue;
212        }
213        // Billed tokens count as evidence as well as the flag. Reconcile runs
214        // on the persist tick rather than on stage entry, so resting "did this
215        // run" entirely on having been observed as the cursor would report a
216        // stage that somehow slipped between two ticks as never entered - and
217        // calling a stage that did work `Skipped` is a worse error than the one
218        // being fixed. A stage with tokens against its name ran.
219        rec.entered |= rec.prompt_tokens > 0 || rec.completion_tokens > 0;
220        if !rec.entered {
221            rec.status = match run_is_over {
222                true => StageRunStatus::Skipped,
223                false => StageRunStatus::Pending,
224            };
225            continue;
226        }
227        // Entered earlier and not the current stage, so it has been left. A
228        // stage that loops back becomes the cursor again and is re-marked.
229        rec.status = StageRunStatus::Complete;
230        if rec.ended_at.is_none() {
231            rec.ended_at = Some(now);
232        }
233    }
234}
235
236/// What `dispatch_persistence` selects.
237///
238/// `&'static` is bevy's `WorldQuery` convention, not a claim about
239/// lifetimes: the borrow is bound when the query is fetched.
240type PersistenceQuery = (
241    Entity,
242    &'static RunMetadata,
243    &'static AgentState,
244    &'static ContextWindow,
245    &'static StageCursor,
246    &'static TokenTotals,
247    &'static mut PersistWatermark,
248    Option<&'static mut StageLedger>,
249    Option<&'static mut StageIoBuffer>,
250    Option<&'static crate::taint::TaintGate>,
251    Option<&'static crate::components::ParentRef>,
252    Option<&'static crate::components::SubAgentChildren>,
253    Option<&'static crate::fanout::FanOutWaiting>,
254    (
255        Option<&'static crate::interaction_points::AwaitingInteractionPoint>,
256        Option<&'static crate::interaction_points::InteractionPointCursor>,
257        Option<&'static crate::interaction_points::InteractionPointRounds>,
258        Option<&'static crate::persistence::RunOutcomeFlags>,
259        Option<&'static crate::persistence::FinalOutput>,
260        // The remaining reasons a run can be parked. Read here because this is
261        // where they are queryable, and recorded on `meta.json` so a client
262        // does not have to reconstruct them from what it can see.
263        Option<&'static crate::gate_prompt::AwaitingGatePrompt>,
264        Option<&'static super::WaitingForChildren>,
265        Option<&'static crate::components::AwaitingInteraction>,
266        Option<&'static super::PausedForSetup>,
267    ),
268);
269
270/// Hand each agent's current state to the persistence lane, which writes it to
271/// disk off the schedule thread.
272///
273/// Coalescing lives here rather than in the lane: an agent whose digest has not
274/// changed since its last send is skipped, so a world full of idle runs costs
275/// nothing per tick.
276pub fn dispatch_persistence(
277    mut agents: Query<PersistenceQuery>,
278    stage: Res<PersistenceStage>,
279    hub: Option<Res<InteractionHub>>,
280    sink: Option<Res<crate::host::WorldEventSink>>,
281) {
282    crate::tick_scope::clear();
283    for (
284        entity,
285        md,
286        state,
287        window,
288        cursor,
289        totals,
290        mut watermark,
291        mut ledger,
292        buffer,
293        taint_gate,
294        parent_ref,
295        children,
296        fan_out_waiting,
297        (
298            awaiting_point,
299            ip_cursor,
300            ip_rounds,
301            outcome_flags,
302            final_output,
303            gate_prompt,
304            waiting_for_children,
305            awaiting_interaction,
306            paused_for_setup,
307        ),
308    ) in agents.iter_mut()
309    {
310        crate::tick_scope::enter(entity);
311        let now = chrono::Utc::now().timestamp();
312
313        // Reconcile the stage ledger every persist tick so status/timestamps track
314        // the agent regardless of whether the run-level watermark changed.
315        if let Some(ledger) = ledger.as_deref_mut() {
316            reconcile_stage_ledger(ledger, cursor.index, &state.status, now);
317        }
318
319        // Always flush any buffered per-stage output/log lines.
320        let (output_appends, log_appends) = match buffer {
321            Some(mut buf) => (
322                std::mem::take(&mut buf.output),
323                std::mem::take(&mut buf.logs),
324            ),
325            None => (Vec::new(), Vec::new()),
326        };
327        let has_appends = !output_appends.is_empty() || !log_appends.is_empty();
328
329        let status = crate::persistence::run_status_from(&state.status);
330        let current = (state.iteration, cursor.index, status);
331        let watermark_changed = watermark.last.as_ref() != Some(&current);
332        // Beat even when nothing changed, so `updated_at` distinguishes a run
333        // that is slow from one that nothing is driving.
334        let due_for_heartbeat = watermark
335            .last_written_at
336            .is_none_or(|at| now.saturating_sub(at) >= PERSIST_HEARTBEAT_SECS);
337        if !watermark_changed && !has_appends && !due_for_heartbeat {
338            continue; // nothing meaningful changed, nothing buffered, beat not due
339        }
340
341        // Stream each buffered line to WS subscribers as a `Log` event (in
342        // addition to the disk append below). No-op in worlds without the sink
343        // (test / `lev run`); a zero-subscriber `send` error is ignored.
344        //
345        // Truncated for the broadcast only - the full line still reaches the
346        // stage log on disk. The ring retains every slot's strings until the
347        // slot is overwritten, so an assistant's whole multi-KB turn broadcast
348        // per line made the ring a multi-MB permanent floor after any busy run.
349        if let Some(sink) = &sink {
350            for (_idx, line) in output_appends.iter().chain(log_appends.iter()) {
351                // `Res<T>` derefs to `T` in bevy_ecs 0.19; it is not a tuple struct.
352                let _ = sink.0.send(crate::host::WorldEvent::Log {
353                    run_id: md.run_id.clone(),
354                    agent_id: state.agent_id.clone(),
355                    line: truncate_log_line(line),
356                });
357            }
358        }
359
360        // Buffered lines with no real progress and no heartbeat due: journal
361        // just the lines. The full path below deep-clones the whole context
362        // window per snapshot, and tool activity buffers lines several times
363        // per iteration - snapshotting on each batch multiplied the lane's
364        // biggest allocation by the run's tool traffic for no new state.
365        if !watermark_changed && !due_for_heartbeat {
366            let _ = stage.0.send(PersistMsg::StageLines {
367                run_id: md.run_id.clone(),
368                output_appends,
369                log_appends,
370            });
371            continue;
372        }
373
374        if watermark_changed {
375            watermark.last = Some(current);
376            watermark.last_progress_at = Some(now);
377        }
378        watermark.last_written_at = Some(now);
379
380        // Tree links, for a deterministic restart-time rebuild of the graph.
381        let depth = parent_ref.map(|p| p.depth).unwrap_or(0);
382        let max_child_depth = children.map(|c| c.max_child_depth).unwrap_or(0);
383        let flags = outcome_flags.cloned().unwrap_or_default();
384        // Read the progress stamp *after* the update above, so a write that
385        // carried progress reports `now` and a heartbeat-only write reports
386        // whenever the run last moved. That difference is the whole signal: it is
387        // what lets an observer reading `meta.json` tell a slow run from a wedged
388        // one, which `updated_at` (which is `now` either way) cannot.
389        let meta = build_run_meta(
390            crate::persistence::RunMetaSources {
391                md,
392                state,
393                totals,
394                flags: &flags,
395                final_output,
396                parked: leviath_core::run_meta::WaitMarkers {
397                    gate_prompt: gate_prompt.is_some_and(|g| g.0 > 0),
398                    interaction_point: awaiting_point.is_some(),
399                    fan_out_outstanding: fan_out_waiting.map(|f| f.outstanding()),
400                    // The count needs each child's status, which this query
401                    // cannot reach; the listing computes it live. Recording
402                    // the reason without the number is the honest half.
403                    children_outstanding: waiting_for_children
404                        .map(|_| children.map(|c| c.children.len()).unwrap_or(0)),
405                    interaction: hub.as_ref().and_then(|h| {
406                        h.pending()
407                            .into_iter()
408                            .find(|(agent_id, _)| *agent_id == state.agent_id)
409                            .map(|(_, req)| req.kind)
410                    }),
411                    awaiting_interaction: awaiting_interaction.is_some(),
412                    needs_setup: paused_for_setup.map(|p| leviath_core::run_meta::SetupNeeded {
413                        blocker: p.blocker,
414                        remedy: p.remedy.clone(),
415                    }),
416                },
417            },
418            crate::persistence::RunPosition {
419                stage_index: cursor.index,
420                now_secs: now,
421                last_progress_at: watermark.last_progress_at(),
422                depth,
423                max_child_depth,
424            },
425        );
426        let context = build_context_snapshot(window, &state.current_stage);
427        let stages = ledger.as_deref().map(|l| l.0.clone()).unwrap_or_default();
428        // Persist the taint gate's audit log (per-stage) when it gained events
429        // since the last write, so security decisions are inspectable after
430        // the fact. The log is append-only, so an unchanged (stage, count)
431        // means the file on disk is already current - re-serializing the whole
432        // log every heartbeat was an O(events) allocation that grew with the
433        // run.
434        let taint_audit = taint_gate
435            .filter(|g| !g.audit_log().is_empty())
436            .and_then(|g| {
437                let key = (cursor.index, g.audit_log().len());
438                if watermark.last_taint == Some(key) {
439                    return None;
440                }
441                watermark.last_taint = Some(key);
442                Some((
443                    cursor.index,
444                    serde_json::to_string(g.audit_log())
445                        .expect("GateEvent slice always serializes"),
446                ))
447            });
448        // A parent parked mid fan-out: persist its waiting state so the
449        // split/merge resumes after a restart (removed once it's no longer
450        // waiting - see the writer).
451        let fanout = fan_out_waiting
452            .map(|w| serde_json::to_string(&w.to_state()).expect("FanOutState always serializes"));
453        // An agent parked at a stage-boundary interaction point: persist the open
454        // point (cursor/round + the reviewed document) so a restart re-presents the
455        // same prompt rather than dropping it and re-inferring (issue #38). The
456        // document comes from the open request in the hub - which is present by the
457        // time `reflect_interaction_status` (running just before this system) has
458        // flipped the agent to `Waiting`. If the request isn't registered yet, skip
459        // this tick; the next persist captures it (removing any stale sidecar).
460        let interactions = awaiting_point.and_then(|_| {
461            let request = hub
462                .as_ref()?
463                .pending()
464                .into_iter()
465                .find(|(aid, req)| aid == &state.agent_id && req.id.contains("-point-"))?;
466            let ip_state = crate::interaction_points::InteractionPointState {
467                cursor: ip_cursor.map_or(0, |c| c.0),
468                round: ip_rounds.map_or(0, |r| r.0),
469                body: request.1.body.unwrap_or_default(),
470            };
471            Some(serde_json::to_string(&ip_state).expect("InteractionPointState always serializes"))
472        });
473        // Always carry the answer's bytes when the agent holds them; the
474        // persistence lane decides whether they still need writing.
475        //
476        // This used to be skipped here, keyed on a watermark advanced when the
477        // job was *built*. That assumed every job it built would be written,
478        // and the lane explicitly does not promise that: it coalesces queued
479        // snapshots per run and keeps only the newest. A run that finished
480        // inside one persistence window therefore had the job carrying the body
481        // dropped as superseded, while every later job carried `None` and still
482        // rewrote `meta.json` with the descriptor - leaving the descriptor and
483        // the sidecar permanently disagreeing, which `read_final_output` reads
484        // as "no answer" (issue #276).
485        //
486        // The skip itself was worth keeping - it stops a heartbeat rewriting a
487        // quarter-megabyte file every thirty seconds - so it moved to the lane,
488        // past the coalescing, where "did this get written" is a fact rather
489        // than an assumption. The cost here is one clone of the answer per
490        // snapshot, on a path that already deep-clones the whole context window.
491        let final_output_body = final_output.map(|o| o.0.content.clone());
492        let _ = stage.0.send(PersistMsg::Snapshot(Box::new(PersistJob {
493            run_id: md.run_id.clone(),
494            meta,
495            context,
496            stages,
497            output_appends,
498            log_appends,
499            taint_audit,
500            final_output: final_output_body,
501            fanout,
502            interactions,
503        })));
504    }
505}