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 receipt: Option<EditCheckpointReceipt>,
252}
253
254#[derive(Debug, Clone)]
255struct EditCheckpointReceipt {
256 undo_id: String,
257 wrote_hash: String,
258}
259
260#[derive(Debug, Clone)]
261pub struct BashState {
262 pub cwd: PathBuf,
263 pub workspace_root: PathBuf,
264 pub current_thread_id: String,
265 pub mode: Modes,
266 pub bash_command_mode: BashCommandMode,
267 pub file_edit_mode: FileEditMode,
268 pub write_if_empty_mode: WriteIfEmptyMode,
269 pub whitelist_for_overwrite: HashMap<String, FileWhitelistData>,
270 whitelist_recency: VecDeque<String>,
274 pub pty_shell: Arc<Mutex<Option<PtyShell>>>,
275 pub foreground_command_gate: Arc<Mutex<()>>,
278 pub initialized: bool,
279 pub edit_checkpoints: VecDeque<EditCheckpoint>,
283 edit_checkpoint_metadata: VecDeque<EditCheckpointMetadata>,
284 pub derived_code_map_usage: crate::utils::agent_temp::DerivedCodeMapUsage,
287 image_deliveries: VecDeque<String>,
288 edit_mutation_receipts: VecDeque<EditMutationReceipt>,
289 last_shell_reset_at: Option<Instant>,
292 shell_runtime_failure_since_reset: bool,
293 pub recovery_note: Option<String>,
298}
299
300impl Default for BashState {
301 fn default() -> Self {
302 Self::new()
303 }
304}
305
306impl BashState {
307 pub fn new() -> Self {
308 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/tmp"));
309 Self {
310 cwd: cwd.clone(),
311 workspace_root: cwd,
312 current_thread_id: generate_thread_id(),
313 mode: Modes::Wcgw,
314 bash_command_mode: BashCommandMode {
315 bash_mode: BashMode::NormalMode,
316 allowed_commands: AllowedCommands::All("all".to_string()),
317 },
318 file_edit_mode: FileEditMode { allowed_globs: AllowedGlobs::All("all".to_string()) },
319 write_if_empty_mode: WriteIfEmptyMode {
320 allowed_globs: AllowedGlobs::All("all".to_string()),
321 },
322 whitelist_for_overwrite: HashMap::new(),
323 whitelist_recency: VecDeque::new(),
324 pty_shell: Arc::new(Mutex::new(None)),
325 foreground_command_gate: Arc::new(Mutex::new(())),
326 initialized: false,
327 edit_checkpoints: VecDeque::new(),
328 edit_checkpoint_metadata: VecDeque::new(),
329 derived_code_map_usage: crate::utils::agent_temp::DerivedCodeMapUsage::default(),
330 image_deliveries: VecDeque::new(),
331 edit_mutation_receipts: VecDeque::new(),
332 last_shell_reset_at: None,
333 shell_runtime_failure_since_reset: false,
334 recovery_note: None,
335 }
336 }
337
338 pub(crate) fn shell_reset_retry_after(&self, now: Instant) -> Option<Duration> {
341 if self.shell_runtime_failure_since_reset {
342 return None;
343 }
344 let last_reset = self.last_shell_reset_at?;
345 SHELL_RESET_COOLDOWN
346 .checked_sub(now.saturating_duration_since(last_reset))
347 .filter(|remaining| !remaining.is_zero())
348 }
349
350 pub(crate) fn record_shell_reset(&mut self, now: Instant) {
351 self.last_shell_reset_at = Some(now);
352 self.shell_runtime_failure_since_reset = false;
353 }
354
355 pub(crate) fn record_shell_runtime_failure(&mut self) {
356 if self.last_shell_reset_at.is_some() {
357 self.shell_runtime_failure_since_reset = true;
358 }
359 }
360
361 pub(crate) fn edit_mutation_receipt(
362 &mut self,
363 fingerprint: &str,
364 now_unix_ms: u64,
365 ttl_ms: u64,
366 ) -> Option<EditMutationReceipt> {
367 self.prune_edit_mutation_receipts(now_unix_ms, ttl_ms);
368 let index = self
369 .edit_mutation_receipts
370 .iter()
371 .rposition(|receipt| receipt.fingerprint == fingerprint)?;
372 let receipt = self.edit_mutation_receipts.remove(index)?;
373 self.edit_mutation_receipts.push_back(receipt.clone());
374 Some(receipt)
375 }
376
377 pub(crate) fn edit_mutation_receipt_variant(
378 &mut self,
379 fingerprint: &str,
380 verification_id: Option<&str>,
381 now_unix_ms: u64,
382 ttl_ms: u64,
383 ) -> Option<EditMutationReceipt> {
384 self.prune_edit_mutation_receipts(now_unix_ms, ttl_ms);
385 let index = self.edit_mutation_receipts.iter().rposition(|receipt| {
386 receipt.fingerprint == fingerprint
387 && receipt.verification_id.as_deref() == verification_id
388 })?;
389 let receipt = self.edit_mutation_receipts.remove(index)?;
390 self.edit_mutation_receipts.push_back(receipt.clone());
391 Some(receipt)
392 }
393
394 #[cfg(test)]
395 pub(crate) fn record_edit_mutation_receipt(&mut self, receipt: EditMutationReceipt) {
396 self.record_edit_mutation_receipts([receipt]);
397 }
398
399 pub(crate) fn record_edit_mutation_receipts(
400 &mut self,
401 receipts: impl IntoIterator<Item = EditMutationReceipt>,
402 ) {
403 let receipts = receipts.into_iter().collect::<Vec<_>>();
404 for receipt in &receipts {
405 self.edit_mutation_receipts.retain(|existing| {
406 !(existing.fingerprint == receipt.fingerprint
407 && existing.verification_id == receipt.verification_id
408 && existing.legacy_verification_id == receipt.legacy_verification_id)
409 });
410 }
411 self.edit_mutation_receipts.extend(receipts);
412 while self.edit_mutation_receipts.len() > EDIT_MUTATION_RECEIPT_CAP {
413 let Some(group) =
414 self.edit_mutation_receipts.front().map(|receipt| receipt.receipt_id.clone())
415 else {
416 break;
417 };
418 self.edit_mutation_receipts.retain(|receipt| receipt.receipt_id != group);
419 }
420 }
421
422 fn prune_edit_mutation_receipts(&mut self, now_unix_ms: u64, ttl_ms: u64) {
423 let expired_groups = self
424 .edit_mutation_receipts
425 .iter()
426 .filter(|receipt| now_unix_ms.saturating_sub(receipt.committed_at_unix_ms) > ttl_ms)
427 .map(|receipt| receipt.receipt_id.clone())
428 .collect::<std::collections::HashSet<_>>();
429 self.edit_mutation_receipts.retain(|receipt| !expired_groups.contains(&receipt.receipt_id));
430 }
431
432 pub(crate) fn edit_mutation_receipt_by_verification_id(
433 &mut self,
434 verification_id: &str,
435 now_unix_ms: u64,
436 ttl_ms: u64,
437 ) -> Option<EditMutationReceipt> {
438 self.prune_edit_mutation_receipts(now_unix_ms, ttl_ms);
439 let index = self.edit_mutation_receipts.iter().position(|receipt| {
440 receipt.schema_version >= 1
441 && receipt.tool == "EditFiles"
442 && receipt.verification_id.as_deref() == Some(verification_id)
443 })?;
444 let receipt = self.edit_mutation_receipts.remove(index)?;
445 self.edit_mutation_receipts.push_back(receipt.clone());
446 Some(receipt)
447 }
448
449 pub(crate) fn edit_mutation_receipt_by_legacy_verification_id(
450 &mut self,
451 verification_id: &str,
452 now_unix_ms: u64,
453 ttl_ms: u64,
454 ) -> Option<EditMutationReceipt> {
455 self.prune_edit_mutation_receipts(now_unix_ms, ttl_ms);
456 let index = self.edit_mutation_receipts.iter().position(|receipt| {
457 receipt.tool != "EditFiles"
458 && receipt.legacy_verification_id.as_deref() == Some(verification_id)
459 })?;
460 let receipt = self.edit_mutation_receipts.remove(index)?;
461 self.edit_mutation_receipts.push_back(receipt.clone());
462 Some(receipt)
463 }
464
465 pub(crate) fn update_edit_mutation_verification(
466 &mut self,
467 verification_id: &str,
468 status: &str,
469 verification_state: EditVerificationState,
470 execution: Option<&EditVerificationExecution>,
471 now_unix_ms: u64,
472 ttl_ms: u64,
473 ) -> bool {
474 self.prune_edit_mutation_receipts(now_unix_ms, ttl_ms);
475 let mut updated = false;
476 for receipt in &mut self.edit_mutation_receipts {
477 if receipt.verification_id.as_deref() == Some(verification_id) {
478 receipt.status = status.to_string();
479 receipt.verification_pending = matches!(
480 verification_state,
481 EditVerificationState::Pending
482 | EditVerificationState::Reserved
483 | EditVerificationState::Running
484 | EditVerificationState::Interrupted
485 );
486 receipt.verification_state = Some(verification_state);
487 receipt.verification_execution.clone_from(&execution.cloned());
488 updated = true;
489 }
490 }
491 updated
492 }
493
494 pub(crate) fn mark_edit_mutation_verification_volatile(&mut self, verification_id: &str) {
495 for receipt in &mut self.edit_mutation_receipts {
496 if receipt.verification_id.as_deref() == Some(verification_id) {
497 receipt.persisted = false;
498 }
499 }
500 }
501
502 pub(crate) fn mark_edit_mutation_receipt_volatile(&mut self, fingerprint: &str) {
503 if let Some(receipt) = self
504 .edit_mutation_receipts
505 .iter_mut()
506 .find(|receipt| receipt.fingerprint == fingerprint)
507 {
508 receipt.persisted = false;
509 }
510 }
511
512 pub(crate) fn image_was_delivered(&mut self, fingerprint: &str) -> bool {
516 let Some(index) = self.image_deliveries.iter().position(|item| item == fingerprint) else {
517 return false;
518 };
519 if let Some(item) = self.image_deliveries.remove(index) {
520 self.image_deliveries.push_back(item);
521 }
522 true
523 }
524
525 pub(crate) fn record_image_delivery(&mut self, fingerprint: String) {
527 if let Some(index) = self.image_deliveries.iter().position(|item| item == &fingerprint) {
528 self.image_deliveries.remove(index);
529 }
530 self.image_deliveries.push_back(fingerprint);
531 while self.image_deliveries.len() > IMAGE_DELIVERY_CACHE_CAP {
532 self.image_deliveries.pop_front();
533 }
534 }
535
536 pub fn push_edit_checkpoint(&mut self, checkpoint: EditCheckpoint) {
540 let _ = self.push_edit_checkpoint_inner(checkpoint, None);
541 }
542
543 pub(crate) fn push_receipt_bound_edit_checkpoint(
544 &mut self,
545 checkpoint: EditCheckpoint,
546 wrote_hash: String,
547 ) -> Option<String> {
548 let undo_id = format!("undo_{:032x}", rand::random::<u128>());
549 self.push_edit_checkpoint_inner(
550 checkpoint,
551 Some(EditCheckpointReceipt { undo_id: undo_id.clone(), wrote_hash }),
552 )
553 .then_some(undo_id)
554 }
555
556 fn push_edit_checkpoint_inner(
557 &mut self,
558 mut checkpoint: EditCheckpoint,
559 receipt: Option<EditCheckpointReceipt>,
560 ) -> bool {
561 if checkpoint.prior_content.len() > EDIT_CHECKPOINT_MAX_CONTENT_BYTES {
562 info!(
563 file = %checkpoint.file_path_str,
564 "UndoEdit: not checkpointing a file over 1 MB (too large to hold in memory)"
565 );
566 return false;
567 }
568 sanitize_checkpoint_metadata(&mut checkpoint);
569 let fingerprint = checkpoint_fingerprint(&checkpoint);
570 self.edit_checkpoints.push_back(checkpoint);
571 self.edit_checkpoint_metadata.push_back(EditCheckpointMetadata {
574 checkpoint_fingerprint: fingerprint.clone(),
575 receipt,
576 });
577 while self.edit_checkpoints.len() > EDIT_CHECKPOINT_CAP
578 || self.undo_content_bytes() > EDIT_CHECKPOINT_CONTENT_BYTES_CAP
579 || self.undo_metadata_bytes() > EDIT_CHECKPOINT_METADATA_BYTES_CAP
580 {
581 if let Some(removed) = self.edit_checkpoints.pop_front() {
582 self.remove_oldest_checkpoint_metadata(&checkpoint_fingerprint(&removed));
583 } else {
584 break;
585 }
586 }
587 self.reconcile_checkpoint_metadata();
588 self.edit_checkpoints.iter().any(|item| checkpoint_fingerprint(item) == fingerprint)
589 }
590
591 pub fn pop_edit_checkpoint_for(&mut self, file_path_str: &str) -> Option<EditCheckpoint> {
595 let index =
596 self.edit_checkpoints.iter().rposition(|cp| cp.file_path_str == file_path_str)?;
597 let checkpoint = self.edit_checkpoints.remove(index)?;
598 let fingerprint = checkpoint_fingerprint(&checkpoint);
599 if let Some(metadata_index) = self
600 .edit_checkpoint_metadata
601 .iter()
602 .rposition(|metadata| metadata.checkpoint_fingerprint == fingerprint)
603 {
604 self.edit_checkpoint_metadata.remove(metadata_index);
605 }
606 Some(checkpoint)
607 }
608
609 pub(crate) fn pop_latest_edit_checkpoint_by_id(
610 &mut self,
611 file_path_str: &str,
612 undo_id: &str,
613 ) -> Option<EditCheckpoint> {
614 let index = self
615 .edit_checkpoints
616 .iter()
617 .rposition(|checkpoint| checkpoint.file_path_str == file_path_str)?;
618 let fingerprint = checkpoint_fingerprint(self.edit_checkpoints.get(index)?);
619 let metadata_index = self
620 .edit_checkpoint_metadata
621 .iter()
622 .rposition(|metadata| metadata.checkpoint_fingerprint == fingerprint)?;
623 let receipt = self.edit_checkpoint_metadata.get(metadata_index)?.receipt.as_ref()?;
624 if receipt.undo_id != undo_id {
625 return None;
626 }
627 let checkpoint = self.edit_checkpoints.remove(index)?;
628 self.edit_checkpoint_metadata.remove(metadata_index);
629 Some(checkpoint)
630 }
631
632 pub fn latest_edit_checkpoint_for(&self, file_path_str: &str) -> Option<EditCheckpoint> {
635 self.edit_checkpoints
636 .iter()
637 .rev()
638 .find(|checkpoint| checkpoint.file_path_str == file_path_str)
639 .cloned()
640 }
641
642 pub(crate) fn undo_checkpoint_count_for(&self, file_path_str: &str) -> usize {
643 self.edit_checkpoints
644 .iter()
645 .filter(|checkpoint| checkpoint.file_path_str == file_path_str)
646 .count()
647 }
648
649 pub(crate) fn next_undo_id_for(&self, file_path_str: &str) -> Option<String> {
650 let checkpoint = self
651 .edit_checkpoints
652 .iter()
653 .rev()
654 .find(|checkpoint| checkpoint.file_path_str == file_path_str)?;
655 let fingerprint = checkpoint_fingerprint(checkpoint);
656 self.edit_checkpoint_metadata
657 .iter()
658 .rev()
659 .find(|metadata| metadata.checkpoint_fingerprint == fingerprint)?
660 .receipt
661 .as_ref()
662 .map(|receipt| receipt.undo_id.clone())
663 }
664
665 pub(crate) fn latest_receipt_bound_checkpoint(
666 &self,
667 file_path_str: &str,
668 ) -> Option<(EditCheckpoint, String, String)> {
669 let checkpoint = self
670 .edit_checkpoints
671 .iter()
672 .rev()
673 .find(|checkpoint| checkpoint.file_path_str == file_path_str)?;
674 let fingerprint = checkpoint_fingerprint(checkpoint);
675 let metadata = self
676 .edit_checkpoint_metadata
677 .iter()
678 .rev()
679 .find(|metadata| metadata.checkpoint_fingerprint == fingerprint)?;
680 let receipt = metadata.receipt.as_ref()?;
681 Some((checkpoint.clone(), receipt.undo_id.clone(), receipt.wrote_hash.clone()))
682 }
683
684 pub(crate) fn has_receipt_bound_checkpoint(&self, file_path_str: &str, undo_id: &str) -> bool {
688 self.edit_checkpoints.iter().rev().any(|checkpoint| {
689 if checkpoint.file_path_str != file_path_str {
690 return false;
691 }
692 let fingerprint = checkpoint_fingerprint(checkpoint);
693 self.edit_checkpoint_metadata.iter().rev().any(|metadata| {
694 metadata.checkpoint_fingerprint == fingerprint
695 && metadata.receipt.as_ref().is_some_and(|receipt| receipt.undo_id == undo_id)
696 })
697 })
698 }
699
700 fn undo_content_bytes(&self) -> usize {
701 self.edit_checkpoints.iter().map(|checkpoint| checkpoint.prior_content.len()).sum()
702 }
703
704 fn undo_metadata_bytes(&self) -> usize {
705 self.edit_checkpoints.iter().map(checkpoint_metadata_bytes).sum()
706 }
707
708 fn remove_oldest_checkpoint_metadata(&mut self, fingerprint: &str) {
709 if let Some(index) = self
710 .edit_checkpoint_metadata
711 .iter()
712 .position(|metadata| metadata.checkpoint_fingerprint == fingerprint)
713 {
714 self.edit_checkpoint_metadata.remove(index);
715 }
716 }
717
718 fn reconcile_checkpoint_metadata(&mut self) {
719 let mut available = HashMap::<String, usize>::new();
720 for checkpoint in &self.edit_checkpoints {
721 *available.entry(checkpoint_fingerprint(checkpoint)).or_default() += 1;
722 }
723 self.edit_checkpoint_metadata.retain(|metadata| {
724 let Some(count) = available.get_mut(&metadata.checkpoint_fingerprint) else {
725 return false;
726 };
727 if *count == 0 {
728 return false;
729 }
730 *count -= 1;
731 true
732 });
733 }
734
735 pub fn record_read_coverage(
740 &mut self,
741 path: &str,
742 ranges: impl IntoIterator<Item = (usize, usize)>,
743 file_hash: String,
744 total_lines: usize,
745 ) {
746 let ranges = ranges.into_iter().collect::<Vec<_>>();
747 match self.whitelist_for_overwrite.get_mut(path) {
748 Some(existing)
749 if existing.file_hash == file_hash && existing.total_lines == total_lines =>
750 {
751 existing.merge_ranges(ranges);
752 }
753 Some(existing) => {
754 *existing = FileWhitelistData::new(file_hash, ranges, total_lines);
755 }
756 None => {
757 self.whitelist_for_overwrite.insert(
758 path.to_string(),
759 FileWhitelistData::new(file_hash, ranges, total_lines),
760 );
761 }
762 }
763 self.touch_whitelist(path);
764 self.enforce_whitelist_cap();
765 }
766
767 pub fn set_whitelist_entry(&mut self, path: &str, entry: FileWhitelistData) {
769 self.whitelist_for_overwrite.insert(path.to_string(), entry);
770 self.touch_whitelist(path);
771 self.enforce_whitelist_cap();
772 }
773
774 pub fn remove_whitelist_entry(&mut self, path: &str) -> Option<FileWhitelistData> {
776 self.whitelist_recency.retain(|candidate| candidate != path);
777 self.whitelist_for_overwrite.remove(path)
778 }
779
780 fn touch_whitelist(&mut self, path: &str) {
781 self.whitelist_recency.retain(|candidate| candidate != path);
782 self.whitelist_recency.push_back(path.to_string());
783 }
784
785 fn rebuild_whitelist_recency(&mut self) {
786 let mut paths = self.whitelist_for_overwrite.keys().cloned().collect::<Vec<_>>();
787 paths.sort_unstable();
788 self.whitelist_recency = paths.into();
789 self.enforce_whitelist_cap();
790 }
791
792 fn enforce_whitelist_cap(&mut self) {
793 self.whitelist_recency.retain(|path| self.whitelist_for_overwrite.contains_key(path));
794 while self.whitelist_for_overwrite.len() > MAX_WHITELIST_FILES {
795 let victim = self.whitelist_recency.pop_front().or_else(|| {
796 self.whitelist_for_overwrite.keys().min().cloned()
799 });
800 let Some(victim) = victim else { break };
801 self.whitelist_for_overwrite.remove(&victim);
802 }
803 }
804
805 pub async fn init_pty_shell(&mut self) -> Result<()> {
806 let cwd = self.cwd.clone();
807 let workspace_root = self.workspace_root.clone();
808 let temporary_artifact_dir =
809 crate::utils::agent_temp::session_info(&workspace_root, &self.current_thread_id)
810 .directory;
811 let restricted = self.bash_command_mode.bash_mode == BashMode::RestrictedMode;
812 let shell = tokio::task::spawn_blocking(move || {
816 PtyShell::new_with_agent_paths(
817 &cwd,
818 restricted,
819 Some(&workspace_root),
820 Some(&temporary_artifact_dir),
821 )
822 })
823 .await
824 .map_err(|e| {
825 crate::errors::WinxError::ShellInitializationError(format!("PTY init task failed: {e}"))
826 })??;
827 *self.pty_shell.lock().await = Some(shell);
828 Ok(())
829 }
830
831 pub fn update_cwd(&mut self, path: &Path) -> Result<()> {
832 self.cwd = path.to_path_buf();
833 Ok(())
834 }
835
836 pub fn update_workspace_root(&mut self, path: &Path) -> Result<()> {
837 self.workspace_root = path.to_path_buf();
838 Ok(())
839 }
840
841 pub fn is_command_allowed(&self, command: &str) -> bool {
842 if self.mode == Modes::Architect {
843 crate::utils::bash_parser::is_architect_command_allowed(command)
844 } else {
845 self.bash_command_mode.allowed_commands.is_allowed(command)
846 }
847 }
848
849 pub fn is_file_edit_allowed(&self, path: &str) -> bool {
850 self.file_edit_mode.allowed_globs.is_allowed(path)
851 }
852
853 pub fn is_file_write_allowed(&self, path: &str) -> bool {
854 self.write_if_empty_mode.allowed_globs.is_allowed(path)
855 }
856 pub fn save_state_to_disk(&self) -> Result<()> {
857 let snapshot = self.snapshot();
858 save_state_file(&self.current_thread_id, &snapshot)?;
859 Ok(())
860 }
861
862 pub fn snapshot(&self) -> BashStateSnapshot {
863 BashStateSnapshot::from_state(
864 &self.cwd.to_string_lossy(),
865 &self.workspace_root.to_string_lossy(),
866 self.mode,
867 &self.bash_command_mode,
868 &self.file_edit_mode,
869 &self.write_if_empty_mode,
870 &self.whitelist_for_overwrite,
871 &self.current_thread_id,
872 &self.edit_mutation_receipts,
873 )
874 }
875
876 pub fn apply_snapshot(&mut self, snapshot: &BashStateSnapshot) {
877 let (cwd, root, mode, bmode, emode, wmode, whitelist, tid) = snapshot.to_state_components();
878 let root = PathBuf::from(root);
879 let image_identity_changed = self.current_thread_id != tid || self.workspace_root != root;
880
881 self.cwd = PathBuf::from(cwd);
882 self.workspace_root = root;
883 self.mode = mode;
884 self.bash_command_mode = bmode;
885 self.file_edit_mode = emode;
886 self.write_if_empty_mode = wmode;
887 self.whitelist_for_overwrite = whitelist;
888 self.rebuild_whitelist_recency();
889 self.current_thread_id = tid;
890 self.edit_mutation_receipts = snapshot.edit_mutation_receipts.iter().cloned().collect();
891 self.edit_checkpoints.clear();
895 self.edit_checkpoint_metadata.clear();
896 for receipt in &mut self.edit_mutation_receipts {
897 receipt.undo_ids.clear();
901 receipt.next_undo_id = None;
902 if matches!(
903 receipt.verification_state,
904 Some(EditVerificationState::Reserved | EditVerificationState::Running)
905 ) {
906 receipt.verification_state = Some(EditVerificationState::Interrupted);
907 receipt.verification_pending = true;
908 receipt.verification_execution = None;
909 receipt.status = "completed_with_issues".to_string();
910 }
911 }
912 if image_identity_changed {
913 self.image_deliveries.clear();
914 }
915 self.initialized = true;
916 }
917
918 pub fn load_state_from_disk(&mut self, thread_id: &str) -> Result<bool> {
919 if let Some(snapshot) = load_state_file(thread_id)? {
920 self.apply_snapshot(&snapshot);
921 Ok(true)
922 } else {
923 Ok(false)
924 }
925 }
926
927 pub fn new_with_thread_id(thread_id: Option<&str>) -> Self {
928 let mut state = Self::new();
929
930 if let Some(tid) = thread_id {
931 if !tid.is_empty() {
932 if let Ok(true) = state.load_state_from_disk(tid) {
933 info!("Loaded state for thread_id '{}'", tid);
934 } else {
935 state.current_thread_id = tid.to_string();
936 }
937 }
938 }
939
940 state
941 }
942}
943
944fn sanitize_checkpoint_metadata(checkpoint: &mut EditCheckpoint) {
945 let Some(whitelist) = checkpoint.prior_whitelist.as_mut() else { return };
946 let mut ranges = std::mem::take(&mut whitelist.line_ranges_read);
947 ranges.sort_unstable();
948 let mut bounded: Vec<(usize, usize)> =
949 Vec::with_capacity(ranges.len().min(EDIT_CHECKPOINT_MAX_READ_RANGES));
950 for (start, end) in ranges {
951 if start > end {
952 continue;
953 }
954 if let Some((_, previous_end)) = bounded.last_mut() {
955 if start <= previous_end.saturating_add(1) {
956 *previous_end = (*previous_end).max(end);
957 continue;
958 }
959 }
960 if bounded.len() < EDIT_CHECKPOINT_MAX_READ_RANGES {
961 bounded.push((start, end));
962 } else {
963 break;
964 }
965 }
966 whitelist.line_ranges_read = bounded;
967}
968
969fn checkpoint_metadata_bytes(checkpoint: &EditCheckpoint) -> usize {
970 let whitelist = checkpoint.prior_whitelist.as_ref().map_or(0, |whitelist| {
971 whitelist
972 .file_hash
973 .len()
974 .saturating_add(whitelist.line_ranges_read.len().saturating_mul(2 * size_of::<usize>()))
975 .saturating_add(size_of::<usize>())
976 });
977 checkpoint
978 .file_path_str
979 .len()
980 .saturating_add(checkpoint.path.as_os_str().as_encoded_bytes().len())
981 .saturating_add(whitelist)
982}
983
984fn checkpoint_fingerprint(checkpoint: &EditCheckpoint) -> String {
985 fn hash_field(hasher: &mut Sha256, bytes: &[u8]) {
986 hasher.update(u64::try_from(bytes.len()).unwrap_or(u64::MAX).to_le_bytes());
987 hasher.update(bytes);
988 }
989 let mut hasher = Sha256::new();
990 hash_field(&mut hasher, checkpoint.file_path_str.as_bytes());
991 hash_field(&mut hasher, checkpoint.path.as_os_str().as_encoded_bytes());
992 hash_field(&mut hasher, checkpoint.prior_content.as_bytes());
993 if let Some(whitelist) = checkpoint.prior_whitelist.as_ref() {
994 hash_field(&mut hasher, whitelist.file_hash.as_bytes());
995 hasher.update(u64::try_from(whitelist.total_lines).unwrap_or(u64::MAX).to_le_bytes());
996 for (start, end) in &whitelist.line_ranges_read {
997 hasher.update(u64::try_from(*start).unwrap_or(u64::MAX).to_le_bytes());
998 hasher.update(u64::try_from(*end).unwrap_or(u64::MAX).to_le_bytes());
999 }
1000 }
1001 let digest = hasher.finalize();
1002 let mut encoded = String::with_capacity(digest.len() * 2);
1003 for byte in digest {
1004 let _ = write!(encoded, "{byte:02x}");
1005 }
1006 encoded
1007}
1008
1009pub fn generate_thread_id() -> String {
1010 let mut rng = rand::rng();
1011 format!("tid_{:x}", rng.random::<u64>())
1012}
1013
1014#[cfg(test)]
1015mod whitelist_range_tests {
1016 #![allow(clippy::expect_used)]
1017
1018 use std::collections::HashMap;
1019 use std::time::{Duration, Instant};
1020
1021 use super::{
1022 BashState, EditCheckpoint, EditMutationPostcondition, EditMutationReceipt,
1023 EditVerificationState, FileWhitelistData, EDIT_CHECKPOINT_CAP,
1024 EDIT_CHECKPOINT_CONTENT_BYTES_CAP, EDIT_CHECKPOINT_MAX_READ_RANGES,
1025 EDIT_CHECKPOINT_METADATA_BYTES_CAP, EDIT_MUTATION_RECEIPT_CAP, MAX_WHITELIST_FILES,
1026 SHELL_RESET_COOLDOWN,
1027 };
1028
1029 fn wl(ranges: &[(usize, usize)], total: usize) -> FileWhitelistData {
1030 FileWhitelistData::new("h".to_string(), ranges.to_vec(), total)
1031 }
1032
1033 #[test]
1034 fn repeated_shell_reset_needs_failure_evidence_until_cooldown_expires() {
1035 let mut state = BashState::new();
1036 let first_reset = Instant::now();
1037 assert!(state.shell_reset_retry_after(first_reset).is_none());
1038
1039 state.record_shell_reset(first_reset);
1040 assert_eq!(
1041 state.shell_reset_retry_after(first_reset + Duration::from_secs(30)),
1042 Some(SHELL_RESET_COOLDOWN.saturating_sub(Duration::from_secs(30)))
1043 );
1044
1045 state.record_shell_runtime_failure();
1046 assert!(state.shell_reset_retry_after(first_reset + Duration::from_secs(31)).is_none());
1047
1048 state.record_shell_reset(first_reset + Duration::from_secs(31));
1049 assert!(state
1050 .shell_reset_retry_after(first_reset + Duration::from_secs(31) + SHELL_RESET_COOLDOWN)
1051 .is_none());
1052 }
1053
1054 #[test]
1055 fn merge_collapses_overlap_and_adjacency() {
1056 let w = wl(&[(4, 5), (1, 3), (8, 12), (7, 9)], 20);
1058 assert_eq!(w.line_ranges_read, vec![(1, 5), (7, 12)]);
1059 }
1060
1061 #[test]
1062 fn re_reading_does_not_grow_unbounded() {
1063 let mut w = wl(&[(1, 10)], 100);
1064 for _ in 0..1000 {
1065 w.merge_ranges(std::iter::once((1, 10)));
1066 w.merge_ranges(std::iter::once((5, 15)));
1067 }
1068 assert_eq!(w.line_ranges_read, vec![(1, 15)]);
1070 }
1071
1072 #[test]
1073 fn percentage_counts_distinct_lines_with_overlap() {
1074 let w = wl(&[(1, 5), (3, 8)], 10);
1076 assert!((w.get_percentage_read() - 80.0).abs() < 1e-9);
1077 assert!(wl(&[(1, 10)], 10).is_read_enough());
1078 }
1079
1080 #[test]
1081 fn unread_ranges_are_the_gaps() {
1082 let w = wl(&[(2, 4), (7, 8)], 10);
1084 assert_eq!(w.get_unread_ranges(), vec![(1, 1), (5, 6), (9, 10)]);
1085 }
1086
1087 #[test]
1088 fn out_of_range_entries_are_clamped() {
1089 let w = wl(&[(0, 999)], 10);
1091 assert!((w.get_percentage_read() - 100.0).abs() < 1e-9);
1092 assert!(w.get_unread_ranges().is_empty(), "expected empty w.get_unread_ranges()");
1093 }
1094
1095 #[test]
1096 fn coverage_from_different_file_versions_is_not_combined() {
1097 let mut state = BashState::new();
1098 state.record_read_coverage("/workspace/file.rs", [(1, 50)], "old-hash".to_string(), 100);
1099 state.record_read_coverage("/workspace/file.rs", [(51, 100)], "new-hash".to_string(), 100);
1100
1101 let coverage = &state.whitelist_for_overwrite["/workspace/file.rs"];
1102 assert_eq!(coverage.file_hash, "new-hash");
1103 assert_eq!(coverage.line_ranges_read, vec![(51, 100)]);
1104 assert_eq!(coverage.get_unread_ranges(), vec![(1, 50)]);
1105 }
1106
1107 #[test]
1108 fn whitelist_is_lru_bounded_and_recent_entries_survive() {
1109 let mut state = BashState::new();
1110 for index in 0..MAX_WHITELIST_FILES {
1111 let path = format!("/workspace/file-{index:04}.rs");
1112 state.record_read_coverage(&path, [(1, 1)], format!("hash-{index}"), 1);
1113 }
1114
1115 state.record_read_coverage("/workspace/file-0000.rs", [(1, 1)], "hash-0".to_string(), 1);
1118 state.record_read_coverage("/workspace/newest.rs", [(1, 1)], "newest-hash".to_string(), 1);
1119
1120 assert_eq!(state.whitelist_for_overwrite.len(), MAX_WHITELIST_FILES);
1121 assert!(state.whitelist_for_overwrite.contains_key("/workspace/file-0000.rs"));
1122 assert!(!state.whitelist_for_overwrite.contains_key("/workspace/file-0001.rs"));
1123 assert!(state.whitelist_for_overwrite.contains_key("/workspace/newest.rs"));
1124 }
1125
1126 #[test]
1127 fn image_delivery_cache_survives_same_session_snapshot_sync_only() {
1128 let mut state = BashState::new();
1129 state.workspace_root = "/workspace/one".into();
1130 state.current_thread_id = "one".to_string();
1131 state.record_image_delivery("fingerprint".to_string());
1132
1133 let same_session = state.snapshot();
1134 state.apply_snapshot(&same_session);
1135 assert!(state.image_was_delivered("fingerprint"));
1136
1137 let mut other = BashState::new();
1138 other.workspace_root = "/workspace/two".into();
1139 other.current_thread_id = "two".to_string();
1140 state.apply_snapshot(&other.snapshot());
1141 assert!(!state.image_was_delivered("fingerprint"));
1142 }
1143
1144 #[test]
1145 fn mutation_receipts_survive_snapshot_and_expire_closed() -> Result<(), &'static str> {
1146 let mut state = BashState::new();
1147 state.record_edit_mutation_receipt(EditMutationReceipt {
1148 schema_version: 0,
1149 fingerprint: "fingerprint".to_string(),
1150 receipt_id: "edit_receipt".to_string(),
1151 tool: "FileWriteOrEdit".to_string(),
1152 status: "completed".to_string(),
1153 committed_at_unix_ms: 1_000,
1154 postconditions: vec![EditMutationPostcondition {
1155 path: "/workspace/file.rs".to_string(),
1156 sha256: "hash".to_string(),
1157 }],
1158 persisted: true,
1159 verification_pending: false,
1160 verification_id: None,
1161 legacy_verification_id: None,
1162 verification_state: None,
1163 verification_execution: None,
1164 uncommitted_paths: Vec::new(),
1165 next_undo_id: None,
1166 undo_ids: Vec::new(),
1167 });
1168
1169 let snapshot = state.snapshot();
1170 let mut restored = BashState::new();
1171 restored.apply_snapshot(&snapshot);
1172 let receipt = restored
1173 .edit_mutation_receipt("fingerprint", 1_500, 1_000)
1174 .ok_or("fresh persisted receipt missing")?;
1175 assert_eq!(receipt.receipt_id, "edit_receipt");
1176 assert!(receipt.persisted);
1177 assert!(restored.edit_mutation_receipt("fingerprint", 3_000, 1_000).is_none());
1178 Ok(())
1179 }
1180
1181 #[test]
1182 fn receipt_bound_verification_updates_without_extending_commit_ttl() -> Result<(), &'static str>
1183 {
1184 let mut state = BashState::new();
1185 state.record_edit_mutation_receipt(EditMutationReceipt {
1186 schema_version: 1,
1187 fingerprint: "fingerprint".to_string(),
1188 receipt_id: "edit_receipt".to_string(),
1189 tool: "EditFiles".to_string(),
1190 status: "completed_with_issues".to_string(),
1191 committed_at_unix_ms: 1_000,
1192 postconditions: Vec::new(),
1193 persisted: true,
1194 verification_pending: true,
1195 verification_id: Some("verify_receipt".to_string()),
1196 legacy_verification_id: None,
1197 verification_state: Some(EditVerificationState::Pending),
1198 verification_execution: None,
1199 uncommitted_paths: Vec::new(),
1200 next_undo_id: None,
1201 undo_ids: Vec::new(),
1202 });
1203 assert!(state.update_edit_mutation_verification(
1204 "verify_receipt",
1205 "completed",
1206 EditVerificationState::Passed,
1207 None,
1208 1_500,
1209 1_000,
1210 ));
1211 let receipt = state
1212 .edit_mutation_receipt("fingerprint", 1_500, 1_000)
1213 .ok_or("updated receipt missing")?;
1214 assert_eq!(receipt.status, "completed");
1215 assert!(!receipt.verification_pending);
1216 assert_eq!(receipt.committed_at_unix_ms, 1_000);
1217 assert!(state.edit_mutation_receipt("fingerprint", 3_000, 1_000).is_none());
1218 Ok(())
1219 }
1220
1221 fn verification_receipt(
1222 fingerprint: &str,
1223 tool: &str,
1224 canonical_id: &str,
1225 legacy_id: Option<&str>,
1226 ) -> EditMutationReceipt {
1227 EditMutationReceipt {
1228 schema_version: 1,
1229 fingerprint: fingerprint.to_string(),
1230 receipt_id: format!("edit_{fingerprint}"),
1231 tool: tool.to_string(),
1232 status: "completed_with_issues".to_string(),
1233 committed_at_unix_ms: 1_000,
1234 postconditions: Vec::new(),
1235 persisted: true,
1236 verification_pending: true,
1237 verification_id: Some(canonical_id.to_string()),
1238 legacy_verification_id: legacy_id.map(str::to_string),
1239 verification_state: Some(EditVerificationState::Pending),
1240 verification_execution: None,
1241 uncommitted_paths: Vec::new(),
1242 next_undo_id: None,
1243 undo_ids: Vec::new(),
1244 }
1245 }
1246
1247 #[test]
1248 fn canonical_verification_lookup_ignores_reordered_legacy_shadow_collision(
1249 ) -> Result<(), &'static str> {
1250 let mut state = BashState::new();
1251 state.record_edit_mutation_receipt(verification_receipt(
1252 "canonical",
1253 "EditFiles",
1254 "verify_same",
1255 None,
1256 ));
1257 state.record_edit_mutation_receipt(verification_receipt(
1258 "shadow",
1259 "FileWriteOrEdit",
1260 "verify_same",
1261 Some("verify_legacy"),
1262 ));
1263
1264 let shadow = state
1265 .edit_mutation_receipt_by_legacy_verification_id("verify_legacy", 1_001, 10_000)
1266 .ok_or("legacy shadow missing")?;
1267 assert_eq!(shadow.tool, "FileWriteOrEdit");
1268 let canonical = state
1269 .edit_mutation_receipt_by_verification_id("verify_same", 1_001, 10_000)
1270 .ok_or("canonical receipt missing")?;
1271 assert_eq!(canonical.tool, "EditFiles");
1272 assert_eq!(canonical.fingerprint, "canonical");
1273 Ok(())
1274 }
1275
1276 #[test]
1277 fn mutation_receipt_lru_evicts_canonical_shadow_groups_atomically() {
1278 let mut state = BashState::new();
1279 for index in 0..=(EDIT_MUTATION_RECEIPT_CAP / 2) {
1280 let receipt_id = format!("edit-{index}");
1281 let canonical_id = format!("verify-{index}");
1282 let mut canonical = verification_receipt(
1283 &format!("canonical-{index}"),
1284 "EditFiles",
1285 &canonical_id,
1286 None,
1287 );
1288 canonical.receipt_id.clone_from(&receipt_id);
1289 let mut shadow = verification_receipt(
1290 &format!("legacy-{index}"),
1291 "FileWriteOrEdit",
1292 &canonical_id,
1293 Some(&format!("legacy-verify-{index}")),
1294 );
1295 shadow.receipt_id = receipt_id;
1296 state.record_edit_mutation_receipts([canonical, shadow]);
1297 }
1298
1299 let groups = state.edit_mutation_receipts.iter().fold(
1300 HashMap::<&str, usize>::new(),
1301 |mut groups, receipt| {
1302 *groups.entry(receipt.receipt_id.as_str()).or_default() += 1;
1303 groups
1304 },
1305 );
1306 assert!(state.edit_mutation_receipts.len() <= EDIT_MUTATION_RECEIPT_CAP);
1307 assert!(groups.values().all(|count| *count == 2));
1308 }
1309
1310 fn checkpoint(path: &str, _id: usize, prior_content: String) -> EditCheckpoint {
1311 EditCheckpoint {
1312 file_path_str: path.to_string(),
1313 path: path.into(),
1314 prior_content,
1315 prior_whitelist: None,
1316 }
1317 }
1318
1319 #[test]
1320 fn undo_history_is_lifo_per_file_and_bounded_by_count() {
1321 let mut state = BashState::new();
1322 let mut ids = Vec::new();
1323 for id in 0..=(EDIT_CHECKPOINT_CAP + 2) {
1324 ids.push(
1325 state
1326 .push_receipt_bound_edit_checkpoint(
1327 checkpoint("/workspace/hot.rs", id, "x".to_string()),
1328 format!("hash-{id}"),
1329 )
1330 .expect("retained newest checkpoint"),
1331 );
1332 }
1333 assert_eq!(state.undo_checkpoint_count_for("/workspace/hot.rs"), EDIT_CHECKPOINT_CAP);
1334 let latest = ids.last().expect("latest id");
1335 assert_eq!(state.next_undo_id_for("/workspace/hot.rs").as_deref(), Some(latest.as_str()));
1336 assert!(state.pop_latest_edit_checkpoint_by_id("/workspace/hot.rs", &ids[1]).is_none());
1337
1338 for undo_id in ids.iter().rev().take(EDIT_CHECKPOINT_CAP) {
1339 assert!(state.pop_latest_edit_checkpoint_by_id("/workspace/hot.rs", undo_id).is_some());
1340 assert!(
1341 !state.has_receipt_bound_checkpoint("/workspace/hot.rs", undo_id),
1342 "consumed handle must not remain attached to an older identical snapshot"
1343 );
1344 }
1345 assert!(state.next_undo_id_for("/workspace/hot.rs").is_none());
1346
1347 for id in 0..(EDIT_CHECKPOINT_CAP + 8) {
1348 let path = format!("/workspace/file-{id}.rs");
1349 state.push_edit_checkpoint(checkpoint(&path, 100 + id, String::new()));
1350 }
1351 assert!(state.edit_checkpoints.len() <= EDIT_CHECKPOINT_CAP);
1352 }
1353
1354 #[test]
1355 fn legacy_undo_of_repeated_snapshot_preserves_older_receipt() {
1356 let mut state = BashState::new();
1357 let path = "/workspace/repeated.rs";
1358 let first = state
1359 .push_receipt_bound_edit_checkpoint(
1360 checkpoint(path, 1, "original".to_string()),
1361 "first-hash".to_string(),
1362 )
1363 .expect("first checkpoint");
1364 let second = state
1365 .push_receipt_bound_edit_checkpoint(
1366 checkpoint(path, 2, "original".to_string()),
1367 "second-hash".to_string(),
1368 )
1369 .expect("second checkpoint");
1370
1371 assert!(state.pop_edit_checkpoint_for(path).is_some());
1372 assert!(!state.has_receipt_bound_checkpoint(path, &second));
1373 assert_eq!(state.next_undo_id_for(path).as_deref(), Some(first.as_str()));
1374 assert!(state.pop_latest_edit_checkpoint_by_id(path, &first).is_some());
1375 assert!(state.next_undo_id_for(path).is_none());
1376 }
1377
1378 #[test]
1379 fn legacy_checkpoint_does_not_borrow_an_older_undo_receipt() {
1380 let mut state = BashState::new();
1381 let path = "/workspace/repeated.rs";
1382 let original = checkpoint(path, 1, "original".to_string());
1383 let undo_id = state
1384 .push_receipt_bound_edit_checkpoint(original.clone(), "edited-hash".to_string())
1385 .expect("bound checkpoint");
1386 state.push_edit_checkpoint(original);
1387
1388 assert!(state.next_undo_id_for(path).is_none());
1389 assert!(state.latest_receipt_bound_checkpoint(path).is_none());
1390 assert!(state.pop_edit_checkpoint_for(path).is_some());
1391 assert_eq!(state.next_undo_id_for(path).as_deref(), Some(undo_id.as_str()));
1392 assert!(state.pop_latest_edit_checkpoint_by_id(path, &undo_id).is_some());
1393 }
1394
1395 #[test]
1396 fn evicting_legacy_checkpoint_preserves_later_identical_undo_receipt() {
1397 let mut state = BashState::new();
1398 let path = "/workspace/repeated.rs";
1399 let original = checkpoint(path, 1, "original".to_string());
1400 state.push_edit_checkpoint(original.clone());
1401 let undo_id = state
1402 .push_receipt_bound_edit_checkpoint(original, "edited-hash".to_string())
1403 .expect("bound checkpoint");
1404 for id in 0..(EDIT_CHECKPOINT_CAP - 1) {
1405 state.push_edit_checkpoint(checkpoint(
1406 &format!("/workspace/other-{id}.rs"),
1407 id,
1408 "other".to_string(),
1409 ));
1410 }
1411
1412 assert_eq!(state.undo_checkpoint_count_for(path), 1);
1413 assert_eq!(state.next_undo_id_for(path).as_deref(), Some(undo_id.as_str()));
1414 assert!(state.pop_latest_edit_checkpoint_by_id(path, &undo_id).is_some());
1415 }
1416
1417 #[test]
1418 fn undo_content_and_metadata_budgets_are_deterministic() {
1419 let mut state = BashState::new();
1420 let chunk = "x".repeat(900_000);
1421 state.push_edit_checkpoint(checkpoint("/workspace/cold.rs", 1, chunk.clone()));
1422 for id in 2..=6 {
1423 state.push_edit_checkpoint(checkpoint("/workspace/hot.rs", id, chunk.clone()));
1424 }
1425
1426 assert!(state.undo_content_bytes() <= EDIT_CHECKPOINT_CONTENT_BYTES_CAP);
1427 assert!(state.edit_checkpoints.len() <= EDIT_CHECKPOINT_CAP);
1428
1429 let mut huge = checkpoint("/workspace/ranges.rs", 7, String::new());
1430 huge.prior_whitelist = Some(FileWhitelistData {
1431 file_hash: "h".repeat(1_024),
1432 line_ranges_read: (0..100_000).map(|index| (index * 2 + 1, index * 2 + 1)).collect(),
1433 total_lines: 200_000,
1434 });
1435 state.push_edit_checkpoint(huge);
1436 let retained = state.latest_edit_checkpoint_for("/workspace/ranges.rs").expect("retained");
1437 assert!(
1438 retained.prior_whitelist.expect("whitelist").line_ranges_read.len()
1439 <= EDIT_CHECKPOINT_MAX_READ_RANGES
1440 );
1441 assert!(state.undo_metadata_bytes() <= EDIT_CHECKPOINT_METADATA_BYTES_CAP);
1442 }
1443
1444 #[test]
1445 fn undo_content_is_intentionally_absent_after_snapshot_restart() {
1446 let mut state = BashState::new();
1447 state.push_edit_checkpoint(checkpoint(
1448 "/workspace/file.rs",
1449 1,
1450 "private prior source".to_string(),
1451 ));
1452 state.record_edit_mutation_receipt(EditMutationReceipt {
1453 schema_version: 1,
1454 fingerprint: "apply-fingerprint".to_string(),
1455 receipt_id: "edit-apply".to_string(),
1456 tool: "EditFiles".to_string(),
1457 status: "completed".to_string(),
1458 committed_at_unix_ms: 1_000,
1459 postconditions: Vec::new(),
1460 persisted: true,
1461 verification_pending: false,
1462 verification_id: None,
1463 legacy_verification_id: None,
1464 verification_state: None,
1465 verification_execution: None,
1466 uncommitted_paths: Vec::new(),
1467 next_undo_id: Some("undo-next".to_string()),
1468 undo_ids: vec![Some("undo-current".to_string()), None],
1469 });
1470 let snapshot = state.snapshot();
1471 let mut restored = BashState::new();
1472 restored.apply_snapshot(&snapshot);
1473
1474 assert!(restored.next_undo_id_for("/workspace/file.rs").is_none());
1475 assert_eq!(restored.undo_content_bytes(), 0);
1476 let replay = restored
1477 .edit_mutation_receipt("apply-fingerprint", 1_001, 10_000)
1478 .expect("mutation receipt remains available");
1479 assert!(replay.undo_ids.is_empty(), "expected empty replay.undo_ids");
1480 assert!(replay.next_undo_id.is_none());
1481 }
1482
1483 #[test]
1484 fn restart_interrupts_reserved_and_running_verifications_without_a_binding() {
1485 let mut state = BashState::new();
1486 for (fingerprint, verification_state, execution) in [
1487 ("reserved", EditVerificationState::Reserved, None),
1488 (
1489 "running",
1490 EditVerificationState::Running,
1491 Some(super::EditVerificationExecution {
1492 generation: 9,
1493 guardian_epoch: "old-guardian".to_string(),
1494 session_epoch: "old-session".to_string(),
1495 }),
1496 ),
1497 ] {
1498 state.record_edit_mutation_receipt(EditMutationReceipt {
1499 schema_version: 1,
1500 fingerprint: fingerprint.to_string(),
1501 receipt_id: format!("edit-{fingerprint}"),
1502 tool: "EditFiles".to_string(),
1503 status: "completed_with_issues".to_string(),
1504 committed_at_unix_ms: 1_000,
1505 postconditions: Vec::new(),
1506 persisted: true,
1507 verification_pending: true,
1508 verification_id: Some(format!("verify-{fingerprint}")),
1509 legacy_verification_id: None,
1510 verification_state: Some(verification_state),
1511 verification_execution: execution,
1512 uncommitted_paths: Vec::new(),
1513 next_undo_id: None,
1514 undo_ids: Vec::new(),
1515 });
1516 }
1517
1518 let snapshot = state.snapshot();
1519 let mut restored = BashState::new();
1520 restored.apply_snapshot(&snapshot);
1521 for fingerprint in ["reserved", "running"] {
1522 let receipt = restored
1523 .edit_mutation_receipt(fingerprint, 1_001, 10_000)
1524 .expect("receipt survives restart");
1525 assert_eq!(receipt.verification_state, Some(EditVerificationState::Interrupted));
1526 assert!(receipt.verification_execution.is_none());
1527 assert!(receipt.verification_pending);
1528 }
1529 }
1530}