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}
27
28/// The sending end of the persistence I/O lane (the receiving end is drained by
29/// `persistence_bridge::persistence_worker`).
30#[derive(Resource)]
31pub struct PersistenceStage(pub UnboundedSender<PersistJob>);
32
33/// Persistence-dispatch system: for each agent carrying run metadata whose
34/// (iteration, stage, status) has changed since its last snapshot, build the
35/// `meta.json` + `context.json` value snapshot and hand it to the persistence
36/// lane. Fire-and-forget - no result to collect; the single-worker lane keeps a
37/// given agent's writes ordered. Agents without [`RunMetadata`] aren't persisted.
38#[allow(clippy::type_complexity)]
39/// Interaction-status reflection system: mirror the shared [`InteractionHub`]'s
40/// open requests into agent status so a blocked agent shows as `Waiting` (and
41/// the dashboard / `lev ps` surface its prompt) instead of a silent `Active`.
42///
43/// An agent's `ask_user_*` / tool-approval / plan-approval call blocks deep in
44/// the async tool lane, invisible to the ECS - which otherwise leaves the agent
45/// `Active` with meta.json written `running`, so the dashboard (gated on
46/// `WaitingInput`) never shows the prompt and the run looks frozen. This system
47/// closes that gap: an agent whose id has an open hub request flips
48/// `Active → Waiting` (tagged [`AwaitingInteraction`]); when the request clears
49/// it flips back `Waiting → Active`. Fan-out waiting ([`FanOutWaiting`]) is left
50/// untouched. No-op when the world has no hub resource (test worlds).
51pub fn reflect_interaction_status(
52 hub: Option<Res<InteractionHub>>,
53 mut agents: Query<
54 (Entity, &mut AgentState, Option<&AwaitingInteraction>),
55 Without<FanOutWaiting>,
56 >,
57 mut commands: Commands,
58) {
59 crate::tick_scope::clear();
60 let Some(hub) = hub else { return };
61 let pending: std::collections::HashSet<String> =
62 hub.pending().into_iter().map(|(id, _)| id).collect();
63 for (entity, mut state, marked) in agents.iter_mut() {
64 crate::tick_scope::enter(entity);
65 match (pending.contains(&state.agent_id), marked.is_some()) {
66 // Newly blocked on a prompt: surface it as Waiting.
67 (true, false) => {
68 if state.status == AgentStatus::Active {
69 state.status = AgentStatus::Waiting;
70 commands.entity(entity).insert(AwaitingInteraction);
71 }
72 }
73 // Request cleared (answered / cancelled): return to Active, unless
74 // the agent has since reached a terminal status.
75 (false, true) => {
76 commands.entity(entity).remove::<AwaitingInteraction>();
77 if state.status == AgentStatus::Waiting {
78 state.status = AgentStatus::Active;
79 }
80 }
81 _ => {}
82 }
83 }
84}
85
86/// Reconcile a [`StageLedger`]'s per-stage `status` + timestamps against the
87/// agent's current stage index and status: stages before the cursor are
88/// `Complete`, the cursor stage takes the mapped agent status, later stages stay
89/// `Pending`. `started_at`/`ended_at` are stamped once and never overwritten, so
90/// repeated calls are idempotent.
91pub(crate) fn reconcile_stage_ledger(
92 ledger: &mut StageLedger,
93 cursor_index: usize,
94 status: &AgentStatus,
95 now: i64,
96) {
97 use leviath_core::run_meta::StageRunStatus;
98 let active = crate::persistence::stage_status_from(status);
99 for rec in ledger.0.iter_mut() {
100 match rec.index.cmp(&cursor_index) {
101 std::cmp::Ordering::Less => {
102 if rec.started_at.is_none() {
103 rec.started_at = Some(now);
104 }
105 rec.status = StageRunStatus::Complete;
106 if rec.ended_at.is_none() {
107 rec.ended_at = Some(now);
108 }
109 }
110 std::cmp::Ordering::Equal => {
111 if rec.started_at.is_none() {
112 rec.started_at = Some(now);
113 }
114 if active == StageRunStatus::Complete && rec.ended_at.is_none() {
115 rec.ended_at = Some(now);
116 }
117 rec.status = active.clone();
118 }
119 std::cmp::Ordering::Greater => {
120 rec.status = StageRunStatus::Pending;
121 }
122 }
123 }
124}
125
126#[allow(clippy::type_complexity)]
127pub fn dispatch_persistence(
128 mut agents: Query<(
129 Entity,
130 &RunMetadata,
131 &AgentState,
132 &ContextWindow,
133 &StageCursor,
134 &TokenTotals,
135 &mut PersistWatermark,
136 Option<&mut StageLedger>,
137 Option<&mut StageIoBuffer>,
138 Option<&crate::taint::TaintGate>,
139 Option<&crate::components::ParentRef>,
140 Option<&crate::components::SubAgentChildren>,
141 Option<&crate::fanout::FanOutWaiting>,
142 (
143 Option<&crate::interaction_points::AwaitingInteractionPoint>,
144 Option<&crate::interaction_points::InteractionPointCursor>,
145 Option<&crate::interaction_points::InteractionPointRounds>,
146 Option<&crate::persistence::RunOutcomeFlags>,
147 ),
148 )>,
149 stage: Res<PersistenceStage>,
150 hub: Option<Res<InteractionHub>>,
151 sink: Option<Res<crate::host::WorldEventSink>>,
152) {
153 crate::tick_scope::clear();
154 for (
155 entity,
156 md,
157 state,
158 window,
159 cursor,
160 totals,
161 mut watermark,
162 mut ledger,
163 buffer,
164 taint_gate,
165 parent_ref,
166 children,
167 fan_out_waiting,
168 (awaiting_point, ip_cursor, ip_rounds, outcome_flags),
169 ) in agents.iter_mut()
170 {
171 crate::tick_scope::enter(entity);
172 let now = chrono::Utc::now().timestamp();
173
174 // Reconcile the stage ledger every persist tick so status/timestamps track
175 // the agent regardless of whether the run-level watermark changed.
176 if let Some(ledger) = ledger.as_deref_mut() {
177 reconcile_stage_ledger(ledger, cursor.index, &state.status, now);
178 }
179
180 // Always flush any buffered per-stage output/log lines.
181 let (output_appends, log_appends) = match buffer {
182 Some(mut buf) => (
183 std::mem::take(&mut buf.output),
184 std::mem::take(&mut buf.logs),
185 ),
186 None => (Vec::new(), Vec::new()),
187 };
188 let has_appends = !output_appends.is_empty() || !log_appends.is_empty();
189
190 let status = crate::persistence::run_status_from(&state.status);
191 let current = (state.iteration, cursor.index, status);
192 let watermark_changed = watermark.last.as_ref() != Some(¤t);
193 // Beat even when nothing changed, so `updated_at` distinguishes a run
194 // that is slow from one that nothing is driving.
195 let due_for_heartbeat = watermark
196 .last_written_at
197 .is_none_or(|at| now.saturating_sub(at) >= PERSIST_HEARTBEAT_SECS);
198 if !watermark_changed && !has_appends && !due_for_heartbeat {
199 continue; // nothing meaningful changed, nothing buffered, beat not due
200 }
201 if watermark_changed {
202 watermark.last = Some(current);
203 }
204 watermark.last_written_at = Some(now);
205
206 // Stream each buffered line to WS subscribers as a `Log` event (in
207 // addition to the disk append below). No-op in worlds without the sink
208 // (test / `lev run`); a zero-subscriber `send` error is ignored.
209 if let Some(sink) = &sink {
210 for (_idx, line) in output_appends.iter().chain(log_appends.iter()) {
211 // `Res<T>` derefs to `T` in bevy_ecs 0.19; it is not a tuple struct.
212 let _ = sink.0.send(crate::host::WorldEvent::Log {
213 run_id: md.run_id.clone(),
214 agent_id: state.agent_id.clone(),
215 line: line.clone(),
216 });
217 }
218 }
219
220 // Tree links, for a deterministic restart-time rebuild of the graph.
221 let depth = parent_ref.map(|p| p.depth).unwrap_or(0);
222 let max_child_depth = children.map(|c| c.max_child_depth).unwrap_or(0);
223 let flags = outcome_flags.cloned().unwrap_or_default();
224 let meta = build_run_meta(
225 md,
226 state,
227 totals,
228 &flags,
229 cursor.index,
230 now,
231 depth,
232 max_child_depth,
233 );
234 let context = build_context_snapshot(window, &state.current_stage);
235 let stages = ledger.as_deref().map(|l| l.0.clone()).unwrap_or_default();
236 // Persist the taint gate's audit log (per-stage) when it has events, so
237 // security decisions are inspectable after the fact.
238 let taint_audit = taint_gate.filter(|g| !g.audit_log().is_empty()).map(|g| {
239 (
240 cursor.index,
241 serde_json::to_string_pretty(g.audit_log())
242 .expect("GateEvent slice always serializes"),
243 )
244 });
245 // A parent parked mid fan-out: persist its waiting state so the
246 // split/merge resumes after a restart (removed once it's no longer
247 // waiting - see the writer).
248 let fanout = fan_out_waiting
249 .map(|w| serde_json::to_string(&w.to_state()).expect("FanOutState always serializes"));
250 // An agent parked at a stage-boundary interaction point: persist the open
251 // point (cursor/round + the reviewed document) so a restart re-presents the
252 // same prompt rather than dropping it and re-inferring (issue #38). The
253 // document comes from the open request in the hub - which is present by the
254 // time `reflect_interaction_status` (running just before this system) has
255 // flipped the agent to `Waiting`. If the request isn't registered yet, skip
256 // this tick; the next persist captures it (removing any stale sidecar).
257 let interactions = awaiting_point.and_then(|_| {
258 let request = hub
259 .as_ref()?
260 .pending()
261 .into_iter()
262 .find(|(aid, req)| aid == &state.agent_id && req.id.contains("-point-"))?;
263 let ip_state = crate::interaction_points::InteractionPointState {
264 cursor: ip_cursor.map_or(0, |c| c.0),
265 round: ip_rounds.map_or(0, |r| r.0),
266 body: request.1.body.unwrap_or_default(),
267 };
268 Some(serde_json::to_string(&ip_state).expect("InteractionPointState always serializes"))
269 });
270 let _ = stage.0.send(PersistJob {
271 run_id: md.run_id.clone(),
272 meta,
273 context,
274 stages,
275 output_appends,
276 log_appends,
277 taint_audit,
278 fanout,
279 interactions,
280 });
281 }
282}