返回 CodeWhale
turn.rs
根目录 / crates / tui / src / core / turn.rs
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
1160 lines RUST