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