From 9ee06c7ec90901ab830262a3a106ca9a117532e5 Mon Sep 17 00:00:00 2001 From: Blomios Date: Thu, 17 Sep 2026 20:23:36 +0200 Subject: [PATCH] =?UTF-8?q?fix(domain,application,infrastructure,backend,w?= =?UTF-8?q?eb-server,app-tauri,frontend):=20statut/LED=20projet=20index?= =?UTF-8?q?=C3=A9=20par=20projectId,=20source=20LiveSessions/LiveStateStor?= =?UTF-8?q?e/ReconcileLiveState=20=E2=80=94=20la=20fin=20d'un=20agent=20da?= =?UTF-8?q?ns=20un=20projet=20non=20actif=20repasse=20d=C3=A9sormais=20la?= =?UTF-8?q?=20LED=20du=20projet=20au=20repos=20=E2=80=94=20domain:=20Domai?= =?UTF-8?q?nEvent::AgentBusyChanged=20porte=20project=5Fid=20;=20infrastru?= =?UTF-8?q?cture=20input:=20busy=20=C3=A9mis=20au=20d=C3=A9marrage=20du=20?= =?UTF-8?q?tour=20et=20idle=20au=20mark=5Fidle=20avec=20le=20projet=20poss?= =?UTF-8?q?esseur=20;=20backend:=20DTO=20agentBusyChanged=20s=C3=A9rialise?= =?UTF-8?q?=20project=5Fid=20(busy=5Fchanged=5Fkeeps=5Fowning=5Fproject=5F?= =?UTF-8?q?on=5Fthe=5Fwire)=20;=20application=20workstate:=20ReconcileLive?= =?UTF-8?q?State=20ex=C3=A9cut=C3=A9=20aussi=20avant=20les=20lectures=20de?= =?UTF-8?q?=20projection=20(plus=20seulement=20=C3=A0=20l'ouverture)=20et?= =?UTF-8?q?=20liveness=20=C3=A9valu=C3=A9e=20par=20(projectId,=20agentId)?= =?UTF-8?q?=20=E2=80=94=20un=20m=C3=AAme=20agent=5Fid=20vivant=20dans=20un?= =?UTF-8?q?=20autre=20projet=20n'=C3=A9pargne=20plus=20un=20orphelin=20;?= =?UTF-8?q?=20web-server:=20ws=5Fsend=5Fchat=20lib=C3=A8re=20le=20busy=20a?= =?UTF-8?q?vant=20le=20Final=20visible=20et=20la=20route=20work-state=20pr?= =?UTF-8?q?ojet=20reste=20au=20contrat=20tauri=20;=20app-tauri:=20commande?= =?UTF-8?q?s=20work-state/busy=20align=C3=A9es=20sur=20le=20projectId=20po?= =?UTF-8?q?ssesseur=20;=20frontend:=20useProjectActivity=20agr=C3=A8ge=20l?= =?UTF-8?q?'activit=C3=A9=20par=20projectId=20(busy=5Fchanged=20filtr?= =?UTF-8?q?=C3=A9=20par=20projet=20possesseur,=20plus=20le=20seul=20projet?= =?UTF-8?q?=20courant),=20wsLiveClient/wsLiveClientReconnect=20propagant?= =?UTF-8?q?=20project=5Fid,=20AnnouncementsProvider/CustomAgentChatView/We?= =?UTF-8?q?bWorkspaceLive/LayoutGrid.cellControlsLayering=20align=C3=A9s?= =?UTF-8?q?=20=E2=80=94=20#373=20(QA=20verte:=20cargo=20check=20--tests=20?= =?UTF-8?q?-p=20domain=20-p=20application=20-p=20infrastructure=20-p=20bac?= =?UTF-8?q?kend=20-p=20app-tauri=20-p=20web-server,=20cargo=20test=20-p=20?= =?UTF-8?q?application=20workstate::reconcile::tests,=20cargo=20test=20-p?= =?UTF-8?q?=20infrastructure=20input::tests::busy=5Fevent=5Ffires=5Fon=5Ft?= =?UTF-8?q?urn=5Fstart=5Fand=5Fidle=5Fon=5Fmark=5Fidle,=20cargo=20test=20-?= =?UTF-8?q?p=20backend=20events::tests::busy=5Fchanged=5Fkeeps=5Fowning=5F?= =?UTF-8?q?project=5Fon=5Fthe=5Fwire,=20cargo=20test=20-p=20web-server=20w?= =?UTF-8?q?ebsocket=5Fchat=5Fsend=5Freleases=5Fbusy=5Fbefore=5Fvisible=5Ff?= =?UTF-8?q?inal=20+=20authorized=5Fget=5Fproject=5Fwork=5Fstate=5Freturns?= =?UTF-8?q?=5Ftauri=5Fcontract,=20npm=20test=20--=20useProjectActivity=20w?= =?UTF-8?q?sLiveClient=20wsLiveClientReconnect=20workstate=20Announcements?= =?UTF-8?q?Provider=20CustomAgentChatView=20WebWorkspaceLive=20LayoutGrid.?= =?UTF-8?q?cellControlsLayering,=20npm=20run=20typecheck,=20cargo=20fmt=20?= =?UTF-8?q?--all=20--=20--check,=20git=20diff=20--check=20;=20fichiers=20.?= =?UTF-8?q?ideai/*=20et=20scripts/=20hors=20p=C3=A9rim=C3=A8tre=20;=20aucu?= =?UTF-8?q?n=20push)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/app-tauri/src/commands.rs | 17 ++- crates/application/src/workstate/reconcile.rs | 52 ++++++-- crates/backend/src/events.rs | 30 ++++- crates/domain/src/events.rs | 3 + crates/infrastructure/src/input/mod.rs | 29 +++-- crates/web-server/src/lib.rs | 30 +++-- .../src/adapters/http/wsLiveClient.test.ts | 4 +- .../http/wsLiveClientReconnect.test.ts | 2 +- frontend/src/domain/index.ts | 2 +- .../agents/CustomAgentChatView.test.tsx | 12 +- .../features/agents/CustomAgentChatView.tsx | 3 + .../AnnouncementsProvider.test.tsx | 2 +- .../announcements/AnnouncementsProvider.tsx | 4 +- .../LayoutGrid.cellControlsLayering.test.tsx | 2 +- .../projects/projectCliInteractionActivity.ts | 14 +++ .../projects/useProjectActivity.test.tsx | 114 +++++++++++++++++- .../features/projects/useProjectActivity.ts | 53 ++++++-- .../features/web/WebWorkspaceLive.test.tsx | 2 +- .../features/workstate/useProjectWorkState.ts | 3 + .../src/features/workstate/workstate.test.tsx | 1 + 20 files changed, 319 insertions(+), 60 deletions(-) diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index 91c1767e..71e21a3b 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -24,10 +24,10 @@ use application::{ ListResumableAgentsInput, ListSkillsInput, ListSlashCommandsInput, LiveSessions, LoadLayoutInput, McpRuntime, MutateLayoutInput, OpenPluginLayoutWindowInput, ReadAgentContextInput, ReadConversationPageInput, ReadMcpToolPermissionsInput, - ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput, RenameDeviceInput, - RenameLayoutInput, ResolveAgentPermissionsInput, ResolveAgentSystemPermissionsInput, - ResolveMemoryLinksInput, RestartCellAgentRuntimeInput, RevokeDeviceInput, - RotateConversationLogInput, SaveChatDraftInput, SetActiveLayoutInput, + ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput, ReconcileLiveStateInput, + RenameDeviceInput, RenameLayoutInput, ResolveAgentPermissionsInput, + ResolveAgentSystemPermissionsInput, ResolveMemoryLinksInput, RestartCellAgentRuntimeInput, + RevokeDeviceInput, RotateConversationLogInput, SaveChatDraftInput, SetActiveLayoutInput, SetAgentMcpToolAllowedInput, SlashCommandEffect, SnapshotRunningAgentsInput, StopLiveAgentInput, SyncAgentWithTemplateInput, UnassignSkillFromAgentInput, UpdateAgentContactableInput, UpdateAgentContextInput, UpdateAgentEffortInput, @@ -2270,6 +2270,15 @@ pub async fn get_project_work_state( state: State<'_, AppState>, ) -> Result { let project = resolve_project(&project_id, &state).await?; + // The project sidebar reads every open project, not only the focused one. + // Reconcile the requested project's persisted state against the global live + // registry so a dead runtime cannot keep an inactive project orange. + let _ = state + .reconcile_live_state + .execute(ReconcileLiveStateInput { + project_id: project.id, + }) + .await; state .get_project_work_state .execute(GetProjectWorkStateInput { project }) diff --git a/crates/application/src/workstate/reconcile.rs b/crates/application/src/workstate/reconcile.rs index 628f440b..eb2a77ff 100644 --- a/crates/application/src/workstate/reconcile.rs +++ b/crates/application/src/workstate/reconcile.rs @@ -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, registry: Arc, @@ -123,11 +126,11 @@ mod tests { /// Liveness fake: only the agents in the set are considered live. struct FakeRegistry { - live: HashSet, + 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, live: HashSet, now: i64) { + fn other_pid() -> ProjectId { + ProjectId::from_uuid(uuid::Uuid::from_u128(43)) + } + + async fn run( + store: Arc, + 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 diff --git a/crates/backend/src/events.rs b/crates/backend/src/events.rs index e05ea797..9f0f74bd 100644 --- a/crates/backend/src/events.rs +++ b/crates/backend/src/events.rs @@ -274,6 +274,8 @@ pub enum DomainEventDto { /// "Envoyer" while `busy` is `true`. #[serde(rename_all = "camelCase")] AgentBusyChanged { + /// Owning project id. + project_id: String, /// Agent id. agent_id: String, /// `true` when a turn is in flight, `false` when idle. @@ -956,7 +958,12 @@ impl From<&DomainEvent> for DomainEventDto { DomainEvent::TicketAssistantClosed { issue_ref } => Self::TicketAssistantClosed { issue_ref: issue_ref.to_string(), }, - DomainEvent::AgentBusyChanged { agent_id, busy } => Self::AgentBusyChanged { + DomainEvent::AgentBusyChanged { + project_id, + agent_id, + busy, + } => Self::AgentBusyChanged { + project_id: project_id.to_string(), agent_id: agent_id.to_string(), busy: *busy, }, @@ -1465,6 +1472,27 @@ mod tests { assert_eq!(json["status"]["reused"], true); } + #[test] + fn busy_changed_keeps_owning_project_on_the_wire() { + let project_id = ProjectId::from_uuid(uuid::Uuid::from_u128(41)); + let agent_id = agent(42); + let dto = DomainEventDto::from(&DomainEvent::AgentBusyChanged { + project_id, + agent_id, + busy: false, + }); + + assert_eq!( + serde_json::to_value(dto).expect("serialisable"), + json!({ + "type": "agentBusyChanged", + "projectId": project_id.to_string(), + "agentId": agent_id.to_string(), + "busy": false, + }) + ); + } + #[test] fn plugin_reloaded_relays_hashes_and_changed_flag_to_wire() { let plugin_id = domain::PluginId::new("dev.acme.gitgraph").unwrap(); diff --git a/crates/domain/src/events.rs b/crates/domain/src/events.rs index d3bc04f6..f2dbdbd1 100644 --- a/crates/domain/src/events.rs +++ b/crates/domain/src/events.rs @@ -341,6 +341,9 @@ pub enum DomainEvent { /// mediated-input view can dim "Envoyer" while a turn is in flight (it never /// blocks the enqueue — the FIFO keeps accepting; the flag is purely advisory). AgentBusyChanged { + /// Project that owns the runtime. Agent ids are only meaningful inside this + /// boundary for cross-project activity projections. + project_id: ProjectId, /// The agent whose state changed. agent_id: AgentId, /// `true` when a turn is in flight, `false` when the agent is idle. diff --git a/crates/infrastructure/src/input/mod.rs b/crates/infrastructure/src/input/mod.rs index ec4dbcdc..48e18243 100644 --- a/crates/infrastructure/src/input/mod.rs +++ b/crates/infrastructure/src/input/mod.rs @@ -433,6 +433,7 @@ impl BusyTracker { application::diag!("[input-mediator] mark_idle turn ended agent={agent}"); if let Some(events) = &self.events { events.publish(DomainEvent::AgentBusyChanged { + project_id: agent.project_id, agent_id: agent.agent_id, busy: false, }); @@ -877,6 +878,7 @@ impl InputMediator for MediatedInbox { if started_turn { if let Some(events) = &self.tracker.events { events.publish(DomainEvent::AgentBusyChanged { + project_id: agent.project_id, agent_id: agent.agent_id, busy: true, }); @@ -942,6 +944,7 @@ impl InputMediator for MediatedInbox { self.tracker.arm_liveness(agent, stall_after_ms, now_ms); if let Some(events) = &self.tracker.events { events.publish(DomainEvent::AgentBusyChanged { + project_id: agent.project_id, agent_id: agent.agent_id, busy: true, }); @@ -1218,13 +1221,17 @@ mod tests { } } impl RecordingBus { - fn busy_events(&self) -> Vec<(AgentId, bool)> { + fn busy_events(&self) -> Vec<(domain::ProjectId, AgentId, bool)> { self.0 .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .iter() .filter_map(|e| match e { - DomainEvent::AgentBusyChanged { agent_id, busy } => Some((*agent_id, *busy)), + DomainEvent::AgentBusyChanged { + project_id, + agent_id, + busy, + } => Some((*project_id, *agent_id, *busy)), _ => None, }) .collect() @@ -1266,13 +1273,13 @@ mod tests { // First enqueue starts a turn ⇒ exactly one Busy(true) event. inbox.enqueue(a, ticket(10, "first")); - assert_eq!(bus.busy_events(), vec![(a.agent_id, true)]); + assert_eq!(bus.busy_events(), vec![(a.project_id, a.agent_id, true)]); // Second enqueue while Busy queues behind ⇒ NO new busy event. inbox.enqueue(a, ticket(11, "second")); assert_eq!( bus.busy_events(), - vec![(a.agent_id, true)], + vec![(a.project_id, a.agent_id, true)], "no re-announce while busy" ); @@ -1280,14 +1287,20 @@ mod tests { inbox.mark_idle(a); assert_eq!( bus.busy_events(), - vec![(a.agent_id, true), (a.agent_id, false)] + vec![ + (a.project_id, a.agent_id, true), + (a.project_id, a.agent_id, false), + ] ); // mark_idle on an already-idle agent ⇒ no spurious event. inbox.mark_idle(a); assert_eq!( bus.busy_events(), - vec![(a.agent_id, true), (a.agent_id, false)] + vec![ + (a.project_id, a.agent_id, true), + (a.project_id, a.agent_id, false), + ] ); } @@ -1300,7 +1313,7 @@ mod tests { inbox.enqueue(a, ticket(10, "t")); inbox.preempt(a); // Only the enqueue's Busy(true); preempt does not toggle busy state. - assert_eq!(bus.busy_events(), vec![(a.agent_id, true)]); + assert_eq!(bus.busy_events(), vec![(a.project_id, a.agent_id, true)]); } // ==================================================================== @@ -1360,7 +1373,7 @@ mod tests { inbox.enqueue_silent(a, ticket(10, "headless turn")); assert!(inbox.busy_state(a).is_busy()); - assert_eq!(bus.busy_events(), vec![(a.agent_id, true)]); + assert_eq!(bus.busy_events(), vec![(a.project_id, a.agent_id, true)]); assert!( bus.delegation_ready().is_empty(), "headless bookkeeping must not leak a prompt to the terminal" diff --git a/crates/web-server/src/lib.rs b/crates/web-server/src/lib.rs index b9a9eafb..439d2f37 100644 --- a/crates/web-server/src/lib.rs +++ b/crates/web-server/src/lib.rs @@ -53,16 +53,17 @@ use application::{ PairAttemptDecision, PairDeviceInput, RateLimitKey, ReadAgentContextInput, ReadConversationPageInput, ReadIssueAttachmentInput, ReadIssueCarnetInput, ReadIssueInput, ReadMcpToolPermissionsInput, ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput, - RenameDeviceInput, RenameLayoutInput, RenameSprintInput, ReorderSprintsInput, - ResizeTerminalInput, ResolveAgentPermissionsInput, ResolveAgentSystemPermissionsInput, - ResolveMemoryLinksInput, RevokeDeviceInput, RotateConversationLogInput, SetActiveLayoutInput, - SetAgentMcpToolAllowedInput, StopLiveAgentInput, SyncAgentWithTemplateInput, TouchDeviceInput, - UnassignSkillFromAgentInput, UnassignTicketFromSprintInput, UnlinkIssuesInput, - UpdateAgentContactableInput, UpdateAgentContextInput, UpdateAgentEffortInput, - UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput, - UpdateAgentSystemPermissionsInput, UpdateIssueCarnetInput, UpdateMemoryInput, - UpdateProjectContextInput, UpdateProjectMcpToolPermissionsInput, UpdateProjectPermissionsInput, - UpdateProjectSystemPermissionsInput, UpdateSkillInput, WriteToTerminalInput, + ReconcileLiveStateInput, RenameDeviceInput, RenameLayoutInput, RenameSprintInput, + ReorderSprintsInput, ResizeTerminalInput, ResolveAgentPermissionsInput, + ResolveAgentSystemPermissionsInput, ResolveMemoryLinksInput, RevokeDeviceInput, + RotateConversationLogInput, SetActiveLayoutInput, SetAgentMcpToolAllowedInput, + StopLiveAgentInput, SyncAgentWithTemplateInput, TouchDeviceInput, UnassignSkillFromAgentInput, + UnassignTicketFromSprintInput, UnlinkIssuesInput, UpdateAgentContactableInput, + UpdateAgentContextInput, UpdateAgentEffortInput, UpdateAgentMcpToolPermissionsInput, + UpdateAgentPermissionsInput, UpdateAgentSystemPermissionsInput, UpdateIssueCarnetInput, + UpdateMemoryInput, UpdateProjectContextInput, UpdateProjectMcpToolPermissionsInput, + UpdateProjectPermissionsInput, UpdateProjectSystemPermissionsInput, UpdateSkillInput, + WriteToTerminalInput, }; use domain::ports::{ PtyHandle, ReplyEvent, ReplyProgress, ReplyProgressKind, ReplyProgressSource, @@ -4572,6 +4573,15 @@ async fn invoke_get_project_work_state( message: "get_project_work_state requires args.projectId".to_owned(), })?; let project = resolve_project_readonly(project_id, state).await?; + // The requested project can be visible in navigation without being the + // currently focused workspace. Reconcile that exact project's persisted + // state before returning its runtime projection. + let _ = state + .reconcile_live_state + .execute(ReconcileLiveStateInput { + project_id: project.id, + }) + .await; let output = state .get_project_work_state .execute(GetProjectWorkStateInput { project }) diff --git a/frontend/src/adapters/http/wsLiveClient.test.ts b/frontend/src/adapters/http/wsLiveClient.test.ts index 3eebad12..371d8cca 100644 --- a/frontend/src/adapters/http/wsLiveClient.test.ts +++ b/frontend/src/adapters/http/wsLiveClient.test.ts @@ -148,9 +148,9 @@ describe("WsLiveClient", () => { const handler = vi.fn(); client.setDomainEventHandler(handler); - socket.receive({ kind: "event.domain", payload: { type: "agentBusyChanged", agentId: "a1", busy: true } }); + socket.receive({ kind: "event.domain", payload: { type: "agentBusyChanged", projectId: "p1", agentId: "a1", busy: true } }); - expect(handler).toHaveBeenCalledWith({ type: "agentBusyChanged", agentId: "a1", busy: true }); + expect(handler).toHaveBeenCalledWith({ type: "agentBusyChanged", projectId: "p1", agentId: "a1", busy: true }); }); it("rejects a pending command when an error frame arrives", async () => { diff --git a/frontend/src/adapters/http/wsLiveClientReconnect.test.ts b/frontend/src/adapters/http/wsLiveClientReconnect.test.ts index c93803aa..74169158 100644 --- a/frontend/src/adapters/http/wsLiveClientReconnect.test.ts +++ b/frontend/src/adapters/http/wsLiveClientReconnect.test.ts @@ -177,7 +177,7 @@ describe("WsLiveClient connection state + reconnection", () => { // Events resume flowing to the persisted handler after reconnect. const events: unknown[] = []; h.ws.setDomainEventHandler((e) => events.push(e)); - h.sockets[1].receive({ kind: "event.domain", payload: { type: "agentBusyChanged", agentId: "a1", busy: true } }); + h.sockets[1].receive({ kind: "event.domain", payload: { type: "agentBusyChanged", projectId: "p1", agentId: "a1", busy: true } }); expect(events).toHaveLength(1); }); diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index 7daef582..f7dbada1 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -333,7 +333,7 @@ export type DomainEvent = message: string; } | { type: "agentProfileChanged"; agentId: string; profileId: string } - | { type: "agentBusyChanged"; agentId: string; busy: boolean } + | { type: "agentBusyChanged"; projectId: string; agentId: string; busy: boolean } | { type: "agentRestartRequested"; agentId: string; diff --git a/frontend/src/features/agents/CustomAgentChatView.test.tsx b/frontend/src/features/agents/CustomAgentChatView.test.tsx index 32fb4393..8ebff1ea 100644 --- a/frontend/src/features/agents/CustomAgentChatView.test.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.test.tsx @@ -1866,7 +1866,7 @@ describe("CustomAgentChatView", () => { ], conversations: [], }); - system.emit({ type: "agentBusyChanged", agentId: "agent-1", busy: false }); + system.emit({ type: "agentBusyChanged", projectId: "project-1", agentId: "agent-1", busy: false }); onChunk({ kind: "final", content: "Next answer" }); }), stopLiveAgent: vi.fn(), @@ -1949,7 +1949,7 @@ describe("CustomAgentChatView", () => { ], conversations: [], }); - system.emit({ type: "agentBusyChanged", agentId: "agent-1", busy: true }); + system.emit({ type: "agentBusyChanged", projectId: "project-1", agentId: "agent-1", busy: true }); fireEvent.click(await screen.findByRole("button", { name: "Stop" })); @@ -2103,7 +2103,7 @@ describe("CustomAgentChatView", () => { expect(screen.getByRole("button", { name: "Stop" })).toBeTruthy(); workState._setProjectWorkState("project-1", stateFor(false)); - act(() => system.emit({ type: "agentBusyChanged", agentId: "agent-1", busy: false })); + act(() => system.emit({ type: "agentBusyChanged", projectId: "project-1", agentId: "agent-1", busy: false })); expect(await screen.findByText("Tour terminé sans réponse finale.")).toBeTruthy(); expect(screen.queryByRole("button", { name: "Stop" })).toBeNull(); @@ -2254,7 +2254,7 @@ describe("CustomAgentChatView", () => { ], conversations: [], }); - system.emit({ type: "agentBusyChanged", agentId: "agent-1", busy: false }); + system.emit({ type: "agentBusyChanged", projectId: "project-1", agentId: "agent-1", busy: false }); await waitFor(() => expect(composer).toHaveProperty("disabled", false)); expect(screen.getByRole("button", { name: "Envoyer" })).toBeTruthy(); @@ -6465,7 +6465,7 @@ describe("CustomAgentChatView", () => { expect(screen.getByRole("button", { name: "En attente · 2" })).toBeTruthy(); workState._setProjectWorkState("project-1", stateFor(true)); - act(() => system.emit({ type: "agentBusyChanged", agentId: "agent-1", busy: true })); + act(() => system.emit({ type: "agentBusyChanged", projectId: "project-1", agentId: "agent-1", busy: true })); await waitFor(() => expect(screen.getByRole("button", { name: "Mettre en attente" })).toBeTruthy()); act(() => harness.emit(0, { kind: "final", content: "first final" })); @@ -6475,7 +6475,7 @@ describe("CustomAgentChatView", () => { expect(screen.getByRole("button", { name: "En attente · 2" })).toBeTruthy(); workState._setProjectWorkState("project-1", stateFor(false)); - act(() => system.emit({ type: "agentBusyChanged", agentId: "agent-1", busy: false })); + act(() => system.emit({ type: "agentBusyChanged", projectId: "project-1", agentId: "agent-1", busy: false })); await waitFor(() => expect(harness.sendAgentChat).toHaveBeenCalledTimes(2)); expect(harness.sendAgentChat).toHaveBeenNthCalledWith( diff --git a/frontend/src/features/agents/CustomAgentChatView.tsx b/frontend/src/features/agents/CustomAgentChatView.tsx index e1c5dabd..ffc58799 100644 --- a/frontend/src/features/agents/CustomAgentChatView.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.tsx @@ -1587,6 +1587,9 @@ export function CustomAgentChatView({ let cancelled = false; void system .onDomainEvent((event) => { + if (event.type === "agentBusyChanged" && event.projectId !== projectId) { + return; + } if ( event.type === "agentBusyChanged" || event.type === "delegationReady" || diff --git a/frontend/src/features/announcements/AnnouncementsProvider.test.tsx b/frontend/src/features/announcements/AnnouncementsProvider.test.tsx index 4ed1bcff..22a6982b 100644 --- a/frontend/src/features/announcements/AnnouncementsProvider.test.tsx +++ b/frontend/src/features/announcements/AnnouncementsProvider.test.tsx @@ -56,7 +56,7 @@ function announcement(over: Partial = {}): AnnouncementEvent } function busy(agentId: string, isBusy: boolean): DomainEvent { - return { type: "agentBusyChanged", agentId, busy: isBusy }; + return { type: "agentBusyChanged", projectId: "p1", agentId, busy: isBusy }; } // The provider subscribes asynchronously (onDomainEvent returns a Promise) and diff --git a/frontend/src/features/announcements/AnnouncementsProvider.tsx b/frontend/src/features/announcements/AnnouncementsProvider.tsx index 60270a6e..6b110ef1 100644 --- a/frontend/src/features/announcements/AnnouncementsProvider.tsx +++ b/frontend/src/features/announcements/AnnouncementsProvider.tsx @@ -8,7 +8,8 @@ * and the requester preview F2). * 2. **Overlay lifecycle** — the per-agent **busy/idle** state, the single * authority for mounting/retracting the target overlay (Architect arbitrage - * 2026-07-04). Fed live by `agentBusyChanged { agentId, busy }` and hydrated + * 2026-07-04). Fed live by + * `agentBusyChanged { projectId, agentId, busy }` and hydrated * from the reconciled read-model `ProjectWorkState.agents[].busy` at mount / * reboot (via {@link useHydrateAgentBusy}). * 3. **Self-restart presentation** — the live restart phase plus a small, @@ -160,6 +161,7 @@ export function AnnouncementsProvider({ }), ); } else if (event.type === "agentBusyChanged") { + if (event.projectId !== projectId) return; // Lifecycle authority. `true` mounts the target overlay; `false` retracts // it (idle) and clears the target's content so a next turn starts fresh — // even when no completion event was ever emitted (the sticking case). diff --git a/frontend/src/features/layout/LayoutGrid.cellControlsLayering.test.tsx b/frontend/src/features/layout/LayoutGrid.cellControlsLayering.test.tsx index 2358375a..6f3a1552 100644 --- a/frontend/src/features/layout/LayoutGrid.cellControlsLayering.test.tsx +++ b/frontend/src/features/layout/LayoutGrid.cellControlsLayering.test.tsx @@ -137,7 +137,7 @@ function renderGrid( } function busy(agentId: string, isBusy: boolean): DomainEvent { - return { type: "agentBusyChanged", agentId, busy: isBusy }; + return { type: "agentBusyChanged", projectId: "p1", agentId, busy: isBusy }; } async function flush() { diff --git a/frontend/src/features/projects/projectCliInteractionActivity.ts b/frontend/src/features/projects/projectCliInteractionActivity.ts index 8e384c67..3638fcbc 100644 --- a/frontend/src/features/projects/projectCliInteractionActivity.ts +++ b/frontend/src/features/projects/projectCliInteractionActivity.ts @@ -49,6 +49,20 @@ export function hasProjectCliInteraction(projectId: string): boolean { return (activeByProject.get(projectId)?.size ?? 0) > 0; } +/** + * Drops optimistic interaction markers once the authoritative project snapshot + * confirms that no work remains. This is intentionally project-scoped: a + * terminal event received while another project is displayed must never leave + * the originating project's LED busy, nor clear activity in another project. + */ +export function reconcileProjectCliInteractionActivity( + projectId: string, + hasActiveWork: boolean, +): void { + if (hasActiveWork || !activeByProject.delete(projectId)) return; + emit(); +} + export function subscribeProjectCliInteractionActivity( listener: Listener, ): () => void { diff --git a/frontend/src/features/projects/useProjectActivity.test.tsx b/frontend/src/features/projects/useProjectActivity.test.tsx index aa8cf90b..67891df3 100644 --- a/frontend/src/features/projects/useProjectActivity.test.tsx +++ b/frontend/src/features/projects/useProjectActivity.test.tsx @@ -1,11 +1,12 @@ import { act, render, screen, waitFor } from "@testing-library/react"; -import { beforeEach, describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { DIProvider } from "@/app/di"; -import { MockWorkStateGateway } from "@/adapters/mock"; +import { MockSystemGateway, MockWorkStateGateway } from "@/adapters/mock"; import type { Gateways } from "@/ports"; import { __resetProjectCliInteractionActivityForTests, + hasProjectCliInteraction, markProjectCliInteractionStarted, markProjectCliInteractionStopped, } from "./projectCliInteractionActivity"; @@ -16,6 +17,19 @@ function ActivityProbe({ projectId }: { projectId: string }) { return {activity[projectId]}; } +function MultiProjectActivityProbe({ projectIds }: { projectIds: string[] }) { + const activity = useProjectActivity(projectIds); + return ( + <> + {projectIds.map((projectId) => ( + + {activity[projectId]} + + ))} + + ); +} + describe("useProjectActivity CLI interaction projection (#198)", () => { beforeEach(() => { __resetProjectCliInteractionActivityForTests(); @@ -45,4 +59,100 @@ describe("useProjectActivity CLI interaction projection (#198)", () => { expect(screen.getByTestId("activity").textContent).toBe("none"), ); }); + + it("does not clear optimistic activity on non-terminal refresh events", async () => { + const projectId = "project-1"; + const workState = new MockWorkStateGateway(); + const system = new MockSystemGateway(); + const getProjectWorkState = vi.spyOn(workState, "getProjectWorkState"); + workState._setProjectWorkState(projectId, { agents: [], conversations: [] }); + act(() => markProjectCliInteractionStarted(projectId, "agent-1:turn-1")); + + render( + + + , + ); + + expect((await screen.findByTestId("activity")).textContent).toBe("busy"); + await waitFor(() => expect(getProjectWorkState).toHaveBeenCalledTimes(1)); + act(() => system.emit({ + type: "agentBusyChanged", + projectId, + agentId: "agent-1", + busy: true, + })); + await waitFor(() => expect(getProjectWorkState).toHaveBeenCalledTimes(2)); + expect(screen.getByTestId("activity").textContent).toBe("busy"); + expect(hasProjectCliInteraction(projectId)).toBe(true); + }); + + it("reconciles an inactive project's optimistic LED by project id (#373)", async () => { + const workState = new MockWorkStateGateway(); + const system = new MockSystemGateway(); + const getProjectWorkState = vi.spyOn(workState, "getProjectWorkState"); + const busyAgent = (agentId: string) => ({ + agentId, + name: agentId, + profileId: "profile-1", + live: { kind: "structured" as const, nodeId: `node-${agentId}`, sessionId: `session-${agentId}` }, + busy: { state: "busy" as const, ticket: `ticket-${agentId}`, sinceMs: 1 }, + tickets: [], + }); + const idleAgent = (agentId: string) => ({ + ...busyAgent(agentId), + busy: { state: "idle" as const }, + }); + workState._setProjectWorkState("project-a", { + agents: [busyAgent("agent-a1"), busyAgent("agent-a2")], + conversations: [], + }); + workState._setProjectWorkState("project-b", { agents: [], conversations: [] }); + act(() => { + markProjectCliInteractionStarted("project-a", "agent-a1:turn-1"); + markProjectCliInteractionStarted("project-a", "agent-a2:turn-1"); + }); + + render( + + + , + ); + + expect((await screen.findByTestId("activity-project-a")).textContent).toBe("busy"); + expect(screen.getByTestId("activity-project-b").textContent).toBe("none"); + await waitFor(() => expect(getProjectWorkState).toHaveBeenCalledTimes(2)); + + workState._setProjectWorkState("project-a", { + agents: [idleAgent("agent-a1"), busyAgent("agent-a2")], + conversations: [], + }); + act(() => system.emit({ + type: "agentBusyChanged", + projectId: "project-a", + agentId: "agent-a1", + busy: false, + })); + await waitFor(() => expect(getProjectWorkState).toHaveBeenCalledTimes(3)); + expect(getProjectWorkState).toHaveBeenLastCalledWith("project-a"); + expect(screen.getByTestId("activity-project-a").textContent).toBe("busy"); + expect(hasProjectCliInteraction("project-a")).toBe(true); + + workState._setProjectWorkState("project-a", { + agents: [idleAgent("agent-a1"), idleAgent("agent-a2")], + conversations: [], + }); + act(() => system.emit({ + type: "agentBusyChanged", + projectId: "project-a", + agentId: "agent-a2", + busy: false, + })); + await waitFor(() => expect(getProjectWorkState).toHaveBeenCalledTimes(4)); + expect(getProjectWorkState).toHaveBeenLastCalledWith("project-a"); + await waitFor(() => + expect(screen.getByTestId("activity-project-a").textContent).toBe("idle"), + ); + expect(hasProjectCliInteraction("project-a")).toBe(false); + }); }); diff --git a/frontend/src/features/projects/useProjectActivity.ts b/frontend/src/features/projects/useProjectActivity.ts index 19151bbb..2d8ca89f 100644 --- a/frontend/src/features/projects/useProjectActivity.ts +++ b/frontend/src/features/projects/useProjectActivity.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { ProjectWorkState } from "@/domain"; import { useGateways } from "@/app/di"; @@ -8,6 +8,7 @@ import { } from "./projectActivity"; import { hasProjectCliInteraction, + reconcileProjectCliInteractionActivity, useProjectCliInteractionActivityVersion, } from "./projectCliInteractionActivity"; @@ -35,30 +36,56 @@ export function useProjectActivity(projectIds: string[]): ProjectActivityMap { const [states, setStates] = useState>( {}, ); + const refreshSequenceByProject = useRef>({}); const cliInteractionVersion = useProjectCliInteractionActivityVersion(); - const refresh = useCallback(async () => { - if (stableProjectIds.length === 0) { + const refresh = useCallback(async ( + requestedProjectIds: string[] = stableProjectIds, + reconcileLocalActivity = false, + ) => { + if (requestedProjectIds.length === 0) { setStates({}); return; } const entries = await Promise.all( - stableProjectIds.map(async (projectId) => { + requestedProjectIds.map(async (projectId) => { + const sequence = (refreshSequenceByProject.current[projectId] ?? 0) + 1; + refreshSequenceByProject.current[projectId] = sequence; try { return [ projectId, await workState.getProjectWorkState(projectId), + sequence, ] as const; } catch { - return [projectId, null] as const; + return [projectId, null, sequence] as const; } }), ); - setStates(Object.fromEntries(entries)); + const currentEntries = entries.filter( + ([projectId, , sequence]) => + refreshSequenceByProject.current[projectId] === sequence, + ); + if (currentEntries.length === 0) return; + if (reconcileLocalActivity) { + for (const [projectId, state] of currentEntries) { + if (!state) continue; + reconcileProjectCliInteractionActivity( + projectId, + projectActivityStatus(state) === "busy", + ); + } + } + setStates((previous) => ({ + ...previous, + ...Object.fromEntries( + currentEntries.map(([projectId, state]) => [projectId, state]), + ), + })); }, [stableProjectIds, workState]); useEffect(() => { - void refresh(); + void refresh(stableProjectIds); }, [refresh]); useEffect(() => { @@ -67,7 +94,15 @@ export function useProjectActivity(projectIds: string[]): ProjectActivityMap { let cancelled = false; void system .onDomainEvent((event) => { - if (REFRESH_EVENTS.has(event.type)) void refresh(); + if (!REFRESH_EVENTS.has(event.type)) return; + if (event.type === "agentBusyChanged") { + if (!stableProjectIds.includes(event.projectId)) return; + void refresh([event.projectId], !event.busy); + return; + } + const terminalAgentEvent = + event.type === "agentExited"; + void refresh(stableProjectIds, terminalAgentEvent); }) .then((un) => { if (cancelled) un(); @@ -77,7 +112,7 @@ export function useProjectActivity(projectIds: string[]): ProjectActivityMap { cancelled = true; unsubscribe?.(); }; - }, [refresh, system]); + }, [refresh, stableProjectIds, system]); return useMemo(() => { const next: ProjectActivityMap = {}; diff --git a/frontend/src/features/web/WebWorkspaceLive.test.tsx b/frontend/src/features/web/WebWorkspaceLive.test.tsx index 155adec8..2d340283 100644 --- a/frontend/src/features/web/WebWorkspaceLive.test.tsx +++ b/frontend/src/features/web/WebWorkspaceLive.test.tsx @@ -103,7 +103,7 @@ describe("WebWorkspace live surfaces (F5)", () => { ); // …and an `agentBusyChanged` domain event drives a live refresh. act(() => { - (gateways.system as MockSystemGateway).emit({ type: "agentBusyChanged", agentId: "a1", busy: true }); + (gateways.system as MockSystemGateway).emit({ type: "agentBusyChanged", projectId, agentId: "a1", busy: true }); }); await waitFor(() => diff --git a/frontend/src/features/workstate/useProjectWorkState.ts b/frontend/src/features/workstate/useProjectWorkState.ts index c13aa3de..fb518df9 100644 --- a/frontend/src/features/workstate/useProjectWorkState.ts +++ b/frontend/src/features/workstate/useProjectWorkState.ts @@ -60,6 +60,9 @@ export function useProjectWorkState(projectId: string): ProjectWorkStateViewMode let unsubscribe: (() => void) | undefined; let cancelled = false; void system.onDomainEvent((event) => { + if (event.type === "agentBusyChanged" && event.projectId !== projectId) { + return; + } if ( event.type === "agentLaunched" || event.type === "agentExited" || diff --git a/frontend/src/features/workstate/workstate.test.tsx b/frontend/src/features/workstate/workstate.test.tsx index a0b2d8d4..fb2fb700 100644 --- a/frontend/src/features/workstate/workstate.test.tsx +++ b/frontend/src/features/workstate/workstate.test.tsx @@ -818,6 +818,7 @@ describe("ProjectWorkStatePanel", () => { system.emit({ type: "agentBusyChanged", + projectId: PROJECT_ID, agentId: "agent-3", busy: true, });