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:
@ -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
|
||||
|
||||
@ -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();
|
||||
|
||||
Reference in New Issue
Block a user