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, });