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