Skip to main content

basis_tasks/
state.rs

1//! Durable per-agent state: metadata, terminal records, and their bounds.
2//!
3//! Three files carry an agent's task state, each atomic-replace JSON:
4//! `meta.json` (bookkeeping and the recorded spawn request — never
5//! conversation content, which is mentra's store's alone), `inbox.json`
6//! (messages, see [`super::inbox`](mod@super::inbox)), and `terminal.json` — written as the
7//! executor's **last** act, whose existence is the completion signal. An agent
8//! is resumable iff its terminal record does not exist; every crash before
9//! that write resolves toward resumable.
10
11use std::{
12    io,
13    time::{SystemTime, UNIX_EPOCH},
14};
15
16use basis::{Bound, Effort, RunUsage, SystemPrompt};
17
18use serde::{Deserialize, Serialize};
19use serde_json::Value;
20
21use crate::approve::Approve;
22use crate::data_dir::{AgentPaths, write_private_atomic};
23
24pub(crate) const MAX_MESSAGES: usize = 16;
25pub(crate) const MAX_MESSAGE: usize = 256 * 1024;
26/// The largest prompt `spawn` accepts, in bytes. Public so a host that
27/// assembles a prompt from parts of its own can refuse an oversized one as
28/// the caller's mistake, before `spawn` refuses it as a failure.
29pub const MAX_PROMPT: usize = 256 * 1024;
30pub(crate) const MAX_EVENT_BYTES: usize = 32 * 1024;
31pub(crate) const MAX_RESULT_BYTES: usize = 1024 * 1024;
32/// How many tasks one workspace may hold at once. `spawn` refuses past it
33/// (archiving an old agent directory is the way out); the ownership-policy
34/// walks in `policy` and `attach::cancel_tree` are bounded by it too, so
35/// corrupt or cyclic metadata cannot loop forever.
36pub const MAX_TASKS: usize = 1024;
37/// Byte cap on one agent's `events.jsonl`, standing in for the daemon-era
38/// journal cap: on overflow a final notice line is appended and recording
39/// stops — a run is never failed for observability.
40pub(crate) const MAX_EVENTS_BYTES: u64 = 32 * 1024 * 1024;
41
42/// The recorded spawn request, so a later attach can execute what spawn
43/// accepted. Credentials are intentionally absent: the attaching process owns
44/// its own environment, exactly as any other host does.
45#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
46pub(crate) struct RunOptions {
47    pub provider: Option<String>,
48    pub base_url: Option<String>,
49    pub model: Option<String>,
50    pub no_shell: bool,
51    /// The host's say over the system prompt, held as the type it is:
52    /// `Replace` displaces the workspace's context, `Append` follows it.
53    ///
54    /// Written typed (`{"replace": …}` / `{"append": …}`). The pre-0.6
55    /// record spelled it as two flat strings, and a task directory outlives
56    /// the binary that minted it, so the compat reader below still accepts
57    /// the flat `Replace` half and [`folded`](Self::folded) picks up the
58    /// legacy append field on load.
59    #[serde(
60        default,
61        skip_serializing_if = "Option::is_none",
62        deserialize_with = "system_prompt_compat"
63    )]
64    pub system_prompt: Option<SystemPrompt>,
65    /// The pre-0.6 append half, read-only: folded into
66    /// [`system_prompt`](Self::system_prompt) on load and never written back.
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub append_system_prompt: Option<String>,
69    /// Typed, and byte-for-byte the strings the old writer spelled
70    /// (`"low"` … `"max"`), so an old record needs no shim.
71    pub effort: Option<Effort>,
72    /// Typed, same story: `"always"` / `"never"` / `"prompt"` on the wire,
73    /// unchanged — and an invalid mode is now unrepresentable, so corruption
74    /// fails at decode, named, instead of somewhere downstream.
75    pub approve: Approve,
76    pub deadline_ms: Option<u64>,
77    pub tool_budget: Option<usize>,
78    pub token_budget: Option<u64>,
79    /// Whether the task answers a caller who can be handed files, and so is
80    /// offered `attach_file` (ADR-0041 §7). Absent — and so false — in a
81    /// record from before it.
82    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
83    pub answering: bool,
84    /// Where an attach reads the workspace's `config.json` answers from.
85    /// Always written. Absent in a record from before it, which was spawned
86    /// when every open discovered the files — so it keeps discovering.
87    #[serde(default = "legacy_config_lookup")]
88    pub config: basis::ConfigLookup,
89}
90
91/// The lookup a record from before [`RunOptions::config`] was spawned under.
92fn legacy_config_lookup() -> basis::ConfigLookup {
93    basis::ConfigLookup::Discover
94}
95
96impl RunOptions {
97    /// Folds the pre-0.6 two-string system-prompt spelling into the typed
98    /// field. Called by [`load_meta`], so everything downstream sees one
99    /// spelling however old the record is.
100    fn folded(self) -> Self {
101        let (system_prompt, append) = (
102            self.system_prompt.clone(),
103            self.append_system_prompt.clone(),
104        );
105        Self {
106            system_prompt: system_prompt.or_else(|| append.map(SystemPrompt::Append)),
107            append_system_prompt: None,
108            ..self
109        }
110    }
111}
112
113/// Accepts the typed spelling and the pre-0.6 flat string, which was always
114/// the `Replace` half — clap refused both flags at once before either was
115/// recorded.
116fn system_prompt_compat<'de, D>(deserializer: D) -> Result<Option<SystemPrompt>, D::Error>
117where
118    D: serde::Deserializer<'de>,
119{
120    #[derive(Deserialize)]
121    #[serde(untagged)]
122    enum Compat {
123        Typed(SystemPrompt),
124        Legacy(String),
125    }
126
127    Ok(
128        Option::<Compat>::deserialize(deserializer)?.map(|value| match value {
129            Compat::Typed(system_prompt) => system_prompt,
130            Compat::Legacy(replace) => SystemPrompt::Replace(replace),
131        }),
132    )
133}
134
135/// Accepts what the typed writer spells — the same `deadline` /
136/// `tool_budget` / `token_budget` strings the old one used — and reads
137/// anything else, like the `unknown` the old writer could emit for a bound it
138/// had no name for, as no bound rather than an unloadable record.
139fn bound_compat<'de, D>(deserializer: D) -> Result<Option<Bound>, D::Error>
140where
141    D: serde::Deserializer<'de>,
142{
143    let name = Option::<String>::deserialize(deserializer)?;
144    Ok(name.and_then(|name| Bound::from_type_tag(&name)))
145}
146
147/// A task whose own work has finished, but whose attached children may still
148/// lack terminal records. Recorded in `meta.json` before the settle pass, so a
149/// kill between a child's terminal and the parent's leaves the parent
150/// resumable with the model work already done.
151#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
152#[serde(tag = "state", rename_all = "snake_case")]
153#[non_exhaustive]
154pub enum Terminal {
155    /// The task produced its final answer.
156    Succeeded { result: String },
157    /// The task stopped with a failure message.
158    Failed { error: String },
159    /// The task stopped because cancellation was requested.
160    Cancelled,
161}
162
163/// A terminal payload exactly as written, with the basis-owned fields this
164/// build recognizes alongside it.
165#[derive(Debug, Clone, PartialEq)]
166pub struct TerminalRecord {
167    /// The exact terminal or message-terminal JSON read from disk.
168    pub raw: Value,
169    /// The typed terminal state, or `None` when a newer writer used a state
170    /// this build does not know.
171    pub terminal: Option<Terminal>,
172    /// The bound that stopped the run, when one did.
173    pub stopped_by: Option<Bound>,
174    /// Usage reported by the completed task, when present.
175    pub usage: Option<RunUsage>,
176    /// Whether the result text was truncated before it was recorded.
177    pub result_truncated: bool,
178}
179
180impl TerminalRecord {
181    pub(crate) fn from_raw(raw: Value) -> Self {
182        let terminal = serde_json::from_value(raw.clone()).ok();
183        let stopped_by = raw
184            .get("stopped_by")
185            .and_then(Value::as_str)
186            .and_then(Bound::from_type_tag);
187        let usage = raw
188            .get("usage")
189            .cloned()
190            .and_then(|usage| serde_json::from_value(usage).ok());
191        let result_truncated = raw
192            .get("result_truncated")
193            .and_then(Value::as_bool)
194            .unwrap_or(false);
195        Self {
196            raw,
197            terminal,
198            stopped_by,
199            usage,
200            result_truncated,
201        }
202    }
203}
204
205#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
206#[serde(rename_all = "snake_case")]
207pub enum MessageState {
208    /// Accepted but not yet presented to the task.
209    Pending,
210    /// Presented to the task in the current turn.
211    InFlight,
212    /// Presented to a completed turn.
213    Delivered,
214}
215
216#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
217pub struct MessageReply {
218    /// The reply text.
219    pub result: String,
220    /// Whether the reply text was truncated.
221    #[serde(default, skip_serializing_if = "is_false")]
222    pub result_truncated: bool,
223    /// The bound that stopped the reply's turn, when one did.
224    #[serde(
225        default,
226        skip_serializing_if = "Option::is_none",
227        deserialize_with = "bound_compat"
228    )]
229    pub stopped_by: Option<Bound>,
230}
231
232#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
233pub struct MessageRecord {
234    /// The durable message id.
235    pub id: String,
236    /// The message text.
237    pub body: String,
238    /// Whether the bounded public view shortened [`Self::body`].
239    #[serde(default, skip_serializing_if = "is_false")]
240    pub body_truncated: bool,
241    /// Delivery state.
242    pub state: MessageState,
243    /// Milliseconds since the Unix epoch when the message was accepted.
244    pub created_ms: u64,
245    /// The correlated reply, when the task produced one.
246    #[serde(default, skip_serializing_if = "Option::is_none")]
247    pub reply: Option<MessageReply>,
248}
249
250/// The exact `basis inbox` payload with its bounded typed messages alongside.
251#[derive(Debug, Clone, PartialEq)]
252pub struct InboxRecord {
253    /// The exact JSON shape printed by `basis inbox --json`.
254    pub raw: Value,
255    /// Bounded message views, oldest first.
256    pub messages: Vec<MessageRecord>,
257}
258
259fn is_zero(value: &u32) -> bool {
260    *value == 0
261}
262
263#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
264pub(crate) struct TaskMeta {
265    pub id: String,
266    pub parent: Option<String>,
267    pub detached: bool,
268    pub workspace: String,
269    /// The mentra resume key; empty until the first attach prepares the run.
270    pub agent_id: String,
271    /// The conversation this task was told to pick up, when `spawn` was given
272    /// `--continue` or `--session`. The first attach resumes it instead of
273    /// minting one, which is what lets a new handle carry an old dialogue —
274    /// and why continuing a settled task is a new task rather than a message
275    /// to a closed inbox (ADR-0019).
276    #[serde(default, skip_serializing_if = "Option::is_none")]
277    pub continues: Option<String>,
278    /// How many assistant turns the conversation already held when this task
279    /// first attached to it.
280    ///
281    /// Zero for a task that opened its own conversation. Nonzero only for a
282    /// continued one, where the previous answers are on the transcript from
283    /// the very first turn — and where the resume recovery in `attach` would
284    /// otherwise read one of them as this task's own answer and settle
285    /// `succeeded` without ever asking its prompt.
286    #[serde(default)]
287    pub answered_before: usize,
288    pub prompt: String,
289    pub options: RunOptions,
290    #[serde(default, skip_serializing_if = "Option::is_none")]
291    pub pending_terminal: Option<Terminal>,
292    #[serde(default, skip_serializing_if = "is_false")]
293    pub result_truncated: bool,
294    #[serde(
295        default,
296        skip_serializing_if = "Option::is_none",
297        deserialize_with = "bound_compat"
298    )]
299    pub stopped_by: Option<Bound>,
300    /// What every turn this task has driven reported spending, summed.
301    ///
302    /// Kept in `meta.json` rather than recomputed from the event journal
303    /// because a task outlives the process that drove it: two attaches, each
304    /// running turns, both add to one tally, and the journal may have been
305    /// capped ([`MAX_EVENTS_BYTES`]) long before anyone asks.
306    #[serde(default)]
307    pub usage: RunUsage,
308    pub deadline_at_ms: Option<u64>,
309    /// The agent-to-agent hop the task's work arrived at — see
310    /// [`RunSpec::with_hop`](crate::RunSpec::with_hop). Absent is 0, which
311    /// is what every task recorded before hops existed was.
312    #[serde(default, skip_serializing_if = "is_zero")]
313    pub hop: u32,
314    pub created_ms: u64,
315    /// When this task last advanced, as its executor records it: the attach
316    /// that opened its conversation, every turn it banked, and the completion
317    /// it settled on. Each write to this record is a step the task took, which
318    /// is what lets one field carry both meanings — a bookkeeping rewrite that
319    /// is *not* a step would have to say so rather than borrow this one.
320    ///
321    /// Read by [`super::tasks`](mod@super::tasks) to answer "the conversation
322    /// I was just having", the question `--continue` asks. Defaulted for that
323    /// reason: a record that omits it must still load, because a task that
324    /// vanished from `basis list` after an upgrade would be a worse answer
325    /// than a task ordered by the wrong clock. The reader falls back to
326    /// `created_ms`.
327    #[serde(default)]
328    pub updated_ms: u64,
329}
330
331impl TaskMeta {
332    pub(crate) fn new(
333        id: String,
334        parent: Option<String>,
335        detached: bool,
336        workspace: String,
337        prompt: String,
338        options: RunOptions,
339        deadline_at_ms: Option<u64>,
340    ) -> Self {
341        let now = now_ms();
342        Self {
343            id,
344            parent,
345            detached,
346            workspace,
347            agent_id: String::new(),
348            continues: None,
349            answered_before: 0,
350            prompt,
351            options,
352            pending_terminal: None,
353            result_truncated: false,
354            stopped_by: None,
355            usage: RunUsage::default(),
356            deadline_at_ms,
357            hop: 0,
358            created_ms: now,
359            updated_ms: now,
360        }
361    }
362
363    pub(crate) fn at_hop(self, hop: u32) -> Self {
364        Self { hop, ..self }
365    }
366
367    /// Records the conversation this task continues, when it continues one.
368    ///
369    /// A field rather than a pre-set `agent_id`, because the two mean
370    /// different things to the executor: `agent_id` says *this task has
371    /// attached before*, and taking a continued conversation for a resumed
372    /// one is how a task settles on someone else's answer.
373    #[must_use]
374    pub(crate) fn continuing(self, agent_id: Option<String>) -> Self {
375        Self {
376            continues: agent_id,
377            ..self
378        }
379    }
380
381    pub(crate) fn deadline_passed(&self) -> bool {
382        self.deadline_at_ms
383            .is_some_and(|deadline| deadline <= now_ms())
384    }
385
386    /// The terminal payload the recorded completion earns: exactly the shape
387    /// the daemon's journal produced, minted once into `terminal.json`.
388    pub(crate) fn terminal_payload(&self) -> Option<Value> {
389        let payload = match self.pending_terminal.as_ref()? {
390            Terminal::Succeeded { result } => {
391                let mut terminal = serde_json::json!({"state": "succeeded", "result": result});
392                if self.result_truncated {
393                    terminal["result_truncated"] = Value::Bool(true);
394                }
395                with_stopped_by(terminal, self.stopped_by)
396            }
397            Terminal::Failed { error } => with_stopped_by(
398                serde_json::json!({"state": "failed", "error": error}),
399                self.stopped_by,
400            ),
401            Terminal::Cancelled => serde_json::json!({"state": "cancelled"}),
402        };
403        Some(with_usage(payload, self.usage))
404    }
405}
406
407fn with_stopped_by(mut payload: Value, stopped_by: Option<Bound>) -> Value {
408    if let Some(stopped_by) = stopped_by {
409        payload["stopped_by"] = serde_json::json!(stopped_by);
410    }
411    payload
412}
413
414/// Adds what the task spent, when it spent anything.
415///
416/// A record that names no usage is a task whose turns reported none — a
417/// cancellation that never reached the model is the ordinary case — and a
418/// `usage` object full of zeros would claim a measurement nobody made. The
419/// same rule the finish line follows (`Event::RunFinished::usage`), so the
420/// stream and the record cannot disagree.
421fn with_usage(mut payload: Value, usage: RunUsage) -> Value {
422    if usage != RunUsage::default() {
423        payload["usage"] = serde_json::json!(usage);
424    }
425    payload
426}
427
428fn is_false(value: &bool) -> bool {
429    !*value
430}
431
432pub(crate) fn load_meta(paths: &AgentPaths) -> Result<TaskMeta, String> {
433    let bytes = std::fs::read(paths.meta())
434        .map_err(|error| format!("read task metadata for {}: {error}", paths.dir().display()))?;
435    let meta: TaskMeta = serde_json::from_slice(&bytes).map_err(|error| {
436        format!(
437            "decode task metadata for {}: {error}",
438            paths.dir().display()
439        )
440    })?;
441
442    // One spelling downstream, however old the record: the pre-0.6 system
443    // prompt strings fold into the typed field on the way in.
444    Ok(TaskMeta {
445        options: meta.options.folded(),
446        ..meta
447    })
448}
449
450pub(crate) fn save_meta(paths: &AgentPaths, meta: &TaskMeta) -> Result<(), String> {
451    let bytes =
452        serde_json::to_vec(meta).map_err(|error| format!("encode task metadata: {error}"))?;
453    write_private_atomic(&paths.meta(), &bytes)
454        .map_err(|error| format!("persist task metadata: {error}"))
455}
456
457/// Reads the terminal record. `None` is the resumable state.
458pub(crate) fn read_terminal(paths: &AgentPaths) -> Result<Option<Value>, String> {
459    match std::fs::read(paths.terminal()) {
460        Ok(bytes) => serde_json::from_slice(&bytes)
461            .map(Some)
462            .map_err(|error| format!("decode terminal record: {error}")),
463        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
464        Err(error) => Err(format!("read terminal record: {error}")),
465    }
466}
467
468pub(crate) fn write_terminal(paths: &AgentPaths, payload: &Value) -> Result<(), String> {
469    let bytes =
470        serde_json::to_vec(payload).map_err(|error| format!("encode terminal record: {error}"))?;
471    write_private_atomic(&paths.terminal(), &bytes)
472        .map_err(|error| format!("persist terminal record: {error}"))
473}
474
475pub(crate) fn cancel_requested(paths: &AgentPaths) -> bool {
476    paths.cancel_marker().exists()
477}
478
479/// Writes the cancel marker. Existence is the signal; the content is
480/// diagnostic only.
481pub(crate) fn request_cancel(paths: &AgentPaths, by: Option<&str>) -> Result<(), String> {
482    let content = serde_json::json!({"requested_ms": now_ms(), "by": by});
483    write_private_atomic(&paths.cancel_marker(), content.to_string().as_bytes())
484        .map_err(|error| format!("record cancel request: {error}"))
485}
486
487pub(crate) fn bounded_text(mut value: String, limit: usize) -> (String, bool) {
488    if value.len() <= limit {
489        return (value, false);
490    }
491    let mut end = limit;
492    while !value.is_char_boundary(end) {
493        end -= 1;
494    }
495    value.truncate(end);
496    (value, true)
497}
498
499/// Milliseconds since the epoch, clamped rather than panicking on a clock
500/// before it. The one clock every durable record here is stamped with —
501/// `created_ms`, `updated_ms`, a cancel marker's `requested_ms`, a message's
502/// `created_ms` — and what a host rendering one of those alongside its own
503/// notion of "now" (`basis list`'s own age column, for one) should read
504/// `now` from, so the two clocks cannot disagree about the epoch.
505pub fn now_ms() -> u64 {
506    SystemTime::now()
507        .duration_since(UNIX_EPOCH)
508        .unwrap_or_default()
509        .as_millis()
510        .min(u128::from(u64::MAX)) as u64
511}
512
513#[cfg(test)]
514mod tests {
515    use super::*;
516    use crate::data_dir::DataDir;
517
518    fn agent(dir: &tempfile::TempDir) -> AgentPaths {
519        let data = DataDir::from_path(dir.path()).unwrap();
520        let paths = data
521            .agent_dir("0123456789abcdef/0123456789abcdef0123456789abcdef")
522            .unwrap();
523        std::fs::create_dir_all(paths.dir()).unwrap();
524        paths
525    }
526
527    fn meta(paths: &AgentPaths) -> TaskMeta {
528        TaskMeta::new(
529            "0123456789abcdef/0123456789abcdef0123456789abcdef".to_string(),
530            None,
531            true,
532            "/repo".to_string(),
533            "prompt".to_string(),
534            RunOptions::default(),
535            None,
536        )
537        .tap(paths)
538    }
539
540    trait Tap {
541        fn tap(self, paths: &AgentPaths) -> Self;
542    }
543    impl Tap for TaskMeta {
544        fn tap(self, paths: &AgentPaths) -> Self {
545            save_meta(paths, &self).unwrap();
546            self
547        }
548    }
549
550    #[test]
551    fn a_record_from_before_the_config_lookup_keeps_discovering() {
552        // Spawned when every open read `.basis/config.json`; resuming it must
553        // not quietly run a different model than the one it started with.
554        let mut legacy = serde_json::to_value(RunOptions::default()).unwrap();
555        legacy.as_object_mut().unwrap().remove("config");
556
557        let options: RunOptions = serde_json::from_value(legacy).unwrap();
558
559        assert_eq!(options.config, basis::ConfigLookup::Discover);
560    }
561
562    #[test]
563    fn the_config_lookup_is_always_written() {
564        let written = serde_json::to_value(RunOptions::default()).unwrap();
565
566        assert_eq!(written["config"], serde_json::json!("none"));
567    }
568
569    #[test]
570    fn metadata_round_trips_through_its_file() {
571        let dir = tempfile::tempdir().unwrap();
572        let paths = agent(&dir);
573        let saved = meta(&paths);
574        assert_eq!(load_meta(&paths).unwrap(), saved);
575    }
576
577    #[test]
578    fn terminal_records_are_absent_until_written_then_repeatably_observable() {
579        let dir = tempfile::tempdir().unwrap();
580        let paths = agent(&dir);
581        assert!(read_terminal(&paths).unwrap().is_none());
582
583        let mut record = meta(&paths);
584        record.pending_terminal = Some(Terminal::Succeeded {
585            result: "done".to_string(),
586        });
587        let payload = record.terminal_payload().unwrap();
588        write_terminal(&paths, &payload).unwrap();
589
590        assert_eq!(read_terminal(&paths).unwrap().unwrap(), payload);
591        assert_eq!(read_terminal(&paths).unwrap().unwrap(), payload);
592        assert_eq!(
593            payload,
594            serde_json::json!({"state": "succeeded", "result": "done"})
595        );
596    }
597
598    #[test]
599    fn terminal_record_keeps_raw_json_beside_the_typed_view() {
600        let raw = serde_json::json!({
601            "state": "succeeded",
602            "result": "done",
603            "result_truncated": true,
604            "stopped_by": "token_budget",
605            "usage": {
606                "input_tokens": 7,
607                "output_tokens": 3,
608                "cache_read_tokens": 0,
609                "cache_creation_tokens": 0
610            },
611            "newer_field": "kept",
612        });
613        let record = TerminalRecord::from_raw(raw.clone());
614
615        assert_eq!(record.raw, raw);
616        assert_eq!(
617            record.terminal,
618            Some(Terminal::Succeeded {
619                result: "done".to_string()
620            })
621        );
622        assert_eq!(record.stopped_by, Some(Bound::TokenBudget));
623        assert_eq!(record.usage.expect("typed usage").input_tokens, 7);
624        assert!(record.result_truncated);
625
626        let unknown = serde_json::json!({"state": "paused_by_newer_basis", "detail": 1});
627        let record = TerminalRecord::from_raw(unknown.clone());
628        assert_eq!(record.raw, unknown);
629        assert_eq!(record.terminal, None);
630    }
631
632    #[test]
633    fn terminal_payloads_carry_truncation_and_bound_metadata() {
634        let dir = tempfile::tempdir().unwrap();
635        let paths = agent(&dir);
636        let mut record = meta(&paths);
637        record.pending_terminal = Some(Terminal::Failed {
638            error: "took too long".to_string(),
639        });
640        record.stopped_by = Some(Bound::Deadline);
641        assert_eq!(
642            record.terminal_payload().unwrap(),
643            serde_json::json!({"state": "failed", "error": "took too long", "stopped_by": "deadline"})
644        );
645
646        record.pending_terminal = Some(Terminal::Cancelled);
647        assert_eq!(
648            record.terminal_payload().unwrap(),
649            serde_json::json!({"state": "cancelled"}),
650            "a cancelled terminal never reports a bound"
651        );
652    }
653
654    /// The terminal record is what `basis wait --json` and `basis list --json`
655    /// read, and it is the only place a settled task's cost survives: the
656    /// event journal can be capped, and the process that spent the tokens is
657    /// gone.
658    #[test]
659    fn a_settled_task_records_what_it_spent_only_when_it_spent_something() {
660        let dir = tempfile::tempdir().unwrap();
661        let paths = agent(&dir);
662        let mut record = meta(&paths);
663        record.pending_terminal = Some(Terminal::Succeeded {
664            result: "done".to_string(),
665        });
666
667        assert_eq!(
668            record.terminal_payload().unwrap(),
669            serde_json::json!({"state": "succeeded", "result": "done"}),
670            "a task whose turns reported nothing claims no measurement"
671        );
672
673        record.usage = RunUsage {
674            input_tokens: 900,
675            output_tokens: 100,
676            cache_read_tokens: 7,
677            cache_creation_tokens: 3,
678            reasoning_tokens: 0,
679            thoughts_tokens: 0,
680            model_responses: 1,
681        };
682        let payload = record.terminal_payload().unwrap();
683        assert_eq!(payload["usage"]["input_tokens"], 900);
684        assert_eq!(payload["usage"]["output_tokens"], 100);
685        assert_eq!(payload["usage"]["cache_read_tokens"], 7);
686        assert_eq!(payload["usage"]["cache_creation_tokens"], 3);
687    }
688
689    #[test]
690    fn cancel_markers_signal_by_existence() {
691        let dir = tempfile::tempdir().unwrap();
692        let paths = agent(&dir);
693        assert!(!cancel_requested(&paths));
694        request_cancel(&paths, Some("caller/id")).unwrap();
695        assert!(cancel_requested(&paths));
696    }
697
698    #[test]
699    fn bounded_text_never_splits_utf8() {
700        let (value, truncated) = bounded_text("a界b".to_string(), 2);
701        assert_eq!(value, "a");
702        assert!(truncated);
703    }
704}