Skip to main content

leviath_core/
run_meta.rs

1//! Plain, serializable run-state data types.
2//!
3//! These are pure data (`serde`-derived structs/enums plus trivial constructors)
4//! with no filesystem or async dependencies, so they can be named by both
5//! `leviath-cli` and the `leviath-runtime` engine. All on-disk IO for
6//! these types (reading/writing `meta.json`, run directories, snapshots, etc.)
7//! lives in `leviath_cli::runstate`.
8
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use std::time::{SystemTime, UNIX_EPOCH};
12
13/// Current status of a background run.
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
15#[serde(rename_all = "snake_case")]
16pub enum RunStatus {
17    /// Accepted and being set up; no inference has been issued yet.
18    Starting,
19    /// Working: inferring, calling tools, or moving between stages.
20    Running,
21    /// Blocked on a person. A human-in-the-loop tool or an interaction point is
22    /// waiting for an answer, and the run holds its concurrency slot until it
23    /// gets one.
24    WaitingInput,
25    /// Finished, with nothing further to accept.
26    Complete,
27    /// All required stages done; agent still accepts optional follow-up input.
28    /// Shown as "Complete" in the dashboard - no kill option, input still enabled.
29    CompleteInteractive,
30    /// Paused by the user; resumes on request and is restored paused after a
31    /// daemon restart.
32    Paused,
33    /// Stopped by a failure. `RunMeta::error` carries what went wrong.
34    Error,
35    /// Stopped from outside, by `lev kill` or a shutting-down daemon. Distinct
36    /// from [`Error`](Self::Error): nothing went wrong, someone decided.
37    Cancelled,
38}
39
40impl std::fmt::Display for RunStatus {
41    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        match self {
43            RunStatus::Starting => write!(f, "Starting"),
44            RunStatus::Running => write!(f, "Running"),
45            RunStatus::WaitingInput => write!(f, "WaitingInput"),
46            RunStatus::Complete => write!(f, "Complete"),
47            RunStatus::CompleteInteractive => write!(f, "CompleteInteractive"),
48            RunStatus::Paused => write!(f, "Paused"),
49            RunStatus::Error => write!(f, "Error"),
50            RunStatus::Cancelled => write!(f, "Cancelled"),
51        }
52    }
53}
54
55/// Why a run's status is [`RunStatus::WaitingInput`].
56///
57/// `WaitingInput` alone is several unrelated situations wearing one word, and
58/// they call for opposite responses: a fan-out parent whose workers are
59/// churning is healthy and needs nothing, while a run parked on a
60/// tool-approval prompt is stopped dead until a person answers it. Issue #184
61/// is what happens when the two are indistinguishable - an operator reading
62/// `waiting` across a factory concluded it had stalled and started killing
63/// healthy runs. Issue #431 is the same conflation reaching every client that
64/// reads `meta.json`.
65///
66/// Derived on demand from markers the engine already sets, by
67/// [`wait_reason_from`]; nothing tracks it separately, so it cannot fall out of
68/// sync with the status it explains. It lives here rather than in the runtime
69/// because it is both reported live over the control socket and written to
70/// `meta.json`, and one vocabulary across those two is the whole point.
71///
72/// Deliberately not new [`RunStatus`] variants: the status is matched
73/// exhaustively across the codebase and serialized two ways on the wire, so
74/// splitting it would break every consumer to express something that is not a
75/// new state. The run really is waiting; this says on what.
76#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
77#[serde(rename_all = "snake_case", tag = "reason")]
78pub enum WaitReason {
79    /// Blocked on a tool-approval prompt. Needs a person (or `--yolo`).
80    ToolApproval,
81
82    /// Blocked on a question the agent itself asked (`ask_user_*`,
83    /// `present_for_review`). Needs a person.
84    UserPrompt,
85
86    /// Blocked on a taint-gate clearance prompt. Needs a person.
87    TaintGate,
88
89    /// Blocked on a blueprint stage-boundary checkpoint. Needs a person.
90    InteractionPoint,
91
92    /// Parked while fan-out workers run. Healthy; resolves on its own.
93    FanOutWorkers {
94        /// Workers still to finish, counting both running and not-yet-started.
95        outstanding: usize,
96    },
97
98    /// Parked while spawned sub-agents run (`requires_children`). Healthy;
99    /// resolves on its own.
100    Children {
101        /// Children that have not reached a terminal status.
102        outstanding: usize,
103    },
104
105    /// Parked because something on the machine has to change before this run
106    /// can go on: a provider it needs is not configured, a key was rejected,
107    /// an account is out of credits.
108    ///
109    /// These used to end the run. They are all deterministic and all outside
110    /// the run's control, so ending it threw away everything it had done to
111    /// punish a person for a typo in `config.toml`. The run holds its place
112    /// instead, and `lev resume` picks it up once the machine is fixed.
113    ///
114    /// The distinction that matters is not "is there a fix" but "does the fix
115    /// let *this* run continue": a broken blueprint is equally deterministic
116    /// and equally fixable, and still cannot be resumed into, because the
117    /// blueprint was read at spawn.
118    NeedsSetup {
119        /// Which kind of problem, so a client can offer the right thing to do
120        /// rather than parse the sentence below.
121        blocker: SetupBlocker,
122        /// What to do about it, in a sentence, for whoever reads the run.
123        remedy: String,
124    },
125}
126
127/// What is stopping a [`WaitReason::NeedsSetup`] run, in a form a client can
128/// branch on.
129///
130/// One variant per remedy, not per error: these are the cases whose *fixes*
131/// differ. Topping up an account, adding a provider to `config.toml` and
132/// replacing a rejected key are three different screens, and a console that
133/// had only the sentence would be reduced to matching on its wording.
134#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
135#[serde(rename_all = "snake_case")]
136pub enum SetupBlocker {
137    /// The stage names a provider this install has not configured.
138    ProviderMissing,
139    /// The account behind the provider is out of credits.
140    CreditsExhausted,
141    /// The key was rejected.
142    AuthFailed,
143    /// The key is valid but not allowed to use the model.
144    Forbidden,
145    /// Every candidate is out of service, for reasons that do not agree or are
146    /// not known. The remedy names what was tried last.
147    ProvidersUnavailable,
148}
149
150impl std::fmt::Display for SetupBlocker {
151    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
152        match self {
153            Self::ProviderMissing => f.write_str("provider"),
154            Self::CreditsExhausted => f.write_str("credits"),
155            Self::AuthFailed => f.write_str("key"),
156            Self::Forbidden => f.write_str("access"),
157            Self::ProvidersUnavailable => f.write_str("providers"),
158        }
159    }
160}
161
162/// What a parked run needs, gathered where the markers are visible.
163#[derive(Debug, Clone, PartialEq, Eq)]
164pub struct SetupNeeded {
165    /// Which kind of problem it is.
166    pub blocker: SetupBlocker,
167    /// What to do about it.
168    pub remedy: String,
169}
170
171impl WaitReason {
172    /// Whether clearing this needs a person. `false` means the run is parked on
173    /// other work and will move on by itself.
174    pub fn needs_a_person(&self) -> bool {
175        !matches!(self, Self::FanOutWorkers { .. } | Self::Children { .. })
176    }
177}
178
179impl std::fmt::Display for WaitReason {
180    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
181        match self {
182            Self::ToolApproval => f.write_str("tool approval"),
183            Self::UserPrompt => f.write_str("user prompt"),
184            Self::TaintGate => f.write_str("taint gate"),
185            Self::InteractionPoint => f.write_str("checkpoint"),
186            Self::FanOutWorkers { outstanding } => write!(f, "workers({outstanding})"),
187            Self::Children { outstanding } => write!(f, "children({outstanding})"),
188            // The remedy is a sentence; this is a table cell. The blocker is
189            // the half that fits, and the half that says which screen to open.
190            Self::NeedsSetup { blocker, .. } => write!(f, "needs {blocker}"),
191        }
192    }
193}
194
195/// The parking markers an agent carries, gathered by whoever can see them.
196///
197/// The live listing reads these straight off the world; the persistence system
198/// reads them off its query. Both then hand them here, so the precedence below
199/// is written once instead of once per surface - two copies of it would
200/// disagree the first time either was edited.
201#[derive(Debug, Clone, Default, PartialEq)]
202pub struct WaitMarkers {
203    /// A taint-gate clearance prompt is outstanding.
204    pub gate_prompt: bool,
205    /// A blueprint stage-boundary checkpoint is holding.
206    pub interaction_point: bool,
207    /// Fan-out workers still to finish, when this run is a fan-out parent.
208    pub fan_out_outstanding: Option<usize>,
209    /// Sub-agents still running, when this run is held for its children.
210    pub children_outstanding: Option<usize>,
211    /// The kind of hub request holding this run, when one is.
212    pub interaction: Option<crate::interaction::InteractionKind>,
213    /// Whether a hub request is holding it at all. Separate from the kind
214    /// because the kind can be unknown while the block is real.
215    pub awaiting_interaction: bool,
216    /// The run is parked until the machine is fixed, and this is what it
217    /// needs.
218    pub needs_setup: Option<SetupNeeded>,
219}
220
221/// Why a parked run is parked, or `None` when it is not parked or nothing has
222/// claimed it.
223///
224/// Order matters, and it is the specific claim first. A taint-gate block and a
225/// stage checkpoint each open a hub request of their own, so both also look
226/// like a generic prompt; asking the specific markers first is what keeps them
227/// from all reporting as one.
228pub fn wait_reason_from(parked: bool, markers: &WaitMarkers) -> Option<WaitReason> {
229    if !parked {
230        return None;
231    }
232    // First, because it outranks everything: a run whose provider is missing
233    // is not going to be unblocked by answering a prompt.
234    if let Some(need) = &markers.needs_setup {
235        return Some(WaitReason::NeedsSetup {
236            blocker: need.blocker,
237            remedy: need.remedy.clone(),
238        });
239    }
240    if markers.gate_prompt {
241        return Some(WaitReason::TaintGate);
242    }
243    if markers.interaction_point {
244        return Some(WaitReason::InteractionPoint);
245    }
246    if let Some(outstanding) = markers.fan_out_outstanding {
247        return Some(WaitReason::FanOutWorkers { outstanding });
248    }
249    if let Some(outstanding) = markers.children_outstanding {
250        return Some(WaitReason::Children { outstanding });
251    }
252    if markers.awaiting_interaction {
253        return Some(match markers.interaction {
254            Some(crate::interaction::InteractionKind::ToolApproval) => WaitReason::ToolApproval,
255            _ => WaitReason::UserPrompt,
256        });
257    }
258    None
259}
260
261/// Metadata for a single background agent run.
262#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
263pub struct RunMeta {
264    /// Identifies the run everywhere, and names its directory under
265    /// `~/.leviath/runs/`. Assigned at spawn and never reused.
266    pub run_id: String,
267    /// The blueprint's `[agent] name`, not the file it was loaded from. Two runs
268    /// of the same agent from different paths share this.
269    pub agent_name: String,
270    /// Absolute path to the agent manifest directory
271    pub agent_path: String,
272    /// The task text the run was started with, verbatim.
273    pub task: String,
274    /// The `provider/model` actually resolved for the entry stage, or `None`
275    /// before resolution. Later stages may use a different one; this is not
276    /// rewritten to follow them.
277    pub model: Option<String>,
278    /// Always 0. There is no worker process per run: the daemon hosts every run
279    /// as an entity in one shared world, so no run has a pid of its own.
280    ///
281    /// Kept because it is written into every `meta.json` there has ever been,
282    /// and served from `GET /api/agents`. Do not key liveness on it. `pid == 0`
283    /// is true of a run that is working, a run that has finished, and a run
284    /// nothing is driving, so a sweeper that reverts on it reverts everything.
285    /// Ask the daemon (`lev ps`) whether it is still hosting the run, and read
286    /// `status` and `last_progress_at` off disk for what became of it.
287    #[serde(default)]
288    pub pid: u32,
289    /// Where the run stands. The durable counterpart to the ECS world's live
290    /// `AgentStatus`, and the one that survives a daemon restart.
291    pub status: RunStatus,
292    /// Name of the stage the run is in, matching a key under `[stages]`.
293    pub current_stage: String,
294    /// Zero-based position of `current_stage` in the blueprint's stage list.
295    /// Not a progress measure: stages can loop and revisit.
296    pub stage_index: usize,
297    /// How many stages the blueprint declares, so a reader can render
298    /// `stage_index` as "3 of 7" without loading the manifest.
299    pub num_stages: usize,
300    /// Inference turns taken in the current stage, reset on entering a new one.
301    /// Compared against the stage's `max_iterations`.
302    pub iteration: usize,
303    /// Cumulative input tokens billed across every inference this run has made,
304    /// including retries.
305    pub prompt_tokens: usize,
306    /// Cumulative output tokens billed across every inference this run has made.
307    pub completion_tokens: usize,
308    /// Cumulative tokens read from provider cache.
309    #[serde(default)]
310    pub cached_tokens: usize,
311    /// Cumulative tokens written to provider cache.
312    #[serde(default)]
313    pub cache_write_tokens: usize,
314    /// Total number of tool calls made across all iterations.
315    #[serde(default)]
316    pub tool_calls: usize,
317    /// Absolute path to the working directory for tool execution
318    pub workdir: String,
319    /// Unix timestamp (seconds)
320    pub started_at: i64,
321    /// Unix timestamp (seconds)
322    pub updated_at: i64,
323    /// Unix seconds when this run last actually moved: a new iteration, a new
324    /// stage, or a change of status. `None` before the first snapshot lands, and
325    /// on runs written by a daemon older than this field.
326    ///
327    /// Distinct from `updated_at`, which also advances on the 30-second
328    /// persistence heartbeat and so stays fresh on a run that is wedged. A fresh
329    /// `updated_at` is evidence the daemon is alive, and no evidence at all about
330    /// the run. Anything that ages a run must read this instead. Note that a
331    /// daemon restart resets it: a reloaded run really is re-driven from its
332    /// saved context, so it really has just moved.
333    #[serde(default)]
334    pub last_progress_at: Option<i64>,
335    /// What went wrong, set alongside [`RunStatus::Error`]. `None` on every
336    /// other status.
337    pub error: Option<String>,
338    /// Short human-readable title generated from the task prompt (None until generated).
339    #[serde(default)]
340    pub title: Option<String>,
341    /// Custom key-value pairs from the spawn request (API metadata).
342    #[serde(default)]
343    pub metadata: HashMap<String, String>,
344    /// Webhook URL to POST on agent completion/error.
345    #[serde(default)]
346    pub callback_url: Option<String>,
347    /// Optional shared secret used to HMAC-SHA256 sign the webhook body
348    /// (`X-Leviath-Signature` header) so the receiver can verify authenticity.
349    ///
350    /// Persisted, because the daemon must still be able to sign a webhook for a
351    /// run it reloaded after a restart. **Never serve it** - strip it with
352    /// [`RunMeta::redacted`] before any of this struct leaves the process. See
353    /// that method for what went wrong.
354    #[serde(default)]
355    pub callback_secret: Option<String>,
356    /// Links sub-agent runs to their parent run.
357    #[serde(default)]
358    pub parent_run_id: Option<String>,
359    /// Run-ids of this agent's direct sub-agents (sub-agent-tool spawns and
360    /// fan-out workers). Persisted so the daemon can rebuild the exact
361    /// parent→children tree on restart rather than reload children as orphans.
362    #[serde(default)]
363    pub children: Vec<String>,
364    /// This agent's depth in the sub-agent tree (0 for a top-level run).
365    /// Persisted so a reloaded child enforces its remaining spawn-depth budget.
366    #[serde(default)]
367    pub depth: usize,
368    /// The sub-agent depth cap this agent imposes on its own children
369    /// (0 when it has none). Restores `SubAgentChildren::max_child_depth`.
370    #[serde(default)]
371    pub max_child_depth: usize,
372    /// Why this run may have produced nothing useful - see [`RunFlags`].
373    #[serde(default)]
374    pub flags: RunFlags,
375    /// Whether the run was launched unattended (`--yolo`), so a daemon restart
376    /// resumes it the way it was started.
377    ///
378    /// This used to be dropped on reload, on the reasoning that forgetting a
379    /// launch override can only prompt more, never less. In practice it meant a
380    /// restart silently converted an unattended run into one parked on a prompt
381    /// nobody was watching for - the operator's own consent, given at launch,
382    /// discarded by an implementation detail they never saw. Runs written before
383    /// this field existed default to attended, so nothing is escalated
384    /// retroactively.
385    #[serde(default)]
386    pub yolo: bool,
387    /// How much of the blueprint's `[read_paths]` the config granted, as
388    /// resolved at spawn. `None` for a blueprint that declared none, and for
389    /// runs written before this field existed.
390    #[serde(default, skip_serializing_if = "Option::is_none")]
391    pub read_paths: Option<ReadPathGrantCounts>,
392    /// What the agent handed back, if it submitted anything: everything about
393    /// the answer except the bytes.
394    ///
395    /// This is the run's answer, as distinct from `error` (why it failed) and
396    /// from the stage logs (what it did along the way). The content itself is
397    /// in a sidecar file beside this one, because this file is parsed for every
398    /// run on every listing and must stay small no matter how long an answer is.
399    #[serde(default, skip_serializing_if = "Option::is_none")]
400    pub final_output: Option<crate::output::FinalOutputDescriptor>,
401
402    /// Why this run is parked, when it is. `None` on every other status, and
403    /// on a run written before this field existed. Same vocabulary the live
404    /// listing reports, so `lev ps` and a client reading this file describe a
405    /// run the same way.
406    ///
407    /// Additive on purpose: `default` means a `meta.json` from an older build
408    /// still loads, and `skip_serializing_if` means a run that is not parked
409    /// writes exactly the file it wrote before, so an older build reading a
410    /// newer run sees nothing new either.
411    #[serde(default, skip_serializing_if = "Option::is_none")]
412    pub waiting_on: Option<WaitReason>,
413    /// The output shape this run was launched asking for, when the caller
414    /// overrode the blueprint's.
415    ///
416    /// Persisted for the same reason `yolo` is: a daemon restart rebuilds the
417    /// run's spawn arguments from this file, and dropping the request would
418    /// silently revert the run to the blueprint's shape partway through. The
419    /// caller asked once and should not have to ask again.
420    #[serde(default, skip_serializing_if = "Option::is_none")]
421    pub output_request: Option<crate::output::OutputSpec>,
422}
423
424/// How many `[read_paths]` entries a run's blueprint declared, and how many of
425/// them the user's config actually granted.
426///
427/// Declaring is not granting: an ungranted entry is inert, and the reads it was
428/// meant to allow are refused. Recorded at spawn, because that is when the
429/// policy the run enforces is fixed - editing the config afterwards changes
430/// nothing for a run already in flight.
431#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
432pub struct ReadPathGrantCounts {
433    /// Entries the blueprint declares.
434    pub declared: usize,
435    /// Entries the config grants.
436    pub granted: usize,
437}
438
439/// Post-hoc diagnosis of a run's productivity, persisted in `meta.json` so a
440/// harness (or the dashboard) can tell an empty run from a successful one
441/// without inspecting the workspace or parsing logs.
442///
443/// The motivating failure: 13/300 SWE-bench runs completed their whole stage
444/// pipeline and produced no file changes at all. Nothing on disk said so, or
445/// said why.
446#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
447pub struct RunFlags {
448    /// Paths passed to file-modifying tools that succeeded, in first-touch
449    /// order. Capped at [`MAX_TRACKED_MODIFIED_FILES`]; `modified_file_count`
450    /// keeps the true total.
451    #[serde(default)]
452    pub modified_files: Vec<String>,
453    /// Total successful file-modifying tool calls across the run (uncapped).
454    #[serde(default)]
455    pub modified_file_count: usize,
456    /// The run reached a terminal status having modified nothing, and its
457    /// blueprint gave it a way to modify something. See [`Self::no_output_tools`].
458    #[serde(default)]
459    pub empty_output: bool,
460    /// No stage of the blueprint advertised a file-modifying tool, so this run
461    /// could never have produced the file changes `empty_output` looks for.
462    ///
463    /// Recorded because "modified no files" only diagnoses an agent that was
464    /// supposed to modify files. A router that spawns sub-agents, or an agent
465    /// whose answer is its text, would otherwise report itself empty on every
466    /// successful run - which is what happened in issue #192. The framework has
467    /// no basis to judge such a run, so it says nothing rather than accusing.
468    ///
469    /// This mirrors the escape the runtime's `gate_blocks` already applies per
470    /// stage: a `require_modifications` gate on a stage that advertises no
471    /// modifying tool is skipped, because it could never pass.
472    ///
473    /// Phrased negatively so the `false` that [`Default`] and `serde(default)`
474    /// produce means "was capable" - the behavior every `meta.json` written
475    /// before this field had.
476    #[serde(default)]
477    pub no_output_tools: bool,
478    /// How many stages exhausted their `max_iterations`.
479    #[serde(default)]
480    pub max_iterations_hit: usize,
481    /// How many transitions proceeded past an unsatisfied gate because the
482    /// gate's re-run budget ran out.
483    #[serde(default)]
484    pub gates_forced: usize,
485    /// Regions declared `required` that were still empty when the stage that
486    /// owed them gave up and moved on, in the order they were abandoned.
487    ///
488    /// The mechanism re-runs the stage a bounded number of times and then
489    /// proceeds with a log line, which nothing downstream reads: a run whose
490    /// agent wrote its plan and a run where we asked twice and moved on both
491    /// finished `complete`, with the second silently missing the artifact every
492    /// later stage's prompt says to work from (#371). Names rather than a count
493    /// because knowing *which* region was abandoned is what makes it
494    /// actionable, and a run cannot abandon many.
495    #[serde(default)]
496    pub required_regions_abandoned: Vec<String>,
497    /// The working directory disappeared mid-run.
498    #[serde(default)]
499    pub workspace_lost: bool,
500    /// The run submitted a final output.
501    ///
502    /// Counts as having produced something, alongside file modifications.
503    /// Without this an agent whose whole deliverable is its answer - a
504    /// researcher, a reviewer, a router - reported itself empty on every
505    /// successful run, which is the same mistake [`Self::no_output_tools`] was
506    /// added to correct from the other direction.
507    #[serde(default)]
508    pub produced_output: bool,
509    /// How many stages transitioned without the final output they required,
510    /// because the re-run budget ran out.
511    ///
512    /// The counterpart to [`Self::gates_forced`]: the run finished, and this
513    /// says the answer it hands back may be missing.
514    #[serde(default)]
515    pub output_forced: usize,
516}
517
518/// How many distinct modified paths [`RunFlags`] records before it stops
519/// growing (the count keeps rising). Bounds `meta.json` for a long run.
520pub const MAX_TRACKED_MODIFIED_FILES: usize = 200;
521
522impl RunFlags {
523    /// Record a successful modifying tool call on `path`.
524    pub fn record_modification(&mut self, path: &str) {
525        self.modified_file_count += 1;
526        if self.modified_files.len() < MAX_TRACKED_MODIFIED_FILES
527            && !self.modified_files.iter().any(|p| p == path)
528        {
529            self.modified_files.push(path.to_string());
530        }
531    }
532}
533
534impl RunMeta {
535    /// This run's metadata with the webhook signing secret removed, for anything
536    /// that leaves the process.
537    ///
538    /// `GET /api/agents`, `/api/agents/{id}` and `/api/agents/{id}/children` all
539    /// serialized `RunMeta` whole, so any holder of the API token could read
540    /// every run's `callback_secret` - the key that authenticates Leviath's
541    /// webhooks to their receivers. Mirrors the `RedactedConfig` pattern the
542    /// `/api/config` handler already uses correctly.
543    ///
544    /// Returns an owned copy rather than mutating in place so a caller cannot
545    /// accidentally redact the record the daemon still needs for signing.
546    #[must_use]
547    pub fn redacted(&self) -> Self {
548        Self {
549            callback_secret: None,
550            ..self.clone()
551        }
552    }
553
554    /// A newly accepted run: [`RunStatus::Starting`], both timestamps now, every
555    /// counter at zero and every optional field unset.
556    ///
557    /// Only the seven values a caller genuinely knows at spawn are parameters.
558    /// Everything else is filled in by the daemon as the run proceeds, so taking
559    /// them here would invite a caller to invent a stage or a token count.
560    pub fn new(
561        run_id: String,
562        agent_name: String,
563        agent_path: String,
564        task: String,
565        model: Option<String>,
566        workdir: String,
567        num_stages: usize,
568    ) -> Self {
569        let now = now_secs();
570        Self {
571            run_id,
572            agent_name,
573            agent_path,
574            task,
575            model,
576            pid: 0,
577            status: RunStatus::Starting,
578            current_stage: String::new(),
579            stage_index: 0,
580            num_stages,
581            iteration: 0,
582            prompt_tokens: 0,
583            completion_tokens: 0,
584            cached_tokens: 0,
585            cache_write_tokens: 0,
586            tool_calls: 0,
587            workdir,
588            started_at: now,
589            updated_at: now,
590            last_progress_at: None,
591            error: None,
592            title: None,
593            metadata: HashMap::new(),
594            callback_url: None,
595            callback_secret: None,
596            parent_run_id: None,
597            children: Vec::new(),
598            depth: 0,
599            max_child_depth: 0,
600            final_output: None,
601            waiting_on: None,
602            output_request: None,
603            flags: RunFlags::default(),
604            yolo: false,
605            read_paths: None,
606        }
607    }
608
609    /// Stamp `updated_at` with the current time.
610    ///
611    /// Deliberately does **not** touch `last_progress_at`: the 30-second
612    /// persistence heartbeat calls this, and a run that is wedged must not look
613    /// like one that just moved. See [`RunMeta::last_progress_at`].
614    pub fn touch(&mut self) {
615        self.updated_at = now_secs();
616    }
617}
618
619/// One content entry within a region, captured at snapshot time.
620#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
621pub struct RegionEntrySnapshot {
622    /// The entry's text, exactly as it sat in the live region.
623    pub content: String,
624    /// The entry's token cost as counted when it was added, carried through the
625    /// snapshot so a reload does not have to re-tokenize to rebuild budgets.
626    pub tokens: usize,
627    /// The entry's role/kind, so a snapshot round-trips faithfully when the
628    /// daemon reloads it on restart. Defaults to `Text` for older snapshots.
629    #[serde(default)]
630    pub kind: crate::region::EntryKind,
631    /// Free-form structured data an entry writer attached, passed through
632    /// untouched. Nothing in the engine interprets it.
633    #[serde(default, skip_serializing_if = "Option::is_none")]
634    pub metadata: Option<serde_json::Value>,
635    /// Key for HashMap region entries (file paths, section names, etc.)
636    #[serde(default, skip_serializing_if = "Option::is_none")]
637    pub key: Option<String>,
638    /// How sensitive this entry is.
639    ///
640    /// Persisted because taint was not, and a restore that dropped it silently
641    /// disarmed the gate: the reloaded run re-enabled taint tracking, found
642    /// every region back at `Public`, and let outbound tools through that had
643    /// been blocked a moment earlier. Any restart, crash-recovery, `resume`, or
644    /// page-in did it.
645    ///
646    /// Defaults to `Public` for snapshots written before this field existed -
647    /// the same value they were being restored with anyway, so nothing is worse
648    /// than it was, and new runs are correct from their first write.
649    #[serde(default)]
650    pub taint: crate::taint::TaintLevel,
651}
652
653/// Per-region token snapshot written by the background worker after each inference.
654#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
655pub struct RegionSnapshot {
656    /// The region's name, matching its key under `[context.regions]`.
657    pub name: String,
658    /// Stringified kind: "pinned", "temporary", "clearable", "sliding", "compacting", "history"
659    pub kind: String,
660    /// Tokens the region held when the snapshot was taken.
661    pub current_tokens: usize,
662    /// The region's ceiling at snapshot time, already resolved against the
663    /// model in front of it, so a percentage budget appears here as a number.
664    pub max_tokens: usize,
665    /// Actual content entries stored in this region (empty for zero-token regions).
666    #[serde(default, skip_serializing_if = "Vec::is_empty")]
667    pub entries: Vec<RegionEntrySnapshot>,
668}
669
670/// Snapshot of the full context window, written to `context.json` alongside `meta.json`.
671#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
672pub struct ContextSnapshot {
673    /// The stage the run was in when this was written.
674    pub stage_name: String,
675    /// Tokens held across every region, which is what the next request costs
676    /// before the model's reply.
677    pub total_tokens: usize,
678    /// The whole window's budget, from the blueprint's `total_budget_tokens` or
679    /// the model's own limit.
680    pub max_tokens: usize,
681    /// Every region, in layout order.
682    pub regions: Vec<RegionSnapshot>,
683}
684
685/// Status of an individual stage within a run.
686#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
687#[serde(rename_all = "snake_case")]
688pub enum StageRunStatus {
689    /// Declared but not yet entered.
690    Pending,
691    /// The stage the run is in right now. At most one stage is `Active`.
692    Active,
693    /// Entered, and blocked on a person answering.
694    WaitingInput,
695    /// Finished and left. A stage that loops back becomes `Active` again.
696    Complete,
697    /// Ended in a failure. The run's own `error` carries the message.
698    Error,
699    /// The run finished without ever entering this stage.
700    ///
701    /// Distinct from [`Pending`](Self::Pending), which means "not yet" while a
702    /// run is live, and from [`Complete`](Self::Complete), which these used to
703    /// be recorded as: the ledger marked every stage positioned before the
704    /// cursor complete, and a graph does not visit its stages in index order,
705    /// so an error-recovery branch nothing reached was filed as having run
706    /// (#372). Its `region_tokens` is empty because nothing ever wrote it,
707    /// which made the next real stage look like it had written every region
708    /// from zero.
709    Skipped,
710}
711
712impl std::fmt::Display for StageRunStatus {
713    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
714        match self {
715            StageRunStatus::Pending => write!(f, "Pending"),
716            StageRunStatus::Skipped => write!(f, "Skipped"),
717            StageRunStatus::Active => write!(f, "Active"),
718            StageRunStatus::WaitingInput => write!(f, "WaitingInput"),
719            StageRunStatus::Complete => write!(f, "Complete"),
720            StageRunStatus::Error => write!(f, "Error"),
721        }
722    }
723}
724
725/// Metadata record for a single stage within a run.
726#[derive(Debug, Clone, Serialize, Deserialize)]
727pub struct StageRecord {
728    /// The stage's name, matching its key under `[stages]`.
729    pub name: String,
730    /// Zero-based position in the blueprint's stage list.
731    pub index: usize,
732    /// Where this stage stands.
733    pub status: StageRunStatus,
734    /// Whether the run has ever actually been in this stage.
735    ///
736    /// Position cannot answer this. A graph blueprint reaches its stages in
737    /// whatever order its edges describe, so "index below the cursor" includes
738    /// every branch the run went past without taking - and reading it as
739    /// "finished" is what filed never-entered stages as `Complete` (#372).
740    /// Sticky once set, so a stage the run has left and may re-enter stays
741    /// entered.
742    #[serde(default)]
743    pub entered: bool,
744    /// Input tokens billed while this stage was active. A revisited stage keeps
745    /// accumulating rather than resetting, so the run's total is the sum.
746    pub prompt_tokens: usize,
747    /// Output tokens billed while this stage was active, accumulating the same
748    /// way.
749    pub completion_tokens: usize,
750    /// Tokens read from provider cache in this stage.
751    #[serde(default)]
752    pub cached_tokens: usize,
753    /// Tokens *written* to provider cache in this stage.
754    ///
755    /// Without it only half of a cache decision was visible: a stage showing
756    /// no reads might be paying to write a prefix nothing reuses, or might not
757    /// be caching at all, and the ledger could not tell those apart.
758    #[serde(default)]
759    pub cache_write_tokens: usize,
760    /// Per-region token contribution to this stage's calls, by region name.
761    ///
762    /// The central question of a structured layout is "what am I paying to
763    /// carry, and where", and answering it meant replaying the context history
764    /// and grouping by stage - archaeology for something the runtime already
765    /// knows. Recorded as the largest each region reached while the stage was
766    /// active, which is the number that decides whether a region is earning its
767    /// place.
768    ///
769    /// Every region the window carries is measured, including the ones a stage
770    /// layout hides rather than declares, so a stage can list a region it never
771    /// assembled into a request.
772    #[serde(default)]
773    pub region_tokens: std::collections::BTreeMap<String, usize>,
774    /// Prompt tokens billed by this stage's first call, the baseline the
775    /// runaway-context check compares against. `None` until it runs once.
776    #[serde(default)]
777    pub first_call_prompt_tokens: Option<usize>,
778    /// Whether the runaway-context warning has already fired for this stage, so
779    /// it is said once on the crossing rather than on every call afterwards.
780    #[serde(default)]
781    pub runaway_warned: bool,
782    /// Unix timestamp (seconds); None until the stage starts.
783    pub started_at: Option<i64>,
784    /// Unix timestamp (seconds); None until the stage ends.
785    pub ended_at: Option<i64>,
786}
787
788impl StageRecord {
789    /// A stage the run has not entered yet: [`StageRunStatus::Pending`], zero
790    /// tokens, and neither timestamp set.
791    pub fn new(name: String, index: usize) -> Self {
792        Self {
793            name,
794            index,
795            status: StageRunStatus::Pending,
796            entered: false,
797            prompt_tokens: 0,
798            completion_tokens: 0,
799            cached_tokens: 0,
800            cache_write_tokens: 0,
801            region_tokens: std::collections::BTreeMap::new(),
802            first_call_prompt_tokens: None,
803            runaway_warned: false,
804            started_at: None,
805            ended_at: None,
806        }
807    }
808}
809
810/// Current Unix time in seconds (saturating to 0 before the epoch).
811fn now_secs() -> i64 {
812    SystemTime::now()
813        .duration_since(UNIX_EPOCH)
814        .map(|d| d.as_secs() as i64)
815        .unwrap_or(0)
816}
817#[cfg(test)]
818mod tests {
819    use super::*;
820
821    fn sample_meta() -> RunMeta {
822        RunMeta::new(
823            "run-1".to_string(),
824            "agent".to_string(),
825            "/agents/agent".to_string(),
826            "do the thing".to_string(),
827            Some("claude-sonnet-4-6".to_string()),
828            "/work".to_string(),
829            3,
830        )
831    }
832
833    /// The webhook signing secret must not survive into anything served over
834    /// the API - an unredacted meta lets `GET /api/agents` hand it to any
835    /// token holder.
836    #[test]
837    fn redacted_drops_the_callback_secret_and_keeps_everything_else() {
838        let mut m = sample_meta();
839        m.callback_secret = Some("shhh".to_string());
840        m.callback_url = Some("https://example.com/hook".to_string());
841
842        let r = m.redacted();
843        assert_eq!(r.callback_secret, None);
844        // The URL is not a secret and stays: a caller needs to see where its own
845        // webhook was pointed.
846        assert_eq!(r.callback_url.as_deref(), Some("https://example.com/hook"));
847        assert_eq!(r.run_id, m.run_id);
848        assert_eq!(r.task, m.task);
849
850        // Serializing the redacted form must not mention it at all - a `None`
851        // that still emitted `"callback_secret": null` would be fine, but an
852        // assertion on the wire format is what a reviewer actually checks.
853        let json = serde_json::to_string(&r).unwrap();
854        assert!(!json.contains("shhh"), "{json}");
855
856        // ...and the original is untouched, because the daemon still needs it to
857        // sign the webhook for a run it reloaded after a restart.
858        assert_eq!(m.callback_secret.as_deref(), Some("shhh"));
859    }
860
861    #[test]
862    fn run_meta_new_sets_defaults() {
863        let m = sample_meta();
864        assert_eq!(m.run_id, "run-1");
865        assert_eq!(m.agent_name, "agent");
866        assert_eq!(m.agent_path, "/agents/agent");
867        assert_eq!(m.task, "do the thing");
868        assert_eq!(m.model.as_deref(), Some("claude-sonnet-4-6"));
869        assert_eq!(m.workdir, "/work");
870        assert_eq!(m.num_stages, 3);
871        assert_eq!(m.pid, 0);
872        assert_eq!(m.status, RunStatus::Starting);
873        assert_eq!(m.stage_index, 0);
874        assert_eq!(m.iteration, 0);
875        assert_eq!(m.prompt_tokens, 0);
876        assert_eq!(m.completion_tokens, 0);
877        assert_eq!(m.cached_tokens, 0);
878        assert_eq!(m.cache_write_tokens, 0);
879        assert_eq!(m.tool_calls, 0);
880        assert!(m.error.is_none());
881        assert!(m.title.is_none());
882        assert!(m.metadata.is_empty());
883        assert!(m.callback_url.is_none());
884        assert!(m.callback_secret.is_none());
885        assert!(m.parent_run_id.is_none());
886        assert!(m.children.is_empty());
887        assert_eq!(m.depth, 0);
888        assert_eq!(m.max_child_depth, 0);
889        assert!(m.current_stage.is_empty());
890        assert_eq!(m.started_at, m.updated_at);
891    }
892
893    #[test]
894    fn run_meta_touch_advances_updated_at() {
895        let mut m = sample_meta();
896        m.updated_at = 0;
897        m.touch();
898        assert!(m.updated_at > 0);
899    }
900
901    /// A `meta.json` written before `waiting_on` existed still loads.
902    ///
903    /// This is the whole compatibility question for the field, and it is worth
904    /// a test rather than a reading of the serde attributes: every run already
905    /// on disk was written by a build that had never heard of it, and a
906    /// deserialize that insisted on the key would make every one of them
907    /// unreadable.
908    #[test]
909    fn a_run_written_before_waiting_on_existed_still_loads() {
910        let mut original = sample_meta();
911        original.status = RunStatus::WaitingInput;
912        let mut value = serde_json::to_value(&original).unwrap();
913        // Whatever the current build writes, an older file simply has no such
914        // key. Removing it reproduces that exactly.
915        value
916            .as_object_mut()
917            .expect("meta is an object")
918            .remove("waiting_on");
919        assert!(value.get("waiting_on").is_none(), "the old shape");
920
921        let back: RunMeta = serde_json::from_value(value).unwrap();
922        assert_eq!(back.waiting_on, None);
923        assert_eq!(back.status, RunStatus::WaitingInput);
924        assert_eq!(back.run_id, original.run_id);
925    }
926
927    /// A run that is not parked writes the file it always wrote, so an older
928    /// build reading a newer run sees nothing it does not understand.
929    #[test]
930    fn a_run_that_is_not_parked_writes_no_waiting_on_key() {
931        let mut m = sample_meta();
932        m.status = RunStatus::Running;
933        m.waiting_on = None;
934        let json = serde_json::to_value(&m).unwrap();
935        assert!(json.get("waiting_on").is_none(), "{json}");
936
937        m.waiting_on = Some(WaitReason::FanOutWorkers { outstanding: 3 });
938        let json = serde_json::to_value(&m).unwrap();
939        assert_eq!(
940            json["waiting_on"],
941            serde_json::json!({"reason": "fan_out_workers", "outstanding": 3})
942        );
943    }
944
945    /// Every variant is on the wire in snake_case, the way `RunStatus` is, and
946    /// round-trips. The counted ones carry their number with them, which is
947    /// what lets a client say "waiting on 3 of them" rather than "waiting".
948    #[test]
949    fn wait_reason_serializes_in_snake_case() {
950        for (variant, wire) in [
951            (WaitReason::ToolApproval, "tool_approval"),
952            (WaitReason::UserPrompt, "user_prompt"),
953            (WaitReason::TaintGate, "taint_gate"),
954            (WaitReason::InteractionPoint, "interaction_point"),
955            (
956                WaitReason::FanOutWorkers { outstanding: 3 },
957                "fan_out_workers",
958            ),
959            (WaitReason::Children { outstanding: 1 }, "children"),
960        ] {
961            let json = serde_json::to_value(&variant).unwrap();
962            assert_eq!(json["reason"], serde_json::json!(wire));
963            let back: WaitReason = serde_json::from_value(json).unwrap();
964            assert_eq!(back, variant);
965        }
966    }
967
968    /// Each marker names its own reason, and the counted ones carry the count.
969    ///
970    /// The precedence is the specific claim first: a taint gate and a
971    /// checkpoint each open a hub request of their own, so a generic-prompt
972    /// answer would swallow both.
973    #[test]
974    fn each_marker_names_its_own_reason_specific_first() {
975        let cases = [
976            (
977                WaitMarkers {
978                    gate_prompt: true,
979                    awaiting_interaction: true,
980                    ..Default::default()
981                },
982                WaitReason::TaintGate,
983            ),
984            (
985                WaitMarkers {
986                    interaction_point: true,
987                    awaiting_interaction: true,
988                    ..Default::default()
989                },
990                WaitReason::InteractionPoint,
991            ),
992            (
993                WaitMarkers {
994                    fan_out_outstanding: Some(5),
995                    ..Default::default()
996                },
997                WaitReason::FanOutWorkers { outstanding: 5 },
998            ),
999            (
1000                WaitMarkers {
1001                    children_outstanding: Some(4),
1002                    ..Default::default()
1003                },
1004                WaitReason::Children { outstanding: 4 },
1005            ),
1006        ];
1007        for (markers, expected) in cases {
1008            assert_eq!(
1009                wait_reason_from(true, &markers),
1010                Some(expected),
1011                "{markers:?}"
1012            );
1013        }
1014        // A parent holding both kinds of sub-work reports the more specific one.
1015        assert_eq!(
1016            wait_reason_from(
1017                true,
1018                &WaitMarkers {
1019                    fan_out_outstanding: Some(2),
1020                    children_outstanding: Some(9),
1021                    ..Default::default()
1022                }
1023            ),
1024            Some(WaitReason::FanOutWorkers { outstanding: 2 })
1025        );
1026    }
1027
1028    /// A run parked until the machine is fixed says so before anything else.
1029    ///
1030    /// It outranks every other marker on purpose: answering a prompt does not
1031    /// help a run whose provider is not configured, so sending someone to the
1032    /// prompt would be sending them to the wrong screen.
1033    #[test]
1034    fn needing_setup_outranks_every_other_reason() {
1035        let need = SetupNeeded {
1036            blocker: SetupBlocker::ProviderMissing,
1037            remedy: "add it to config.toml".to_string(),
1038        };
1039        let reason = wait_reason_from(
1040            true,
1041            &WaitMarkers {
1042                needs_setup: Some(need.clone()),
1043                // Everything else at once, so precedence is being tested
1044                // rather than the absence of competition.
1045                gate_prompt: true,
1046                interaction_point: true,
1047                fan_out_outstanding: Some(2),
1048                children_outstanding: Some(3),
1049                awaiting_interaction: true,
1050                interaction: Some(crate::interaction::InteractionKind::ToolApproval),
1051            },
1052        );
1053        assert_eq!(
1054            reason,
1055            Some(WaitReason::NeedsSetup {
1056                blocker: SetupBlocker::ProviderMissing,
1057                remedy: "add it to config.toml".to_string(),
1058            })
1059        );
1060        assert!(
1061            reason.unwrap().needs_a_person(),
1062            "nothing resolves this without somebody"
1063        );
1064    }
1065
1066    /// Each blocker is its own value on the wire, so a console can offer the
1067    /// right remedy instead of matching on the sentence.
1068    #[test]
1069    fn every_blocker_has_its_own_wire_name_and_label() {
1070        for (blocker, wire, label) in [
1071            (
1072                SetupBlocker::ProviderMissing,
1073                "provider_missing",
1074                "provider",
1075            ),
1076            (
1077                SetupBlocker::CreditsExhausted,
1078                "credits_exhausted",
1079                "credits",
1080            ),
1081            (SetupBlocker::AuthFailed, "auth_failed", "key"),
1082            (SetupBlocker::Forbidden, "forbidden", "access"),
1083            (
1084                SetupBlocker::ProvidersUnavailable,
1085                "providers_unavailable",
1086                "providers",
1087            ),
1088        ] {
1089            assert_eq!(serde_json::to_value(blocker).unwrap(), wire);
1090            assert_eq!(blocker.to_string(), label);
1091            let back: SetupBlocker = serde_json::from_value(serde_json::json!(wire)).unwrap();
1092            assert_eq!(back, blocker);
1093            // The row renders the kind, not the sentence: a remedy is a
1094            // sentence and this is a table cell.
1095            assert_eq!(
1096                WaitReason::NeedsSetup {
1097                    blocker,
1098                    remedy: "a whole sentence that would not fit".to_string(),
1099                }
1100                .to_string(),
1101                format!("needs {label}")
1102            );
1103        }
1104    }
1105
1106    /// A generic hub block reports what kind of prompt it is, so "approve this
1107    /// tool call" and "answer this question" are not the same row.
1108    #[test]
1109    fn a_hub_block_reports_the_kind_of_prompt_holding_it() {
1110        let held = |kind| WaitMarkers {
1111            awaiting_interaction: true,
1112            interaction: kind,
1113            ..Default::default()
1114        };
1115        assert_eq!(
1116            wait_reason_from(
1117                true,
1118                &held(Some(crate::interaction::InteractionKind::ToolApproval))
1119            ),
1120            Some(WaitReason::ToolApproval)
1121        );
1122        // Anything else the agent asked for is a question for a person. The
1123        // kind can also be unknown while the block is real, which reads the
1124        // same way: somebody is being waited on.
1125        assert_eq!(
1126            wait_reason_from(
1127                true,
1128                &held(Some(crate::interaction::InteractionKind::FreeText))
1129            ),
1130            Some(WaitReason::UserPrompt)
1131        );
1132        assert_eq!(
1133            wait_reason_from(true, &held(None)),
1134            Some(WaitReason::UserPrompt)
1135        );
1136    }
1137
1138    /// Parked with nothing claiming it: the field is left off rather than
1139    /// filled with a guess, and a run that is not parked never has one.
1140    #[test]
1141    fn nothing_claiming_a_parked_run_reports_no_reason() {
1142        assert_eq!(wait_reason_from(true, &WaitMarkers::default()), None);
1143        assert_eq!(
1144            wait_reason_from(
1145                false,
1146                &WaitMarkers {
1147                    gate_prompt: true,
1148                    ..Default::default()
1149                }
1150            ),
1151            None,
1152            "a run that is not waiting is not waiting on anything"
1153        );
1154    }
1155
1156    /// The rendered form every text surface uses, counts included. Narrow
1157    /// enough for a table column, which is why it is not the variant name.
1158    #[test]
1159    fn every_reason_renders_for_a_narrow_column() {
1160        assert_eq!(WaitReason::ToolApproval.to_string(), "tool approval");
1161        assert_eq!(WaitReason::UserPrompt.to_string(), "user prompt");
1162        assert_eq!(WaitReason::TaintGate.to_string(), "taint gate");
1163        assert_eq!(WaitReason::InteractionPoint.to_string(), "checkpoint");
1164        assert_eq!(
1165            WaitReason::FanOutWorkers { outstanding: 3 }.to_string(),
1166            "workers(3)"
1167        );
1168        assert_eq!(
1169            WaitReason::Children { outstanding: 2 }.to_string(),
1170            "children(2)"
1171        );
1172    }
1173
1174    /// Only the two engine-side reasons resolve on their own; the rest are a
1175    /// person's to clear. This is the predicate a badge should be built on.
1176    #[test]
1177    fn only_the_engine_side_reasons_need_nobody() {
1178        assert!(WaitReason::ToolApproval.needs_a_person());
1179        assert!(WaitReason::UserPrompt.needs_a_person());
1180        assert!(WaitReason::TaintGate.needs_a_person());
1181        assert!(WaitReason::InteractionPoint.needs_a_person());
1182        assert!(!WaitReason::FanOutWorkers { outstanding: 2 }.needs_a_person());
1183        assert!(!WaitReason::Children { outstanding: 2 }.needs_a_person());
1184    }
1185
1186    #[test]
1187    fn run_meta_serde_roundtrip() {
1188        let mut m = sample_meta();
1189        m.status = RunStatus::Running;
1190        m.metadata.insert("k".to_string(), "v".to_string());
1191        m.title = Some("A title".to_string());
1192        m.callback_secret = Some("shh".to_string());
1193        m.parent_run_id = Some("parent-1".to_string());
1194        m.children = vec!["child-a".to_string(), "child-b".to_string()];
1195        m.depth = 2;
1196        m.max_child_depth = 5;
1197        let json = serde_json::to_string(&m).unwrap();
1198        let back: RunMeta = serde_json::from_str(&json).unwrap();
1199        assert_eq!(back.run_id, m.run_id);
1200        assert_eq!(back.status, RunStatus::Running);
1201        assert_eq!(back.metadata.get("k").map(String::as_str), Some("v"));
1202        assert_eq!(back.title.as_deref(), Some("A title"));
1203        assert_eq!(back.callback_secret.as_deref(), Some("shh"));
1204        assert_eq!(back.parent_run_id.as_deref(), Some("parent-1"));
1205        assert_eq!(
1206            back.children,
1207            vec!["child-a".to_string(), "child-b".to_string()]
1208        );
1209        assert_eq!(back.depth, 2);
1210        assert_eq!(back.max_child_depth, 5);
1211    }
1212
1213    #[test]
1214    fn run_status_display_all_variants() {
1215        assert_eq!(RunStatus::Starting.to_string(), "Starting");
1216        assert_eq!(RunStatus::Running.to_string(), "Running");
1217        assert_eq!(RunStatus::WaitingInput.to_string(), "WaitingInput");
1218        assert_eq!(RunStatus::Complete.to_string(), "Complete");
1219        assert_eq!(
1220            RunStatus::CompleteInteractive.to_string(),
1221            "CompleteInteractive"
1222        );
1223        assert_eq!(RunStatus::Paused.to_string(), "Paused");
1224        assert_eq!(RunStatus::Error.to_string(), "Error");
1225        assert_eq!(RunStatus::Cancelled.to_string(), "Cancelled");
1226    }
1227
1228    #[test]
1229    fn run_status_serde_snake_case_roundtrip() {
1230        for s in [
1231            RunStatus::Starting,
1232            RunStatus::Running,
1233            RunStatus::WaitingInput,
1234            RunStatus::Complete,
1235            RunStatus::CompleteInteractive,
1236            RunStatus::Paused,
1237            RunStatus::Error,
1238            RunStatus::Cancelled,
1239        ] {
1240            let json = serde_json::to_string(&s).unwrap();
1241            let back: RunStatus = serde_json::from_str(&json).unwrap();
1242            assert_eq!(back, s);
1243        }
1244        assert_eq!(
1245            serde_json::to_string(&RunStatus::WaitingInput).unwrap(),
1246            "\"waiting_input\""
1247        );
1248        assert_eq!(
1249            serde_json::to_string(&RunStatus::Paused).unwrap(),
1250            "\"paused\""
1251        );
1252    }
1253
1254    #[test]
1255    fn context_snapshot_serde_roundtrip() {
1256        let snap = ContextSnapshot {
1257            stage_name: "plan".to_string(),
1258            total_tokens: 42,
1259            max_tokens: 100,
1260            regions: vec![RegionSnapshot {
1261                name: "history".to_string(),
1262                kind: "sliding".to_string(),
1263                current_tokens: 10,
1264                max_tokens: 50,
1265                entries: vec![RegionEntrySnapshot {
1266                    content: "hi".to_string(),
1267                    tokens: 1,
1268                    kind: crate::region::EntryKind::UserMessage,
1269                    metadata: Some(serde_json::json!({"a": 1})),
1270                    key: Some("k".to_string()),
1271                    taint: Default::default(),
1272                }],
1273            }],
1274        };
1275        let json = serde_json::to_string(&snap).unwrap();
1276        let back: ContextSnapshot = serde_json::from_str(&json).unwrap();
1277        assert_eq!(back.stage_name, "plan");
1278        assert_eq!(back.regions.len(), 1);
1279        assert_eq!(back.regions[0].entries.len(), 1);
1280        assert_eq!(back.regions[0].entries[0].content, "hi");
1281        assert_eq!(back.regions[0].entries[0].key.as_deref(), Some("k"));
1282    }
1283
1284    #[test]
1285    fn region_snapshot_skips_empty_entries_in_json() {
1286        let snap = RegionSnapshot {
1287            name: "r".to_string(),
1288            kind: "pinned".to_string(),
1289            current_tokens: 0,
1290            max_tokens: 0,
1291            entries: vec![],
1292        };
1293        let json = serde_json::to_string(&snap).unwrap();
1294        assert!(!json.contains("entries"));
1295    }
1296
1297    #[test]
1298    fn stage_run_status_display_all_variants() {
1299        assert_eq!(StageRunStatus::Pending.to_string(), "Pending");
1300        assert_eq!(StageRunStatus::Skipped.to_string(), "Skipped");
1301        assert_eq!(StageRunStatus::Active.to_string(), "Active");
1302        assert_eq!(StageRunStatus::WaitingInput.to_string(), "WaitingInput");
1303        assert_eq!(StageRunStatus::Complete.to_string(), "Complete");
1304        assert_eq!(StageRunStatus::Error.to_string(), "Error");
1305    }
1306
1307    #[test]
1308    fn run_flags_record_modification_dedups_paths_and_caps_the_list() {
1309        let mut flags = RunFlags::default();
1310        flags.record_modification("src/a.rs");
1311        flags.record_modification("src/a.rs");
1312        flags.record_modification("src/b.rs");
1313        assert_eq!(flags.modified_file_count, 3);
1314        assert_eq!(flags.modified_files, vec!["src/a.rs", "src/b.rs"]);
1315
1316        // Past the cap the count keeps rising but the list stops growing, so a
1317        // long run can't bloat meta.json.
1318        for i in 0..MAX_TRACKED_MODIFIED_FILES {
1319            flags.record_modification(&format!("f{i}.rs"));
1320        }
1321        assert_eq!(flags.modified_files.len(), MAX_TRACKED_MODIFIED_FILES);
1322        assert_eq!(flags.modified_file_count, 3 + MAX_TRACKED_MODIFIED_FILES);
1323    }
1324
1325    #[test]
1326    fn run_meta_flags_default_for_older_files() {
1327        // meta.json written before #107 has no `flags` key at all.
1328        let mut meta = RunMeta::new(
1329            "r".to_string(),
1330            "a".to_string(),
1331            "/p".to_string(),
1332            "t".to_string(),
1333            None,
1334            "/w".to_string(),
1335            1,
1336        );
1337        meta.flags.empty_output = true;
1338        // Drop the key structurally rather than by string surgery: a literal
1339        // spelling of the serialized flags silently stops matching the moment a
1340        // field is added, and the test then passes for the wrong reason.
1341        let mut json = serde_json::to_value(&meta).unwrap();
1342        json.as_object_mut().unwrap().remove("flags").unwrap();
1343        assert!(!json.to_string().contains("flags"));
1344        let back: RunMeta = serde_json::from_value(json).unwrap();
1345        assert_eq!(back.flags, RunFlags::default());
1346    }
1347
1348    #[test]
1349    fn stage_record_new_and_serde_roundtrip() {
1350        let rec = StageRecord::new("analyze".to_string(), 2);
1351        assert_eq!(rec.name, "analyze");
1352        assert_eq!(rec.index, 2);
1353        assert_eq!(rec.status, StageRunStatus::Pending);
1354        assert_eq!(rec.prompt_tokens, 0);
1355        assert_eq!(rec.completion_tokens, 0);
1356        assert_eq!(rec.cached_tokens, 0);
1357        assert!(rec.started_at.is_none());
1358        assert!(rec.ended_at.is_none());
1359
1360        let json = serde_json::to_string(&rec).unwrap();
1361        let back: StageRecord = serde_json::from_str(&json).unwrap();
1362        assert_eq!(back.name, "analyze");
1363        assert_eq!(back.status, StageRunStatus::Pending);
1364    }
1365}