1use std::fs;
2use std::io::{Read as _, Write as _};
3use std::path::{Component, Path, PathBuf};
4
5use anyhow::{Context, Result, ensure};
6use chrono::Utc;
7use uuid::Uuid;
8use vtcode_commons::canonicalize;
9
10use crate::utils::session_archive::VerifiedSessionArchiveIdentifier;
11use crate::utils::session_debug::sanitize_debug_component;
12
13const TASKS_DIR: &str = ".vtcode/tasks";
14const CURRENT_BLOCKED_FILE: &str = "current_blocked.md";
15const BLOCKERS_DIR: &str = "blockers";
16
17const PLAN_MODE_HANDOFF_GUIDANCE: &str = "- Plan mode was active (read-only) when this turn blocked: `Mutation blocked` means the edit was stopped by policy, not by a failing check. To keep planning, type `continue` to resume with retained history; to implement, approve the plan or run `/mode build` (`/mode auto` for unattended).";
23
24struct BlockedHandoffPaths<'a> {
25 current: &'a Path,
26 archive: &'a Path,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct BlockedHandoffArtifacts {
33 pub current_path: PathBuf,
35 pub archive_path: PathBuf,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum BlockedHandoffResume<'a> {
42 Available(&'a VerifiedSessionArchiveIdentifier),
44 Unavailable(&'a str),
46}
47
48pub fn write_blocked_handoff(
56 workspace: &Path,
57 session_id: &str,
58 outcome_code: &str,
59 blocker_summary: &str,
60 relevant_paths: &[PathBuf],
61) -> Result<BlockedHandoffArtifacts> {
62 write_blocked_handoff_with_resume(
63 workspace,
64 session_id,
65 outcome_code,
66 blocker_summary,
67 relevant_paths,
68 BlockedHandoffResume::Unavailable(
69 "Resume is unavailable because this compatibility entry point has no verified session archive.",
70 ),
71 false,
72 )
73}
74
75pub fn write_blocked_handoff_with_resume(
82 workspace: &Path,
83 session_id: &str,
84 outcome_code: &str,
85 blocker_summary: &str,
86 relevant_paths: &[PathBuf],
87 resume: BlockedHandoffResume<'_>,
88 planning_active: bool,
89) -> Result<BlockedHandoffArtifacts> {
90 let (workspace, tasks_dir, blockers_dir) = safe_handoff_directories(workspace)?;
91 fs::create_dir_all(&blockers_dir)
92 .with_context(|| format!("failed to create blockers dir {}", blockers_dir.display()))?;
93
94 let current_path = tasks_dir.join(CURRENT_BLOCKED_FILE);
95 let timestamp = Utc::now();
96 let archive_name = format!(
97 "{}-{}-{}.md",
98 sanitize_debug_component(session_id, "session"),
99 timestamp.format("%Y%m%dT%H%M%SZ"),
100 Uuid::new_v4()
101 );
102 let archive_path = blockers_dir.join(archive_name);
103
104 let markdown = render_blocked_handoff(
105 &workspace,
106 session_id,
107 outcome_code,
108 blocker_summary,
109 BlockedHandoffPaths { current: ¤t_path, archive: &archive_path },
110 relevant_paths,
111 timestamp.to_rfc3339(),
112 resume,
113 planning_active,
114 );
115
116 write_handoff_file(&archive_path, &markdown, false)?;
117 write_handoff_file(¤t_path, &markdown, true)?;
118
119 copy_blocker_session_forensics(&workspace, session_id, &archive_path);
122 pin_blocker_session_retention(&workspace, session_id);
123
124 Ok(BlockedHandoffArtifacts { current_path, archive_path })
125}
126
127fn copy_blocker_session_forensics(workspace: &Path, session_id: &str, archive_path: &Path) {
130 let session_dir = vtcode_memory::session_directory(workspace, session_id);
131 if !session_dir.is_dir() {
132 return;
133 }
134 copy_forensics_from_dir(&session_dir, archive_path);
135}
136
137fn copy_forensics_from_dir(session_dir: &Path, archive_path: &Path) {
138 let Some(stem) = archive_path.file_stem().and_then(|s| s.to_str()) else {
139 return;
140 };
141 let Some(parent) = archive_path.parent() else {
142 return;
143 };
144 let forensics_dir = parent.join(format!("{stem}-forensics"));
145 if fs::create_dir_all(&forensics_dir).is_err() {
146 return;
147 }
148 for rel in ["events.jsonl", "derived/atif-trajectory.json"] {
149 let src = session_dir.join(rel);
150 if !src.is_file() {
151 continue;
152 }
153 let dest_name = rel.replace('/', "__");
154 let _ = fs::copy(&src, forensics_dir.join(dest_name));
155 }
156}
157
158fn pin_blocker_session_retention(workspace: &Path, session_id: &str) {
160 let session_dir = vtcode_memory::session_directory(workspace, session_id);
161 if !session_dir.is_dir() {
162 return;
163 }
164 let _ = vtcode_memory::pin_session_retention(&session_dir, "unresolved-blocker");
165}
166
167fn unpin_resolved_blocker_session(workspace: &Path, session_id: &str) {
170 let Ok((_, _, blockers_dir)) = safe_handoff_directories(workspace) else {
171 return;
172 };
173 if let Ok(entries) = fs::read_dir(&blockers_dir) {
174 let resolution_marker = format!("resolved_by_session: {session_id}");
175 for entry in entries.flatten() {
176 let path = entry.path();
177 if path.extension().and_then(|e| e.to_str()) != Some("md") {
178 continue;
179 }
180 let Ok(content) = fs::read_to_string(&path) else {
181 continue;
182 };
183 if parse_blocked_handoff_content(&content).is_some_and(|info| info.session_id == session_id)
184 && !content.lines().any(|line| line.trim() == resolution_marker)
185 {
186 return;
188 }
189 }
190 }
191 let session_dir = vtcode_memory::session_directory(workspace, session_id);
192 let _ = vtcode_memory::unpin_session_retention(&session_dir);
193}
194
195#[derive(Debug, Clone, PartialEq, Eq)]
197pub struct BlockedHandoffInfo {
198 pub session_id: String,
199 pub outcome_code: String,
200 pub blocker_summary: String,
201 pub created_at: Option<String>,
202 pub resume_command: Option<String>,
203}
204
205pub fn read_current_blocked_handoff(workspace: &Path) -> Option<BlockedHandoffInfo> {
208 let (_, tasks_dir) = safe_handoff_tasks_dir(workspace).ok()?;
209 let current_path = tasks_dir.join(CURRENT_BLOCKED_FILE);
210 ensure_not_symlink(¤t_path).ok()?;
211 let content = fs::read_to_string(¤t_path).ok()?;
212 parse_blocked_handoff_content(&content)
213}
214
215pub fn find_latest_archived_blocker_for_session(workspace: &Path, session_id: &str) -> Option<BlockedHandoffInfo> {
219 let (_, _, blockers_dir) = safe_handoff_directories(workspace).ok()?;
220 let entries = fs::read_dir(&blockers_dir).ok()?;
221 let needle = session_id.to_ascii_lowercase();
222 let mut best: Option<(std::time::SystemTime, BlockedHandoffInfo)> = None;
223 for entry in entries.flatten() {
224 let path = entry.path();
225 let file_name = path.file_name()?.to_string_lossy().to_ascii_lowercase();
226 if !file_name.contains(&needle) && !needle.contains(file_name.as_str()) {
227 if !file_name.contains("session-") {
229 continue;
230 }
231 }
233 ensure_not_symlink(&path).ok()?;
234 let content = fs::read_to_string(&path).ok()?;
235 let info = parse_blocked_handoff_content(&content)?;
236 if info.session_id != session_id
237 && !session_id.contains(&info.session_id)
238 && !info.session_id.contains(session_id)
239 {
240 continue;
241 }
242 let modified = entry.metadata().and_then(|meta| meta.modified()).ok()?;
243 let replace = best.as_ref().is_none_or(|(best_time, _)| modified > *best_time);
244 if replace {
245 best = Some((modified, info));
246 }
247 }
248 best.map(|(_, info)| info)
249}
250
251fn parse_blocked_handoff_content(content: &str) -> Option<BlockedHandoffInfo> {
252 let mut lines = content.lines();
253 if lines.next()?.trim() != "---" {
254 return None;
255 }
256
257 let mut session_id = None;
258 let mut outcome_code = None;
259 let mut created_at = None;
260 let mut resume_command = None;
261
262 let mut in_front_matter = true;
263 let mut body_lines = Vec::new();
264
265 for line in lines {
266 if in_front_matter {
267 let trimmed = line.trim();
268 if trimmed == "---" {
269 in_front_matter = false;
270 continue;
271 }
272 if let Some((key, val)) = trimmed.split_once(':') {
273 let key = key.trim();
274 let val = val.trim().trim_matches('"').trim_matches('\'').trim().to_string();
275 match key {
276 "session_id" => session_id = Some(val),
277 "outcome" => outcome_code = Some(val),
278 "created_at" => created_at = Some(val),
279 "resume_command" => resume_command = Some(val),
280 _ => {}
281 }
282 }
283 } else {
284 body_lines.push(line);
285 }
286 }
287
288 let session_id = session_id?;
289 let outcome_code = outcome_code.unwrap_or_else(|| "blocked".to_string());
290
291 let mut blocker_summary = String::new();
292 let mut in_summary = false;
293 for line in body_lines {
294 let trimmed = line.trim();
295 if trimmed == "# Blocker Summary" {
296 in_summary = true;
297 continue;
298 }
299 if in_summary {
300 if trimmed.starts_with('#') {
301 break;
302 }
303 blocker_summary.push_str(line);
304 blocker_summary.push('\n');
305 }
306 }
307 let blocker_summary = blocker_summary.trim().to_string();
308
309 Some(BlockedHandoffInfo {
310 session_id,
311 outcome_code,
312 blocker_summary,
313 created_at,
314 resume_command,
315 })
316}
317
318fn safe_handoff_tasks_dir(workspace: &Path) -> Result<(PathBuf, PathBuf)> {
319 let canonical_workspace =
320 canonicalize(workspace).with_context(|| format!("failed to canonicalize {}", workspace.display()))?;
321 let tasks_dir = canonical_workspace.join(TASKS_DIR);
322 ensure_no_symlinked_components(&canonical_workspace, &tasks_dir)?;
323 Ok((canonical_workspace, tasks_dir))
324}
325
326fn safe_handoff_directories(workspace: &Path) -> Result<(PathBuf, PathBuf, PathBuf)> {
327 let (canonical_workspace, tasks_dir) = safe_handoff_tasks_dir(workspace)?;
328 let blockers_dir = tasks_dir.join(BLOCKERS_DIR);
329 ensure_no_symlinked_components(&canonical_workspace, &blockers_dir)?;
330 Ok((canonical_workspace, tasks_dir, blockers_dir))
331}
332
333fn ensure_no_symlinked_components(workspace: &Path, path: &Path) -> Result<()> {
334 let relative = path
335 .strip_prefix(workspace)
336 .with_context(|| format!("handoff path escaped {}", workspace.display()))?;
337 let mut current = workspace.to_path_buf();
338 for component in relative.components() {
339 let Component::Normal(component) = component else {
340 anyhow::bail!("handoff path contains an unsafe component: {}", path.display());
341 };
342 current.push(component);
343 match fs::symlink_metadata(¤t) {
344 Ok(metadata) => {
345 ensure!(
346 !metadata.file_type().is_symlink(),
347 "refusing symlinked handoff directory {}",
348 current.display()
349 );
350 }
351 Err(err) if err.kind() == std::io::ErrorKind::NotFound => break,
352 Err(err) => {
353 return Err(err).with_context(|| format!("failed to inspect handoff directory {}", current.display()));
354 }
355 }
356 }
357 Ok(())
358}
359
360fn ensure_not_symlink(path: &Path) -> Result<()> {
361 match fs::symlink_metadata(path) {
362 Ok(metadata) => {
363 ensure!(!metadata.file_type().is_symlink(), "refusing symlinked handoff {}", path.display());
364 Ok(())
365 }
366 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
367 Err(err) => Err(err).with_context(|| format!("failed to inspect handoff {}", path.display())),
368 }
369}
370
371fn ensure_safe_handoff_target(path: &Path) -> Result<()> {
372 match fs::symlink_metadata(path) {
373 Ok(metadata) => {
374 ensure!(!metadata.file_type().is_symlink(), "refusing symlinked handoff {}", path.display());
375 ensure!(metadata.is_file(), "refusing non-file handoff target {}", path.display());
376 #[cfg(windows)]
377 let is_single_linked = {
378 let file = fs::File::open(path)
379 .with_context(|| format!("failed to open handoff target {} for inspection", path.display()))?;
380 single_link_file(&file)
381 };
382 #[cfg(not(windows))]
383 let is_single_linked = single_link_file(&metadata);
384 ensure!(is_single_linked, "refusing hard-linked handoff target {}", path.display());
385 Ok(())
386 }
387 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
388 Err(err) => Err(err).with_context(|| format!("failed to inspect handoff target {}", path.display())),
389 }
390}
391
392fn write_handoff_file(path: &Path, contents: &str, replace_existing: bool) -> Result<()> {
393 if replace_existing {
394 return write_replaced_handoff_file(path, contents);
395 }
396
397 write_new_handoff_file(path, contents)
398}
399
400fn write_new_handoff_file(path: &Path, contents: &str) -> Result<()> {
401 let mut options = fs::OpenOptions::new();
402 options.write(true);
403 options.create_new(true);
404 #[cfg(unix)]
405 {
406 use std::os::unix::fs::OpenOptionsExt;
407
408 options.custom_flags(libc::O_NOFOLLOW);
409 }
410 let mut file = options
411 .open(path)
412 .with_context(|| format!("failed to open handoff {} for writing", path.display()))?;
413 file.write_all(contents.as_bytes())
414 .with_context(|| format!("failed to write handoff {}", path.display()))?;
415 file.sync_data()
416 .with_context(|| format!("failed to sync handoff {}", path.display()))?;
417 Ok(())
418}
419
420fn write_replaced_handoff_file(path: &Path, contents: &str) -> Result<()> {
421 ensure_safe_handoff_target(path)?;
422 let parent = path.parent().context("handoff target has no parent directory")?;
423 let target_name = path.file_name().and_then(|name| name.to_str()).unwrap_or("handoff");
424 let temporary_path = parent.join(format!(".{target_name}.{}.tmp", Uuid::new_v4()));
425
426 if let Err(error) = write_new_handoff_file(&temporary_path, contents) {
427 let _ = fs::remove_file(&temporary_path);
428 return Err(error);
429 }
430
431 #[cfg(unix)]
432 let replacement = fs::rename(&temporary_path, path);
433 #[cfg(not(unix))]
434 let replacement = match fs::rename(&temporary_path, path) {
435 Ok(()) => Ok(()),
436 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
437 ensure_safe_handoff_target(path)?;
438 fs::remove_file(path).and_then(|()| fs::rename(&temporary_path, path))
439 }
440 Err(error) => Err(error),
441 };
442
443 if let Err(error) = replacement {
444 let _ = fs::remove_file(&temporary_path);
445 return Err(error).with_context(|| format!("failed to replace handoff {}", path.display()));
446 }
447 Ok(())
448}
449
450pub fn clear_current_blocked_handoff(workspace: &Path) -> Result<bool> {
454 let (_, tasks_dir) = safe_handoff_tasks_dir(workspace)?;
455 let current_path = tasks_dir.join(CURRENT_BLOCKED_FILE);
456 ensure_not_symlink(¤t_path)?;
457 match fs::remove_file(¤t_path) {
458 Ok(()) => Ok(true),
459 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false),
460 Err(err) => Err(err).with_context(|| format!("failed to remove {}", current_path.display())),
461 }
462}
463
464pub fn clear_current_blocked_handoff_for_session(workspace: &Path, session_id: &str) -> Result<bool> {
469 let (workspace, tasks_dir) = safe_handoff_tasks_dir(workspace)?;
470 let current_path = tasks_dir.join(CURRENT_BLOCKED_FILE);
471 ensure_not_symlink(¤t_path)?;
472 let claim_path = current_path.with_file_name(format!(
473 ".{CURRENT_BLOCKED_FILE}.{}.{}.resolving",
474 std::process::id(),
475 Uuid::new_v4()
476 ));
477 match fs::rename(¤t_path, &claim_path) {
478 Ok(()) => {}
479 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(false),
480 Err(err) => {
481 return Err(err).with_context(|| {
482 format!("failed to claim blocked handoff {} as {}", current_path.display(), claim_path.display())
483 });
484 }
485 }
486
487 let result = (|| {
488 let content = fs::read_to_string(&claim_path)
489 .with_context(|| format!("failed to read claimed handoff {}", claim_path.display()))?;
490 let Some(info) = parse_blocked_handoff_content(&content) else {
491 return Ok(false);
492 };
493 if info.session_id != session_id {
494 return Ok(false);
495 }
496 mark_archived_handoff_resolved(&workspace, &content, session_id)?;
497 fs::remove_file(&claim_path)
498 .with_context(|| format!("failed to remove claimed handoff {}", claim_path.display()))?;
499 Ok(true)
500 })();
501
502 if !matches!(result, Ok(true)) {
503 restore_claim_without_overwrite(&claim_path, ¤t_path)?;
504 }
505 result
506}
507
508fn restore_claim_without_overwrite(claim_path: &Path, current_path: &Path) -> Result<()> {
512 match fs::hard_link(claim_path, current_path) {
513 Ok(()) => {}
514 Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {}
515 Err(err) => {
516 return Err(err)
517 .with_context(|| format!("failed to restore blocked handoff claim {}", claim_path.display()));
518 }
519 };
520 fs::remove_file(claim_path)
521 .with_context(|| format!("failed to release blocked handoff claim {}", claim_path.display()))
522}
523
524fn front_matter_value<'a>(content: &'a str, expected_key: &str) -> Option<&'a str> {
525 let mut lines = content.lines();
526 if lines.next()?.trim() != "---" {
527 return None;
528 }
529 for line in lines {
530 let trimmed = line.trim();
531 if trimmed == "---" {
532 return None;
533 }
534 let Some((key, value)) = trimmed.split_once(':') else {
535 continue;
536 };
537 if key.trim() == expected_key {
538 return Some(value.trim().trim_matches('"').trim_matches('\''));
539 }
540 }
541 None
542}
543
544fn mark_archived_handoff_resolved(workspace: &Path, current_content: &str, session_id: &str) -> Result<()> {
545 let archive_file =
546 front_matter_value(current_content, "archive_file").context("blocked handoff archive_file is missing")?;
547 let archive_component = Path::new(archive_file);
548 let mut components = archive_component.components();
549 ensure!(
550 matches!(components.next(), Some(Component::Normal(_))) && components.next().is_none(),
551 "blocked handoff archive_file must be a single path component"
552 );
553
554 let (canonical_workspace, _, blockers_dir) = safe_handoff_directories(workspace)?;
555 let archive_path = blockers_dir.join(archive_component);
556 let canonical_blockers =
557 canonicalize(&blockers_dir).with_context(|| format!("failed to canonicalize {}", blockers_dir.display()))?;
558 ensure!(
559 canonical_blockers.starts_with(&canonical_workspace),
560 "blocked handoff blockers directory escaped {}",
561 canonical_workspace.display()
562 );
563 let canonical_archive =
564 canonicalize(&archive_path).with_context(|| format!("failed to canonicalize {}", archive_path.display()))?;
565 ensure!(
566 canonical_archive.parent() == Some(canonical_blockers.as_path()),
567 "blocked handoff archive escaped {}",
568 canonical_blockers.display()
569 );
570
571 let mut archive = open_archive_for_resolution(&canonical_archive)?;
572 let metadata = archive
573 .metadata()
574 .with_context(|| format!("failed to stat {}", canonical_archive.display()))?;
575 ensure!(metadata.is_file(), "blocked handoff archive is not a regular file");
576 #[cfg(windows)]
577 let is_single_linked = single_link_file(&archive);
578 #[cfg(not(windows))]
579 let is_single_linked = single_link_file(&metadata);
580 ensure!(is_single_linked, "blocked handoff archive has unexpected hard links");
581
582 let mut archive_content = String::new();
583 archive
584 .read_to_string(&mut archive_content)
585 .with_context(|| format!("failed to read {}", canonical_archive.display()))?;
586 ensure!(
587 parse_blocked_handoff_content(&archive_content).is_some_and(|info| info.session_id == session_id),
588 "blocked handoff archive session does not match {session_id}"
589 );
590 let resolution_marker = format!("resolved_by_session: {session_id}");
591 if archive_content.lines().any(|line| line.trim() == resolution_marker) {
592 unpin_resolved_blocker_session(workspace, session_id);
595 return Ok(());
596 }
597
598 write!(
599 archive,
600 "\n# Resolution\n\n{resolution_marker}\nresolved_at: {}\nresolution_scope: blocked turn recovered; task may remain incomplete\n",
601 Utc::now().to_rfc3339()
602 )
603 .with_context(|| format!("failed to append resolution to {}", canonical_archive.display()))?;
604 archive
605 .sync_data()
606 .with_context(|| format!("failed to sync {}", canonical_archive.display()))?;
607 unpin_resolved_blocker_session(workspace, session_id);
608 Ok(())
609}
610
611fn open_archive_for_resolution(path: &Path) -> Result<fs::File> {
612 let mut options = fs::OpenOptions::new();
613 options.read(true).append(true);
614 #[cfg(unix)]
615 {
616 use std::os::unix::fs::OpenOptionsExt;
617
618 options.custom_flags(libc::O_NOFOLLOW);
619 }
620 options.open(path).with_context(|| format!("failed to open {}", path.display()))
621}
622
623#[cfg(unix)]
624fn single_link_file(metadata: &fs::Metadata) -> bool {
625 use std::os::unix::fs::MetadataExt;
626
627 metadata.nlink() == 1
628}
629
630#[cfg(windows)]
631#[expect(
632 unsafe_code,
633 reason = "Windows has no stable standard-library hard-link count API; query the owning file handle through Win32"
634)]
635fn single_link_file(file: &fs::File) -> bool {
636 use std::os::windows::io::AsRawHandle;
637 use windows_sys::Win32::Storage::FileSystem::{BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle};
638
639 let mut information = BY_HANDLE_FILE_INFORMATION::default();
640 let query_succeeded = unsafe { GetFileInformationByHandle(file.as_raw_handle(), &mut information) != 0 };
643 query_succeeded && information.nNumberOfLinks == 1
644}
645
646#[cfg(not(any(unix, windows)))]
647const fn single_link_file(_metadata: &fs::Metadata) -> bool {
648 false
651}
652
653fn render_blocked_handoff(
654 workspace: &Path,
655 session_id: &str,
656 outcome_code: &str,
657 blocker_summary: &str,
658 handoff_paths: BlockedHandoffPaths<'_>,
659 relevant_paths: &[PathBuf],
660 created_at: String,
661 resume: BlockedHandoffResume<'_>,
662 planning_active: bool,
663) -> String {
664 let mut paths = vec![
665 workspace.to_path_buf(),
666 handoff_paths.current.to_path_buf(),
667 handoff_paths.archive.to_path_buf(),
668 ];
669 for path in relevant_paths {
670 if !paths.iter().any(|existing| existing == path) {
671 paths.push(path.clone());
672 }
673 }
674
675 let relevant_paths_section = paths
676 .iter()
677 .map(|path| format!("- `{}`", path.display()))
678 .collect::<Vec<_>>()
679 .join("\n");
680
681 let (resume_front_matter, resume_metadata, resume_actionable) = match resume {
682 BlockedHandoffResume::Available(identifier) => (
683 format!("resume_command: \"vtcode --resume {}\"\n", identifier.as_str()),
684 format!("- Resume command: `vtcode --resume {}`\n", identifier.as_str()),
685 format!("- From terminal: Run `vtcode --resume {}`\n", identifier.as_str()),
686 ),
687 BlockedHandoffResume::Unavailable(explanation) => {
688 (String::new(), format!("- Resume unavailable: {}\n", explanation.trim()), String::new())
689 }
690 };
691
692 let actionable_steps = format!(
693 "## Actionable Next Steps\n\n- In this session: Type `continue` to retry with retained history, or provide alternative instructions.\n{resume_actionable}- Archived details: `{}`.\n- Live pointer: `{}` may be cleared after this session recovers successfully.{plan_mode_actionable}",
694 handoff_paths.archive.display(),
695 handoff_paths.current.display(),
696 plan_mode_actionable = if planning_active {
697 format!("\n{PLAN_MODE_HANDOFF_GUIDANCE}")
698 } else {
699 String::new()
700 },
701 );
702 let archive_file = handoff_paths
703 .archive
704 .file_name()
705 .and_then(|name| name.to_str())
706 .unwrap_or("unknown");
707
708 format!(
709 "---\nsession_id: {session_id}\noutcome: {outcome_code}\ncreated_at: {created_at}\nworkspace: {}\narchive_file: {archive_file}\n{resume_front_matter}---\n\n# Blocker Summary\n\n{}\n\n{}\n\n# Session Tracker Snapshot\n\n_Tracker snapshot unavailable: `.vtcode/tasks/current_task.md` is workspace-global and is not safe to attribute to this session._\n\n# Relevant Paths\n\n{}\n\n# Resume Metadata\n\n- Session ID: `{session_id}`\n- Outcome: `{outcome_code}`\n{resume_metadata}",
710 workspace.display(),
711 blocker_summary.trim(),
712 actionable_steps,
713 relevant_paths_section,
714 )
715}
716
717#[derive(Debug, Clone, PartialEq, Eq)]
719pub struct AsyncApprovalArtifacts {
720 pub current_path: PathBuf,
722 pub approval_token: String,
724}
725
726pub fn write_async_approval_blocker(
732 workspace: &Path,
733 session_id: &str,
734 approval_question: &str,
735 tool_name: &str,
736 args: &serde_json::Value,
737 estimated_cost: Option<f64>,
738 notify_command: Option<&str>,
739) -> Result<AsyncApprovalArtifacts> {
740 let (_, _, blockers_dir) = safe_handoff_directories(workspace)?;
741 fs::create_dir_all(&blockers_dir)
742 .with_context(|| format!("failed to create blockers dir {}", blockers_dir.display()))?;
743
744 let approval_token = Uuid::new_v4().to_string();
745 let timestamp = Utc::now();
746 let archive_name = format!(
747 "async-{}-{}-{}.md",
748 sanitize_debug_component(session_id, "session"),
749 timestamp.format("%Y%m%dT%H%M%SZ"),
750 Uuid::new_v4()
751 );
752 let current_path = blockers_dir.join(archive_name);
753
754 let cost_line = estimated_cost.map(|c| format!("Estimated cost: ${c:.4}")).unwrap_or_default();
755
756 let notify_line = notify_command.map(|cmd| format!("Notify command: `{cmd}`")).unwrap_or_default();
757
758 let markdown = format!(
759 "---\ntoken: {approval_token}\nsession_id: {session_id}\ntool: {tool_name}\ncreated_at: {created_at}\ntype: async_approval\n---\n\n\
760 # Async Approval Request\n\n\
761 ## Question\n\n{approval_question}\n\n\
762 ## Tool\n- Name: `{tool_name}`\n- Arguments: ```json\n{args_json}\n```\n\
763 {cost_line}\n{notify_line}\n\n\
764 ## How to Approve\n\n\
765 ```\nvtcode approve {approval_token}\nvtcode reject {approval_token}\nvtcode approve list\n```\n",
766 created_at = timestamp.to_rfc3339(),
767 args_json = serde_json::to_string_pretty(args).unwrap_or_else(|_| args.to_string()),
768 );
769
770 write_handoff_file(¤t_path, &markdown, false)?;
771
772 Ok(AsyncApprovalArtifacts { current_path, approval_token })
773}
774
775#[cfg(test)]
776mod tests {
777 use crate::utils::session_archive::VerifiedSessionArchiveIdentifier;
778
779 use super::*;
780
781 #[test]
782 fn writes_current_and_archived_blocked_handoffs() {
783 let temp = tempfile::TempDir::new().expect("temp");
784 let session_dir = vtcode_memory::session_directory(temp.path(), "session-a");
786 fs::create_dir_all(session_dir.join("derived")).expect("session dir");
787 fs::write(session_dir.join("events.jsonl"), "{\"type\":\"thread.started\"}\n").expect("events");
788 fs::write(session_dir.join("derived/atif-trajectory.json"), "{\"schema_version\":\"ATIF-v1.4\",\"steps\":[]}")
789 .expect("atif");
790
791 let artifacts =
792 write_blocked_handoff(temp.path(), "session-a", "blocked", "first", &[]).expect("write first handoff");
793
794 assert!(artifacts.archive_path.is_file());
795 let stem = artifacts.archive_path.file_stem().unwrap().to_string_lossy().to_string();
796 let forensics = artifacts
797 .archive_path
798 .parent()
799 .expect("parent")
800 .join(format!("{stem}-forensics"));
801 assert!(forensics.join("events.jsonl").is_file(), "forensics events copy missing");
802 assert!(forensics.join("derived__atif-trajectory.json").is_file(), "forensics ATIF copy missing");
803 assert!(
804 vtcode_memory::session_retention_pinned(&session_dir),
805 "unresolved blocker must pin session retention"
806 );
807
808 let artifacts2 =
809 write_blocked_handoff(temp.path(), "session-a", "blocked", "second", &[]).expect("write second handoff");
810 assert_ne!(artifacts.archive_path, artifacts2.archive_path);
811 }
812
813 #[test]
814 fn resolution_clears_retention_pin_when_no_other_open_blocker() {
815 let temp = tempfile::TempDir::new().expect("temp");
816 let session_dir = vtcode_memory::session_directory(temp.path(), "session-pin");
817 fs::create_dir_all(&session_dir).expect("session dir");
818
819 let artifacts =
820 write_blocked_handoff(temp.path(), "session-pin", "blocked", "stall", &[]).expect("write handoff");
821 assert!(vtcode_memory::session_retention_pinned(&session_dir));
822
823 clear_current_blocked_handoff_for_session(temp.path(), "session-pin").expect("resolve");
824 assert!(
825 !vtcode_memory::session_retention_pinned(&session_dir),
826 "resolution must clear retention pin when no other unresolved archive remains"
827 );
828 assert!(artifacts.archive_path.is_file());
829 }
830
831 #[test]
832 fn blocked_handoff_archives_have_unique_paths() {
833 let temp = tempfile::tempdir().expect("temp dir");
834
835 let first =
836 write_blocked_handoff(temp.path(), "session-a", "blocked", "first", &[]).expect("write first handoff");
837 let second =
838 write_blocked_handoff(temp.path(), "session-a", "blocked", "second", &[]).expect("write second handoff");
839
840 assert_ne!(first.archive_path, second.archive_path);
841 assert!(first.archive_path.exists());
842 assert!(second.archive_path.exists());
843 assert!(
844 fs::read_to_string(first.archive_path)
845 .expect("read first archive")
846 .contains("first")
847 );
848 }
849
850 #[test]
851 fn replacing_current_handoff_does_not_leave_temporary_files() {
852 let temp = tempfile::tempdir().expect("temp dir");
853 let tasks_dir = temp.path().join(TASKS_DIR);
854 fs::create_dir_all(&tasks_dir).expect("tasks dir");
855 let current = tasks_dir.join(CURRENT_BLOCKED_FILE);
856 fs::write(¤t, "old handoff").expect("write old handoff");
857
858 write_handoff_file(¤t, "new handoff", true).expect("replace handoff");
859
860 assert_eq!(fs::read_to_string(¤t).expect("read replacement"), "new handoff");
861 assert_eq!(
862 fs::read_dir(tasks_dir)
863 .expect("read tasks dir")
864 .filter_map(Result::ok)
865 .filter(|entry| entry.file_name().to_string_lossy().starts_with(".current_blocked.md."))
866 .count(),
867 0
868 );
869 }
870
871 #[test]
872 fn async_approval_blockers_have_unique_paths() {
873 let temp = tempfile::tempdir().expect("temp dir");
874 let first = write_async_approval_blocker(
875 temp.path(),
876 "session-a",
877 "approve the first request",
878 "exec_command",
879 &serde_json::json!({"command": "true"}),
880 None,
881 None,
882 )
883 .expect("write first approval blocker");
884 let second = write_async_approval_blocker(
885 temp.path(),
886 "session-a",
887 "approve the second request",
888 "exec_command",
889 &serde_json::json!({"command": "false"}),
890 None,
891 None,
892 )
893 .expect("write second approval blocker");
894
895 assert_ne!(first.current_path, second.current_path);
896 assert!(first.current_path.exists());
897 assert!(second.current_path.exists());
898 }
899
900 #[test]
901 fn writes_blocked_handoff_without_resume_when_archive_is_unavailable() {
902 let temp = tempfile::tempdir().expect("temp dir");
903
904 let artifacts = write_blocked_handoff_with_resume(
905 temp.path(),
906 "runtime-session",
907 "blocked",
908 "History persistence is disabled.",
909 &[temp.path().join("src/lib.rs")],
910 BlockedHandoffResume::Unavailable("Resume is unavailable because the session archive was not persisted."),
911 false,
912 )
913 .expect("write handoff");
914
915 let current = fs::read_to_string(&artifacts.current_path).expect("current handoff");
916 assert!(!current.contains("resume_command:"));
917 assert!(!current.contains("vtcode --resume"));
918 assert!(current.contains("Resume is unavailable because the session archive was not persisted."));
919 }
920
921 #[test]
922 fn uses_verified_archive_identifier_for_resume_command() {
923 let temp = tempfile::tempdir().expect("temp dir");
924
925 let verified_identifier = VerifiedSessionArchiveIdentifier("session-archive-id".to_owned());
926 let artifacts = write_blocked_handoff_with_resume(
927 temp.path(),
928 "runtime-session",
929 "blocked",
930 "Execution stalled on a loop.",
931 &[],
932 BlockedHandoffResume::Available(&verified_identifier),
933 false,
934 )
935 .expect("write handoff");
936
937 let current = fs::read_to_string(&artifacts.current_path).expect("current handoff");
938 assert!(current.contains("vtcode --resume session-archive-id"));
939 assert!(!current.contains("vtcode --resume runtime-session"));
940 }
941
942 #[test]
943 fn planning_handoff_carries_mode_guidance_in_actionable_steps() {
944 let temp = tempfile::tempdir().expect("temp dir");
945
946 let planning = write_blocked_handoff_with_resume(
947 temp.path(),
948 "plan-session",
949 "blocked",
950 "Mutation blocked until verification: 1 mutating command(s) await a verifier.",
951 &[],
952 BlockedHandoffResume::Unavailable("Resume unavailable in this test."),
953 true,
954 )
955 .expect("write planning handoff");
956 let planning_content = fs::read_to_string(&planning.current_path).expect("planning handoff");
957 assert!(planning_content.contains("Plan mode was active (read-only)"));
958 assert!(planning_content.contains("/mode build"));
959 let planning_archive = fs::read_to_string(&planning.archive_path).expect("planning archive");
961 assert!(planning_archive.contains("Plan mode was active (read-only)"));
962
963 let non_planning = write_blocked_handoff_with_resume(
964 temp.path(),
965 "build-session",
966 "blocked",
967 "Mutation blocked until verification: 1 mutating command(s) await a verifier.",
968 &[],
969 BlockedHandoffResume::Unavailable("Resume unavailable in this test."),
970 false,
971 )
972 .expect("write non-planning handoff");
973 let non_planning_content = fs::read_to_string(&non_planning.current_path).expect("non-planning handoff");
974 assert!(!non_planning_content.contains("Plan mode was active (read-only)"));
975 }
976
977 #[test]
978 fn write_async_approval_blocker_creates_file_with_token() {
979 let temp = tempfile::tempdir().expect("temp dir");
980 let tasks_dir = temp.path().join(".vtcode/tasks");
981 fs::create_dir_all(&tasks_dir).expect("tasks dir");
982
983 let artifacts = write_async_approval_blocker(
984 temp.path(),
985 "session-456",
986 "Push 50 commits to main?",
987 "git_push",
988 &serde_json::json!({"force": true, "branch": "main"}),
989 Some(0.50),
990 Some("/usr/local/bin/notify"),
991 )
992 .expect("write async blocker");
993
994 assert!(!artifacts.approval_token.is_empty());
995 assert!(artifacts.current_path.exists());
996
997 let content = fs::read_to_string(&artifacts.current_path).expect("read blocker");
998 assert!(content.contains("Push 50 commits to main?"));
999 assert!(content.contains("git_push"));
1000 assert!(content.contains("Estimated cost: $0.50"));
1001 assert!(content.contains("vtcode approve"));
1002 assert!(content.contains(&artifacts.approval_token));
1003 }
1004
1005 #[test]
1006 fn write_async_approval_blocker_handles_minimal_input() {
1007 let temp = tempfile::tempdir().expect("temp dir");
1008 let tasks_dir = temp.path().join(".vtcode/tasks");
1009 fs::create_dir_all(&tasks_dir).expect("tasks dir");
1010
1011 let artifacts = write_async_approval_blocker(
1012 temp.path(),
1013 "session-789",
1014 "Delete the file?",
1015 "delete_file",
1016 &serde_json::json!({"path": "/tmp/x"}),
1017 None,
1018 None,
1019 )
1020 .expect("write async blocker");
1021
1022 assert!(!artifacts.approval_token.is_empty());
1023 assert!(artifacts.current_path.exists());
1024
1025 let content = fs::read_to_string(&artifacts.current_path).expect("read blocker");
1026 assert!(content.contains("Delete the file?"));
1027 assert!(content.contains("delete_file"));
1028 assert!(!content.contains("Estimated cost:"));
1030 assert!(!content.contains("Notify command:"));
1031 }
1032
1033 #[test]
1034 fn test_read_and_clear_current_blocked_handoff() {
1035 let temp = tempfile::tempdir().expect("temp dir");
1036
1037 assert_eq!(read_current_blocked_handoff(temp.path()), None);
1039 assert!(!clear_current_blocked_handoff(temp.path()).unwrap());
1040
1041 let verified_identifier = VerifiedSessionArchiveIdentifier("session-archive-id".to_owned());
1043 let _artifacts = write_blocked_handoff_with_resume(
1044 temp.path(),
1045 "test-session-123",
1046 "blocked",
1047 "Tool call failed repeatedly with permission errors.",
1048 &[],
1049 BlockedHandoffResume::Available(&verified_identifier),
1050 false,
1051 )
1052 .expect("write handoff");
1053
1054 let info = read_current_blocked_handoff(temp.path()).expect("read info");
1056 assert_eq!(info.session_id, "test-session-123");
1057 assert_eq!(info.outcome_code, "blocked");
1058 assert_eq!(info.blocker_summary, "Tool call failed repeatedly with permission errors.");
1059 assert!(info.created_at.is_some());
1060 assert_eq!(info.resume_command.as_deref(), Some("vtcode --resume session-archive-id"));
1061
1062 assert!(clear_current_blocked_handoff(temp.path()).unwrap());
1064 assert_eq!(read_current_blocked_handoff(temp.path()), None);
1066 assert!(!clear_current_blocked_handoff(temp.path()).unwrap());
1067 }
1068
1069 #[test]
1070 fn session_scoped_clear_preserves_another_sessions_handoff() {
1071 let temp = tempfile::tempdir().expect("temp dir");
1072 let artifacts =
1073 write_blocked_handoff(temp.path(), "session-a", "blocked", "stalled", &[]).expect("write handoff");
1074
1075 assert!(!clear_current_blocked_handoff_for_session(temp.path(), "session-b").expect("scoped clear"));
1076 assert_eq!(
1077 read_current_blocked_handoff(temp.path()).map(|info| info.session_id),
1078 Some("session-a".to_string())
1079 );
1080 assert!(clear_current_blocked_handoff_for_session(temp.path(), "session-a").expect("scoped clear"));
1081 assert_eq!(read_current_blocked_handoff(temp.path()), None);
1082 let archive = fs::read_to_string(artifacts.archive_path).expect("read resolved archive");
1083 assert!(archive.contains("# Resolution"));
1084 assert!(archive.contains("resolved_by_session: session-a"));
1085 assert!(
1086 archive.contains("resolution_scope: blocked turn recovered; task may remain incomplete"),
1087 "resolution states turn scope, not task completion"
1088 );
1089 }
1090
1091 #[test]
1092 fn session_scoped_clear_preserves_malformed_handoff() {
1093 let temp = tempfile::tempdir().expect("temp dir");
1094 let tasks_dir = temp.path().join(TASKS_DIR);
1095 fs::create_dir_all(&tasks_dir).expect("tasks dir");
1096 let current = tasks_dir.join(CURRENT_BLOCKED_FILE);
1097 fs::write(¤t, "not a handoff").expect("write malformed handoff");
1098
1099 assert!(!clear_current_blocked_handoff_for_session(temp.path(), "session-a").expect("scoped clear"));
1100 assert!(current.exists());
1101 }
1102
1103 #[test]
1104 fn session_scoped_clear_rejects_archive_path_traversal() {
1105 let temp = tempfile::tempdir().expect("temp dir");
1106 let tasks_dir = temp.path().join(TASKS_DIR);
1107 fs::create_dir_all(&tasks_dir).expect("tasks dir");
1108 let current = tasks_dir.join(CURRENT_BLOCKED_FILE);
1109 fs::write(
1110 ¤t,
1111 "---\nsession_id: session-a\noutcome: blocked\narchive_file: ../../outside.md\n---\n\n# Blocker Summary\n\nstalled\n",
1112 )
1113 .expect("write traversal handoff");
1114
1115 assert!(clear_current_blocked_handoff_for_session(temp.path(), "session-a").is_err());
1116 assert!(current.exists());
1117 assert!(!temp.path().join("outside.md").exists());
1118 }
1119
1120 #[cfg(any(unix, windows))]
1121 #[test]
1122 fn session_scoped_clear_rejects_hardlinked_archive() {
1123 let temp = tempfile::tempdir().expect("temp dir");
1124 let artifacts =
1125 write_blocked_handoff(temp.path(), "session-a", "blocked", "stalled", &[]).expect("write handoff");
1126 let external = temp.path().join("outside.md");
1127 fs::copy(&artifacts.archive_path, &external).expect("copy archive");
1128 fs::remove_file(&artifacts.archive_path).expect("remove original archive");
1129 fs::hard_link(&external, &artifacts.archive_path).expect("create hard link");
1130
1131 assert!(clear_current_blocked_handoff_for_session(temp.path(), "session-a").is_err());
1132 assert!(read_current_blocked_handoff(temp.path()).is_some());
1133 assert!(
1134 !fs::read_to_string(external)
1135 .expect("read external file")
1136 .contains("# Resolution")
1137 );
1138 }
1139
1140 #[cfg(any(unix, windows))]
1141 #[test]
1142 fn write_blocked_handoff_rejects_hardlinked_current_pointer() {
1143 let temp = tempfile::tempdir().expect("workspace temp dir");
1144 let tasks_dir = temp.path().join(TASKS_DIR);
1145 fs::create_dir_all(&tasks_dir).expect("tasks dir");
1146 let external = temp.path().join("external.md");
1147 fs::write(&external, "must remain unchanged").expect("external file");
1148 fs::hard_link(&external, tasks_dir.join(CURRENT_BLOCKED_FILE)).expect("hard link current pointer");
1149
1150 assert!(write_blocked_handoff(temp.path(), "session-a", "blocked", "stalled", &[]).is_err());
1151 assert_eq!(fs::read_to_string(external).expect("read external file"), "must remain unchanged");
1152 }
1153
1154 #[cfg(unix)]
1155 #[test]
1156 fn session_scoped_clear_rejects_blockers_directory_outside_workspace() {
1157 let temp = tempfile::tempdir().expect("temp dir");
1158 let artifacts =
1159 write_blocked_handoff(temp.path(), "session-a", "blocked", "stalled", &[]).expect("write handoff");
1160 let blockers_dir = temp.path().join(TASKS_DIR).join(BLOCKERS_DIR);
1161 let outside_temp = tempfile::tempdir().expect("outside temp dir");
1162 let outside_dir = outside_temp.path().join("outside-blockers");
1163 fs::create_dir(&outside_dir).expect("outside directory");
1164 let outside_archive = outside_dir.join(artifacts.archive_path.file_name().expect("archive file name"));
1165 fs::rename(&artifacts.archive_path, &outside_archive).expect("move archive outside");
1166 fs::remove_dir(&blockers_dir).expect("remove blockers directory");
1167 std::os::unix::fs::symlink(&outside_dir, &blockers_dir).expect("link blockers directory");
1168
1169 assert!(clear_current_blocked_handoff_for_session(temp.path(), "session-a").is_err());
1170 assert!(read_current_blocked_handoff(temp.path()).is_some());
1171 assert!(
1172 !fs::read_to_string(outside_archive)
1173 .expect("read outside archive")
1174 .contains("# Resolution")
1175 );
1176 }
1177
1178 #[cfg(unix)]
1179 #[test]
1180 fn handoff_paths_reject_symlinked_vtcode_parent() {
1181 let temp = tempfile::tempdir().expect("workspace temp dir");
1182 let outside = tempfile::tempdir().expect("outside temp dir");
1183 let outside_tasks = outside.path().join(TASKS_DIR);
1184 fs::create_dir_all(&outside_tasks).expect("outside tasks dir");
1185 std::os::unix::fs::symlink(outside.path().join(".vtcode"), temp.path().join(".vtcode"))
1186 .expect("symlink vtcode parent");
1187
1188 assert!(write_blocked_handoff(temp.path(), "session-a", "blocked", "stalled", &[]).is_err());
1189 assert!(
1190 write_async_approval_blocker(
1191 temp.path(),
1192 "session-a",
1193 "approve this",
1194 "exec_command",
1195 &serde_json::json!({}),
1196 None,
1197 None,
1198 )
1199 .is_err()
1200 );
1201 assert!(clear_current_blocked_handoff(temp.path()).is_err());
1202 assert!(!outside_tasks.join(CURRENT_BLOCKED_FILE).exists());
1203 }
1204
1205 #[test]
1206 fn restoring_claim_does_not_overwrite_concurrent_handoff() {
1207 let temp = tempfile::tempdir().expect("temp dir");
1208 let current = temp.path().join(CURRENT_BLOCKED_FILE);
1209 let claim = temp.path().join(".current_blocked.md.claim");
1210 fs::write(&claim, "session-a").expect("write claim");
1211 fs::write(¤t, "session-b").expect("write replacement");
1212
1213 restore_claim_without_overwrite(&claim, ¤t).expect("restore without overwrite");
1214
1215 assert_eq!(fs::read_to_string(¤t).expect("read current"), "session-b");
1216 assert!(!claim.exists());
1217 }
1218}