diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs index 4cd29909..c7ce18b2 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -499,7 +499,7 @@ async fn app_exit_work_guard_state( /// /// The order intentionally mirrors the historical inline `CloseRequested` hook: /// snapshot windows, snapshot `agent_was_running`, kill PTYs, stop model servers, -/// stop the embedded server, then close secondary webview windows. +/// stop the embedded server, purge managed temp, then close secondary webview windows. pub fn shutdown_app_after_confirm(handle: &tauri::AppHandle) { if let Some(state) = handle.try_state::() { let open_windows = snapshot_open_webview_windows(handle); @@ -508,6 +508,7 @@ pub fn shutdown_app_after_confirm(handle: &tauri::AppHandle) { let snapshot = std::sync::Arc::clone(&state.snapshot_running_agents); let model_servers = std::sync::Arc::clone(&state.ensure_local_model_server); let embedded_server = std::sync::Arc::clone(&state.embedded_server); + let purge_managed_temp = std::sync::Arc::clone(&state.purge_managed_temp); let open_projects = state.open_project_ids(); let handles = state.terminal_sessions.handles(); tauri::async_runtime::block_on(async move { @@ -526,12 +527,19 @@ pub fn shutdown_app_after_confirm(handle: &tauri::AppHandle) { } let _ = model_servers.stop_on_app_exit().await; let _ = embedded_server.stop().await; + purge_managed_temp_for_shutdown(&purge_managed_temp).await; }); } close_non_main_webview_windows(handle); } +async fn purge_managed_temp_for_shutdown(purge: &application::PurgeManagedTemp) { + if let Err(err) = purge.execute().await { + application::diag!("[managed-temp] shutdown purge failed: {err}"); + } +} + pub(crate) fn confirm_next_main_window_close() { EXIT_GUARD_CONFIRMED.store(true, Ordering::SeqCst); } @@ -848,12 +856,21 @@ mod tests { use super::{ apply_main_close_decision, confirm_next_main_window_close, consume_exit_guard_confirmation, decide_main_close_action, persisted_view_identity_from_label, - persisted_window_identity_from_label, should_install_exit_guard, MainCloseAction, + persisted_window_identity_from_label, purge_managed_temp_for_shutdown, + should_install_exit_guard, MainCloseAction, }; use super::{should_close_with_main_window, PersistedWindowKind}; - use application::AppExitWorkGuardState; + use application::{AppExitWorkGuardState, PurgeManagedTemp}; + use async_trait::async_trait; + use domain::ports::ManagedTempStore; + use domain::{ + ManagedTempAllocation, ManagedTempError, ManagedTempGcReport, ManagedTempHandle, + ManagedTempLease, + }; use serde_json::json; use std::cell::Cell; + use std::sync::atomic::{AtomicBool, Ordering as AtomicOrdering}; + use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; use tauri::test::{get_ipc_response, mock_builder, mock_context, noop_assets, INVOKE_KEY}; @@ -865,6 +882,57 @@ mod tests { ); } + struct FailingPurgeStore { + called: Arc, + } + + #[async_trait] + impl ManagedTempStore for FailingPurgeStore { + async fn allocate( + &self, + _allocation: ManagedTempAllocation, + ) -> Result { + Err(ManagedTempError::Store("unused".to_owned())) + } + + async fn resolve( + &self, + _handle: &ManagedTempHandle, + _now_ms: u64, + ) -> Result { + Err(ManagedTempError::Store("unused".to_owned())) + } + + async fn release( + &self, + _handle: &ManagedTempHandle, + _now_ms: u64, + ) -> Result { + Err(ManagedTempError::Store("unused".to_owned())) + } + + async fn gc(&self, _now_ms: u64) -> Result { + Err(ManagedTempError::Store("unused".to_owned())) + } + + async fn purge_all(&self) -> Result { + self.called.store(true, AtomicOrdering::SeqCst); + Err(ManagedTempError::Store("partial purge failed".to_owned())) + } + } + + #[tokio::test] + async fn shutdown_managed_temp_purge_is_attempted_and_non_blocking_on_error() { + let called = Arc::new(AtomicBool::new(false)); + let purge = PurgeManagedTemp::new(Arc::new(FailingPurgeStore { + called: Arc::clone(&called), + })); + + purge_managed_temp_for_shutdown(&purge).await; + + assert!(called.load(AtomicOrdering::SeqCst)); + } + #[test] fn main_close_with_work_prevents_and_notifies_before_shutdown() { assert_eq!( diff --git a/crates/app-tauri/src/managed_temp.rs b/crates/app-tauri/src/managed_temp.rs index cd297525..9f523c02 100644 --- a/crates/app-tauri/src/managed_temp.rs +++ b/crates/app-tauri/src/managed_temp.rs @@ -232,6 +232,12 @@ mod tests { async fn gc(&self, _now_ms: u64) -> Result { Ok(ManagedTempGcReport { deleted: 1 }) } + + async fn purge_all(&self) -> Result { + let deleted = self.leases.lock().unwrap().len(); + self.leases.lock().unwrap().clear(); + Ok(ManagedTempGcReport { deleted }) + } } fn project() -> Project { diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index f8c1c765..4a033e23 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -134,9 +134,9 @@ pub use layout::{ }; pub use managed_temp::{ AllocateManagedTemp, AllocateManagedTempInput, AllocateManagedTempOutput, GcManagedTemp, - GcManagedTempOutput, ReleaseManagedTemp, ReleaseManagedTempInput, ReleaseManagedTempOutput, - ResolveManagedTemp, ResolveManagedTempInput, ResolveManagedTempOutput, - MANAGED_TEMP_DEFAULT_TTL_MS, MANAGED_TEMP_PURPOSE_MAX_CHARS, + GcManagedTempOutput, PurgeManagedTemp, PurgeManagedTempOutput, ReleaseManagedTemp, + ReleaseManagedTempInput, ReleaseManagedTempOutput, ResolveManagedTemp, ResolveManagedTempInput, + ResolveManagedTempOutput, MANAGED_TEMP_DEFAULT_TTL_MS, MANAGED_TEMP_PURPOSE_MAX_CHARS, }; pub use mcp_tool_permissions::{ McpToolPermissionCatalogue, ReadMcpToolPermissions, ReadMcpToolPermissionsInput, diff --git a/crates/application/src/managed_temp.rs b/crates/application/src/managed_temp.rs index db38a989..832f111e 100644 --- a/crates/application/src/managed_temp.rs +++ b/crates/application/src/managed_temp.rs @@ -191,6 +191,32 @@ impl GcManagedTemp { } } +/// Output for [`PurgeManagedTemp`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PurgeManagedTempOutput { + /// Purge report. + pub report: ManagedTempGcReport, +} + +/// Eagerly purges all managed temporary allocations. +pub struct PurgeManagedTemp { + store: Arc, +} + +impl PurgeManagedTemp { + /// Builds the use case. + #[must_use] + pub fn new(store: Arc) -> Self { + Self { store } + } + + /// Executes the purge. + pub async fn execute(&self) -> Result { + let report = self.store.purge_all().await?; + Ok(PurgeManagedTempOutput { report }) + } +} + fn now_ms(clock: &Arc) -> u64 { u64::try_from(clock.now_millis()).unwrap_or(0) } diff --git a/crates/application/tests/agent_lifecycle.rs b/crates/application/tests/agent_lifecycle.rs index 52f54a9d..c650d85d 100644 --- a/crates/application/tests/agent_lifecycle.rs +++ b/crates/application/tests/agent_lifecycle.rs @@ -818,6 +818,12 @@ impl ManagedTempStore for FakeManagedTempStore { async fn gc(&self, _now_ms: u64) -> Result { Ok(ManagedTempGcReport { deleted: 0 }) } + + async fn purge_all(&self) -> Result { + let deleted = self.leases.lock().unwrap().len(); + self.leases.lock().unwrap().clear(); + Ok(ManagedTempGcReport { deleted }) + } } struct FixedClock; @@ -4665,13 +4671,17 @@ async fn launch_pty_allocates_managed_tmp_env_and_sandbox_from_same_path() { let store = Arc::new(FakeManagedTempStore::default()); store.set_allocation_path(tmp.clone()); let (allocator, _resolver, releaser) = managed_temp_usecases(&store); - let profile = codex_profile().with_projector(ProjectorKey::Codex); + let profile = profile( + pid(9), + ContextInjection::convention_file("CLAUDE.md").unwrap(), + ) + .with_projector(ProjectorKey::Claude); let (launch, agent, _fs, pty, _sessions) = launch_with_projection( profile, Some(ContextInjectionPlan::File { - target: "AGENTS.md".to_owned(), + target: "CLAUDE.md".to_owned(), }), - Some(full_registry()), + None, Some(perm_doc(Posture::Ask)), ); let launch = launch.with_launch_managed_temp(allocator, releaser); @@ -4691,6 +4701,51 @@ async fn launch_pty_allocates_managed_tmp_env_and_sandbox_from_same_path() { let _ = std::fs::remove_dir_all(&root); } +#[tokio::test] +async fn launch_structured_non_codex_uses_managed_tmp_for_env_and_sandbox_without_writable_roots() { + let root = fresh_temp_dir("launch-tmp-structured-non-codex"); + let tmp = root.join("managed-tmp-main").to_string_lossy().into_owned(); + let store = Arc::new(FakeManagedTempStore::default()); + store.set_allocation_path(tmp.clone()); + let (allocator, _resolver, releaser) = managed_temp_usecases(&store); + let profile = profile( + pid(9), + ContextInjection::convention_file("CLAUDE.md").unwrap(), + ) + .with_projector(ProjectorKey::Claude) + .with_structured_adapter(StructuredAdapter::Claude); + let (mut launch, agent, _fs, pty, _sessions) = launch_with_projection( + profile, + Some(ContextInjectionPlan::File { + target: "CLAUDE.md".to_owned(), + }), + None, + Some(perm_doc(Posture::Ask)), + ); + let factory = FakeStructuredFactory::new(trace(), sid(891)); + let structured = Arc::new(StructuredSessions::new()); + launch = launch + .with_launch_managed_temp(allocator, releaser) + .with_structured_routing_mode(StructuredRoutingMode::RequireStructured) + .with_structured(Arc::new(factory.clone()), Arc::clone(&structured)); + + launch + .execute(launch_input_with_project_root(agent.id, &root)) + .await + .expect("structured launch"); + + assert!(pty.spawns().is_empty()); + let env = factory.envs().remove(0); + let env: HashMap<_, _> = env.into_iter().collect(); + assert_eq!(env.get("TMPDIR"), Some(&tmp)); + assert_eq!(env.get("TMP"), Some(&tmp)); + assert_eq!(env.get("TEMP"), Some(&tmp)); + assert_eq!(factory.policies(), vec![None]); + let sandbox = factory.sandboxes().remove(0).expect("sandbox plan"); + assert!(sandbox_has_rw_root(&sandbox, &tmp)); + let _ = std::fs::remove_dir_all(&root); +} + #[tokio::test] async fn launch_structured_codex_uses_same_managed_tmp_for_env_sandbox_and_writable_roots() { let root = fresh_temp_dir("launch-tmp-structured"); diff --git a/crates/application/tests/managed_temp.rs b/crates/application/tests/managed_temp.rs index 350c35c6..1118e867 100644 --- a/crates/application/tests/managed_temp.rs +++ b/crates/application/tests/managed_temp.rs @@ -10,7 +10,7 @@ use domain::{ use uuid::Uuid; use application::{ - AllocateManagedTemp, AllocateManagedTempInput, AppError, ResolveManagedTemp, + AllocateManagedTemp, AllocateManagedTempInput, AppError, PurgeManagedTemp, ResolveManagedTemp, ResolveManagedTempInput, MANAGED_TEMP_DEFAULT_TTL_MS, MANAGED_TEMP_PURPOSE_MAX_CHARS, }; @@ -90,6 +90,12 @@ impl ManagedTempStore for FakeManagedTempStore { async fn gc(&self, _now_ms: u64) -> Result { Ok(ManagedTempGcReport { deleted: 0 }) } + + async fn purge_all(&self) -> Result { + let deleted = self.leases.lock().unwrap().len(); + self.leases.lock().unwrap().clear(); + Ok(ManagedTempGcReport { deleted }) + } } #[tokio::test] @@ -132,6 +138,31 @@ async fn allocate_uses_generated_opaque_handle_and_default_ttl() { ); } +#[tokio::test] +async fn purge_managed_temp_calls_store_and_returns_report() { + let store = Arc::new(FakeManagedTempStore::default()); + let allocate = AllocateManagedTemp::new( + Arc::clone(&store) as Arc, + Arc::new(FixedIds), + Arc::new(FixedClock), + ); + allocate + .execute(AllocateManagedTempInput { + owner_project_id: None, + kind: ManagedTempKind::Directory, + purpose: "purge me".to_owned(), + ttl_ms: None, + }) + .await + .unwrap(); + let purge = PurgeManagedTemp::new(Arc::clone(&store) as Arc); + + let out = purge.execute().await.unwrap(); + + assert_eq!(out.report.deleted, 1); + assert!(store.leases.lock().unwrap().is_empty()); +} + #[tokio::test] async fn allocate_rejects_empty_or_zero_ttl_and_bounds_purpose_metadata() { let store = Arc::new(FakeManagedTempStore::default()); diff --git a/crates/backend/src/lib.rs b/crates/backend/src/lib.rs index 1d46a293..cdc1754d 100644 --- a/crates/backend/src/lib.rs +++ b/crates/backend/src/lib.rs @@ -39,10 +39,10 @@ use application::{ OpenPluginLayoutWindow, OpenProject, OpenTerminal, OpenTicketAssistant, OrchestratorService, PairAttemptLimiter, PairDevice, PermissionProjectorRegistry, PluginCommandTasks, PluginConfigDocuments, PluginEventSubscriptions, PluginStorageAccess, - PluginToolchainDiagnostics, PluginWorkspaceAccess, ProposeContext, QueryProjectStructure, - ReadAgentContext, ReadChatDraft, ReadContext, ReadConversationPage, ReadIssue, - ReadIssueAttachment, ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory, ReadMemoryIndex, - ReadProjectContext, ReadSkill, ReadTemplate, RecallMemory, ReconcileLayouts, + PluginToolchainDiagnostics, PluginWorkspaceAccess, ProposeContext, PurgeManagedTemp, + QueryProjectStructure, ReadAgentContext, ReadChatDraft, ReadContext, ReadConversationPage, + ReadIssue, ReadIssueAttachment, ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory, + ReadMemoryIndex, ReadProjectContext, ReadSkill, ReadTemplate, RecallMemory, ReconcileLayouts, ReconcileLiveState, ReconcileLiveStateInput, ReconcilePluginMcpServers, RecordTurn, RecordTurnProvider, ReferenceProfiles, ReleaseManagedTemp, RenameDevice, RenameLayout, RenameSprint, ReorderSprints, ResizeTerminal, ResolveAgentCapabilities, @@ -1106,6 +1106,8 @@ pub struct BackendCore { pub release_managed_temp: Arc, /// Garbage-collect released or expired managed temporary allocations. pub gc_managed_temp: Arc, + /// Eagerly purge all managed temporary allocations. + pub purge_managed_temp: Arc, /// Read one persisted custom chat draft. pub read_chat_draft: Arc, /// Save one persisted custom chat draft. @@ -1887,6 +1889,8 @@ impl BackendCore { Arc::clone(&managed_temp_store_port), Arc::clone(&clock) as Arc, )); + let purge_managed_temp = + Arc::new(PurgeManagedTemp::new(Arc::clone(&managed_temp_store_port))); let read_chat_draft = Arc::new(ReadChatDraft::new( Arc::clone(&store_port), Arc::clone(&chat_draft_store_port), @@ -3153,6 +3157,7 @@ impl BackendCore { resolve_managed_temp, release_managed_temp, gc_managed_temp, + purge_managed_temp, read_chat_draft, save_chat_draft, clear_chat_draft, diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index 3a436200..59046849 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -1349,6 +1349,9 @@ pub trait ManagedTempStore: Send + Sync { /// Deletes released or expired allocations. async fn gc(&self, now_ms: u64) -> Result; + + /// Best-effort eager purge of all managed temporary allocations. + async fn purge_all(&self) -> Result; } /// Persistence port for project-scoped custom chat drafts. diff --git a/crates/infrastructure/src/managed_temp.rs b/crates/infrastructure/src/managed_temp.rs index cfa65b53..2910c786 100644 --- a/crates/infrastructure/src/managed_temp.rs +++ b/crates/infrastructure/src/managed_temp.rs @@ -238,4 +238,27 @@ impl ManagedTempStore for FsManagedTempStore { } Ok(ManagedTempGcReport { deleted }) } + + async fn purge_all(&self) -> Result { + let mut deleted = 0usize; + let mut entries = match tokio::fs::read_dir(&self.root).await { + Ok(entries) => entries, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + return Ok(ManagedTempGcReport { deleted: 0 }); + } + Err(err) => return Err(io_error(err)), + }; + while let Some(entry) = entries.next_entry().await.map_err(io_error)? { + let path = entry.path(); + let removed = match entry.file_type().await { + Ok(file_type) if file_type.is_dir() => tokio::fs::remove_dir_all(&path).await, + Ok(_) => tokio::fs::remove_file(&path).await, + Err(err) => Err(err), + }; + if removed.is_ok() { + deleted += 1; + } + } + Ok(ManagedTempGcReport { deleted }) + } } diff --git a/crates/infrastructure/tests/managed_temp.rs b/crates/infrastructure/tests/managed_temp.rs index d61d6237..7c347b3f 100644 --- a/crates/infrastructure/tests/managed_temp.rs +++ b/crates/infrastructure/tests/managed_temp.rs @@ -173,3 +173,43 @@ async fn handle_collision_does_not_reuse_existing_allocation() { let _ = tokio::fs::remove_dir_all(app_data).await; } + +#[tokio::test] +async fn purge_all_deletes_active_allocations_eagerly() { + let app_data = temp_app_data("purge-all"); + let store = FsManagedTempStore::new(&app_data); + + store + .allocate(allocation( + handle("purge_dir"), + ManagedTempKind::Directory, + "dir", + 1_000, + None, + )) + .await + .unwrap(); + store + .allocate(allocation( + handle("purge_file"), + ManagedTempKind::File, + "file", + 1_000, + None, + )) + .await + .unwrap(); + + let report = store.purge_all().await.unwrap(); + + assert_eq!(report.deleted, 2); + assert!(tokio::fs::metadata(store.root().join("purge_dir")) + .await + .is_err()); + assert!(tokio::fs::metadata(store.root().join("purge_file")) + .await + .is_err()); + assert_eq!(store.purge_all().await.unwrap().deleted, 0); + + let _ = tokio::fs::remove_dir_all(app_data).await; +}