fix(backend,domain,infrastructure,frontend): réconcilie les états et tâches fantômes au démarrage — #307 (cause: l'activation projet balayait tous les documents background-task sous le root sans filtre project_id et LiveState::reconcile_orphans conservait correlation_id des lignes mortes, laissant après reboot des LED working fantômes et des tâches Running orphelines reprojetées par GetProjectWorkState, tandis qu'un conversationId isolé affichait indéfiniment « Conversation liée — récupération du transcript en cours. »; fix backend: FsBackgroundTaskStore::reconcile_project_boot borne la réconciliation boot au document du projet activé (API reconcile_boot historique préservée), AppBackgroundTaskStore filtre list_undelivered_completions sur project_id, et une ligne active orpheline efface désormais aussi correlation_id en plus de ticket/last_delegation; garde-fou frontend: CustomAgentChatView affiche l'état terminal « Conversation liée — transcript indisponible. » quand openOrAttach terminé n'a récupéré aucun tour, spinner borné à opening, conversationId et cellule préservés; QA verte reconfirmée par Git: rustup run stable cargo fmt --all -- --check, git diff --check, cargo test -p infrastructure --test background_task_store 12/12 dont project_reconcile_does_not_repair_or_report_foreign_project_tasks, -p domain --lib live_state 15/15, -p backend ticket307_activation_reconcile_tests 1/1 (activate_project_clears_stale_live_correlation_and_terminalizes_orphan_task) et background_tasks_b7 1/1 renforcé (orphan évicté de list_open_for_agent), -p application --test workstate 29/29, cargo check -p backend, frontend npm test CustomAgentChatView 72/72 dont garde-fou transcript indisponible, npm run typecheck; risque résiduel documenté: le garde-fou suppose que openOrAttach finit par se résoudre, pas de timeout transport ajouté; fichiers .ideai/tickets/296/*, tickets/299-307/*, counter/index, attachments/ et scripts/ hors périmètre et préservés en worktree; aucun push)

This commit is contained in:
2026-09-10 16:33:02 +02:00
parent 99b400a59e
commit 57cf825f05
6 changed files with 241 additions and 6 deletions

View File

@ -120,6 +120,31 @@ impl FsBackgroundTaskStore {
&self,
live_task_ids: &[TaskId],
now_ms: u64,
) -> Result<BackgroundTaskReconcileReport, BackgroundTaskPortError> {
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<BackgroundTaskReconcileReport, BackgroundTaskPortError> {
self.reconcile_boot_inner(Some(project_id), live_task_ids, now_ms)
.await
}
async fn reconcile_boot_inner(
&self,
only_project_id: Option<ProjectId>,
live_task_ids: &[TaskId],
now_ms: u64,
) -> Result<BackgroundTaskReconcileReport, BackgroundTaskPortError> {
let live: HashSet<TaskId> = 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

View File

@ -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();