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