Skip to main content

winx_code_agent/state/
bash_state.rs

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
29/// Clamp ranges to `1..=total_lines`, drop inverted/empty ones, and sort. Shared
30/// by the coverage queries so both tolerate overlapping/out-of-bounds ranges.
31fn 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
41/// Count distinct lines covered by (possibly overlapping) `ranges`, clamped to
42/// `1..=total_lines`, via a single sort+sweep — O(k log k) in the range count.
43fn covered_line_count(ranges: &[(usize, usize)], total_lines: usize) -> usize {
44    let mut covered = 0usize;
45    let mut last_end = 0usize; // highest line already counted (0 = none)
46    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        // Sort+sweep over the ranges (O(k log k) in the range count) instead of
77        // building an O(total_lines) HashSet on every call. Robust to overlapping
78        // or out-of-range entries from older un-merged snapshots.
79        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; // next line not yet known-read
90        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    /// Record `[start, end]` as read, merging it into the existing intervals so
103    /// `line_ranges_read` stays a bounded set of disjoint ranges. Without the
104    /// merge, re-reading a file appended duplicate ranges forever (unbounded
105    /// memory per session).
106    pub fn add_range(&mut self, start: usize, end: usize) {
107        self.merge_ranges(std::iter::once((start, end)));
108    }
109
110    /// Merge `new` ranges into `line_ranges_read`, keeping it sorted and disjoint
111    /// (adjacent inclusive ranges like `(1,3)` and `(4,5)` collapse to `(1,5)`).
112    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    /// Whether every line in one inclusive range was visible to the model for
131    /// this exact file hash.
132    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
141/// How many edit checkpoints to keep per session for `UndoEdit`. In-memory only
142/// (not persisted), oldest dropped past the cap, bounding memory on long sessions.
143const EDIT_CHECKPOINT_CAP: usize = 10;
144/// Aggregate source-content budget in addition to the historical count and
145/// per-file limits.
146const EDIT_CHECKPOINT_CONTENT_BYTES_CAP: usize = 4 * 1024 * 1024;
147/// Aggregate metadata budget. In particular this prevents externally-created
148/// `EditCheckpoint` values with enormous read-range vectors from bypassing the
149/// source-content budget.
150const EDIT_CHECKPOINT_METADATA_BYTES_CAP: usize = 256 * 1024;
151const EDIT_CHECKPOINT_MAX_READ_RANGES: usize = 1_024;
152
153/// Largest prior-content a checkpoint will hold. Files (up to the 50 MB edit
154/// ceiling) above this aren't checkpointed, so a session editing huge assets
155/// can't pile up to ~CAP * 50 MB of undo snapshots in memory; those edits just
156/// aren't undoable.
157const EDIT_CHECKPOINT_MAX_CONTENT_BYTES: usize = 1_000_000;
158
159/// Maximum number of files whose read coverage is retained in one session.
160/// Evicting an old entry is fail-closed: a later edit simply has to read that
161/// file again, while long-lived HTTP/plugin sessions keep bounded memory and
162/// persisted state.
163const MAX_WHITELIST_FILES: usize = 1_024;
164
165/// Recent image fingerprints remembered per live session. The cache contains
166/// no image bytes and is intentionally not persisted; it only prevents an LLM
167/// from resending the same unchanged image in one conversation.
168const IMAGE_DELIVERY_CACHE_CAP: usize = 32;
169
170/// Recent committed-edit receipts retained per logical session. The records
171/// contain only request/file fingerprints and are persisted so an MCP adapter
172/// restart cannot blindly repeat a mutation whose response was lost.
173const EDIT_MUTATION_RECEIPT_CAP: usize = 64;
174/// A model may retry one recovery reset, but repeated resets in a short window
175/// need fresh runtime-failure evidence so a healthy PTY is not churned.
176pub(crate) const SHELL_RESET_COOLDOWN: Duration = Duration::from_secs(5 * 60);
177
178#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
179pub(crate) struct EditMutationPostcondition {
180    pub path: String,
181    pub sha256: String,
182}
183
184#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
185pub(crate) struct EditMutationReceipt {
186    #[serde(default)]
187    pub schema_version: u8,
188    pub fingerprint: String,
189    pub receipt_id: String,
190    pub tool: String,
191    pub status: String,
192    pub committed_at_unix_ms: u64,
193    pub postconditions: Vec<EditMutationPostcondition>,
194    #[serde(default)]
195    pub persisted: bool,
196    #[serde(default)]
197    pub verification_pending: bool,
198    #[serde(default, skip_serializing_if = "Option::is_none")]
199    pub verification_id: Option<String>,
200    #[serde(default, skip_serializing_if = "Option::is_none")]
201    pub legacy_verification_id: Option<String>,
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    pub verification_state: Option<EditVerificationState>,
204    #[serde(default, skip_serializing_if = "Option::is_none")]
205    pub verification_execution: Option<EditVerificationExecution>,
206    #[serde(default, skip_serializing_if = "Vec::is_empty")]
207    pub uncommitted_paths: Vec<String>,
208    #[serde(default, skip_serializing_if = "Option::is_none")]
209    pub next_undo_id: Option<String>,
210    #[serde(default, skip_serializing_if = "Vec::is_empty")]
211    pub undo_ids: Vec<Option<String>>,
212}
213
214#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
215#[serde(rename_all = "snake_case")]
216pub(crate) enum EditVerificationState {
217    Pending,
218    Reserved,
219    Running,
220    Interrupted,
221    Skipped,
222    Passed,
223    Failed,
224}
225
226#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
227pub(crate) struct EditVerificationExecution {
228    pub generation: u64,
229    pub guardian_epoch: String,
230    pub session_epoch: String,
231}
232
233/// A single file's pre-edit state, captured by `FileWriteOrEdit`/`MultiFileEdit`
234/// after a successful write so `UndoEdit` can restore it. Only existing files get
235/// one (a brand-new file's creation is not undoable - there is no prior content).
236#[derive(Debug, Clone)]
237pub struct EditCheckpoint {
238    /// Resolved, workspace-confined path string (matches `whitelist_for_overwrite` keys).
239    pub file_path_str: String,
240    pub path: PathBuf,
241    /// File content before the edit, to be written back on undo.
242    pub prior_content: String,
243    /// The whitelist entry before the edit, restored on undo so the hash gate of a
244    /// later edit matches the reverted content. `None` if there was none.
245    pub prior_whitelist: Option<FileWhitelistData>,
246}
247
248#[derive(Debug, Clone)]
249struct EditCheckpointMetadata {
250    checkpoint_fingerprint: String,
251    undo_id: String,
252    wrote_hash: String,
253}
254
255#[derive(Debug, Clone)]
256pub struct BashState {
257    pub cwd: PathBuf,
258    pub workspace_root: PathBuf,
259    pub current_thread_id: String,
260    pub mode: Modes,
261    pub bash_command_mode: BashCommandMode,
262    pub file_edit_mode: FileEditMode,
263    pub write_if_empty_mode: WriteIfEmptyMode,
264    pub whitelist_for_overwrite: HashMap<String, FileWhitelistData>,
265    /// Least-recently-used order for `whitelist_for_overwrite`, oldest first.
266    /// Snapshots intentionally persist only the guarded data; restored entries
267    /// receive a deterministic order when loaded.
268    whitelist_recency: VecDeque<String>,
269    pub pty_shell: Arc<Mutex<Option<PtyShell>>>,
270    /// Serializes foreground command startup across cloned request-local states.
271    /// Status/input actions remain concurrent so callers can drive a running TUI.
272    pub foreground_command_gate: Arc<Mutex<()>>,
273    pub initialized: bool,
274    /// In-memory ring of recent edit checkpoints for `UndoEdit` (newest at the
275    /// back). Deliberately not part of `BashStateSnapshot`: undo is for immediate
276    /// mid-session recovery, not across restarts.
277    pub edit_checkpoints: VecDeque<EditCheckpoint>,
278    edit_checkpoint_metadata: VecDeque<EditCheckpointMetadata>,
279    /// In-memory aggregate guard for syntax maps over non-canonical helpers.
280    /// Canonical `CodeMap` calls are intentionally not counted.
281    pub derived_code_map_usage: crate::utils::agent_temp::DerivedCodeMapUsage,
282    image_deliveries: VecDeque<String>,
283    edit_mutation_receipts: VecDeque<EditMutationReceipt>,
284    /// Adapter-local reset evidence. It intentionally is not persisted or sent
285    /// to a guardian: a process restart is already a new recovery boundary.
286    last_shell_reset_at: Option<Instant>,
287    shell_runtime_failure_since_reset: bool,
288    /// Adapter-local, show-once message set when this state was rebuilt from
289    /// its persisted snapshot after a restart (see `WinxService::tool_session_for`).
290    /// Surfaced ahead of the next `BashCommand` result, then cleared; never
291    /// persisted — a note about one recovery must not outlive it.
292    pub recovery_note: Option<String>,
293}
294
295impl Default for BashState {
296    fn default() -> Self {
297        Self::new()
298    }
299}
300
301impl BashState {
302    pub fn new() -> Self {
303        let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/tmp"));
304        Self {
305            cwd: cwd.clone(),
306            workspace_root: cwd,
307            current_thread_id: generate_thread_id(),
308            mode: Modes::Wcgw,
309            bash_command_mode: BashCommandMode {
310                bash_mode: BashMode::NormalMode,
311                allowed_commands: AllowedCommands::All("all".to_string()),
312            },
313            file_edit_mode: FileEditMode { allowed_globs: AllowedGlobs::All("all".to_string()) },
314            write_if_empty_mode: WriteIfEmptyMode {
315                allowed_globs: AllowedGlobs::All("all".to_string()),
316            },
317            whitelist_for_overwrite: HashMap::new(),
318            whitelist_recency: VecDeque::new(),
319            pty_shell: Arc::new(Mutex::new(None)),
320            foreground_command_gate: Arc::new(Mutex::new(())),
321            initialized: false,
322            edit_checkpoints: VecDeque::new(),
323            edit_checkpoint_metadata: VecDeque::new(),
324            derived_code_map_usage: crate::utils::agent_temp::DerivedCodeMapUsage::default(),
325            image_deliveries: VecDeque::new(),
326            edit_mutation_receipts: VecDeque::new(),
327            last_shell_reset_at: None,
328            shell_runtime_failure_since_reset: false,
329            recovery_note: None,
330        }
331    }
332
333    /// Remaining cooldown when another reset would only churn a recently
334    /// replaced shell. `None` means the reset is currently justified.
335    pub(crate) fn shell_reset_retry_after(&self, now: Instant) -> Option<Duration> {
336        if self.shell_runtime_failure_since_reset {
337            return None;
338        }
339        let last_reset = self.last_shell_reset_at?;
340        SHELL_RESET_COOLDOWN
341            .checked_sub(now.saturating_duration_since(last_reset))
342            .filter(|remaining| !remaining.is_zero())
343    }
344
345    pub(crate) fn record_shell_reset(&mut self, now: Instant) {
346        self.last_shell_reset_at = Some(now);
347        self.shell_runtime_failure_since_reset = false;
348    }
349
350    pub(crate) fn record_shell_runtime_failure(&mut self) {
351        if self.last_shell_reset_at.is_some() {
352            self.shell_runtime_failure_since_reset = true;
353        }
354    }
355
356    pub(crate) fn edit_mutation_receipt(
357        &mut self,
358        fingerprint: &str,
359        now_unix_ms: u64,
360        ttl_ms: u64,
361    ) -> Option<EditMutationReceipt> {
362        self.prune_edit_mutation_receipts(now_unix_ms, ttl_ms);
363        let index = self
364            .edit_mutation_receipts
365            .iter()
366            .rposition(|receipt| receipt.fingerprint == fingerprint)?;
367        let receipt = self.edit_mutation_receipts.remove(index)?;
368        self.edit_mutation_receipts.push_back(receipt.clone());
369        Some(receipt)
370    }
371
372    pub(crate) fn edit_mutation_receipt_variant(
373        &mut self,
374        fingerprint: &str,
375        verification_id: Option<&str>,
376        now_unix_ms: u64,
377        ttl_ms: u64,
378    ) -> Option<EditMutationReceipt> {
379        self.prune_edit_mutation_receipts(now_unix_ms, ttl_ms);
380        let index = self.edit_mutation_receipts.iter().rposition(|receipt| {
381            receipt.fingerprint == fingerprint
382                && receipt.verification_id.as_deref() == verification_id
383        })?;
384        let receipt = self.edit_mutation_receipts.remove(index)?;
385        self.edit_mutation_receipts.push_back(receipt.clone());
386        Some(receipt)
387    }
388
389    #[cfg(test)]
390    pub(crate) fn record_edit_mutation_receipt(&mut self, receipt: EditMutationReceipt) {
391        self.record_edit_mutation_receipts([receipt]);
392    }
393
394    pub(crate) fn record_edit_mutation_receipts(
395        &mut self,
396        receipts: impl IntoIterator<Item = EditMutationReceipt>,
397    ) {
398        let receipts = receipts.into_iter().collect::<Vec<_>>();
399        for receipt in &receipts {
400            self.edit_mutation_receipts.retain(|existing| {
401                !(existing.fingerprint == receipt.fingerprint
402                    && existing.verification_id == receipt.verification_id
403                    && existing.legacy_verification_id == receipt.legacy_verification_id)
404            });
405        }
406        self.edit_mutation_receipts.extend(receipts);
407        while self.edit_mutation_receipts.len() > EDIT_MUTATION_RECEIPT_CAP {
408            let Some(group) =
409                self.edit_mutation_receipts.front().map(|receipt| receipt.receipt_id.clone())
410            else {
411                break;
412            };
413            self.edit_mutation_receipts.retain(|receipt| receipt.receipt_id != group);
414        }
415    }
416
417    fn prune_edit_mutation_receipts(&mut self, now_unix_ms: u64, ttl_ms: u64) {
418        let expired_groups = self
419            .edit_mutation_receipts
420            .iter()
421            .filter(|receipt| now_unix_ms.saturating_sub(receipt.committed_at_unix_ms) > ttl_ms)
422            .map(|receipt| receipt.receipt_id.clone())
423            .collect::<std::collections::HashSet<_>>();
424        self.edit_mutation_receipts.retain(|receipt| !expired_groups.contains(&receipt.receipt_id));
425    }
426
427    pub(crate) fn edit_mutation_receipt_by_verification_id(
428        &mut self,
429        verification_id: &str,
430        now_unix_ms: u64,
431        ttl_ms: u64,
432    ) -> Option<EditMutationReceipt> {
433        self.prune_edit_mutation_receipts(now_unix_ms, ttl_ms);
434        let index = self.edit_mutation_receipts.iter().position(|receipt| {
435            receipt.schema_version >= 1
436                && receipt.tool == "EditFiles"
437                && receipt.verification_id.as_deref() == Some(verification_id)
438        })?;
439        let receipt = self.edit_mutation_receipts.remove(index)?;
440        self.edit_mutation_receipts.push_back(receipt.clone());
441        Some(receipt)
442    }
443
444    pub(crate) fn edit_mutation_receipt_by_legacy_verification_id(
445        &mut self,
446        verification_id: &str,
447        now_unix_ms: u64,
448        ttl_ms: u64,
449    ) -> Option<EditMutationReceipt> {
450        self.prune_edit_mutation_receipts(now_unix_ms, ttl_ms);
451        let index = self.edit_mutation_receipts.iter().position(|receipt| {
452            receipt.tool != "EditFiles"
453                && receipt.legacy_verification_id.as_deref() == Some(verification_id)
454        })?;
455        let receipt = self.edit_mutation_receipts.remove(index)?;
456        self.edit_mutation_receipts.push_back(receipt.clone());
457        Some(receipt)
458    }
459
460    pub(crate) fn update_edit_mutation_verification(
461        &mut self,
462        verification_id: &str,
463        status: &str,
464        verification_state: EditVerificationState,
465        execution: Option<&EditVerificationExecution>,
466        now_unix_ms: u64,
467        ttl_ms: u64,
468    ) -> bool {
469        self.prune_edit_mutation_receipts(now_unix_ms, ttl_ms);
470        let mut updated = false;
471        for receipt in &mut self.edit_mutation_receipts {
472            if receipt.verification_id.as_deref() == Some(verification_id) {
473                receipt.status = status.to_string();
474                receipt.verification_pending = matches!(
475                    verification_state,
476                    EditVerificationState::Pending
477                        | EditVerificationState::Reserved
478                        | EditVerificationState::Running
479                        | EditVerificationState::Interrupted
480                );
481                receipt.verification_state = Some(verification_state);
482                receipt.verification_execution.clone_from(&execution.cloned());
483                updated = true;
484            }
485        }
486        updated
487    }
488
489    pub(crate) fn mark_edit_mutation_verification_volatile(&mut self, verification_id: &str) {
490        for receipt in &mut self.edit_mutation_receipts {
491            if receipt.verification_id.as_deref() == Some(verification_id) {
492                receipt.persisted = false;
493            }
494        }
495    }
496
497    pub(crate) fn mark_edit_mutation_receipt_volatile(&mut self, fingerprint: &str) {
498        if let Some(receipt) = self
499            .edit_mutation_receipts
500            .iter_mut()
501            .find(|receipt| receipt.fingerprint == fingerprint)
502        {
503            receipt.persisted = false;
504        }
505    }
506
507    /// Return whether this image was already delivered, refreshing its LRU
508    /// position on a hit. Fingerprints are content-based, so aliases and copies
509    /// cannot bypass deduplication accidentally.
510    pub(crate) fn image_was_delivered(&mut self, fingerprint: &str) -> bool {
511        let Some(index) = self.image_deliveries.iter().position(|item| item == fingerprint) else {
512            return false;
513        };
514        if let Some(item) = self.image_deliveries.remove(index) {
515            self.image_deliveries.push_back(item);
516        }
517        true
518    }
519
520    /// Record a successful image delivery without retaining its payload.
521    pub(crate) fn record_image_delivery(&mut self, fingerprint: String) {
522        if let Some(index) = self.image_deliveries.iter().position(|item| item == &fingerprint) {
523            self.image_deliveries.remove(index);
524        }
525        self.image_deliveries.push_back(fingerprint);
526        while self.image_deliveries.len() > IMAGE_DELIVERY_CACHE_CAP {
527            self.image_deliveries.pop_front();
528        }
529    }
530
531    /// Record a pre-edit checkpoint for `UndoEdit`, dropping the oldest past the
532    /// cap. This public API intentionally retains its 0.2.x signature and data
533    /// type; opaque receipt metadata lives in a separate private queue.
534    pub fn push_edit_checkpoint(&mut self, checkpoint: EditCheckpoint) {
535        let _ = self.push_edit_checkpoint_inner(checkpoint, None);
536    }
537
538    pub(crate) fn push_receipt_bound_edit_checkpoint(
539        &mut self,
540        checkpoint: EditCheckpoint,
541        wrote_hash: String,
542    ) -> Option<String> {
543        let undo_id = format!("undo_{:032x}", rand::random::<u128>());
544        self.push_edit_checkpoint_inner(
545            checkpoint,
546            Some(EditCheckpointMetadata {
547                checkpoint_fingerprint: String::new(),
548                undo_id: undo_id.clone(),
549                wrote_hash,
550            }),
551        )
552        .then_some(undo_id)
553    }
554
555    fn push_edit_checkpoint_inner(
556        &mut self,
557        mut checkpoint: EditCheckpoint,
558        metadata: Option<EditCheckpointMetadata>,
559    ) -> bool {
560        if checkpoint.prior_content.len() > EDIT_CHECKPOINT_MAX_CONTENT_BYTES {
561            info!(
562                file = %checkpoint.file_path_str,
563                "UndoEdit: not checkpointing a file over 1 MB (too large to hold in memory)"
564            );
565            return false;
566        }
567        sanitize_checkpoint_metadata(&mut checkpoint);
568        let fingerprint = checkpoint_fingerprint(&checkpoint);
569        self.edit_checkpoints.push_back(checkpoint);
570        if let Some(mut metadata) = metadata {
571            metadata.checkpoint_fingerprint.clone_from(&fingerprint);
572            self.edit_checkpoint_metadata.push_back(metadata);
573        }
574        while self.edit_checkpoints.len() > EDIT_CHECKPOINT_CAP
575            || self.undo_content_bytes() > EDIT_CHECKPOINT_CONTENT_BYTES_CAP
576            || self.undo_metadata_bytes() > EDIT_CHECKPOINT_METADATA_BYTES_CAP
577        {
578            if let Some(removed) = self.edit_checkpoints.pop_front() {
579                self.remove_checkpoint_metadata(&checkpoint_fingerprint(&removed));
580            } else {
581                break;
582            }
583        }
584        self.reconcile_checkpoint_metadata();
585        self.edit_checkpoints.iter().any(|item| checkpoint_fingerprint(item) == fingerprint)
586    }
587
588    /// Remove and return the most recent checkpoint for `file_path_str` (per-file
589    /// LIFO, so repeated undos on one file walk its edits back while leaving other
590    /// files' checkpoints in place). `None` if that file has no checkpoint.
591    pub fn pop_edit_checkpoint_for(&mut self, file_path_str: &str) -> Option<EditCheckpoint> {
592        let index =
593            self.edit_checkpoints.iter().rposition(|cp| cp.file_path_str == file_path_str)?;
594        let checkpoint = self.edit_checkpoints.remove(index)?;
595        self.remove_checkpoint_metadata(&checkpoint_fingerprint(&checkpoint));
596        Some(checkpoint)
597    }
598
599    pub(crate) fn pop_latest_edit_checkpoint_by_id(
600        &mut self,
601        file_path_str: &str,
602        undo_id: &str,
603    ) -> Option<EditCheckpoint> {
604        let index = self
605            .edit_checkpoints
606            .iter()
607            .rposition(|checkpoint| checkpoint.file_path_str == file_path_str)?;
608        let fingerprint = checkpoint_fingerprint(self.edit_checkpoints.get(index)?);
609        if self
610            .edit_checkpoint_metadata
611            .iter()
612            .rev()
613            .find(|metadata| metadata.checkpoint_fingerprint == fingerprint)?
614            .undo_id
615            != undo_id
616        {
617            return None;
618        }
619        let checkpoint = self.edit_checkpoints.remove(index)?;
620        self.remove_checkpoint_metadata(&fingerprint);
621        Some(checkpoint)
622    }
623
624    /// Clone the most recent per-file checkpoint without consuming it. Undo
625    /// removes the checkpoint only after the replacement commits successfully.
626    pub fn latest_edit_checkpoint_for(&self, file_path_str: &str) -> Option<EditCheckpoint> {
627        self.edit_checkpoints
628            .iter()
629            .rev()
630            .find(|checkpoint| checkpoint.file_path_str == file_path_str)
631            .cloned()
632    }
633
634    pub(crate) fn undo_checkpoint_count_for(&self, file_path_str: &str) -> usize {
635        self.edit_checkpoints
636            .iter()
637            .filter(|checkpoint| checkpoint.file_path_str == file_path_str)
638            .count()
639    }
640
641    pub(crate) fn next_undo_id_for(&self, file_path_str: &str) -> Option<String> {
642        let checkpoint = self
643            .edit_checkpoints
644            .iter()
645            .rev()
646            .find(|checkpoint| checkpoint.file_path_str == file_path_str)?;
647        let fingerprint = checkpoint_fingerprint(checkpoint);
648        self.edit_checkpoint_metadata
649            .iter()
650            .rev()
651            .find(|metadata| metadata.checkpoint_fingerprint == fingerprint)
652            .map(|metadata| metadata.undo_id.clone())
653    }
654
655    pub(crate) fn latest_receipt_bound_checkpoint(
656        &self,
657        file_path_str: &str,
658    ) -> Option<(EditCheckpoint, String, String)> {
659        let checkpoint = self
660            .edit_checkpoints
661            .iter()
662            .rev()
663            .find(|checkpoint| checkpoint.file_path_str == file_path_str)?;
664        let fingerprint = checkpoint_fingerprint(checkpoint);
665        let metadata = self
666            .edit_checkpoint_metadata
667            .iter()
668            .rev()
669            .find(|metadata| metadata.checkpoint_fingerprint == fingerprint)?;
670        Some((checkpoint.clone(), metadata.undo_id.clone(), metadata.wrote_hash.clone()))
671    }
672
673    /// Whether an opaque undo handle still has its private in-memory checkpoint.
674    /// Mutation receipts outlive the bounded checkpoint queue, so replay must
675    /// consult this before advertising a handle that may have been evicted.
676    pub(crate) fn has_receipt_bound_checkpoint(&self, file_path_str: &str, undo_id: &str) -> bool {
677        self.edit_checkpoints.iter().rev().any(|checkpoint| {
678            if checkpoint.file_path_str != file_path_str {
679                return false;
680            }
681            let fingerprint = checkpoint_fingerprint(checkpoint);
682            self.edit_checkpoint_metadata.iter().rev().any(|metadata| {
683                metadata.checkpoint_fingerprint == fingerprint && metadata.undo_id == undo_id
684            })
685        })
686    }
687
688    fn undo_content_bytes(&self) -> usize {
689        self.edit_checkpoints.iter().map(|checkpoint| checkpoint.prior_content.len()).sum()
690    }
691
692    fn undo_metadata_bytes(&self) -> usize {
693        self.edit_checkpoints.iter().map(checkpoint_metadata_bytes).sum()
694    }
695
696    fn remove_checkpoint_metadata(&mut self, fingerprint: &str) {
697        if let Some(index) = self
698            .edit_checkpoint_metadata
699            .iter()
700            .position(|metadata| metadata.checkpoint_fingerprint == fingerprint)
701        {
702            self.edit_checkpoint_metadata.remove(index);
703        }
704    }
705
706    fn reconcile_checkpoint_metadata(&mut self) {
707        let mut available = HashMap::<String, usize>::new();
708        for checkpoint in &self.edit_checkpoints {
709            *available.entry(checkpoint_fingerprint(checkpoint)).or_default() += 1;
710        }
711        self.edit_checkpoint_metadata.retain(|metadata| {
712            let Some(count) = available.get_mut(&metadata.checkpoint_fingerprint) else {
713                return false;
714            };
715            if *count == 0 {
716                return false;
717            }
718            *count -= 1;
719            true
720        });
721    }
722
723    /// Merge visible read coverage for `path` and make it the newest guarded
724    /// entry. Coverage from a different file version is discarded instead of
725    /// being combined with the new hash, which could otherwise make disjoint
726    /// reads across two versions look like one complete read.
727    pub fn record_read_coverage(
728        &mut self,
729        path: &str,
730        ranges: impl IntoIterator<Item = (usize, usize)>,
731        file_hash: String,
732        total_lines: usize,
733    ) {
734        let ranges = ranges.into_iter().collect::<Vec<_>>();
735        match self.whitelist_for_overwrite.get_mut(path) {
736            Some(existing)
737                if existing.file_hash == file_hash && existing.total_lines == total_lines =>
738            {
739                existing.merge_ranges(ranges);
740            }
741            Some(existing) => {
742                *existing = FileWhitelistData::new(file_hash, ranges, total_lines);
743            }
744            None => {
745                self.whitelist_for_overwrite.insert(
746                    path.to_string(),
747                    FileWhitelistData::new(file_hash, ranges, total_lines),
748                );
749            }
750        }
751        self.touch_whitelist(path);
752        self.enforce_whitelist_cap();
753    }
754
755    /// Replace a whitelist entry after a successful edit or undo.
756    pub fn set_whitelist_entry(&mut self, path: &str, entry: FileWhitelistData) {
757        self.whitelist_for_overwrite.insert(path.to_string(), entry);
758        self.touch_whitelist(path);
759        self.enforce_whitelist_cap();
760    }
761
762    /// Remove a whitelist entry and its LRU metadata.
763    pub fn remove_whitelist_entry(&mut self, path: &str) -> Option<FileWhitelistData> {
764        self.whitelist_recency.retain(|candidate| candidate != path);
765        self.whitelist_for_overwrite.remove(path)
766    }
767
768    fn touch_whitelist(&mut self, path: &str) {
769        self.whitelist_recency.retain(|candidate| candidate != path);
770        self.whitelist_recency.push_back(path.to_string());
771    }
772
773    fn rebuild_whitelist_recency(&mut self) {
774        let mut paths = self.whitelist_for_overwrite.keys().cloned().collect::<Vec<_>>();
775        paths.sort_unstable();
776        self.whitelist_recency = paths.into();
777        self.enforce_whitelist_cap();
778    }
779
780    fn enforce_whitelist_cap(&mut self) {
781        self.whitelist_recency.retain(|path| self.whitelist_for_overwrite.contains_key(path));
782        while self.whitelist_for_overwrite.len() > MAX_WHITELIST_FILES {
783            let victim = self.whitelist_recency.pop_front().or_else(|| {
784                // The map remains public for compatibility, so tolerate callers
785                // that inserted directly without updating the LRU metadata.
786                self.whitelist_for_overwrite.keys().min().cloned()
787            });
788            let Some(victim) = victim else { break };
789            self.whitelist_for_overwrite.remove(&victim);
790        }
791    }
792
793    pub async fn init_pty_shell(&mut self) -> Result<()> {
794        let cwd = self.cwd.clone();
795        let workspace_root = self.workspace_root.clone();
796        let temporary_artifact_dir =
797            crate::utils::agent_temp::session_info(&workspace_root, &self.current_thread_id)
798                .directory;
799        let restricted = self.bash_command_mode.bash_mode == BashMode::RestrictedMode;
800        // PtyShell::new forks+execs a shell and does a ~300ms blocking prompt init
801        // (thread::sleep + drain_output busy-wait). Run it on the blocking pool so
802        // it never pins a tokio worker thread.
803        let shell = tokio::task::spawn_blocking(move || {
804            PtyShell::new_with_agent_paths(
805                &cwd,
806                restricted,
807                Some(&workspace_root),
808                Some(&temporary_artifact_dir),
809            )
810        })
811        .await
812        .map_err(|e| {
813            crate::errors::WinxError::ShellInitializationError(format!("PTY init task failed: {e}"))
814        })??;
815        *self.pty_shell.lock().await = Some(shell);
816        Ok(())
817    }
818
819    pub fn update_cwd(&mut self, path: &Path) -> Result<()> {
820        self.cwd = path.to_path_buf();
821        Ok(())
822    }
823
824    pub fn update_workspace_root(&mut self, path: &Path) -> Result<()> {
825        self.workspace_root = path.to_path_buf();
826        Ok(())
827    }
828
829    pub fn is_command_allowed(&self, command: &str) -> bool {
830        if self.mode == Modes::Architect {
831            crate::utils::bash_parser::is_architect_command_allowed(command)
832        } else {
833            self.bash_command_mode.allowed_commands.is_allowed(command)
834        }
835    }
836
837    pub fn is_file_edit_allowed(&self, path: &str) -> bool {
838        self.file_edit_mode.allowed_globs.is_allowed(path)
839    }
840
841    pub fn is_file_write_allowed(&self, path: &str) -> bool {
842        self.write_if_empty_mode.allowed_globs.is_allowed(path)
843    }
844    pub fn save_state_to_disk(&self) -> Result<()> {
845        let snapshot = self.snapshot();
846        save_state_file(&self.current_thread_id, &snapshot)?;
847        Ok(())
848    }
849
850    pub fn snapshot(&self) -> BashStateSnapshot {
851        BashStateSnapshot::from_state(
852            &self.cwd.to_string_lossy(),
853            &self.workspace_root.to_string_lossy(),
854            self.mode,
855            &self.bash_command_mode,
856            &self.file_edit_mode,
857            &self.write_if_empty_mode,
858            &self.whitelist_for_overwrite,
859            &self.current_thread_id,
860            &self.edit_mutation_receipts,
861        )
862    }
863
864    pub fn apply_snapshot(&mut self, snapshot: &BashStateSnapshot) {
865        let (cwd, root, mode, bmode, emode, wmode, whitelist, tid) = snapshot.to_state_components();
866        let root = PathBuf::from(root);
867        let image_identity_changed = self.current_thread_id != tid || self.workspace_root != root;
868
869        self.cwd = PathBuf::from(cwd);
870        self.workspace_root = root;
871        self.mode = mode;
872        self.bash_command_mode = bmode;
873        self.file_edit_mode = emode;
874        self.write_if_empty_mode = wmode;
875        self.whitelist_for_overwrite = whitelist;
876        self.rebuild_whitelist_recency();
877        self.current_thread_id = tid;
878        self.edit_mutation_receipts = snapshot.edit_mutation_receipts.iter().cloned().collect();
879        // Undo checkpoints are deliberately live-process only. Applying a
880        // persisted snapshot is a restart/reattach boundary and must never make
881        // a stale opaque undo receipt valid again.
882        self.edit_checkpoints.clear();
883        self.edit_checkpoint_metadata.clear();
884        for receipt in &mut self.edit_mutation_receipts {
885            // Opaque undo handles are meaningful only while their private
886            // in-memory checkpoints exist. Never advertise one after a
887            // restart/reattach boundary has deliberately dropped that history.
888            receipt.undo_ids.clear();
889            receipt.next_undo_id = None;
890            if matches!(
891                receipt.verification_state,
892                Some(EditVerificationState::Reserved | EditVerificationState::Running)
893            ) {
894                receipt.verification_state = Some(EditVerificationState::Interrupted);
895                receipt.verification_pending = true;
896                receipt.verification_execution = None;
897                receipt.status = "completed_with_issues".to_string();
898            }
899        }
900        if image_identity_changed {
901            self.image_deliveries.clear();
902        }
903        self.initialized = true;
904    }
905
906    pub fn load_state_from_disk(&mut self, thread_id: &str) -> Result<bool> {
907        if let Some(snapshot) = load_state_file(thread_id)? {
908            self.apply_snapshot(&snapshot);
909            Ok(true)
910        } else {
911            Ok(false)
912        }
913    }
914
915    pub fn new_with_thread_id(thread_id: Option<&str>) -> Self {
916        let mut state = Self::new();
917
918        if let Some(tid) = thread_id {
919            if !tid.is_empty() {
920                if let Ok(true) = state.load_state_from_disk(tid) {
921                    info!("Loaded state for thread_id '{}'", tid);
922                } else {
923                    state.current_thread_id = tid.to_string();
924                }
925            }
926        }
927
928        state
929    }
930}
931
932fn sanitize_checkpoint_metadata(checkpoint: &mut EditCheckpoint) {
933    let Some(whitelist) = checkpoint.prior_whitelist.as_mut() else { return };
934    let mut ranges = std::mem::take(&mut whitelist.line_ranges_read);
935    ranges.sort_unstable();
936    let mut bounded: Vec<(usize, usize)> =
937        Vec::with_capacity(ranges.len().min(EDIT_CHECKPOINT_MAX_READ_RANGES));
938    for (start, end) in ranges {
939        if start > end {
940            continue;
941        }
942        if let Some((_, previous_end)) = bounded.last_mut() {
943            if start <= previous_end.saturating_add(1) {
944                *previous_end = (*previous_end).max(end);
945                continue;
946            }
947        }
948        if bounded.len() < EDIT_CHECKPOINT_MAX_READ_RANGES {
949            bounded.push((start, end));
950        } else {
951            break;
952        }
953    }
954    whitelist.line_ranges_read = bounded;
955}
956
957fn checkpoint_metadata_bytes(checkpoint: &EditCheckpoint) -> usize {
958    let whitelist = checkpoint.prior_whitelist.as_ref().map_or(0, |whitelist| {
959        whitelist
960            .file_hash
961            .len()
962            .saturating_add(whitelist.line_ranges_read.len().saturating_mul(2 * size_of::<usize>()))
963            .saturating_add(size_of::<usize>())
964    });
965    checkpoint
966        .file_path_str
967        .len()
968        .saturating_add(checkpoint.path.as_os_str().as_encoded_bytes().len())
969        .saturating_add(whitelist)
970}
971
972fn checkpoint_fingerprint(checkpoint: &EditCheckpoint) -> String {
973    fn hash_field(hasher: &mut Sha256, bytes: &[u8]) {
974        hasher.update(u64::try_from(bytes.len()).unwrap_or(u64::MAX).to_le_bytes());
975        hasher.update(bytes);
976    }
977    let mut hasher = Sha256::new();
978    hash_field(&mut hasher, checkpoint.file_path_str.as_bytes());
979    hash_field(&mut hasher, checkpoint.path.as_os_str().as_encoded_bytes());
980    hash_field(&mut hasher, checkpoint.prior_content.as_bytes());
981    if let Some(whitelist) = checkpoint.prior_whitelist.as_ref() {
982        hash_field(&mut hasher, whitelist.file_hash.as_bytes());
983        hasher.update(u64::try_from(whitelist.total_lines).unwrap_or(u64::MAX).to_le_bytes());
984        for (start, end) in &whitelist.line_ranges_read {
985            hasher.update(u64::try_from(*start).unwrap_or(u64::MAX).to_le_bytes());
986            hasher.update(u64::try_from(*end).unwrap_or(u64::MAX).to_le_bytes());
987        }
988    }
989    let digest = hasher.finalize();
990    let mut encoded = String::with_capacity(digest.len() * 2);
991    for byte in digest {
992        let _ = write!(encoded, "{byte:02x}");
993    }
994    encoded
995}
996
997pub fn generate_thread_id() -> String {
998    let mut rng = rand::rng();
999    format!("tid_{:x}", rng.random::<u64>())
1000}
1001
1002#[cfg(test)]
1003mod whitelist_range_tests {
1004    #![allow(clippy::expect_used)]
1005
1006    use std::collections::HashMap;
1007    use std::time::{Duration, Instant};
1008
1009    use super::{
1010        BashState, EditCheckpoint, EditMutationPostcondition, EditMutationReceipt,
1011        EditVerificationState, FileWhitelistData, EDIT_CHECKPOINT_CAP,
1012        EDIT_CHECKPOINT_CONTENT_BYTES_CAP, EDIT_CHECKPOINT_MAX_READ_RANGES,
1013        EDIT_CHECKPOINT_METADATA_BYTES_CAP, EDIT_MUTATION_RECEIPT_CAP, MAX_WHITELIST_FILES,
1014        SHELL_RESET_COOLDOWN,
1015    };
1016
1017    fn wl(ranges: &[(usize, usize)], total: usize) -> FileWhitelistData {
1018        FileWhitelistData::new("h".to_string(), ranges.to_vec(), total)
1019    }
1020
1021    #[test]
1022    fn repeated_shell_reset_needs_failure_evidence_until_cooldown_expires() {
1023        let mut state = BashState::new();
1024        let first_reset = Instant::now();
1025        assert!(state.shell_reset_retry_after(first_reset).is_none());
1026
1027        state.record_shell_reset(first_reset);
1028        assert_eq!(
1029            state.shell_reset_retry_after(first_reset + Duration::from_secs(30)),
1030            Some(SHELL_RESET_COOLDOWN.saturating_sub(Duration::from_secs(30)))
1031        );
1032
1033        state.record_shell_runtime_failure();
1034        assert!(state.shell_reset_retry_after(first_reset + Duration::from_secs(31)).is_none());
1035
1036        state.record_shell_reset(first_reset + Duration::from_secs(31));
1037        assert!(state
1038            .shell_reset_retry_after(first_reset + Duration::from_secs(31) + SHELL_RESET_COOLDOWN)
1039            .is_none());
1040    }
1041
1042    #[test]
1043    fn merge_collapses_overlap_and_adjacency() {
1044        // (1,3)+(4,5) are adjacent (inclusive) -> (1,5); (7,9)+(8,12) overlap -> (7,12).
1045        let w = wl(&[(4, 5), (1, 3), (8, 12), (7, 9)], 20);
1046        assert_eq!(w.line_ranges_read, vec![(1, 5), (7, 12)]);
1047    }
1048
1049    #[test]
1050    fn re_reading_does_not_grow_unbounded() {
1051        let mut w = wl(&[(1, 10)], 100);
1052        for _ in 0..1000 {
1053            w.merge_ranges(std::iter::once((1, 10)));
1054            w.merge_ranges(std::iter::once((5, 15)));
1055        }
1056        // 1000 re-reads collapse to a single interval, not 2000 entries.
1057        assert_eq!(w.line_ranges_read, vec![(1, 15)]);
1058    }
1059
1060    #[test]
1061    fn percentage_counts_distinct_lines_with_overlap() {
1062        // lines 1..=5 and 3..=8 cover 1..=8 = 8 of 10 = 80%.
1063        let w = wl(&[(1, 5), (3, 8)], 10);
1064        assert!((w.get_percentage_read() - 80.0).abs() < 1e-9);
1065        assert!(wl(&[(1, 10)], 10).is_read_enough());
1066    }
1067
1068    #[test]
1069    fn unread_ranges_are_the_gaps() {
1070        // read 2..=4 and 7..=8 of 10 -> unread 1, 5..=6, 9..=10.
1071        let w = wl(&[(2, 4), (7, 8)], 10);
1072        assert_eq!(w.get_unread_ranges(), vec![(1, 1), (5, 6), (9, 10)]);
1073    }
1074
1075    #[test]
1076    fn out_of_range_entries_are_clamped() {
1077        // A (0, 999) range on a 10-line file counts as full coverage, not a panic.
1078        let w = wl(&[(0, 999)], 10);
1079        assert!((w.get_percentage_read() - 100.0).abs() < 1e-9);
1080        assert!(w.get_unread_ranges().is_empty(), "expected empty w.get_unread_ranges()");
1081    }
1082
1083    #[test]
1084    fn coverage_from_different_file_versions_is_not_combined() {
1085        let mut state = BashState::new();
1086        state.record_read_coverage("/workspace/file.rs", [(1, 50)], "old-hash".to_string(), 100);
1087        state.record_read_coverage("/workspace/file.rs", [(51, 100)], "new-hash".to_string(), 100);
1088
1089        let coverage = &state.whitelist_for_overwrite["/workspace/file.rs"];
1090        assert_eq!(coverage.file_hash, "new-hash");
1091        assert_eq!(coverage.line_ranges_read, vec![(51, 100)]);
1092        assert_eq!(coverage.get_unread_ranges(), vec![(1, 50)]);
1093    }
1094
1095    #[test]
1096    fn whitelist_is_lru_bounded_and_recent_entries_survive() {
1097        let mut state = BashState::new();
1098        for index in 0..MAX_WHITELIST_FILES {
1099            let path = format!("/workspace/file-{index:04}.rs");
1100            state.record_read_coverage(&path, [(1, 1)], format!("hash-{index}"), 1);
1101        }
1102
1103        // Refresh the oldest entry, then force one eviction. The next-oldest
1104        // entry must be discarded while the refreshed one stays guarded.
1105        state.record_read_coverage("/workspace/file-0000.rs", [(1, 1)], "hash-0".to_string(), 1);
1106        state.record_read_coverage("/workspace/newest.rs", [(1, 1)], "newest-hash".to_string(), 1);
1107
1108        assert_eq!(state.whitelist_for_overwrite.len(), MAX_WHITELIST_FILES);
1109        assert!(state.whitelist_for_overwrite.contains_key("/workspace/file-0000.rs"));
1110        assert!(!state.whitelist_for_overwrite.contains_key("/workspace/file-0001.rs"));
1111        assert!(state.whitelist_for_overwrite.contains_key("/workspace/newest.rs"));
1112    }
1113
1114    #[test]
1115    fn image_delivery_cache_survives_same_session_snapshot_sync_only() {
1116        let mut state = BashState::new();
1117        state.workspace_root = "/workspace/one".into();
1118        state.current_thread_id = "one".to_string();
1119        state.record_image_delivery("fingerprint".to_string());
1120
1121        let same_session = state.snapshot();
1122        state.apply_snapshot(&same_session);
1123        assert!(state.image_was_delivered("fingerprint"));
1124
1125        let mut other = BashState::new();
1126        other.workspace_root = "/workspace/two".into();
1127        other.current_thread_id = "two".to_string();
1128        state.apply_snapshot(&other.snapshot());
1129        assert!(!state.image_was_delivered("fingerprint"));
1130    }
1131
1132    #[test]
1133    fn mutation_receipts_survive_snapshot_and_expire_closed() -> Result<(), &'static str> {
1134        let mut state = BashState::new();
1135        state.record_edit_mutation_receipt(EditMutationReceipt {
1136            schema_version: 0,
1137            fingerprint: "fingerprint".to_string(),
1138            receipt_id: "edit_receipt".to_string(),
1139            tool: "FileWriteOrEdit".to_string(),
1140            status: "completed".to_string(),
1141            committed_at_unix_ms: 1_000,
1142            postconditions: vec![EditMutationPostcondition {
1143                path: "/workspace/file.rs".to_string(),
1144                sha256: "hash".to_string(),
1145            }],
1146            persisted: true,
1147            verification_pending: false,
1148            verification_id: None,
1149            legacy_verification_id: None,
1150            verification_state: None,
1151            verification_execution: None,
1152            uncommitted_paths: Vec::new(),
1153            next_undo_id: None,
1154            undo_ids: Vec::new(),
1155        });
1156
1157        let snapshot = state.snapshot();
1158        let mut restored = BashState::new();
1159        restored.apply_snapshot(&snapshot);
1160        let receipt = restored
1161            .edit_mutation_receipt("fingerprint", 1_500, 1_000)
1162            .ok_or("fresh persisted receipt missing")?;
1163        assert_eq!(receipt.receipt_id, "edit_receipt");
1164        assert!(receipt.persisted);
1165        assert!(restored.edit_mutation_receipt("fingerprint", 3_000, 1_000).is_none());
1166        Ok(())
1167    }
1168
1169    #[test]
1170    fn receipt_bound_verification_updates_without_extending_commit_ttl() -> Result<(), &'static str>
1171    {
1172        let mut state = BashState::new();
1173        state.record_edit_mutation_receipt(EditMutationReceipt {
1174            schema_version: 1,
1175            fingerprint: "fingerprint".to_string(),
1176            receipt_id: "edit_receipt".to_string(),
1177            tool: "EditFiles".to_string(),
1178            status: "completed_with_issues".to_string(),
1179            committed_at_unix_ms: 1_000,
1180            postconditions: Vec::new(),
1181            persisted: true,
1182            verification_pending: true,
1183            verification_id: Some("verify_receipt".to_string()),
1184            legacy_verification_id: None,
1185            verification_state: Some(EditVerificationState::Pending),
1186            verification_execution: None,
1187            uncommitted_paths: Vec::new(),
1188            next_undo_id: None,
1189            undo_ids: Vec::new(),
1190        });
1191        assert!(state.update_edit_mutation_verification(
1192            "verify_receipt",
1193            "completed",
1194            EditVerificationState::Passed,
1195            None,
1196            1_500,
1197            1_000,
1198        ));
1199        let receipt = state
1200            .edit_mutation_receipt("fingerprint", 1_500, 1_000)
1201            .ok_or("updated receipt missing")?;
1202        assert_eq!(receipt.status, "completed");
1203        assert!(!receipt.verification_pending);
1204        assert_eq!(receipt.committed_at_unix_ms, 1_000);
1205        assert!(state.edit_mutation_receipt("fingerprint", 3_000, 1_000).is_none());
1206        Ok(())
1207    }
1208
1209    fn verification_receipt(
1210        fingerprint: &str,
1211        tool: &str,
1212        canonical_id: &str,
1213        legacy_id: Option<&str>,
1214    ) -> EditMutationReceipt {
1215        EditMutationReceipt {
1216            schema_version: 1,
1217            fingerprint: fingerprint.to_string(),
1218            receipt_id: format!("edit_{fingerprint}"),
1219            tool: tool.to_string(),
1220            status: "completed_with_issues".to_string(),
1221            committed_at_unix_ms: 1_000,
1222            postconditions: Vec::new(),
1223            persisted: true,
1224            verification_pending: true,
1225            verification_id: Some(canonical_id.to_string()),
1226            legacy_verification_id: legacy_id.map(str::to_string),
1227            verification_state: Some(EditVerificationState::Pending),
1228            verification_execution: None,
1229            uncommitted_paths: Vec::new(),
1230            next_undo_id: None,
1231            undo_ids: Vec::new(),
1232        }
1233    }
1234
1235    #[test]
1236    fn canonical_verification_lookup_ignores_reordered_legacy_shadow_collision(
1237    ) -> Result<(), &'static str> {
1238        let mut state = BashState::new();
1239        state.record_edit_mutation_receipt(verification_receipt(
1240            "canonical",
1241            "EditFiles",
1242            "verify_same",
1243            None,
1244        ));
1245        state.record_edit_mutation_receipt(verification_receipt(
1246            "shadow",
1247            "FileWriteOrEdit",
1248            "verify_same",
1249            Some("verify_legacy"),
1250        ));
1251
1252        let shadow = state
1253            .edit_mutation_receipt_by_legacy_verification_id("verify_legacy", 1_001, 10_000)
1254            .ok_or("legacy shadow missing")?;
1255        assert_eq!(shadow.tool, "FileWriteOrEdit");
1256        let canonical = state
1257            .edit_mutation_receipt_by_verification_id("verify_same", 1_001, 10_000)
1258            .ok_or("canonical receipt missing")?;
1259        assert_eq!(canonical.tool, "EditFiles");
1260        assert_eq!(canonical.fingerprint, "canonical");
1261        Ok(())
1262    }
1263
1264    #[test]
1265    fn mutation_receipt_lru_evicts_canonical_shadow_groups_atomically() {
1266        let mut state = BashState::new();
1267        for index in 0..=(EDIT_MUTATION_RECEIPT_CAP / 2) {
1268            let receipt_id = format!("edit-{index}");
1269            let canonical_id = format!("verify-{index}");
1270            let mut canonical = verification_receipt(
1271                &format!("canonical-{index}"),
1272                "EditFiles",
1273                &canonical_id,
1274                None,
1275            );
1276            canonical.receipt_id.clone_from(&receipt_id);
1277            let mut shadow = verification_receipt(
1278                &format!("legacy-{index}"),
1279                "FileWriteOrEdit",
1280                &canonical_id,
1281                Some(&format!("legacy-verify-{index}")),
1282            );
1283            shadow.receipt_id = receipt_id;
1284            state.record_edit_mutation_receipts([canonical, shadow]);
1285        }
1286
1287        let groups = state.edit_mutation_receipts.iter().fold(
1288            HashMap::<&str, usize>::new(),
1289            |mut groups, receipt| {
1290                *groups.entry(receipt.receipt_id.as_str()).or_default() += 1;
1291                groups
1292            },
1293        );
1294        assert!(state.edit_mutation_receipts.len() <= EDIT_MUTATION_RECEIPT_CAP);
1295        assert!(groups.values().all(|count| *count == 2));
1296    }
1297
1298    fn checkpoint(path: &str, _id: usize, prior_content: String) -> EditCheckpoint {
1299        EditCheckpoint {
1300            file_path_str: path.to_string(),
1301            path: path.into(),
1302            prior_content,
1303            prior_whitelist: None,
1304        }
1305    }
1306
1307    #[test]
1308    fn undo_history_is_lifo_per_file_and_bounded_by_count() {
1309        let mut state = BashState::new();
1310        let mut ids = Vec::new();
1311        for id in 0..=(EDIT_CHECKPOINT_CAP + 2) {
1312            ids.push(
1313                state
1314                    .push_receipt_bound_edit_checkpoint(
1315                        checkpoint("/workspace/hot.rs", id, "x".to_string()),
1316                        format!("hash-{id}"),
1317                    )
1318                    .expect("retained newest checkpoint"),
1319            );
1320        }
1321        assert_eq!(state.undo_checkpoint_count_for("/workspace/hot.rs"), EDIT_CHECKPOINT_CAP);
1322        let latest = ids.last().expect("latest id");
1323        assert_eq!(state.next_undo_id_for("/workspace/hot.rs").as_deref(), Some(latest.as_str()));
1324        assert!(state.pop_latest_edit_checkpoint_by_id("/workspace/hot.rs", &ids[1]).is_none());
1325
1326        for id in 0..(EDIT_CHECKPOINT_CAP + 8) {
1327            let path = format!("/workspace/file-{id}.rs");
1328            state.push_edit_checkpoint(checkpoint(&path, 100 + id, String::new()));
1329        }
1330        assert!(state.edit_checkpoints.len() <= EDIT_CHECKPOINT_CAP);
1331    }
1332
1333    #[test]
1334    fn undo_content_and_metadata_budgets_are_deterministic() {
1335        let mut state = BashState::new();
1336        let chunk = "x".repeat(900_000);
1337        state.push_edit_checkpoint(checkpoint("/workspace/cold.rs", 1, chunk.clone()));
1338        for id in 2..=6 {
1339            state.push_edit_checkpoint(checkpoint("/workspace/hot.rs", id, chunk.clone()));
1340        }
1341
1342        assert!(state.undo_content_bytes() <= EDIT_CHECKPOINT_CONTENT_BYTES_CAP);
1343        assert!(state.edit_checkpoints.len() <= EDIT_CHECKPOINT_CAP);
1344
1345        let mut huge = checkpoint("/workspace/ranges.rs", 7, String::new());
1346        huge.prior_whitelist = Some(FileWhitelistData {
1347            file_hash: "h".repeat(1_024),
1348            line_ranges_read: (0..100_000).map(|index| (index * 2 + 1, index * 2 + 1)).collect(),
1349            total_lines: 200_000,
1350        });
1351        state.push_edit_checkpoint(huge);
1352        let retained = state.latest_edit_checkpoint_for("/workspace/ranges.rs").expect("retained");
1353        assert!(
1354            retained.prior_whitelist.expect("whitelist").line_ranges_read.len()
1355                <= EDIT_CHECKPOINT_MAX_READ_RANGES
1356        );
1357        assert!(state.undo_metadata_bytes() <= EDIT_CHECKPOINT_METADATA_BYTES_CAP);
1358    }
1359
1360    #[test]
1361    fn undo_content_is_intentionally_absent_after_snapshot_restart() {
1362        let mut state = BashState::new();
1363        state.push_edit_checkpoint(checkpoint(
1364            "/workspace/file.rs",
1365            1,
1366            "private prior source".to_string(),
1367        ));
1368        state.record_edit_mutation_receipt(EditMutationReceipt {
1369            schema_version: 1,
1370            fingerprint: "apply-fingerprint".to_string(),
1371            receipt_id: "edit-apply".to_string(),
1372            tool: "EditFiles".to_string(),
1373            status: "completed".to_string(),
1374            committed_at_unix_ms: 1_000,
1375            postconditions: Vec::new(),
1376            persisted: true,
1377            verification_pending: false,
1378            verification_id: None,
1379            legacy_verification_id: None,
1380            verification_state: None,
1381            verification_execution: None,
1382            uncommitted_paths: Vec::new(),
1383            next_undo_id: Some("undo-next".to_string()),
1384            undo_ids: vec![Some("undo-current".to_string()), None],
1385        });
1386        let snapshot = state.snapshot();
1387        let mut restored = BashState::new();
1388        restored.apply_snapshot(&snapshot);
1389
1390        assert!(restored.next_undo_id_for("/workspace/file.rs").is_none());
1391        assert_eq!(restored.undo_content_bytes(), 0);
1392        let replay = restored
1393            .edit_mutation_receipt("apply-fingerprint", 1_001, 10_000)
1394            .expect("mutation receipt remains available");
1395        assert!(replay.undo_ids.is_empty(), "expected empty replay.undo_ids");
1396        assert!(replay.next_undo_id.is_none());
1397    }
1398
1399    #[test]
1400    fn restart_interrupts_reserved_and_running_verifications_without_a_binding() {
1401        let mut state = BashState::new();
1402        for (fingerprint, verification_state, execution) in [
1403            ("reserved", EditVerificationState::Reserved, None),
1404            (
1405                "running",
1406                EditVerificationState::Running,
1407                Some(super::EditVerificationExecution {
1408                    generation: 9,
1409                    guardian_epoch: "old-guardian".to_string(),
1410                    session_epoch: "old-session".to_string(),
1411                }),
1412            ),
1413        ] {
1414            state.record_edit_mutation_receipt(EditMutationReceipt {
1415                schema_version: 1,
1416                fingerprint: fingerprint.to_string(),
1417                receipt_id: format!("edit-{fingerprint}"),
1418                tool: "EditFiles".to_string(),
1419                status: "completed_with_issues".to_string(),
1420                committed_at_unix_ms: 1_000,
1421                postconditions: Vec::new(),
1422                persisted: true,
1423                verification_pending: true,
1424                verification_id: Some(format!("verify-{fingerprint}")),
1425                legacy_verification_id: None,
1426                verification_state: Some(verification_state),
1427                verification_execution: execution,
1428                uncommitted_paths: Vec::new(),
1429                next_undo_id: None,
1430                undo_ids: Vec::new(),
1431            });
1432        }
1433
1434        let snapshot = state.snapshot();
1435        let mut restored = BashState::new();
1436        restored.apply_snapshot(&snapshot);
1437        for fingerprint in ["reserved", "running"] {
1438            let receipt = restored
1439                .edit_mutation_receipt(fingerprint, 1_001, 10_000)
1440                .expect("receipt survives restart");
1441            assert_eq!(receipt.verification_state, Some(EditVerificationState::Interrupted));
1442            assert!(receipt.verification_execution.is_none());
1443            assert!(receipt.verification_pending);
1444        }
1445    }
1446}