vtcode 0.169.4

A Rust-based terminal coding agent with modular architecture supporting multiple LLM providers
use anyhow::{Context, Result};
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};

use tokio::task::spawn_blocking;
use vtcode_config::loader::VTCodeConfig;
use vtcode_core::core::agent::features::FeatureSet;
use vtcode_core::exec::events::{ThreadEvent, ThreadStartedEvent};
use vtcode_memory::RetentionPolicy;

use crate::agent::runloop::unified::inline_events::harness::{
    HARNESS_LOG_MAX_AGE_DAYS, HarnessEventEmitter, prune_old_harness_logs, resolve_event_log_path,
};
use crate::agent::runloop::unified::run_loop_context::TurnRunId;

/// Open canonical harness persistence only. Retention sweeps are maintenance
/// and run in [`run_harness_retention`], spawned after first paint is available.
pub(super) async fn initialize_harness(
    workspace: &Path,
    vt_cfg: Option<&VTCodeConfig>,
    model: &str,
    turn_run_id: &TurnRunId,
) -> Result<Option<HarnessEventEmitter>> {
    let harness_config = vt_cfg.map(|cfg| cfg.agent.harness.clone()).unwrap_or_default();
    let legacy_path = harness_config
        .event_log_path
        .as_deref()
        .filter(|path| !path.trim().is_empty())
        .map(|path| resolve_event_log_path(path, turn_run_id));

    // Canonical persistence is mandatory. Setup errors propagate instead of
    // silently downgrading the run to an unpersisted session.
    let emitter = HarnessEventEmitter::new_async(workspace, &turn_run_id.0, legacy_path).await?;

    let session_derived = vtcode_memory::session_directory(workspace, &turn_run_id.0).join("derived");
    let features = FeatureSet::from_config(vt_cfg);
    if features.open_responses.emit_events {
        let open_responses_config = vt_cfg.map(|cfg| cfg.agent.open_responses.clone()).unwrap_or_default();
        let output_path = session_derived.join("open-responses.jsonl");
        if let Err(error) = emitter
            .enable_open_responses_async(open_responses_config, model, Some(output_path.clone()))
            .await
        {
            tracing::warn!(target: "vtcode.harness", phase = "open_responses_setup", path = %output_path.display(), error = %error, "Open Responses setup failed");
        }
    }

    if vt_cfg.is_some_and(|cfg| cfg.telemetry.atif_enabled) {
        let atif_path = session_derived.join("atif-trajectory.json");
        if let Err(error) = emitter.enable_atif(model, atif_path.clone()) {
            tracing::warn!(target: "vtcode.harness", phase = "atif_setup", path = %atif_path.display(), error = %error, "ATIF setup failed");
        }
    }

    if let Err(error) = emitter
        .emit(ThreadEvent::ThreadStarted(ThreadStartedEvent { thread_id: turn_run_id.0.clone() }))
        .context("failed to emit canonical thread.started event")
    {
        emitter.finish_after_unexpected_exit().await;
        return Err(error);
    }
    Ok(Some(emitter))
}

/// Session-store retention and legacy harness-log pruning.
///
/// Spawned after `initialize_session_ui` so a large `.vtcode/sessions` tree never delays
/// the first frame. Failures are logged and non-fatal.
pub(super) async fn run_harness_retention(workspace: &Path, vt_cfg: Option<&VTCodeConfig>, turn_run_id: &TurnRunId) {
    let harness_config = vt_cfg.map(|cfg| cfg.agent.harness.clone()).unwrap_or_default();
    if let Some(log_path) = harness_config
        .event_log_path
        .as_deref()
        .filter(|path| !path.trim().is_empty())
        .map(|path| resolve_event_log_path(path, turn_run_id))
    {
        let log_dir = if log_path.extension().is_some() {
            log_path.parent().unwrap_or(Path::new(".")).to_path_buf()
        } else {
            log_path.clone()
        };
        let prune_result = spawn_blocking(move || {
            if log_dir.is_dir() {
                prune_old_harness_logs(&log_dir, HARNESS_LOG_MAX_AGE_DAYS)
            } else {
                Ok(())
            }
        })
        .await;
        match prune_result {
            Ok(Ok(())) => {}
            Ok(Err(error)) => {
                tracing::warn!(target: "vtcode.harness", phase = "legacy_prune", path = %log_path.display(), error = %error, "legacy harness log pruning failed");
            }
            Err(error) => {
                tracing::warn!(target: "vtcode.harness", phase = "legacy_prune", path = %log_path.display(), error = %error, "legacy harness log pruning task failed");
            }
        }
    }

    let retention_workspace = workspace.to_path_buf();
    let retention_session_id = turn_run_id.0.clone();
    match spawn_blocking(move || {
        vtcode_memory::apply_retention_preserving(
            &retention_workspace,
            RetentionPolicy::default(),
            Some(retention_session_id.as_str()),
        )
    })
    .await
    {
        Ok(Ok(removed)) if removed > 0 => {
            tracing::debug!(target: "vtcode.harness", removed, "pruned closed canonical sessions");
        }
        Ok(Ok(_)) => {}
        Ok(Err(error)) => {
            tracing::warn!(target: "vtcode.harness", phase = "canonical_retention", error = %error, "canonical session retention failed")
        }
        Err(error) => {
            tracing::warn!(target: "vtcode.harness", phase = "canonical_retention", error = %error, "canonical session retention task failed")
        }
    }

    let history_workspace = workspace.to_path_buf();
    let history_preserve = turn_run_id.0.clone();
    let history_max_age_days = RetentionPolicy::default().max_age_days;
    match spawn_blocking(move || prune_history_envelopes(&history_workspace, &history_preserve, history_max_age_days))
        .await
    {
        Ok(Ok(removed)) if removed > 0 => {
            tracing::debug!(target: "vtcode.harness", removed, "pruned legacy history memory envelopes");
        }
        Ok(Ok(_)) => {}
        Ok(Err(error)) => {
            tracing::warn!(target: "vtcode.harness", phase = "history_envelope_retention", error = %error, "history envelope pruning failed")
        }
        Err(error) => {
            tracing::warn!(target: "vtcode.harness", phase = "history_envelope_retention", error = %error, "history envelope pruning task failed")
        }
    }
}

/// Cap `.vtcode/history/*.memory.json` so legacy envelopes cannot grow without
/// bound while dual writes still land there.
///
/// Keeps the `HISTORY_ENVELOPE_KEEP` newest files and anything belonging to
/// `preserve_session_id`; drops envelopes older than `max_age_days`.
fn prune_history_envelopes(workspace: &Path, preserve_session_id: &str, max_age_days: u64) -> Result<usize> {
    const HISTORY_ENVELOPE_KEEP: usize = 50;

    let history_dir = workspace.join(".vtcode").join("history");
    let Ok(entries) = std::fs::read_dir(&history_dir) else {
        return Ok(0);
    };
    let mut envelopes: Vec<(PathBuf, SystemTime)> = Vec::new();
    for entry in entries.flatten() {
        let path = entry.path();
        let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
            continue;
        };
        if !name.ends_with(".memory.json") || !path.is_file() {
            continue;
        }
        // Exact sanitized-id match (same rule as envelope writers). Loose
        // prefix matching would preserve unrelated sessions.
        if vtcode_core::compaction::memory_envelope::memory_envelope_file_matches_session(name, preserve_session_id) {
            continue;
        }
        let modified = entry
            .metadata()
            .ok()
            .and_then(|meta| meta.modified().ok())
            .unwrap_or(SystemTime::UNIX_EPOCH);
        envelopes.push((path, modified));
    }
    envelopes.sort_by_key(|(_, modified)| std::cmp::Reverse(*modified));

    let cutoff = SystemTime::now() - Duration::from_secs(max_age_days * 24 * 3600);
    let mut removed = 0usize;
    for (index, (path, modified)) in envelopes.iter().enumerate() {
        let over_count = index >= HISTORY_ENVELOPE_KEEP;
        let aged_out = *modified < cutoff;
        if !over_count && !aged_out {
            continue;
        }
        match std::fs::remove_file(path) {
            Ok(()) => removed += 1,
            Err(error) => {
                tracing::warn!(target: "vtcode.harness", path = %path.display(), %error, "failed to prune history envelope");
            }
        }
    }
    Ok(removed)
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::TempDir;

    #[tokio::test]
    async fn initialize_harness_emits_thread_started_without_retention_side_effects() {
        let temp = TempDir::new().expect("temp dir");
        let turn = TurnRunId("test-harness-init".to_string());
        let emitter = initialize_harness(temp.path(), None, "gpt-5", &turn).await.expect("emitter");
        assert!(emitter.is_some(), "canonical emitter is mandatory");
        // Retention is deferred: no session store is required for emitter setup.
        assert!(
            !temp.path().join("sessions").exists(),
            "initialize_harness must not create or sweep the session store before first paint"
        );
    }

    #[tokio::test]
    async fn run_harness_retention_is_idempotent_on_empty_workspace() {
        let temp = TempDir::new().expect("temp dir");
        let turn = TurnRunId("test-harness-retention".to_string());
        run_harness_retention(temp.path(), None, &turn).await;
        run_harness_retention(temp.path(), None, &turn).await;
    }

    #[test]
    fn prune_history_envelopes_keeps_newest_and_preserved_session() {
        let temp = TempDir::new().expect("temp dir");
        let history = temp.path().join(".vtcode").join("history");
        std::fs::create_dir_all(&history).expect("history dir");
        for i in 0..60 {
            let name = format!("session-{i:03}.memory.json");
            std::fs::write(history.join(name), b"{}").expect("write envelope");
        }
        std::fs::write(history.join("session-keep.memory.json"), b"{}").expect("write preserved");

        let removed = prune_history_envelopes(temp.path(), "session-keep", 30).expect("prune");
        assert!(removed >= 10, "count cap should drop the oldest extras: {removed}");
        assert!(history.join("session-keep.memory.json").exists(), "preserved session stays");
        let remaining: Vec<_> = std::fs::read_dir(&history)
            .expect("read history")
            .flatten()
            .map(|e| e.file_name())
            .collect();
        assert!(remaining.len() <= 51, "bounded history envelopes: {}", remaining.len());
    }

    #[test]
    fn prune_history_envelopes_preserves_real_session_id_prefix() {
        let temp = TempDir::new().expect("temp dir");
        let history = temp.path().join(".vtcode").join("history");
        std::fs::create_dir_all(&history).expect("history dir");
        // Real ids are sanitized to 32 chars for envelope filenames.
        let session_id = "session-vtcode-20260925T234343Z_201620-81429";
        let envelope_name = "session-vtcode-20260925T234343Z_.memory.json";
        std::fs::write(history.join(envelope_name), b"{}").expect("write preserved");
        for i in 0..60 {
            std::fs::write(history.join(format!("other-{i:03}.memory.json")), b"{}").expect("write other");
        }

        prune_history_envelopes(temp.path(), session_id, 30).expect("prune");
        assert!(
            history.join(envelope_name).exists(),
            "finalizing session envelope must match on the 32-char sanitized prefix"
        );
    }

    #[test]
    fn prune_history_envelopes_does_not_over_preserve_shared_prefixes() {
        let temp = TempDir::new().expect("temp dir");
        let history = temp.path().join(".vtcode").join("history");
        std::fs::create_dir_all(&history).expect("history dir");
        // Near-miss name that loose `starts_with("session-keep")` would wrongly preserve.
        std::fs::write(history.join("session-keep.memory.json"), b"{}").expect("write preserved");
        std::fs::write(history.join("session-keeper.memory.json"), b"{}").expect("write near-miss");
        for i in 0..60 {
            std::fs::write(history.join(format!("other-{i:03}.memory.json")), b"{}").expect("write other");
        }

        // max_age_days = 0 ages out every non-preserved envelope so the
        // assertion is about matching, not the count cap.
        prune_history_envelopes(temp.path(), "session-keep", 0).expect("prune");
        assert!(history.join("session-keep.memory.json").exists(), "exact session stays");
        assert!(
            !history.join("session-keeper.memory.json").exists(),
            "near-miss name must not be preserved by loose prefix matching"
        );
    }
}