1use 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::process_driver::QuotaWindow;
19use crate::spend::SpendMeter;
20
21pub const SESSION_STATUS_TOOL: &str = "session-status";
23
24const NONE: &str = "none";
26
27const UNKNOWN: &str = "unknown";
29
30const DEFAULT_NOTICE_CEILING_SECS: u64 = 300;
32
33pub const MAX_DEADLINE_SECS: u64 = 315_360_000;
36
37pub const INHERITED_DEADLINE_FLAG: &str = "--inherited-deadline";
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum DeadlineSource {
44 Own,
46 Inherited,
48}
49
50impl DeadlineSource {
51 pub const fn as_str(self) -> &'static str {
53 match self {
54 Self::Own => "own",
55 Self::Inherited => "inherited",
56 }
57 }
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub struct SessionDeadline {
68 at: Instant,
69 at_wall: SystemTime,
70 secs: u64,
71 source: DeadlineSource,
72}
73
74impl SessionDeadline {
75 pub fn after(started: Instant, started_wall: SystemTime, secs: u64) -> Self {
78 let secs = secs.min(MAX_DEADLINE_SECS);
79 let length = Duration::from_secs(secs);
80 Self {
81 at: started + length,
82 at_wall: started_wall + length,
83 secs,
84 source: DeadlineSource::Own,
85 }
86 }
87
88 pub fn until(started: Instant, started_wall: SystemTime, at_wall: SystemTime) -> Self {
92 let bound = Duration::from_secs(MAX_DEADLINE_SECS);
93 let left = at_wall
94 .duration_since(started_wall)
95 .unwrap_or(Duration::ZERO);
96 let (left, at_wall) = if left > bound {
97 (bound, started_wall + bound)
98 } else {
99 (left, at_wall)
100 };
101 let secs = left.as_secs() + u64::from(left.subsec_nanos() > 0);
102 Self {
103 at: started + left,
104 at_wall,
105 secs,
106 source: DeadlineSource::Inherited,
107 }
108 }
109
110 pub fn from_unix_millis(started: Instant, started_wall: SystemTime, millis: u64) -> Self {
114 let bound = started_wall + Duration::from_secs(MAX_DEADLINE_SECS);
115 let at_wall = SystemTime::UNIX_EPOCH
116 .checked_add(Duration::from_millis(millis))
117 .unwrap_or(bound);
118 Self::until(started, started_wall, at_wall)
119 }
120
121 pub fn at_unix_millis(&self) -> u64 {
125 self.at_wall
126 .duration_since(SystemTime::UNIX_EPOCH)
127 .map_or(0, |since| {
128 u64::try_from(since.as_millis()).unwrap_or(u64::MAX)
129 })
130 }
131
132 pub fn earliest(own: Option<Self>, inherited: Option<Self>) -> Option<Self> {
135 match (own, inherited) {
136 (Some(own), Some(inherited)) if own.at < inherited.at => Some(own),
137 (_, Some(inherited)) => Some(inherited),
138 (own, None) => own,
139 }
140 }
141
142 pub fn secs(&self) -> u64 {
145 self.secs
146 }
147
148 pub fn source(&self) -> DeadlineSource {
150 self.source
151 }
152
153 pub fn at_rfc3339(&self) -> String {
155 chrono::DateTime::<chrono::Utc>::from(self.at_wall)
156 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
157 }
158
159 pub fn left(&self, now: Instant) -> Duration {
161 self.at.saturating_duration_since(now)
162 }
163
164 pub(crate) fn instant(&self) -> Instant {
166 self.at
167 }
168}
169
170pub fn default_notice_secs(deadline_secs: u64) -> u64 {
174 (deadline_secs / 10).clamp(1, DEFAULT_NOTICE_CEILING_SECS)
175}
176
177pub fn notice_secs(declared: Option<u64>, deadline_secs: u64) -> u64 {
180 declared.unwrap_or_else(|| default_notice_secs(deadline_secs))
181}
182
183#[derive(Debug, Clone, PartialEq, Eq)]
185pub(crate) struct DeadlineNotice {
186 pub(crate) at: String,
188 pub(crate) left_secs: u64,
190 pub(crate) notice_secs: u64,
192}
193
194impl DeadlineNotice {
195 pub(crate) fn text(&self) -> String {
197 format!(
198 "Session deadline: the runtime ends this session at {}, in {} seconds.",
199 self.at, self.left_secs
200 )
201 }
202}
203
204#[derive(Debug, Clone, Copy, PartialEq, Eq)]
206pub(crate) struct SpendReading {
207 pub(crate) used: u64,
209 pub(crate) ceiling: Option<u64>,
211 pub(crate) machine: Option<(u64, u64)>,
214}
215
216impl SpendReading {
217 pub(crate) fn of(meter: &SpendMeter) -> Self {
219 Self {
220 used: meter.used(),
221 ceiling: meter.session_ceiling(),
222 machine: meter.machine_reading(),
223 }
224 }
225}
226
227#[derive(Debug, Clone, Copy, PartialEq, Eq)]
229struct ContextReading {
230 used_tokens: u32,
231 window_tokens: Option<u32>,
232 budget_tokens: Option<u32>,
233 budget_basis: BudgetBasis,
234 measured_by: OccupancyBasis,
235}
236
237#[derive(Debug, Default)]
239struct Fed {
240 task: Option<(String, Instant)>,
242 task_turns: u32,
244 keep_working_used: u32,
247 done_check_reopens: u32,
250 input: Option<u64>,
251 output: Option<u64>,
252 cached: Option<u64>,
253 cache_write: Option<u64>,
254 unreported_calls: u64,
256 context: Option<ContextReading>,
257 quota: Vec<QuotaWindow>,
260}
261
262#[derive(Debug)]
264pub(crate) struct SessionStatus {
265 started: Instant,
266 harness_transport: bool,
268 turn_cap: Option<u32>,
270 deadline: Option<SessionDeadline>,
271 notice_secs: u64,
273 keep_working_limit: Option<u32>,
275 done_check_limit: Option<u32>,
277 notice_taken: AtomicBool,
278 fed: Mutex<Fed>,
279}
280
281impl SessionStatus {
282 pub(crate) fn new(
287 started: Instant,
288 harness_transport: bool,
289 turn_cap: Option<u32>,
290 deadline: Option<SessionDeadline>,
291 notice_secs: u64,
292 keep_working_limit: Option<u32>,
293 done_check_limit: Option<u32>,
294 ) -> Self {
295 Self {
296 started,
297 harness_transport,
298 turn_cap,
299 deadline,
300 notice_secs,
301 keep_working_limit,
302 done_check_limit,
303 notice_taken: AtomicBool::new(false),
304 fed: Mutex::new(Fed::default()),
305 }
306 }
307
308 fn fed(&self) -> std::sync::MutexGuard<'_, Fed> {
309 self.fed
311 .lock()
312 .unwrap_or_else(std::sync::PoisonError::into_inner)
313 }
314
315 pub(crate) fn deadline(&self) -> Option<SessionDeadline> {
317 self.deadline
318 }
319
320 pub(crate) fn notice_secs(&self) -> u64 {
322 self.notice_secs
323 }
324
325 pub(crate) fn task_started(&self, task_id: &str, at: Instant) {
327 let mut fed = self.fed();
328 fed.task = Some((task_id.to_string(), at));
329 fed.task_turns = 0;
330 fed.keep_working_used = 0;
331 fed.done_check_reopens = 0;
332 }
333
334 pub(crate) fn keep_working_sent(&self, number: u32) {
336 self.fed().keep_working_used = number;
337 }
338
339 pub(crate) fn done_check_reopened(&self, reopens: u32) {
342 self.fed().done_check_reopens = reopens;
343 }
344
345 pub(crate) fn task_ended(&self) {
347 self.fed().task = None;
348 }
349
350 pub(crate) fn task_id(&self) -> Option<String> {
352 self.fed().task.as_ref().map(|(id, _)| id.clone())
353 }
354
355 pub(crate) fn record_inference(
362 &self,
363 task_turns: u32,
364 input_tokens: Option<u64>,
365 output_tokens: Option<u64>,
366 usage: Option<&DriverUsage>,
367 ) {
368 let (input, output) = if self.harness_transport {
369 (input_tokens, output_tokens)
370 } else {
371 (
372 usage.and_then(|u| u.input_tokens),
373 usage.and_then(|u| u.output_tokens),
374 )
375 };
376 let cached = usage.and_then(|u| u.cached_tokens);
377 let cache_write = usage.and_then(|u| u.cache_write_tokens);
378 let mut guard = self.fed();
379 let fed = &mut *guard;
380 fed.task_turns = task_turns;
381 if [input, output, cached, cache_write]
382 .iter()
383 .all(Option::is_none)
384 {
385 fed.unreported_calls = fed.unreported_calls.saturating_add(1);
386 return;
387 }
388 for (sum, count) in [
389 (&mut fed.input, input),
390 (&mut fed.output, output),
391 (&mut fed.cached, cached),
392 (&mut fed.cache_write, cache_write),
393 ] {
394 if let Some(count) = count {
395 *sum = Some(sum.unwrap_or(0).saturating_add(count));
396 }
397 }
398 }
399
400 pub(crate) fn record_quota(&self, windows: &[QuotaWindow]) {
404 let mut fed = self.fed();
405 for window in windows {
406 match fed.quota.iter_mut().find(|held| held.name == window.name) {
407 Some(held) => *held = window.clone(),
408 None => fed.quota.push(window.clone()),
409 }
410 }
411 }
412
413 pub(crate) fn set_context(
418 &self,
419 used_tokens: u32,
420 window_tokens: Option<u32>,
421 budget: Option<InputBudget>,
422 measured_by: OccupancyBasis,
423 ) {
424 self.fed().context = Some(ContextReading {
425 used_tokens,
426 window_tokens,
427 budget_tokens: budget.map(|budget| budget.tokens),
428 budget_basis: budget.map_or(BudgetBasis::None, |budget| budget.basis),
429 measured_by,
430 });
431 }
432
433 pub(crate) fn snapshot(&self, spend: SpendReading) -> StatusReport {
435 self.snapshot_at(Instant::now(), spend)
436 }
437
438 pub(crate) fn snapshot_at(&self, now: Instant, spend: SpendReading) -> StatusReport {
440 let fed = self.fed();
441 let whole_secs = |since: Instant| now.saturating_duration_since(since).as_secs();
442 let time = TimeReport {
443 elapsed_secs: whole_secs(self.started),
444 task_elapsed_secs: fed.task.as_ref().map_or_else(
445 || Value::from(UNKNOWN),
446 |(_, at)| Value::from(whole_secs(*at)),
447 ),
448 deadline: self.deadline.map_or_else(
449 || Value::from(NONE),
450 |deadline| {
451 serde_json::json!({
452 "at": deadline.at_rfc3339(),
453 "left_secs": deadline.left(now).as_secs(),
454 })
455 },
456 ),
457 };
458 let context = match (self.harness_transport, fed.context) {
459 (false, Some(reading)) => serde_json::json!({
460 "used_tokens": reading.used_tokens,
461 "window_tokens": or_none(reading.window_tokens),
462 "budget_tokens": or_none(reading.budget_tokens),
463 "budget_basis": reading.budget_basis.wire_name(),
464 "measured_by": reading.measured_by,
465 }),
466 _ => Value::from(UNKNOWN),
467 };
468 StatusReport {
469 time,
470 turns: TurnsReport {
471 used: fed.task_turns,
472 cap: or_none(self.turn_cap),
473 },
474 keep_working: self.keep_working_limit.map_or_else(
475 || Value::from(NONE),
476 |limit| {
477 serde_json::json!({
478 "used": fed.keep_working_used,
479 "left": limit.saturating_sub(fed.keep_working_used),
480 })
481 },
482 ),
483 done_check: self.done_check_limit.map_or_else(
484 || Value::from(NONE),
485 |limit| {
486 serde_json::json!({
487 "used": fed.done_check_reopens,
488 "left": limit.saturating_sub(fed.done_check_reopens),
489 })
490 },
491 ),
492 context,
493 quota: (self.harness_transport && !fed.quota.is_empty())
494 .then(|| fed.quota.iter().map(QuotaReport::of).collect()),
495 tokens: TokensReport {
496 input: or_unknown(fed.input),
497 output: or_unknown(fed.output),
498 cached: or_unknown(fed.cached),
499 cache_write: or_unknown(fed.cache_write),
500 unreported_calls: fed.unreported_calls,
501 },
502 spend: SpendReport {
503 tokens: spend.used,
504 ceiling: or_none(spend.ceiling),
505 machine: spend.machine.map_or_else(
506 || Value::from(NONE),
507 |(tokens_today, ceiling)| {
508 serde_json::json!({"tokens_today": tokens_today, "ceiling": ceiling})
509 },
510 ),
511 },
512 }
513 }
514
515 pub(crate) fn take_due_notice(&self, now: Instant) -> Option<DeadlineNotice> {
518 let deadline = self.deadline?;
519 if self.notice_secs == 0 {
520 return None;
521 }
522 let left = deadline.left(now);
523 if left > Duration::from_secs(self.notice_secs) {
524 return None;
525 }
526 if self.notice_taken.swap(true, Ordering::AcqRel) {
527 return None;
528 }
529 Some(DeadlineNotice {
530 at: deadline.at_rfc3339(),
531 left_secs: left.as_secs(),
532 notice_secs: self.notice_secs,
533 })
534 }
535}
536
537fn or_none<T: Into<Value>>(value: Option<T>) -> Value {
538 value.map_or_else(|| Value::from(NONE), Into::into)
539}
540
541fn or_unknown<T: Into<Value>>(value: Option<T>) -> Value {
542 value.map_or_else(|| Value::from(UNKNOWN), Into::into)
543}
544
545#[derive(Debug, Clone, PartialEq, Serialize)]
547pub(crate) struct StatusReport {
548 time: TimeReport,
549 turns: TurnsReport,
550 keep_working: Value,
552 done_check: Value,
554 context: Value,
556 #[serde(skip_serializing_if = "Option::is_none")]
559 quota: Option<Vec<QuotaReport>>,
560 tokens: TokensReport,
561 spend: SpendReport,
562}
563
564#[derive(Debug, Clone, PartialEq, Serialize)]
567struct QuotaReport {
568 name: String,
569 #[serde(skip_serializing_if = "Option::is_none")]
570 used: Option<f64>,
571 #[serde(skip_serializing_if = "Option::is_none")]
573 resets_at: Option<String>,
574 #[serde(skip_serializing_if = "Option::is_none")]
575 window_minutes: Option<u64>,
576 #[serde(skip_serializing_if = "Option::is_none")]
577 state: Option<&'static str>,
578}
579
580impl QuotaReport {
581 fn of(window: &QuotaWindow) -> Self {
582 Self {
583 name: window.name.clone(),
584 used: window.used,
585 resets_at: window.resets_at.and_then(rfc3339_of_unix_millis),
586 window_minutes: window.window_minutes,
587 state: window
588 .state
589 .map(crate::process_driver::QuotaState::wire_name),
590 }
591 }
592}
593
594fn rfc3339_of_unix_millis(millis: u64) -> Option<String> {
597 let millis = i64::try_from(millis).ok()?;
598 chrono::DateTime::<chrono::Utc>::from_timestamp_millis(millis)
599 .map(|at| at.to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
600}
601
602#[derive(Debug, Clone, PartialEq, Serialize)]
603struct TimeReport {
604 elapsed_secs: u64,
605 task_elapsed_secs: Value,
606 deadline: Value,
607}
608
609#[derive(Debug, Clone, PartialEq, Serialize)]
610struct TurnsReport {
611 used: u32,
612 cap: Value,
613}
614
615#[derive(Debug, Clone, PartialEq, Serialize)]
616struct TokensReport {
617 input: Value,
618 output: Value,
619 cached: Value,
620 cache_write: Value,
621 unreported_calls: u64,
622}
623
624#[derive(Debug, Clone, PartialEq, Serialize)]
625struct SpendReport {
626 tokens: u64,
627 ceiling: Value,
628 machine: Value,
629}
630
631const FIELDS: &str = "This session's state now, as one JSON object; no input. time: \
633 elapsed_secs (session), task_elapsed_secs (current task), deadline {at: RFC 3339 UTC \
634 session end, left_secs to it}. turns: used, cap (this task's inference calls). \
635 keep_working: used, left (lifecycle.keep_working messages that kept this task working \
636 past a turn end). done_check: used, left (lifecycle.done_check reopens of this task on \
637 a failed command).";
638
639const SPEND_AND_VALUES: &str = "spend: tokens (this session's, as the spend ceilings count \
641 them), session ceiling, machine {tokens_today, daily ceiling}; counted in tokens, no \
642 money amount is kept. Unset limit: \"none\". Unknown count: \"unknown\". Seconds are whole.";
643
644pub fn description_http() -> String {
647 format!(
648 "{FIELDS} context: used_tokens (conversation size at last count), window_tokens \
649 (model context window), budget_tokens (most input tokens per call; compaction measures \
650 against it), budget_basis (\"max_input_tokens\": provider input ceiling; \
651 \"context_window_less_output_cap\": window less output cap), measured_by (\"actual\": \
652 provider's count of the last call; \"estimate\": runtime's count). tokens: \
653 provider-reported input, output, cached, cache_write for this session's calls, \
654 and unreported_calls it reported none for. {SPEND_AND_VALUES}"
655 )
656}
657
658pub fn description_process() -> String {
661 format!(
662 "{FIELDS} Turns are counted as they close. context: \"unknown\"; the harness keeps its \
663 own context. tokens: harness-reported input, output, cached, cache_write for \
664 this session's closed turns, and unreported_calls, turns it reported none for. \
665 quota, once the harness reports one: its latest subscription windows, name, used (0-1), \
666 resets_at, window_minutes, state; unreported members are left out. {SPEND_AND_VALUES}"
667 )
668}
669
670pub const INPUT_SCHEMA: &str = r#"{"type":"object","properties":{}}"#;
672
673#[cfg(test)]
674mod tests {
675 use super::*;
676 use crate::process_driver::QuotaState;
677 use serde_json::json;
678
679 fn usage(
680 input: Option<u64>,
681 output: Option<u64>,
682 cached: Option<u64>,
683 cache_write: Option<u64>,
684 ) -> DriverUsage {
685 DriverUsage {
686 input_tokens: input,
687 output_tokens: output,
688 cached_tokens: cached,
689 cache_write_tokens: cache_write,
690 thinking_tokens: None,
691 }
692 }
693
694 fn no_spend() -> SpendReading {
695 SpendReading {
696 used: 0,
697 ceiling: None,
698 machine: None,
699 }
700 }
701
702 fn report(status: &SessionStatus, now: Instant, spend: SpendReading) -> Value {
703 serde_json::to_value(status.snapshot_at(now, spend)).unwrap()
704 }
705
706 #[test]
709 fn session_status_names_no_cpu_limit_and_no_shell_grace() {
710 let started = Instant::now();
711 let status = SessionStatus::new(started, false, Some(20), None, 300, None, None);
712 let text = serde_json::to_string(&status.snapshot_at(started, no_spend())).unwrap();
713 for description in [description_http(), description_process()] {
714 for named in [text.as_str(), description.as_str()] {
715 let lower = named.to_lowercase();
716 for word in ["cpu", "grace", "background"] {
717 assert!(!lower.contains(word), "{word}: {named}");
718 }
719 }
720 }
721 }
722
723 #[test]
724 fn session_status_reports_every_fed_value_on_http() {
725 let started = Instant::now();
726 let wall = SystemTime::UNIX_EPOCH + Duration::from_secs(1_792_497_600);
727 let deadline = SessionDeadline::after(started, wall, 7_200);
728 let status = SessionStatus::new(started, false, Some(20), Some(deadline), 300, None, None);
729 status.task_started("tsk_1", started + Duration::from_secs(44));
730 status.record_inference(
731 1,
732 Some(900),
733 Some(40),
734 Some(&usage(Some(1_000), Some(50), Some(600), Some(100))),
735 );
736 status.record_inference(
737 2,
738 Some(950),
739 Some(30),
740 Some(&usage(Some(1_200), Some(70), Some(0), Some(10))),
741 );
742 status.set_context(
743 61_234,
744 Some(200_000),
745 Some(InputBudget {
746 tokens: 191_808,
747 basis: BudgetBasis::ContextWindowLessOutputCap,
748 }),
749 OccupancyBasis::Estimate,
750 );
751 let now = started + Duration::from_millis(1_834_900);
752 let spend = SpendReading {
753 used: 1_920,
754 ceiling: Some(1_000_000),
755 machine: Some((5_000, 9_000_000)),
756 };
757 assert_eq!(
758 report(&status, now, spend),
759 json!({
760 "time": {"elapsed_secs": 1834, "task_elapsed_secs": 1790,
761 "deadline": {"at": "2026-10-20T14:00:00Z", "left_secs": 5365}},
762 "turns": {"used": 2, "cap": 20},
763 "keep_working": "none",
764 "done_check": "none",
765 "context": {"used_tokens": 61234, "window_tokens": 200000,
766 "budget_tokens": 191808,
767 "budget_basis": "context_window_less_output_cap",
768 "measured_by": "estimate"},
769 "tokens": {"input": 2200, "output": 120, "cached": 600, "cache_write": 110,
770 "unreported_calls": 0},
771 "spend": {"tokens": 1920, "ceiling": 1000000,
772 "machine": {"tokens_today": 5000, "ceiling": 9000000}},
773 })
774 );
775 let text = serde_json::to_string(&status.snapshot_at(now, spend)).unwrap();
777 let order: Vec<usize> = [
778 "\"time\"",
779 "\"turns\"",
780 "\"keep_working\"",
781 "\"done_check\"",
782 "\"context\"",
783 "\"tokens\"",
784 "\"spend\"",
785 ]
786 .iter()
787 .map(|key| text.find(key).unwrap())
788 .collect();
789 assert!(order.windows(2).all(|pair| pair[0] < pair[1]), "{text}");
790 }
791
792 #[test]
793 fn session_status_says_none_for_unset_limits_and_unknown_for_missing_counts() {
794 let started = Instant::now();
795 let status = SessionStatus::new(started, false, None, None, 0, None, None);
796 assert_eq!(
797 report(&status, started, no_spend()),
798 json!({
799 "time": {"elapsed_secs": 0, "task_elapsed_secs": "unknown", "deadline": "none"},
800 "turns": {"used": 0, "cap": "none"},
801 "keep_working": "none",
802 "done_check": "none",
803 "context": "unknown",
804 "tokens": {"input": "unknown", "output": "unknown", "cached": "unknown",
805 "cache_write": "unknown", "unreported_calls": 0},
806 "spend": {"tokens": 0, "ceiling": "none", "machine": "none"},
807 })
808 );
809
810 status.task_started("tsk_1", started);
811 status.record_inference(1, Some(900), Some(40), None);
814 status.record_inference(
815 2,
816 Some(900),
817 Some(40),
818 Some(&usage(Some(7), None, None, None)),
819 );
820 status.set_context(940, None, None, OccupancyBasis::Estimate);
821 let value = report(&status, started + Duration::from_secs(3), no_spend());
822 assert_eq!(
823 value["tokens"],
824 json!({"input": 7, "output": "unknown", "cached": "unknown",
825 "cache_write": "unknown", "unreported_calls": 1})
826 );
827 assert_eq!(
828 value["context"],
829 json!({"used_tokens": 940, "window_tokens": "none", "budget_tokens": "none",
830 "budget_basis": "none", "measured_by": "estimate"})
831 );
832 assert_eq!(value["turns"]["used"], 2);
833 assert_eq!(value["time"]["task_elapsed_secs"], 3);
834
835 status.set_context(1_010, None, None, OccupancyBasis::Actual);
837 assert_eq!(
838 report(&status, started, no_spend())["context"],
839 json!({"used_tokens": 1010, "window_tokens": "none", "budget_tokens": "none",
840 "budget_basis": "none", "measured_by": "actual"})
841 );
842 }
843
844 #[test]
845 fn session_status_under_process_reads_the_harness_counts_and_no_context() {
846 let started = Instant::now();
847 let status = SessionStatus::new(started, true, Some(10), None, 0, None, None);
848 status.task_started("tsk_1", started);
849 status.record_inference(
850 1,
851 Some(3_000),
852 Some(200),
853 Some(&usage(None, None, Some(2_500), Some(400))),
854 );
855 status.record_inference(2, None, None, None);
856 status.set_context(1, Some(2), None, OccupancyBasis::Estimate);
858 let value = report(&status, started, no_spend());
859 assert_eq!(value["context"], json!("unknown"));
860 assert_eq!(
861 value["tokens"],
862 json!({"input": 3000, "output": 200, "cached": 2500, "cache_write": 400,
863 "unreported_calls": 1})
864 );
865 assert_eq!(value["turns"], json!({"used": 2, "cap": 10}));
866 }
867
868 fn window(
869 name: &str,
870 used: Option<f64>,
871 resets_at: Option<u64>,
872 window_minutes: Option<u64>,
873 state: Option<QuotaState>,
874 ) -> QuotaWindow {
875 QuotaWindow {
876 name: name.to_string(),
877 used,
878 resets_at,
879 window_minutes,
880 state,
881 }
882 }
883
884 #[test]
887 fn session_status_quota_keeps_the_latest_window_per_name() {
888 let started = Instant::now();
889 let status = SessionStatus::new(started, true, None, None, 0, None, None);
890 assert!(report(&status, started, no_spend()).get("quota").is_none());
891 status.record_quota(&[
892 window(
893 "five-hour",
894 Some(0.42),
895 Some(1_792_497_600_999),
896 Some(300),
897 Some(QuotaState::Ok),
898 ),
899 window("seven-day", Some(0.1), None, Some(10_080), None),
900 ]);
901 assert_eq!(
902 report(&status, started, no_spend())["quota"],
903 json!([
904 {"name": "five-hour", "used": 0.42, "resets_at": "2026-10-20T12:00:00Z",
905 "window_minutes": 300, "state": "ok"},
906 {"name": "seven-day", "used": 0.1, "window_minutes": 10080}
907 ])
908 );
909 status.record_quota(&[
910 window("extra", None, None, None, None),
911 window(
912 "five-hour",
913 Some(0.97),
914 None,
915 None,
916 Some(QuotaState::Warning),
917 ),
918 ]);
919 assert_eq!(
920 report(&status, started, no_spend())["quota"],
921 json!([
922 {"name": "five-hour", "used": 0.97, "state": "warning"},
923 {"name": "seven-day", "used": 0.1, "window_minutes": 10080},
924 {"name": "extra"}
925 ])
926 );
927 }
928
929 #[test]
931 fn session_status_quota_is_never_on_http() {
932 let started = Instant::now();
933 let status = SessionStatus::new(started, false, None, None, 0, None, None);
934 status.record_quota(&[window("five-hour", Some(1.0), None, None, None)]);
935 assert!(report(&status, started, no_spend()).get("quota").is_none());
936 }
937
938 #[test]
939 fn session_status_restarts_the_task_clock_and_turns_at_each_task() {
940 let started = Instant::now();
941 let status = SessionStatus::new(started, false, None, None, 0, None, None);
942 status.task_started("tsk_1", started);
943 status.record_inference(1, None, None, None);
944 status.task_ended();
945 assert_eq!(status.task_id(), None);
946 status.task_started("tsk_2", started + Duration::from_secs(10));
947 assert_eq!(status.task_id().as_deref(), Some("tsk_2"));
948 let value = report(&status, started + Duration::from_secs(12), no_spend());
949 assert_eq!(value["turns"]["used"], 0);
950 assert_eq!(value["time"]["task_elapsed_secs"], 2);
951 assert_eq!(value["time"]["elapsed_secs"], 12);
952 assert_eq!(value["tokens"]["unreported_calls"], 1);
954 }
955
956 #[test]
959 fn session_status_counts_keep_working_per_task_against_the_limit() {
960 let started = Instant::now();
961 let status = SessionStatus::new(started, false, None, None, 0, Some(3), None);
962 assert_eq!(
963 report(&status, started, no_spend())["keep_working"],
964 json!({"used": 0, "left": 3})
965 );
966 status.task_started("tsk_1", started);
967 assert_eq!(
968 report(&status, started, no_spend())["keep_working"],
969 json!({"used": 0, "left": 3})
970 );
971 status.keep_working_sent(1);
972 status.keep_working_sent(2);
973 assert_eq!(
974 report(&status, started, no_spend())["keep_working"],
975 json!({"used": 2, "left": 1})
976 );
977 status.task_ended();
978 status.task_started("tsk_2", started);
979 assert_eq!(
980 report(&status, started, no_spend())["keep_working"],
981 json!({"used": 0, "left": 3})
982 );
983 }
984
985 #[test]
986 fn session_status_default_notice_secs_is_five_minutes_a_tenth_or_one_second() {
987 assert_eq!(default_notice_secs(7_200), 300);
988 assert_eq!(default_notice_secs(600), 60);
989 assert_eq!(default_notice_secs(5), 1);
990 assert_eq!(default_notice_secs(1), 1);
991 assert_eq!(notice_secs(Some(0), 7_200), 0);
992 assert_eq!(notice_secs(Some(90), 150), 90);
993 assert_eq!(notice_secs(None, 150), 15);
994 }
995
996 #[test]
997 fn session_status_take_due_notice_fires_once_inside_the_margin() {
998 let started = Instant::now();
999 let deadline = SessionDeadline::after(started, SystemTime::now(), 30);
1000 let status = SessionStatus::new(started, false, None, Some(deadline), 20, None, None);
1001 assert_eq!(
1002 status.take_due_notice(started + Duration::from_secs(9)),
1003 None
1004 );
1005 let notice = status
1006 .take_due_notice(started + Duration::from_secs(10))
1007 .expect("due once 20 seconds are left");
1008 assert_eq!(notice.left_secs, 20);
1009 assert_eq!(notice.notice_secs, 20);
1010 assert_eq!(
1011 notice.text(),
1012 format!(
1013 "Session deadline: the runtime ends this session at {}, in 20 seconds.",
1014 deadline.at_rfc3339()
1015 )
1016 );
1017 assert_eq!(
1018 status.take_due_notice(started + Duration::from_secs(11)),
1019 None
1020 );
1021 assert_eq!(
1022 status.take_due_notice(started + Duration::from_secs(40)),
1023 None
1024 );
1025 }
1026
1027 #[test]
1028 fn session_status_take_due_notice_never_fires_at_zero_or_without_a_deadline() {
1029 let started = Instant::now();
1030 let deadline = SessionDeadline::after(started, SystemTime::now(), 30);
1031 let silent = SessionStatus::new(started, false, None, Some(deadline), 0, None, None);
1032 assert_eq!(
1033 silent.take_due_notice(started + Duration::from_secs(29)),
1034 None
1035 );
1036 let unbounded = SessionStatus::new(started, false, None, None, 300, None, None);
1037 assert_eq!(unbounded.take_due_notice(started), None);
1038 let past = SessionStatus::new(started, false, None, Some(deadline), 5, None, None);
1040 let notice = past
1041 .take_due_notice(started + Duration::from_secs(31))
1042 .unwrap();
1043 assert_eq!(notice.left_secs, 0);
1044 }
1045
1046 #[test]
1047 fn session_status_deadline_spells_at_in_utc_to_the_second() {
1048 let started = Instant::now();
1049 let wall = SystemTime::UNIX_EPOCH + Duration::from_millis(1_792_497_600_750);
1050 let deadline = SessionDeadline::after(started, wall, 600);
1051 assert_eq!(deadline.at_rfc3339(), "2026-10-20T12:10:00Z");
1052 assert_eq!(deadline.secs(), 600);
1053 assert_eq!(
1054 deadline.left(started + Duration::from_secs(700)),
1055 Duration::ZERO
1056 );
1057 }
1058
1059 #[test]
1060 fn session_status_deadline_beyond_the_bound_is_taken_as_the_bound() {
1061 let started = Instant::now();
1062 let deadline = SessionDeadline::after(started, SystemTime::now(), u64::MAX);
1063 assert_eq!(deadline.secs(), MAX_DEADLINE_SECS);
1064 assert_eq!(
1065 deadline.left(started),
1066 Duration::from_secs(MAX_DEADLINE_SECS)
1067 );
1068 assert!(deadline.at_rfc3339().ends_with('Z'));
1069 }
1070
1071 #[test]
1072 fn session_status_until_leaves_the_difference_zero_when_past_and_the_bound_at_most() {
1073 let started = Instant::now();
1074 let wall = SystemTime::UNIX_EPOCH + Duration::from_secs(1_792_497_600);
1075 let ahead = SessionDeadline::until(started, wall, wall + Duration::from_secs(90));
1076 assert_eq!(ahead.left(started), Duration::from_secs(90));
1077 assert_eq!(ahead.secs(), 90);
1078 assert_eq!(ahead.source(), DeadlineSource::Inherited);
1079 assert_eq!(ahead.at_rfc3339(), "2026-10-20T12:01:30Z");
1080
1081 let past = SessionDeadline::until(started, wall, wall - Duration::from_secs(5));
1082 assert_eq!(past.left(started), Duration::ZERO);
1083 assert_eq!(past.secs(), 0);
1084 assert_eq!(past.at_rfc3339(), "2026-10-20T11:59:55Z");
1086
1087 let far = SessionDeadline::until(
1088 started,
1089 wall,
1090 wall + Duration::from_secs(MAX_DEADLINE_SECS + 10),
1091 );
1092 assert_eq!(far.left(started), Duration::from_secs(MAX_DEADLINE_SECS));
1093 assert_eq!(far.secs(), MAX_DEADLINE_SECS);
1094 }
1095
1096 #[test]
1097 fn session_status_until_rounds_secs_up() {
1098 let started = Instant::now();
1099 let wall = SystemTime::UNIX_EPOCH + Duration::from_secs(1_792_497_600);
1100 let deadline = SessionDeadline::until(started, wall, wall + Duration::from_millis(29_001));
1101 assert_eq!(deadline.secs(), 30);
1102 assert_eq!(deadline.left(started), Duration::from_millis(29_001));
1103 let whole = SessionDeadline::until(started, wall, wall + Duration::from_secs(29));
1104 assert_eq!(whole.secs(), 29);
1105 let instant = SessionDeadline::until(started, wall, wall + Duration::from_nanos(1));
1106 assert_eq!(instant.secs(), 1);
1107 }
1108
1109 #[test]
1110 fn session_status_unix_millis_round_trip_to_the_same_second() {
1111 let started = Instant::now();
1112 let wall = SystemTime::UNIX_EPOCH + Duration::from_nanos(1_792_497_600_999_999_999);
1113 let own = SessionDeadline::after(started, wall, 60);
1114 assert_eq!(own.source(), DeadlineSource::Own);
1115 let millis = own.at_unix_millis();
1116 assert_eq!(millis, 1_792_497_660_999);
1117 let child_started = started + Duration::from_millis(3_500);
1119 let child_wall = wall + Duration::from_millis(3_500);
1120 let inherited = SessionDeadline::from_unix_millis(child_started, child_wall, millis);
1121 assert_eq!(inherited.at_rfc3339(), own.at_rfc3339());
1122 assert_eq!(inherited.at_unix_millis(), millis);
1123 assert_eq!(inherited.secs(), 57);
1124 assert!(own.left(child_started) - inherited.left(child_started) < Duration::from_millis(1));
1125
1126 let overflow = SessionDeadline::from_unix_millis(started, wall, u64::MAX);
1128 assert_eq!(overflow.secs(), MAX_DEADLINE_SECS);
1129 assert_eq!(
1130 overflow.left(started),
1131 Duration::from_secs(MAX_DEADLINE_SECS)
1132 );
1133 let epoch = SessionDeadline::from_unix_millis(started, wall, 0);
1134 assert_eq!(epoch.left(started), Duration::ZERO);
1135 }
1136
1137 #[test]
1138 fn session_status_earliest_keeps_the_earlier_and_the_inherited_on_a_tie() {
1139 let started = Instant::now();
1140 let wall = SystemTime::UNIX_EPOCH + Duration::from_secs(1_792_497_600);
1141 let own = SessionDeadline::after(started, wall, 60);
1142 let sooner = SessionDeadline::until(started, wall, wall + Duration::from_secs(30));
1143 let later = SessionDeadline::until(started, wall, wall + Duration::from_secs(90));
1144 let tie = SessionDeadline::until(started, wall, wall + Duration::from_secs(60));
1145
1146 assert_eq!(
1147 SessionDeadline::earliest(Some(own), Some(sooner)),
1148 Some(sooner)
1149 );
1150 assert_eq!(SessionDeadline::earliest(Some(own), Some(later)), Some(own));
1151 let tied = SessionDeadline::earliest(Some(own), Some(tie)).unwrap();
1152 assert_eq!(tied.source(), DeadlineSource::Inherited);
1153 assert_eq!(tied.secs(), 60);
1154 assert_eq!(SessionDeadline::earliest(Some(own), None), Some(own));
1155 assert_eq!(SessionDeadline::earliest(None, Some(later)), Some(later));
1156 assert_eq!(SessionDeadline::earliest(None, None), None);
1157 assert_eq!(DeadlineSource::Own.as_str(), "own");
1158 assert_eq!(DeadlineSource::Inherited.as_str(), "inherited");
1159 }
1160
1161 #[test]
1164 fn session_status_counts_done_check_reopens_per_task_against_the_limit() {
1165 let started = Instant::now();
1166 let status = SessionStatus::new(started, false, None, None, 0, None, Some(3));
1167 assert_eq!(
1168 report(&status, started, no_spend())["done_check"],
1169 json!({"used": 0, "left": 3})
1170 );
1171 assert_eq!(report(&status, started, no_spend())["keep_working"], "none");
1172 status.task_started("tsk_1", started);
1173 status.done_check_reopened(1);
1174 status.done_check_reopened(2);
1175 assert_eq!(
1176 report(&status, started, no_spend())["done_check"],
1177 json!({"used": 2, "left": 1})
1178 );
1179 status.task_ended();
1180 status.task_started("tsk_2", started);
1181 assert_eq!(
1182 report(&status, started, no_spend())["done_check"],
1183 json!({"used": 0, "left": 3})
1184 );
1185 }
1186
1187 #[test]
1190 fn session_status_descriptions_name_every_field_and_no_timing_advice() {
1191 let shared = [
1192 "This session's state now, as one JSON object; no input.",
1193 "time: elapsed_secs (session), task_elapsed_secs (current task), deadline {at: RFC \
1194 3339 UTC session end, left_secs to it}.",
1195 "turns: used, cap (this task's inference calls).",
1196 "keep_working: used, left (lifecycle.keep_working messages that kept this task \
1197 working past a turn end).",
1198 "done_check: used, left (lifecycle.done_check reopens of this task on a failed \
1199 command).",
1200 " input, output, cached, cache_write for this session's ",
1201 "unreported_calls",
1202 "spend: tokens (this session's, as the spend ceilings count them), session ceiling, \
1203 machine {tokens_today, daily ceiling}; counted in tokens, no money amount is kept.",
1204 "Unset limit: \"none\".",
1205 "Unknown count: \"unknown\".",
1206 "Seconds are whole.",
1207 ];
1208 let http = description_http();
1209 let process = description_process();
1210 for (description, own) in [
1211 (
1212 &http,
1213 &[
1214 "used_tokens (conversation size at last count)",
1215 "window_tokens (model context window)",
1216 "budget_tokens (most input tokens per call; compaction measures against it)",
1217 "budget_basis",
1218 "\"max_input_tokens\": provider input ceiling",
1219 "\"context_window_less_output_cap\": window less output cap",
1220 "measured_by",
1221 "\"actual\": provider's count of the last call",
1222 "\"estimate\": runtime's count",
1223 "context: used_tokens",
1224 "tokens: provider-reported input, output, cached, cache_write for this \
1225 session's calls, and unreported_calls it reported none for.",
1226 ][..],
1227 ),
1228 (
1229 &process,
1230 &[
1231 "Turns are counted as they close",
1232 "context: \"unknown\"; the harness keeps its own context",
1233 "tokens: harness-reported input, output, cached, cache_write for this \
1234 session's closed turns",
1235 "unreported_calls, turns it reported none for",
1236 "quota, once the harness reports one: its latest subscription windows, name, \
1237 used (0-1), resets_at, window_minutes, state; unreported members are left out.",
1238 ][..],
1239 ),
1240 ] {
1241 for fact in shared.iter().chain(own) {
1242 assert!(description.contains(fact), "{fact} missing: {description}");
1243 }
1244 let lower = description.to_lowercase();
1245 for advice in ["when", "should", "call this", "periodically"] {
1246 assert!(!lower.contains(advice), "{advice}: {description}");
1247 }
1248 }
1249 for http_only in [
1250 "used_tokens",
1251 "window_tokens",
1252 "budget_tokens",
1253 "measured_by",
1254 ] {
1255 assert!(!process.contains(http_only), "{http_only}: {process}");
1256 }
1257 assert!(!http.contains("quota"), "{http}");
1258 }
1259}