diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index d8ed44e..d8581c8 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -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 { + 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. /// diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs index b97428a..3c77c80 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -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::() { - 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 { + let Some(state) = handle.try_state::() 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::() { + 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 { + 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() { diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 820bbc4..53c0eed 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -183,11 +183,13 @@ pub use window::{ RestoreOpenWindowsOutput, SnapshotOpenWindows, SnapshotOpenWindowsInput, }; pub use workstate::{ - AgentBackgroundTaskState, AgentTicketState, AgentWorkState, AttachLiveAgent, - AttachLiveAgentInput, AttachLiveAgentOutput, BackgroundTaskKindLabel, ConversationLogProvider, - ConversationPreviewStatus, ConversationTurnWorkPreview, ConversationWorkSummary, - GetLiveStateLean, GetProjectWorkState, GetProjectWorkStateInput, LeanLiveEntry, LeanLiveState, - LiveWorkSession, ProjectWorkState, ReconcileLiveState, ReconcileLiveStateInput, StopLiveAgent, - StopLiveAgentInput, StopLiveAgentOutput, TicketWorkSource, TicketWorkStatus, UpdateLiveState, - UpdateLiveStateInput, LIVE_STATE_MAX_ENTRIES, LIVE_STATE_TTL_MS, + AgentBackgroundTaskState, AgentTicketState, AgentWorkState, AppExitWorkGuardDetail, + AppExitWorkGuardState, AttachLiveAgent, AttachLiveAgentInput, AttachLiveAgentOutput, + BackgroundTaskKindLabel, ConversationLogProvider, ConversationPreviewStatus, + ConversationTurnWorkPreview, ConversationWorkSummary, GetAppExitWorkGuardState, + GetAppExitWorkGuardStateInput, GetLiveStateLean, GetProjectWorkState, GetProjectWorkStateInput, + LeanLiveEntry, LeanLiveState, LiveWorkSession, ProjectWorkState, ReconcileLiveState, + ReconcileLiveStateInput, StopLiveAgent, StopLiveAgentInput, StopLiveAgentOutput, + TicketWorkSource, TicketWorkStatus, UpdateLiveState, UpdateLiveStateInput, + LIVE_STATE_MAX_ENTRIES, LIVE_STATE_TTL_MS, }; diff --git a/crates/application/src/workstate/mod.rs b/crates/application/src/workstate/mod.rs index 2e5071d..1daab65 100644 --- a/crates/application/src/workstate/mod.rs +++ b/crates/application/src/workstate/mod.rs @@ -72,6 +72,61 @@ pub struct ProjectWorkState { pub conversations: Vec, } +/// Input for [`GetAppExitWorkGuardState::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GetAppExitWorkGuardStateInput { + /// Projects currently open in the application. + pub projects: Vec, +} + +/// Application-wide shutdown guard summary for the close-confirmation UX. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AppExitWorkGuardState { + /// Whether at least one active work item would be interrupted by app exit. + pub has_work_in_progress: bool, + /// Number of busy agents across all open projects. + pub busy_agent_count: usize, + /// Number of non-terminal background tasks across all open projects. + pub active_background_task_count: usize, + /// Best-effort detail for compact UX display. + pub details: Vec, +} + +/// One work item contributing to [`AppExitWorkGuardState`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AppExitWorkGuardDetail { + /// A manifest agent is currently processing a turn. + BusyAgent { + /// Owning project id. + project_id: domain::ProjectId, + /// Owning project display name. + project_name: String, + /// Agent id. + agent_id: AgentId, + /// Agent display name. + agent_name: String, + /// Busy ticket, if carried by the mediator state. + ticket_id: Option, + }, + /// A first-class background task is queued, running or waiting. + ActiveBackgroundTask { + /// Owning project id. + project_id: domain::ProjectId, + /// Owning project display name. + project_name: String, + /// Owning agent id. + agent_id: AgentId, + /// Owning agent display name. + agent_name: String, + /// Stable task id. + task_id: TaskId, + /// Lifecycle state. + state: BackgroundTaskState, + /// Kind discriminant. + kind: BackgroundTaskKindLabel, + }, +} + /// Best-effort, read-only summary of one conversation visible through the tickets. /// /// Derived live from the [`HandoffStore`] (primary source) with a bounded @@ -276,6 +331,80 @@ pub struct GetProjectWorkState { background_tasks: Option>, } +/// Read-only use case aggregating app-wide work that should guard application exit. +pub struct GetAppExitWorkGuardState { + work_state: Arc, +} + +impl GetAppExitWorkGuardState { + /// Builds the app-exit guard from the existing per-project work-state read model. + #[must_use] + pub fn new(work_state: Arc) -> Self { + Self { work_state } + } + + /// Executes the guard aggregation across all currently open projects. + /// + /// # Errors + /// Propagates the per-project work-state read errors. + pub async fn execute( + &self, + input: GetAppExitWorkGuardStateInput, + ) -> Result { + let mut busy_agent_count = 0; + let mut active_background_task_count = 0; + let mut details = Vec::new(); + + for project in input.projects { + let project_id = project.id; + let project_name = project.name.clone(); + let state = self + .work_state + .execute(GetProjectWorkStateInput { + project: project.clone(), + }) + .await?; + + for agent in state.agents { + if agent.busy.is_busy() { + busy_agent_count += 1; + details.push(AppExitWorkGuardDetail::BusyAgent { + project_id, + project_name: project_name.clone(), + agent_id: agent.agent_id, + agent_name: agent.name.clone(), + ticket_id: agent.busy.ticket(), + }); + } + + for task in agent + .background_tasks + .into_iter() + .filter(|task| !task.state.is_terminal()) + { + active_background_task_count += 1; + details.push(AppExitWorkGuardDetail::ActiveBackgroundTask { + project_id, + project_name: project_name.clone(), + agent_id: agent.agent_id, + agent_name: agent.name.clone(), + task_id: task.task_id, + state: task.state, + kind: task.kind, + }); + } + } + } + + Ok(AppExitWorkGuardState { + has_work_in_progress: busy_agent_count > 0 || active_background_task_count > 0, + busy_agent_count, + active_background_task_count, + details, + }) + } +} + impl GetProjectWorkState { /// Builds the read-model use case from existing stores/registries. /// diff --git a/crates/application/tests/workstate.rs b/crates/application/tests/workstate.rs index a153123..5bf09b4 100644 --- a/crates/application/tests/workstate.rs +++ b/crates/application/tests/workstate.rs @@ -8,7 +8,8 @@ use std::sync::{Arc, Mutex}; use async_trait::async_trait; use application::{ - ConversationLogProvider, ConversationPreviewStatus, GetProjectWorkState, + AppExitWorkGuardDetail, ConversationLogProvider, ConversationPreviewStatus, + GetAppExitWorkGuardState, GetAppExitWorkGuardStateInput, GetProjectWorkState, GetProjectWorkStateInput, HandoffProvider, LiveSessionKind, LiveSessions, StructuredSessions, TerminalSessions, TicketWorkSource, TicketWorkStatus, }; @@ -559,9 +560,12 @@ fn background_task( .unwrap(); match state { BackgroundTaskState::Queued => base, - BackgroundTaskState::Running | BackgroundTaskState::Waiting => { - base.transition(state, created_at_ms + 10).unwrap() - } + BackgroundTaskState::Running => base.transition(state, created_at_ms + 10).unwrap(), + BackgroundTaskState::Waiting => base + .transition(BackgroundTaskState::Running, created_at_ms + 10) + .unwrap() + .transition(BackgroundTaskState::Waiting, created_at_ms + 20) + .unwrap(), BackgroundTaskState::Completed | BackgroundTaskState::Failed | BackgroundTaskState::Cancelled @@ -695,6 +699,162 @@ async fn workstate_attaches_live_structured_session_to_manifest_agent() { assert_eq!(live.kind, LiveSessionKind::Structured); } +#[tokio::test] +async fn app_exit_guard_is_false_without_busy_agent_or_active_background_task() { + let a = agent(10, "alpha"); + let f = fixture(std::slice::from_ref(&a)); + insert_pty(&f.pty, sid(1), a.id, nid(100)); + + let guard = GetAppExitWorkGuardState::new(Arc::new(f.usecase)); + let out = guard + .execute(GetAppExitWorkGuardStateInput { + projects: vec![f.project], + }) + .await + .unwrap(); + + assert!(!out.has_work_in_progress); + assert_eq!(out.busy_agent_count, 0); + assert_eq!(out.active_background_task_count, 0); + assert!(out.details.is_empty()); +} + +#[tokio::test] +async fn app_exit_guard_is_true_with_busy_agent() { + let a = agent(10, "alpha"); + let f = fixture(std::slice::from_ref(&a)); + f.input.set_busy( + a.id, + AgentBusyState::Busy { + ticket: ticket_id(77), + since_ms: 1_700_000_000_100, + }, + ); + + let guard = GetAppExitWorkGuardState::new(Arc::new(f.usecase)); + let out = guard + .execute(GetAppExitWorkGuardStateInput { + projects: vec![f.project.clone()], + }) + .await + .unwrap(); + + assert!(out.has_work_in_progress); + assert_eq!(out.busy_agent_count, 1); + assert_eq!(out.active_background_task_count, 0); + assert_eq!( + out.details, + vec![AppExitWorkGuardDetail::BusyAgent { + project_id: f.project.id, + project_name: "demo".to_owned(), + agent_id: a.id, + agent_name: "alpha".to_owned(), + ticket_id: Some(ticket_id(77)), + }] + ); +} + +#[tokio::test] +async fn app_exit_guard_is_true_with_non_terminal_background_tasks() { + let a = agent(10, "alpha"); + let f = background_fixture(std::slice::from_ref(&a)); + f.store.set_tasks(vec![ + background_task( + 1, + f.project.id, + a.id, + 1_700_000_000_000, + BackgroundTaskState::Queued, + false, + ), + background_task( + 2, + f.project.id, + a.id, + 1_700_000_000_100, + BackgroundTaskState::Running, + false, + ), + background_task( + 3, + f.project.id, + a.id, + 1_700_000_000_200, + BackgroundTaskState::Waiting, + false, + ), + ]); + + let guard = GetAppExitWorkGuardState::new(Arc::new(f.usecase)); + let out = guard + .execute(GetAppExitWorkGuardStateInput { + projects: vec![f.project.clone()], + }) + .await + .unwrap(); + + assert!(out.has_work_in_progress); + assert_eq!(out.busy_agent_count, 0); + assert_eq!(out.active_background_task_count, 3); + assert!(out + .details + .iter() + .all(|detail| matches!(detail, AppExitWorkGuardDetail::ActiveBackgroundTask { .. }))); +} + +#[tokio::test] +async fn app_exit_guard_ignores_terminal_background_tasks() { + let a = agent(10, "alpha"); + let f = background_fixture(std::slice::from_ref(&a)); + f.store.set_tasks(vec![ + background_task( + 1, + f.project.id, + a.id, + 1_700_000_000_000, + BackgroundTaskState::Completed, + false, + ), + background_task( + 2, + f.project.id, + a.id, + 1_700_000_000_100, + BackgroundTaskState::Failed, + false, + ), + background_task( + 3, + f.project.id, + a.id, + 1_700_000_000_200, + BackgroundTaskState::Cancelled, + false, + ), + background_task( + 4, + f.project.id, + a.id, + 1_700_000_000_300, + BackgroundTaskState::Expired, + false, + ), + ]); + + let guard = GetAppExitWorkGuardState::new(Arc::new(f.usecase)); + let out = guard + .execute(GetAppExitWorkGuardStateInput { + projects: vec![f.project], + }) + .await + .unwrap(); + + assert!(!out.has_work_in_progress); + assert_eq!(out.busy_agent_count, 0); + assert_eq!(out.active_background_task_count, 0); + assert!(out.details.is_empty()); +} + #[tokio::test] async fn workstate_includes_busy_state_from_input_mediator() { let a = agent(10, "alpha"); diff --git a/crates/backend/src/dto.rs b/crates/backend/src/dto.rs index d166d16..c438e1e 100644 --- a/crates/backend/src/dto.rs +++ b/crates/backend/src/dto.rs @@ -9,12 +9,12 @@ use serde::{Deserialize, Serialize}; use application::{ - AgentBackgroundTaskState, AgentTicketState, AppError, AttachLiveAgentOutput, - BackgroundTaskKindLabel, ConversationPreviewStatus, ConversationTurnWorkPreview, - ConversationWorkSummary, CreateProjectInput, CreateProjectOutput, GitGraphOutput, HealthInput, - HealthReport, LayoutKind, ListProjectsOutput, LiveSessionKind, LiveSessionSnapshot, - OpenProjectOutput, ProjectWorkState, StopLiveAgentOutput, TicketWorkSource, TicketWorkStatus, - TurnPage, TurnSource, TurnView, + AgentBackgroundTaskState, AgentTicketState, AppError, AppExitWorkGuardDetail, + AppExitWorkGuardState, AttachLiveAgentOutput, BackgroundTaskKindLabel, + ConversationPreviewStatus, ConversationTurnWorkPreview, ConversationWorkSummary, + CreateProjectInput, CreateProjectOutput, GitGraphOutput, HealthInput, HealthReport, LayoutKind, + ListProjectsOutput, LiveSessionKind, LiveSessionSnapshot, OpenProjectOutput, ProjectWorkState, + StopLiveAgentOutput, TicketWorkSource, TicketWorkStatus, TurnPage, TurnSource, TurnView, }; use domain::{AgentBusyState, PageCursor, PageDirection, Project, ProjectId, TurnRole}; @@ -2275,6 +2275,111 @@ impl From for ProjectWorkStateDto { } } +/// App-wide shutdown guard read model for the exit confirmation flow. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AppExitWorkGuardStateDto { + /// Whether at least one active work item would be interrupted by app exit. + pub has_work_in_progress: bool, + /// Number of busy agents across all open projects. + pub busy_agent_count: usize, + /// Number of non-terminal background tasks across all open projects. + pub active_background_task_count: usize, + /// Total active work items. + pub total_work_count: usize, + /// Best-effort compact details for the confirmation dialog. + pub details: Vec, +} + +impl From for AppExitWorkGuardStateDto { + fn from(state: AppExitWorkGuardState) -> Self { + Self { + has_work_in_progress: state.has_work_in_progress, + busy_agent_count: state.busy_agent_count, + active_background_task_count: state.active_background_task_count, + total_work_count: state.busy_agent_count + state.active_background_task_count, + details: state + .details + .into_iter() + .map(AppExitWorkGuardDetailDto::from) + .collect(), + } + } +} + +/// One active work item contributing to [`AppExitWorkGuardStateDto`]. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase", tag = "kind")] +pub enum AppExitWorkGuardDetailDto { + /// A manifest agent is currently processing a turn. + BusyAgent { + /// Owning project id. + project_id: String, + /// Owning project display name. + project_name: String, + /// Agent id. + agent_id: String, + /// Agent display name. + agent_name: String, + /// Busy ticket id, when available. + ticket_id: Option, + }, + /// A first-class background task is queued, running or waiting. + ActiveBackgroundTask { + /// Owning project id. + project_id: String, + /// Owning project display name. + project_name: String, + /// Owning agent id. + agent_id: String, + /// Owning agent display name. + agent_name: String, + /// Stable task id. + task_id: String, + /// Lifecycle state. + state: String, + /// Kind discriminant. + task_kind: String, + }, +} + +impl From for AppExitWorkGuardDetailDto { + fn from(detail: AppExitWorkGuardDetail) -> Self { + match detail { + AppExitWorkGuardDetail::BusyAgent { + project_id, + project_name, + agent_id, + agent_name, + ticket_id, + } => Self::BusyAgent { + project_id: project_id.to_string(), + project_name, + agent_id: agent_id.to_string(), + agent_name, + ticket_id: ticket_id.map(|id| id.to_string()), + }, + AppExitWorkGuardDetail::ActiveBackgroundTask { + project_id, + project_name, + agent_id, + agent_name, + task_id, + state, + kind, + } => Self::ActiveBackgroundTask { + project_id: project_id.to_string(), + project_name, + agent_id: agent_id.to_string(), + agent_name, + task_id: task_id.to_string(), + state: background_state_label(state).to_owned(), + task_kind: background_kind_label_from_work_state(kind).to_owned(), + }, + } + } +} + /// Request DTO for `attach_live_agent`: bind an already-running agent session to /// a visible layout cell without spawning a new process. #[derive(Debug, Clone, Deserialize)] diff --git a/crates/backend/src/lib.rs b/crates/backend/src/lib.rs index c4d9417..2c5e8a0 100644 --- a/crates/backend/src/lib.rs +++ b/crates/backend/src/lib.rs @@ -21,30 +21,30 @@ use application::{ CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteIssue, DeleteLayout, DeleteMemory, DeleteModelServer, DeleteProfile, DeleteSkill, DeleteSprint, DeleteTemplate, DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles, DismissEmbedderSuggestion, - EnsureLocalModelServer, FirstRunState, GetLiveStateLean, GetMemory, GetProjectPermissions, - GetProjectWorkState, GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, - GitStatus, GitUnstage, HarvestMemoryFromTurn, HealthUseCase, InspectConversation, LaunchAgent, - LaunchAgentInput, LinkIssues, ListAgents, ListAgentsInput, ListDevices, ListEmbedderProfiles, - ListIssues, ListLayouts, ListMemories, ListModelServers, ListProfiles, ListProjects, - ListResumableAgents, ListSkills, ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions, - LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout, McpRuntime, - McpToolPermissionCatalogue, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, - OpenTerminal, OpenTicketAssistant, OrchestratorService, PairAttemptLimiter, PairDevice, - PermissionProjectorRegistry, ProposeContext, ReadAgentContext, ReadContext, - ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory, - ReadMemoryIndex, ReadProjectContext, ReadSkill, ReadTemplate, RecallMemory, ReconcileLayouts, - ReconcileLiveState, ReconcileLiveStateInput, RecordTurn, RecordTurnProvider, ReferenceProfiles, - RenameDevice, RenameLayout, RenameSprint, ReorderSprints, ResizeTerminal, - ResolveAgentPermissions, ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask, - RevokeAllDevices, RevokeDevice, RotateConversationLog, SaveEmbedderProfile, SaveModelServer, - SaveProfile, SessionLimitService, SetActiveLayout, SnapshotOpenWindows, SnapshotRunningAgents, - SpawnBackgroundCommand, StopLiveAgent, StructuredRoutingMode, StructuredSessions, - SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, TouchDevice, - UnassignSkillFromAgent, UnassignTicketFromSprint, UnlinkIssues, UpdateAgentContext, - UpdateAgentMcpToolPermissions, UpdateAgentPermissions, UpdateIssue, UpdateIssueCarnet, - UpdateLiveState, UpdateMemory, UpdateProjectContext, UpdateProjectMcpToolPermissions, - UpdateProjectPermissions, UpdateSkill, UpdateTemplate, WakeSessionProvider, WriteMemory, - WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET, + EnsureLocalModelServer, FirstRunState, GetAppExitWorkGuardState, GetLiveStateLean, GetMemory, + GetProjectPermissions, GetProjectWorkState, GitBranches, GitCheckout, GitCommit, GitGraph, + GitInit, GitLog, GitStage, GitStatus, GitUnstage, HarvestMemoryFromTurn, HealthUseCase, + InspectConversation, LaunchAgent, LaunchAgentInput, LinkIssues, ListAgents, ListAgentsInput, + ListDevices, ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories, ListModelServers, + ListProfiles, ListProjects, ListResumableAgents, ListSkills, ListSprints, ListTemplates, + LiveAgentRegistry, LiveSessions, LiveStateLeanProvider, LiveStateProvider, + LiveStateReadProvider, LoadLayout, McpRuntime, McpToolPermissionCatalogue, MoveTabToNewWindow, + MutateLayout, OnnxModelView, OpenProject, OpenTerminal, OpenTicketAssistant, + OrchestratorService, PairAttemptLimiter, PairDevice, PermissionProjectorRegistry, + ProposeContext, ReadAgentContext, ReadContext, ReadConversationPage, ReadIssue, + ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory, ReadMemoryIndex, ReadProjectContext, + ReadSkill, ReadTemplate, RecallMemory, ReconcileLayouts, ReconcileLiveState, + ReconcileLiveStateInput, RecordTurn, RecordTurnProvider, ReferenceProfiles, RenameDevice, + RenameLayout, RenameSprint, ReorderSprints, ResizeTerminal, ResolveAgentPermissions, + ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask, RevokeAllDevices, RevokeDevice, + RotateConversationLog, SaveEmbedderProfile, SaveModelServer, SaveProfile, SessionLimitService, + SetActiveLayout, SnapshotOpenWindows, SnapshotRunningAgents, SpawnBackgroundCommand, + StopLiveAgent, StructuredRoutingMode, StructuredSessions, SuggestedThisSession, + SyncAgentWithTemplate, TerminalSessions, TouchDevice, UnassignSkillFromAgent, + UnassignTicketFromSprint, UnlinkIssues, UpdateAgentContext, UpdateAgentMcpToolPermissions, + UpdateAgentPermissions, UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory, + UpdateProjectContext, UpdateProjectMcpToolPermissions, UpdateProjectPermissions, UpdateSkill, + UpdateTemplate, WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET, }; use async_trait::async_trait; use domain::ports::{ @@ -1020,6 +1020,8 @@ pub struct BackendCore { pub list_resumable_agents: Arc, /// Read-only live/busy state for the project's manifest agents. pub get_project_work_state: Arc, + /// App-wide work-in-progress guard used before confirmed application exit. + pub get_app_exit_work_guard_state: Arc, /// Human paginated read of a conversation's full transcript (lot LS6). pub read_conversation_page: Arc, /// Best-effort log rotation, triggered off the hot path at thread resume/open (lot LS6). @@ -2233,6 +2235,9 @@ impl BackendCore { ) .with_background_tasks(Arc::clone(&background_tasks_port)), ); + let get_app_exit_work_guard_state = Arc::new(GetAppExitWorkGuardState::new(Arc::clone( + &get_project_work_state, + ))); // Lot LS6 — rotation (hors chemin chaud) + lecture humaine paginée. Tous deux // composent le provider d'archive par root ; la rotation lit aussi le handoff // (plancher `up_to`, INV-LS6). Aucune persistance déclenchée par un `append`. @@ -2543,6 +2548,7 @@ impl BackendCore { change_agent_profile, list_resumable_agents, get_project_work_state, + get_app_exit_work_guard_state, read_conversation_page, rotate_conversation_log, attach_live_agent,