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}