| 1 | //! Turn context and tracking. |
| 2 | //! |
| 3 | //! A "turn" is one user message and the resulting AI response, |
| 4 | //! including any tool calls that occur. |
| 5 | //! |
| 6 | //! ## Snapshot lifecycle hooks |
| 7 | //! |
| 8 | //! [`pre_turn_snapshot`] and [`post_turn_snapshot`] book-end a turn by |
| 9 | //! taking a workspace-level snapshot into a side git repo (see |
| 10 | //! `crate::snapshot`). They are intentionally non-blocking and |
| 11 | //! non-fatal: any IO error is logged at WARN and swallowed so a busted |
| 12 | //! filesystem or missing `git` binary never derails the agent loop. |
| 13 | //! `/restore N` and the `revert_turn` tool both consume these |
| 14 | //! snapshots. |
| 15 | |
| 16 | use crate::core::events::TurnRoute; |
| 17 | use crate::snapshot::SnapshotRepo; |
| 18 | use codewhale_models::Usage; |
| 19 | use std::path::Path; |
| 20 | use std::time::{Duration, Instant}; |
| 21 | |
| 22 | /// Which configured limit governs a turn's step budget (#5994). |
| 23 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 24 | pub enum StepBudgetSource { |
| 25 | /// The ordinary interactive ceiling (`max_steps`). |
| 26 | Interactive, |
| 27 | /// The goal-turn allowance (`[goal] max_steps`). |
| 28 | Goal, |
| 29 | } |
| 30 | |
| 31 | impl StepBudgetSource { |
| 32 | /// The configuration key named in soft-landing and exhaustion notices. |
| 33 | #[must_use] |
| 34 | pub const fn key_label(self) -> &'static str { |
| 35 | match self { |
| 36 | Self::Interactive => "max_steps", |
| 37 | Self::Goal => "[goal] max_steps", |
| 38 | } |
| 39 | } |
| 40 | } |
| 41 | |
| 42 | /// Context for a single turn (user message + AI response). |
| 43 | #[derive(Debug)] |
| 44 | pub struct TurnContext { |
| 45 | pub max_output_tokens: Option<std::num::NonZeroU32>, |
| 46 | /// Turn ID |
| 47 | pub id: String, |
| 48 | |
| 49 | /// When the turn started |
| 50 | pub started_at: Instant, |
| 51 | |
| 52 | /// Current step in the turn (tool call iteration) |
| 53 | pub step: u32, |
| 54 | |
| 55 | /// Configured steps, or `u32::MAX` for no limit. Use `step_limit` for |
| 56 | /// budget decisions; the counter saturates without stopping an uncapped turn. |
| 57 | pub max_steps: u32, |
| 58 | |
| 59 | /// Which configured limit `max_steps` came from. |
| 60 | pub budget_source: StepBudgetSource, |
| 61 | |
| 62 | /// The turn's step budget was exhausted and the bounded final report was |
| 63 | /// granted (#5994). Set by the turn loop; the cross-turn goal fence reads |
| 64 | /// it so an exhausted goal pauses instead of re-arming. |
| 65 | pub budget_exhausted_final_report: bool, |
| 66 | |
| 67 | pub(crate) stop_diagnostics: crate::tool_inspection::TurnStopDiagnostics, |
| 68 | pub(crate) last_request_snapshot: Option<crate::tool_inspection::ToolInspectionSnapshot>, |
| 69 | |
| 70 | /// Number of tool calls made in this turn. |
| 71 | |
| 72 | /// Whether the turn has been cancelled |
| 73 | #[expect(dead_code)] |
| 74 | pub cancelled: bool, |
| 75 | |
| 76 | /// Usage for this turn |
| 77 | pub usage: Usage, |
| 78 | |
| 79 | /// Subset of `usage` served by the parent turn's frozen route. Programmatic |
| 80 | /// reviewer/RLM calls remain in the total above but are billed only from |
| 81 | /// their own routed receipts. |
| 82 | pub parent_route_usage: Usage, |
| 83 | |
| 84 | /// Provider calls whose usage became ambiguous after dispatch (for |
| 85 | /// example an RLM timeout). A non-zero value makes cost coverage |
| 86 | /// explicitly incomplete instead of inventing a zero-usage receipt. |
| 87 | pub routed_usage_dropped_records: u64, |
| 88 | |
| 89 | /// Input tokens reported for the most recent parent-route model request. |
| 90 | /// This is deliberately separate from `usage`, which accumulates every |
| 91 | /// parent step and programmatic child call for billing. |
| 92 | pub(crate) latest_parent_input_tokens: Option<u32>, |
| 93 | |
| 94 | /// `session.messages.len()` at the parent request whose billed prompt is |
| 95 | /// in `latest_parent_input_tokens`. Tool results appended after that |
| 96 | /// request are not in the bill; GrokBuild's pre-sampling gate adds a |
| 97 | /// byte-estimate of that suffix so auto-compact can fire mid-turn. |
| 98 | pub(crate) messages_len_at_last_parent_prompt: Option<usize>, |
| 99 | |
| 100 | /// One-shot latch: an automatic-compaction refusal has already been |
| 101 | /// surfaced this turn. Pressure is re-checked every step, and repeating |
| 102 | /// the same refusal on each of a long turn's steps would be noise. |
| 103 | pub(crate) compaction_refusal_notified: bool, |
| 104 | |
| 105 | /// Route facts resolved for this turn but not timestamped until the first |
| 106 | /// provider request is actually dispatched. |
| 107 | pub(crate) pending_route: Option<TurnRoute>, |
| 108 | } |
| 109 | |
| 110 | impl TurnContext { |
| 111 | /// Create a new turn context |
| 112 | pub fn new(max_steps: u32) -> Self { |
| 113 | Self::with_budget_source(max_steps, StepBudgetSource::Interactive) |
| 114 | } |
| 115 | |
| 116 | /// Create a turn context with an explicit budget provenance (#5994). |
| 117 | pub fn with_budget_source(max_steps: u32, budget_source: StepBudgetSource) -> Self { |
| 118 | Self { |
| 119 | max_output_tokens: None, |
| 120 | id: uuid::Uuid::new_v4().to_string(), |
| 121 | started_at: Instant::now(), |
| 122 | step: 0, |
| 123 | max_steps, |
| 124 | budget_source, |
| 125 | budget_exhausted_final_report: false, |
| 126 | stop_diagnostics: crate::tool_inspection::TurnStopDiagnostics { |
| 127 | effective_max_steps: (max_steps != u32::MAX).then_some(max_steps), |
| 128 | step_budget_source: budget_source.key_label(), |
| 129 | ..Default::default() |
| 130 | }, |
| 131 | last_request_snapshot: None, |
| 132 | cancelled: false, |
| 133 | usage: Usage { |
| 134 | input_tokens: 0, |
| 135 | output_tokens: 0, |
| 136 | ..Usage::default() |
| 137 | }, |
| 138 | parent_route_usage: Usage::default(), |
| 139 | routed_usage_dropped_records: 0, |
| 140 | latest_parent_input_tokens: None, |
| 141 | messages_len_at_last_parent_prompt: None, |
| 142 | compaction_refusal_notified: false, |
| 143 | pending_route: None, |
| 144 | } |
| 145 | } |
| 146 | |
| 147 | /// Increment the step counter |
| 148 | pub fn next_step(&mut self) -> bool { |
| 149 | self.step = self.step.saturating_add(1); |
| 150 | self.step_limit().is_none_or(|limit| self.step <= limit) |
| 151 | } |
| 152 | |
| 153 | /// A resolved integer default means no ceiling, including at counter |
| 154 | /// saturation. Explicit positive configuration is clamped before here. |
| 155 | #[must_use] |
| 156 | pub fn step_limit(&self) -> Option<u32> { |
| 157 | (self.max_steps != u32::MAX).then_some(self.max_steps) |
| 158 | } |
| 159 | |
| 160 | /// Check if the turn has reached max steps |
| 161 | pub fn at_max_steps(&self) -> bool { |
| 162 | self.step_limit().is_some_and(|limit| self.step >= limit) |
| 163 | } |
| 164 | |
| 165 | /// Model steps consumed so far (for soft-landing and reporting). |
| 166 | #[must_use] |
| 167 | pub fn steps_used(&self) -> u32 { |
| 168 | self.step |
| 169 | } |
| 170 | |
| 171 | /// Cancel the turn |
| 172 | #[expect(dead_code)] |
| 173 | pub fn cancel(&mut self) { |
| 174 | self.cancelled = true; |
| 175 | } |
| 176 | |
| 177 | /// Get the elapsed time |
| 178 | pub fn elapsed(&self) -> Duration { |
| 179 | self.started_at.elapsed() |
| 180 | } |
| 181 | |
| 182 | /// Complete the existing request projection with observed turn-exit facts. |
| 183 | /// A turn that never prepared a request has no request snapshot to publish. |
| 184 | pub(crate) fn terminal_request_snapshot( |
| 185 | &mut self, |
| 186 | status: super::events::TurnOutcomeStatus, |
| 187 | ) -> Option<crate::tool_inspection::ToolInspectionSnapshot> { |
| 188 | use crate::tool_inspection::TurnStopReason; |
| 189 | self.stop_diagnostics.status = Some(status); |
| 190 | self.stop_diagnostics.model_step_index = self.step; |
| 191 | self.stop_diagnostics.final_report_requested |= self.budget_exhausted_final_report; |
| 192 | self.stop_diagnostics.last_reported_input_tokens = self.latest_parent_input_tokens; |
| 193 | match status { |
| 194 | super::events::TurnOutcomeStatus::Interrupted => { |
| 195 | self.stop_diagnostics.reason = Some(TurnStopReason::Interrupted); |
| 196 | } |
| 197 | super::events::TurnOutcomeStatus::Failed if self.stop_diagnostics.reason.is_none() => { |
| 198 | self.stop_diagnostics.reason = Some(TurnStopReason::Failed); |
| 199 | } |
| 200 | _ => {} |
| 201 | } |
| 202 | let mut snapshot = self.last_request_snapshot.take()?; |
| 203 | snapshot.terminal = Some(self.stop_diagnostics.clone()); |
| 204 | Some(snapshot) |
| 205 | } |
| 206 | |
| 207 | /// Add usage from an API response |
| 208 | pub fn add_usage(&mut self, usage: &Usage) { |
| 209 | add_usage_to(&mut self.usage, usage); |
| 210 | } |
| 211 | |
| 212 | /// Record one parent-route response for both billing and live-context |
| 213 | /// pressure. Child-model usage must call [`Self::add_usage`] directly so |
| 214 | /// it cannot masquerade as the parent request's context size. |
| 215 | pub fn add_parent_usage(&mut self, usage: &Usage) { |
| 216 | self.latest_parent_input_tokens = (usage.input_tokens > 0).then_some(usage.input_tokens); |
| 217 | self.add_usage(usage); |
| 218 | add_usage_to(&mut self.parent_route_usage, usage); |
| 219 | } |
| 220 | |
| 221 | pub fn add_routed_usage_dropped_records(&mut self, dropped_records: u64) { |
| 222 | self.routed_usage_dropped_records = self |
| 223 | .routed_usage_dropped_records |
| 224 | .saturating_add(dropped_records); |
| 225 | } |
| 226 | |
| 227 | /// Add programmatic child-call usage to the authoritative total and |
| 228 | /// return the same batch aggregate for telemetry emission. |
| 229 | pub fn add_routed_usages<'a>(&mut self, usages: impl IntoIterator<Item = &'a Usage>) -> Usage { |
| 230 | let mut aggregate = Usage::default(); |
| 231 | for usage in usages { |
| 232 | self.add_usage(usage); |
| 233 | add_usage_to(&mut aggregate, usage); |
| 234 | } |
| 235 | aggregate |
| 236 | } |
| 237 | } |
| 238 | |
| 239 | pub(crate) fn add_usage_to(total: &mut Usage, delta: &Usage) { |
| 240 | total.input_tokens = total.input_tokens.saturating_add(delta.input_tokens); |
| 241 | total.output_tokens = total.output_tokens.saturating_add(delta.output_tokens); |
| 242 | total.prompt_cache_hit_tokens = |
| 243 | add_optional_usage(total.prompt_cache_hit_tokens, delta.prompt_cache_hit_tokens); |
| 244 | total.prompt_cache_miss_tokens = add_optional_usage( |
| 245 | total.prompt_cache_miss_tokens, |
| 246 | delta.prompt_cache_miss_tokens, |
| 247 | ); |
| 248 | total.prompt_cache_write_tokens = add_optional_usage( |
| 249 | total.prompt_cache_write_tokens, |
| 250 | delta.prompt_cache_write_tokens, |
| 251 | ); |
| 252 | total.reasoning_tokens = add_optional_usage(total.reasoning_tokens, delta.reasoning_tokens); |
| 253 | total.reasoning_replay_tokens = |
| 254 | add_optional_usage(total.reasoning_replay_tokens, delta.reasoning_replay_tokens); |
| 255 | if let Some(delta) = delta.server_tool_use.as_ref() { |
| 256 | let server_total = total.server_tool_use.get_or_insert_default(); |
| 257 | server_total.code_execution_requests = add_optional_usage( |
| 258 | server_total.code_execution_requests, |
| 259 | delta.code_execution_requests, |
| 260 | ); |
| 261 | server_total.tool_search_requests = add_optional_usage( |
| 262 | server_total.tool_search_requests, |
| 263 | delta.tool_search_requests, |
| 264 | ); |
| 265 | } |
| 266 | } |
| 267 | |
| 268 | impl TurnContext { |
| 269 | /// Billed prompt the compaction gate should honor: this turn's latest |
| 270 | /// parent request, else the session-carried receipt from the previous |
| 271 | /// turn. A fresh `TurnContext` starts empty, so without the session |
| 272 | /// fallback an 842k DeepSeek bill dies at the turn boundary and the |
| 273 | /// next send never auto-compacts (#5577). |
| 274 | #[must_use] |
| 275 | pub(crate) fn billed_input_tokens_for_compaction( |
| 276 | &self, |
| 277 | session_billed: Option<u32>, |
| 278 | ) -> Option<u64> { |
| 279 | self.latest_parent_input_tokens |
| 280 | .or(session_billed) |
| 281 | .map(u64::from) |
| 282 | } |
| 283 | |
| 284 | /// Record how long the transcript was when the latest parent prompt was |
| 285 | /// billed. Call immediately after `add_parent_usage`, before this |
| 286 | /// response's assistant/tool messages are appended. |
| 287 | pub(crate) fn note_parent_prompt_len(&mut self, message_count: usize) { |
| 288 | self.messages_len_at_last_parent_prompt = Some(message_count); |
| 289 | } |
| 290 | |
| 291 | /// Live context for the auto-compact gate: last billed prompt plus a |
| 292 | /// /4 estimate of messages appended since that prompt (tool results, |
| 293 | /// the assistant reply that will be replayed on the next request). |
| 294 | /// |
| 295 | /// `max(billed, estimate(full list))` hides mid-turn growth when the |
| 296 | /// estimator undercounts the whole transcript below the last bill — |
| 297 | /// which is why auto-compact never fired even with the UI meter above |
| 298 | /// 80%. GrokBuild's `check_auto_compact_needed` uses the same split: |
| 299 | /// exact prior count + byte-estimate of items since last response. |
| 300 | #[must_use] |
| 301 | pub(crate) fn live_input_tokens_for_compaction( |
| 302 | &self, |
| 303 | messages: &[codewhale_models::Message], |
| 304 | system_prompt: Option<&codewhale_models::SystemPrompt>, |
| 305 | session_billed: Option<u32>, |
| 306 | ) -> Option<u64> { |
| 307 | let billed = self.billed_input_tokens_for_compaction(session_billed); |
| 308 | let suffix_start = self |
| 309 | .messages_len_at_last_parent_prompt |
| 310 | .unwrap_or(messages.len()) |
| 311 | .min(messages.len()); |
| 312 | let suffix = &messages[suffix_start..]; |
| 313 | let growth = if suffix.is_empty() { |
| 314 | 0 |
| 315 | } else { |
| 316 | u64::try_from(crate::compaction::estimate_input_tokens_for_pressure( |
| 317 | suffix, None, |
| 318 | )) |
| 319 | .unwrap_or(u64::MAX) |
| 320 | }; |
| 321 | let estimated = u64::try_from(crate::compaction::estimate_input_tokens_for_pressure( |
| 322 | messages, |
| 323 | system_prompt, |
| 324 | )) |
| 325 | .unwrap_or(u64::MAX); |
| 326 | let live = estimated.max(billed.unwrap_or(0).saturating_add(growth)); |
| 327 | (live > 0).then_some(live) |
| 328 | } |
| 329 | |
| 330 | /// Drop the turn-local billed receipt after history is rewritten so the |
| 331 | /// next step cannot compact again on the pre-compaction prompt. |
| 332 | pub(crate) fn clear_parent_input_tokens(&mut self) { |
| 333 | self.latest_parent_input_tokens = None; |
| 334 | self.messages_len_at_last_parent_prompt = None; |
| 335 | } |
| 336 | } |
| 337 | |
| 338 | fn add_optional_usage(total: Option<u32>, delta: Option<u32>) -> Option<u32> { |
| 339 | match (total, delta) { |
| 340 | (Some(total), Some(delta)) => Some(total.saturating_add(delta)), |
| 341 | (None, Some(delta)) => Some(delta), |
| 342 | (Some(total), None) => Some(total), |
| 343 | (None, None) => None, |
| 344 | } |
| 345 | } |
| 346 | |
| 347 | #[cfg(test)] |
| 348 | mod usage_tests { |
| 349 | use super::*; |
| 350 | use codewhale_models::ServerToolUsage; |
| 351 | |
| 352 | #[test] |
| 353 | fn add_usage_preserves_replay_and_saturates_server_tool_counters() { |
| 354 | let mut turn = TurnContext::new(2); |
| 355 | turn.add_usage(&Usage { |
| 356 | reasoning_replay_tokens: Some(u32::MAX - 1), |
| 357 | server_tool_use: Some(ServerToolUsage { |
| 358 | code_execution_requests: Some(u32::MAX), |
| 359 | tool_search_requests: Some(2), |
| 360 | }), |
| 361 | ..Usage::default() |
| 362 | }); |
| 363 | turn.add_usage(&Usage { |
| 364 | reasoning_replay_tokens: Some(9), |
| 365 | server_tool_use: Some(ServerToolUsage { |
| 366 | code_execution_requests: Some(1), |
| 367 | tool_search_requests: Some(3), |
| 368 | }), |
| 369 | ..Usage::default() |
| 370 | }); |
| 371 | |
| 372 | assert_eq!(turn.usage.reasoning_replay_tokens, Some(u32::MAX)); |
| 373 | let server = turn.usage.server_tool_use.expect("server tool usage"); |
| 374 | assert_eq!(server.code_execution_requests, Some(u32::MAX)); |
| 375 | assert_eq!(server.tool_search_requests, Some(5)); |
| 376 | } |
| 377 | |
| 378 | fn below_threshold(messages: &[codewhale_models::Message], turn: &TurnContext) -> bool { |
| 379 | let config = crate::compaction::CompactionConfig { |
| 380 | enabled: true, |
| 381 | token_threshold: 100_000, |
| 382 | ..Default::default() |
| 383 | }; |
| 384 | !crate::compaction::compaction_pressure_reached_with_billed( |
| 385 | messages, |
| 386 | None, |
| 387 | &config, |
| 388 | turn.latest_parent_input_tokens.map(u64::from), |
| 389 | ) |
| 390 | } |
| 391 | |
| 392 | #[test] |
| 393 | fn cumulative_low_context_parent_steps_cannot_trigger_compaction() { |
| 394 | let mut turn = TurnContext::new(4); |
| 395 | turn.add_parent_usage(&Usage { |
| 396 | input_tokens: 60_000, |
| 397 | ..Usage::default() |
| 398 | }); |
| 399 | turn.add_parent_usage(&Usage { |
| 400 | input_tokens: 70_000, |
| 401 | ..Usage::default() |
| 402 | }); |
| 403 | |
| 404 | assert_eq!(turn.usage.input_tokens, 130_000); |
| 405 | assert_eq!(turn.latest_parent_input_tokens, Some(70_000)); |
| 406 | assert!(below_threshold(&[], &turn)); |
| 407 | } |
| 408 | |
| 409 | #[test] |
| 410 | fn child_usage_cannot_replace_parent_context_pressure() { |
| 411 | let mut turn = TurnContext::new(4); |
| 412 | turn.add_parent_usage(&Usage { |
| 413 | input_tokens: 70_000, |
| 414 | ..Usage::default() |
| 415 | }); |
| 416 | turn.add_usage(&Usage { |
| 417 | input_tokens: 250_000, |
| 418 | ..Usage::default() |
| 419 | }); |
| 420 | |
| 421 | assert_eq!(turn.usage.input_tokens, 320_000); |
| 422 | assert_eq!(turn.latest_parent_input_tokens, Some(70_000)); |
| 423 | assert!(below_threshold(&[], &turn)); |
| 424 | } |
| 425 | |
| 426 | #[test] |
| 427 | fn fresh_turn_inherits_session_billed_prompt_for_compaction() { |
| 428 | let turn = TurnContext::new(4); |
| 429 | assert_eq!(turn.latest_parent_input_tokens, None); |
| 430 | assert_eq!( |
| 431 | turn.billed_input_tokens_for_compaction(Some(842_000)), |
| 432 | Some(842_000) |
| 433 | ); |
| 434 | assert_eq!(turn.billed_input_tokens_for_compaction(None), None); |
| 435 | } |
| 436 | |
| 437 | #[test] |
| 438 | fn live_turn_billed_outranks_stale_session_billed() { |
| 439 | let mut turn = TurnContext::new(4); |
| 440 | turn.add_parent_usage(&Usage { |
| 441 | input_tokens: 12_000, |
| 442 | ..Usage::default() |
| 443 | }); |
| 444 | assert_eq!( |
| 445 | turn.billed_input_tokens_for_compaction(Some(842_000)), |
| 446 | Some(12_000) |
| 447 | ); |
| 448 | turn.clear_parent_input_tokens(); |
| 449 | assert_eq!(turn.latest_parent_input_tokens, None); |
| 450 | assert_eq!(turn.messages_len_at_last_parent_prompt, None); |
| 451 | assert_eq!( |
| 452 | turn.billed_input_tokens_for_compaction(Some(842_000)), |
| 453 | Some(842_000) |
| 454 | ); |
| 455 | } |
| 456 | |
| 457 | #[test] |
| 458 | fn live_compaction_tokens_include_tool_results_after_the_billed_prompt() { |
| 459 | // GrokBuild/Codex: last billed prompt + items since that request. |
| 460 | // A 70k bill plus a large tool result must cross an 80k trigger even |
| 461 | // when the full-list /4 estimate stays below the bill (the failure |
| 462 | // mode that kept auto-compact from firing mid-turn above 80%). |
| 463 | let mut turn = TurnContext::new(4); |
| 464 | turn.add_parent_usage(&Usage { |
| 465 | input_tokens: 70_000, |
| 466 | ..Usage::default() |
| 467 | }); |
| 468 | let prompt = vec![codewhale_models::Message { |
| 469 | role: codewhale_models::Role::User, |
| 470 | content: vec![codewhale_models::ContentBlock::Text { |
| 471 | text: "do the work".to_string(), |
| 472 | cache_control: None, |
| 473 | }], |
| 474 | }]; |
| 475 | turn.note_parent_prompt_len(prompt.len()); |
| 476 | |
| 477 | let mut with_tool = prompt; |
| 478 | with_tool.push(codewhale_models::Message { |
| 479 | role: codewhale_models::Role::User, |
| 480 | content: vec![codewhale_models::ContentBlock::ToolResult { |
| 481 | tool_use_id: "call-1".to_string(), |
| 482 | content: "x".repeat(80_000), |
| 483 | is_error: None, |
| 484 | content_blocks: None, |
| 485 | }], |
| 486 | }); |
| 487 | |
| 488 | let config = crate::compaction::CompactionConfig { |
| 489 | enabled: true, |
| 490 | token_threshold: 80_000, |
| 491 | ..Default::default() |
| 492 | }; |
| 493 | assert!( |
| 494 | !crate::compaction::compaction_pressure_reached_with_billed( |
| 495 | &with_tool, |
| 496 | None, |
| 497 | &config, |
| 498 | turn.billed_input_tokens_for_compaction(None), |
| 499 | ), |
| 500 | "stale billed prompt alone must not be the live gate" |
| 501 | ); |
| 502 | let live = turn |
| 503 | .live_input_tokens_for_compaction(&with_tool, None, None) |
| 504 | .expect("live tokens"); |
| 505 | assert!( |
| 506 | live >= 80_000, |
| 507 | "tool-result suffix must lift live tokens over the trigger, got {live}" |
| 508 | ); |
| 509 | assert!(crate::compaction::compaction_pressure_reached_with_billed( |
| 510 | &with_tool, |
| 511 | None, |
| 512 | &config, |
| 513 | Some(live), |
| 514 | )); |
| 515 | } |
| 516 | } |
| 517 | |
| 518 | /// Maximum characters of the user prompt snippet to embed in a snapshot |
| 519 | /// label. Longer prompts are truncated with an ellipsis. |
| 520 | const USER_PROMPT_LABEL_MAX: usize = 100; |
| 521 | |
| 522 | /// Format a snapshot label that includes the user prompt for readability |
| 523 | /// in `/restore` listings. |
| 524 | /// |
| 525 | /// Takes the first line of the prompt (up to `USER_PROMPT_LABEL_MAX` |
| 526 | /// characters) and appends it to the traditional `type:seq` label so |
| 527 | /// users can identify which turn each snapshot belongs to. |
| 528 | pub(crate) fn format_snapshot_label( |
| 529 | prefix: &str, |
| 530 | turn_seq: u64, |
| 531 | user_prompt: Option<&str>, |
| 532 | ) -> String { |
| 533 | let base = format!("{prefix}:{turn_seq}"); |
| 534 | match user_prompt { |
| 535 | None | Some("") => base, |
| 536 | Some(prompt) => match snapshot_label_prompt_snippet(prompt) { |
| 537 | None => base, |
| 538 | Some(snippet) => format!("{base}: {snippet}"), |
| 539 | }, |
| 540 | } |
| 541 | } |
| 542 | |
| 543 | /// The exact prompt snippet [`format_snapshot_label`] embeds after `type:seq`. |
| 544 | /// |
| 545 | /// Read surfaces that want to correlate a recorded prompt back to a restore |
| 546 | /// point must go through this function rather than re-deriving the truncation, |
| 547 | /// so the reader and the writer can never disagree about what a label means. |
| 548 | /// Returns `None` when the prompt contributes no snippet at all. |
| 549 | pub(crate) fn snapshot_label_prompt_snippet(prompt: &str) -> Option<String> { |
| 550 | if prompt.is_empty() { |
| 551 | return None; |
| 552 | } |
| 553 | let first_line = prompt.lines().next().unwrap_or(""); |
| 554 | let truncated: String = first_line.chars().take(USER_PROMPT_LABEL_MAX).collect(); |
| 555 | if truncated.chars().count() < first_line.chars().count() { |
| 556 | Some(format!("{truncated}…")) |
| 557 | } else { |
| 558 | Some(truncated) |
| 559 | } |
| 560 | } |
| 561 | |
| 562 | /// A snapshot label parsed back into its parts. |
| 563 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 564 | pub(crate) struct ParsedSnapshotLabel { |
| 565 | /// `pre-turn`, `post-turn`, `tool`, or whatever prefix produced it. |
| 566 | pub kind: String, |
| 567 | /// The turn sequence for `pre-turn`/`post-turn` labels. `tool` labels |
| 568 | /// carry a call id rather than a sequence, so this stays `None` for them. |
| 569 | pub seq: Option<u64>, |
| 570 | /// The embedded prompt snippet, exactly as |
| 571 | /// [`snapshot_label_prompt_snippet`] produced it. |
| 572 | pub prompt_snippet: Option<String>, |
| 573 | } |
| 574 | |
| 575 | /// Parse a label produced by [`format_snapshot_label`]. |
| 576 | /// |
| 577 | /// This is deliberately total: an unrecognized label still yields a record with |
| 578 | /// the raw text as `kind`, because a read surface must describe what is really |
| 579 | /// stored rather than silently dropping rows it does not recognize. |
| 580 | pub(crate) fn parse_snapshot_label(label: &str) -> ParsedSnapshotLabel { |
| 581 | let (head, snippet) = match label.split_once(": ") { |
| 582 | Some((head, rest)) => (head, Some(rest.to_string())), |
| 583 | None => (label, None), |
| 584 | }; |
| 585 | match head.split_once(':') { |
| 586 | Some((kind, seq)) => ParsedSnapshotLabel { |
| 587 | kind: kind.to_string(), |
| 588 | seq: seq.parse::<u64>().ok(), |
| 589 | prompt_snippet: snippet, |
| 590 | }, |
| 591 | None => ParsedSnapshotLabel { |
| 592 | kind: head.to_string(), |
| 593 | seq: None, |
| 594 | prompt_snippet: snippet, |
| 595 | }, |
| 596 | } |
| 597 | } |
| 598 | |
| 599 | /// Take a `pre-turn:<seq>` workspace snapshot. |
| 600 | /// |
| 601 | /// `cap_bytes` is the workspace-size ceiling that gates first-init |
| 602 | /// (passed through to [`SnapshotRepo::open_or_init_with_cap`]); pass |
| 603 | /// `0` to disable the cap. |
| 604 | /// `user_prompt` is an optional snippet of the user's message for this |
| 605 | /// turn, embedded in the snapshot label so `/restore` listings are |
| 606 | /// human-readable. |
| 607 | /// |
| 608 | /// Returns the snapshot SHA on success, `None` on any error. Errors are |
| 609 | /// logged at WARN; the turn loop must not block on this. |
| 610 | pub fn pre_turn_snapshot( |
| 611 | workspace: &Path, |
| 612 | turn_seq: u64, |
| 613 | cap_bytes: u64, |
| 614 | user_prompt: Option<&str>, |
| 615 | session_id: Option<&str>, |
| 616 | ) -> Option<String> { |
| 617 | snapshot_with_label( |
| 618 | workspace, |
| 619 | &format_snapshot_label("pre-turn", turn_seq, user_prompt), |
| 620 | cap_bytes, |
| 621 | session_id, |
| 622 | ) |
| 623 | } |
| 624 | |
| 625 | /// Take a `tool:<call_id>` workspace snapshot, taken before executing a |
| 626 | /// file-modifying tool call (write_file, edit_file, apply_patch). |
| 627 | /// |
| 628 | /// This enables surgical undo: `/undo` can restore to the most recent |
| 629 | /// `tool:<call_id>` snapshot to revert just the last file write. |
| 630 | /// |
| 631 | /// Returns the snapshot SHA on success, `None` on any error. Errors are |
| 632 | /// logged at WARN and are non-fatal. |
| 633 | pub fn pre_tool_snapshot( |
| 634 | workspace: &Path, |
| 635 | call_id: &str, |
| 636 | cap_bytes: u64, |
| 637 | session_id: Option<&str>, |
| 638 | ) -> Option<String> { |
| 639 | snapshot_with_label(workspace, &format!("tool:{call_id}"), cap_bytes, session_id) |
| 640 | } |
| 641 | |
| 642 | /// Take a `post-turn:<seq>` workspace snapshot. Same failure model as |
| 643 | /// [`pre_turn_snapshot`]. |
| 644 | pub fn post_turn_snapshot( |
| 645 | workspace: &Path, |
| 646 | turn_seq: u64, |
| 647 | cap_bytes: u64, |
| 648 | user_prompt: Option<&str>, |
| 649 | session_id: Option<&str>, |
| 650 | ) -> Option<String> { |
| 651 | snapshot_with_label( |
| 652 | workspace, |
| 653 | &format_snapshot_label("post-turn", turn_seq, user_prompt), |
| 654 | cap_bytes, |
| 655 | session_id, |
| 656 | ) |
| 657 | } |
| 658 | |
| 659 | fn snapshot_with_label( |
| 660 | workspace: &Path, |
| 661 | label: &str, |
| 662 | cap_bytes: u64, |
| 663 | session_id: Option<&str>, |
| 664 | ) -> Option<String> { |
| 665 | match SnapshotRepo::open_or_init_with_cap(workspace, cap_bytes) { |
| 666 | Ok(repo) => { |
| 667 | clear_snapshots_disabled_status(workspace, session_id); |
| 668 | let id = match repo.snapshot_with_session(label, session_id) { |
| 669 | Ok(id) => Some(id.0), |
| 670 | Err(e) => { |
| 671 | tracing::warn!(target: "snapshot", "snapshot '{label}' failed: {e}"); |
| 672 | return None; |
| 673 | } |
| 674 | }; |
| 675 | // Prune oldest snapshots to cap disk usage (#1112). |
| 676 | if let Err(e) = repo.prune_keep_last_n(crate::snapshot::DEFAULT_MAX_SNAPSHOTS) { |
| 677 | tracing::warn!(target: "snapshot", "snapshot prune failed: {e}"); |
| 678 | } |
| 679 | id |
| 680 | } |
| 681 | Err(e) => { |
| 682 | // The first gated failure belongs to this session, even when other |
| 683 | // sessions use the same workspace in this process (#5930). |
| 684 | if maybe_notify_snapshots_disabled_once(workspace, session_id, cap_bytes, &e) { |
| 685 | tracing::warn!(target: "snapshot", session_id, "snapshot repo init failed: {e}"); |
| 686 | } else { |
| 687 | tracing::debug!(target: "snapshot", "snapshot repo init still failing: {e}"); |
| 688 | } |
| 689 | None |
| 690 | } |
| 691 | } |
| 692 | } |
| 693 | |
| 694 | /// Which gate turned snapshots off. Each variant selects its own consequence |
| 695 | /// and recovery copy: only [`Self::WorkspaceTooLarge`] is lifted by |
| 696 | /// [`SNAPSHOTS_CAP_CONFIG_KEY`], so the other two must never advertise it. |
| 697 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 698 | pub enum SnapshotsDisabledScope { |
| 699 | /// Snapshot-eligible content exceeds `[snapshots] max_workspace_gb`. |
| 700 | WorkspaceTooLarge, |
| 701 | /// The bounded walk hit the entry ceiling. Raising (or zeroing) the GB cap |
| 702 | /// does not lift this bound. |
| 703 | TooManyFiles, |
| 704 | /// Home, filesystem root, or a top-level home folder: refused for safety, |
| 705 | /// and no config value changes that. |
| 706 | UnsafeLocation, |
| 707 | } |
| 708 | |
| 709 | /// Snapshot availability observed for a session and its workspace. Delivering |
| 710 | /// the notice does not erase the status: `/status` can still explain why undo |
| 711 | /// is unavailable after the transient toast has expired (#5930). |
| 712 | /// |
| 713 | /// The notice carries the gate, not prose: every surface renders exactly one |
| 714 | /// localized line from it, so the workspace, the limit, and the recovery are |
| 715 | /// each stated once (#6042). |
| 716 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 717 | pub struct SnapshotsDisabledNotice { |
| 718 | pub workspace: String, |
| 719 | pub scope: SnapshotsDisabledScope, |
| 720 | /// Preformatted limit for the scope that names one (`2.0 GB`, `200000`). |
| 721 | /// Empty for scopes whose message names no limit. |
| 722 | pub limit: String, |
| 723 | } |
| 724 | |
| 725 | impl SnapshotsDisabledNotice { |
| 726 | fn message_id(&self) -> codewhale_localization::MessageId { |
| 727 | use codewhale_localization::MessageId; |
| 728 | match self.scope { |
| 729 | SnapshotsDisabledScope::WorkspaceTooLarge => MessageId::SnapshotsDisabledTooLarge, |
| 730 | SnapshotsDisabledScope::TooManyFiles => MessageId::SnapshotsDisabledTooManyFiles, |
| 731 | SnapshotsDisabledScope::UnsafeLocation => MessageId::SnapshotsDisabledUnsafeLocation, |
| 732 | } |
| 733 | } |
| 734 | |
| 735 | /// The single user-facing line: what is off, for which workspace, why, and |
| 736 | /// the recovery that actually applies to this gate. |
| 737 | pub fn localize(&self, locale: codewhale_localization::Locale) -> String { |
| 738 | codewhale_localization::tr(locale, self.message_id()) |
| 739 | .replace("{workspace}", &self.workspace) |
| 740 | .replace("{limit}", &self.limit) |
| 741 | .replace("{config_key}", SNAPSHOTS_CAP_CONFIG_KEY) |
| 742 | } |
| 743 | } |
| 744 | |
| 745 | /// The config key that lifts the size gate. Named only by the size-gate |
| 746 | /// notice: it is not a remedy for the entry ceiling or the safety refusal. |
| 747 | pub const SNAPSHOTS_CAP_CONFIG_KEY: &str = "[snapshots] max_workspace_gb"; |
| 748 | |
| 749 | /// Human-readable byte cap for the size-gate notice. Keeps small test caps |
| 750 | /// from rendering as a misleading `0 GB`. |
| 751 | fn format_cap_bytes(bytes: u64) -> String { |
| 752 | const KIB: f64 = 1024.0; |
| 753 | let value = bytes as f64; |
| 754 | if value >= KIB.powi(3) { |
| 755 | format!("{:.1} GB", value / KIB.powi(3)) |
| 756 | } else if value >= KIB.powi(2) { |
| 757 | format!("{:.1} MB", value / KIB.powi(2)) |
| 758 | } else if value >= KIB { |
| 759 | format!("{:.1} KB", value / KIB) |
| 760 | } else { |
| 761 | format!("{bytes} bytes") |
| 762 | } |
| 763 | } |
| 764 | |
| 765 | type SnapshotNoticeKey = (std::path::PathBuf, Option<String>); |
| 766 | |
| 767 | #[derive(Default)] |
| 768 | struct SnapshotNoticeState { |
| 769 | warned: bool, |
| 770 | pending: bool, |
| 771 | disabled: Option<SnapshotsDisabledNotice>, |
| 772 | } |
| 773 | |
| 774 | fn snapshot_notices() |
| 775 | -> &'static std::sync::Mutex<std::collections::HashMap<SnapshotNoticeKey, SnapshotNoticeState>> { |
| 776 | static NOTICES: std::sync::OnceLock< |
| 777 | std::sync::Mutex<std::collections::HashMap<SnapshotNoticeKey, SnapshotNoticeState>>, |
| 778 | > = std::sync::OnceLock::new(); |
| 779 | NOTICES.get_or_init(Default::default) |
| 780 | } |
| 781 | |
| 782 | fn snapshot_notice_key(workspace: &Path, session_id: Option<&str>) -> SnapshotNoticeKey { |
| 783 | (workspace.to_path_buf(), session_id.map(str::to_owned)) |
| 784 | } |
| 785 | |
| 786 | /// Take only this session's pending delivery. Other sessions in the same |
| 787 | /// workspace keep their own notice; the observed disabled status remains. |
| 788 | pub fn take_snapshots_disabled_notices( |
| 789 | workspace: &Path, |
| 790 | session_id: Option<&str>, |
| 791 | ) -> Vec<SnapshotsDisabledNotice> { |
| 792 | let mut states = snapshot_notices() |
| 793 | .lock() |
| 794 | .unwrap_or_else(std::sync::PoisonError::into_inner); |
| 795 | let Some(state) = states.get_mut(&snapshot_notice_key(workspace, session_id)) else { |
| 796 | return Vec::new(); |
| 797 | }; |
| 798 | if !std::mem::take(&mut state.pending) { |
| 799 | return Vec::new(); |
| 800 | } |
| 801 | state.disabled.iter().cloned().collect() |
| 802 | } |
| 803 | |
| 804 | /// Non-consuming availability projection for the current session's status. |
| 805 | pub fn snapshots_disabled_status( |
| 806 | workspace: &Path, |
| 807 | session_id: Option<&str>, |
| 808 | ) -> Option<SnapshotsDisabledNotice> { |
| 809 | snapshot_notices() |
| 810 | .lock() |
| 811 | .unwrap_or_else(std::sync::PoisonError::into_inner) |
| 812 | .get(&snapshot_notice_key(workspace, session_id)) |
| 813 | .and_then(|state| state.disabled.clone()) |
| 814 | } |
| 815 | |
| 816 | fn clear_snapshots_disabled_status(workspace: &Path, session_id: Option<&str>) { |
| 817 | if let Some(state) = snapshot_notices() |
| 818 | .lock() |
| 819 | .unwrap_or_else(std::sync::PoisonError::into_inner) |
| 820 | .get_mut(&snapshot_notice_key(workspace, session_id)) |
| 821 | { |
| 822 | state.disabled = None; |
| 823 | state.pending = false; |
| 824 | } |
| 825 | } |
| 826 | |
| 827 | // Keep stderr for headless sessions. The TUI receives the same notice via the |
| 828 | // existing Engine event, and `/status` reads the retained observation. |
| 829 | // Production snapshot callers always supply the current Engine session id; |
| 830 | // callers without one retain the legacy workspace scope. |
| 831 | #[allow(clippy::print_stderr)] |
| 832 | fn maybe_notify_snapshots_disabled_once( |
| 833 | workspace: &Path, |
| 834 | session_id: Option<&str>, |
| 835 | cap_bytes: u64, |
| 836 | error: &std::io::Error, |
| 837 | ) -> bool { |
| 838 | let message = error.to_string(); |
| 839 | // The gate markers are declared by the snapshot policy that produces them, |
| 840 | // so this stays one classifier rather than a second copy of the rules. |
| 841 | let scope = if message.contains(crate::snapshot::GATE_TOO_LARGE_MARKER) { |
| 842 | SnapshotsDisabledScope::WorkspaceTooLarge |
| 843 | } else if message.contains(crate::snapshot::GATE_TOO_MANY_ENTRIES_MARKER) { |
| 844 | SnapshotsDisabledScope::TooManyFiles |
| 845 | } else if message.contains(crate::snapshot::GATE_UNSAFE_LOCATION_MARKER) { |
| 846 | SnapshotsDisabledScope::UnsafeLocation |
| 847 | } else { |
| 848 | // A real snapshot/data-loss error, not a gate: leave it to the caller's |
| 849 | // WARN so it is never softened into a "snapshots are off" notice. |
| 850 | return true; |
| 851 | }; |
| 852 | let notice = SnapshotsDisabledNotice { |
| 853 | workspace: workspace.to_string_lossy().into_owned(), |
| 854 | scope, |
| 855 | limit: match scope { |
| 856 | SnapshotsDisabledScope::WorkspaceTooLarge => format_cap_bytes(cap_bytes), |
| 857 | SnapshotsDisabledScope::TooManyFiles => { |
| 858 | crate::snapshot::SIZE_WALK_MAX_ENTRIES.to_string() |
| 859 | } |
| 860 | SnapshotsDisabledScope::UnsafeLocation => String::new(), |
| 861 | }, |
| 862 | }; |
| 863 | let mut states = snapshot_notices() |
| 864 | .lock() |
| 865 | .unwrap_or_else(std::sync::PoisonError::into_inner); |
| 866 | let state = states |
| 867 | .entry(snapshot_notice_key(workspace, session_id)) |
| 868 | .or_default(); |
| 869 | state.disabled = Some(notice.clone()); |
| 870 | if std::mem::replace(&mut state.warned, true) { |
| 871 | return false; |
| 872 | } |
| 873 | state.pending = true; |
| 874 | drop(states); |
| 875 | // Headless stderr has no session locale to resolve; English is the pack |
| 876 | // this path has always printed. The TUI and `/status` localize properly. |
| 877 | eprintln!( |
| 878 | "warning: {}", |
| 879 | notice.localize(codewhale_localization::Locale::En) |
| 880 | ); |
| 881 | true |
| 882 | } |
| 883 | |
| 884 | #[cfg(test)] |
| 885 | mod snapshot_notice_tests { |
| 886 | use super::*; |
| 887 | use std::sync::{ |
| 888 | Arc, |
| 889 | atomic::{AtomicUsize, Ordering}, |
| 890 | }; |
| 891 | use tracing_subscriber::prelude::*; |
| 892 | |
| 893 | #[derive(Clone, Default)] |
| 894 | struct SnapshotWarnings(Arc<AtomicUsize>); |
| 895 | |
| 896 | impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for SnapshotWarnings { |
| 897 | fn on_event( |
| 898 | &self, |
| 899 | event: &tracing::Event<'_>, |
| 900 | _context: tracing_subscriber::layer::Context<'_, S>, |
| 901 | ) { |
| 902 | if event.metadata().target() == "snapshot" |
| 903 | && *event.metadata().level() == tracing::Level::WARN |
| 904 | { |
| 905 | self.0.fetch_add(1, Ordering::SeqCst); |
| 906 | } |
| 907 | } |
| 908 | } |
| 909 | |
| 910 | #[test] |
| 911 | fn oversized_workspace_warns_once_per_session_and_retains_status_after_delivery() { |
| 912 | let _env = crate::test_support::lock_test_env(); |
| 913 | let root = tempfile::tempdir().unwrap(); |
| 914 | let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.path()); |
| 915 | let _user_home = crate::test_support::EnvVarGuard::set("HOME", root.path()); |
| 916 | let _user_profile = crate::test_support::EnvVarGuard::set("USERPROFILE", root.path()); |
| 917 | let workspace = root.path().join("workspace"); |
| 918 | std::fs::create_dir(&workspace).unwrap(); |
| 919 | std::fs::write(workspace.join("large.txt"), vec![b'x'; 4096]).unwrap(); |
| 920 | let warnings = SnapshotWarnings::default(); |
| 921 | let subscriber = tracing_subscriber::registry().with(warnings.clone()); |
| 922 | tracing::subscriber::with_default(subscriber, || { |
| 923 | for session in ["session-a", "session-b"] { |
| 924 | for turn in 1..=3 { |
| 925 | assert!( |
| 926 | pre_turn_snapshot(&workspace, turn, 1024, None, Some(session)).is_none() |
| 927 | ); |
| 928 | assert!( |
| 929 | post_turn_snapshot(&workspace, turn, 1024, None, Some(session)).is_none() |
| 930 | ); |
| 931 | } |
| 932 | } |
| 933 | }); |
| 934 | assert_eq!( |
| 935 | warnings.0.load(Ordering::SeqCst), |
| 936 | 2, |
| 937 | "exactly one real WARN for each session" |
| 938 | ); |
| 939 | for session in ["session-b", "session-a"] { |
| 940 | let notices = take_snapshots_disabled_notices(&workspace, Some(session)); |
| 941 | assert_eq!(notices.len(), 1, "each session receives its own notice"); |
| 942 | assert_eq!(notices[0].scope, SnapshotsDisabledScope::WorkspaceTooLarge); |
| 943 | let line = notices[0].localize(codewhale_localization::Locale::En); |
| 944 | assert_eq!(line.lines().count(), 1, "one line, not a stacked notice"); |
| 945 | assert_eq!( |
| 946 | line.matches(&workspace.display().to_string()).count(), |
| 947 | 1, |
| 948 | "the workspace is named exactly once: {line}" |
| 949 | ); |
| 950 | assert_eq!( |
| 951 | line.matches(SNAPSHOTS_CAP_CONFIG_KEY).count(), |
| 952 | 1, |
| 953 | "the remedy is stated exactly once: {line}" |
| 954 | ); |
| 955 | assert!(line.contains("1.0 KB"), "the tripped cap is named: {line}"); |
| 956 | assert!(take_snapshots_disabled_notices(&workspace, Some(session)).is_empty()); |
| 957 | assert_eq!( |
| 958 | snapshots_disabled_status(&workspace, Some(session)), |
| 959 | notices.first().cloned(), |
| 960 | "delivery must not erase /status" |
| 961 | ); |
| 962 | } |
| 963 | assert!(snapshots_disabled_status(&workspace, Some("session-c")).is_none()); |
| 964 | assert!(snapshots_disabled_status(&root.path().join("other"), Some("session-a")).is_none()); |
| 965 | } |
| 966 | |
| 967 | /// The quiet case: a workspace under the cap snapshots and says nothing. |
| 968 | #[test] |
| 969 | fn small_workspace_snapshots_with_no_notice_at_all() { |
| 970 | let _env = crate::test_support::lock_test_env(); |
| 971 | let root = tempfile::tempdir().unwrap(); |
| 972 | let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.path()); |
| 973 | let _user_home = crate::test_support::EnvVarGuard::set("HOME", root.path()); |
| 974 | let _user_profile = crate::test_support::EnvVarGuard::set("USERPROFILE", root.path()); |
| 975 | let workspace = root.path().join("workspace"); |
| 976 | std::fs::create_dir(&workspace).unwrap(); |
| 977 | std::fs::write(workspace.join("small.txt"), b"tiny").unwrap(); |
| 978 | assert!(pre_turn_snapshot(&workspace, 1, 1024 * 1024, None, Some("session")).is_some()); |
| 979 | assert!(snapshots_disabled_status(&workspace, Some("session")).is_none()); |
| 980 | assert!(take_snapshots_disabled_notices(&workspace, Some("session")).is_empty()); |
| 981 | } |
| 982 | |
| 983 | #[test] |
| 984 | fn successful_snapshot_clears_disabled_status_and_pending_notice() { |
| 985 | let _env = crate::test_support::lock_test_env(); |
| 986 | let root = tempfile::tempdir().unwrap(); |
| 987 | let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.path()); |
| 988 | let _user_home = crate::test_support::EnvVarGuard::set("HOME", root.path()); |
| 989 | let _user_profile = crate::test_support::EnvVarGuard::set("USERPROFILE", root.path()); |
| 990 | let workspace = root.path().join("workspace"); |
| 991 | std::fs::create_dir(&workspace).unwrap(); |
| 992 | std::fs::write(workspace.join("large.txt"), vec![b'x'; 4096]).unwrap(); |
| 993 | assert!(pre_turn_snapshot(&workspace, 1, 1024, None, Some("session")).is_none()); |
| 994 | assert!(snapshots_disabled_status(&workspace, Some("session")).is_some()); |
| 995 | assert!(pre_turn_snapshot(&workspace, 2, 0, None, Some("session")).is_some()); |
| 996 | assert!(snapshots_disabled_status(&workspace, Some("session")).is_none()); |
| 997 | assert!(take_snapshots_disabled_notices(&workspace, Some("session")).is_empty()); |
| 998 | } |
| 999 | |
| 1000 | #[test] |
| 1001 | fn unrelated_snapshot_errors_are_not_gated_notices() { |
| 1002 | let workspace = tempfile::tempdir().unwrap(); |
| 1003 | let error = std::io::Error::other("disk full"); |
| 1004 | assert!(maybe_notify_snapshots_disabled_once( |
| 1005 | workspace.path(), |
| 1006 | Some("session"), |
| 1007 | 1024, |
| 1008 | &error |
| 1009 | )); |
| 1010 | assert!(take_snapshots_disabled_notices(workspace.path(), Some("session")).is_empty()); |
| 1011 | assert!(snapshots_disabled_status(workspace.path(), Some("session")).is_none()); |
| 1012 | } |
| 1013 | |
| 1014 | /// Every gate must state a recovery that actually lifts *that* gate. The |
| 1015 | /// entry ceiling and the home/root refusal are not raised by the GB cap, |
| 1016 | /// so naming it there is the unhelpful follow-up this packet removes. |
| 1017 | #[test] |
| 1018 | fn each_gate_gets_its_own_accurate_recovery() { |
| 1019 | let workspace = tempfile::tempdir().unwrap(); |
| 1020 | for (gate_message, scope, cap_bytes) in [ |
| 1021 | ( |
| 1022 | format!( |
| 1023 | "{}: over 2 bytes in x", |
| 1024 | crate::snapshot::GATE_TOO_MANY_ENTRIES_MARKER |
| 1025 | ), |
| 1026 | SnapshotsDisabledScope::TooManyFiles, |
| 1027 | 0, |
| 1028 | ), |
| 1029 | ( |
| 1030 | format!( |
| 1031 | "{} for home directory: x", |
| 1032 | crate::snapshot::GATE_UNSAFE_LOCATION_MARKER |
| 1033 | ), |
| 1034 | SnapshotsDisabledScope::UnsafeLocation, |
| 1035 | 2 * 1024 * 1024 * 1024, |
| 1036 | ), |
| 1037 | ] { |
| 1038 | let session = format!("{scope:?}"); |
| 1039 | let error = std::io::Error::new(std::io::ErrorKind::InvalidInput, gate_message); |
| 1040 | assert!(maybe_notify_snapshots_disabled_once( |
| 1041 | workspace.path(), |
| 1042 | Some(&session), |
| 1043 | cap_bytes, |
| 1044 | &error |
| 1045 | )); |
| 1046 | let notice = snapshots_disabled_status(workspace.path(), Some(&session)) |
| 1047 | .expect("gated error must be retained for /status"); |
| 1048 | assert_eq!(notice.scope, scope); |
| 1049 | let line = notice.localize(codewhale_localization::Locale::En); |
| 1050 | assert_eq!(line.lines().count(), 1, "one line, not a stacked notice"); |
| 1051 | assert!( |
| 1052 | !line.contains(SNAPSHOTS_CAP_CONFIG_KEY), |
| 1053 | "{scope:?} must not advertise a config key that cannot lift it: {line}" |
| 1054 | ); |
| 1055 | assert!(line.contains("/undo"), "the consequence is named: {line}"); |
| 1056 | if scope == SnapshotsDisabledScope::TooManyFiles { |
| 1057 | // The `{limit}` this notice carries is the entry ceiling. |
| 1058 | // Nothing else asserts it reaches the user, so a dropped |
| 1059 | // placeholder would render "more than files" silently. |
| 1060 | assert!( |
| 1061 | line.contains(&crate::snapshot::SIZE_WALK_MAX_ENTRIES.to_string()), |
| 1062 | "the entry ceiling must be stated, not left as a blank limit: {line}" |
| 1063 | ); |
| 1064 | } |
| 1065 | } |
| 1066 | } |
| 1067 | |
| 1068 | #[test] |
| 1069 | fn oversize_notice_names_the_cap_and_only_then_the_config_key() { |
| 1070 | let workspace = tempfile::tempdir().unwrap(); |
| 1071 | let error = std::io::Error::new( |
| 1072 | std::io::ErrorKind::InvalidInput, |
| 1073 | format!( |
| 1074 | "{}: over x bytes in y", |
| 1075 | crate::snapshot::GATE_TOO_LARGE_MARKER |
| 1076 | ), |
| 1077 | ); |
| 1078 | assert!(maybe_notify_snapshots_disabled_once( |
| 1079 | workspace.path(), |
| 1080 | Some("session"), |
| 1081 | 2 * 1024 * 1024 * 1024, |
| 1082 | &error |
| 1083 | )); |
| 1084 | let notice = |
| 1085 | snapshots_disabled_status(workspace.path(), Some("session")).expect("retained"); |
| 1086 | let line = notice.localize(codewhale_localization::Locale::En); |
| 1087 | assert!(line.contains("2.0 GB"), "{line}"); |
| 1088 | assert!(line.contains(SNAPSHOTS_CAP_CONFIG_KEY), "{line}"); |
| 1089 | } |
| 1090 | } |
| 1091 | |
| 1092 | #[cfg(test)] |
| 1093 | mod snapshot_label_tests { |
| 1094 | use super::*; |
| 1095 | |
| 1096 | #[test] |
| 1097 | fn label_writer_and_parser_agree_on_prompt_snippet() { |
| 1098 | let prompt = "rename the widget\nsecond line is dropped"; |
| 1099 | let label = format_snapshot_label("pre-turn", 7, Some(prompt)); |
| 1100 | assert_eq!(label, "pre-turn:7: rename the widget"); |
| 1101 | |
| 1102 | let parsed = parse_snapshot_label(&label); |
| 1103 | assert_eq!(parsed.kind, "pre-turn"); |
| 1104 | assert_eq!(parsed.seq, Some(7)); |
| 1105 | assert_eq!( |
| 1106 | parsed.prompt_snippet.as_deref(), |
| 1107 | snapshot_label_prompt_snippet(prompt).as_deref(), |
| 1108 | "a reader must recover exactly the snippet the writer embedded" |
| 1109 | ); |
| 1110 | } |
| 1111 | |
| 1112 | #[test] |
| 1113 | fn truncated_prompt_round_trips_with_its_ellipsis() { |
| 1114 | let prompt = "x".repeat(USER_PROMPT_LABEL_MAX + 25); |
| 1115 | let label = format_snapshot_label("post-turn", 2, Some(&prompt)); |
| 1116 | let parsed = parse_snapshot_label(&label); |
| 1117 | let snippet = parsed.prompt_snippet.expect("snippet"); |
| 1118 | assert!(snippet.ends_with('…')); |
| 1119 | assert_eq!(snippet.chars().count(), USER_PROMPT_LABEL_MAX + 1); |
| 1120 | assert_eq!( |
| 1121 | Some(snippet), |
| 1122 | snapshot_label_prompt_snippet(&prompt), |
| 1123 | "truncated snippets must also round-trip" |
| 1124 | ); |
| 1125 | } |
| 1126 | |
| 1127 | #[test] |
| 1128 | fn labels_without_a_prompt_parse_without_inventing_one() { |
| 1129 | let label = format_snapshot_label("pre-turn", 3, None); |
| 1130 | assert_eq!(label, "pre-turn:3"); |
| 1131 | let parsed = parse_snapshot_label(&label); |
| 1132 | assert_eq!(parsed.kind, "pre-turn"); |
| 1133 | assert_eq!(parsed.seq, Some(3)); |
| 1134 | assert_eq!(parsed.prompt_snippet, None); |
| 1135 | } |
| 1136 | |
| 1137 | #[test] |
| 1138 | fn tool_labels_carry_a_call_id_not_a_sequence() { |
| 1139 | let label = format!("tool:{}", "call_abc123"); |
| 1140 | let parsed = parse_snapshot_label(&label); |
| 1141 | assert_eq!(parsed.kind, "tool"); |
| 1142 | assert_eq!(parsed.seq, None, "a call id is not a turn sequence"); |
| 1143 | assert_eq!(parsed.prompt_snippet, None); |
| 1144 | } |
| 1145 | |
| 1146 | #[test] |
| 1147 | fn unrecognized_labels_are_reported_rather_than_dropped() { |
| 1148 | let parsed = parse_snapshot_label("manual checkpoint"); |
| 1149 | assert_eq!(parsed.kind, "manual checkpoint"); |
| 1150 | assert_eq!(parsed.seq, None); |
| 1151 | assert_eq!(parsed.prompt_snippet, None); |
| 1152 | } |
| 1153 | |
| 1154 | #[test] |
| 1155 | fn empty_prompt_contributes_no_snippet() { |
| 1156 | assert_eq!(snapshot_label_prompt_snippet(""), None); |
| 1157 | assert_eq!(format_snapshot_label("pre-turn", 1, Some("")), "pre-turn:1"); |
| 1158 | } |
| 1159 | } |
| 1160 |