Skip to main content

leviath_runtime/
persistence.rs

1//! Agent-state persistence: turning a live ECS agent into the on-disk snapshot
2//! the dashboard/API read (`meta.json` + `context.json` under the run directory).
3//!
4//! This module holds the **pure** serialization core - components that carry an
5//! agent's run identity and running token totals, plus functions that build the
6//! [`RunMeta`]/[`ContextSnapshot`] value types from an agent's live components.
7//! It does no I/O; the async write lane and the snapshot-dispatch system layer on
8//! top of these.
9
10use bevy_ecs::prelude::*;
11use leviath_core::RegionKind;
12use leviath_core::run_meta::{
13    ContextSnapshot, RegionEntrySnapshot, RegionSnapshot, RunMeta, RunStatus, StageRunStatus,
14    WaitMarkers, wait_reason_from,
15};
16
17use crate::components::{AgentState, AgentStatus, ContextWindow};
18
19/// Static per-agent run metadata (the parts of [`RunMeta`] that don't change as
20/// the agent runs). Set once when the agent is spawned; the dynamic fields are
21/// filled from the live components at snapshot time.
22#[derive(Component, Clone)]
23pub struct RunMetadata {
24    /// The run's unique id (its directory name under the runs dir).
25    pub run_id: String,
26    /// The agent/blueprint name.
27    pub agent_name: String,
28    /// Absolute path to the agent manifest directory.
29    pub agent_path: String,
30    /// The task prompt.
31    pub task: String,
32    /// The resolved model label (provider/model), if known.
33    pub model: Option<String>,
34    /// Absolute working directory for tool execution.
35    pub workdir: String,
36    /// Total number of stages in the blueprint.
37    pub num_stages: usize,
38    /// When the run started (unix seconds).
39    pub started_at: i64,
40    /// Parent run id, for sub-agent runs.
41    pub parent_run_id: Option<String>,
42    /// Custom key-value metadata from the spawn request.
43    pub metadata: std::collections::HashMap<String, String>,
44    /// Webhook to POST on completion/error.
45    pub callback_url: Option<String>,
46    /// Optional shared secret for HMAC-SHA256 signing the webhook body.
47    pub callback_secret: Option<String>,
48    /// Short human-readable title (None until generated).
49    pub title: Option<String>,
50    /// Whether this run is unattended (launched with `--yolo`).
51    ///
52    /// Recorded on the agent so anything holding the world can ask. Two things
53    /// need it: the sub-agent and fan-out spawners, which pass it down so a
54    /// child of an unattended run is unattended too, and `meta.json`, so a
55    /// daemon restart resumes the run the way it was launched. Both used to
56    /// hardcode "attended", which stranded unattended runs on prompts no one was
57    /// there to answer.
58    pub unattended: bool,
59    /// How much of the blueprint's `[read_paths]` the config granted, resolved
60    /// once at spawn (see [`ReadPathGrantCounts`]). `None` when the blueprint
61    /// declares none, which is nearly every agent.
62    ///
63    /// [`ReadPathGrantCounts`]: leviath_core::run_meta::ReadPathGrantCounts
64    pub read_paths: Option<leviath_core::run_meta::ReadPathGrantCounts>,
65    /// The output shape the caller asked for at launch, if they overrode the
66    /// blueprint's. Held so it reaches `meta.json` and survives a restart; the
67    /// resolved per-stage shape lives on `StageInference`/`StageSetup`.
68    pub output_request: Option<leviath_core::output::OutputSpec>,
69}
70
71/// Running token + tool-call totals accumulated across an agent's inferences, for
72/// the snapshot. Updated by the inference-collect system.
73#[derive(Component, Clone, Copy, Default, Debug, PartialEq, Eq)]
74pub struct TokenTotals {
75    /// Cumulative prompt tokens.
76    pub prompt_tokens: usize,
77    /// Cumulative completion tokens.
78    pub completion_tokens: usize,
79    /// Cumulative tokens read from provider cache.
80    pub cached_tokens: usize,
81    /// Cumulative tokens written to provider cache.
82    pub cache_write_tokens: usize,
83    /// Cumulative tool calls across all iterations.
84    pub tool_calls: usize,
85}
86
87/// Run-scoped productivity flags, mirrored into `meta.json` so an empty run can
88/// be recognized (and explained) from disk. Unlike [`StageProgress`], this is
89/// never reset on a stage transition - it describes the whole run.
90///
91/// [`StageProgress`]: crate::pipeline::StageProgress
92#[derive(Component, Clone, Default, Debug, PartialEq)]
93pub struct RunOutcomeFlags(pub leviath_core::run_meta::RunFlags);
94
95impl RunOutcomeFlags {
96    /// Seed a fresh run's flags from the blueprint it is about to run.
97    ///
98    /// Every counter starts at zero; the one thing decided here is
99    /// [`no_output_tools`], which is fixed for the run's lifetime and so is
100    /// answered once rather than re-derived on every persist tick.
101    ///
102    /// Judged across *every* stage, not only the ones the run reaches: a run
103    /// cancelled in the first stage of an agent that writes files really did
104    /// produce nothing, and should still say so.
105    ///
106    /// [`no_output_tools`]: leviath_core::run_meta::RunFlags::no_output_tools
107    pub fn for_blueprint(bp: &leviath_core::Blueprint) -> Self {
108        Self(leviath_core::run_meta::RunFlags {
109            no_output_tools: !bp.stages.iter().any(stage_can_modify),
110            ..Default::default()
111        })
112    }
113}
114
115/// The final output an agent has submitted, held on the agent entity until the
116/// persistence lane copies it into `meta.json`.
117///
118/// Absent until `submit_output` is called, and replaced (not appended to) by a
119/// later call: an agent that submits twice meant the second one. The stage name
120/// travels inside so the enforcement gate can tell "this stage submitted" from
121/// "an earlier one did".
122#[derive(Component, Clone, Debug, PartialEq)]
123pub struct FinalOutput(pub leviath_core::output::FinalOutput);
124
125/// Whether `stage` advertises a tool whose writes the framework would record:
126/// a built-in [`MODIFYING_TOOLS`] name, or one that this stage's own outgoing
127/// transition gates name (the declared escape hatch for agents whose writes go
128/// through MCP or script tools).
129///
130/// Deliberately the same test the transition gate applies in `gate_blocks`, so
131/// a gated stage and the run's flags cannot disagree about what "can modify"
132/// means.
133/// `shell` is absent from both: an agent can edit through `sed -i` without the
134/// framework seeing it, so shell capability is real but unverifiable - which
135/// is exactly why such a run should still be reported as empty rather than
136/// excused.
137///
138/// [`MODIFYING_TOOLS`]: leviath_core::blueprint::MODIFYING_TOOLS
139fn stage_can_modify(stage: &leviath_core::Stage) -> bool {
140    stage.available_tools.iter().any(|t| {
141        let canonical = leviath_tools::canonical_tool_name(t);
142        leviath_core::blueprint::MODIFYING_TOOLS.contains(&canonical)
143            || stage
144                .transitions
145                .iter()
146                .flat_map(|edges| edges.values())
147                .filter_map(|edge| edge.gate.as_ref())
148                .any(|gate| {
149                    gate.tools
150                        .iter()
151                        .any(|extra| leviath_tools::canonical_tool_name(extra) == canonical)
152                })
153    })
154}
155
156impl TokenTotals {
157    /// Add one inference response's usage to the running totals.
158    pub fn add_usage(&mut self, usage: &leviath_providers::TokenUsage) {
159        self.prompt_tokens += usage.prompt_tokens;
160        self.completion_tokens += usage.completion_tokens;
161        self.cached_tokens += usage.cached_tokens;
162        self.cache_write_tokens += usage.cache_write_tokens;
163    }
164}
165
166/// Whether a run in `status` carrying `flags` stopped with nothing to show for
167/// itself.
168///
169/// Four things have to hold. The run has to have *stopped* - an agent that
170/// hasn't written anything yet is not an empty run, it is a busy one. It has to
171/// have modified nothing. It must not have submitted a final output, which is
172/// producing something even when no file changed. And its blueprint has to have
173/// offered a way to modify something, or the question does not apply to it (see
174/// [`no_output_tools`](leviath_core::run_meta::RunFlags::no_output_tools)).
175///
176/// One definition, called by both `meta.json` and the run listing, so what an
177/// operator reads in `lev ps` and what a harness reads off disk cannot drift
178/// apart.
179pub fn is_empty_output(status: &AgentStatus, flags: &leviath_core::run_meta::RunFlags) -> bool {
180    matches!(
181        run_status_from(status),
182        RunStatus::Complete | RunStatus::Error | RunStatus::Cancelled
183    ) && flags.modified_file_count == 0
184        && !flags.produced_output
185        && !flags.no_output_tools
186}
187
188/// Map an agent's ECS status to the on-disk [`RunStatus`].
189pub fn run_status_from(status: &AgentStatus) -> RunStatus {
190    match status {
191        AgentStatus::Idle | AgentStatus::Active => RunStatus::Running,
192        AgentStatus::Paused => RunStatus::Paused,
193        AgentStatus::Waiting => RunStatus::WaitingInput,
194        AgentStatus::Complete => RunStatus::Complete,
195        AgentStatus::Error { .. } => RunStatus::Error,
196        AgentStatus::Cancelled => RunStatus::Cancelled,
197    }
198}
199
200/// Map an agent's ECS status to the on-disk per-stage [`StageRunStatus`] for the
201/// stage it is currently in. `Cancelled` has no stage-level equivalent, so it
202/// surfaces as `Error` (the stage stopped without completing).
203pub fn stage_status_from(status: &AgentStatus) -> StageRunStatus {
204    match status {
205        // A paused agent's current stage is still mid-flight, not a new stage state.
206        AgentStatus::Idle | AgentStatus::Active | AgentStatus::Paused => StageRunStatus::Active,
207        AgentStatus::Waiting => StageRunStatus::WaitingInput,
208        AgentStatus::Complete => StageRunStatus::Complete,
209        AgentStatus::Error { .. } | AgentStatus::Cancelled => StageRunStatus::Error,
210    }
211}
212
213/// The stringified region kind used in snapshots (matches the dashboard reader).
214fn region_kind_str(kind: &RegionKind) -> &'static str {
215    match kind {
216        RegionKind::Pinned => "pinned",
217        RegionKind::Temporary => "temporary",
218        RegionKind::Clearable => "clearable",
219        RegionKind::SlidingWindow { .. } => "sliding",
220        RegionKind::Compacting { .. } => "compacting",
221        RegionKind::CompactHistory { .. } => "history",
222        RegionKind::HashMap { .. } => "hashmap",
223        RegionKind::Checklist => "checklist",
224        RegionKind::Custom { .. } => "custom",
225    }
226}
227
228/// Build the full context snapshot (`context.json`) from a window. Pure over the
229/// window - no engine/entity. (Ported from the CLI's `build_context_snapshot`.)
230pub fn build_context_snapshot(window: &ContextWindow, stage_name: &str) -> ContextSnapshot {
231    let regions = window
232        .regions
233        .iter()
234        .map(|r| RegionSnapshot {
235            name: r.name.clone(),
236            kind: region_kind_str(&r.kind).to_string(),
237            current_tokens: r.current_tokens,
238            max_tokens: r.max_tokens,
239            entries: r
240                .content
241                .iter()
242                .enumerate()
243                .map(|(i, e)| RegionEntrySnapshot {
244                    content: e.content.clone(),
245                    tokens: e.tokens,
246                    kind: e.kind.clone(),
247                    metadata: e.metadata.clone(),
248                    key: e.key.clone(),
249                    // `None` when the region has no taint tracking (it is off,
250                    // or this is an older region): `Public`, which is what a
251                    // restore assumed anyway.
252                    taint: r
253                        .taint
254                        .as_ref()
255                        .and_then(|t| t.entry_taint(i))
256                        .unwrap_or_default(),
257                })
258                .collect(),
259        })
260        .collect();
261    ContextSnapshot {
262        stage_name: stage_name.to_string(),
263        total_tokens: window.current_tokens,
264        max_tokens: window.max_tokens,
265        regions,
266    }
267}
268
269/// The agent components `meta.json` is built from.
270///
271/// Held apart from [`RunPosition`] because these are read off the entity while
272/// the position is stamped onto it: one is what the agent *is*, the other is
273/// where it has got to.
274pub struct RunMetaSources<'a> {
275    /// The run's immutable metadata, fixed at spawn.
276    pub md: &'a RunMetadata,
277    /// The agent's live state.
278    pub state: &'a AgentState,
279    /// Token totals accumulated so far.
280    pub totals: &'a TokenTotals,
281    /// Outcome flags the blueprint's shape decides.
282    pub flags: &'a RunOutcomeFlags,
283    /// The submitted answer, when the run has produced one.
284    pub final_output: Option<&'a FinalOutput>,
285    /// The parking markers the agent is carrying, read off the entity by the
286    /// caller, which is where they are queryable.
287    pub parked: WaitMarkers,
288}
289
290/// Where the run has got to, and when.
291pub struct RunPosition {
292    /// Index of the stage the agent is in.
293    pub stage_index: usize,
294    /// The moment `updated_at` is stamped with.
295    pub now_secs: i64,
296    /// When the run last actually moved, as distinct from last being touched.
297    pub last_progress_at: Option<i64>,
298    /// How deep in the sub-agent tree this run sits.
299    pub depth: usize,
300    /// How deep the tree may go.
301    pub max_child_depth: usize,
302}
303
304/// Build the run metadata (`meta.json`) from an agent's live components, stamping
305/// `updated_at` with `now_secs`. `stage_index` is the agent's current stage
306/// position within its blueprint.
307///
308/// `last_progress_at` is the caller's separate record of when the run last
309/// actually moved, which is not the same as `now_secs`: this is called on the
310/// heartbeat too, and a heartbeat write must advance `updated_at` while leaving
311/// the progress stamp where it was. Taken as a plain `Option` rather than the
312/// watermark it comes from so this stays a data mapper with no dependency on the
313/// persistence pipeline.
314pub fn build_run_meta(sources: RunMetaSources<'_>, at: RunPosition) -> RunMeta {
315    let RunMetaSources {
316        md,
317        state,
318        totals,
319        flags,
320        final_output,
321        parked,
322    } = sources;
323    let RunPosition {
324        stage_index,
325        now_secs,
326        last_progress_at,
327        depth,
328        max_child_depth,
329    } = at;
330    let status = run_status_from(&state.status);
331    let mut flags = flags.0.clone();
332    // Having submitted an output is itself production, so this is settled before
333    // the emptiness verdict rather than after it.
334    flags.produced_output = final_output.is_some();
335    flags.empty_output = is_empty_output(&state.status, &flags);
336    RunMeta {
337        run_id: md.run_id.clone(),
338        agent_name: md.agent_name.clone(),
339        agent_path: md.agent_path.clone(),
340        task: md.task.clone(),
341        model: md.model.clone(),
342        pid: 0, // no per-run worker process in the shared world; see RunMeta::pid
343        status,
344        current_stage: state.current_stage.clone(),
345        stage_index,
346        num_stages: md.num_stages,
347        iteration: state.iteration,
348        prompt_tokens: totals.prompt_tokens,
349        completion_tokens: totals.completion_tokens,
350        cached_tokens: totals.cached_tokens,
351        cache_write_tokens: totals.cache_write_tokens,
352        tool_calls: totals.tool_calls,
353        workdir: md.workdir.clone(),
354        started_at: md.started_at,
355        updated_at: now_secs,
356        last_progress_at,
357        error: match &state.status {
358            AgentStatus::Error { message } => Some(message.clone()),
359            _ => None,
360        },
361        title: md.title.clone(),
362        metadata: md.metadata.clone(),
363        callback_url: md.callback_url.clone(),
364        callback_secret: md.callback_secret.clone(),
365        parent_run_id: md.parent_run_id.clone(),
366        // The tree links, so restart can rebuild the exact parent→children graph.
367        children: state.spawned_children_ids.clone(),
368        depth,
369        max_child_depth,
370        flags,
371        yolo: md.unattended,
372        read_paths: md.read_paths,
373        final_output: final_output.map(|o| o.0.descriptor()),
374        // Paused counts as parked here, not just Waiting: a run held until the
375        // machine is fixed is exactly the case where a reader most needs to be
376        // told why, and it is `Paused` rather than `Waiting` because nothing is
377        // holding a prompt open for it.
378        waiting_on: wait_reason_from(
379            matches!(state.status, AgentStatus::Waiting | AgentStatus::Paused),
380            &parked,
381        ),
382        output_request: md.output_request.clone(),
383    }
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389    use leviath_core::Region;
390    use leviath_core::run_meta::WaitReason;
391    use leviath_providers::TokenUsage;
392
393    fn state(status: AgentStatus) -> AgentState {
394        AgentState {
395            agent_id: "a".to_string(),
396            current_stage: "plan".to_string(),
397            iteration: 4,
398            status,
399            spawned_children_ids: vec![],
400            pending_wait: None,
401            accepts_messages: true,
402        }
403    }
404
405    fn metadata() -> RunMetadata {
406        RunMetadata {
407            run_id: "run-1".to_string(),
408            agent_name: "coder".to_string(),
409            agent_path: "/agents/coder".to_string(),
410            task: "do it".to_string(),
411            model: Some("anthropic/claude".to_string()),
412            workdir: "/work".to_string(),
413            num_stages: 3,
414            started_at: 1000,
415            parent_run_id: Some("parent".to_string()),
416            metadata: std::collections::HashMap::from([("k".to_string(), "v".to_string())]),
417            callback_url: Some("http://cb".to_string()),
418            callback_secret: Some("sekret".to_string()),
419            title: Some("Do It".to_string()),
420            unattended: false,
421            read_paths: None,
422            output_request: None,
423        }
424    }
425
426    /// A stage advertising `tools`, with `gate_tools` named by the gate on its
427    /// single outgoing edge. `gate_tools: None` gives the stage no transitions
428    /// at all, which is the other half of the `Option` the scan walks.
429    fn stage_with(tools: &[&str], gate_tools: Option<&[&str]>) -> leviath_core::Stage {
430        let mut stage = leviath_core::Stage::new(
431            "s".to_string(),
432            leviath_core::blueprint::ModelConfig::new("p".to_string(), "m".to_string()),
433        );
434        stage.available_tools = tools.iter().map(|t| (*t).to_string()).collect();
435        stage.transitions = gate_tools.map(|extra| {
436            let gate = (!extra.is_empty()).then(|| leviath_core::blueprint::TransitionGate {
437                require_modifications: true,
438                tools: extra.iter().map(|t| (*t).to_string()).collect(),
439                ..Default::default()
440            });
441            std::collections::HashMap::from([(
442                "next".to_string(),
443                leviath_core::blueprint::TransitionEdge {
444                    target: "next".to_string(),
445                    condition: leviath_core::blueprint::TransitionCondition::Always,
446                    hint: None,
447                    transform: leviath_core::blueprint::EdgeTransform::Direct,
448                    gate,
449                    stuck: None,
450                },
451            )])
452        });
453        stage
454    }
455
456    fn blueprint_of(stages: Vec<leviath_core::Stage>) -> leviath_core::Blueprint {
457        leviath_core::Blueprint::new(
458            "bp".to_string(),
459            "d".to_string(),
460            stages,
461            leviath_core::ContextLayout::new(vec![], 1000),
462        )
463    }
464
465    fn no_output_tools(stages: Vec<leviath_core::Stage>) -> bool {
466        RunOutcomeFlags::for_blueprint(&blueprint_of(stages))
467            .0
468            .no_output_tools
469    }
470
471    #[test]
472    fn for_blueprint_asks_whether_any_stage_could_have_written() {
473        // A blueprint with no stages at all offers nothing.
474        assert!(no_output_tools(vec![]));
475        // Read-only, and the sub-agent tools a router would use: nothing the
476        // framework tracks as a file change. This is the issue #192 case.
477        assert!(no_output_tools(vec![stage_with(
478            &["read_file", "spawn_agent", "context_write"],
479            None
480        )]));
481        // `shell` confers no tracked write: an agent editing through `sed -i`
482        // leaves no record, so silence from it stays suspicious rather than
483        // excused. The alias resolves, so `bash` is judged as `shell`.
484        assert!(no_output_tools(vec![stage_with(&["bash"], None)]));
485        // A built-in modifying tool, under either name.
486        assert!(!no_output_tools(vec![stage_with(&["write_file"], None)]));
487        assert!(!no_output_tools(vec![stage_with(&["edit_file"], None)]));
488        // Only one stage needs it.
489        assert!(!no_output_tools(vec![
490            stage_with(&["read_file"], None),
491            stage_with(&["write_file"], None),
492        ]));
493    }
494
495    #[test]
496    fn for_blueprint_honors_a_gate_declaring_its_own_write_tool() {
497        // An MCP/script write tool the stage advertises AND a gate names is a
498        // tracked write - the same escape hatch `stage_modifying_tools` gives.
499        assert!(!no_output_tools(vec![stage_with(
500            &["mcp__fs__put"],
501            Some(&["mcp__fs__put"])
502        )]));
503        // Declared by the gate but never advertised: the stage cannot call it.
504        assert!(no_output_tools(vec![stage_with(
505            &["read_file"],
506            Some(&["mcp__fs__put"])
507        )]));
508        // Transitions present, but no gate on the edge.
509        assert!(no_output_tools(vec![stage_with(&["read_file"], Some(&[]))]));
510        // A gate that names a tool unrelated to what the stage advertises.
511        assert!(no_output_tools(vec![stage_with(
512            &["mcp__fs__put"],
513            Some(&["mcp__other__put"])
514        )]));
515    }
516
517    #[test]
518    fn is_empty_output_needs_a_stopped_run_that_could_have_written() {
519        let nothing = leviath_core::run_meta::RunFlags::default();
520        // Running: it has not finished not-writing yet.
521        assert!(!is_empty_output(&AgentStatus::Active, &nothing));
522        assert!(!is_empty_output(&AgentStatus::Idle, &nothing));
523        assert!(!is_empty_output(&AgentStatus::Paused, &nothing));
524        assert!(!is_empty_output(&AgentStatus::Waiting, &nothing));
525        // Every way of stopping counts.
526        for status in [
527            AgentStatus::Complete,
528            AgentStatus::Cancelled,
529            AgentStatus::Error {
530                message: "x".to_string(),
531            },
532        ] {
533            assert!(is_empty_output(&status, &nothing));
534        }
535        // Wrote something.
536        let mut wrote = leviath_core::run_meta::RunFlags::default();
537        wrote.record_modification("src/a.rs");
538        assert!(!is_empty_output(&AgentStatus::Complete, &wrote));
539        // Had nothing to write with.
540        let incapable = leviath_core::run_meta::RunFlags {
541            no_output_tools: true,
542            ..Default::default()
543        };
544        assert!(!is_empty_output(&AgentStatus::Complete, &incapable));
545    }
546
547    #[test]
548    fn status_mapping_covers_all_variants() {
549        assert_eq!(run_status_from(&AgentStatus::Idle), RunStatus::Running);
550        assert_eq!(run_status_from(&AgentStatus::Active), RunStatus::Running);
551        assert_eq!(run_status_from(&AgentStatus::Paused), RunStatus::Paused);
552        assert_eq!(
553            run_status_from(&AgentStatus::Waiting),
554            RunStatus::WaitingInput
555        );
556        assert_eq!(run_status_from(&AgentStatus::Complete), RunStatus::Complete);
557        assert_eq!(
558            run_status_from(&AgentStatus::Error {
559                message: "x".to_string()
560            }),
561            RunStatus::Error
562        );
563        assert_eq!(
564            run_status_from(&AgentStatus::Cancelled),
565            RunStatus::Cancelled
566        );
567    }
568
569    #[test]
570    fn stage_status_mapping_covers_all_variants() {
571        use leviath_core::run_meta::StageRunStatus;
572        assert_eq!(
573            stage_status_from(&AgentStatus::Idle),
574            StageRunStatus::Active
575        );
576        assert_eq!(
577            stage_status_from(&AgentStatus::Active),
578            StageRunStatus::Active
579        );
580        assert_eq!(
581            stage_status_from(&AgentStatus::Paused),
582            StageRunStatus::Active
583        );
584        assert_eq!(
585            stage_status_from(&AgentStatus::Waiting),
586            StageRunStatus::WaitingInput
587        );
588        assert_eq!(
589            stage_status_from(&AgentStatus::Complete),
590            StageRunStatus::Complete
591        );
592        assert_eq!(
593            stage_status_from(&AgentStatus::Error {
594                message: "x".to_string()
595            }),
596            StageRunStatus::Error
597        );
598        assert_eq!(
599            stage_status_from(&AgentStatus::Cancelled),
600            StageRunStatus::Error
601        );
602    }
603
604    #[test]
605    fn token_totals_accumulate() {
606        let mut t = TokenTotals::default();
607        t.add_usage(&TokenUsage {
608            prompt_tokens: 10,
609            completion_tokens: 5,
610            total_tokens: 15,
611            cached_tokens: 2,
612            cache_write_tokens: 1,
613        });
614        t.add_usage(&TokenUsage {
615            prompt_tokens: 3,
616            completion_tokens: 4,
617            total_tokens: 7,
618            cached_tokens: 0,
619            cache_write_tokens: 0,
620        });
621        t.tool_calls = 6;
622        assert_eq!(t.prompt_tokens, 13);
623        assert_eq!(t.completion_tokens, 9);
624        assert_eq!(t.cached_tokens, 2);
625        assert_eq!(t.cache_write_tokens, 1);
626    }
627
628    /// Each marker names its own reason, and only while the run is parked.
629    ///
630    /// The precedence itself is `leviath_core`'s, shared with the live
631    /// listing; this pins that the persistence path feeds it the right
632    /// markers.
633    #[test]
634    fn each_parking_marker_names_its_own_reason() {
635        let cases = [
636            (
637                WaitMarkers {
638                    gate_prompt: true,
639                    ..Default::default()
640                },
641                WaitReason::TaintGate,
642            ),
643            (
644                WaitMarkers {
645                    interaction_point: true,
646                    ..Default::default()
647                },
648                WaitReason::InteractionPoint,
649            ),
650            (
651                WaitMarkers {
652                    fan_out_outstanding: Some(3),
653                    ..Default::default()
654                },
655                WaitReason::FanOutWorkers { outstanding: 3 },
656            ),
657            (
658                WaitMarkers {
659                    children_outstanding: Some(2),
660                    ..Default::default()
661                },
662                WaitReason::Children { outstanding: 2 },
663            ),
664        ];
665        for (markers, expected) in cases {
666            assert_eq!(
667                wait_reason_from(true, &markers),
668                Some(expected.clone()),
669                "{markers:?}"
670            );
671            // The same markers on a run that is not parked say nothing: an
672            // active or finished run is not waiting on anybody.
673            assert_eq!(wait_reason_from(false, &markers), None, "{markers:?}");
674        }
675    }
676
677    /// The whole point of the field: a fan-out parent is not waiting on a
678    /// person, and must not be reported as if it were.
679    #[test]
680    fn a_fan_out_parent_is_never_reported_as_waiting_on_a_person() {
681        let reason = wait_reason_from(
682            true,
683            &WaitMarkers {
684                fan_out_outstanding: Some(8),
685                ..Default::default()
686            },
687        )
688        .expect("a parked parent has a reason");
689        assert_eq!(reason, WaitReason::FanOutWorkers { outstanding: 8 });
690        assert!(
691            !reason.needs_a_person(),
692            "its workers are still going; nobody is needed"
693        );
694    }
695
696    /// The reason reaches `meta.json`, which is the file every client reads.
697    #[test]
698    fn build_run_meta_records_why_a_run_is_parked() {
699        let meta = build_run_meta(
700            RunMetaSources {
701                md: &metadata(),
702                state: &state(AgentStatus::Waiting),
703                totals: &TokenTotals::default(),
704                flags: &RunOutcomeFlags::default(),
705                final_output: None,
706                parked: WaitMarkers {
707                    children_outstanding: Some(2),
708                    ..Default::default()
709                },
710            },
711            RunPosition {
712                stage_index: 0,
713                now_secs: 0,
714                last_progress_at: None,
715                depth: 0,
716                max_child_depth: 0,
717            },
718        );
719        assert_eq!(meta.status, RunStatus::WaitingInput);
720        assert_eq!(
721            meta.waiting_on,
722            Some(WaitReason::Children { outstanding: 2 })
723        );
724    }
725
726    #[test]
727    fn build_run_meta_fills_dynamic_and_static_fields() {
728        let md = metadata();
729        let totals = TokenTotals {
730            prompt_tokens: 100,
731            completion_tokens: 50,
732            cached_tokens: 10,
733            cache_write_tokens: 5,
734            tool_calls: 7,
735        };
736        let mut st = state(AgentStatus::Active);
737        st.spawned_children_ids = vec!["child-a".to_string(), "child-b".to_string()];
738        let meta = build_run_meta(
739            RunMetaSources {
740                md: &md,
741                state: &st,
742                totals: &totals,
743                flags: &RunOutcomeFlags::default(),
744                final_output: None,
745                parked: WaitMarkers::default(),
746            },
747            RunPosition {
748                stage_index: 1,
749                now_secs: 2000,
750                last_progress_at: Some(1900),
751                depth: 1,
752                max_child_depth: 4,
753            },
754        );
755
756        assert_eq!(meta.run_id, "run-1");
757        assert_eq!(meta.status, RunStatus::Running);
758        assert_eq!(meta.current_stage, "plan");
759        assert_eq!(meta.stage_index, 1);
760        assert_eq!(meta.iteration, 4);
761        assert_eq!(meta.prompt_tokens, 100);
762        assert_eq!(meta.tool_calls, 7);
763        assert_eq!(meta.updated_at, 2000);
764        // The two stamps are independent: this snapshot was written at 2000, and
765        // the run last moved at 1900. A heartbeat write is exactly that shape.
766        assert_eq!(meta.last_progress_at, Some(1900));
767        assert_eq!(meta.parent_run_id.as_deref(), Some("parent"));
768        assert_eq!(meta.callback_url.as_deref(), Some("http://cb"));
769        assert_eq!(meta.callback_secret.as_deref(), Some("sekret"));
770        assert!(meta.error.is_none());
771        // The tree links are carried through from the agent's live state.
772        assert_eq!(
773            meta.children,
774            vec!["child-a".to_string(), "child-b".to_string()]
775        );
776        assert_eq!(meta.depth, 1);
777        assert_eq!(meta.max_child_depth, 4);
778        // Attended by default, so an ordinary run is never written as unattended.
779        assert!(!meta.yolo);
780    }
781
782    /// The snapshot carries `unattended` through to `meta.json`, which is what a
783    /// daemon restart reads back to resume the run the way it was launched.
784    #[test]
785    fn build_run_meta_records_an_unattended_run() {
786        let mut md = metadata();
787        md.unattended = true;
788        let meta = build_run_meta(
789            RunMetaSources {
790                md: &md,
791                state: &state(AgentStatus::Active),
792                totals: &TokenTotals::default(),
793                flags: &RunOutcomeFlags::default(),
794                final_output: None,
795                parked: WaitMarkers::default(),
796            },
797            RunPosition {
798                stage_index: 1,
799                now_secs: 2000,
800                last_progress_at: None,
801                depth: 1,
802                max_child_depth: 4,
803            },
804        );
805        assert!(meta.yolo);
806    }
807
808    #[test]
809    fn build_run_meta_flags_empty_output_only_once_the_run_has_stopped() {
810        let mut flags = RunOutcomeFlags::default();
811        flags.0.gates_forced = 2;
812        // Still running with nothing written: not (yet) an empty run.
813        let running = build_run_meta(
814            RunMetaSources {
815                md: &metadata(),
816                state: &state(AgentStatus::Active),
817                totals: &TokenTotals::default(),
818                flags: &flags,
819                final_output: None,
820                parked: WaitMarkers::default(),
821            },
822            RunPosition {
823                stage_index: 0,
824                now_secs: 1000,
825                last_progress_at: None,
826                depth: 0,
827                max_child_depth: 0,
828            },
829        );
830        assert!(!running.flags.empty_output);
831        assert_eq!(running.flags.gates_forced, 2);
832
833        // Finished with nothing written: that is the #107 signature.
834        for status in [
835            AgentStatus::Complete,
836            AgentStatus::Cancelled,
837            AgentStatus::Error {
838                message: "x".to_string(),
839            },
840        ] {
841            let meta = build_run_meta(
842                RunMetaSources {
843                    md: &metadata(),
844                    state: &state(status),
845                    totals: &TokenTotals::default(),
846                    flags: &flags,
847                    final_output: None,
848                    parked: WaitMarkers::default(),
849                },
850                RunPosition {
851                    stage_index: 0,
852                    now_secs: 1000,
853                    last_progress_at: None,
854                    depth: 0,
855                    max_child_depth: 0,
856                },
857            );
858            assert!(meta.flags.empty_output);
859        }
860
861        // Finished having written something: not empty.
862        let mut wrote = RunOutcomeFlags::default();
863        wrote.0.record_modification("src/a.rs");
864        let meta = build_run_meta(
865            RunMetaSources {
866                md: &metadata(),
867                state: &state(AgentStatus::Complete),
868                totals: &TokenTotals::default(),
869                flags: &wrote,
870                final_output: None,
871                parked: WaitMarkers::default(),
872            },
873            RunPosition {
874                stage_index: 0,
875                now_secs: 1000,
876                last_progress_at: None,
877                depth: 0,
878                max_child_depth: 0,
879            },
880        );
881        assert!(!meta.flags.empty_output);
882        assert_eq!(meta.flags.modified_files, vec!["src/a.rs".to_string()]);
883
884        // Finished having written nothing, with nothing to write *with*: the
885        // framework has no basis to call this empty, so it doesn't (issue #192).
886        let mut incapable = RunOutcomeFlags::default();
887        incapable.0.no_output_tools = true;
888        let meta = build_run_meta(
889            RunMetaSources {
890                md: &metadata(),
891                state: &state(AgentStatus::Complete),
892                totals: &TokenTotals::default(),
893                flags: &incapable,
894                final_output: None,
895                parked: WaitMarkers::default(),
896            },
897            RunPosition {
898                stage_index: 0,
899                now_secs: 1000,
900                last_progress_at: None,
901                depth: 0,
902                max_child_depth: 0,
903            },
904        );
905        assert!(!meta.flags.empty_output);
906        assert!(meta.flags.no_output_tools);
907    }
908
909    #[test]
910    fn build_run_meta_carries_error_message() {
911        let meta = build_run_meta(
912            RunMetaSources {
913                md: &metadata(),
914                state: &state(AgentStatus::Error {
915                    message: "boom".to_string(),
916                }),
917                totals: &TokenTotals::default(),
918                flags: &RunOutcomeFlags::default(),
919                final_output: None,
920                parked: WaitMarkers::default(),
921            },
922            RunPosition {
923                stage_index: 2,
924                now_secs: 3000,
925                last_progress_at: None,
926                depth: 0,
927                max_child_depth: 0,
928            },
929        );
930        assert_eq!(meta.status, RunStatus::Error);
931        assert_eq!(meta.error.as_deref(), Some("boom"));
932    }
933
934    /// A submitted output reaches `meta.json` and settles the emptiness verdict.
935    ///
936    /// The second half is the point: an agent whose whole deliverable is its
937    /// answer modifies no files, and before `produced_output` existed every one
938    /// of its successful runs was reported `complete (no output)`.
939    #[test]
940    fn build_run_meta_carries_a_submitted_output_and_clears_the_empty_verdict() {
941        let submitted = FinalOutput(leviath_core::output::FinalOutput::new(
942            "Renamed two helpers and updated their callers.",
943            Some("markdown".to_string()),
944            "summary".to_string(),
945            1234,
946        ));
947        let meta = build_run_meta(
948            RunMetaSources {
949                md: &metadata(),
950                state: &state(AgentStatus::Complete),
951                totals: &TokenTotals::default(),
952                flags: &RunOutcomeFlags::default(),
953                final_output: Some(&submitted),
954                parked: WaitMarkers::default(),
955            },
956            RunPosition {
957                stage_index: 0,
958                now_secs: 1000,
959                last_progress_at: None,
960                depth: 0,
961                max_child_depth: 0,
962            },
963        );
964        let carried = meta.final_output.expect("the submission reached meta.json");
965        // The descriptor, not the bytes: `meta.json` is parsed for every run on
966        // every listing, so the answer itself lives in a sidecar beside it.
967        assert_eq!(
968            carried.bytes,
969            "Renamed two helpers and updated their callers.".len()
970        );
971        assert_eq!(carried.format.as_deref(), Some("markdown"));
972        assert_eq!(carried.stage, "summary");
973        assert!(meta.flags.produced_output);
974        // Modified nothing, yet produced something: not an empty run.
975        assert!(!meta.flags.empty_output);
976    }
977
978    /// The same run without the submission is still judged empty, so the clause
979    /// above is doing the work rather than some other condition.
980    #[test]
981    fn a_run_that_submits_nothing_is_still_judged_empty() {
982        let meta = build_run_meta(
983            RunMetaSources {
984                md: &metadata(),
985                state: &state(AgentStatus::Complete),
986                totals: &TokenTotals::default(),
987                flags: &RunOutcomeFlags::default(),
988                final_output: None,
989                parked: WaitMarkers::default(),
990            },
991            RunPosition {
992                stage_index: 0,
993                now_secs: 1000,
994                last_progress_at: None,
995                depth: 0,
996                max_child_depth: 0,
997            },
998        );
999        assert!(meta.final_output.is_none());
1000        assert!(!meta.flags.produced_output);
1001        assert!(meta.flags.empty_output);
1002    }
1003
1004    #[test]
1005    fn context_snapshot_captures_all_region_kinds() {
1006        let mut w = ContextWindow::new(1000);
1007        w.add_region(Region::new("pin".to_string(), RegionKind::Pinned, 100));
1008        w.add_region(Region::new("tmp".to_string(), RegionKind::Temporary, 100));
1009        w.add_region(Region::new("clr".to_string(), RegionKind::Clearable, 100));
1010        w.add_region(Region::new(
1011            "slide".to_string(),
1012            RegionKind::SlidingWindow {
1013                max_items: 5,
1014                eviction_strategy: leviath_core::EvictionStrategy::PerItem,
1015            },
1016            100,
1017        ));
1018        w.add_region(Region::new(
1019            "comp".to_string(),
1020            RegionKind::Compacting {
1021                threshold_tokens: 5,
1022            },
1023            100,
1024        ));
1025        w.add_region(Region::new(
1026            "hist".to_string(),
1027            RegionKind::CompactHistory {
1028                source_region: "comp".to_string(),
1029            },
1030            100,
1031        ));
1032        w.add_region(Region::new(
1033            "map".to_string(),
1034            RegionKind::HashMap { max_entries: None },
1035            100,
1036        ));
1037        w.add_region(Region::new(
1038            "brain".to_string(),
1039            RegionKind::Custom {
1040                script: "b.rhai".to_string(),
1041                persistent: false,
1042            },
1043            100,
1044        ));
1045        w.add_region(Region::new("todos".to_string(), RegionKind::Checklist, 100));
1046        let _ = w.add_to_region("pin", "hello".to_string(), 3);
1047        w.current_tokens = w.calculate_tokens();
1048
1049        let snap = build_context_snapshot(&w, "plan");
1050
1051        assert_eq!(snap.stage_name, "plan");
1052        let kinds: Vec<&str> = snap.regions.iter().map(|r| r.kind.as_str()).collect();
1053        assert_eq!(
1054            kinds,
1055            vec![
1056                "pinned",
1057                "temporary",
1058                "clearable",
1059                "sliding",
1060                "compacting",
1061                "history",
1062                "hashmap",
1063                "custom",
1064                "checklist"
1065            ]
1066        );
1067        // The pinned region's entry is captured.
1068        let pin = snap.regions.iter().find(|r| r.name == "pin").unwrap();
1069        assert_eq!(pin.entries.len(), 1);
1070        assert_eq!(pin.entries[0].content, "hello");
1071    }
1072}