#![allow(
clippy::too_many_arguments,
reason = "Intentional compatibility, platform, or test-only suppression."
)]
use anyhow::Result;
use std::io;
use std::path::PathBuf;
use vtcode_core::core::agent::snapshots::SnapshotTurnDiagnostics;
use vtcode_core::hooks::{LifecycleHookEngine, SessionEndReason};
use vtcode_core::llm::provider as uni;
use vtcode_core::notifications::{set_global_notification_hook_engine, set_global_terminal_focused};
use vtcode_core::ui::set_tui_mode;
use vtcode_core::utils::ansi::{AnsiRenderer, MessageStyle};
use vtcode_core::utils::session_archive::{SessionArchive, SessionMessage};
use vtcode_core::utils::transcript;
use vtcode_ui::tui::app::{InlineHandle, InlineSession};
use crate::agent::runloop::unified::async_mcp_manager::AsyncMcpManager;
use crate::agent::runloop::unified::state::SessionStats;
use crate::agent::runloop::unified::workspace_links::{LinkedDirectory, remove_directory_symlink};
use super::utils::render_hook_messages;
pub(super) struct FinalizationOutput {
pub archive_path: Option<PathBuf>,
}
const ARCHIVE_FINALIZE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(1);
const ARCHIVE_FINALIZE_TIMEOUT_FAST: std::time::Duration = std::time::Duration::from_millis(500);
fn interrupt_budget(interrupt_exit: bool, normal: std::time::Duration) -> std::time::Duration {
if interrupt_exit {
std::time::Duration::from_millis(500)
} else {
normal
}
}
fn is_fast_session_teardown(reason: SessionEndReason) -> bool {
matches!(reason, SessionEndReason::Exit | SessionEndReason::Cancelled | SessionEndReason::NewSession)
}
fn restore_terminal_on_exit() -> io::Result<()> {
vtcode_ui::tui::panic_hook::restore_tui_keep_raw_mode()
}
pub(super) async fn finalize_session(
renderer: &mut AnsiRenderer,
lifecycle_hooks: Option<&LifecycleHookEngine>,
turn_id: &str,
session_end_reason: SessionEndReason,
session_archive: &mut Option<SessionArchive>,
session_stats: &SessionStats,
last_turn_diagnostics: Option<SnapshotTurnDiagnostics>,
conversation_history: &[uni::Message],
linked_directories: Vec<LinkedDirectory>,
async_mcp_manager: Option<&AsyncMcpManager>,
handle: &InlineHandle,
session: &mut InlineSession,
) -> Result<FinalizationOutput> {
let transcript_lines = transcript::snapshot();
let mut archive_path: Option<PathBuf> = None;
if let Some(archive) = session_archive.take() {
let distinct_tools = session_stats.sorted_tools();
let total_messages = conversation_history.len();
let session_messages: Vec<SessionMessage> = conversation_history.iter().map(SessionMessage::from).collect();
if matches!(session_end_reason, SessionEndReason::NewSession) {
tokio::task::spawn_blocking(move || {
if let Err(err) = archive.finalize_with_diagnostics(
transcript_lines,
total_messages,
distinct_tools,
session_messages,
last_turn_diagnostics,
) {
tracing::warn!("background session archive finalize failed on /new: {err}");
}
});
} else {
let archive_timeout = if is_fast_session_teardown(session_end_reason) {
ARCHIVE_FINALIZE_TIMEOUT_FAST
} else {
ARCHIVE_FINALIZE_TIMEOUT
};
let finalize_task = tokio::task::spawn_blocking(move || {
archive.finalize_with_diagnostics(
transcript_lines,
total_messages,
distinct_tools,
session_messages,
last_turn_diagnostics,
)
});
match tokio::time::timeout(archive_timeout, finalize_task).await {
Ok(Ok(Ok(path))) => {
archive_path = Some(path.clone());
if let Some(hooks) = lifecycle_hooks {
hooks.update_transcript_path(Some(path.clone())).await;
}
renderer.line(MessageStyle::Info, &format!("Session saved to {}", path.display()))?;
renderer.line_if_not_empty(MessageStyle::Output)?;
}
Ok(Ok(Err(err))) => {
renderer.line(MessageStyle::Error, &format!("Failed to save session: {err}"))?;
renderer.line_if_not_empty(MessageStyle::Output)?;
}
Ok(Err(join_error)) => {
renderer.line(MessageStyle::Error, &format!("Failed to save session: {join_error}"))?;
renderer.line_if_not_empty(MessageStyle::Output)?;
}
Err(_elapsed) => {
tracing::warn!(
"session archive finalize timed out after {:?}; keeping the last progress snapshot",
archive_timeout
);
}
}
}
}
for linked in linked_directories {
if let Err(err) = remove_directory_symlink(&linked.link_path).await {
tracing::warn!("Failed to remove linked directory {}: {}", linked.link_path.display(), err);
}
}
let interrupt_exit = is_fast_session_teardown(session_end_reason);
let hooks_future = async {
let Some(hooks) = lifecycle_hooks else {
return (Vec::new(), None);
};
let hook_budget = interrupt_budget(interrupt_exit, std::time::Duration::from_secs(3));
match tokio::time::timeout(hook_budget, hooks.run_session_end(turn_id, session_end_reason)).await {
Ok(Ok(messages)) => (messages, None),
Ok(Err(err)) => (Vec::new(), Some(err)),
Err(_elapsed) => {
tracing::warn!("Session end hooks timed out, skipping");
(Vec::new(), None)
}
}
};
let mcp_future = async {
let Some(mcp_manager) = async_mcp_manager else {
return;
};
let mcp_budget = interrupt_budget(interrupt_exit, std::time::Duration::from_secs(2));
match tokio::time::timeout(mcp_budget, mcp_manager.shutdown()).await {
Ok(Err(e)) => {
let error_msg = e.to_string();
if error_msg.contains("EPIPE") || error_msg.contains("Broken pipe") || error_msg.contains("write EPIPE")
{
tracing::debug!("MCP client shutdown encountered pipe errors (normal): {}", e);
} else {
tracing::warn!("Failed to shutdown MCP client cleanly: {}", e);
}
}
Err(_elapsed) => {
tracing::warn!("MCP client shutdown timed out during finalization");
}
Ok(Ok(())) => {}
}
};
let ((hook_messages, hook_error), ()) = tokio::join!(hooks_future, mcp_future);
handle.shutdown();
set_global_notification_hook_engine(None);
set_global_terminal_focused(false);
let tui_exit_budget = if interrupt_exit {
std::time::Duration::from_millis(1000)
} else {
std::time::Duration::from_millis(2000)
};
if !session.wait_for_exit(tui_exit_budget).await {
tracing::warn!("TUI task did not exit after shutdown; forcing terminal restore");
}
let _ = restore_terminal_on_exit();
vtcode_ui::tui::panic_hook::finish_deferred_raw_mode_restore();
transcript::clear_inline_handle();
set_tui_mode(false);
if let Some(err) = hook_error {
renderer.line(MessageStyle::Error, &format!("Failed to run session end hooks: {err}"))?;
} else {
render_hook_messages(renderer, &hook_messages)?;
}
let open_circuits = session_stats.circuit_breaker.get_open_circuits();
if !open_circuits.is_empty() {
renderer.line(MessageStyle::Warning, &format!("Open Circuit Breakers ({}):", open_circuits.len()))?;
for tool in &open_circuits {
renderer.line(MessageStyle::Warning, &format!(" - {tool}"))?;
}
renderer.line_if_not_empty(MessageStyle::Output)?;
}
let all_stats = session_stats.tool_health_tracker.get_all_tool_stats();
let mut unhealthy_tools: Vec<_> = all_stats
.iter()
.filter(|(name, _)| !session_stats.tool_health_tracker.is_healthy(name))
.collect();
unhealthy_tools.sort_by(|a, b| a.0.cmp(&b.0));
if !unhealthy_tools.is_empty() {
renderer.line(MessageStyle::Warning, &format!("Unhealthy Tools ({}):", unhealthy_tools.len()))?;
for (name, _) in unhealthy_tools {
let (_, reason) = session_stats.tool_health_tracker.check_health(name);
if let Some(r) = reason {
renderer.line(MessageStyle::Warning, &format!(" - {name}: {r}"))?;
}
}
renderer.line_if_not_empty(MessageStyle::Output)?;
}
Ok(FinalizationOutput { archive_path })
}
#[cfg(test)]
mod tests {
use super::{ARCHIVE_FINALIZE_TIMEOUT, ARCHIVE_FINALIZE_TIMEOUT_FAST, interrupt_budget, is_fast_session_teardown};
use vtcode_core::hooks::SessionEndReason;
#[test]
fn fast_teardown_covers_exit_cancel_and_new_session_only() {
for reason in [
SessionEndReason::Exit,
SessionEndReason::Cancelled,
SessionEndReason::NewSession,
] {
assert!(is_fast_session_teardown(reason), "{reason:?} should be fast");
assert_eq!(
interrupt_budget(is_fast_session_teardown(reason), std::time::Duration::from_secs(3)),
std::time::Duration::from_millis(500),
"{reason:?} must map to the tight cap"
);
}
for reason in [SessionEndReason::Completed, SessionEndReason::Error] {
assert!(!is_fast_session_teardown(reason), "{reason:?} should keep full budget");
assert_eq!(
interrupt_budget(is_fast_session_teardown(reason), std::time::Duration::from_secs(2)),
std::time::Duration::from_secs(2),
"{reason:?} must preserve the full budget"
);
}
}
#[test]
fn archive_finalize_fast_cap_is_tighter_than_normal() {
assert!(
ARCHIVE_FINALIZE_TIMEOUT_FAST < ARCHIVE_FINALIZE_TIMEOUT,
"fast archive cap must be tighter than normal"
);
assert_eq!(ARCHIVE_FINALIZE_TIMEOUT_FAST, std::time::Duration::from_millis(500));
}
#[test]
fn interrupt_budget_preserves_normal_duration_when_not_interrupted() {
let normal = std::time::Duration::from_secs(2);
assert_eq!(interrupt_budget(false, normal), normal);
assert_eq!(interrupt_budget(false, std::time::Duration::from_millis(0)), std::time::Duration::from_millis(0));
}
}