merge(domain,application,infrastructure,backend,web-server,app-tauri,frontend): fix #373 — statut/LED projet par projectId depuis LiveSessions/LiveStateStore/ReconcileLiveState, la fin d'un agent hors projet actif repasse la LED au repos (QA verte)
This commit is contained in:
@ -24,10 +24,10 @@ use application::{
|
|||||||
ListResumableAgentsInput, ListSkillsInput, ListSlashCommandsInput, LiveSessions,
|
ListResumableAgentsInput, ListSkillsInput, ListSlashCommandsInput, LiveSessions,
|
||||||
LoadLayoutInput, McpRuntime, MutateLayoutInput, OpenPluginLayoutWindowInput,
|
LoadLayoutInput, McpRuntime, MutateLayoutInput, OpenPluginLayoutWindowInput,
|
||||||
ReadAgentContextInput, ReadConversationPageInput, ReadMcpToolPermissionsInput,
|
ReadAgentContextInput, ReadConversationPageInput, ReadMcpToolPermissionsInput,
|
||||||
ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput, RenameDeviceInput,
|
ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput, ReconcileLiveStateInput,
|
||||||
RenameLayoutInput, ResolveAgentPermissionsInput, ResolveAgentSystemPermissionsInput,
|
RenameDeviceInput, RenameLayoutInput, ResolveAgentPermissionsInput,
|
||||||
ResolveMemoryLinksInput, RestartCellAgentRuntimeInput, RevokeDeviceInput,
|
ResolveAgentSystemPermissionsInput, ResolveMemoryLinksInput, RestartCellAgentRuntimeInput,
|
||||||
RotateConversationLogInput, SaveChatDraftInput, SetActiveLayoutInput,
|
RevokeDeviceInput, RotateConversationLogInput, SaveChatDraftInput, SetActiveLayoutInput,
|
||||||
SetAgentMcpToolAllowedInput, SlashCommandEffect, SnapshotRunningAgentsInput,
|
SetAgentMcpToolAllowedInput, SlashCommandEffect, SnapshotRunningAgentsInput,
|
||||||
StopLiveAgentInput, SyncAgentWithTemplateInput, UnassignSkillFromAgentInput,
|
StopLiveAgentInput, SyncAgentWithTemplateInput, UnassignSkillFromAgentInput,
|
||||||
UpdateAgentContactableInput, UpdateAgentContextInput, UpdateAgentEffortInput,
|
UpdateAgentContactableInput, UpdateAgentContextInput, UpdateAgentEffortInput,
|
||||||
@ -2270,6 +2270,15 @@ pub async fn get_project_work_state(
|
|||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<ProjectWorkStateDto, ErrorDto> {
|
) -> Result<ProjectWorkStateDto, ErrorDto> {
|
||||||
let project = resolve_project(&project_id, &state).await?;
|
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
|
state
|
||||||
.get_project_work_state
|
.get_project_work_state
|
||||||
.execute(GetProjectWorkStateInput { project })
|
.execute(GetProjectWorkStateInput { project })
|
||||||
|
|||||||
@ -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`
|
//! 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
|
//! 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ù
|
//! 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
|
//! `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),
|
//! politique d'orphelin et la réécriture exacte des champs),
|
||||||
//! - l'horloge ([`Clock`]) pour estampiller `updated_at_ms` (last-writer-wins).
|
//! - l'horloge ([`Clock`]) pour estampiller `updated_at_ms` (last-writer-wins).
|
||||||
//!
|
//!
|
||||||
//! **Best-effort** : appelé après `reconcile_layouts` dans la chaîne `open_project`,
|
//! **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).
|
//! et avant les lectures de work-state, un échec ne doit jamais bloquer l'ouverture
|
||||||
//! **Race-safe** : tourne **avant** toute relance d'agent ; toute reprise ultérieure
|
//! ni la lecture (les call sites ignorent le résultat).
|
||||||
//! réécrit la ligne avec un `updated_at_ms` plus frais (LWW).
|
//! **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;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@ -45,7 +48,7 @@ pub struct ReconcileLiveStateInput {
|
|||||||
pub project_id: ProjectId,
|
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 {
|
pub struct ReconcileLiveState {
|
||||||
store: Arc<dyn LiveStateStore>,
|
store: Arc<dyn LiveStateStore>,
|
||||||
registry: Arc<dyn LiveAgentRegistry>,
|
registry: Arc<dyn LiveAgentRegistry>,
|
||||||
@ -123,11 +126,11 @@ mod tests {
|
|||||||
|
|
||||||
/// Liveness fake: only the agents in the set are considered live.
|
/// Liveness fake: only the agents in the set are considered live.
|
||||||
struct FakeRegistry {
|
struct FakeRegistry {
|
||||||
live: HashSet<AgentId>,
|
live: HashSet<(ProjectId, AgentId)>,
|
||||||
}
|
}
|
||||||
impl LiveAgentRegistry for FakeRegistry {
|
impl LiveAgentRegistry for FakeRegistry {
|
||||||
fn is_agent_live(&self, _project_id: domain::ProjectId, agent_id: &AgentId) -> bool {
|
fn is_agent_live(&self, project_id: ProjectId, agent_id: &AgentId) -> bool {
|
||||||
self.live.contains(agent_id)
|
self.live.contains(&(project_id, *agent_id))
|
||||||
}
|
}
|
||||||
fn is_node_live(&self, _node_id: &NodeId) -> bool {
|
fn is_node_live(&self, _node_id: &NodeId) -> bool {
|
||||||
false
|
false
|
||||||
@ -149,7 +152,15 @@ mod tests {
|
|||||||
ProjectId::from_uuid(uuid::Uuid::from_u128(42))
|
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(
|
let uc = ReconcileLiveState::new(
|
||||||
store,
|
store,
|
||||||
Arc::new(FakeRegistry { live }),
|
Arc::new(FakeRegistry { live }),
|
||||||
@ -196,13 +207,30 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
// aid(1) is still live ⇒ untouched.
|
// 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];
|
let row = &store.load().await.unwrap().entries[0];
|
||||||
assert_eq!(row.status, WorkStatus::Working, "live agent left as-is");
|
assert_eq!(row.status, WorkStatus::Working, "live agent left as-is");
|
||||||
assert_eq!(row.updated_at_ms, 7, "not restamped");
|
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]
|
#[tokio::test]
|
||||||
async fn empty_store_is_a_noop_at_boot() {
|
async fn empty_store_is_a_noop_at_boot() {
|
||||||
// First boot / crashed before any publish ⇒ the live-state file is absent
|
// First boot / crashed before any publish ⇒ the live-state file is absent
|
||||||
|
|||||||
@ -274,6 +274,8 @@ pub enum DomainEventDto {
|
|||||||
/// "Envoyer" while `busy` is `true`.
|
/// "Envoyer" while `busy` is `true`.
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
AgentBusyChanged {
|
AgentBusyChanged {
|
||||||
|
/// Owning project id.
|
||||||
|
project_id: String,
|
||||||
/// Agent id.
|
/// Agent id.
|
||||||
agent_id: String,
|
agent_id: String,
|
||||||
/// `true` when a turn is in flight, `false` when idle.
|
/// `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 {
|
DomainEvent::TicketAssistantClosed { issue_ref } => Self::TicketAssistantClosed {
|
||||||
issue_ref: issue_ref.to_string(),
|
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(),
|
agent_id: agent_id.to_string(),
|
||||||
busy: *busy,
|
busy: *busy,
|
||||||
},
|
},
|
||||||
@ -1465,6 +1472,27 @@ mod tests {
|
|||||||
assert_eq!(json["status"]["reused"], true);
|
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]
|
#[test]
|
||||||
fn plugin_reloaded_relays_hashes_and_changed_flag_to_wire() {
|
fn plugin_reloaded_relays_hashes_and_changed_flag_to_wire() {
|
||||||
let plugin_id = domain::PluginId::new("dev.acme.gitgraph").unwrap();
|
let plugin_id = domain::PluginId::new("dev.acme.gitgraph").unwrap();
|
||||||
|
|||||||
@ -341,6 +341,9 @@ pub enum DomainEvent {
|
|||||||
/// mediated-input view can dim "Envoyer" while a turn is in flight (it never
|
/// 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).
|
/// blocks the enqueue — the FIFO keeps accepting; the flag is purely advisory).
|
||||||
AgentBusyChanged {
|
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.
|
/// The agent whose state changed.
|
||||||
agent_id: AgentId,
|
agent_id: AgentId,
|
||||||
/// `true` when a turn is in flight, `false` when the agent is idle.
|
/// `true` when a turn is in flight, `false` when the agent is idle.
|
||||||
|
|||||||
@ -433,6 +433,7 @@ impl BusyTracker {
|
|||||||
application::diag!("[input-mediator] mark_idle turn ended agent={agent}");
|
application::diag!("[input-mediator] mark_idle turn ended agent={agent}");
|
||||||
if let Some(events) = &self.events {
|
if let Some(events) = &self.events {
|
||||||
events.publish(DomainEvent::AgentBusyChanged {
|
events.publish(DomainEvent::AgentBusyChanged {
|
||||||
|
project_id: agent.project_id,
|
||||||
agent_id: agent.agent_id,
|
agent_id: agent.agent_id,
|
||||||
busy: false,
|
busy: false,
|
||||||
});
|
});
|
||||||
@ -877,6 +878,7 @@ impl InputMediator for MediatedInbox {
|
|||||||
if started_turn {
|
if started_turn {
|
||||||
if let Some(events) = &self.tracker.events {
|
if let Some(events) = &self.tracker.events {
|
||||||
events.publish(DomainEvent::AgentBusyChanged {
|
events.publish(DomainEvent::AgentBusyChanged {
|
||||||
|
project_id: agent.project_id,
|
||||||
agent_id: agent.agent_id,
|
agent_id: agent.agent_id,
|
||||||
busy: true,
|
busy: true,
|
||||||
});
|
});
|
||||||
@ -942,6 +944,7 @@ impl InputMediator for MediatedInbox {
|
|||||||
self.tracker.arm_liveness(agent, stall_after_ms, now_ms);
|
self.tracker.arm_liveness(agent, stall_after_ms, now_ms);
|
||||||
if let Some(events) = &self.tracker.events {
|
if let Some(events) = &self.tracker.events {
|
||||||
events.publish(DomainEvent::AgentBusyChanged {
|
events.publish(DomainEvent::AgentBusyChanged {
|
||||||
|
project_id: agent.project_id,
|
||||||
agent_id: agent.agent_id,
|
agent_id: agent.agent_id,
|
||||||
busy: true,
|
busy: true,
|
||||||
});
|
});
|
||||||
@ -1218,13 +1221,17 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
impl RecordingBus {
|
impl RecordingBus {
|
||||||
fn busy_events(&self) -> Vec<(AgentId, bool)> {
|
fn busy_events(&self) -> Vec<(domain::ProjectId, AgentId, bool)> {
|
||||||
self.0
|
self.0
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|e| match e {
|
.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,
|
_ => None,
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
@ -1266,13 +1273,13 @@ mod tests {
|
|||||||
|
|
||||||
// First enqueue starts a turn ⇒ exactly one Busy(true) event.
|
// First enqueue starts a turn ⇒ exactly one Busy(true) event.
|
||||||
inbox.enqueue(a, ticket(10, "first"));
|
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.
|
// Second enqueue while Busy queues behind ⇒ NO new busy event.
|
||||||
inbox.enqueue(a, ticket(11, "second"));
|
inbox.enqueue(a, ticket(11, "second"));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
bus.busy_events(),
|
bus.busy_events(),
|
||||||
vec![(a.agent_id, true)],
|
vec![(a.project_id, a.agent_id, true)],
|
||||||
"no re-announce while busy"
|
"no re-announce while busy"
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -1280,14 +1287,20 @@ mod tests {
|
|||||||
inbox.mark_idle(a);
|
inbox.mark_idle(a);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
bus.busy_events(),
|
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.
|
// mark_idle on an already-idle agent ⇒ no spurious event.
|
||||||
inbox.mark_idle(a);
|
inbox.mark_idle(a);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
bus.busy_events(),
|
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.enqueue(a, ticket(10, "t"));
|
||||||
inbox.preempt(a);
|
inbox.preempt(a);
|
||||||
// Only the enqueue's Busy(true); preempt does not toggle busy state.
|
// 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"));
|
inbox.enqueue_silent(a, ticket(10, "headless turn"));
|
||||||
|
|
||||||
assert!(inbox.busy_state(a).is_busy());
|
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!(
|
assert!(
|
||||||
bus.delegation_ready().is_empty(),
|
bus.delegation_ready().is_empty(),
|
||||||
"headless bookkeeping must not leak a prompt to the terminal"
|
"headless bookkeeping must not leak a prompt to the terminal"
|
||||||
|
|||||||
@ -53,16 +53,17 @@ use application::{
|
|||||||
PairAttemptDecision, PairDeviceInput, RateLimitKey, ReadAgentContextInput,
|
PairAttemptDecision, PairDeviceInput, RateLimitKey, ReadAgentContextInput,
|
||||||
ReadConversationPageInput, ReadIssueAttachmentInput, ReadIssueCarnetInput, ReadIssueInput,
|
ReadConversationPageInput, ReadIssueAttachmentInput, ReadIssueCarnetInput, ReadIssueInput,
|
||||||
ReadMcpToolPermissionsInput, ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput,
|
ReadMcpToolPermissionsInput, ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput,
|
||||||
RenameDeviceInput, RenameLayoutInput, RenameSprintInput, ReorderSprintsInput,
|
ReconcileLiveStateInput, RenameDeviceInput, RenameLayoutInput, RenameSprintInput,
|
||||||
ResizeTerminalInput, ResolveAgentPermissionsInput, ResolveAgentSystemPermissionsInput,
|
ReorderSprintsInput, ResizeTerminalInput, ResolveAgentPermissionsInput,
|
||||||
ResolveMemoryLinksInput, RevokeDeviceInput, RotateConversationLogInput, SetActiveLayoutInput,
|
ResolveAgentSystemPermissionsInput, ResolveMemoryLinksInput, RevokeDeviceInput,
|
||||||
SetAgentMcpToolAllowedInput, StopLiveAgentInput, SyncAgentWithTemplateInput, TouchDeviceInput,
|
RotateConversationLogInput, SetActiveLayoutInput, SetAgentMcpToolAllowedInput,
|
||||||
UnassignSkillFromAgentInput, UnassignTicketFromSprintInput, UnlinkIssuesInput,
|
StopLiveAgentInput, SyncAgentWithTemplateInput, TouchDeviceInput, UnassignSkillFromAgentInput,
|
||||||
UpdateAgentContactableInput, UpdateAgentContextInput, UpdateAgentEffortInput,
|
UnassignTicketFromSprintInput, UnlinkIssuesInput, UpdateAgentContactableInput,
|
||||||
UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput,
|
UpdateAgentContextInput, UpdateAgentEffortInput, UpdateAgentMcpToolPermissionsInput,
|
||||||
UpdateAgentSystemPermissionsInput, UpdateIssueCarnetInput, UpdateMemoryInput,
|
UpdateAgentPermissionsInput, UpdateAgentSystemPermissionsInput, UpdateIssueCarnetInput,
|
||||||
UpdateProjectContextInput, UpdateProjectMcpToolPermissionsInput, UpdateProjectPermissionsInput,
|
UpdateMemoryInput, UpdateProjectContextInput, UpdateProjectMcpToolPermissionsInput,
|
||||||
UpdateProjectSystemPermissionsInput, UpdateSkillInput, WriteToTerminalInput,
|
UpdateProjectPermissionsInput, UpdateProjectSystemPermissionsInput, UpdateSkillInput,
|
||||||
|
WriteToTerminalInput,
|
||||||
};
|
};
|
||||||
use domain::ports::{
|
use domain::ports::{
|
||||||
PtyHandle, ReplyEvent, ReplyProgress, ReplyProgressKind, ReplyProgressSource,
|
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(),
|
message: "get_project_work_state requires args.projectId".to_owned(),
|
||||||
})?;
|
})?;
|
||||||
let project = resolve_project_readonly(project_id, state).await?;
|
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
|
let output = state
|
||||||
.get_project_work_state
|
.get_project_work_state
|
||||||
.execute(GetProjectWorkStateInput { project })
|
.execute(GetProjectWorkStateInput { project })
|
||||||
|
|||||||
@ -148,9 +148,9 @@ describe("WsLiveClient", () => {
|
|||||||
const handler = vi.fn();
|
const handler = vi.fn();
|
||||||
client.setDomainEventHandler(handler);
|
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 () => {
|
it("rejects a pending command when an error frame arrives", async () => {
|
||||||
|
|||||||
@ -177,7 +177,7 @@ describe("WsLiveClient connection state + reconnection", () => {
|
|||||||
// Events resume flowing to the persisted handler after reconnect.
|
// Events resume flowing to the persisted handler after reconnect.
|
||||||
const events: unknown[] = [];
|
const events: unknown[] = [];
|
||||||
h.ws.setDomainEventHandler((e) => events.push(e));
|
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);
|
expect(events).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -333,7 +333,7 @@ export type DomainEvent =
|
|||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
| { type: "agentProfileChanged"; agentId: string; profileId: string }
|
| { type: "agentProfileChanged"; agentId: string; profileId: string }
|
||||||
| { type: "agentBusyChanged"; agentId: string; busy: boolean }
|
| { type: "agentBusyChanged"; projectId: string; agentId: string; busy: boolean }
|
||||||
| {
|
| {
|
||||||
type: "agentRestartRequested";
|
type: "agentRestartRequested";
|
||||||
agentId: string;
|
agentId: string;
|
||||||
|
|||||||
@ -1866,7 +1866,7 @@ describe("CustomAgentChatView", () => {
|
|||||||
],
|
],
|
||||||
conversations: [],
|
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" });
|
onChunk({ kind: "final", content: "Next answer" });
|
||||||
}),
|
}),
|
||||||
stopLiveAgent: vi.fn(),
|
stopLiveAgent: vi.fn(),
|
||||||
@ -1949,7 +1949,7 @@ describe("CustomAgentChatView", () => {
|
|||||||
],
|
],
|
||||||
conversations: [],
|
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" }));
|
fireEvent.click(await screen.findByRole("button", { name: "Stop" }));
|
||||||
|
|
||||||
@ -2103,7 +2103,7 @@ describe("CustomAgentChatView", () => {
|
|||||||
expect(screen.getByRole("button", { name: "Stop" })).toBeTruthy();
|
expect(screen.getByRole("button", { name: "Stop" })).toBeTruthy();
|
||||||
|
|
||||||
workState._setProjectWorkState("project-1", stateFor(false));
|
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(await screen.findByText("Tour terminé sans réponse finale.")).toBeTruthy();
|
||||||
expect(screen.queryByRole("button", { name: "Stop" })).toBeNull();
|
expect(screen.queryByRole("button", { name: "Stop" })).toBeNull();
|
||||||
@ -2254,7 +2254,7 @@ describe("CustomAgentChatView", () => {
|
|||||||
],
|
],
|
||||||
conversations: [],
|
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));
|
await waitFor(() => expect(composer).toHaveProperty("disabled", false));
|
||||||
expect(screen.getByRole("button", { name: "Envoyer" })).toBeTruthy();
|
expect(screen.getByRole("button", { name: "Envoyer" })).toBeTruthy();
|
||||||
@ -6465,7 +6465,7 @@ describe("CustomAgentChatView", () => {
|
|||||||
expect(screen.getByRole("button", { name: "En attente · 2" })).toBeTruthy();
|
expect(screen.getByRole("button", { name: "En attente · 2" })).toBeTruthy();
|
||||||
|
|
||||||
workState._setProjectWorkState("project-1", stateFor(true));
|
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());
|
await waitFor(() => expect(screen.getByRole("button", { name: "Mettre en attente" })).toBeTruthy());
|
||||||
|
|
||||||
act(() => harness.emit(0, { kind: "final", content: "first final" }));
|
act(() => harness.emit(0, { kind: "final", content: "first final" }));
|
||||||
@ -6475,7 +6475,7 @@ describe("CustomAgentChatView", () => {
|
|||||||
expect(screen.getByRole("button", { name: "En attente · 2" })).toBeTruthy();
|
expect(screen.getByRole("button", { name: "En attente · 2" })).toBeTruthy();
|
||||||
|
|
||||||
workState._setProjectWorkState("project-1", stateFor(false));
|
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));
|
await waitFor(() => expect(harness.sendAgentChat).toHaveBeenCalledTimes(2));
|
||||||
expect(harness.sendAgentChat).toHaveBeenNthCalledWith(
|
expect(harness.sendAgentChat).toHaveBeenNthCalledWith(
|
||||||
|
|||||||
@ -1587,6 +1587,9 @@ export function CustomAgentChatView({
|
|||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
void system
|
void system
|
||||||
.onDomainEvent((event) => {
|
.onDomainEvent((event) => {
|
||||||
|
if (event.type === "agentBusyChanged" && event.projectId !== projectId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
event.type === "agentBusyChanged" ||
|
event.type === "agentBusyChanged" ||
|
||||||
event.type === "delegationReady" ||
|
event.type === "delegationReady" ||
|
||||||
|
|||||||
@ -56,7 +56,7 @@ function announcement(over: Partial<AnnouncementEvent> = {}): AnnouncementEvent
|
|||||||
}
|
}
|
||||||
|
|
||||||
function busy(agentId: string, isBusy: boolean): DomainEvent {
|
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
|
// The provider subscribes asynchronously (onDomainEvent returns a Promise) and
|
||||||
|
|||||||
@ -8,7 +8,8 @@
|
|||||||
* and the requester preview F2).
|
* and the requester preview F2).
|
||||||
* 2. **Overlay lifecycle** — the per-agent **busy/idle** state, the single
|
* 2. **Overlay lifecycle** — the per-agent **busy/idle** state, the single
|
||||||
* authority for mounting/retracting the target overlay (Architect arbitrage
|
* 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 /
|
* from the reconciled read-model `ProjectWorkState.agents[].busy` at mount /
|
||||||
* reboot (via {@link useHydrateAgentBusy}).
|
* reboot (via {@link useHydrateAgentBusy}).
|
||||||
* 3. **Self-restart presentation** — the live restart phase plus a small,
|
* 3. **Self-restart presentation** — the live restart phase plus a small,
|
||||||
@ -160,6 +161,7 @@ export function AnnouncementsProvider({
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
} else if (event.type === "agentBusyChanged") {
|
} else if (event.type === "agentBusyChanged") {
|
||||||
|
if (event.projectId !== projectId) return;
|
||||||
// Lifecycle authority. `true` mounts the target overlay; `false` retracts
|
// Lifecycle authority. `true` mounts the target overlay; `false` retracts
|
||||||
// it (idle) and clears the target's content so a next turn starts fresh —
|
// 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).
|
// even when no completion event was ever emitted (the sticking case).
|
||||||
|
|||||||
@ -137,7 +137,7 @@ function renderGrid(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function busy(agentId: string, isBusy: boolean): DomainEvent {
|
function busy(agentId: string, isBusy: boolean): DomainEvent {
|
||||||
return { type: "agentBusyChanged", agentId, busy: isBusy };
|
return { type: "agentBusyChanged", projectId: "p1", agentId, busy: isBusy };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function flush() {
|
async function flush() {
|
||||||
|
|||||||
@ -49,6 +49,20 @@ export function hasProjectCliInteraction(projectId: string): boolean {
|
|||||||
return (activeByProject.get(projectId)?.size ?? 0) > 0;
|
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(
|
export function subscribeProjectCliInteractionActivity(
|
||||||
listener: Listener,
|
listener: Listener,
|
||||||
): () => void {
|
): () => void {
|
||||||
|
|||||||
@ -1,11 +1,12 @@
|
|||||||
import { act, render, screen, waitFor } from "@testing-library/react";
|
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 { DIProvider } from "@/app/di";
|
||||||
import { MockWorkStateGateway } from "@/adapters/mock";
|
import { MockSystemGateway, MockWorkStateGateway } from "@/adapters/mock";
|
||||||
import type { Gateways } from "@/ports";
|
import type { Gateways } from "@/ports";
|
||||||
import {
|
import {
|
||||||
__resetProjectCliInteractionActivityForTests,
|
__resetProjectCliInteractionActivityForTests,
|
||||||
|
hasProjectCliInteraction,
|
||||||
markProjectCliInteractionStarted,
|
markProjectCliInteractionStarted,
|
||||||
markProjectCliInteractionStopped,
|
markProjectCliInteractionStopped,
|
||||||
} from "./projectCliInteractionActivity";
|
} from "./projectCliInteractionActivity";
|
||||||
@ -16,6 +17,19 @@ function ActivityProbe({ projectId }: { projectId: string }) {
|
|||||||
return <span data-testid="activity">{activity[projectId]}</span>;
|
return <span data-testid="activity">{activity[projectId]}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MultiProjectActivityProbe({ projectIds }: { projectIds: string[] }) {
|
||||||
|
const activity = useProjectActivity(projectIds);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{projectIds.map((projectId) => (
|
||||||
|
<span key={projectId} data-testid={`activity-${projectId}`}>
|
||||||
|
{activity[projectId]}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
describe("useProjectActivity CLI interaction projection (#198)", () => {
|
describe("useProjectActivity CLI interaction projection (#198)", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
__resetProjectCliInteractionActivityForTests();
|
__resetProjectCliInteractionActivityForTests();
|
||||||
@ -45,4 +59,100 @@ describe("useProjectActivity CLI interaction projection (#198)", () => {
|
|||||||
expect(screen.getByTestId("activity").textContent).toBe("none"),
|
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(
|
||||||
|
<DIProvider gateways={{ workState, system } as unknown as Gateways}>
|
||||||
|
<ActivityProbe projectId={projectId} />
|
||||||
|
</DIProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<DIProvider gateways={{ workState, system } as unknown as Gateways}>
|
||||||
|
<MultiProjectActivityProbe projectIds={["project-a", "project-b"]} />
|
||||||
|
</DIProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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 type { ProjectWorkState } from "@/domain";
|
||||||
import { useGateways } from "@/app/di";
|
import { useGateways } from "@/app/di";
|
||||||
@ -8,6 +8,7 @@ import {
|
|||||||
} from "./projectActivity";
|
} from "./projectActivity";
|
||||||
import {
|
import {
|
||||||
hasProjectCliInteraction,
|
hasProjectCliInteraction,
|
||||||
|
reconcileProjectCliInteractionActivity,
|
||||||
useProjectCliInteractionActivityVersion,
|
useProjectCliInteractionActivityVersion,
|
||||||
} from "./projectCliInteractionActivity";
|
} from "./projectCliInteractionActivity";
|
||||||
|
|
||||||
@ -35,30 +36,56 @@ export function useProjectActivity(projectIds: string[]): ProjectActivityMap {
|
|||||||
const [states, setStates] = useState<Record<string, ProjectWorkState | null>>(
|
const [states, setStates] = useState<Record<string, ProjectWorkState | null>>(
|
||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
|
const refreshSequenceByProject = useRef<Record<string, number>>({});
|
||||||
const cliInteractionVersion = useProjectCliInteractionActivityVersion();
|
const cliInteractionVersion = useProjectCliInteractionActivityVersion();
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async (
|
||||||
if (stableProjectIds.length === 0) {
|
requestedProjectIds: string[] = stableProjectIds,
|
||||||
|
reconcileLocalActivity = false,
|
||||||
|
) => {
|
||||||
|
if (requestedProjectIds.length === 0) {
|
||||||
setStates({});
|
setStates({});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const entries = await Promise.all(
|
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 {
|
try {
|
||||||
return [
|
return [
|
||||||
projectId,
|
projectId,
|
||||||
await workState.getProjectWorkState(projectId),
|
await workState.getProjectWorkState(projectId),
|
||||||
|
sequence,
|
||||||
] as const;
|
] as const;
|
||||||
} catch {
|
} 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]);
|
}, [stableProjectIds, workState]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void refresh();
|
void refresh(stableProjectIds);
|
||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -67,7 +94,15 @@ export function useProjectActivity(projectIds: string[]): ProjectActivityMap {
|
|||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
void system
|
void system
|
||||||
.onDomainEvent((event) => {
|
.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) => {
|
.then((un) => {
|
||||||
if (cancelled) un();
|
if (cancelled) un();
|
||||||
@ -77,7 +112,7 @@ export function useProjectActivity(projectIds: string[]): ProjectActivityMap {
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
unsubscribe?.();
|
unsubscribe?.();
|
||||||
};
|
};
|
||||||
}, [refresh, system]);
|
}, [refresh, stableProjectIds, system]);
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
const next: ProjectActivityMap = {};
|
const next: ProjectActivityMap = {};
|
||||||
|
|||||||
@ -103,7 +103,7 @@ describe("WebWorkspace live surfaces (F5)", () => {
|
|||||||
);
|
);
|
||||||
// …and an `agentBusyChanged` domain event drives a live refresh.
|
// …and an `agentBusyChanged` domain event drives a live refresh.
|
||||||
act(() => {
|
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(() =>
|
await waitFor(() =>
|
||||||
|
|||||||
@ -60,6 +60,9 @@ export function useProjectWorkState(projectId: string): ProjectWorkStateViewMode
|
|||||||
let unsubscribe: (() => void) | undefined;
|
let unsubscribe: (() => void) | undefined;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
void system.onDomainEvent((event) => {
|
void system.onDomainEvent((event) => {
|
||||||
|
if (event.type === "agentBusyChanged" && event.projectId !== projectId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
event.type === "agentLaunched" ||
|
event.type === "agentLaunched" ||
|
||||||
event.type === "agentExited" ||
|
event.type === "agentExited" ||
|
||||||
|
|||||||
@ -818,6 +818,7 @@ describe("ProjectWorkStatePanel", () => {
|
|||||||
|
|
||||||
system.emit({
|
system.emit({
|
||||||
type: "agentBusyChanged",
|
type: "agentBusyChanged",
|
||||||
|
projectId: PROJECT_ID,
|
||||||
agentId: "agent-3",
|
agentId: "agent-3",
|
||||||
busy: true,
|
busy: true,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user