1use 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#[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 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
175pub 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(¤t) {
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
211pub 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(¤t).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
273pub 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
291pub 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
305pub 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
347pub 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}