Skip to main content

vtcode_core/core/agent/
context_reset.rs

1//! Context reset logic for long-running harness sessions.
2use anyhow::{Context, Result};
3use serde::{Deserialize, Serialize};
4use std::path::Path;
5
6const CONTEXT_RESET_DIR: &str = ".vtcode/tasks";
7pub const CONTEXT_RESET_FILE: &str = "current_transition.json";
8const LEGACY_CONTEXT_RESET_FILE: &str = "current_context_reset.md";
9const TRANSITION_MANIFEST_VERSION: u32 = 1;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(rename_all = "snake_case")]
13pub enum TransitionKind {
14    CompactionReset,
15    StallReset,
16    LegacyReset,
17}
18
19/// Private durable handoff for compaction/reset recovery. This is deliberately
20/// separate from the frozen public `ThreadEvent` schema.
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22pub struct TransitionManifest {
23    pub version: u32,
24    pub thread_id: Option<String>,
25    pub turn_id: Option<String>,
26    pub transition_kind: TransitionKind,
27    pub triggered_at: String,
28    pub local_trigger: String,
29    pub stall_count: u32,
30    #[serde(default)]
31    pub checkpoint_paths: Vec<String>,
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub compact_boundary_ref: Option<String>,
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub context_reset_ref: Option<String>,
36}
37
38impl TransitionManifest {
39    fn from_legacy(manifest: &ContextResetManifest) -> Self {
40        let transition_kind = match manifest.trigger.as_str() {
41            "compaction" => TransitionKind::CompactionReset,
42            "stall" => TransitionKind::StallReset,
43            _ => TransitionKind::LegacyReset,
44        };
45        Self {
46            version: TRANSITION_MANIFEST_VERSION,
47            thread_id: None,
48            turn_id: None,
49            transition_kind,
50            triggered_at: manifest.triggered_at.clone(),
51            local_trigger: manifest.trigger.clone(),
52            stall_count: manifest.stall_count,
53            checkpoint_paths: Vec::new(),
54            compact_boundary_ref: None,
55            context_reset_ref: None,
56        }
57    }
58
59    fn as_legacy_reset(&self) -> ContextResetManifest {
60        ContextResetManifest {
61            triggered_at: self.triggered_at.clone(),
62            trigger: self.local_trigger.clone(),
63            stall_count: self.stall_count,
64        }
65    }
66
67    pub fn orientation_text(&self) -> String {
68        format!(
69            "# Context Transition Manifest\n\n- Version: {}\n- Transition: {:?}\n- Triggered at: {}\n- Local trigger: {}\n- Stall count: {}\n\nThis session starts fresh from durable artifacts only. Reorient before applying the reset.",
70            self.version, self.transition_kind, self.triggered_at, self.local_trigger, self.stall_count
71        )
72    }
73}
74
75pub async fn write_transition_manifest_async(workspace_root: &Path, manifest: &TransitionManifest) -> Result<bool> {
76    anyhow::ensure!(
77        manifest.version == TRANSITION_MANIFEST_VERSION,
78        "unsupported transition manifest version {}",
79        manifest.version
80    );
81    let dir = workspace_root.join(CONTEXT_RESET_DIR);
82    tokio::fs::create_dir_all(&dir)
83        .await
84        .with_context(|| format!("create context reset directory {}", dir.display()))?;
85    let path = dir.join(CONTEXT_RESET_FILE);
86    vtcode_commons::fs::write_private_json_file(&path, manifest)
87        .await
88        .with_context(|| format!("write context transition manifest {}", path.display()))?;
89    Ok(true)
90}
91
92#[derive(Debug, Clone, PartialEq, Eq)]
93pub enum ContextResetDecision {
94    Continue,
95    Reset { reason: String, stall_count: u32 },
96}
97
98#[derive(Debug, Clone)]
99pub struct ContextResetManifest {
100    pub triggered_at: String,
101    pub trigger: String,
102    pub stall_count: u32,
103}
104
105impl ContextResetManifest {
106    pub fn to_markdown(&self) -> String {
107        format!(
108            "# Context Reset Manifest\n\n - **Triggered at:** {}\n - **Trigger:** {}\n - **Stall count:** {}\n\nThis session starts fresh from external artifacts only.\nRead the current feature list and progress tracker to reorient.\n",
109            self.triggered_at, self.trigger, self.stall_count
110        )
111    }
112    pub fn from_markdown(md: &str) -> Option<Self> {
113        /// Extract the value following `- **<key>:**` on a line, trimmed.
114        fn field_value(line: &str, key: &str) -> Option<String> {
115            let line = line.trim();
116            let prefix = format!("- **{key}:**");
117            line.strip_prefix(&prefix).map(|rest| rest.trim().to_string())
118        }
119
120        let mut a = String::new();
121        let mut b = String::new();
122        let mut c = 0u32;
123        for line in md.lines() {
124            if let Some(v) = field_value(line, "Triggered at") {
125                a = v;
126            } else if let Some(v) = field_value(line, "Trigger") {
127                b = v;
128            } else if let Some(v) = field_value(line, "Stall count") {
129                c = v.parse().unwrap_or(0);
130            }
131        }
132        if a.is_empty() || b.is_empty() {
133            return None;
134        }
135        Some(Self { triggered_at: a, trigger: b, stall_count: c })
136    }
137}
138
139pub fn should_reset(mode: &str, compaction: bool, stall: u32, threshold: u32) -> ContextResetDecision {
140    match mode {
141        "off" => ContextResetDecision::Continue,
142        "on_compaction" => {
143            if compaction {
144                ContextResetDecision::Reset {
145                    reason: "compaction triggered reset".into(),
146                    stall_count: 0,
147                }
148            } else {
149                ContextResetDecision::Continue
150            }
151        }
152        "on_stall" => {
153            if stall >= threshold && threshold > 0 {
154                ContextResetDecision::Reset {
155                    reason: format!("stall {stall}>={threshold}"),
156                    stall_count: stall,
157                }
158            } else {
159                ContextResetDecision::Continue
160            }
161        }
162        _ => ContextResetDecision::Continue,
163    }
164}
165
166pub fn write_manifest(workspace_root: &Path, manifest: &ContextResetManifest) -> Result<bool> {
167    let dir = workspace_root.join(CONTEXT_RESET_DIR);
168    std::fs::create_dir_all(&dir)?;
169    let serialized = serde_json::to_vec_pretty(&TransitionManifest::from_legacy(manifest))
170        .context("serialize context transition manifest")?;
171    vtcode_commons::VtCodePaths::write_private_file_atomic(dir.join(CONTEXT_RESET_FILE), &serialized)?;
172    Ok(true)
173}
174
175/// Write a context-reset manifest without blocking the async executor.
176pub async fn write_manifest_async(workspace_root: &Path, manifest: &ContextResetManifest) -> Result<bool> {
177    let transition = TransitionManifest::from_legacy(manifest);
178    write_transition_manifest_async(workspace_root, &transition).await
179}
180
181pub fn read_transition_manifest(workspace_root: &Path) -> Result<Option<TransitionManifest>> {
182    let directory = workspace_root.join(CONTEXT_RESET_DIR);
183    let current = directory.join(CONTEXT_RESET_FILE);
184    match std::fs::read(&current) {
185        Ok(contents) => {
186            let manifest: TransitionManifest = serde_json::from_slice(&contents)
187                .with_context(|| format!("parse context transition manifest {}", current.display()))?;
188            anyhow::ensure!(
189                manifest.version == TRANSITION_MANIFEST_VERSION,
190                "unsupported context transition manifest version {}",
191                manifest.version
192            );
193            Ok(Some(manifest))
194        }
195        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
196            let legacy_path = directory.join(LEGACY_CONTEXT_RESET_FILE);
197            match std::fs::read_to_string(&legacy_path) {
198                Ok(legacy) => ContextResetManifest::from_markdown(&legacy)
199                    .map(|manifest| Some(TransitionManifest::from_legacy(&manifest)))
200                    .ok_or_else(|| anyhow::anyhow!("invalid legacy context reset manifest {}", legacy_path.display())),
201                Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
202                Err(error) => {
203                    Err(error).with_context(|| format!("read legacy context reset manifest {}", legacy_path.display()))
204                }
205            }
206        }
207        Err(error) => Err(error).with_context(|| format!("read context transition manifest {}", current.display())),
208    }
209}
210
211/// Read a pending transition manifest without blocking the async executor.
212pub async fn read_transition_manifest_async(workspace_root: &Path) -> Result<Option<TransitionManifest>> {
213    let directory = workspace_root.join(CONTEXT_RESET_DIR);
214    let current = directory.join(CONTEXT_RESET_FILE);
215    match tokio::fs::read(&current).await {
216        Ok(contents) => {
217            let manifest: TransitionManifest = serde_json::from_slice(&contents)
218                .with_context(|| format!("parse context transition manifest {}", current.display()))?;
219            anyhow::ensure!(
220                manifest.version == TRANSITION_MANIFEST_VERSION,
221                "unsupported context transition manifest version {}",
222                manifest.version
223            );
224            Ok(Some(manifest))
225        }
226        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
227            let legacy_path = directory.join(LEGACY_CONTEXT_RESET_FILE);
228            match tokio::fs::read_to_string(&legacy_path).await {
229                Ok(legacy) => ContextResetManifest::from_markdown(&legacy)
230                    .map(|manifest| Some(TransitionManifest::from_legacy(&manifest)))
231                    .ok_or_else(|| anyhow::anyhow!("invalid legacy context reset manifest {}", legacy_path.display())),
232                Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
233                Err(error) => {
234                    Err(error).with_context(|| format!("read legacy context reset manifest {}", legacy_path.display()))
235                }
236            }
237        }
238        Err(error) => Err(error).with_context(|| format!("read context transition manifest {}", current.display())),
239    }
240}
241
242pub fn read_manifest(workspace_root: &Path) -> Option<ContextResetManifest> {
243    read_transition_manifest(workspace_root)
244        .ok()
245        .flatten()
246        .map(|manifest| manifest.as_legacy_reset())
247}
248
249pub fn read_manifest_orientation(workspace_root: &Path) -> Option<String> {
250    read_transition_manifest(workspace_root)
251        .ok()
252        .flatten()
253        .map(|manifest| manifest.orientation_text())
254}
255
256pub fn consume_manifest(workspace_root: &Path) -> Result<()> {
257    let directory = workspace_root.join(CONTEXT_RESET_DIR);
258    for path in [
259        directory.join(CONTEXT_RESET_FILE),
260        directory.join(LEGACY_CONTEXT_RESET_FILE),
261    ] {
262        match std::fs::remove_file(&path) {
263            Ok(()) => {}
264            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
265            Err(error) => {
266                return Err(error).with_context(|| format!("consume context transition manifest {}", path.display()));
267            }
268        }
269    }
270    Ok(())
271}
272
273/// Consume a pending transition manifest without blocking the async executor.
274pub async fn consume_manifest_async(workspace_root: &Path) -> Result<()> {
275    let directory = workspace_root.join(CONTEXT_RESET_DIR);
276    for path in [
277        directory.join(CONTEXT_RESET_FILE),
278        directory.join(LEGACY_CONTEXT_RESET_FILE),
279    ] {
280        match tokio::fs::remove_file(&path).await {
281            Ok(()) => {}
282            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
283            Err(error) => {
284                return Err(error).with_context(|| format!("consume context transition manifest {}", path.display()));
285            }
286        }
287    }
288    Ok(())
289}
290
291/// The `stall_count` is 0 because compaction-triggered resets are not stall-driven;
292/// the threshold check is enforced only for `on_stall` mode in `should_reset`.
293pub fn maybe_write_reset_after_compaction(workspace_root: &Path, mode: &str) -> bool {
294    if mode != "on_compaction" {
295        return false;
296    }
297    let m = ContextResetManifest {
298        triggered_at: chrono::Utc::now().to_rfc3339(),
299        trigger: "compaction".into(),
300        stall_count: 0,
301    };
302    write_manifest(workspace_root, &m).unwrap_or(false)
303}
304
305/// Write the compaction-triggered reset manifest without blocking the runtime.
306pub async fn maybe_write_reset_after_compaction_async(workspace_root: &Path, mode: &str) -> Result<bool> {
307    maybe_write_reset_after_compaction_with_context_async(workspace_root, mode, None, None, Vec::new(), None).await
308}
309
310pub async fn maybe_write_reset_after_compaction_with_context_async(
311    workspace_root: &Path,
312    mode: &str,
313    thread_id: Option<String>,
314    turn_id: Option<String>,
315    checkpoint_paths: Vec<String>,
316    compact_boundary_ref: Option<String>,
317) -> Result<bool> {
318    if mode != "on_compaction" {
319        return Ok(false);
320    }
321    let manifest = TransitionManifest {
322        version: TRANSITION_MANIFEST_VERSION,
323        thread_id,
324        turn_id,
325        transition_kind: TransitionKind::CompactionReset,
326        triggered_at: chrono::Utc::now().to_rfc3339(),
327        local_trigger: "compaction".into(),
328        stall_count: 0,
329        checkpoint_paths,
330        compact_boundary_ref,
331        context_reset_ref: Some("context_reset:unknown".to_string()),
332    };
333    write_transition_manifest_async(workspace_root, &manifest).await
334}
335
336pub fn maybe_write_reset_on_stall(workspace_root: &Path, stall_count: u32, mode: &str, threshold: u32) {
337    if let ContextResetDecision::Reset { stall_count: sc, .. } = should_reset(mode, false, stall_count, threshold) {
338        let m = ContextResetManifest {
339            triggered_at: chrono::Utc::now().to_rfc3339(),
340            trigger: "stall".into(),
341            stall_count: sc,
342        };
343        let _ = write_manifest(workspace_root, &m);
344    }
345}
346
347/// Write the stall-triggered reset manifest without blocking the runtime.
348pub async fn maybe_write_reset_on_stall_async(
349    workspace_root: &Path,
350    stall_count: u32,
351    mode: &str,
352    threshold: u32,
353) -> Result<bool> {
354    maybe_write_reset_on_stall_with_context_async(workspace_root, stall_count, mode, threshold, None, None).await
355}
356
357pub async fn maybe_write_reset_on_stall_with_context_async(
358    workspace_root: &Path,
359    stall_count: u32,
360    mode: &str,
361    threshold: u32,
362    thread_id: Option<String>,
363    turn_id: Option<String>,
364) -> Result<bool> {
365    let ContextResetDecision::Reset { stall_count: sc, .. } = should_reset(mode, false, stall_count, threshold) else {
366        return Ok(false);
367    };
368    let manifest = TransitionManifest {
369        version: TRANSITION_MANIFEST_VERSION,
370        thread_id,
371        turn_id,
372        transition_kind: TransitionKind::StallReset,
373        triggered_at: chrono::Utc::now().to_rfc3339(),
374        local_trigger: "stall".into(),
375        stall_count: sc,
376        checkpoint_paths: Vec::new(),
377        compact_boundary_ref: None,
378        context_reset_ref: Some("context_reset:unknown".to_string()),
379    };
380    write_transition_manifest_async(workspace_root, &manifest).await
381}
382
383#[cfg(test)]
384mod tests {
385    use super::*;
386    use tempfile::TempDir;
387    #[test]
388    fn off_always_continues() {
389        assert!(matches!(should_reset("off", true, 10, 2), ContextResetDecision::Continue));
390    }
391    #[test]
392    fn compaction_triggers() {
393        assert!(matches!(should_reset("on_compaction", true, 0, 0), ContextResetDecision::Reset { .. }));
394    }
395    #[test]
396    fn stall_triggers_at_threshold() {
397        assert!(matches!(should_reset("on_stall", false, 2, 2), ContextResetDecision::Reset { stall_count: 2, .. }));
398    }
399    #[test]
400    fn manifest_round_trips_through_markdown() {
401        let m = ContextResetManifest {
402            triggered_at: "2026-01-01T00:00:00Z".into(),
403            trigger: "stall".into(),
404            stall_count: 3,
405        };
406        let md = m.to_markdown();
407        let back = ContextResetManifest::from_markdown(&md).expect("round-trip");
408        assert_eq!(back.triggered_at, m.triggered_at);
409        assert_eq!(back.trigger, m.trigger);
410        assert_eq!(back.stall_count, 3);
411    }
412    #[test]
413    fn manifest_from_empty_is_none() {
414        assert!(ContextResetManifest::from_markdown("").is_none());
415    }
416
417    #[tokio::test]
418    async fn async_manifest_writer_preserves_manifest_contents() {
419        let workspace = TempDir::new().expect("workspace");
420        let manifest = ContextResetManifest {
421            triggered_at: "2026-01-01T00:00:00Z".into(),
422            trigger: "compaction".into(),
423            stall_count: 0,
424        };
425
426        assert!(write_manifest_async(workspace.path(), &manifest).await.expect("write manifest"));
427        let loaded = read_manifest(workspace.path()).expect("read manifest");
428        assert_eq!(loaded.triggered_at, manifest.triggered_at);
429        assert_eq!(loaded.trigger, manifest.trigger);
430        assert_eq!(loaded.stall_count, manifest.stall_count);
431    }
432
433    #[tokio::test]
434    async fn async_reset_helpers_only_write_for_matching_modes() {
435        let workspace = TempDir::new().expect("workspace");
436
437        assert!(
438            !maybe_write_reset_after_compaction_async(workspace.path(), "off")
439                .await
440                .expect("compaction mode")
441        );
442        assert!(
443            !maybe_write_reset_on_stall_async(workspace.path(), 1, "on_stall", 2)
444                .await
445                .expect("stall threshold")
446        );
447        assert!(read_manifest(workspace.path()).is_none());
448    }
449
450    #[test]
451    fn malformed_transition_manifest_remains_retryable() {
452        let workspace = TempDir::new().expect("workspace");
453        let path = workspace.path().join(CONTEXT_RESET_DIR).join(CONTEXT_RESET_FILE);
454        std::fs::create_dir_all(path.parent().expect("manifest parent")).expect("create manifest parent");
455        std::fs::write(&path, b"{ malformed").expect("write malformed manifest");
456
457        assert!(read_transition_manifest(workspace.path()).is_err());
458        assert!(path.exists(), "failed reads must not consume the pending transition");
459    }
460
461    #[tokio::test]
462    async fn transition_manifest_preserves_identity_and_checkpoint_references() {
463        let workspace = TempDir::new().expect("workspace");
464        let manifest = TransitionManifest {
465            version: TRANSITION_MANIFEST_VERSION,
466            thread_id: Some("thread-1".to_string()),
467            turn_id: Some("turn-2".to_string()),
468            transition_kind: TransitionKind::CompactionReset,
469            triggered_at: "2026-01-01T00:00:00Z".to_string(),
470            local_trigger: "compaction".to_string(),
471            stall_count: 0,
472            checkpoint_paths: vec!["history.md".to_string(), "memory.md".to_string()],
473            compact_boundary_ref: Some("segment-2".to_string()),
474            context_reset_ref: Some("context_reset:unknown".to_string()),
475        };
476
477        assert!(
478            write_transition_manifest_async(workspace.path(), &manifest)
479                .await
480                .expect("write transition")
481        );
482        assert_eq!(read_transition_manifest_async(workspace.path()).await.expect("read transition"), Some(manifest));
483        consume_manifest_async(workspace.path()).await.expect("consume transition");
484        assert_eq!(
485            read_transition_manifest_async(workspace.path())
486                .await
487                .expect("read consumed transition"),
488            None
489        );
490    }
491}