merge(infra): temp IdeA pour tous les agents et purge managed-tmp à la fermeture — #200 (QA verte)

This commit is contained in:
2026-08-15 00:14:25 +02:00
10 changed files with 271 additions and 14 deletions

View File

@ -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::<AppState>() {
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<AtomicBool>,
}
#[async_trait]
impl ManagedTempStore for FailingPurgeStore {
async fn allocate(
&self,
_allocation: ManagedTempAllocation,
) -> Result<ManagedTempLease, ManagedTempError> {
Err(ManagedTempError::Store("unused".to_owned()))
}
async fn resolve(
&self,
_handle: &ManagedTempHandle,
_now_ms: u64,
) -> Result<ManagedTempLease, ManagedTempError> {
Err(ManagedTempError::Store("unused".to_owned()))
}
async fn release(
&self,
_handle: &ManagedTempHandle,
_now_ms: u64,
) -> Result<ManagedTempLease, ManagedTempError> {
Err(ManagedTempError::Store("unused".to_owned()))
}
async fn gc(&self, _now_ms: u64) -> Result<ManagedTempGcReport, ManagedTempError> {
Err(ManagedTempError::Store("unused".to_owned()))
}
async fn purge_all(&self) -> Result<ManagedTempGcReport, ManagedTempError> {
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!(

View File

@ -232,6 +232,12 @@ mod tests {
async fn gc(&self, _now_ms: u64) -> Result<ManagedTempGcReport, ManagedTempError> {
Ok(ManagedTempGcReport { deleted: 1 })
}
async fn purge_all(&self) -> Result<ManagedTempGcReport, ManagedTempError> {
let deleted = self.leases.lock().unwrap().len();
self.leases.lock().unwrap().clear();
Ok(ManagedTempGcReport { deleted })
}
}
fn project() -> Project {

View File

@ -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,

View File

@ -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<dyn ManagedTempStore>,
}
impl PurgeManagedTemp {
/// Builds the use case.
#[must_use]
pub fn new(store: Arc<dyn ManagedTempStore>) -> Self {
Self { store }
}
/// Executes the purge.
pub async fn execute(&self) -> Result<PurgeManagedTempOutput, AppError> {
let report = self.store.purge_all().await?;
Ok(PurgeManagedTempOutput { report })
}
}
fn now_ms(clock: &Arc<dyn Clock>) -> u64 {
u64::try_from(clock.now_millis()).unwrap_or(0)
}

View File

@ -818,6 +818,12 @@ impl ManagedTempStore for FakeManagedTempStore {
async fn gc(&self, _now_ms: u64) -> Result<ManagedTempGcReport, ManagedTempError> {
Ok(ManagedTempGcReport { deleted: 0 })
}
async fn purge_all(&self) -> Result<ManagedTempGcReport, ManagedTempError> {
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");

View File

@ -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<ManagedTempGcReport, ManagedTempError> {
Ok(ManagedTempGcReport { deleted: 0 })
}
async fn purge_all(&self) -> Result<ManagedTempGcReport, ManagedTempError> {
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<dyn ManagedTempStore>,
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<dyn ManagedTempStore>);
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());

View File

@ -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<ReleaseManagedTemp>,
/// Garbage-collect released or expired managed temporary allocations.
pub gc_managed_temp: Arc<GcManagedTemp>,
/// Eagerly purge all managed temporary allocations.
pub purge_managed_temp: Arc<PurgeManagedTemp>,
/// Read one persisted custom chat draft.
pub read_chat_draft: Arc<ReadChatDraft>,
/// Save one persisted custom chat draft.
@ -1887,6 +1889,8 @@ impl BackendCore {
Arc::clone(&managed_temp_store_port),
Arc::clone(&clock) as Arc<dyn Clock>,
));
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,

View File

@ -1349,6 +1349,9 @@ pub trait ManagedTempStore: Send + Sync {
/// Deletes released or expired allocations.
async fn gc(&self, now_ms: u64) -> Result<ManagedTempGcReport, ManagedTempError>;
/// Best-effort eager purge of all managed temporary allocations.
async fn purge_all(&self) -> Result<ManagedTempGcReport, ManagedTempError>;
}
/// Persistence port for project-scoped custom chat drafts.

View File

@ -238,4 +238,27 @@ impl ManagedTempStore for FsManagedTempStore {
}
Ok(ManagedTempGcReport { deleted })
}
async fn purge_all(&self) -> Result<ManagedTempGcReport, ManagedTempError> {
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 })
}
}

View File

@ -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;
}