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:
@ -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,
|
||||
};
|
||||
|
||||
@ -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.
|
||||
///
|
||||
|
||||
@ -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");
|
||||
|
||||
Reference in New Issue
Block a user