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    /// What the blueprint says this region is for, when it says.
669    ///
670    /// Carried on the snapshot so every reader of `context.json` can show it -
671    /// the dashboard, the history API, a console - rather than each having to
672    /// find and re-parse the manifest to explain a region it is already
673    /// displaying.
674    #[serde(default, skip_serializing_if = "Option::is_none")]
675    pub description: Option<String>,
676}
677
678/// Snapshot of the full context window, written to `context.json` alongside `meta.json`.
679#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
680pub struct ContextSnapshot {
681    /// The stage the run was in when this was written.
682    pub stage_name: String,
683    /// Tokens held across every region, which is what the next request costs
684    /// before the model's reply.
685    pub total_tokens: usize,
686    /// The whole window's budget, from the blueprint's `total_budget_tokens` or
687    /// the model's own limit.
688    pub max_tokens: usize,
689    /// Every region, in layout order.
690    pub regions: Vec<RegionSnapshot>,
691}
692
693/// Status of an individual stage within a run.
694#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
695#[serde(rename_all = "snake_case")]
696pub enum StageRunStatus {
697    /// Declared but not yet entered.
698    Pending,
699    /// The stage the run is in right now. At most one stage is `Active`.
700    Active,
701    /// Entered, and blocked on a person answering.
702    WaitingInput,
703    /// Finished and left. A stage that loops back becomes `Active` again.
704    Complete,
705    /// Ended in a failure. The run's own `error` carries the message.
706    Error,
707    /// The run finished without ever entering this stage.
708    ///
709    /// Distinct from [`Pending`](Self::Pending), which means "not yet" while a
710    /// run is live, and from [`Complete`](Self::Complete), which these used to
711    /// be recorded as: the ledger marked every stage positioned before the
712    /// cursor complete, and a graph does not visit its stages in index order,
713    /// so an error-recovery branch nothing reached was filed as having run
714    /// (#372). Its `region_tokens` is empty because nothing ever wrote it,
715    /// which made the next real stage look like it had written every region
716    /// from zero.
717    Skipped,
718}
719
720impl std::fmt::Display for StageRunStatus {
721    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
722        match self {
723            StageRunStatus::Pending => write!(f, "Pending"),
724            StageRunStatus::Skipped => write!(f, "Skipped"),
725            StageRunStatus::Active => write!(f, "Active"),
726            StageRunStatus::WaitingInput => write!(f, "WaitingInput"),
727            StageRunStatus::Complete => write!(f, "Complete"),
728            StageRunStatus::Error => write!(f, "Error"),
729        }
730    }
731}
732
733/// Metadata record for a single stage within a run.
734#[derive(Debug, Clone, Serialize, Deserialize)]
735pub struct StageRecord {
736    /// The stage's name, matching its key under `[stages]`.
737    pub name: String,
738    /// Zero-based position in the blueprint's stage list.
739    pub index: usize,
740    /// Where this stage stands.
741    pub status: StageRunStatus,
742    /// Whether the run has ever actually been in this stage.
743    ///
744    /// Position cannot answer this. A graph blueprint reaches its stages in
745    /// whatever order its edges describe, so "index below the cursor" includes
746    /// every branch the run went past without taking - and reading it as
747    /// "finished" is what filed never-entered stages as `Complete` (#372).
748    /// Sticky once set, so a stage the run has left and may re-enter stays
749    /// entered.
750    #[serde(default)]
751    pub entered: bool,
752    /// Input tokens billed while this stage was active. A revisited stage keeps
753    /// accumulating rather than resetting, so the run's total is the sum.
754    pub prompt_tokens: usize,
755    /// Output tokens billed while this stage was active, accumulating the same
756    /// way.
757    pub completion_tokens: usize,
758    /// Tokens read from provider cache in this stage.
759    #[serde(default)]
760    pub cached_tokens: usize,
761    /// Tokens *written* to provider cache in this stage.
762    ///
763    /// Without it only half of a cache decision was visible: a stage showing
764    /// no reads might be paying to write a prefix nothing reuses, or might not
765    /// be caching at all, and the ledger could not tell those apart.
766    #[serde(default)]
767    pub cache_write_tokens: usize,
768    /// Per-region token contribution to this stage's calls, by region name.
769    ///
770    /// The central question of a structured layout is "what am I paying to
771    /// carry, and where", and answering it meant replaying the context history
772    /// and grouping by stage - archaeology for something the runtime already
773    /// knows. Recorded as the largest each region reached while the stage was
774    /// active, which is the number that decides whether a region is earning its
775    /// place.
776    ///
777    /// Every region the window carries is measured, including the ones a stage
778    /// layout hides rather than declares, so a stage can list a region it never
779    /// assembled into a request.
780    #[serde(default)]
781    pub region_tokens: std::collections::BTreeMap<String, usize>,
782    /// Prompt tokens billed by this stage's first call, the baseline the
783    /// runaway-context check compares against. `None` until it runs once.
784    #[serde(default)]
785    pub first_call_prompt_tokens: Option<usize>,
786    /// Whether the runaway-context warning has already fired for this stage, so
787    /// it is said once on the crossing rather than on every call afterwards.
788    #[serde(default)]
789    pub runaway_warned: bool,
790    /// Unix timestamp (seconds); None until the stage starts.
791    pub started_at: Option<i64>,
792    /// Unix timestamp (seconds); None until the stage ends.
793    pub ended_at: Option<i64>,
794}
795
796impl StageRecord {
797    /// A stage the run has not entered yet: [`StageRunStatus::Pending`], zero
798    /// tokens, and neither timestamp set.
799    pub fn new(name: String, index: usize) -> Self {
800        Self {
801            name,
802            index,
803            status: StageRunStatus::Pending,
804            entered: false,
805            prompt_tokens: 0,
806            completion_tokens: 0,
807            cached_tokens: 0,
808            cache_write_tokens: 0,
809            region_tokens: std::collections::BTreeMap::new(),
810            first_call_prompt_tokens: None,
811            runaway_warned: false,
812            started_at: None,
813            ended_at: None,
814        }
815    }
816}
817
818/// Current Unix time in seconds (saturating to 0 before the epoch).
819fn now_secs() -> i64 {
820    SystemTime::now()
821        .duration_since(UNIX_EPOCH)
822        .map(|d| d.as_secs() as i64)
823        .unwrap_or(0)
824}
825#[cfg(test)]
826mod tests {
827    use super::*;
828
829    fn sample_meta() -> RunMeta {
830        RunMeta::new(
831            "run-1".to_string(),
832            "agent".to_string(),
833            "/agents/agent".to_string(),
834            "do the thing".to_string(),
835            Some("claude-sonnet-4-6".to_string()),
836            "/work".to_string(),
837            3,
838        )
839    }
840
841    /// The webhook signing secret must not survive into anything served over
842    /// the API - an unredacted meta lets `GET /api/agents` hand it to any
843    /// token holder.
844    #[test]
845    fn redacted_drops_the_callback_secret_and_keeps_everything_else() {
846        let mut m = sample_meta();
847        m.callback_secret = Some("shhh".to_string());
848        m.callback_url = Some("https://example.com/hook".to_string());
849
850        let r = m.redacted();
851        assert_eq!(r.callback_secret, None);
852        // The URL is not a secret and stays: a caller needs to see where its own
853        // webhook was pointed.
854        assert_eq!(r.callback_url.as_deref(), Some("https://example.com/hook"));
855        assert_eq!(r.run_id, m.run_id);
856        assert_eq!(r.task, m.task);
857
858        // Serializing the redacted form must not mention it at all - a `None`
859        // that still emitted `"callback_secret": null` would be fine, but an
860        // assertion on the wire format is what a reviewer actually checks.
861        let json = serde_json::to_string(&r).unwrap();
862        assert!(!json.contains("shhh"), "{json}");
863
864        // ...and the original is untouched, because the daemon still needs it to
865        // sign the webhook for a run it reloaded after a restart.
866        assert_eq!(m.callback_secret.as_deref(), Some("shhh"));
867    }
868
869    #[test]
870    fn run_meta_new_sets_defaults() {
871        let m = sample_meta();
872        assert_eq!(m.run_id, "run-1");
873        assert_eq!(m.agent_name, "agent");
874        assert_eq!(m.agent_path, "/agents/agent");
875        assert_eq!(m.task, "do the thing");
876        assert_eq!(m.model.as_deref(), Some("claude-sonnet-4-6"));
877        assert_eq!(m.workdir, "/work");
878        assert_eq!(m.num_stages, 3);
879        assert_eq!(m.pid, 0);
880        assert_eq!(m.status, RunStatus::Starting);
881        assert_eq!(m.stage_index, 0);
882        assert_eq!(m.iteration, 0);
883        assert_eq!(m.prompt_tokens, 0);
884        assert_eq!(m.completion_tokens, 0);
885        assert_eq!(m.cached_tokens, 0);
886        assert_eq!(m.cache_write_tokens, 0);
887        assert_eq!(m.tool_calls, 0);
888        assert!(m.error.is_none());
889        assert!(m.title.is_none());
890        assert!(m.metadata.is_empty());
891        assert!(m.callback_url.is_none());
892        assert!(m.callback_secret.is_none());
893        assert!(m.parent_run_id.is_none());
894        assert!(m.children.is_empty());
895        assert_eq!(m.depth, 0);
896        assert_eq!(m.max_child_depth, 0);
897        assert!(m.current_stage.is_empty());
898        assert_eq!(m.started_at, m.updated_at);
899    }
900
901    #[test]
902    fn run_meta_touch_advances_updated_at() {
903        let mut m = sample_meta();
904        m.updated_at = 0;
905        m.touch();
906        assert!(m.updated_at > 0);
907    }
908
909    /// A `meta.json` written before `waiting_on` existed still loads.
910    ///
911    /// This is the whole compatibility question for the field, and it is worth
912    /// a test rather than a reading of the serde attributes: every run already
913    /// on disk was written by a build that had never heard of it, and a
914    /// deserialize that insisted on the key would make every one of them
915    /// unreadable.
916    #[test]
917    fn a_run_written_before_waiting_on_existed_still_loads() {
918        let mut original = sample_meta();
919        original.status = RunStatus::WaitingInput;
920        let mut value = serde_json::to_value(&original).unwrap();
921        // Whatever the current build writes, an older file simply has no such
922        // key. Removing it reproduces that exactly.
923        value
924            .as_object_mut()
925            .expect("meta is an object")
926            .remove("waiting_on");
927        assert!(value.get("waiting_on").is_none(), "the old shape");
928
929        let back: RunMeta = serde_json::from_value(value).unwrap();
930        assert_eq!(back.waiting_on, None);
931        assert_eq!(back.status, RunStatus::WaitingInput);
932        assert_eq!(back.run_id, original.run_id);
933    }
934
935    /// A run that is not parked writes the file it always wrote, so an older
936    /// build reading a newer run sees nothing it does not understand.
937    #[test]
938    fn a_run_that_is_not_parked_writes_no_waiting_on_key() {
939        let mut m = sample_meta();
940        m.status = RunStatus::Running;
941        m.waiting_on = None;
942        let json = serde_json::to_value(&m).unwrap();
943        assert!(json.get("waiting_on").is_none(), "{json}");
944
945        m.waiting_on = Some(WaitReason::FanOutWorkers { outstanding: 3 });
946        let json = serde_json::to_value(&m).unwrap();
947        assert_eq!(
948            json["waiting_on"],
949            serde_json::json!({"reason": "fan_out_workers", "outstanding": 3})
950        );
951    }
952
953    /// Every variant is on the wire in snake_case, the way `RunStatus` is, and
954    /// round-trips. The counted ones carry their number with them, which is
955    /// what lets a client say "waiting on 3 of them" rather than "waiting".
956    #[test]
957    fn wait_reason_serializes_in_snake_case() {
958        for (variant, wire) in [
959            (WaitReason::ToolApproval, "tool_approval"),
960            (WaitReason::UserPrompt, "user_prompt"),
961            (WaitReason::TaintGate, "taint_gate"),
962            (WaitReason::InteractionPoint, "interaction_point"),
963            (
964                WaitReason::FanOutWorkers { outstanding: 3 },
965                "fan_out_workers",
966            ),
967            (WaitReason::Children { outstanding: 1 }, "children"),
968        ] {
969            let json = serde_json::to_value(&variant).unwrap();
970            assert_eq!(json["reason"], serde_json::json!(wire));
971            let back: WaitReason = serde_json::from_value(json).unwrap();
972            assert_eq!(back, variant);
973        }
974    }
975
976    /// Each marker names its own reason, and the counted ones carry the count.
977    ///
978    /// The precedence is the specific claim first: a taint gate and a
979    /// checkpoint each open a hub request of their own, so a generic-prompt
980    /// answer would swallow both.
981    #[test]
982    fn each_marker_names_its_own_reason_specific_first() {
983        let cases = [
984            (
985                WaitMarkers {
986                    gate_prompt: true,
987                    awaiting_interaction: true,
988                    ..Default::default()
989                },
990                WaitReason::TaintGate,
991            ),
992            (
993                WaitMarkers {
994                    interaction_point: true,
995                    awaiting_interaction: true,
996                    ..Default::default()
997                },
998                WaitReason::InteractionPoint,
999            ),
1000            (
1001                WaitMarkers {
1002                    fan_out_outstanding: Some(5),
1003                    ..Default::default()
1004                },
1005                WaitReason::FanOutWorkers { outstanding: 5 },
1006            ),
1007            (
1008                WaitMarkers {
1009                    children_outstanding: Some(4),
1010                    ..Default::default()
1011                },
1012                WaitReason::Children { outstanding: 4 },
1013            ),
1014        ];
1015        for (markers, expected) in cases {
1016            assert_eq!(
1017                wait_reason_from(true, &markers),
1018                Some(expected),
1019                "{markers:?}"
1020            );
1021        }
1022        // A parent holding both kinds of sub-work reports the more specific one.
1023        assert_eq!(
1024            wait_reason_from(
1025                true,
1026                &WaitMarkers {
1027                    fan_out_outstanding: Some(2),
1028                    children_outstanding: Some(9),
1029                    ..Default::default()
1030                }
1031            ),
1032            Some(WaitReason::FanOutWorkers { outstanding: 2 })
1033        );
1034    }
1035
1036    /// A run parked until the machine is fixed says so before anything else.
1037    ///
1038    /// It outranks every other marker on purpose: answering a prompt does not
1039    /// help a run whose provider is not configured, so sending someone to the
1040    /// prompt would be sending them to the wrong screen.
1041    #[test]
1042    fn needing_setup_outranks_every_other_reason() {
1043        let need = SetupNeeded {
1044            blocker: SetupBlocker::ProviderMissing,
1045            remedy: "add it to config.toml".to_string(),
1046        };
1047        let reason = wait_reason_from(
1048            true,
1049            &WaitMarkers {
1050                needs_setup: Some(need.clone()),
1051                // Everything else at once, so precedence is being tested
1052                // rather than the absence of competition.
1053                gate_prompt: true,
1054                interaction_point: true,
1055                fan_out_outstanding: Some(2),
1056                children_outstanding: Some(3),
1057                awaiting_interaction: true,
1058                interaction: Some(crate::interaction::InteractionKind::ToolApproval),
1059            },
1060        );
1061        assert_eq!(
1062            reason,
1063            Some(WaitReason::NeedsSetup {
1064                blocker: SetupBlocker::ProviderMissing,
1065                remedy: "add it to config.toml".to_string(),
1066            })
1067        );
1068        assert!(
1069            reason.unwrap().needs_a_person(),
1070            "nothing resolves this without somebody"
1071        );
1072    }
1073
1074    /// Each blocker is its own value on the wire, so a console can offer the
1075    /// right remedy instead of matching on the sentence.
1076    #[test]
1077    fn every_blocker_has_its_own_wire_name_and_label() {
1078        for (blocker, wire, label) in [
1079            (
1080                SetupBlocker::ProviderMissing,
1081                "provider_missing",
1082                "provider",
1083            ),
1084            (
1085                SetupBlocker::CreditsExhausted,
1086                "credits_exhausted",
1087                "credits",
1088            ),
1089            (SetupBlocker::AuthFailed, "auth_failed", "key"),
1090            (SetupBlocker::Forbidden, "forbidden", "access"),
1091            (
1092                SetupBlocker::ProvidersUnavailable,
1093                "providers_unavailable",
1094                "providers",
1095            ),
1096        ] {
1097            assert_eq!(serde_json::to_value(blocker).unwrap(), wire);
1098            assert_eq!(blocker.to_string(), label);
1099            let back: SetupBlocker = serde_json::from_value(serde_json::json!(wire)).unwrap();
1100            assert_eq!(back, blocker);
1101            // The row renders the kind, not the sentence: a remedy is a
1102            // sentence and this is a table cell.
1103            assert_eq!(
1104                WaitReason::NeedsSetup {
1105                    blocker,
1106                    remedy: "a whole sentence that would not fit".to_string(),
1107                }
1108                .to_string(),
1109                format!("needs {label}")
1110            );
1111        }
1112    }
1113
1114    /// A generic hub block reports what kind of prompt it is, so "approve this
1115    /// tool call" and "answer this question" are not the same row.
1116    #[test]
1117    fn a_hub_block_reports_the_kind_of_prompt_holding_it() {
1118        let held = |kind| WaitMarkers {
1119            awaiting_interaction: true,
1120            interaction: kind,
1121            ..Default::default()
1122        };
1123        assert_eq!(
1124            wait_reason_from(
1125                true,
1126                &held(Some(crate::interaction::InteractionKind::ToolApproval))
1127            ),
1128            Some(WaitReason::ToolApproval)
1129        );
1130        // Anything else the agent asked for is a question for a person. The
1131        // kind can also be unknown while the block is real, which reads the
1132        // same way: somebody is being waited on.
1133        assert_eq!(
1134            wait_reason_from(
1135                true,
1136                &held(Some(crate::interaction::InteractionKind::FreeText))
1137            ),
1138            Some(WaitReason::UserPrompt)
1139        );
1140        assert_eq!(
1141            wait_reason_from(true, &held(None)),
1142            Some(WaitReason::UserPrompt)
1143        );
1144    }
1145
1146    /// Parked with nothing claiming it: the field is left off rather than
1147    /// filled with a guess, and a run that is not parked never has one.
1148    #[test]
1149    fn nothing_claiming_a_parked_run_reports_no_reason() {
1150        assert_eq!(wait_reason_from(true, &WaitMarkers::default()), None);
1151        assert_eq!(
1152            wait_reason_from(
1153                false,
1154                &WaitMarkers {
1155                    gate_prompt: true,
1156                    ..Default::default()
1157                }
1158            ),
1159            None,
1160            "a run that is not waiting is not waiting on anything"
1161        );
1162    }
1163
1164    /// The rendered form every text surface uses, counts included. Narrow
1165    /// enough for a table column, which is why it is not the variant name.
1166    #[test]
1167    fn every_reason_renders_for_a_narrow_column() {
1168        assert_eq!(WaitReason::ToolApproval.to_string(), "tool approval");
1169        assert_eq!(WaitReason::UserPrompt.to_string(), "user prompt");
1170        assert_eq!(WaitReason::TaintGate.to_string(), "taint gate");
1171        assert_eq!(WaitReason::InteractionPoint.to_string(), "checkpoint");
1172        assert_eq!(
1173            WaitReason::FanOutWorkers { outstanding: 3 }.to_string(),
1174            "workers(3)"
1175        );
1176        assert_eq!(
1177            WaitReason::Children { outstanding: 2 }.to_string(),
1178            "children(2)"
1179        );
1180    }
1181
1182    /// Only the two engine-side reasons resolve on their own; the rest are a
1183    /// person's to clear. This is the predicate a badge should be built on.
1184    #[test]
1185    fn only_the_engine_side_reasons_need_nobody() {
1186        assert!(WaitReason::ToolApproval.needs_a_person());
1187        assert!(WaitReason::UserPrompt.needs_a_person());
1188        assert!(WaitReason::TaintGate.needs_a_person());
1189        assert!(WaitReason::InteractionPoint.needs_a_person());
1190        assert!(!WaitReason::FanOutWorkers { outstanding: 2 }.needs_a_person());
1191        assert!(!WaitReason::Children { outstanding: 2 }.needs_a_person());
1192    }
1193
1194    #[test]
1195    fn run_meta_serde_roundtrip() {
1196        let mut m = sample_meta();
1197        m.status = RunStatus::Running;
1198        m.metadata.insert("k".to_string(), "v".to_string());
1199        m.title = Some("A title".to_string());
1200        m.callback_secret = Some("shh".to_string());
1201        m.parent_run_id = Some("parent-1".to_string());
1202        m.children = vec!["child-a".to_string(), "child-b".to_string()];
1203        m.depth = 2;
1204        m.max_child_depth = 5;
1205        let json = serde_json::to_string(&m).unwrap();
1206        let back: RunMeta = serde_json::from_str(&json).unwrap();
1207        assert_eq!(back.run_id, m.run_id);
1208        assert_eq!(back.status, RunStatus::Running);
1209        assert_eq!(back.metadata.get("k").map(String::as_str), Some("v"));
1210        assert_eq!(back.title.as_deref(), Some("A title"));
1211        assert_eq!(back.callback_secret.as_deref(), Some("shh"));
1212        assert_eq!(back.parent_run_id.as_deref(), Some("parent-1"));
1213        assert_eq!(
1214            back.children,
1215            vec!["child-a".to_string(), "child-b".to_string()]
1216        );
1217        assert_eq!(back.depth, 2);
1218        assert_eq!(back.max_child_depth, 5);
1219    }
1220
1221    #[test]
1222    fn run_status_display_all_variants() {
1223        assert_eq!(RunStatus::Starting.to_string(), "Starting");
1224        assert_eq!(RunStatus::Running.to_string(), "Running");
1225        assert_eq!(RunStatus::WaitingInput.to_string(), "WaitingInput");
1226        assert_eq!(RunStatus::Complete.to_string(), "Complete");
1227        assert_eq!(
1228            RunStatus::CompleteInteractive.to_string(),
1229            "CompleteInteractive"
1230        );
1231        assert_eq!(RunStatus::Paused.to_string(), "Paused");
1232        assert_eq!(RunStatus::Error.to_string(), "Error");
1233        assert_eq!(RunStatus::Cancelled.to_string(), "Cancelled");
1234    }
1235
1236    #[test]
1237    fn run_status_serde_snake_case_roundtrip() {
1238        for s in [
1239            RunStatus::Starting,
1240            RunStatus::Running,
1241            RunStatus::WaitingInput,
1242            RunStatus::Complete,
1243            RunStatus::CompleteInteractive,
1244            RunStatus::Paused,
1245            RunStatus::Error,
1246            RunStatus::Cancelled,
1247        ] {
1248            let json = serde_json::to_string(&s).unwrap();
1249            let back: RunStatus = serde_json::from_str(&json).unwrap();
1250            assert_eq!(back, s);
1251        }
1252        assert_eq!(
1253            serde_json::to_string(&RunStatus::WaitingInput).unwrap(),
1254            "\"waiting_input\""
1255        );
1256        assert_eq!(
1257            serde_json::to_string(&RunStatus::Paused).unwrap(),
1258            "\"paused\""
1259        );
1260    }
1261
1262    #[test]
1263    fn context_snapshot_serde_roundtrip() {
1264        let snap = ContextSnapshot {
1265            stage_name: "plan".to_string(),
1266            total_tokens: 42,
1267            max_tokens: 100,
1268            regions: vec![RegionSnapshot {
1269                name: "history".to_string(),
1270                kind: "sliding".to_string(),
1271                current_tokens: 10,
1272                max_tokens: 50,
1273                entries: vec![RegionEntrySnapshot {
1274                    content: "hi".to_string(),
1275                    tokens: 1,
1276                    kind: crate::region::EntryKind::UserMessage,
1277                    metadata: Some(serde_json::json!({"a": 1})),
1278                    key: Some("k".to_string()),
1279                    taint: Default::default(),
1280                }],
1281                description: None,
1282            }],
1283        };
1284        let json = serde_json::to_string(&snap).unwrap();
1285        let back: ContextSnapshot = serde_json::from_str(&json).unwrap();
1286        assert_eq!(back.stage_name, "plan");
1287        assert_eq!(back.regions.len(), 1);
1288        assert_eq!(back.regions[0].entries.len(), 1);
1289        assert_eq!(back.regions[0].entries[0].content, "hi");
1290        assert_eq!(back.regions[0].entries[0].key.as_deref(), Some("k"));
1291    }
1292
1293    #[test]
1294    fn region_snapshot_skips_empty_entries_in_json() {
1295        let snap = RegionSnapshot {
1296            name: "r".to_string(),
1297            kind: "pinned".to_string(),
1298            current_tokens: 0,
1299            max_tokens: 0,
1300            entries: vec![],
1301            description: None,
1302        };
1303        let json = serde_json::to_string(&snap).unwrap();
1304        assert!(!json.contains("entries"));
1305    }
1306
1307    #[test]
1308    fn stage_run_status_display_all_variants() {
1309        assert_eq!(StageRunStatus::Pending.to_string(), "Pending");
1310        assert_eq!(StageRunStatus::Skipped.to_string(), "Skipped");
1311        assert_eq!(StageRunStatus::Active.to_string(), "Active");
1312        assert_eq!(StageRunStatus::WaitingInput.to_string(), "WaitingInput");
1313        assert_eq!(StageRunStatus::Complete.to_string(), "Complete");
1314        assert_eq!(StageRunStatus::Error.to_string(), "Error");
1315    }
1316
1317    #[test]
1318    fn run_flags_record_modification_dedups_paths_and_caps_the_list() {
1319        let mut flags = RunFlags::default();
1320        flags.record_modification("src/a.rs");
1321        flags.record_modification("src/a.rs");
1322        flags.record_modification("src/b.rs");
1323        assert_eq!(flags.modified_file_count, 3);
1324        assert_eq!(flags.modified_files, vec!["src/a.rs", "src/b.rs"]);
1325
1326        // Past the cap the count keeps rising but the list stops growing, so a
1327        // long run can't bloat meta.json.
1328        for i in 0..MAX_TRACKED_MODIFIED_FILES {
1329            flags.record_modification(&format!("f{i}.rs"));
1330        }
1331        assert_eq!(flags.modified_files.len(), MAX_TRACKED_MODIFIED_FILES);
1332        assert_eq!(flags.modified_file_count, 3 + MAX_TRACKED_MODIFIED_FILES);
1333    }
1334
1335    #[test]
1336    fn run_meta_flags_default_for_older_files() {
1337        // meta.json written before #107 has no `flags` key at all.
1338        let mut meta = RunMeta::new(
1339            "r".to_string(),
1340            "a".to_string(),
1341            "/p".to_string(),
1342            "t".to_string(),
1343            None,
1344            "/w".to_string(),
1345            1,
1346        );
1347        meta.flags.empty_output = true;
1348        // Drop the key structurally rather than by string surgery: a literal
1349        // spelling of the serialized flags silently stops matching the moment a
1350        // field is added, and the test then passes for the wrong reason.
1351        let mut json = serde_json::to_value(&meta).unwrap();
1352        json.as_object_mut().unwrap().remove("flags").unwrap();
1353        assert!(!json.to_string().contains("flags"));
1354        let back: RunMeta = serde_json::from_value(json).unwrap();
1355        assert_eq!(back.flags, RunFlags::default());
1356    }
1357
1358    #[test]
1359    fn stage_record_new_and_serde_roundtrip() {
1360        let rec = StageRecord::new("analyze".to_string(), 2);
1361        assert_eq!(rec.name, "analyze");
1362        assert_eq!(rec.index, 2);
1363        assert_eq!(rec.status, StageRunStatus::Pending);
1364        assert_eq!(rec.prompt_tokens, 0);
1365        assert_eq!(rec.completion_tokens, 0);
1366        assert_eq!(rec.cached_tokens, 0);
1367        assert!(rec.started_at.is_none());
1368        assert!(rec.ended_at.is_none());
1369
1370        let json = serde_json::to_string(&rec).unwrap();
1371        let back: StageRecord = serde_json::from_str(&json).unwrap();
1372        assert_eq!(back.name, "analyze");
1373        assert_eq!(back.status, StageRunStatus::Pending);
1374    }
1375}