fix(domain,application,infrastructure,backend,web-server,app-tauri,frontend): statut/LED projet indexé par projectId, source LiveSessions/LiveStateStore/ReconcileLiveState — la fin d'un agent dans un projet non actif repasse désormais la LED du projet au repos — domain: DomainEvent::AgentBusyChanged porte project_id ; infrastructure input: busy émis au démarrage du tour et idle au mark_idle avec le projet possesseur ; backend: DTO agentBusyChanged sérialise project_id (busy_changed_keeps_owning_project_on_the_wire) ; application workstate: ReconcileLiveState exécuté aussi avant les lectures de projection (plus seulement à l'ouverture) et liveness évaluée par (projectId, agentId) — un même agent_id vivant dans un autre projet n'épargne plus un orphelin ; web-server: ws_send_chat libère le busy avant le Final visible et la route work-state projet reste au contrat tauri ; app-tauri: commandes work-state/busy alignées sur le projectId possesseur ; frontend: useProjectActivity agrège l'activité par projectId (busy_changed filtré par projet possesseur, plus le seul projet courant), wsLiveClient/wsLiveClientReconnect propagant project_id, AnnouncementsProvider/CustomAgentChatView/WebWorkspaceLive/LayoutGrid.cellControlsLayering alignés — #373 (QA verte: cargo check --tests -p domain -p application -p infrastructure -p backend -p app-tauri -p web-server, cargo test -p application workstate::reconcile::tests, cargo test -p infrastructure input::tests::busy_event_fires_on_turn_start_and_idle_on_mark_idle, cargo test -p backend events::tests::busy_changed_keeps_owning_project_on_the_wire, cargo test -p web-server websocket_chat_send_releases_busy_before_visible_final + authorized_get_project_work_state_returns_tauri_contract, npm test -- useProjectActivity wsLiveClient wsLiveClientReconnect workstate AnnouncementsProvider CustomAgentChatView WebWorkspaceLive LayoutGrid.cellControlsLayering, npm run typecheck, cargo fmt --all -- --check, git diff --check ; fichiers .ideai/* et scripts/ hors périmètre ; aucun push)
This commit is contained in:
@ -1,7 +1,8 @@
|
||||
//! [`ReconcileLiveState`] — réconcilie, à l'**ouverture** d'un projet, les lignes
|
||||
//! [`ReconcileLiveState`] — réconcilie, à l'ouverture ou lors d'une lecture de
|
||||
//! projection d'un projet, les lignes
|
||||
//! de live-state **fantômes** : un agent marqué `working`/`waiting`/`blocked`
|
||||
//! alors que sa session n'est plus vivante (un crash ne déclenche jamais
|
||||
//! `close_project`, donc `open` est le seul filet fiable).
|
||||
//! `close_project`, donc l'ouverture et les lectures constituent les filets fiables).
|
||||
//!
|
||||
//! C'est la **jumelle** de [`super::live::UpdateLiveState`] côté système : là où
|
||||
//! `UpdateLiveState` publie une transition déclarée par un agent, cette
|
||||
@ -21,10 +22,12 @@
|
||||
//! politique d'orphelin et la réécriture exacte des champs),
|
||||
//! - l'horloge ([`Clock`]) pour estampiller `updated_at_ms` (last-writer-wins).
|
||||
//!
|
||||
//! **Best-effort** : appelé après `reconcile_layouts` dans la chaîne `open_project`,
|
||||
//! un échec ne doit jamais bloquer l'ouverture (le call site ignore le résultat).
|
||||
//! **Race-safe** : tourne **avant** toute relance d'agent ; toute reprise ultérieure
|
||||
//! réécrit la ligne avec un `updated_at_ms` plus frais (LWW).
|
||||
//! **Best-effort** : appelé après `reconcile_layouts` dans la chaîne `open_project`
|
||||
//! et avant les lectures de work-state, un échec ne doit jamais bloquer l'ouverture
|
||||
//! ni la lecture (les call sites ignorent le résultat).
|
||||
//! **Race-safe** : une ligne n'est réécrite que si l'agent n'est pas vivant dans
|
||||
//! ce projet précis ; toute reprise ultérieure la remplace avec un
|
||||
//! `updated_at_ms` plus frais (LWW).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@ -45,7 +48,7 @@ pub struct ReconcileLiveStateInput {
|
||||
pub project_id: ProjectId,
|
||||
}
|
||||
|
||||
/// Réconcilie les lignes de live-state fantômes d'un projet à l'ouverture.
|
||||
/// Réconcilie les lignes de live-state fantômes d'un projet.
|
||||
pub struct ReconcileLiveState {
|
||||
store: Arc<dyn LiveStateStore>,
|
||||
registry: Arc<dyn LiveAgentRegistry>,
|
||||
@ -123,11 +126,11 @@ mod tests {
|
||||
|
||||
/// Liveness fake: only the agents in the set are considered live.
|
||||
struct FakeRegistry {
|
||||
live: HashSet<AgentId>,
|
||||
live: HashSet<(ProjectId, AgentId)>,
|
||||
}
|
||||
impl LiveAgentRegistry for FakeRegistry {
|
||||
fn is_agent_live(&self, _project_id: domain::ProjectId, agent_id: &AgentId) -> bool {
|
||||
self.live.contains(agent_id)
|
||||
fn is_agent_live(&self, project_id: ProjectId, agent_id: &AgentId) -> bool {
|
||||
self.live.contains(&(project_id, *agent_id))
|
||||
}
|
||||
fn is_node_live(&self, _node_id: &NodeId) -> bool {
|
||||
false
|
||||
@ -149,7 +152,15 @@ mod tests {
|
||||
ProjectId::from_uuid(uuid::Uuid::from_u128(42))
|
||||
}
|
||||
|
||||
async fn run(store: Arc<InMemoryLiveStateStore>, live: HashSet<AgentId>, now: i64) {
|
||||
fn other_pid() -> ProjectId {
|
||||
ProjectId::from_uuid(uuid::Uuid::from_u128(43))
|
||||
}
|
||||
|
||||
async fn run(
|
||||
store: Arc<InMemoryLiveStateStore>,
|
||||
live: HashSet<(ProjectId, AgentId)>,
|
||||
now: i64,
|
||||
) {
|
||||
let uc = ReconcileLiveState::new(
|
||||
store,
|
||||
Arc::new(FakeRegistry { live }),
|
||||
@ -196,13 +207,30 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
// aid(1) is still live ⇒ untouched.
|
||||
run(store.clone(), HashSet::from([aid(1)]), 999).await;
|
||||
run(store.clone(), HashSet::from([(pid(), aid(1))]), 999).await;
|
||||
|
||||
let row = &store.load().await.unwrap().entries[0];
|
||||
assert_eq!(row.status, WorkStatus::Working, "live agent left as-is");
|
||||
assert_eq!(row.updated_at_ms, 7, "not restamped");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn same_agent_id_live_in_another_project_does_not_spare_orphan() {
|
||||
let store = Arc::new(InMemoryLiveStateStore::default());
|
||||
store
|
||||
.upsert(
|
||||
LiveEntry::new(aid(1), None, "work", WorkStatus::Waiting, None, None, 7).unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
run(store.clone(), HashSet::from([(other_pid(), aid(1))]), 999).await;
|
||||
|
||||
let row = &store.load().await.unwrap().entries[0];
|
||||
assert_eq!(row.status, WorkStatus::Idle);
|
||||
assert_eq!(row.updated_at_ms, 999);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_store_is_a_noop_at_boot() {
|
||||
// First boot / crashed before any publish ⇒ the live-state file is absent
|
||||
|
||||
Reference in New Issue
Block a user