Skip to main content

oxios_kernel/token_maxing/
session.rs

1//! TokenMaxingSession + report (RFC-031 §8).
2//!
3//! One session per activation window (or manual run). Persisted per run via
4//! `StateStore::save_json("token-maxing", &id, &session)` and surfaced by the
5//! report API. The session records what ran, on which provider/model, how much
6//! quota was burned, and why the run ended.
7
8use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10
11use super::quota_tracker::QuotaTrackerSnapshot;
12
13/// A token-maxing activation window `[start, end)`.
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct MaxingWindow {
16    /// When the burn window opens (inclusive).
17    pub start: DateTime<Utc>,
18    /// When the burn window closes (exclusive).
19    pub end: DateTime<Utc>,
20}
21
22/// How a session was activated.
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub enum MaxingStart {
25    /// A scheduled time window (cron-derived or explicit start/end).
26    Scheduled(MaxingWindow),
27    /// A manual toggle — runs until stopped or work is exhausted.
28    Manual,
29}
30
31/// Where a planned task was sourced from (RFC-031 §7).
32#[derive(Debug, Clone, Serialize, Deserialize)]
33#[serde(rename_all = "lowercase")]
34pub enum TaskSource {
35    /// An autonomous-eligible skill (frontmatter `autonomous: true`).
36    Skill,
37    /// A registered project / mount (read-mostly review task).
38    Project,
39    /// A recurring pattern derived from session history (Source C).
40    Recurring,
41}
42
43/// One drained window on one provider (report fidelity, RFC-031 §11.5).
44#[derive(Debug, Clone, Default, Serialize, Deserialize)]
45pub struct ProviderWindowRecord {
46    pub started: DateTime<Utc>,
47    pub ended_at: Option<DateTime<Utc>>,
48}
49
50/// Per-provider rollup within a session.
51#[derive(Debug, Clone, Default, Serialize, Deserialize)]
52pub struct ProviderSessionRecord {
53    pub provider: String,
54    pub models_used: Vec<String>,
55    pub tasks_run: u64,
56    pub tokens_consumed: u64,
57    pub windows_drained: Vec<ProviderWindowRecord>,
58}
59
60/// One executed task and its outcome.
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct TaskRecord {
63    pub source: TaskSource,
64    pub source_name: String,
65    pub goal: String,
66    pub provider: String,
67    pub model: String,
68    pub success: bool,
69    pub tokens: u64,
70    pub duration_secs: f64,
71    /// Truncated agent output (the per-task summary).
72    pub summary: String,
73}
74
75/// Why the session ended. The report distinguishes these so the user can tell
76/// "stopped: nothing to do" from "stopped: window ended" (RFC-031 §8).
77#[derive(Debug, Clone, Serialize, Deserialize)]
78#[serde(rename_all = "snake_case")]
79pub enum StopReason {
80    /// The configured time window elapsed.
81    WindowEnded,
82    /// The planner returned `None` — no more eligible work.
83    NoWork,
84    /// The user requested a stop.
85    Cancelled,
86}
87
88/// Session-wide totals.
89#[derive(Debug, Clone, Default, Serialize, Deserialize)]
90pub struct SessionTotals {
91    pub tasks: u64,
92    pub tokens: u64,
93    pub providers_fully_drained: u64,
94    pub resets_observed: u64,
95}
96
97/// A complete token-maxing run.
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct TokenMaxingSession {
100    pub id: String,
101    pub started_at: DateTime<Utc>,
102    pub ended_at: Option<DateTime<Utc>>,
103    pub window: Option<MaxingWindow>,
104    pub manual: bool,
105    pub providers: Vec<ProviderSessionRecord>,
106    pub tasks: Vec<TaskRecord>,
107    pub totals: SessionTotals,
108    pub stop_reason: Option<StopReason>,
109}
110
111impl TokenMaxingSession {
112    /// Begin a new session.
113    pub fn start(window: Option<MaxingWindow>, manual: bool) -> Self {
114        let now = Utc::now();
115        let id = format!("tm-{}", now.timestamp_millis());
116        Self {
117            id,
118            started_at: now,
119            ended_at: None,
120            window,
121            manual,
122            providers: Vec::new(),
123            tasks: Vec::new(),
124            totals: SessionTotals::default(),
125            stop_reason: None,
126        }
127    }
128
129    /// Whether the window has not yet elapsed (always true for manual runs).
130    pub fn within_window(&self) -> bool {
131        match &self.window {
132            Some(w) => Utc::now() < w.end,
133            None => true,
134        }
135    }
136
137    /// Record one task's outcome and roll it up into per-provider + total stats.
138    #[allow(clippy::too_many_arguments)]
139    pub fn record_task(
140        &mut self,
141        source: TaskSource,
142        source_name: String,
143        goal: String,
144        provider: String,
145        model: String,
146        success: bool,
147        tokens: u64,
148        duration_secs: f64,
149        summary: String,
150    ) {
151        // Per-provider rollup. Index/any-check first to avoid a double
152        // borrow of self.providers (the find + push pattern borrows twice).
153        if !self.providers.iter().any(|r| r.provider == provider) {
154            self.providers.push(ProviderSessionRecord {
155                provider: provider.clone(),
156                ..Default::default()
157            });
158        }
159        // Audit F-4: the just-pushed/ensured record must exist. If the
160        // search invariant is broken, log loudly and skip the per-task
161        // rollup rather than panicking (which under `panic=abort` would
162        // kill the daemon). Changing the public signature to Result is
163        // tracked separately.
164        let rec = match self.providers.iter_mut().find(|r| r.provider == provider) {
165            Some(r) => r,
166            None => {
167                tracing::error!(
168                    provider = %provider,
169                    "token_maxing: provider record missing after ensure — skipping per-task rollup"
170                );
171                return;
172            }
173        };
174        rec.tasks_run += 1;
175        rec.tokens_consumed += tokens;
176        if !rec.models_used.contains(&model) {
177            rec.models_used.push(model.clone());
178        }
179
180        self.tasks.push(TaskRecord {
181            source,
182            source_name,
183            goal,
184            provider,
185            model,
186            success,
187            tokens,
188            duration_secs,
189            summary,
190        });
191        self.totals.tasks += 1;
192        self.totals.tokens += tokens;
193    }
194
195    /// Mark the session as ended for `reason`.
196    pub fn finalize(&mut self, reason: StopReason) {
197        self.ended_at = Some(Utc::now());
198        self.stop_reason = Some(reason);
199    }
200}
201
202/// Live status for `GET /api/token-maxing/status` (RFC-031 §9).
203#[derive(Debug, Clone, Serialize)]
204pub struct MaxerStatus {
205    pub running: bool,
206    pub current_session_id: Option<String>,
207    pub current_provider: Option<String>,
208    pub current_task: Option<String>,
209    pub manual: bool,
210    pub window: Option<MaxingWindow>,
211    pub tokens_this_session: u64,
212    pub tasks_this_session: u64,
213    pub providers: Vec<QuotaTrackerSnapshot>,
214}