Skip to main content

vtcode_core/core/agent/
blocked_handoff.rs

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
17/// Plan-mode note appended to the handoff's Actionable Next Steps when the
18/// blocked turn ran with planning active. Kept as one generic paragraph
19/// (rather than the transcript's mutation/generic split) so the persisted
20/// file stays useful to a resumed session without duplicating the
21/// caller-side block-kind matcher.
22const 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/// Artifacts produced by [`write_blocked_handoff`], containing paths to the
30/// current and archived handoff files.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct BlockedHandoffArtifacts {
33    /// Path to the current blocked handoff markdown file.
34    pub current_path: PathBuf,
35    /// Path to the archived blocked handoff markdown file.
36    pub archive_path: PathBuf,
37}
38
39/// Resume metadata for a blocked handoff.
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum BlockedHandoffResume<'a> {
42    /// The identifier came from a verified, persisted session archive.
43    Available(&'a VerifiedSessionArchiveIdentifier),
44    /// No durable archive can be advertised for this handoff.
45    Unavailable(&'a str),
46}
47
48/// Write a blocked-handoff artifact when the agent hits an unrecoverable blocker.
49///
50/// Creates both a `current_blocked.md` file and a timestamped archive under
51/// `.vtcode/tasks/blockers/`. The handoff includes the blocker summary and
52/// explicitly declines to attribute the workspace-global tracker to a session. Resume commands are added only by
53/// [`write_blocked_handoff_with_resume`] after a caller verifies an
54/// archive identifier.
55pub 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
75/// Write a blocked handoff with resume metadata supplied through the typed
76/// archive-verification boundary.
77///
78/// `planning_active` records whether the blocked turn ran with planning
79/// active so the persisted handoff carries the same mode guidance as the
80/// transcript (a resumed session reads the file, not the old transcript).
81pub 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: &current_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(&current_path, &markdown, true)?;
118
119    // Preserve forensic artifacts and pin the session so ordinary retention
120    // cannot erase evidence while this blocker remains unresolved.
121    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
127/// Copy session `events.jsonl` / ATIF into a forensics dir beside the archive.
128/// Best-effort: missing sources must never fail the handoff write.
129fn 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
158/// Pin the session directory against retention while the blocker is open.
159fn 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
167/// Clear retention pins when a blocker archive for `session_id` is resolved.
168/// Keeps the pin if another unresolved archive still references the session.
169fn 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                // Another unresolved archive still references this session.
187                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/// Parsed information from a blocked handoff file.
196#[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
205/// Reads `.vtcode/tasks/current_blocked.md` if it exists, parsing the front-matter
206/// and blocker summary.
207pub 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(&current_path).ok()?;
211    let content = fs::read_to_string(&current_path).ok()?;
212    parse_blocked_handoff_content(&content)
213}
214
215/// Fallback when the live pointer is missing but an archived blocker exists
216/// for the session (e.g. pointer cleared by a fork or stale workspace).
217/// Returns the most recently modified matching archive, if any.
218pub 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            // Also match sanitized session prefix (blocker files lowercase the id).
228            if !file_name.contains("session-") {
229                continue;
230            }
231            // Fall through to content check: session_id is in front-matter.
232        }
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(&current) {
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
450/// Clears `.vtcode/tasks/current_blocked.md` if it exists.
451///
452/// Returns `Ok(true)` if the file was deleted, or `Ok(false)` if it did not exist.
453pub 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(&current_path)?;
457    match fs::remove_file(&current_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
464/// Clears `.vtcode/tasks/current_blocked.md` only when it belongs to `session_id`.
465///
466/// Missing or malformed handoffs are left intact so one session cannot clear
467/// another session's recovery pointer.
468pub 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(&current_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(&current_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, &current_path)?;
504    }
505    result
506}
507
508/// Restore an unconsumed claim without overwriting a handoff concurrently
509/// written by another session. A hard link provides create-if-absent behavior;
510/// the private claim can then be unlinked independently.
511fn 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        // Already resolved: still release the pin when no other unresolved
593        // archive references this session.
594        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    // SAFETY: `file` owns a valid handle for the duration of the call and
641    // `information` points to writable storage of the expected Win32 type.
642    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    // Refuse resolution on platforms without a stable link-count API rather
649    // than appending to a file whose identity cannot be checked.
650    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/// Artifacts produced by [`write_async_approval_blocker`].
718#[derive(Debug, Clone, PartialEq, Eq)]
719pub struct AsyncApprovalArtifacts {
720    /// Path to the async approval blocker markdown file.
721    pub current_path: PathBuf,
722    /// Unique token used to approve or reject this request via CLI.
723    pub approval_token: String,
724}
725
726/// Write an async (deferred) approval blocker file.
727///
728/// Unlike [`write_blocked_handoff`] which signals a hard stop, this writes a
729/// blocker that can be resolved out-of-band via CLI (`vtcode approve <token>`).
730/// The blocker includes the approval question, tool details, and a unique token.
731pub 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(&current_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        // Provide a session store so forensics/pin paths resolve.
785        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(&current, "old handoff").expect("write old handoff");
857
858        write_handoff_file(&current, "new handoff", true).expect("replace handoff");
859
860        assert_eq!(fs::read_to_string(&current).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        // Both the live pointer and the archive carry the same markdown.
960        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        // No cost or notify section
1029        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        // When file does not exist
1038        assert_eq!(read_current_blocked_handoff(temp.path()), None);
1039        assert!(!clear_current_blocked_handoff(temp.path()).unwrap());
1040
1041        // Write a blocked handoff
1042        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        // Read it back
1055        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        // Clear it
1063        assert!(clear_current_blocked_handoff(temp.path()).unwrap());
1064        // Should no longer exist
1065        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(&current, "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            &current,
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(&current, "session-b").expect("write replacement");
1212
1213        restore_claim_without_overwrite(&claim, &current).expect("restore without overwrite");
1214
1215        assert_eq!(fs::read_to_string(&current).expect("read current"), "session-b");
1216        assert!(!claim.exists());
1217    }
1218}