feat(backend): guard de fermeture "travail en cours" (#83)
Expose l'état du guard de sortie applicative (GetAppExitWorkGuardState) : agents busy + tâches d'arrière-plan actives à travers tous les projets ouverts, avec détails compacts pour la popup de confirmation. Le handler CloseRequested d'app-tauri interroge ce guard avant de laisser la fenêtre se fermer, et respecte la confirmation explicite de l'utilisateur (EXIT_GUARD_CONFIRMED) pour ne pas la redemander en boucle. QA vert (backend + frontend). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@ -36,32 +36,32 @@ use crate::dto::{
|
||||
parse_layout_id, parse_memory_slug, parse_model_server_id, parse_node_id, parse_profile_id,
|
||||
parse_project_id, parse_session_id, parse_skill_id, parse_task_id, parse_template_id,
|
||||
parse_ticket_id, save_model_server_input, AgentDriftListDto, AgentDto, AgentListDto,
|
||||
AssignSkillRequestDto, AttachLiveAgentRequestDto, AttachLiveAgentResponseDto,
|
||||
BackgroundTaskDto, ChangeAgentProfileDto, ChangeAgentProfileRequestDto,
|
||||
CloneOpenCodeProfileFromSeedRequestDto, ConfigureProfilesRequestDto, ConversationDetailsDto,
|
||||
CreateAgentFromTemplateRequestDto, CreateAgentRequestDto, CreateLayoutRequestDto,
|
||||
CreateLayoutResultDto, CreateMemoryRequestDto, CreateProjectRequestDto, CreateSkillRequestDto,
|
||||
CreateTemplateRequestDto, DeleteLayoutRequestDto, DeleteLayoutResultDto,
|
||||
DeliveredDelegationRequestDto, DetectProfilesRequestDto, DetectProfilesResponseDto,
|
||||
EffectivePermissionsDto, EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto,
|
||||
ErrorDto, FirstRunStateDto, FrontAttachedRequestDto, GitBranchesDto, GitCheckoutRequestDto,
|
||||
GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, GitStatusListDto,
|
||||
GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto,
|
||||
InterruptAgentRequestDto, LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto,
|
||||
LiveAgentListDto, MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto,
|
||||
ModelServerConfigDto, ModelServerConfigListDto, OpenTerminalRequestDto,
|
||||
PreviewModelServerCommandDto, ProfileDto, ProfileListDto, ProjectDto, ProjectListDto,
|
||||
ProjectMcpToolPermissionsDto, ProjectPermissionsDto, ProjectWorkStateDto,
|
||||
ReadAgentContextResponseDto, ReadConversationPageRequestDto, ReattachChatDto,
|
||||
ReattachResultDto, RecallMemoryRequestDto, RenameLayoutRequestDto, ReplyChunk,
|
||||
ResizeTerminalRequestDto, ResolveAgentPermissionsRequestDto, ResumableAgentListDto,
|
||||
SaveEmbedderProfileRequestDto, SaveModelServerRequestDto, SaveProfileRequestDto,
|
||||
SetActiveLayoutRequestDto, SetActiveLayoutResultDto, SkillDto, SkillListDto,
|
||||
StopLiveAgentRequestDto, StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto,
|
||||
SyncResultDto, TemplateDto, TemplateListDto, TerminalClosedDto, TerminalSessionDto,
|
||||
TurnPageDto, UnassignSkillRequestDto, UpdateAgentContextRequestDto,
|
||||
UpdateAgentMcpToolPermissionsRequestDto, UpdateAgentPermissionsRequestDto,
|
||||
UpdateMemoryRequestDto, UpdateProjectContextRequestDto,
|
||||
AppExitWorkGuardStateDto, AssignSkillRequestDto, AttachLiveAgentRequestDto,
|
||||
AttachLiveAgentResponseDto, BackgroundTaskDto, ChangeAgentProfileDto,
|
||||
ChangeAgentProfileRequestDto, CloneOpenCodeProfileFromSeedRequestDto,
|
||||
ConfigureProfilesRequestDto, ConversationDetailsDto, CreateAgentFromTemplateRequestDto,
|
||||
CreateAgentRequestDto, CreateLayoutRequestDto, CreateLayoutResultDto, CreateMemoryRequestDto,
|
||||
CreateProjectRequestDto, CreateSkillRequestDto, CreateTemplateRequestDto,
|
||||
DeleteLayoutRequestDto, DeleteLayoutResultDto, DeliveredDelegationRequestDto,
|
||||
DetectProfilesRequestDto, DetectProfilesResponseDto, EffectivePermissionsDto,
|
||||
EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto, ErrorDto, FirstRunStateDto,
|
||||
FrontAttachedRequestDto, GitBranchesDto, GitCheckoutRequestDto, GitCommitDto, GitCommitListDto,
|
||||
GitCommitRequestDto, GitStageRequestDto, GitStatusListDto, GraphCommitListDto,
|
||||
HealthRequestDto, HealthResponseDto, InspectConversationRequestDto, InterruptAgentRequestDto,
|
||||
LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, LiveAgentListDto,
|
||||
MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, ModelServerConfigDto,
|
||||
ModelServerConfigListDto, OpenTerminalRequestDto, PreviewModelServerCommandDto, ProfileDto,
|
||||
ProfileListDto, ProjectDto, ProjectListDto, ProjectMcpToolPermissionsDto,
|
||||
ProjectPermissionsDto, ProjectWorkStateDto, ReadAgentContextResponseDto,
|
||||
ReadConversationPageRequestDto, ReattachChatDto, ReattachResultDto, RecallMemoryRequestDto,
|
||||
RenameLayoutRequestDto, ReplyChunk, ResizeTerminalRequestDto,
|
||||
ResolveAgentPermissionsRequestDto, ResumableAgentListDto, SaveEmbedderProfileRequestDto,
|
||||
SaveModelServerRequestDto, SaveProfileRequestDto, SetActiveLayoutRequestDto,
|
||||
SetActiveLayoutResultDto, SkillDto, SkillListDto, StopLiveAgentRequestDto,
|
||||
StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto,
|
||||
TemplateListDto, TerminalClosedDto, TerminalSessionDto, TurnPageDto, UnassignSkillRequestDto,
|
||||
UpdateAgentContextRequestDto, UpdateAgentMcpToolPermissionsRequestDto,
|
||||
UpdateAgentPermissionsRequestDto, UpdateMemoryRequestDto, UpdateProjectContextRequestDto,
|
||||
UpdateProjectMcpToolPermissionsRequestDto, UpdateProjectPermissionsRequestDto,
|
||||
UpdateSkillRequestDto, UpdateTemplateRequestDto, WriteTerminalRequestDto,
|
||||
};
|
||||
@ -1437,6 +1437,39 @@ pub async fn get_project_work_state(
|
||||
.map_err(ErrorDto::from)
|
||||
}
|
||||
|
||||
/// `get_app_exit_work_guard_state` — aggregate active work across all open projects.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an [`ErrorDto`] if an open project or its work-state read model cannot be read.
|
||||
#[tauri::command]
|
||||
pub async fn get_app_exit_work_guard_state(
|
||||
app: AppHandle,
|
||||
) -> Result<AppExitWorkGuardStateDto, ErrorDto> {
|
||||
crate::read_app_exit_work_guard_state(&app)
|
||||
.await
|
||||
.map(AppExitWorkGuardStateDto::from)
|
||||
.map_err(ErrorDto::from)
|
||||
}
|
||||
|
||||
/// `confirm_app_exit` — bypass the close guard once and request main-window shutdown.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an [`ErrorDto`] if the main window cannot be closed programmatically.
|
||||
#[tauri::command]
|
||||
pub async fn confirm_app_exit(app: AppHandle) -> Result<(), ErrorDto> {
|
||||
crate::confirm_next_main_window_close();
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
window.close().map_err(|err| ErrorDto {
|
||||
code: "INTERNAL".to_owned(),
|
||||
message: format!("failed to close main window: {err}"),
|
||||
})?;
|
||||
} else {
|
||||
crate::shutdown_app_after_confirm(&app);
|
||||
app.exit(0);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `read_conversation_page` — human, paginated read of a conversation's **full**
|
||||
/// transcript (lot LS6). Archive-aware (segments + active), text never truncated.
|
||||
///
|
||||
|
||||
@ -29,19 +29,62 @@ pub mod templates;
|
||||
pub mod tickets;
|
||||
|
||||
use std::process::ExitCode;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use application::SnapshotOpenWindowsInput;
|
||||
use application::{AppError, GetAppExitWorkGuardStateInput, SnapshotOpenWindowsInput};
|
||||
use domain::{
|
||||
PersistedMonitorState, PersistedWindowKind, PersistedWindowPosition, PersistedWindowSize,
|
||||
PersistedWindowState, ProjectId,
|
||||
};
|
||||
use tauri::{
|
||||
Manager, PhysicalPosition, PhysicalSize, WebviewUrl, WebviewWindow, WebviewWindowBuilder,
|
||||
Emitter, Manager, PhysicalPosition, PhysicalSize, WebviewUrl, WebviewWindow,
|
||||
WebviewWindowBuilder,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use state::AppState;
|
||||
|
||||
static EXIT_GUARD_CONFIRMED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum MainCloseAction {
|
||||
AllowShutdown,
|
||||
PreventAndNotify,
|
||||
}
|
||||
|
||||
fn decide_main_close_action(
|
||||
has_work_in_progress: bool,
|
||||
already_confirmed: bool,
|
||||
) -> MainCloseAction {
|
||||
if has_work_in_progress && !already_confirmed {
|
||||
MainCloseAction::PreventAndNotify
|
||||
} else {
|
||||
MainCloseAction::AllowShutdown
|
||||
}
|
||||
}
|
||||
|
||||
fn should_install_exit_guard(window_label: &str) -> bool {
|
||||
window_label == "main"
|
||||
}
|
||||
|
||||
fn apply_main_close_decision(
|
||||
guard: application::AppExitWorkGuardState,
|
||||
already_confirmed: bool,
|
||||
mut prevent_close: impl FnMut(),
|
||||
mut emit_guard: impl FnMut(application::AppExitWorkGuardState),
|
||||
mut shutdown: impl FnMut(),
|
||||
) -> MainCloseAction {
|
||||
let action = decide_main_close_action(guard.has_work_in_progress, already_confirmed);
|
||||
match action {
|
||||
MainCloseAction::PreventAndNotify => {
|
||||
prevent_close();
|
||||
emit_guard(guard);
|
||||
}
|
||||
MainCloseAction::AllowShutdown => shutdown(),
|
||||
}
|
||||
action
|
||||
}
|
||||
|
||||
/// The `argv[1]` subcommand token that switches the binary into the headless
|
||||
/// `mcp-server` **bridge** mode (cadrage v5 §1.3) instead of launching Tauri.
|
||||
pub const MCP_SERVER_SUBCOMMAND: &str = "mcp-server";
|
||||
@ -115,48 +158,35 @@ pub fn run() {
|
||||
// independent of the per-view (navigation/layout) lifecycle — those
|
||||
// must NEVER kill a PTY — and only fires on a genuine app shutdown.
|
||||
// A brutal crash is best-effort and out of scope.
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
if should_install_exit_guard("main") && app.get_webview_window("main").is_some() {
|
||||
let window = app
|
||||
.get_webview_window("main")
|
||||
.expect("main window existence checked above");
|
||||
let handle = app.handle().clone();
|
||||
window.on_window_event(move |event| {
|
||||
if let tauri::WindowEvent::CloseRequested { .. } = event {
|
||||
if let Some(state) = handle.try_state::<AppState>() {
|
||||
let open_windows = snapshot_open_webview_windows(&handle);
|
||||
let window_snapshot =
|
||||
std::sync::Arc::clone(&state.snapshot_open_windows);
|
||||
let pty = std::sync::Arc::clone(&state.pty_port);
|
||||
// ORDER IS CRITICAL: freeze `agent_was_running` on every
|
||||
// agent leaf of every open project FIRST, reading the live
|
||||
// PTY registry as it stands now; only THEN kill the PTYs.
|
||||
// If we killed first, the registry would be empty and every
|
||||
// agent would be persisted as "closed".
|
||||
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 open_projects = state.open_project_ids();
|
||||
let handles = state.terminal_sessions.handles();
|
||||
tauri::async_runtime::block_on(async move {
|
||||
let _ = window_snapshot
|
||||
.execute(SnapshotOpenWindowsInput {
|
||||
windows: open_windows,
|
||||
})
|
||||
.await;
|
||||
for project_id in open_projects {
|
||||
let _ = snapshot
|
||||
.execute(application::SnapshotRunningAgentsInput {
|
||||
project_id,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
for h in handles {
|
||||
let _ = pty.kill(&h).await;
|
||||
}
|
||||
let _ = model_servers.stop_on_app_exit().await;
|
||||
let _ = embedded_server.stop().await;
|
||||
});
|
||||
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||
let already_confirmed = consume_exit_guard_confirmation();
|
||||
let guard =
|
||||
tauri::async_runtime::block_on(app_exit_work_guard_state(&handle));
|
||||
if let Ok(guard) = guard {
|
||||
let action = apply_main_close_decision(
|
||||
guard,
|
||||
already_confirmed,
|
||||
|| api.prevent_close(),
|
||||
|guard| {
|
||||
let _ = handle.emit(
|
||||
"app-exit-work-guard",
|
||||
backend::dto::AppExitWorkGuardStateDto::from(guard),
|
||||
);
|
||||
},
|
||||
|| shutdown_app_after_confirm(&handle),
|
||||
);
|
||||
if action == MainCloseAction::PreventAndNotify {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
shutdown_app_after_confirm(&handle);
|
||||
}
|
||||
|
||||
close_non_main_webview_windows(&handle);
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -211,6 +241,8 @@ pub fn run() {
|
||||
commands::dismiss_embedder_suggestion,
|
||||
commands::create_agent,
|
||||
commands::list_agents,
|
||||
commands::get_app_exit_work_guard_state,
|
||||
commands::confirm_app_exit,
|
||||
tickets::ticket_create,
|
||||
tickets::ticket_read,
|
||||
tickets::ticket_delete,
|
||||
@ -307,6 +339,79 @@ pub fn run() {
|
||||
.expect("error while running IdeA Tauri application");
|
||||
}
|
||||
|
||||
async fn app_exit_work_guard_state(
|
||||
handle: &tauri::AppHandle,
|
||||
) -> Result<application::AppExitWorkGuardState, AppError> {
|
||||
let Some(state) = handle.try_state::<AppState>() else {
|
||||
return Ok(application::AppExitWorkGuardState {
|
||||
has_work_in_progress: false,
|
||||
busy_agent_count: 0,
|
||||
active_background_task_count: 0,
|
||||
details: Vec::new(),
|
||||
});
|
||||
};
|
||||
|
||||
let mut projects = Vec::new();
|
||||
for project_id in state.open_project_ids() {
|
||||
projects.push(state.project_store.load_project(project_id).await?);
|
||||
}
|
||||
state
|
||||
.get_app_exit_work_guard_state
|
||||
.execute(GetAppExitWorkGuardStateInput { projects })
|
||||
.await
|
||||
}
|
||||
|
||||
/// Executes the global shutdown teardown after the close guard has allowed exit.
|
||||
///
|
||||
/// 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.
|
||||
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);
|
||||
let window_snapshot = std::sync::Arc::clone(&state.snapshot_open_windows);
|
||||
let pty = std::sync::Arc::clone(&state.pty_port);
|
||||
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 open_projects = state.open_project_ids();
|
||||
let handles = state.terminal_sessions.handles();
|
||||
tauri::async_runtime::block_on(async move {
|
||||
let _ = window_snapshot
|
||||
.execute(SnapshotOpenWindowsInput {
|
||||
windows: open_windows,
|
||||
})
|
||||
.await;
|
||||
for project_id in open_projects {
|
||||
let _ = snapshot
|
||||
.execute(application::SnapshotRunningAgentsInput { project_id })
|
||||
.await;
|
||||
}
|
||||
for h in handles {
|
||||
let _ = pty.kill(&h).await;
|
||||
}
|
||||
let _ = model_servers.stop_on_app_exit().await;
|
||||
let _ = embedded_server.stop().await;
|
||||
});
|
||||
}
|
||||
|
||||
close_non_main_webview_windows(handle);
|
||||
}
|
||||
|
||||
pub(crate) fn confirm_next_main_window_close() {
|
||||
EXIT_GUARD_CONFIRMED.store(true, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
fn consume_exit_guard_confirmation() -> bool {
|
||||
EXIT_GUARD_CONFIRMED.swap(false, Ordering::SeqCst)
|
||||
}
|
||||
|
||||
pub(crate) async fn read_app_exit_work_guard_state(
|
||||
handle: &tauri::AppHandle,
|
||||
) -> Result<application::AppExitWorkGuardState, AppError> {
|
||||
app_exit_work_guard_state(handle).await
|
||||
}
|
||||
|
||||
fn close_non_main_webview_windows(handle: &tauri::AppHandle) {
|
||||
for (label, window) in handle.webview_windows() {
|
||||
if !should_close_with_main_window(&label) {
|
||||
@ -532,8 +637,114 @@ fn persisted_monitor_is_available(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{persisted_view_identity_from_label, persisted_window_identity};
|
||||
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,
|
||||
should_install_exit_guard, MainCloseAction,
|
||||
};
|
||||
use super::{should_close_with_main_window, PersistedWindowKind};
|
||||
use application::AppExitWorkGuardState;
|
||||
use std::cell::Cell;
|
||||
|
||||
#[test]
|
||||
fn main_close_without_work_allows_shutdown_without_preventing_close() {
|
||||
assert_eq!(
|
||||
decide_main_close_action(false, false),
|
||||
MainCloseAction::AllowShutdown
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn main_close_with_work_prevents_and_notifies_before_shutdown() {
|
||||
assert_eq!(
|
||||
decide_main_close_action(true, false),
|
||||
MainCloseAction::PreventAndNotify
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn main_close_with_work_emits_guard_payload_and_skips_shutdown() {
|
||||
let prevented = Cell::new(false);
|
||||
let shutdown = Cell::new(false);
|
||||
let emitted = Cell::new(None);
|
||||
let guard = AppExitWorkGuardState {
|
||||
has_work_in_progress: true,
|
||||
busy_agent_count: 2,
|
||||
active_background_task_count: 1,
|
||||
details: Vec::new(),
|
||||
};
|
||||
|
||||
let action = apply_main_close_decision(
|
||||
guard,
|
||||
false,
|
||||
|| prevented.set(true),
|
||||
|payload| {
|
||||
emitted.set(Some((
|
||||
payload.busy_agent_count,
|
||||
payload.active_background_task_count,
|
||||
)))
|
||||
},
|
||||
|| shutdown.set(true),
|
||||
);
|
||||
|
||||
assert_eq!(action, MainCloseAction::PreventAndNotify);
|
||||
assert!(prevented.get());
|
||||
assert_eq!(emitted.get(), Some((2, 1)));
|
||||
assert!(!shutdown.get());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn main_close_without_work_runs_shutdown_without_prevent_or_emit() {
|
||||
let prevented = Cell::new(false);
|
||||
let shutdown = Cell::new(false);
|
||||
let emitted = Cell::new(false);
|
||||
let guard = AppExitWorkGuardState {
|
||||
has_work_in_progress: false,
|
||||
busy_agent_count: 0,
|
||||
active_background_task_count: 0,
|
||||
details: Vec::new(),
|
||||
};
|
||||
|
||||
let action = apply_main_close_decision(
|
||||
guard,
|
||||
false,
|
||||
|| prevented.set(true),
|
||||
|_| emitted.set(true),
|
||||
|| shutdown.set(true),
|
||||
);
|
||||
|
||||
assert_eq!(action, MainCloseAction::AllowShutdown);
|
||||
assert!(!prevented.get());
|
||||
assert!(!emitted.get());
|
||||
assert!(shutdown.get());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirmed_main_close_bypasses_guard_once_then_rearms() {
|
||||
confirm_next_main_window_close();
|
||||
let first_attempt_confirmed = consume_exit_guard_confirmation();
|
||||
assert!(first_attempt_confirmed);
|
||||
assert_eq!(
|
||||
decide_main_close_action(true, first_attempt_confirmed),
|
||||
MainCloseAction::AllowShutdown
|
||||
);
|
||||
|
||||
let second_attempt_confirmed = consume_exit_guard_confirmation();
|
||||
assert!(!second_attempt_confirmed);
|
||||
assert_eq!(
|
||||
decide_main_close_action(true, second_attempt_confirmed),
|
||||
MainCloseAction::PreventAndNotify
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_guard_is_scoped_to_main_window_only() {
|
||||
assert!(should_install_exit_guard("main"));
|
||||
assert!(!should_install_exit_guard(
|
||||
"view-work-00000000-0000-0000-0000-000000000001"
|
||||
));
|
||||
assert!(!should_install_exit_guard("settings"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn main_window_close_does_not_target_main_again() {
|
||||
|
||||
Reference in New Issue
Block a user