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:
2026-07-20 19:18:39 +02:00
parent 8509653e3c
commit 60f4b33e53
7 changed files with 755 additions and 109 deletions

View File

@ -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.
///

View File

@ -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;
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;
}
for h in handles {
let _ = pty.kill(&h).await;
} else {
shutdown_app_after_confirm(&handle);
}
let _ = model_servers.stop_on_app_exit().await;
let _ = embedded_server.stop().await;
});
}
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() {

View File

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

View File

@ -72,6 +72,61 @@ pub struct ProjectWorkState {
pub conversations: Vec<ConversationWorkSummary>,
}
/// Input for [`GetAppExitWorkGuardState::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GetAppExitWorkGuardStateInput {
/// Projects currently open in the application.
pub projects: Vec<Project>,
}
/// 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<AppExitWorkGuardDetail>,
}
/// 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<TicketId>,
},
/// 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<Arc<dyn BackgroundTaskStore>>,
}
/// Read-only use case aggregating app-wide work that should guard application exit.
pub struct GetAppExitWorkGuardState {
work_state: Arc<GetProjectWorkState>,
}
impl GetAppExitWorkGuardState {
/// Builds the app-exit guard from the existing per-project work-state read model.
#[must_use]
pub fn new(work_state: Arc<GetProjectWorkState>) -> 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<AppExitWorkGuardState, AppError> {
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.
///

View File

@ -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");

View File

@ -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<ProjectWorkState> 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<AppExitWorkGuardDetailDto>,
}
impl From<AppExitWorkGuardState> 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<String>,
},
/// 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<AppExitWorkGuardDetail> 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)]

View File

@ -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<ListResumableAgents>,
/// Read-only live/busy state for the project's manifest agents.
pub get_project_work_state: Arc<GetProjectWorkState>,
/// App-wide work-in-progress guard used before confirmed application exit.
pub get_app_exit_work_guard_state: Arc<GetAppExitWorkGuardState>,
/// Human paginated read of a conversation's full transcript (lot LS6).
pub read_conversation_page: Arc<ReadConversationPage>,
/// 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,