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