1#![allow(clippy::unwrap_used)]
2use anyhow::Result;
3use rand::RngExt;
4use serde::{Deserialize, Serialize};
5use sha2::{Digest, Sha256};
6use std::collections::{HashMap, VecDeque};
7use std::fmt::Write as _;
8use std::path::{Path, PathBuf};
9use std::sync::Arc;
10use std::time::{Duration, Instant};
11use tokio::sync::Mutex;
12use tracing::info;
13
14use crate::state::persistence::{
15 load_bash_state as load_state_file, save_bash_state as save_state_file, BashStateSnapshot,
16};
17use crate::state::pty::PtyShell;
18use crate::types::{
19 AllowedCommands, AllowedGlobs, BashCommandMode, BashMode, FileEditMode, Modes, WriteIfEmptyMode,
20};
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct FileWhitelistData {
24 pub file_hash: String,
25 pub line_ranges_read: Vec<(usize, usize)>,
26 pub total_lines: usize,
27}
28
29fn clamped_sorted(ranges: &[(usize, usize)], total_lines: usize) -> Vec<(usize, usize)> {
32 let mut v: Vec<(usize, usize)> = ranges
33 .iter()
34 .map(|&(s, e)| (s.max(1), e.min(total_lines)))
35 .filter(|&(s, e)| s <= e)
36 .collect();
37 v.sort_unstable();
38 v
39}
40
41fn covered_line_count(ranges: &[(usize, usize)], total_lines: usize) -> usize {
44 let mut covered = 0usize;
45 let mut last_end = 0usize; for (s, e) in clamped_sorted(ranges, total_lines) {
47 let s = s.max(last_end + 1);
48 if s <= e {
49 covered += e - s + 1;
50 last_end = e;
51 }
52 }
53 covered
54}
55
56impl FileWhitelistData {
57 pub fn new(
58 file_hash: String,
59 line_ranges_read: Vec<(usize, usize)>,
60 total_lines: usize,
61 ) -> Self {
62 let mut data = Self { file_hash, line_ranges_read: Vec::new(), total_lines };
63 data.merge_ranges(line_ranges_read);
64 data
65 }
66
67 pub fn is_read_enough(&self) -> bool {
68 self.get_percentage_read() >= 99.0
69 }
70
71 #[allow(clippy::cast_precision_loss)]
72 pub fn get_percentage_read(&self) -> f64 {
73 if self.total_lines == 0 {
74 return 100.0;
75 }
76 let covered = covered_line_count(&self.line_ranges_read, self.total_lines);
80 (covered as f64 / self.total_lines as f64) * 100.0
81 }
82
83 pub fn get_unread_ranges(&self) -> Vec<(usize, usize)> {
84 if self.total_lines == 0 {
85 return vec![];
86 }
87 let sorted = clamped_sorted(&self.line_ranges_read, self.total_lines);
88 let mut unread = vec![];
89 let mut next = 1usize; for (s, e) in sorted {
91 if s > next {
92 unread.push((next, s - 1));
93 }
94 next = next.max(e.saturating_add(1));
95 }
96 if next <= self.total_lines {
97 unread.push((next, self.total_lines));
98 }
99 unread
100 }
101
102 pub fn add_range(&mut self, start: usize, end: usize) {
107 self.merge_ranges(std::iter::once((start, end)));
108 }
109
110 pub fn merge_ranges(&mut self, new: impl IntoIterator<Item = (usize, usize)>) {
113 self.line_ranges_read.extend(new);
114 self.line_ranges_read.retain(|(s, e)| s <= e);
115 self.line_ranges_read.sort_unstable();
116 let mut merged: Vec<(usize, usize)> = Vec::with_capacity(self.line_ranges_read.len());
117 for (s, e) in self.line_ranges_read.drain(..) {
118 match merged.last_mut() {
119 Some(last) if s <= last.1.saturating_add(1) => last.1 = last.1.max(e),
120 _ => merged.push((s, e)),
121 }
122 }
123 self.line_ranges_read = merged;
124 }
125
126 pub fn needs_more_reading(&self) -> bool {
127 !self.is_read_enough()
128 }
129
130 pub fn covers_range(&self, start: usize, end: usize) -> bool {
133 start <= end
134 && self
135 .line_ranges_read
136 .iter()
137 .any(|(covered_start, covered_end)| *covered_start <= start && *covered_end >= end)
138 }
139}
140
141const EDIT_CHECKPOINT_CAP: usize = 10;
144const EDIT_CHECKPOINT_CONTENT_BYTES_CAP: usize = 4 * 1024 * 1024;
147const EDIT_CHECKPOINT_METADATA_BYTES_CAP: usize = 256 * 1024;
151const EDIT_CHECKPOINT_MAX_READ_RANGES: usize = 1_024;
152
153const EDIT_CHECKPOINT_MAX_CONTENT_BYTES: usize = 1_000_000;
158
159const MAX_WHITELIST_FILES: usize = 1_024;
164
165const IMAGE_DELIVERY_CACHE_CAP: usize = 32;
169
170const EDIT_MUTATION_RECEIPT_CAP: usize = 64;
174pub(crate) const SHELL_RESET_COOLDOWN: Duration = Duration::from_secs(5 * 60);
177
178#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
179pub(crate) struct EditMutationPostcondition {
180 pub path: String,
181 pub sha256: String,
182}
183
184#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
185pub(crate) struct EditMutationReceipt {
186 #[serde(default)]
187 pub schema_version: u8,
188 pub fingerprint: String,
189 pub receipt_id: String,
190 pub tool: String,
191 pub status: String,
192 pub committed_at_unix_ms: u64,
193 pub postconditions: Vec<EditMutationPostcondition>,
194 #[serde(default)]
195 pub persisted: bool,
196 #[serde(default)]
197 pub verification_pending: bool,
198 #[serde(default, skip_serializing_if = "Option::is_none")]
199 pub verification_id: Option<String>,
200 #[serde(default, skip_serializing_if = "Option::is_none")]
201 pub legacy_verification_id: Option<String>,
202 #[serde(default, skip_serializing_if = "Option::is_none")]
203 pub verification_state: Option<EditVerificationState>,
204 #[serde(default, skip_serializing_if = "Option::is_none")]
205 pub verification_execution: Option<EditVerificationExecution>,
206 #[serde(default, skip_serializing_if = "Vec::is_empty")]
207 pub uncommitted_paths: Vec<String>,
208 #[serde(default, skip_serializing_if = "Option::is_none")]
209 pub next_undo_id: Option<String>,
210 #[serde(default, skip_serializing_if = "Vec::is_empty")]
211 pub undo_ids: Vec<Option<String>>,
212}
213
214#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
215#[serde(rename_all = "snake_case")]
216pub(crate) enum EditVerificationState {
217 Pending,
218 Reserved,
219 Running,
220 Interrupted,
221 Skipped,
222 Passed,
223 Failed,
224}
225
226#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
227pub(crate) struct EditVerificationExecution {
228 pub generation: u64,
229 pub guardian_epoch: String,
230 pub session_epoch: String,
231}
232
233#[derive(Debug, Clone)]
237pub struct EditCheckpoint {
238 pub file_path_str: String,
240 pub path: PathBuf,
241 pub prior_content: String,
243 pub prior_whitelist: Option<FileWhitelistData>,
246}
247
248#[derive(Debug, Clone)]
249struct EditCheckpointMetadata {
250 checkpoint_fingerprint: String,
251 undo_id: String,
252 wrote_hash: String,
253}
254
255#[derive(Debug, Clone)]
256pub struct BashState {
257 pub cwd: PathBuf,
258 pub workspace_root: PathBuf,
259 pub current_thread_id: String,
260 pub mode: Modes,
261 pub bash_command_mode: BashCommandMode,
262 pub file_edit_mode: FileEditMode,
263 pub write_if_empty_mode: WriteIfEmptyMode,
264 pub whitelist_for_overwrite: HashMap<String, FileWhitelistData>,
265 whitelist_recency: VecDeque<String>,
269 pub pty_shell: Arc<Mutex<Option<PtyShell>>>,
270 pub foreground_command_gate: Arc<Mutex<()>>,
273 pub initialized: bool,
274 pub edit_checkpoints: VecDeque<EditCheckpoint>,
278 edit_checkpoint_metadata: VecDeque<EditCheckpointMetadata>,
279 pub derived_code_map_usage: crate::utils::agent_temp::DerivedCodeMapUsage,
282 image_deliveries: VecDeque<String>,
283 edit_mutation_receipts: VecDeque<EditMutationReceipt>,
284 last_shell_reset_at: Option<Instant>,
287 shell_runtime_failure_since_reset: bool,
288 pub recovery_note: Option<String>,
293}
294
295impl Default for BashState {
296 fn default() -> Self {
297 Self::new()
298 }
299}
300
301impl BashState {
302 pub fn new() -> Self {
303 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/tmp"));
304 Self {
305 cwd: cwd.clone(),
306 workspace_root: cwd,
307 current_thread_id: generate_thread_id(),
308 mode: Modes::Wcgw,
309 bash_command_mode: BashCommandMode {
310 bash_mode: BashMode::NormalMode,
311 allowed_commands: AllowedCommands::All("all".to_string()),
312 },
313 file_edit_mode: FileEditMode { allowed_globs: AllowedGlobs::All("all".to_string()) },
314 write_if_empty_mode: WriteIfEmptyMode {
315 allowed_globs: AllowedGlobs::All("all".to_string()),
316 },
317 whitelist_for_overwrite: HashMap::new(),
318 whitelist_recency: VecDeque::new(),
319 pty_shell: Arc::new(Mutex::new(None)),
320 foreground_command_gate: Arc::new(Mutex::new(())),
321 initialized: false,
322 edit_checkpoints: VecDeque::new(),
323 edit_checkpoint_metadata: VecDeque::new(),
324 derived_code_map_usage: crate::utils::agent_temp::DerivedCodeMapUsage::default(),
325 image_deliveries: VecDeque::new(),
326 edit_mutation_receipts: VecDeque::new(),
327 last_shell_reset_at: None,
328 shell_runtime_failure_since_reset: false,
329 recovery_note: None,
330 }
331 }
332
333 pub(crate) fn shell_reset_retry_after(&self, now: Instant) -> Option<Duration> {
336 if self.shell_runtime_failure_since_reset {
337 return None;
338 }
339 let last_reset = self.last_shell_reset_at?;
340 SHELL_RESET_COOLDOWN
341 .checked_sub(now.saturating_duration_since(last_reset))
342 .filter(|remaining| !remaining.is_zero())
343 }
344
345 pub(crate) fn record_shell_reset(&mut self, now: Instant) {
346 self.last_shell_reset_at = Some(now);
347 self.shell_runtime_failure_since_reset = false;
348 }
349
350 pub(crate) fn record_shell_runtime_failure(&mut self) {
351 if self.last_shell_reset_at.is_some() {
352 self.shell_runtime_failure_since_reset = true;
353 }
354 }
355
356 pub(crate) fn edit_mutation_receipt(
357 &mut self,
358 fingerprint: &str,
359 now_unix_ms: u64,
360 ttl_ms: u64,
361 ) -> Option<EditMutationReceipt> {
362 self.prune_edit_mutation_receipts(now_unix_ms, ttl_ms);
363 let index = self
364 .edit_mutation_receipts
365 .iter()
366 .rposition(|receipt| receipt.fingerprint == fingerprint)?;
367 let receipt = self.edit_mutation_receipts.remove(index)?;
368 self.edit_mutation_receipts.push_back(receipt.clone());
369 Some(receipt)
370 }
371
372 pub(crate) fn edit_mutation_receipt_variant(
373 &mut self,
374 fingerprint: &str,
375 verification_id: Option<&str>,
376 now_unix_ms: u64,
377 ttl_ms: u64,
378 ) -> Option<EditMutationReceipt> {
379 self.prune_edit_mutation_receipts(now_unix_ms, ttl_ms);
380 let index = self.edit_mutation_receipts.iter().rposition(|receipt| {
381 receipt.fingerprint == fingerprint
382 && receipt.verification_id.as_deref() == verification_id
383 })?;
384 let receipt = self.edit_mutation_receipts.remove(index)?;
385 self.edit_mutation_receipts.push_back(receipt.clone());
386 Some(receipt)
387 }
388
389 #[cfg(test)]
390 pub(crate) fn record_edit_mutation_receipt(&mut self, receipt: EditMutationReceipt) {
391 self.record_edit_mutation_receipts([receipt]);
392 }
393
394 pub(crate) fn record_edit_mutation_receipts(
395 &mut self,
396 receipts: impl IntoIterator<Item = EditMutationReceipt>,
397 ) {
398 let receipts = receipts.into_iter().collect::<Vec<_>>();
399 for receipt in &receipts {
400 self.edit_mutation_receipts.retain(|existing| {
401 !(existing.fingerprint == receipt.fingerprint
402 && existing.verification_id == receipt.verification_id
403 && existing.legacy_verification_id == receipt.legacy_verification_id)
404 });
405 }
406 self.edit_mutation_receipts.extend(receipts);
407 while self.edit_mutation_receipts.len() > EDIT_MUTATION_RECEIPT_CAP {
408 let Some(group) =
409 self.edit_mutation_receipts.front().map(|receipt| receipt.receipt_id.clone())
410 else {
411 break;
412 };
413 self.edit_mutation_receipts.retain(|receipt| receipt.receipt_id != group);
414 }
415 }
416
417 fn prune_edit_mutation_receipts(&mut self, now_unix_ms: u64, ttl_ms: u64) {
418 let expired_groups = self
419 .edit_mutation_receipts
420 .iter()
421 .filter(|receipt| now_unix_ms.saturating_sub(receipt.committed_at_unix_ms) > ttl_ms)
422 .map(|receipt| receipt.receipt_id.clone())
423 .collect::<std::collections::HashSet<_>>();
424 self.edit_mutation_receipts.retain(|receipt| !expired_groups.contains(&receipt.receipt_id));
425 }
426
427 pub(crate) fn edit_mutation_receipt_by_verification_id(
428 &mut self,
429 verification_id: &str,
430 now_unix_ms: u64,
431 ttl_ms: u64,
432 ) -> Option<EditMutationReceipt> {
433 self.prune_edit_mutation_receipts(now_unix_ms, ttl_ms);
434 let index = self.edit_mutation_receipts.iter().position(|receipt| {
435 receipt.schema_version >= 1
436 && receipt.tool == "EditFiles"
437 && receipt.verification_id.as_deref() == Some(verification_id)
438 })?;
439 let receipt = self.edit_mutation_receipts.remove(index)?;
440 self.edit_mutation_receipts.push_back(receipt.clone());
441 Some(receipt)
442 }
443
444 pub(crate) fn edit_mutation_receipt_by_legacy_verification_id(
445 &mut self,
446 verification_id: &str,
447 now_unix_ms: u64,
448 ttl_ms: u64,
449 ) -> Option<EditMutationReceipt> {
450 self.prune_edit_mutation_receipts(now_unix_ms, ttl_ms);
451 let index = self.edit_mutation_receipts.iter().position(|receipt| {
452 receipt.tool != "EditFiles"
453 && receipt.legacy_verification_id.as_deref() == Some(verification_id)
454 })?;
455 let receipt = self.edit_mutation_receipts.remove(index)?;
456 self.edit_mutation_receipts.push_back(receipt.clone());
457 Some(receipt)
458 }
459
460 pub(crate) fn update_edit_mutation_verification(
461 &mut self,
462 verification_id: &str,
463 status: &str,
464 verification_state: EditVerificationState,
465 execution: Option<&EditVerificationExecution>,
466 now_unix_ms: u64,
467 ttl_ms: u64,
468 ) -> bool {
469 self.prune_edit_mutation_receipts(now_unix_ms, ttl_ms);
470 let mut updated = false;
471 for receipt in &mut self.edit_mutation_receipts {
472 if receipt.verification_id.as_deref() == Some(verification_id) {
473 receipt.status = status.to_string();
474 receipt.verification_pending = matches!(
475 verification_state,
476 EditVerificationState::Pending
477 | EditVerificationState::Reserved
478 | EditVerificationState::Running
479 | EditVerificationState::Interrupted
480 );
481 receipt.verification_state = Some(verification_state);
482 receipt.verification_execution.clone_from(&execution.cloned());
483 updated = true;
484 }
485 }
486 updated
487 }
488
489 pub(crate) fn mark_edit_mutation_verification_volatile(&mut self, verification_id: &str) {
490 for receipt in &mut self.edit_mutation_receipts {
491 if receipt.verification_id.as_deref() == Some(verification_id) {
492 receipt.persisted = false;
493 }
494 }
495 }
496
497 pub(crate) fn mark_edit_mutation_receipt_volatile(&mut self, fingerprint: &str) {
498 if let Some(receipt) = self
499 .edit_mutation_receipts
500 .iter_mut()
501 .find(|receipt| receipt.fingerprint == fingerprint)
502 {
503 receipt.persisted = false;
504 }
505 }
506
507 pub(crate) fn image_was_delivered(&mut self, fingerprint: &str) -> bool {
511 let Some(index) = self.image_deliveries.iter().position(|item| item == fingerprint) else {
512 return false;
513 };
514 if let Some(item) = self.image_deliveries.remove(index) {
515 self.image_deliveries.push_back(item);
516 }
517 true
518 }
519
520 pub(crate) fn record_image_delivery(&mut self, fingerprint: String) {
522 if let Some(index) = self.image_deliveries.iter().position(|item| item == &fingerprint) {
523 self.image_deliveries.remove(index);
524 }
525 self.image_deliveries.push_back(fingerprint);
526 while self.image_deliveries.len() > IMAGE_DELIVERY_CACHE_CAP {
527 self.image_deliveries.pop_front();
528 }
529 }
530
531 pub fn push_edit_checkpoint(&mut self, checkpoint: EditCheckpoint) {
535 let _ = self.push_edit_checkpoint_inner(checkpoint, None);
536 }
537
538 pub(crate) fn push_receipt_bound_edit_checkpoint(
539 &mut self,
540 checkpoint: EditCheckpoint,
541 wrote_hash: String,
542 ) -> Option<String> {
543 let undo_id = format!("undo_{:032x}", rand::random::<u128>());
544 self.push_edit_checkpoint_inner(
545 checkpoint,
546 Some(EditCheckpointMetadata {
547 checkpoint_fingerprint: String::new(),
548 undo_id: undo_id.clone(),
549 wrote_hash,
550 }),
551 )
552 .then_some(undo_id)
553 }
554
555 fn push_edit_checkpoint_inner(
556 &mut self,
557 mut checkpoint: EditCheckpoint,
558 metadata: Option<EditCheckpointMetadata>,
559 ) -> bool {
560 if checkpoint.prior_content.len() > EDIT_CHECKPOINT_MAX_CONTENT_BYTES {
561 info!(
562 file = %checkpoint.file_path_str,
563 "UndoEdit: not checkpointing a file over 1 MB (too large to hold in memory)"
564 );
565 return false;
566 }
567 sanitize_checkpoint_metadata(&mut checkpoint);
568 let fingerprint = checkpoint_fingerprint(&checkpoint);
569 self.edit_checkpoints.push_back(checkpoint);
570 if let Some(mut metadata) = metadata {
571 metadata.checkpoint_fingerprint.clone_from(&fingerprint);
572 self.edit_checkpoint_metadata.push_back(metadata);
573 }
574 while self.edit_checkpoints.len() > EDIT_CHECKPOINT_CAP
575 || self.undo_content_bytes() > EDIT_CHECKPOINT_CONTENT_BYTES_CAP
576 || self.undo_metadata_bytes() > EDIT_CHECKPOINT_METADATA_BYTES_CAP
577 {
578 if let Some(removed) = self.edit_checkpoints.pop_front() {
579 self.remove_checkpoint_metadata(&checkpoint_fingerprint(&removed));
580 } else {
581 break;
582 }
583 }
584 self.reconcile_checkpoint_metadata();
585 self.edit_checkpoints.iter().any(|item| checkpoint_fingerprint(item) == fingerprint)
586 }
587
588 pub fn pop_edit_checkpoint_for(&mut self, file_path_str: &str) -> Option<EditCheckpoint> {
592 let index =
593 self.edit_checkpoints.iter().rposition(|cp| cp.file_path_str == file_path_str)?;
594 let checkpoint = self.edit_checkpoints.remove(index)?;
595 self.remove_checkpoint_metadata(&checkpoint_fingerprint(&checkpoint));
596 Some(checkpoint)
597 }
598
599 pub(crate) fn pop_latest_edit_checkpoint_by_id(
600 &mut self,
601 file_path_str: &str,
602 undo_id: &str,
603 ) -> Option<EditCheckpoint> {
604 let index = self
605 .edit_checkpoints
606 .iter()
607 .rposition(|checkpoint| checkpoint.file_path_str == file_path_str)?;
608 let fingerprint = checkpoint_fingerprint(self.edit_checkpoints.get(index)?);
609 if self
610 .edit_checkpoint_metadata
611 .iter()
612 .rev()
613 .find(|metadata| metadata.checkpoint_fingerprint == fingerprint)?
614 .undo_id
615 != undo_id
616 {
617 return None;
618 }
619 let checkpoint = self.edit_checkpoints.remove(index)?;
620 self.remove_checkpoint_metadata(&fingerprint);
621 Some(checkpoint)
622 }
623
624 pub fn latest_edit_checkpoint_for(&self, file_path_str: &str) -> Option<EditCheckpoint> {
627 self.edit_checkpoints
628 .iter()
629 .rev()
630 .find(|checkpoint| checkpoint.file_path_str == file_path_str)
631 .cloned()
632 }
633
634 pub(crate) fn undo_checkpoint_count_for(&self, file_path_str: &str) -> usize {
635 self.edit_checkpoints
636 .iter()
637 .filter(|checkpoint| checkpoint.file_path_str == file_path_str)
638 .count()
639 }
640
641 pub(crate) fn next_undo_id_for(&self, file_path_str: &str) -> Option<String> {
642 let checkpoint = self
643 .edit_checkpoints
644 .iter()
645 .rev()
646 .find(|checkpoint| checkpoint.file_path_str == file_path_str)?;
647 let fingerprint = checkpoint_fingerprint(checkpoint);
648 self.edit_checkpoint_metadata
649 .iter()
650 .rev()
651 .find(|metadata| metadata.checkpoint_fingerprint == fingerprint)
652 .map(|metadata| metadata.undo_id.clone())
653 }
654
655 pub(crate) fn latest_receipt_bound_checkpoint(
656 &self,
657 file_path_str: &str,
658 ) -> Option<(EditCheckpoint, String, String)> {
659 let checkpoint = self
660 .edit_checkpoints
661 .iter()
662 .rev()
663 .find(|checkpoint| checkpoint.file_path_str == file_path_str)?;
664 let fingerprint = checkpoint_fingerprint(checkpoint);
665 let metadata = self
666 .edit_checkpoint_metadata
667 .iter()
668 .rev()
669 .find(|metadata| metadata.checkpoint_fingerprint == fingerprint)?;
670 Some((checkpoint.clone(), metadata.undo_id.clone(), metadata.wrote_hash.clone()))
671 }
672
673 pub(crate) fn has_receipt_bound_checkpoint(&self, file_path_str: &str, undo_id: &str) -> bool {
677 self.edit_checkpoints.iter().rev().any(|checkpoint| {
678 if checkpoint.file_path_str != file_path_str {
679 return false;
680 }
681 let fingerprint = checkpoint_fingerprint(checkpoint);
682 self.edit_checkpoint_metadata.iter().rev().any(|metadata| {
683 metadata.checkpoint_fingerprint == fingerprint && metadata.undo_id == undo_id
684 })
685 })
686 }
687
688 fn undo_content_bytes(&self) -> usize {
689 self.edit_checkpoints.iter().map(|checkpoint| checkpoint.prior_content.len()).sum()
690 }
691
692 fn undo_metadata_bytes(&self) -> usize {
693 self.edit_checkpoints.iter().map(checkpoint_metadata_bytes).sum()
694 }
695
696 fn remove_checkpoint_metadata(&mut self, fingerprint: &str) {
697 if let Some(index) = self
698 .edit_checkpoint_metadata
699 .iter()
700 .position(|metadata| metadata.checkpoint_fingerprint == fingerprint)
701 {
702 self.edit_checkpoint_metadata.remove(index);
703 }
704 }
705
706 fn reconcile_checkpoint_metadata(&mut self) {
707 let mut available = HashMap::<String, usize>::new();
708 for checkpoint in &self.edit_checkpoints {
709 *available.entry(checkpoint_fingerprint(checkpoint)).or_default() += 1;
710 }
711 self.edit_checkpoint_metadata.retain(|metadata| {
712 let Some(count) = available.get_mut(&metadata.checkpoint_fingerprint) else {
713 return false;
714 };
715 if *count == 0 {
716 return false;
717 }
718 *count -= 1;
719 true
720 });
721 }
722
723 pub fn record_read_coverage(
728 &mut self,
729 path: &str,
730 ranges: impl IntoIterator<Item = (usize, usize)>,
731 file_hash: String,
732 total_lines: usize,
733 ) {
734 let ranges = ranges.into_iter().collect::<Vec<_>>();
735 match self.whitelist_for_overwrite.get_mut(path) {
736 Some(existing)
737 if existing.file_hash == file_hash && existing.total_lines == total_lines =>
738 {
739 existing.merge_ranges(ranges);
740 }
741 Some(existing) => {
742 *existing = FileWhitelistData::new(file_hash, ranges, total_lines);
743 }
744 None => {
745 self.whitelist_for_overwrite.insert(
746 path.to_string(),
747 FileWhitelistData::new(file_hash, ranges, total_lines),
748 );
749 }
750 }
751 self.touch_whitelist(path);
752 self.enforce_whitelist_cap();
753 }
754
755 pub fn set_whitelist_entry(&mut self, path: &str, entry: FileWhitelistData) {
757 self.whitelist_for_overwrite.insert(path.to_string(), entry);
758 self.touch_whitelist(path);
759 self.enforce_whitelist_cap();
760 }
761
762 pub fn remove_whitelist_entry(&mut self, path: &str) -> Option<FileWhitelistData> {
764 self.whitelist_recency.retain(|candidate| candidate != path);
765 self.whitelist_for_overwrite.remove(path)
766 }
767
768 fn touch_whitelist(&mut self, path: &str) {
769 self.whitelist_recency.retain(|candidate| candidate != path);
770 self.whitelist_recency.push_back(path.to_string());
771 }
772
773 fn rebuild_whitelist_recency(&mut self) {
774 let mut paths = self.whitelist_for_overwrite.keys().cloned().collect::<Vec<_>>();
775 paths.sort_unstable();
776 self.whitelist_recency = paths.into();
777 self.enforce_whitelist_cap();
778 }
779
780 fn enforce_whitelist_cap(&mut self) {
781 self.whitelist_recency.retain(|path| self.whitelist_for_overwrite.contains_key(path));
782 while self.whitelist_for_overwrite.len() > MAX_WHITELIST_FILES {
783 let victim = self.whitelist_recency.pop_front().or_else(|| {
784 self.whitelist_for_overwrite.keys().min().cloned()
787 });
788 let Some(victim) = victim else { break };
789 self.whitelist_for_overwrite.remove(&victim);
790 }
791 }
792
793 pub async fn init_pty_shell(&mut self) -> Result<()> {
794 let cwd = self.cwd.clone();
795 let workspace_root = self.workspace_root.clone();
796 let temporary_artifact_dir =
797 crate::utils::agent_temp::session_info(&workspace_root, &self.current_thread_id)
798 .directory;
799 let restricted = self.bash_command_mode.bash_mode == BashMode::RestrictedMode;
800 let shell = tokio::task::spawn_blocking(move || {
804 PtyShell::new_with_agent_paths(
805 &cwd,
806 restricted,
807 Some(&workspace_root),
808 Some(&temporary_artifact_dir),
809 )
810 })
811 .await
812 .map_err(|e| {
813 crate::errors::WinxError::ShellInitializationError(format!("PTY init task failed: {e}"))
814 })??;
815 *self.pty_shell.lock().await = Some(shell);
816 Ok(())
817 }
818
819 pub fn update_cwd(&mut self, path: &Path) -> Result<()> {
820 self.cwd = path.to_path_buf();
821 Ok(())
822 }
823
824 pub fn update_workspace_root(&mut self, path: &Path) -> Result<()> {
825 self.workspace_root = path.to_path_buf();
826 Ok(())
827 }
828
829 pub fn is_command_allowed(&self, command: &str) -> bool {
830 if self.mode == Modes::Architect {
831 crate::utils::bash_parser::is_architect_command_allowed(command)
832 } else {
833 self.bash_command_mode.allowed_commands.is_allowed(command)
834 }
835 }
836
837 pub fn is_file_edit_allowed(&self, path: &str) -> bool {
838 self.file_edit_mode.allowed_globs.is_allowed(path)
839 }
840
841 pub fn is_file_write_allowed(&self, path: &str) -> bool {
842 self.write_if_empty_mode.allowed_globs.is_allowed(path)
843 }
844 pub fn save_state_to_disk(&self) -> Result<()> {
845 let snapshot = self.snapshot();
846 save_state_file(&self.current_thread_id, &snapshot)?;
847 Ok(())
848 }
849
850 pub fn snapshot(&self) -> BashStateSnapshot {
851 BashStateSnapshot::from_state(
852 &self.cwd.to_string_lossy(),
853 &self.workspace_root.to_string_lossy(),
854 self.mode,
855 &self.bash_command_mode,
856 &self.file_edit_mode,
857 &self.write_if_empty_mode,
858 &self.whitelist_for_overwrite,
859 &self.current_thread_id,
860 &self.edit_mutation_receipts,
861 )
862 }
863
864 pub fn apply_snapshot(&mut self, snapshot: &BashStateSnapshot) {
865 let (cwd, root, mode, bmode, emode, wmode, whitelist, tid) = snapshot.to_state_components();
866 let root = PathBuf::from(root);
867 let image_identity_changed = self.current_thread_id != tid || self.workspace_root != root;
868
869 self.cwd = PathBuf::from(cwd);
870 self.workspace_root = root;
871 self.mode = mode;
872 self.bash_command_mode = bmode;
873 self.file_edit_mode = emode;
874 self.write_if_empty_mode = wmode;
875 self.whitelist_for_overwrite = whitelist;
876 self.rebuild_whitelist_recency();
877 self.current_thread_id = tid;
878 self.edit_mutation_receipts = snapshot.edit_mutation_receipts.iter().cloned().collect();
879 self.edit_checkpoints.clear();
883 self.edit_checkpoint_metadata.clear();
884 for receipt in &mut self.edit_mutation_receipts {
885 receipt.undo_ids.clear();
889 receipt.next_undo_id = None;
890 if matches!(
891 receipt.verification_state,
892 Some(EditVerificationState::Reserved | EditVerificationState::Running)
893 ) {
894 receipt.verification_state = Some(EditVerificationState::Interrupted);
895 receipt.verification_pending = true;
896 receipt.verification_execution = None;
897 receipt.status = "completed_with_issues".to_string();
898 }
899 }
900 if image_identity_changed {
901 self.image_deliveries.clear();
902 }
903 self.initialized = true;
904 }
905
906 pub fn load_state_from_disk(&mut self, thread_id: &str) -> Result<bool> {
907 if let Some(snapshot) = load_state_file(thread_id)? {
908 self.apply_snapshot(&snapshot);
909 Ok(true)
910 } else {
911 Ok(false)
912 }
913 }
914
915 pub fn new_with_thread_id(thread_id: Option<&str>) -> Self {
916 let mut state = Self::new();
917
918 if let Some(tid) = thread_id {
919 if !tid.is_empty() {
920 if let Ok(true) = state.load_state_from_disk(tid) {
921 info!("Loaded state for thread_id '{}'", tid);
922 } else {
923 state.current_thread_id = tid.to_string();
924 }
925 }
926 }
927
928 state
929 }
930}
931
932fn sanitize_checkpoint_metadata(checkpoint: &mut EditCheckpoint) {
933 let Some(whitelist) = checkpoint.prior_whitelist.as_mut() else { return };
934 let mut ranges = std::mem::take(&mut whitelist.line_ranges_read);
935 ranges.sort_unstable();
936 let mut bounded: Vec<(usize, usize)> =
937 Vec::with_capacity(ranges.len().min(EDIT_CHECKPOINT_MAX_READ_RANGES));
938 for (start, end) in ranges {
939 if start > end {
940 continue;
941 }
942 if let Some((_, previous_end)) = bounded.last_mut() {
943 if start <= previous_end.saturating_add(1) {
944 *previous_end = (*previous_end).max(end);
945 continue;
946 }
947 }
948 if bounded.len() < EDIT_CHECKPOINT_MAX_READ_RANGES {
949 bounded.push((start, end));
950 } else {
951 break;
952 }
953 }
954 whitelist.line_ranges_read = bounded;
955}
956
957fn checkpoint_metadata_bytes(checkpoint: &EditCheckpoint) -> usize {
958 let whitelist = checkpoint.prior_whitelist.as_ref().map_or(0, |whitelist| {
959 whitelist
960 .file_hash
961 .len()
962 .saturating_add(whitelist.line_ranges_read.len().saturating_mul(2 * size_of::<usize>()))
963 .saturating_add(size_of::<usize>())
964 });
965 checkpoint
966 .file_path_str
967 .len()
968 .saturating_add(checkpoint.path.as_os_str().as_encoded_bytes().len())
969 .saturating_add(whitelist)
970}
971
972fn checkpoint_fingerprint(checkpoint: &EditCheckpoint) -> String {
973 fn hash_field(hasher: &mut Sha256, bytes: &[u8]) {
974 hasher.update(u64::try_from(bytes.len()).unwrap_or(u64::MAX).to_le_bytes());
975 hasher.update(bytes);
976 }
977 let mut hasher = Sha256::new();
978 hash_field(&mut hasher, checkpoint.file_path_str.as_bytes());
979 hash_field(&mut hasher, checkpoint.path.as_os_str().as_encoded_bytes());
980 hash_field(&mut hasher, checkpoint.prior_content.as_bytes());
981 if let Some(whitelist) = checkpoint.prior_whitelist.as_ref() {
982 hash_field(&mut hasher, whitelist.file_hash.as_bytes());
983 hasher.update(u64::try_from(whitelist.total_lines).unwrap_or(u64::MAX).to_le_bytes());
984 for (start, end) in &whitelist.line_ranges_read {
985 hasher.update(u64::try_from(*start).unwrap_or(u64::MAX).to_le_bytes());
986 hasher.update(u64::try_from(*end).unwrap_or(u64::MAX).to_le_bytes());
987 }
988 }
989 let digest = hasher.finalize();
990 let mut encoded = String::with_capacity(digest.len() * 2);
991 for byte in digest {
992 let _ = write!(encoded, "{byte:02x}");
993 }
994 encoded
995}
996
997pub fn generate_thread_id() -> String {
998 let mut rng = rand::rng();
999 format!("tid_{:x}", rng.random::<u64>())
1000}
1001
1002#[cfg(test)]
1003mod whitelist_range_tests {
1004 #![allow(clippy::expect_used)]
1005
1006 use std::collections::HashMap;
1007 use std::time::{Duration, Instant};
1008
1009 use super::{
1010 BashState, EditCheckpoint, EditMutationPostcondition, EditMutationReceipt,
1011 EditVerificationState, FileWhitelistData, EDIT_CHECKPOINT_CAP,
1012 EDIT_CHECKPOINT_CONTENT_BYTES_CAP, EDIT_CHECKPOINT_MAX_READ_RANGES,
1013 EDIT_CHECKPOINT_METADATA_BYTES_CAP, EDIT_MUTATION_RECEIPT_CAP, MAX_WHITELIST_FILES,
1014 SHELL_RESET_COOLDOWN,
1015 };
1016
1017 fn wl(ranges: &[(usize, usize)], total: usize) -> FileWhitelistData {
1018 FileWhitelistData::new("h".to_string(), ranges.to_vec(), total)
1019 }
1020
1021 #[test]
1022 fn repeated_shell_reset_needs_failure_evidence_until_cooldown_expires() {
1023 let mut state = BashState::new();
1024 let first_reset = Instant::now();
1025 assert!(state.shell_reset_retry_after(first_reset).is_none());
1026
1027 state.record_shell_reset(first_reset);
1028 assert_eq!(
1029 state.shell_reset_retry_after(first_reset + Duration::from_secs(30)),
1030 Some(SHELL_RESET_COOLDOWN.saturating_sub(Duration::from_secs(30)))
1031 );
1032
1033 state.record_shell_runtime_failure();
1034 assert!(state.shell_reset_retry_after(first_reset + Duration::from_secs(31)).is_none());
1035
1036 state.record_shell_reset(first_reset + Duration::from_secs(31));
1037 assert!(state
1038 .shell_reset_retry_after(first_reset + Duration::from_secs(31) + SHELL_RESET_COOLDOWN)
1039 .is_none());
1040 }
1041
1042 #[test]
1043 fn merge_collapses_overlap_and_adjacency() {
1044 let w = wl(&[(4, 5), (1, 3), (8, 12), (7, 9)], 20);
1046 assert_eq!(w.line_ranges_read, vec![(1, 5), (7, 12)]);
1047 }
1048
1049 #[test]
1050 fn re_reading_does_not_grow_unbounded() {
1051 let mut w = wl(&[(1, 10)], 100);
1052 for _ in 0..1000 {
1053 w.merge_ranges(std::iter::once((1, 10)));
1054 w.merge_ranges(std::iter::once((5, 15)));
1055 }
1056 assert_eq!(w.line_ranges_read, vec![(1, 15)]);
1058 }
1059
1060 #[test]
1061 fn percentage_counts_distinct_lines_with_overlap() {
1062 let w = wl(&[(1, 5), (3, 8)], 10);
1064 assert!((w.get_percentage_read() - 80.0).abs() < 1e-9);
1065 assert!(wl(&[(1, 10)], 10).is_read_enough());
1066 }
1067
1068 #[test]
1069 fn unread_ranges_are_the_gaps() {
1070 let w = wl(&[(2, 4), (7, 8)], 10);
1072 assert_eq!(w.get_unread_ranges(), vec![(1, 1), (5, 6), (9, 10)]);
1073 }
1074
1075 #[test]
1076 fn out_of_range_entries_are_clamped() {
1077 let w = wl(&[(0, 999)], 10);
1079 assert!((w.get_percentage_read() - 100.0).abs() < 1e-9);
1080 assert!(w.get_unread_ranges().is_empty(), "expected empty w.get_unread_ranges()");
1081 }
1082
1083 #[test]
1084 fn coverage_from_different_file_versions_is_not_combined() {
1085 let mut state = BashState::new();
1086 state.record_read_coverage("/workspace/file.rs", [(1, 50)], "old-hash".to_string(), 100);
1087 state.record_read_coverage("/workspace/file.rs", [(51, 100)], "new-hash".to_string(), 100);
1088
1089 let coverage = &state.whitelist_for_overwrite["/workspace/file.rs"];
1090 assert_eq!(coverage.file_hash, "new-hash");
1091 assert_eq!(coverage.line_ranges_read, vec![(51, 100)]);
1092 assert_eq!(coverage.get_unread_ranges(), vec![(1, 50)]);
1093 }
1094
1095 #[test]
1096 fn whitelist_is_lru_bounded_and_recent_entries_survive() {
1097 let mut state = BashState::new();
1098 for index in 0..MAX_WHITELIST_FILES {
1099 let path = format!("/workspace/file-{index:04}.rs");
1100 state.record_read_coverage(&path, [(1, 1)], format!("hash-{index}"), 1);
1101 }
1102
1103 state.record_read_coverage("/workspace/file-0000.rs", [(1, 1)], "hash-0".to_string(), 1);
1106 state.record_read_coverage("/workspace/newest.rs", [(1, 1)], "newest-hash".to_string(), 1);
1107
1108 assert_eq!(state.whitelist_for_overwrite.len(), MAX_WHITELIST_FILES);
1109 assert!(state.whitelist_for_overwrite.contains_key("/workspace/file-0000.rs"));
1110 assert!(!state.whitelist_for_overwrite.contains_key("/workspace/file-0001.rs"));
1111 assert!(state.whitelist_for_overwrite.contains_key("/workspace/newest.rs"));
1112 }
1113
1114 #[test]
1115 fn image_delivery_cache_survives_same_session_snapshot_sync_only() {
1116 let mut state = BashState::new();
1117 state.workspace_root = "/workspace/one".into();
1118 state.current_thread_id = "one".to_string();
1119 state.record_image_delivery("fingerprint".to_string());
1120
1121 let same_session = state.snapshot();
1122 state.apply_snapshot(&same_session);
1123 assert!(state.image_was_delivered("fingerprint"));
1124
1125 let mut other = BashState::new();
1126 other.workspace_root = "/workspace/two".into();
1127 other.current_thread_id = "two".to_string();
1128 state.apply_snapshot(&other.snapshot());
1129 assert!(!state.image_was_delivered("fingerprint"));
1130 }
1131
1132 #[test]
1133 fn mutation_receipts_survive_snapshot_and_expire_closed() -> Result<(), &'static str> {
1134 let mut state = BashState::new();
1135 state.record_edit_mutation_receipt(EditMutationReceipt {
1136 schema_version: 0,
1137 fingerprint: "fingerprint".to_string(),
1138 receipt_id: "edit_receipt".to_string(),
1139 tool: "FileWriteOrEdit".to_string(),
1140 status: "completed".to_string(),
1141 committed_at_unix_ms: 1_000,
1142 postconditions: vec![EditMutationPostcondition {
1143 path: "/workspace/file.rs".to_string(),
1144 sha256: "hash".to_string(),
1145 }],
1146 persisted: true,
1147 verification_pending: false,
1148 verification_id: None,
1149 legacy_verification_id: None,
1150 verification_state: None,
1151 verification_execution: None,
1152 uncommitted_paths: Vec::new(),
1153 next_undo_id: None,
1154 undo_ids: Vec::new(),
1155 });
1156
1157 let snapshot = state.snapshot();
1158 let mut restored = BashState::new();
1159 restored.apply_snapshot(&snapshot);
1160 let receipt = restored
1161 .edit_mutation_receipt("fingerprint", 1_500, 1_000)
1162 .ok_or("fresh persisted receipt missing")?;
1163 assert_eq!(receipt.receipt_id, "edit_receipt");
1164 assert!(receipt.persisted);
1165 assert!(restored.edit_mutation_receipt("fingerprint", 3_000, 1_000).is_none());
1166 Ok(())
1167 }
1168
1169 #[test]
1170 fn receipt_bound_verification_updates_without_extending_commit_ttl() -> Result<(), &'static str>
1171 {
1172 let mut state = BashState::new();
1173 state.record_edit_mutation_receipt(EditMutationReceipt {
1174 schema_version: 1,
1175 fingerprint: "fingerprint".to_string(),
1176 receipt_id: "edit_receipt".to_string(),
1177 tool: "EditFiles".to_string(),
1178 status: "completed_with_issues".to_string(),
1179 committed_at_unix_ms: 1_000,
1180 postconditions: Vec::new(),
1181 persisted: true,
1182 verification_pending: true,
1183 verification_id: Some("verify_receipt".to_string()),
1184 legacy_verification_id: None,
1185 verification_state: Some(EditVerificationState::Pending),
1186 verification_execution: None,
1187 uncommitted_paths: Vec::new(),
1188 next_undo_id: None,
1189 undo_ids: Vec::new(),
1190 });
1191 assert!(state.update_edit_mutation_verification(
1192 "verify_receipt",
1193 "completed",
1194 EditVerificationState::Passed,
1195 None,
1196 1_500,
1197 1_000,
1198 ));
1199 let receipt = state
1200 .edit_mutation_receipt("fingerprint", 1_500, 1_000)
1201 .ok_or("updated receipt missing")?;
1202 assert_eq!(receipt.status, "completed");
1203 assert!(!receipt.verification_pending);
1204 assert_eq!(receipt.committed_at_unix_ms, 1_000);
1205 assert!(state.edit_mutation_receipt("fingerprint", 3_000, 1_000).is_none());
1206 Ok(())
1207 }
1208
1209 fn verification_receipt(
1210 fingerprint: &str,
1211 tool: &str,
1212 canonical_id: &str,
1213 legacy_id: Option<&str>,
1214 ) -> EditMutationReceipt {
1215 EditMutationReceipt {
1216 schema_version: 1,
1217 fingerprint: fingerprint.to_string(),
1218 receipt_id: format!("edit_{fingerprint}"),
1219 tool: tool.to_string(),
1220 status: "completed_with_issues".to_string(),
1221 committed_at_unix_ms: 1_000,
1222 postconditions: Vec::new(),
1223 persisted: true,
1224 verification_pending: true,
1225 verification_id: Some(canonical_id.to_string()),
1226 legacy_verification_id: legacy_id.map(str::to_string),
1227 verification_state: Some(EditVerificationState::Pending),
1228 verification_execution: None,
1229 uncommitted_paths: Vec::new(),
1230 next_undo_id: None,
1231 undo_ids: Vec::new(),
1232 }
1233 }
1234
1235 #[test]
1236 fn canonical_verification_lookup_ignores_reordered_legacy_shadow_collision(
1237 ) -> Result<(), &'static str> {
1238 let mut state = BashState::new();
1239 state.record_edit_mutation_receipt(verification_receipt(
1240 "canonical",
1241 "EditFiles",
1242 "verify_same",
1243 None,
1244 ));
1245 state.record_edit_mutation_receipt(verification_receipt(
1246 "shadow",
1247 "FileWriteOrEdit",
1248 "verify_same",
1249 Some("verify_legacy"),
1250 ));
1251
1252 let shadow = state
1253 .edit_mutation_receipt_by_legacy_verification_id("verify_legacy", 1_001, 10_000)
1254 .ok_or("legacy shadow missing")?;
1255 assert_eq!(shadow.tool, "FileWriteOrEdit");
1256 let canonical = state
1257 .edit_mutation_receipt_by_verification_id("verify_same", 1_001, 10_000)
1258 .ok_or("canonical receipt missing")?;
1259 assert_eq!(canonical.tool, "EditFiles");
1260 assert_eq!(canonical.fingerprint, "canonical");
1261 Ok(())
1262 }
1263
1264 #[test]
1265 fn mutation_receipt_lru_evicts_canonical_shadow_groups_atomically() {
1266 let mut state = BashState::new();
1267 for index in 0..=(EDIT_MUTATION_RECEIPT_CAP / 2) {
1268 let receipt_id = format!("edit-{index}");
1269 let canonical_id = format!("verify-{index}");
1270 let mut canonical = verification_receipt(
1271 &format!("canonical-{index}"),
1272 "EditFiles",
1273 &canonical_id,
1274 None,
1275 );
1276 canonical.receipt_id.clone_from(&receipt_id);
1277 let mut shadow = verification_receipt(
1278 &format!("legacy-{index}"),
1279 "FileWriteOrEdit",
1280 &canonical_id,
1281 Some(&format!("legacy-verify-{index}")),
1282 );
1283 shadow.receipt_id = receipt_id;
1284 state.record_edit_mutation_receipts([canonical, shadow]);
1285 }
1286
1287 let groups = state.edit_mutation_receipts.iter().fold(
1288 HashMap::<&str, usize>::new(),
1289 |mut groups, receipt| {
1290 *groups.entry(receipt.receipt_id.as_str()).or_default() += 1;
1291 groups
1292 },
1293 );
1294 assert!(state.edit_mutation_receipts.len() <= EDIT_MUTATION_RECEIPT_CAP);
1295 assert!(groups.values().all(|count| *count == 2));
1296 }
1297
1298 fn checkpoint(path: &str, _id: usize, prior_content: String) -> EditCheckpoint {
1299 EditCheckpoint {
1300 file_path_str: path.to_string(),
1301 path: path.into(),
1302 prior_content,
1303 prior_whitelist: None,
1304 }
1305 }
1306
1307 #[test]
1308 fn undo_history_is_lifo_per_file_and_bounded_by_count() {
1309 let mut state = BashState::new();
1310 let mut ids = Vec::new();
1311 for id in 0..=(EDIT_CHECKPOINT_CAP + 2) {
1312 ids.push(
1313 state
1314 .push_receipt_bound_edit_checkpoint(
1315 checkpoint("/workspace/hot.rs", id, "x".to_string()),
1316 format!("hash-{id}"),
1317 )
1318 .expect("retained newest checkpoint"),
1319 );
1320 }
1321 assert_eq!(state.undo_checkpoint_count_for("/workspace/hot.rs"), EDIT_CHECKPOINT_CAP);
1322 let latest = ids.last().expect("latest id");
1323 assert_eq!(state.next_undo_id_for("/workspace/hot.rs").as_deref(), Some(latest.as_str()));
1324 assert!(state.pop_latest_edit_checkpoint_by_id("/workspace/hot.rs", &ids[1]).is_none());
1325
1326 for id in 0..(EDIT_CHECKPOINT_CAP + 8) {
1327 let path = format!("/workspace/file-{id}.rs");
1328 state.push_edit_checkpoint(checkpoint(&path, 100 + id, String::new()));
1329 }
1330 assert!(state.edit_checkpoints.len() <= EDIT_CHECKPOINT_CAP);
1331 }
1332
1333 #[test]
1334 fn undo_content_and_metadata_budgets_are_deterministic() {
1335 let mut state = BashState::new();
1336 let chunk = "x".repeat(900_000);
1337 state.push_edit_checkpoint(checkpoint("/workspace/cold.rs", 1, chunk.clone()));
1338 for id in 2..=6 {
1339 state.push_edit_checkpoint(checkpoint("/workspace/hot.rs", id, chunk.clone()));
1340 }
1341
1342 assert!(state.undo_content_bytes() <= EDIT_CHECKPOINT_CONTENT_BYTES_CAP);
1343 assert!(state.edit_checkpoints.len() <= EDIT_CHECKPOINT_CAP);
1344
1345 let mut huge = checkpoint("/workspace/ranges.rs", 7, String::new());
1346 huge.prior_whitelist = Some(FileWhitelistData {
1347 file_hash: "h".repeat(1_024),
1348 line_ranges_read: (0..100_000).map(|index| (index * 2 + 1, index * 2 + 1)).collect(),
1349 total_lines: 200_000,
1350 });
1351 state.push_edit_checkpoint(huge);
1352 let retained = state.latest_edit_checkpoint_for("/workspace/ranges.rs").expect("retained");
1353 assert!(
1354 retained.prior_whitelist.expect("whitelist").line_ranges_read.len()
1355 <= EDIT_CHECKPOINT_MAX_READ_RANGES
1356 );
1357 assert!(state.undo_metadata_bytes() <= EDIT_CHECKPOINT_METADATA_BYTES_CAP);
1358 }
1359
1360 #[test]
1361 fn undo_content_is_intentionally_absent_after_snapshot_restart() {
1362 let mut state = BashState::new();
1363 state.push_edit_checkpoint(checkpoint(
1364 "/workspace/file.rs",
1365 1,
1366 "private prior source".to_string(),
1367 ));
1368 state.record_edit_mutation_receipt(EditMutationReceipt {
1369 schema_version: 1,
1370 fingerprint: "apply-fingerprint".to_string(),
1371 receipt_id: "edit-apply".to_string(),
1372 tool: "EditFiles".to_string(),
1373 status: "completed".to_string(),
1374 committed_at_unix_ms: 1_000,
1375 postconditions: Vec::new(),
1376 persisted: true,
1377 verification_pending: false,
1378 verification_id: None,
1379 legacy_verification_id: None,
1380 verification_state: None,
1381 verification_execution: None,
1382 uncommitted_paths: Vec::new(),
1383 next_undo_id: Some("undo-next".to_string()),
1384 undo_ids: vec![Some("undo-current".to_string()), None],
1385 });
1386 let snapshot = state.snapshot();
1387 let mut restored = BashState::new();
1388 restored.apply_snapshot(&snapshot);
1389
1390 assert!(restored.next_undo_id_for("/workspace/file.rs").is_none());
1391 assert_eq!(restored.undo_content_bytes(), 0);
1392 let replay = restored
1393 .edit_mutation_receipt("apply-fingerprint", 1_001, 10_000)
1394 .expect("mutation receipt remains available");
1395 assert!(replay.undo_ids.is_empty(), "expected empty replay.undo_ids");
1396 assert!(replay.next_undo_id.is_none());
1397 }
1398
1399 #[test]
1400 fn restart_interrupts_reserved_and_running_verifications_without_a_binding() {
1401 let mut state = BashState::new();
1402 for (fingerprint, verification_state, execution) in [
1403 ("reserved", EditVerificationState::Reserved, None),
1404 (
1405 "running",
1406 EditVerificationState::Running,
1407 Some(super::EditVerificationExecution {
1408 generation: 9,
1409 guardian_epoch: "old-guardian".to_string(),
1410 session_epoch: "old-session".to_string(),
1411 }),
1412 ),
1413 ] {
1414 state.record_edit_mutation_receipt(EditMutationReceipt {
1415 schema_version: 1,
1416 fingerprint: fingerprint.to_string(),
1417 receipt_id: format!("edit-{fingerprint}"),
1418 tool: "EditFiles".to_string(),
1419 status: "completed_with_issues".to_string(),
1420 committed_at_unix_ms: 1_000,
1421 postconditions: Vec::new(),
1422 persisted: true,
1423 verification_pending: true,
1424 verification_id: Some(format!("verify-{fingerprint}")),
1425 legacy_verification_id: None,
1426 verification_state: Some(verification_state),
1427 verification_execution: execution,
1428 uncommitted_paths: Vec::new(),
1429 next_undo_id: None,
1430 undo_ids: Vec::new(),
1431 });
1432 }
1433
1434 let snapshot = state.snapshot();
1435 let mut restored = BashState::new();
1436 restored.apply_snapshot(&snapshot);
1437 for fingerprint in ["reserved", "running"] {
1438 let receipt = restored
1439 .edit_mutation_receipt(fingerprint, 1_001, 10_000)
1440 .expect("receipt survives restart");
1441 assert_eq!(receipt.verification_state, Some(EditVerificationState::Interrupted));
1442 assert!(receipt.verification_execution.is_none());
1443 assert!(receipt.verification_pending);
1444 }
1445 }
1446}