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}