From 57cf825f058409eebf66a0819093218f86d893e7 Mon Sep 17 00:00:00 2001 From: Blomios Date: Thu, 10 Sep 2026 16:33:02 +0200 Subject: [PATCH] =?UTF-8?q?fix(backend,domain,infrastructure,frontend):=20?= =?UTF-8?q?r=C3=A9concilie=20les=20=C3=A9tats=20et=20t=C3=A2ches=20fant?= =?UTF-8?q?=C3=B4mes=20au=20d=C3=A9marrage=20=E2=80=94=20#307=20(cause:=20?= =?UTF-8?q?l'activation=20projet=20balayait=20tous=20les=20documents=20bac?= =?UTF-8?q?kground-task=20sous=20le=20root=20sans=20filtre=20project=5Fid?= =?UTF-8?q?=20et=20LiveState::reconcile=5Forphans=20conservait=20correlati?= =?UTF-8?q?on=5Fid=20des=20lignes=20mortes,=20laissant=20apr=C3=A8s=20rebo?= =?UTF-8?q?ot=20des=20LED=20working=20fant=C3=B4mes=20et=20des=20t=C3=A2ch?= =?UTF-8?q?es=20Running=20orphelines=20reprojet=C3=A9es=20par=20GetProject?= =?UTF-8?q?WorkState,=20tandis=20qu'un=20conversationId=20isol=C3=A9=20aff?= =?UTF-8?q?ichait=20ind=C3=A9finiment=20=C2=AB=20Conversation=20li=C3=A9e?= =?UTF-8?q?=20=E2=80=94=20r=C3=A9cup=C3=A9ration=20du=20transcript=20en=20?= =?UTF-8?q?cours.=20=C2=BB;=20fix=20backend:=20FsBackgroundTaskStore::reco?= =?UTF-8?q?ncile=5Fproject=5Fboot=20borne=20la=20r=C3=A9conciliation=20boo?= =?UTF-8?q?t=20au=20document=20du=20projet=20activ=C3=A9=20(API=20reconcil?= =?UTF-8?q?e=5Fboot=20historique=20pr=C3=A9serv=C3=A9e),=20AppBackgroundTa?= =?UTF-8?q?skStore=20filtre=20list=5Fundelivered=5Fcompletions=20sur=20pro?= =?UTF-8?q?ject=5Fid,=20et=20une=20ligne=20active=20orpheline=20efface=20d?= =?UTF-8?q?=C3=A9sormais=20aussi=20correlation=5Fid=20en=20plus=20de=20tic?= =?UTF-8?q?ket/last=5Fdelegation;=20garde-fou=20frontend:=20CustomAgentCha?= =?UTF-8?q?tView=20affiche=20l'=C3=A9tat=20terminal=20=C2=AB=20Conversatio?= =?UTF-8?q?n=20li=C3=A9e=20=E2=80=94=20transcript=20indisponible.=20=C2=BB?= =?UTF-8?q?=20quand=20openOrAttach=20termin=C3=A9=20n'a=20r=C3=A9cup=C3=A9?= =?UTF-8?q?r=C3=A9=20aucun=20tour,=20spinner=20born=C3=A9=20=C3=A0=20openi?= =?UTF-8?q?ng,=20conversationId=20et=20cellule=20pr=C3=A9serv=C3=A9s;=20QA?= =?UTF-8?q?=20verte=20reconfirm=C3=A9e=20par=20Git:=20rustup=20run=20stabl?= =?UTF-8?q?e=20cargo=20fmt=20--all=20--=20--check,=20git=20diff=20--check,?= =?UTF-8?q?=20cargo=20test=20-p=20infrastructure=20--test=20background=5Ft?= =?UTF-8?q?ask=5Fstore=2012/12=20dont=20project=5Freconcile=5Fdoes=5Fnot?= =?UTF-8?q?=5Frepair=5For=5Freport=5Fforeign=5Fproject=5Ftasks,=20-p=20dom?= =?UTF-8?q?ain=20--lib=20live=5Fstate=2015/15,=20-p=20backend=20ticket307?= =?UTF-8?q?=5Factivation=5Freconcile=5Ftests=201/1=20(activate=5Fproject?= =?UTF-8?q?=5Fclears=5Fstale=5Flive=5Fcorrelation=5Fand=5Fterminalizes=5Fo?= =?UTF-8?q?rphan=5Ftask)=20et=20background=5Ftasks=5Fb7=201/1=20renforc?= =?UTF-8?q?=C3=A9=20(orphan=20=C3=A9vict=C3=A9=20de=20list=5Fopen=5Ffor=5F?= =?UTF-8?q?agent),=20-p=20application=20--test=20workstate=2029/29,=20carg?= =?UTF-8?q?o=20check=20-p=20backend,=20frontend=20npm=20test=20CustomAgent?= =?UTF-8?q?ChatView=2072/72=20dont=20garde-fou=20transcript=20indisponible?= =?UTF-8?q?,=20npm=20run=20typecheck;=20risque=20r=C3=A9siduel=20document?= =?UTF-8?q?=C3=A9:=20le=20garde-fou=20suppose=20que=20openOrAttach=20finit?= =?UTF-8?q?=20par=20se=20r=C3=A9soudre,=20pas=20de=20timeout=20transport?= =?UTF-8?q?=20ajout=C3=A9;=20fichiers=20.ideai/tickets/296/*,=20tickets/29?= =?UTF-8?q?9-307/*,=20counter/index,=20attachments/=20et=20scripts/=20hors?= =?UTF-8?q?=20p=C3=A9rim=C3=A8tre=20et=20pr=C3=A9serv=C3=A9s=20en=20worktr?= =?UTF-8?q?ee;=20aucun=20push)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/backend/src/lib.rs | 125 +++++++++++++++++- crates/domain/src/live_state.rs | 13 +- .../src/store/background_task.rs | 28 ++++ .../tests/background_task_store.rs | 28 ++++ .../agents/CustomAgentChatView.test.tsx | 51 +++++++ .../features/agents/CustomAgentChatView.tsx | 2 +- 6 files changed, 241 insertions(+), 6 deletions(-) diff --git a/crates/backend/src/lib.rs b/crates/backend/src/lib.rs index a74b3cfa..819d7cda 100644 --- a/crates/backend/src/lib.rs +++ b/crates/backend/src/lib.rs @@ -729,7 +729,12 @@ impl AppBackgroundTaskStore { project_id: ProjectId, ) -> Result, BackgroundTaskPortError> { let store = self.store_for_project(project_id).await?; - let tasks = store.list_undelivered_completions().await?; + let tasks = store + .list_undelivered_completions() + .await? + .into_iter() + .filter(|task| task.project_id == project_id) + .collect::>(); for task in &tasks { self.remember(task.id, task.project_id); } @@ -743,7 +748,9 @@ impl AppBackgroundTaskStore { now_ms: u64, ) -> Result<(), BackgroundTaskPortError> { let store = self.store_for_project(project_id).await?; - let report = store.reconcile_boot(live_task_ids, now_ms).await?; + let report = store + .reconcile_project_boot(project_id, live_task_ids, now_ms) + .await?; for task_id in report .failed_task_ids .into_iter() @@ -5878,6 +5885,10 @@ mod background_tasks_b7_tests { let orphan_after = store.get(orphan.id).await.unwrap().unwrap(); assert_eq!(orphan_after.state, BackgroundTaskState::Failed); assert!(orphan_after.has_pending_completion_delivery()); + assert!( + store.list_open_for_agent(owner).await.unwrap().is_empty(), + "activation must evict the orphan from the open-task projection" + ); assert!(matches!( orphan_after.result, Some(BackgroundTaskResult::Failure { @@ -5888,6 +5899,116 @@ mod background_tasks_b7_tests { } } +#[cfg(test)] +mod ticket307_activation_reconcile_tests { + use std::path::{Path, PathBuf}; + + use application::CreateProjectInput; + use domain::ports::LiveStateStore; + use domain::{ + AgentId, BackgroundTask, BackgroundTaskKind, BackgroundTaskState, BackgroundTaskWakePolicy, + LiveEntry, Project, ProjectPath, TaskId, WorkStatus, + }; + use infrastructure::FsLiveStateStore; + use uuid::Uuid; + + use super::BackendCore; + + fn temp_path(tag: &str) -> PathBuf { + std::env::temp_dir().join(format!("idea-ticket307-{tag}-{}", Uuid::new_v4())) + } + + async fn create_project(core: &BackendCore, root: &Path) -> Project { + core.create_project + .execute(CreateProjectInput { + name: "ticket-307".to_owned(), + root: root.to_string_lossy().into_owned(), + remote: None, + default_profile_id: None, + }) + .await + .expect("project creation succeeds") + .project + } + + #[tokio::test] + async fn activate_project_clears_stale_live_correlation_and_terminalizes_orphan_task() { + let app_data = temp_path("appdata"); + let root = temp_path("project"); + let core = BackendCore::build(app_data.clone()); + let project = create_project(&core, &root).await; + let owner = AgentId::from_uuid(Uuid::from_u128(307)); + + let live_store = + FsLiveStateStore::new(&ProjectPath::new(root.to_string_lossy().into_owned()).unwrap()); + live_store + .upsert( + LiveEntry::new( + owner, + None, + "stale startup work", + WorkStatus::Working, + None, + None, + 1_000, + ) + .unwrap() + .with_correlation_id(Some("stale-correlation".to_owned())) + .unwrap(), + ) + .await + .unwrap(); + + let task = BackgroundTask::new( + TaskId::from_uuid(Uuid::from_u128(308)), + project.id, + owner, + BackgroundTaskKind::Command { + label: "stale startup task".to_owned(), + }, + BackgroundTaskWakePolicy::RecordOnly, + 1_000, + None, + ) + .unwrap() + .transition(BackgroundTaskState::Running, 1_001) + .unwrap(); + core.background_task_store.create(&task).await.unwrap(); + + core.activate_project(&project).await; + + let state = live_store.load().await.unwrap(); + let row = state + .entries + .iter() + .find(|entry| entry.agent_id == owner) + .unwrap(); + assert_eq!(row.status, WorkStatus::Idle); + assert_eq!(row.correlation_id, None); + assert_eq!(row.ticket, None); + assert_eq!(row.last_delegation, None); + + let task_after = core + .background_task_store + .get(task.id) + .await + .unwrap() + .unwrap(); + assert_eq!(task_after.state, BackgroundTaskState::Failed); + assert!( + core.background_task_store + .list_open_for_agent(owner) + .await + .unwrap() + .is_empty(), + "GetProjectWorkState's open-task source must not see the orphan after activation" + ); + + let _ = std::fs::remove_dir_all(app_data); + let _ = std::fs::remove_dir_all(root); + } +} + #[cfg(test)] mod mcp_serve_peer_tests { //! M5c — server side of the bind transport: [`serve_peer`] + [`parse_handshake`]. diff --git a/crates/domain/src/live_state.rs b/crates/domain/src/live_state.rs index 92f485af..b9f8d0a8 100644 --- a/crates/domain/src/live_state.rs +++ b/crates/domain/src/live_state.rs @@ -226,8 +226,9 @@ impl LiveState { /// "ghost" rows for agents whose sessions are dead. This pure transform returns /// the **rewritten** rows to upsert (it does **not** mutate `self`): each orphan /// is downgraded to [`WorkStatus::Idle`], keeping its `intent` (trace), clearing - /// the now-dead `ticket` and `last_delegation`, stamping a `progress` stale - /// marker and a fresh `updated_at_ms = now_ms` (last-writer-wins). + /// the now-dead `ticket`, `last_delegation` and `correlation_id`, stamping a + /// `progress` stale marker and a fresh `updated_at_ms = now_ms` + /// (last-writer-wins). /// /// Rows that are already `Idle`/`Done`, or whose agent is still live, are /// **never** touched and are absent from the result. The caller persists each @@ -253,7 +254,10 @@ impl LiveState { status: WorkStatus::Idle, progress: Some(STALE_AT_RESTART_MARKER.to_owned()), last_delegation: None, - correlation_id: e.correlation_id.clone(), + // A correlation identifies an active cross-project request. If + // the owning session is gone, retaining it would keep exposing a + // relationship that no runtime can resume or cancel. + correlation_id: None, updated_at_ms: now_ms, }) .collect() @@ -451,6 +455,8 @@ mod tests { Some(tid(8)), 100, ) + .unwrap() + .with_correlation_id(Some("cross-project-request".to_owned())) .unwrap(), ); @@ -468,6 +474,7 @@ mod tests { assert_eq!(row.intent, "implementing lot 2", "intent kept as trace"); assert_eq!(row.ticket, None, "dead ticket cleared"); assert_eq!(row.last_delegation, None, "dead delegation cleared"); + assert_eq!(row.correlation_id, None, "dead correlation cleared"); assert_eq!(row.progress.as_deref(), Some(STALE_AT_RESTART_MARKER)); assert_eq!(row.updated_at_ms, 999, "stamped with the fresh now"); } diff --git a/crates/infrastructure/src/store/background_task.rs b/crates/infrastructure/src/store/background_task.rs index da7fd659..09d739f0 100644 --- a/crates/infrastructure/src/store/background_task.rs +++ b/crates/infrastructure/src/store/background_task.rs @@ -120,6 +120,31 @@ impl FsBackgroundTaskStore { &self, live_task_ids: &[TaskId], now_ms: u64, + ) -> Result { + self.reconcile_boot_inner(None, live_task_ids, now_ms).await + } + + /// Reconciles only the document owned by `project_id`. + /// + /// The filesystem adapter is normally rooted at one project, but documents + /// remain keyed by project id. Activation must therefore stay explicitly + /// project-scoped and must not repair or report a foreign/stale document that + /// happens to exist below the same root. + pub async fn reconcile_project_boot( + &self, + project_id: ProjectId, + live_task_ids: &[TaskId], + now_ms: u64, + ) -> Result { + self.reconcile_boot_inner(Some(project_id), live_task_ids, now_ms) + .await + } + + async fn reconcile_boot_inner( + &self, + only_project_id: Option, + live_task_ids: &[TaskId], + now_ms: u64, ) -> Result { let live: HashSet = live_task_ids.iter().copied().collect(); let mut state = self.read_all_docs().await?; @@ -127,6 +152,9 @@ impl FsBackgroundTaskStore { let mut changed_projects = BTreeSet::new(); for (project_id, doc) in &mut state.docs { + if only_project_id.is_some_and(|expected| expected != *project_id) { + continue; + } for task in &mut doc.tasks { match task.state { BackgroundTaskState::Running diff --git a/crates/infrastructure/tests/background_task_store.rs b/crates/infrastructure/tests/background_task_store.rs index aa207e11..b7e4cd96 100644 --- a/crates/infrastructure/tests/background_task_store.rs +++ b/crates/infrastructure/tests/background_task_store.rs @@ -381,6 +381,34 @@ async fn reconcile_marks_running_without_live_handle_failed_and_pending() { ); } +#[tokio::test] +async fn project_reconcile_does_not_repair_or_report_foreign_project_tasks() { + let tmp = TempDir::new(); + let store = FsBackgroundTaskStore::new(&tmp.project_path()); + let owned = running_task(1, 10, 100, 1_000); + let foreign = running_task(2, 20, 100, 1_000); + + store.create(&owned).await.unwrap(); + store.create(&foreign).await.unwrap(); + + let report = store + .reconcile_project_boot(owned.project_id, &[], 2_000) + .await + .unwrap(); + + assert_eq!(report.failed_task_ids, vec![owned.id]); + assert_eq!(report.delivery_pending_task_ids, vec![owned.id]); + assert_eq!( + store.get(owned.id).await.unwrap().unwrap().state, + BackgroundTaskState::Failed + ); + assert_eq!( + store.get(foreign.id).await.unwrap().unwrap().state, + BackgroundTaskState::Running, + "activating one project must not mutate another project's persisted task" + ); +} + #[tokio::test] async fn reconcile_marks_cancelling_without_live_handle_cancel_failed_and_pending() { let tmp = TempDir::new(); diff --git a/frontend/src/features/agents/CustomAgentChatView.test.tsx b/frontend/src/features/agents/CustomAgentChatView.test.tsx index d394771a..4f5abd6f 100644 --- a/frontend/src/features/agents/CustomAgentChatView.test.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.test.tsx @@ -197,6 +197,57 @@ describe("CustomAgentChatView", () => { __resetProjectCliInteractionActivityForTests(); }); + it("settles an isolated conversation id without recovered turns as unavailable", async () => { + const agent = { + launchAgentChat: vi.fn(async () => ({ sessionId: "fresh-session" })), + reattachAgentChat: vi.fn(async (sessionId: string) => ({ + sessionId, + scrollback: [], + })), + sendAgentChat: vi.fn(async () => {}), + cancelAgentChat: vi.fn(async () => {}), + closeAgentChat: vi.fn(async () => {}), + }; + + render( + null) }, + } as unknown as Gateways} + > + + , + ); + + await waitFor(() => + expect(agent.launchAgentChat).toHaveBeenCalledWith( + "project-1", + "agent-1", + expect.objectContaining({ conversationId: "orphan-conversation" }), + ), + ); + expect( + await screen.findByText("Conversation liée — transcript indisponible."), + ).toBeTruthy(); + expect( + screen.queryByText("Conversation liée — récupération du transcript en cours."), + ).toBeNull(); + expect(screen.queryByRole("progressbar")).toBeNull(); + expect(screen.queryByRole("button", { name: "Réessayer" })).toBeNull(); + }); + it("projects self-restart lifecycle separators in the custom CLI only once", async () => { const system = new MockSystemGateway(); const agent = { diff --git a/frontend/src/features/agents/CustomAgentChatView.tsx b/frontend/src/features/agents/CustomAgentChatView.tsx index 7463c10a..d26e2c3d 100644 --- a/frontend/src/features/agents/CustomAgentChatView.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.tsx @@ -2504,7 +2504,7 @@ export function CustomAgentChatView({ ) : displayTurns.length === 0 && hasLinkedConversation ? (

- Conversation liée — récupération du transcript en cours. + Conversation liée — transcript indisponible.

) : displayTurns.length === 0 ? (