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::spend::SpendMeter;
19
20pub const SESSION_STATUS_TOOL: &str = "session-status";
22
23const NONE: &str = "none";
25
26const UNKNOWN: &str = "unknown";
28
29const DEFAULT_NOTICE_CEILING_SECS: u64 = 300;
31
32pub const MAX_DEADLINE_SECS: u64 = 315_360_000;
35
36pub const INHERITED_DEADLINE_FLAG: &str = "--inherited-deadline";
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum DeadlineSource {
43 Own,
45 Inherited,
47}
48
49impl DeadlineSource {
50 pub const fn as_str(self) -> &'static str {
52 match self {
53 Self::Own => "own",
54 Self::Inherited => "inherited",
55 }
56 }
57}
58
59#[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 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 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 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 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 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 pub fn secs(&self) -> u64 {
144 self.secs
145 }
146
147 pub fn source(&self) -> DeadlineSource {
149 self.source
150 }
151
152 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 pub fn left(&self, now: Instant) -> Duration {
160 self.at.saturating_duration_since(now)
161 }
162
163 pub(crate) fn instant(&self) -> Instant {
165 self.at
166 }
167}
168
169pub fn default_notice_secs(deadline_secs: u64) -> u64 {
173 (deadline_secs / 10).clamp(1, DEFAULT_NOTICE_CEILING_SECS)
174}
175
176pub fn notice_secs(declared: Option<u64>, deadline_secs: u64) -> u64 {
179 declared.unwrap_or_else(|| default_notice_secs(deadline_secs))
180}
181
182#[derive(Debug, Clone, PartialEq, Eq)]
184pub(crate) struct DeadlineNotice {
185 pub(crate) at: String,
187 pub(crate) left_secs: u64,
189 pub(crate) notice_secs: u64,
191}
192
193impl DeadlineNotice {
194 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
205pub(crate) struct SpendReading {
206 pub(crate) used: u64,
208 pub(crate) ceiling: Option<u64>,
210 pub(crate) machine: Option<(u64, u64)>,
213}
214
215impl SpendReading {
216 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#[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#[derive(Debug, Default)]
238struct Fed {
239 task: Option<(String, Instant)>,
241 task_turns: u32,
243 keep_working_used: u32,
246 done_check_reopens: u32,
249 input: Option<u64>,
250 output: Option<u64>,
251 cached: Option<u64>,
252 cache_write: Option<u64>,
253 unreported_calls: u64,
255 context: Option<ContextReading>,
256}
257
258#[derive(Debug)]
260pub(crate) struct SessionStatus {
261 started: Instant,
262 harness_transport: bool,
264 turn_cap: Option<u32>,
266 deadline: Option<SessionDeadline>,
267 notice_secs: u64,
269 keep_working_limit: Option<u32>,
271 done_check_limit: Option<u32>,
273 notice_taken: AtomicBool,
274 fed: Mutex<Fed>,
275}
276
277impl SessionStatus {
278 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 self.fed
307 .lock()
308 .unwrap_or_else(std::sync::PoisonError::into_inner)
309 }
310
311 pub(crate) fn deadline(&self) -> Option<SessionDeadline> {
313 self.deadline
314 }
315
316 pub(crate) fn notice_secs(&self) -> u64 {
318 self.notice_secs
319 }
320
321 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 pub(crate) fn keep_working_sent(&self, number: u32) {
332 self.fed().keep_working_used = number;
333 }
334
335 pub(crate) fn done_check_reopened(&self, reopens: u32) {
338 self.fed().done_check_reopens = reopens;
339 }
340
341 pub(crate) fn task_ended(&self) {
343 self.fed().task = None;
344 }
345
346 pub(crate) fn task_id(&self) -> Option<String> {
348 self.fed().task.as_ref().map(|(id, _)| id.clone())
349 }
350
351 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 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 pub(crate) fn snapshot(&self, spend: SpendReading) -> StatusReport {
418 self.snapshot_at(Instant::now(), spend)
419 }
420
421 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 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#[derive(Debug, Clone, PartialEq, Serialize)]
528pub(crate) struct StatusReport {
529 time: TimeReport,
530 turns: TurnsReport,
531 keep_working: Value,
533 done_check: Value,
535 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
570const 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
579const 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
586pub 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
604pub 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
615pub 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 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 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 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 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 assert_eq!(value["tokens"]["unreported_calls"], 1);
811 }
812
813 #[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 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 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 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 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 #[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}