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