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

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