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

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