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:
@ -729,7 +729,12 @@ impl AppBackgroundTaskStore {
|
||||
project_id: ProjectId,
|
||||
) -> Result<Vec<BackgroundTask>, 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::<Vec<_>>();
|
||||
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`].
|
||||
|
||||
Reference in New Issue
Block a user