Skip to main content

capsule_runtime/
session_status.rs

1//! Where an agent session stands: what the runtime-provided `session-status` tool reports, the
2//! hard deadline a session runs under, and the one notice an agent is given before it.
3//!
4//! [`SessionStatus`] holds no number of its own. It is fed at the funnels that already produce
5//! each one — the trace writer's `inference`, `task_start` and `task_end` writes, and the http
6//! agent loop's compaction check — and a call reads the spend meter as it stands. So every field a
7//! call returns agrees with the trace written before it.
8
9use std::sync::atomic::{AtomicBool, Ordering};
10use std::sync::Mutex;
11use std::time::{Duration, Instant, SystemTime};
12
13use serde::Serialize;
14use serde_json::Value;
15
16use crate::agent::{DriverUsage, OccupancyBasis};
17use crate::model_limits::{BudgetBasis, InputBudget};
18use crate::spend::SpendMeter;
19
20/// The runtime-provided tool every agent capsule has, on both transports, with no grant.
21pub const SESSION_STATUS_TOOL: &str = "session-status";
22
23/// What a field reads when its limit is not set.
24const NONE: &str = "none";
25
26/// What a field reads when the runtime does not have the count.
27const UNKNOWN: &str = "unknown";
28
29/// The longest default notice margin: five minutes.
30const DEFAULT_NOTICE_CEILING_SECS: u64 = 300;
31
32/// The longest deadline a session runs under: ten years. A larger one overflows the clocks
33/// the deadline is read against.
34pub const MAX_DEADLINE_SECS: u64 = 315_360_000;
35
36/// The hidden `mur run` flag a formation launcher and a child launcher pass a deadline down with:
37/// the deadline's wall moment in milliseconds since the Unix epoch.
38pub const INHERITED_DEADLINE_FLAG: &str = "--inherited-deadline";
39
40/// Where a session's deadline came from.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum DeadlineSource {
43    /// The session's own `mur run --deadline`.
44    Own,
45    /// The deadline of the formation or the capsule that launched the session.
46    Inherited,
47}
48
49impl DeadlineSource {
50    /// The `source` value the trace records carry.
51    pub const fn as_str(self) -> &'static str {
52        match self {
53            Self::Own => "own",
54            Self::Inherited => "inherited",
55        }
56    }
57}
58
59/// The hard limit a session runs under: its own `mur run --deadline`, or the moment it inherited
60/// from the formation or capsule that launched it.
61///
62/// Both clocks are read once, at the session's start. The monotonic one is what the runtime
63/// enforces and counts down against; the wall one is only what `at` is spelled from, so a clock
64/// change during the session moves neither the enforcement nor `left_secs`.
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub struct SessionDeadline {
67    at: Instant,
68    at_wall: SystemTime,
69    secs: u64,
70    source: DeadlineSource,
71}
72
73impl SessionDeadline {
74    /// The deadline `secs` after a start read as `started` and `started_wall`. A `secs` above
75    /// [`MAX_DEADLINE_SECS`] is taken as that bound.
76    pub fn after(started: Instant, started_wall: SystemTime, secs: u64) -> Self {
77        let secs = secs.min(MAX_DEADLINE_SECS);
78        let length = Duration::from_secs(secs);
79        Self {
80            at: started + length,
81            at_wall: started_wall + length,
82            secs,
83            source: DeadlineSource::Own,
84        }
85    }
86
87    /// The inherited deadline at the wall moment `at_wall`, for a session whose start was read as
88    /// `started` and `started_wall`. What is left is `at_wall - started_wall`: zero when the moment
89    /// has passed, and at most [`MAX_DEADLINE_SECS`], beyond which `at` is that bound.
90    pub fn until(started: Instant, started_wall: SystemTime, at_wall: SystemTime) -> Self {
91        let bound = Duration::from_secs(MAX_DEADLINE_SECS);
92        let left = at_wall
93            .duration_since(started_wall)
94            .unwrap_or(Duration::ZERO);
95        let (left, at_wall) = if left > bound {
96            (bound, started_wall + bound)
97        } else {
98            (left, at_wall)
99        };
100        let secs = left.as_secs() + u64::from(left.subsec_nanos() > 0);
101        Self {
102            at: started + left,
103            at_wall,
104            secs,
105            source: DeadlineSource::Inherited,
106        }
107    }
108
109    /// [`SessionDeadline::until`] the moment `millis` milliseconds after the Unix epoch, as
110    /// [`INHERITED_DEADLINE_FLAG`] carries it. A moment the wall clock cannot hold is taken as the
111    /// bound.
112    pub fn from_unix_millis(started: Instant, started_wall: SystemTime, millis: u64) -> Self {
113        let bound = started_wall + Duration::from_secs(MAX_DEADLINE_SECS);
114        let at_wall = SystemTime::UNIX_EPOCH
115            .checked_add(Duration::from_millis(millis))
116            .unwrap_or(bound);
117        Self::until(started, started_wall, at_wall)
118    }
119
120    /// `at` in whole milliseconds since the Unix epoch, the value [`INHERITED_DEADLINE_FLAG`]
121    /// carries. Truncated, so the moment a session inherits is never later than this one and
122    /// spells the same second.
123    pub fn at_unix_millis(&self) -> u64 {
124        self.at_wall
125            .duration_since(SystemTime::UNIX_EPOCH)
126            .map_or(0, |since| {
127                u64::try_from(since.as_millis()).unwrap_or(u64::MAX)
128            })
129    }
130
131    /// The session's deadline: the earlier of its own and the inherited one, the inherited one on a
132    /// tie, and `None` when it has neither. Both must have been read against the same start.
133    pub fn earliest(own: Option<Self>, inherited: Option<Self>) -> Option<Self> {
134        match (own, inherited) {
135            (Some(own), Some(inherited)) if own.at < inherited.at => Some(own),
136            (_, Some(inherited)) => Some(inherited),
137            (own, None) => own,
138        }
139    }
140
141    /// Seconds from the session's start to `at`: the `--deadline` value for an own deadline, and
142    /// rounded up, `0` once passed, for an inherited one.
143    pub fn secs(&self) -> u64 {
144        self.secs
145    }
146
147    /// Where the deadline came from.
148    pub fn source(&self) -> DeadlineSource {
149        self.source
150    }
151
152    /// When the runtime ends the session, as RFC 3339 UTC to the second.
153    pub fn at_rfc3339(&self) -> String {
154        chrono::DateTime::<chrono::Utc>::from(self.at_wall)
155            .to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
156    }
157
158    /// How long is left at `now`; zero once the deadline has passed.
159    pub fn left(&self, now: Instant) -> Duration {
160        self.at.saturating_duration_since(now)
161    }
162
163    /// The monotonic instant the session ends at.
164    pub(crate) fn instant(&self) -> Instant {
165        self.at
166    }
167}
168
169/// The notice margin a deadline of `deadline_secs` gets when `lifecycle.deadline_notice_secs` is
170/// not declared: five minutes, or a tenth of the deadline when that is shorter, and at least one
171/// second.
172pub fn default_notice_secs(deadline_secs: u64) -> u64 {
173    (deadline_secs / 10).clamp(1, DEFAULT_NOTICE_CEILING_SECS)
174}
175
176/// The margin in force: the declared `lifecycle.deadline_notice_secs`, or the default for the
177/// deadline. `0` sends no notice.
178pub fn notice_secs(declared: Option<u64>, deadline_secs: u64) -> u64 {
179    declared.unwrap_or_else(|| default_notice_secs(deadline_secs))
180}
181
182/// The one deadline notice, taken by the first dispatched tool result inside the margin.
183#[derive(Debug, Clone, PartialEq, Eq)]
184pub(crate) struct DeadlineNotice {
185    /// `SessionDeadline::at_rfc3339`.
186    pub(crate) at: String,
187    /// Whole seconds left when it was taken.
188    pub(crate) left_secs: u64,
189    /// The margin it was due under.
190    pub(crate) notice_secs: u64,
191}
192
193impl DeadlineNotice {
194    /// The line appended after the tool result's fence.
195    pub(crate) fn text(&self) -> String {
196        format!(
197            "Session deadline: the runtime ends this session at {}, in {} seconds.",
198            self.at, self.left_secs
199        )
200    }
201}
202
203/// The spend meter as a call reads it.
204#[derive(Debug, Clone, Copy, PartialEq, Eq)]
205pub(crate) struct SpendReading {
206    /// `SpendMeter::used`: tokens settled against the ceilings.
207    pub(crate) used: u64,
208    /// `inference.max_session_tokens`.
209    pub(crate) ceiling: Option<u64>,
210    /// The machine ledger's day total and `spend.machine_tokens_per_day`, when this session keeps
211    /// the ledger.
212    pub(crate) machine: Option<(u64, u64)>,
213}
214
215impl SpendReading {
216    /// Read `meter` now, the machine ledger's file included.
217    pub(crate) fn of(meter: &SpendMeter) -> Self {
218        Self {
219            used: meter.used(),
220            ceiling: meter.session_ceiling(),
221            machine: meter.machine_reading(),
222        }
223    }
224}
225
226/// How the context occupancy was last read.
227#[derive(Debug, Clone, Copy, PartialEq, Eq)]
228struct ContextReading {
229    used_tokens: u32,
230    window_tokens: Option<u32>,
231    budget_tokens: Option<u32>,
232    budget_basis: BudgetBasis,
233    measured_by: OccupancyBasis,
234}
235
236/// What the funnels have fed so far.
237#[derive(Debug, Default)]
238struct Fed {
239    /// The active task and when its `task_start` was written.
240    task: Option<(String, Instant)>,
241    /// `TraceWriter::task_turns` as of the last `inference` record.
242    task_turns: u32,
243    /// The active task's `lifecycle.keep_working` continuations, as of its last `keep_working`
244    /// record.
245    keep_working_used: u32,
246    /// The active task's `lifecycle.done_check` reopens, as of its last reopening `done_check`
247    /// record.
248    done_check_reopens: u32,
249    input: Option<u64>,
250    output: Option<u64>,
251    cached: Option<u64>,
252    cache_write: Option<u64>,
253    /// `inference` records that carried no token count.
254    unreported_calls: u64,
255    context: Option<ContextReading>,
256}
257
258/// One session's standing, shared by the store, the trace writer and the agent loop.
259#[derive(Debug)]
260pub(crate) struct SessionStatus {
261    started: Instant,
262    /// `transport: process`: the harness keeps the context, and its counts are the tokens.
263    harness_transport: bool,
264    /// `inference.max_turns`.
265    turn_cap: Option<u32>,
266    deadline: Option<SessionDeadline>,
267    /// The notice margin in force; `0` sends none.
268    notice_secs: u64,
269    /// `lifecycle.keep_working.limit`; `None` when the option is off.
270    keep_working_limit: Option<u32>,
271    /// `lifecycle.done_check.limit`; `None` when the option is off.
272    done_check_limit: Option<u32>,
273    notice_taken: AtomicBool,
274    fed: Mutex<Fed>,
275}
276
277impl SessionStatus {
278    /// A session that started at `started`. `notice_secs` is [`notice_secs`]'s answer, and is
279    /// read only when there is a deadline. `keep_working_limit` is `lifecycle.keep_working.limit`,
280    /// `None` when the capsule does not set the option, and `done_check_limit` is
281    /// `lifecycle.done_check.limit` on the same terms.
282    pub(crate) fn new(
283        started: Instant,
284        harness_transport: bool,
285        turn_cap: Option<u32>,
286        deadline: Option<SessionDeadline>,
287        notice_secs: u64,
288        keep_working_limit: Option<u32>,
289        done_check_limit: Option<u32>,
290    ) -> Self {
291        Self {
292            started,
293            harness_transport,
294            turn_cap,
295            deadline,
296            notice_secs,
297            keep_working_limit,
298            done_check_limit,
299            notice_taken: AtomicBool::new(false),
300            fed: Mutex::new(Fed::default()),
301        }
302    }
303
304    fn fed(&self) -> std::sync::MutexGuard<'_, Fed> {
305        // Every update under the lock is a handful of assignments that cannot panic midway.
306        self.fed
307            .lock()
308            .unwrap_or_else(std::sync::PoisonError::into_inner)
309    }
310
311    /// The session's deadline: the earliest bound placed on it, if any.
312    pub(crate) fn deadline(&self) -> Option<SessionDeadline> {
313        self.deadline
314    }
315
316    /// The notice margin in force, `0` for none; meaningless without a deadline.
317    pub(crate) fn notice_secs(&self) -> u64 {
318        self.notice_secs
319    }
320
321    /// Fed by `TraceWriter::write_task_start`.
322    pub(crate) fn task_started(&self, task_id: &str, at: Instant) {
323        let mut fed = self.fed();
324        fed.task = Some((task_id.to_string(), at));
325        fed.task_turns = 0;
326        fed.keep_working_used = 0;
327        fed.done_check_reopens = 0;
328    }
329
330    /// Fed by `TraceWriter::write_keep_working` with the continuation's 1-based `number`.
331    pub(crate) fn keep_working_sent(&self, number: u32) {
332        self.fed().keep_working_used = number;
333    }
334
335    /// Fed by `TraceWriter::write_done_check` with the task's reopens so far, when a run reopened
336    /// it.
337    pub(crate) fn done_check_reopened(&self, reopens: u32) {
338        self.fed().done_check_reopens = reopens;
339    }
340
341    /// Fed by `TraceWriter::write_task_end`.
342    pub(crate) fn task_ended(&self) {
343        self.fed().task = None;
344    }
345
346    /// The active task, for the records a call writes.
347    pub(crate) fn task_id(&self) -> Option<String> {
348        self.fed().task.as_ref().map(|(id, _)| id.clone())
349    }
350
351    /// Fed by `TraceWriter::write_inference_event` with each `inference` record's counts, after
352    /// the writer's own counters moved: `task_turns` is `TraceWriter::task_turns` as it now stands.
353    ///
354    /// The provider's report is `usage` under `transport: http`; under `transport: process` it is
355    /// the harness's `input_tokens` / `output_tokens` with `usage`'s cache counts. A record whose
356    /// report carries no count is an unreported call.
357    pub(crate) fn record_inference(
358        &self,
359        task_turns: u32,
360        input_tokens: Option<u64>,
361        output_tokens: Option<u64>,
362        usage: Option<&DriverUsage>,
363    ) {
364        let (input, output) = if self.harness_transport {
365            (input_tokens, output_tokens)
366        } else {
367            (
368                usage.and_then(|u| u.input_tokens),
369                usage.and_then(|u| u.output_tokens),
370            )
371        };
372        let cached = usage.and_then(|u| u.cached_tokens);
373        let cache_write = usage.and_then(|u| u.cache_write_tokens);
374        let mut guard = self.fed();
375        let fed = &mut *guard;
376        fed.task_turns = task_turns;
377        if [input, output, cached, cache_write]
378            .iter()
379            .all(Option::is_none)
380        {
381            fed.unreported_calls = fed.unreported_calls.saturating_add(1);
382            return;
383        }
384        for (sum, count) in [
385            (&mut fed.input, input),
386            (&mut fed.output, output),
387            (&mut fed.cached, cached),
388            (&mut fed.cache_write, cache_write),
389        ] {
390            if let Some(count) = count {
391                *sum = Some(sum.unwrap_or(0).saturating_add(count));
392            }
393        }
394    }
395
396    /// Fed by the http agent loop right after its compaction check: the occupancy that check
397    /// compared, the window of the choice that served the call (`None` when it has none), the
398    /// input budget the check compared against (`None` with no window) and how the count was
399    /// made.
400    pub(crate) fn set_context(
401        &self,
402        used_tokens: u32,
403        window_tokens: Option<u32>,
404        budget: Option<InputBudget>,
405        measured_by: OccupancyBasis,
406    ) {
407        self.fed().context = Some(ContextReading {
408            used_tokens,
409            window_tokens,
410            budget_tokens: budget.map(|budget| budget.tokens),
411            budget_basis: budget.map_or(BudgetBasis::None, |budget| budget.basis),
412            measured_by,
413        });
414    }
415
416    /// The `session-status` result as it stands now.
417    pub(crate) fn snapshot(&self, spend: SpendReading) -> StatusReport {
418        self.snapshot_at(Instant::now(), spend)
419    }
420
421    /// The `session-status` result as it stands at `now`.
422    pub(crate) fn snapshot_at(&self, now: Instant, spend: SpendReading) -> StatusReport {
423        let fed = self.fed();
424        let whole_secs = |since: Instant| now.saturating_duration_since(since).as_secs();
425        let time = TimeReport {
426            elapsed_secs: whole_secs(self.started),
427            task_elapsed_secs: fed.task.as_ref().map_or_else(
428                || Value::from(UNKNOWN),
429                |(_, at)| Value::from(whole_secs(*at)),
430            ),
431            deadline: self.deadline.map_or_else(
432                || Value::from(NONE),
433                |deadline| {
434                    serde_json::json!({
435                        "at": deadline.at_rfc3339(),
436                        "left_secs": deadline.left(now).as_secs(),
437                    })
438                },
439            ),
440        };
441        let context = match (self.harness_transport, fed.context) {
442            (false, Some(reading)) => serde_json::json!({
443                "used_tokens": reading.used_tokens,
444                "window_tokens": or_none(reading.window_tokens),
445                "budget_tokens": or_none(reading.budget_tokens),
446                "budget_basis": reading.budget_basis.wire_name(),
447                "measured_by": reading.measured_by,
448            }),
449            _ => Value::from(UNKNOWN),
450        };
451        StatusReport {
452            time,
453            turns: TurnsReport {
454                used: fed.task_turns,
455                cap: or_none(self.turn_cap),
456            },
457            keep_working: self.keep_working_limit.map_or_else(
458                || Value::from(NONE),
459                |limit| {
460                    serde_json::json!({
461                        "used": fed.keep_working_used,
462                        "left": limit.saturating_sub(fed.keep_working_used),
463                    })
464                },
465            ),
466            done_check: self.done_check_limit.map_or_else(
467                || Value::from(NONE),
468                |limit| {
469                    serde_json::json!({
470                        "used": fed.done_check_reopens,
471                        "left": limit.saturating_sub(fed.done_check_reopens),
472                    })
473                },
474            ),
475            context,
476            tokens: TokensReport {
477                input: or_unknown(fed.input),
478                output: or_unknown(fed.output),
479                cached: or_unknown(fed.cached),
480                cache_write: or_unknown(fed.cache_write),
481                unreported_calls: fed.unreported_calls,
482            },
483            spend: SpendReport {
484                tokens: spend.used,
485                ceiling: or_none(spend.ceiling),
486                machine: spend.machine.map_or_else(
487                    || Value::from(NONE),
488                    |(tokens_today, ceiling)| {
489                        serde_json::json!({"tokens_today": tokens_today, "ceiling": ceiling})
490                    },
491                ),
492            },
493        }
494    }
495
496    /// The deadline notice, when it is due at `now` and has not been taken: once per session, from
497    /// the moment no more than the margin is left. Never with no deadline or a margin of `0`.
498    pub(crate) fn take_due_notice(&self, now: Instant) -> Option<DeadlineNotice> {
499        let deadline = self.deadline?;
500        if self.notice_secs == 0 {
501            return None;
502        }
503        let left = deadline.left(now);
504        if left > Duration::from_secs(self.notice_secs) {
505            return None;
506        }
507        if self.notice_taken.swap(true, Ordering::AcqRel) {
508            return None;
509        }
510        Some(DeadlineNotice {
511            at: deadline.at_rfc3339(),
512            left_secs: left.as_secs(),
513            notice_secs: self.notice_secs,
514        })
515    }
516}
517
518fn or_none<T: Into<Value>>(value: Option<T>) -> Value {
519    value.map_or_else(|| Value::from(NONE), Into::into)
520}
521
522fn or_unknown<T: Into<Value>>(value: Option<T>) -> Value {
523    value.map_or_else(|| Value::from(UNKNOWN), Into::into)
524}
525
526/// One `session-status` result: what the model is handed and what `session_status.status` records.
527#[derive(Debug, Clone, PartialEq, Serialize)]
528pub(crate) struct StatusReport {
529    time: TimeReport,
530    turns: TurnsReport,
531    /// `{used, left}` under `lifecycle.keep_working`; `"none"` without it.
532    keep_working: Value,
533    /// `{used, left}` under `lifecycle.done_check`; `"none"` without it.
534    done_check: Value,
535    /// An object under `transport: http`; `"unknown"` under `transport: process`.
536    context: Value,
537    tokens: TokensReport,
538    spend: SpendReport,
539}
540
541#[derive(Debug, Clone, PartialEq, Serialize)]
542struct TimeReport {
543    elapsed_secs: u64,
544    task_elapsed_secs: Value,
545    deadline: Value,
546}
547
548#[derive(Debug, Clone, PartialEq, Serialize)]
549struct TurnsReport {
550    used: u32,
551    cap: Value,
552}
553
554#[derive(Debug, Clone, PartialEq, Serialize)]
555struct TokensReport {
556    input: Value,
557    output: Value,
558    cached: Value,
559    cache_write: Value,
560    unreported_calls: u64,
561}
562
563#[derive(Debug, Clone, PartialEq, Serialize)]
564struct SpendReport {
565    tokens: u64,
566    ceiling: Value,
567    machine: Value,
568}
569
570/// The fields every description states, in the order the result carries them.
571const FIELDS: &str = "`time`: `elapsed_secs` since the session started, `task_elapsed_secs` since \
572     the current task started, and `deadline`, with `at`, the RFC 3339 UTC time the runtime ends \
573     the session, and `left_secs` until then. `turns`: `used`, the inference calls this task has \
574     made, and `cap`, the most it may make. `keep_working`: `used`, the messages \
575     lifecycle.keep_working has sent this task to keep it working past a turn end, and `left`, \
576     how many more it may send. `done_check`: `used`, the times lifecycle.done_check has \
577     reopened this task because its command failed, and `left`, how many more it may.";
578
579/// The `spend` and closing sentences every description shares.
580const SPEND_AND_VALUES: &str = "`spend`: `tokens`, what the spend ceilings count for this \
581     session, `ceiling`, this session's token ceiling, and `machine`, this machine's \
582     `tokens_today` against its daily `ceiling`. No money amount is kept: spend is counted in \
583     tokens. A limit that is not set reads \"none\" and a count the runtime does not have reads \
584     \"unknown\". Seconds are whole.";
585
586/// `session-status`'s description under `transport: http`. Fixed, so the tool array's bytes do
587/// not change from one request to the next.
588pub fn description_http() -> String {
589    format!(
590        "Report where this session stands, measured at the moment of the call. Takes no input and \
591         returns one JSON object. {FIELDS} `context`: `used_tokens`, the conversation's size as \
592         the runtime last counted it, `window_tokens`, the model's context window, \
593         `budget_tokens`, the most input tokens a call may carry, which compaction measures \
594         against, `budget_basis`, the limit that sets it (\"max_input_tokens\": the provider's \
595         input ceiling; \"context_window_less_output_cap\": the window less the call's output \
596         cap), and `measured_by`, how the count was made (\"actual\": the provider's own count of the last \
597         call; \"estimate\": the runtime's own count). `tokens`: \
598         the `input`, `output`, `cached` and `cache_write` tokens the provider reported for this \
599         session's inference calls, and `unreported_calls`, the calls it reported none for. \
600         {SPEND_AND_VALUES}"
601    )
602}
603
604/// `session-status`'s description under `transport: process`.
605pub fn description_process() -> String {
606    format!(
607        "Report where this session stands, measured at the moment of the call. Takes no input and \
608         returns one JSON object. {FIELDS} Turns are counted as they close. `context` reads \
609         \"unknown\": the harness keeps its own context. `tokens`: the `input`, `output`, \
610         `cached` and `cache_write` tokens the harness reported for this session's closed turns, \
611         and `unreported_calls`, the turns it reported none for. {SPEND_AND_VALUES}"
612    )
613}
614
615/// `session-status`'s input schema: no input.
616pub const INPUT_SCHEMA: &str = r#"{"type":"object","properties":{}}"#;
617
618#[cfg(test)]
619mod tests {
620    use super::*;
621    use serde_json::json;
622
623    fn usage(
624        input: Option<u64>,
625        output: Option<u64>,
626        cached: Option<u64>,
627        cache_write: Option<u64>,
628    ) -> DriverUsage {
629        DriverUsage {
630            input_tokens: input,
631            output_tokens: output,
632            cached_tokens: cached,
633            cache_write_tokens: cache_write,
634            thinking_tokens: None,
635        }
636    }
637
638    fn no_spend() -> SpendReading {
639        SpendReading {
640            used: 0,
641            ceiling: None,
642            machine: None,
643        }
644    }
645
646    fn report(status: &SessionStatus, now: Instant, spend: SpendReading) -> Value {
647        serde_json::to_value(status.snapshot_at(now, spend)).unwrap()
648    }
649
650    #[test]
651    fn session_status_reports_every_fed_value_on_http() {
652        let started = Instant::now();
653        let wall = SystemTime::UNIX_EPOCH + Duration::from_secs(1_792_497_600);
654        let deadline = SessionDeadline::after(started, wall, 7_200);
655        let status = SessionStatus::new(started, false, Some(20), Some(deadline), 300, None, None);
656        status.task_started("tsk_1", started + Duration::from_secs(44));
657        status.record_inference(
658            1,
659            Some(900),
660            Some(40),
661            Some(&usage(Some(1_000), Some(50), Some(600), Some(100))),
662        );
663        status.record_inference(
664            2,
665            Some(950),
666            Some(30),
667            Some(&usage(Some(1_200), Some(70), Some(0), Some(10))),
668        );
669        status.set_context(
670            61_234,
671            Some(200_000),
672            Some(InputBudget {
673                tokens: 191_808,
674                basis: BudgetBasis::ContextWindowLessOutputCap,
675            }),
676            OccupancyBasis::Estimate,
677        );
678        let now = started + Duration::from_millis(1_834_900);
679        let spend = SpendReading {
680            used: 1_920,
681            ceiling: Some(1_000_000),
682            machine: Some((5_000, 9_000_000)),
683        };
684        assert_eq!(
685            report(&status, now, spend),
686            json!({
687                "time": {"elapsed_secs": 1834, "task_elapsed_secs": 1790,
688                         "deadline": {"at": "2026-10-20T14:00:00Z", "left_secs": 5365}},
689                "turns": {"used": 2, "cap": 20},
690                "keep_working": "none",
691                "done_check": "none",
692                "context": {"used_tokens": 61234, "window_tokens": 200000,
693                            "budget_tokens": 191808,
694                            "budget_basis": "context_window_less_output_cap",
695                            "measured_by": "estimate"},
696                "tokens": {"input": 2200, "output": 120, "cached": 600, "cache_write": 110,
697                           "unreported_calls": 0},
698                "spend": {"tokens": 1920, "ceiling": 1000000,
699                          "machine": {"tokens_today": 5000, "ceiling": 9000000}},
700            })
701        );
702        // The wire text keeps the documented field order.
703        let text = serde_json::to_string(&status.snapshot_at(now, spend)).unwrap();
704        let order: Vec<usize> = [
705            "\"time\"",
706            "\"turns\"",
707            "\"keep_working\"",
708            "\"done_check\"",
709            "\"context\"",
710            "\"tokens\"",
711            "\"spend\"",
712        ]
713        .iter()
714        .map(|key| text.find(key).unwrap())
715        .collect();
716        assert!(order.windows(2).all(|pair| pair[0] < pair[1]), "{text}");
717    }
718
719    #[test]
720    fn session_status_says_none_for_unset_limits_and_unknown_for_missing_counts() {
721        let started = Instant::now();
722        let status = SessionStatus::new(started, false, None, None, 0, None, None);
723        assert_eq!(
724            report(&status, started, no_spend()),
725            json!({
726                "time": {"elapsed_secs": 0, "task_elapsed_secs": "unknown", "deadline": "none"},
727                "turns": {"used": 0, "cap": "none"},
728                "keep_working": "none",
729                "done_check": "none",
730                "context": "unknown",
731                "tokens": {"input": "unknown", "output": "unknown", "cached": "unknown",
732                           "cache_write": "unknown", "unreported_calls": 0},
733                "spend": {"tokens": 0, "ceiling": "none", "machine": "none"},
734            })
735        );
736
737        status.task_started("tsk_1", started);
738        // A record with no usage is an unreported call; one with only some members leaves the
739        // others unknown.
740        status.record_inference(1, Some(900), Some(40), None);
741        status.record_inference(
742            2,
743            Some(900),
744            Some(40),
745            Some(&usage(Some(7), None, None, None)),
746        );
747        status.set_context(940, None, None, OccupancyBasis::Estimate);
748        let value = report(&status, started + Duration::from_secs(3), no_spend());
749        assert_eq!(
750            value["tokens"],
751            json!({"input": 7, "output": "unknown", "cached": "unknown",
752                   "cache_write": "unknown", "unreported_calls": 1})
753        );
754        assert_eq!(
755            value["context"],
756            json!({"used_tokens": 940, "window_tokens": "none", "budget_tokens": "none",
757                   "budget_basis": "none", "measured_by": "estimate"})
758        );
759        assert_eq!(value["turns"]["used"], 2);
760        assert_eq!(value["time"]["task_elapsed_secs"], 3);
761
762        // The provider's count of the last call, when it reported one, is named as such.
763        status.set_context(1_010, None, None, OccupancyBasis::Actual);
764        assert_eq!(
765            report(&status, started, no_spend())["context"],
766            json!({"used_tokens": 1010, "window_tokens": "none", "budget_tokens": "none",
767                   "budget_basis": "none", "measured_by": "actual"})
768        );
769    }
770
771    #[test]
772    fn session_status_under_process_reads_the_harness_counts_and_no_context() {
773        let started = Instant::now();
774        let status = SessionStatus::new(started, true, Some(10), None, 0, None, None);
775        status.task_started("tsk_1", started);
776        status.record_inference(
777            1,
778            Some(3_000),
779            Some(200),
780            Some(&usage(None, None, Some(2_500), Some(400))),
781        );
782        status.record_inference(2, None, None, None);
783        // A process session is never fed a context reading, and one fed anyway is not reported.
784        status.set_context(1, Some(2), None, OccupancyBasis::Estimate);
785        let value = report(&status, started, no_spend());
786        assert_eq!(value["context"], json!("unknown"));
787        assert_eq!(
788            value["tokens"],
789            json!({"input": 3000, "output": 200, "cached": 2500, "cache_write": 400,
790                   "unreported_calls": 1})
791        );
792        assert_eq!(value["turns"], json!({"used": 2, "cap": 10}));
793    }
794
795    #[test]
796    fn session_status_restarts_the_task_clock_and_turns_at_each_task() {
797        let started = Instant::now();
798        let status = SessionStatus::new(started, false, None, None, 0, None, None);
799        status.task_started("tsk_1", started);
800        status.record_inference(1, None, None, None);
801        status.task_ended();
802        assert_eq!(status.task_id(), None);
803        status.task_started("tsk_2", started + Duration::from_secs(10));
804        assert_eq!(status.task_id().as_deref(), Some("tsk_2"));
805        let value = report(&status, started + Duration::from_secs(12), no_spend());
806        assert_eq!(value["turns"]["used"], 0);
807        assert_eq!(value["time"]["task_elapsed_secs"], 2);
808        assert_eq!(value["time"]["elapsed_secs"], 12);
809        // Tokens are the session's, not the task's.
810        assert_eq!(value["tokens"]["unreported_calls"], 1);
811    }
812
813    /// `keep_working` counts the active task's continuations against the limit, from zero at each
814    /// task's start.
815    #[test]
816    fn session_status_counts_keep_working_per_task_against_the_limit() {
817        let started = Instant::now();
818        let status = SessionStatus::new(started, false, None, None, 0, Some(3), None);
819        assert_eq!(
820            report(&status, started, no_spend())["keep_working"],
821            json!({"used": 0, "left": 3})
822        );
823        status.task_started("tsk_1", started);
824        assert_eq!(
825            report(&status, started, no_spend())["keep_working"],
826            json!({"used": 0, "left": 3})
827        );
828        status.keep_working_sent(1);
829        status.keep_working_sent(2);
830        assert_eq!(
831            report(&status, started, no_spend())["keep_working"],
832            json!({"used": 2, "left": 1})
833        );
834        status.task_ended();
835        status.task_started("tsk_2", started);
836        assert_eq!(
837            report(&status, started, no_spend())["keep_working"],
838            json!({"used": 0, "left": 3})
839        );
840    }
841
842    #[test]
843    fn session_status_default_notice_secs_is_five_minutes_a_tenth_or_one_second() {
844        assert_eq!(default_notice_secs(7_200), 300);
845        assert_eq!(default_notice_secs(600), 60);
846        assert_eq!(default_notice_secs(5), 1);
847        assert_eq!(default_notice_secs(1), 1);
848        assert_eq!(notice_secs(Some(0), 7_200), 0);
849        assert_eq!(notice_secs(Some(90), 150), 90);
850        assert_eq!(notice_secs(None, 150), 15);
851    }
852
853    #[test]
854    fn session_status_take_due_notice_fires_once_inside_the_margin() {
855        let started = Instant::now();
856        let deadline = SessionDeadline::after(started, SystemTime::now(), 30);
857        let status = SessionStatus::new(started, false, None, Some(deadline), 20, None, None);
858        assert_eq!(
859            status.take_due_notice(started + Duration::from_secs(9)),
860            None
861        );
862        let notice = status
863            .take_due_notice(started + Duration::from_secs(10))
864            .expect("due once 20 seconds are left");
865        assert_eq!(notice.left_secs, 20);
866        assert_eq!(notice.notice_secs, 20);
867        assert_eq!(
868            notice.text(),
869            format!(
870                "Session deadline: the runtime ends this session at {}, in 20 seconds.",
871                deadline.at_rfc3339()
872            )
873        );
874        assert_eq!(
875            status.take_due_notice(started + Duration::from_secs(11)),
876            None
877        );
878        assert_eq!(
879            status.take_due_notice(started + Duration::from_secs(40)),
880            None
881        );
882    }
883
884    #[test]
885    fn session_status_take_due_notice_never_fires_at_zero_or_without_a_deadline() {
886        let started = Instant::now();
887        let deadline = SessionDeadline::after(started, SystemTime::now(), 30);
888        let silent = SessionStatus::new(started, false, None, Some(deadline), 0, None, None);
889        assert_eq!(
890            silent.take_due_notice(started + Duration::from_secs(29)),
891            None
892        );
893        let unbounded = SessionStatus::new(started, false, None, None, 300, None, None);
894        assert_eq!(unbounded.take_due_notice(started), None);
895        // A deadline already past still gives its one notice, saying no time is left.
896        let past = SessionStatus::new(started, false, None, Some(deadline), 5, None, None);
897        let notice = past
898            .take_due_notice(started + Duration::from_secs(31))
899            .unwrap();
900        assert_eq!(notice.left_secs, 0);
901    }
902
903    #[test]
904    fn session_status_deadline_spells_at_in_utc_to_the_second() {
905        let started = Instant::now();
906        let wall = SystemTime::UNIX_EPOCH + Duration::from_millis(1_792_497_600_750);
907        let deadline = SessionDeadline::after(started, wall, 600);
908        assert_eq!(deadline.at_rfc3339(), "2026-10-20T12:10:00Z");
909        assert_eq!(deadline.secs(), 600);
910        assert_eq!(
911            deadline.left(started + Duration::from_secs(700)),
912            Duration::ZERO
913        );
914    }
915
916    #[test]
917    fn session_status_deadline_beyond_the_bound_is_taken_as_the_bound() {
918        let started = Instant::now();
919        let deadline = SessionDeadline::after(started, SystemTime::now(), u64::MAX);
920        assert_eq!(deadline.secs(), MAX_DEADLINE_SECS);
921        assert_eq!(
922            deadline.left(started),
923            Duration::from_secs(MAX_DEADLINE_SECS)
924        );
925        assert!(deadline.at_rfc3339().ends_with('Z'));
926    }
927
928    #[test]
929    fn session_status_until_leaves_the_difference_zero_when_past_and_the_bound_at_most() {
930        let started = Instant::now();
931        let wall = SystemTime::UNIX_EPOCH + Duration::from_secs(1_792_497_600);
932        let ahead = SessionDeadline::until(started, wall, wall + Duration::from_secs(90));
933        assert_eq!(ahead.left(started), Duration::from_secs(90));
934        assert_eq!(ahead.secs(), 90);
935        assert_eq!(ahead.source(), DeadlineSource::Inherited);
936        assert_eq!(ahead.at_rfc3339(), "2026-10-20T12:01:30Z");
937
938        let past = SessionDeadline::until(started, wall, wall - Duration::from_secs(5));
939        assert_eq!(past.left(started), Duration::ZERO);
940        assert_eq!(past.secs(), 0);
941        // A passed moment still spells the moment it was.
942        assert_eq!(past.at_rfc3339(), "2026-10-20T11:59:55Z");
943
944        let far = SessionDeadline::until(
945            started,
946            wall,
947            wall + Duration::from_secs(MAX_DEADLINE_SECS + 10),
948        );
949        assert_eq!(far.left(started), Duration::from_secs(MAX_DEADLINE_SECS));
950        assert_eq!(far.secs(), MAX_DEADLINE_SECS);
951    }
952
953    #[test]
954    fn session_status_until_rounds_secs_up() {
955        let started = Instant::now();
956        let wall = SystemTime::UNIX_EPOCH + Duration::from_secs(1_792_497_600);
957        let deadline = SessionDeadline::until(started, wall, wall + Duration::from_millis(29_001));
958        assert_eq!(deadline.secs(), 30);
959        assert_eq!(deadline.left(started), Duration::from_millis(29_001));
960        let whole = SessionDeadline::until(started, wall, wall + Duration::from_secs(29));
961        assert_eq!(whole.secs(), 29);
962        let instant = SessionDeadline::until(started, wall, wall + Duration::from_nanos(1));
963        assert_eq!(instant.secs(), 1);
964    }
965
966    #[test]
967    fn session_status_unix_millis_round_trip_to_the_same_second() {
968        let started = Instant::now();
969        let wall = SystemTime::UNIX_EPOCH + Duration::from_nanos(1_792_497_600_999_999_999);
970        let own = SessionDeadline::after(started, wall, 60);
971        assert_eq!(own.source(), DeadlineSource::Own);
972        let millis = own.at_unix_millis();
973        assert_eq!(millis, 1_792_497_660_999);
974        // A child that starts 3.5 s later inherits the same moment, to the millisecond.
975        let child_started = started + Duration::from_millis(3_500);
976        let child_wall = wall + Duration::from_millis(3_500);
977        let inherited = SessionDeadline::from_unix_millis(child_started, child_wall, millis);
978        assert_eq!(inherited.at_rfc3339(), own.at_rfc3339());
979        assert_eq!(inherited.at_unix_millis(), millis);
980        assert_eq!(inherited.secs(), 57);
981        assert!(own.left(child_started) - inherited.left(child_started) < Duration::from_millis(1));
982
983        // A moment the wall clock cannot hold is the bound, not a panic.
984        let overflow = SessionDeadline::from_unix_millis(started, wall, u64::MAX);
985        assert_eq!(overflow.secs(), MAX_DEADLINE_SECS);
986        assert_eq!(
987            overflow.left(started),
988            Duration::from_secs(MAX_DEADLINE_SECS)
989        );
990        let epoch = SessionDeadline::from_unix_millis(started, wall, 0);
991        assert_eq!(epoch.left(started), Duration::ZERO);
992    }
993
994    #[test]
995    fn session_status_earliest_keeps_the_earlier_and_the_inherited_on_a_tie() {
996        let started = Instant::now();
997        let wall = SystemTime::UNIX_EPOCH + Duration::from_secs(1_792_497_600);
998        let own = SessionDeadline::after(started, wall, 60);
999        let sooner = SessionDeadline::until(started, wall, wall + Duration::from_secs(30));
1000        let later = SessionDeadline::until(started, wall, wall + Duration::from_secs(90));
1001        let tie = SessionDeadline::until(started, wall, wall + Duration::from_secs(60));
1002
1003        assert_eq!(
1004            SessionDeadline::earliest(Some(own), Some(sooner)),
1005            Some(sooner)
1006        );
1007        assert_eq!(SessionDeadline::earliest(Some(own), Some(later)), Some(own));
1008        let tied = SessionDeadline::earliest(Some(own), Some(tie)).unwrap();
1009        assert_eq!(tied.source(), DeadlineSource::Inherited);
1010        assert_eq!(tied.secs(), 60);
1011        assert_eq!(SessionDeadline::earliest(Some(own), None), Some(own));
1012        assert_eq!(SessionDeadline::earliest(None, Some(later)), Some(later));
1013        assert_eq!(SessionDeadline::earliest(None, None), None);
1014        assert_eq!(DeadlineSource::Own.as_str(), "own");
1015        assert_eq!(DeadlineSource::Inherited.as_str(), "inherited");
1016    }
1017
1018    /// `done_check` counts the active task's reopens by `lifecycle.done_check` against the limit,
1019    /// from zero at each task's start.
1020    #[test]
1021    fn session_status_counts_done_check_reopens_per_task_against_the_limit() {
1022        let started = Instant::now();
1023        let status = SessionStatus::new(started, false, None, None, 0, None, Some(3));
1024        assert_eq!(
1025            report(&status, started, no_spend())["done_check"],
1026            json!({"used": 0, "left": 3})
1027        );
1028        assert_eq!(report(&status, started, no_spend())["keep_working"], "none");
1029        status.task_started("tsk_1", started);
1030        status.done_check_reopened(1);
1031        status.done_check_reopened(2);
1032        assert_eq!(
1033            report(&status, started, no_spend())["done_check"],
1034            json!({"used": 2, "left": 1})
1035        );
1036        status.task_ended();
1037        status.task_started("tsk_2", started);
1038        assert_eq!(
1039            report(&status, started, no_spend())["done_check"],
1040            json!({"used": 0, "left": 3})
1041        );
1042    }
1043
1044    #[test]
1045    fn session_status_descriptions_name_every_field_and_no_timing_advice() {
1046        for description in [description_http(), description_process()] {
1047            for field in [
1048                "elapsed_secs",
1049                "task_elapsed_secs",
1050                "deadline",
1051                "left_secs",
1052                "`turns`",
1053                "`cap`",
1054                "`keep_working`",
1055                "`done_check`",
1056                "`used`",
1057                "`left`",
1058                "`context`",
1059                "`tokens`",
1060                "`cached`",
1061                "`cache_write`",
1062                "unreported_calls",
1063                "`spend`",
1064                "`ceiling`",
1065                "`machine`",
1066                "tokens_today",
1067                "No money amount is kept",
1068                "\"none\"",
1069                "\"unknown\"",
1070            ] {
1071                assert!(
1072                    description.contains(field),
1073                    "{field} missing: {description}"
1074                );
1075            }
1076            let lower = description.to_lowercase();
1077            for advice in ["when", "should", "call this", "periodically"] {
1078                assert!(!lower.contains(advice), "{advice}: {description}");
1079            }
1080        }
1081        assert!(description_http().contains("measured_by"));
1082        assert!(description_http().contains("budget_tokens"));
1083        assert!(description_http().contains("budget_basis"));
1084        assert!(description_http().contains("\"actual\""));
1085        assert!(description_process().contains("the harness keeps its own context"));
1086    }
1087}