Files
IdeA/crates/infrastructure/tests/background_task_store.rs
Blomios c1267bf880 fix(infrastructure): corrige le background task store utilisé par idea_ask_agents
Le store de tâches d'arrière-plan produisait un état incohérent lors de la
délégation multi-agent via idea_ask_agents. Tests ciblés ajoutés/étendus,
suite verte.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 12:26:33 +02:00

358 lines
11 KiB
Rust

//! B2 integration tests for [`FsBackgroundTaskStore`].
use std::path::PathBuf;
use std::sync::Arc;
use domain::{
AgentId, BackgroundTask, BackgroundTaskKind, BackgroundTaskResult, BackgroundTaskState,
BackgroundTaskStore, BackgroundTaskWakePolicy, ProjectId, ProjectPath, TaskId,
};
use infrastructure::FsBackgroundTaskStore;
use uuid::Uuid;
struct TempDir(PathBuf);
impl TempDir {
fn new() -> Self {
let path = std::env::temp_dir().join(format!("idea-b2-bg-tasks-{}", Uuid::new_v4()));
std::fs::create_dir_all(&path).unwrap();
Self(path)
}
fn project_path(&self) -> ProjectPath {
ProjectPath::new(self.0.to_string_lossy().into_owned()).unwrap()
}
fn task_dir(&self) -> PathBuf {
self.0.join(".ideai").join("background-tasks")
}
fn task_file(&self, project_id: ProjectId) -> PathBuf {
self.task_dir().join(format!("{project_id}.json"))
}
fn task_tmp_file(&self, project_id: ProjectId) -> PathBuf {
self.task_dir().join(format!("{project_id}.json.tmp"))
}
fn tmp_files(&self) -> Vec<PathBuf> {
let Ok(entries) = std::fs::read_dir(self.task_dir()) else {
return Vec::new();
};
entries
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("tmp"))
.collect()
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn task_id(n: u128) -> TaskId {
TaskId::from_uuid(Uuid::from_u128(n))
}
fn project_id(n: u128) -> ProjectId {
ProjectId::from_uuid(Uuid::from_u128(n))
}
fn agent_id(n: u128) -> AgentId {
AgentId::from_uuid(Uuid::from_u128(n))
}
fn queued_task(id: u128, project: u128, owner: u128, now_ms: u64) -> BackgroundTask {
BackgroundTask::new(
task_id(id),
project_id(project),
agent_id(owner),
BackgroundTaskKind::Command {
label: format!("task-{id}"),
},
BackgroundTaskWakePolicy::WakeOwner,
now_ms,
None,
)
.unwrap()
}
fn running_task(id: u128, project: u128, owner: u128, now_ms: u64) -> BackgroundTask {
queued_task(id, project, owner, now_ms)
.transition(BackgroundTaskState::Running, now_ms + 1)
.unwrap()
}
fn completed_task(id: u128, project: u128, owner: u128, now_ms: u64) -> BackgroundTask {
running_task(id, project, owner, now_ms)
.complete(BackgroundTaskResult::Success {
finished_at_ms: now_ms + 2,
exit_code: Some(0),
summary: "ok".into(),
stdout_tail: Some("done".into()),
stderr_tail: None,
})
.unwrap()
}
#[tokio::test]
async fn create_get_save_round_trips_across_fresh_store() {
let tmp = TempDir::new();
let store = FsBackgroundTaskStore::new(&tmp.project_path());
let task = queued_task(1, 10, 100, 1_000);
store.create(&task).await.unwrap();
assert_eq!(store.get(task.id).await.unwrap(), Some(task.clone()));
let running = task
.transition(BackgroundTaskState::Running, 1_010)
.unwrap();
store.save(&running).await.unwrap();
let reborn = FsBackgroundTaskStore::new(&tmp.project_path());
assert_eq!(reborn.get(task.id).await.unwrap(), Some(running));
assert!(tmp.task_file(project_id(10)).exists());
assert!(!tmp.task_tmp_file(project_id(10)).exists());
let raw = std::fs::read_to_string(tmp.task_file(project_id(10))).unwrap();
assert!(raw.contains("\"projectId\""), "camelCase doc: {raw}");
assert!(raw.contains("\"ownerAgentId\""), "camelCase task: {raw}");
}
#[tokio::test]
async fn create_rejects_duplicate_task_id() {
let tmp = TempDir::new();
let store = FsBackgroundTaskStore::new(&tmp.project_path());
let task = queued_task(1, 10, 100, 1_000);
store.create(&task).await.unwrap();
let err = store.create(&task).await.unwrap_err();
assert_eq!(err, domain::BackgroundTaskPortError::AlreadyExists);
}
#[tokio::test]
async fn list_open_for_agent_uses_registry_and_filters_terminal_tasks() {
let tmp = TempDir::new();
let store = FsBackgroundTaskStore::new(&tmp.project_path());
let open_a = queued_task(1, 10, 100, 1_000);
let open_b = running_task(2, 10, 200, 1_000);
let done_a = completed_task(3, 10, 100, 1_000);
store.create(&open_a).await.unwrap();
store.create(&open_b).await.unwrap();
store.create(&done_a).await.unwrap();
assert_eq!(
store.registry_open_task_ids_for_agent(agent_id(100)).await,
vec![open_a.id]
);
let listed = store.list_open_for_agent(agent_id(100)).await.unwrap();
assert_eq!(listed, vec![open_a]);
assert_eq!(
store.list_open_for_agent(agent_id(200)).await.unwrap(),
vec![open_b]
);
}
#[tokio::test]
async fn list_undelivered_completions_and_mark_are_idempotent() {
let tmp = TempDir::new();
let store = FsBackgroundTaskStore::new(&tmp.project_path());
let task = completed_task(1, 10, 100, 1_000);
store.create(&task).await.unwrap();
assert_eq!(
store.list_undelivered_completions().await.unwrap(),
vec![task.clone()]
);
store.mark_completion_delivered(task.id).await.unwrap();
store.mark_completion_delivered(task.id).await.unwrap();
let delivered = store.get(task.id).await.unwrap().unwrap();
assert!(delivered.completion_delivered);
assert!(store
.list_undelivered_completions()
.await
.unwrap()
.is_empty());
assert!(store
.list_open_for_agent(agent_id(100))
.await
.unwrap()
.is_empty());
}
#[tokio::test]
async fn persistence_is_segmented_by_project_id() {
let tmp = TempDir::new();
let store = FsBackgroundTaskStore::new(&tmp.project_path());
let p1 = queued_task(1, 10, 100, 1_000);
let p2 = queued_task(2, 20, 100, 1_000);
store.create(&p1).await.unwrap();
store.create(&p2).await.unwrap();
assert!(tmp.task_file(project_id(10)).exists());
assert!(tmp.task_file(project_id(20)).exists());
assert_ne!(tmp.task_file(project_id(10)), tmp.task_file(project_id(20)));
let raw_p1 = std::fs::read_to_string(tmp.task_file(project_id(10))).unwrap();
let raw_p2 = std::fs::read_to_string(tmp.task_file(project_id(20))).unwrap();
assert!(raw_p1.contains(&p1.id.to_string()));
assert!(!raw_p1.contains(&p2.id.to_string()));
assert!(raw_p2.contains(&p2.id.to_string()));
assert!(!raw_p2.contains(&p1.id.to_string()));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 8)]
async fn concurrent_creates_for_same_project_do_not_lose_tasks() {
let tmp = TempDir::new();
let store = Arc::new(FsBackgroundTaskStore::new(&tmp.project_path()));
let project = project_id(10);
let owner = agent_id(100);
let mut handles = Vec::new();
for n in 1..=64 {
let store = Arc::clone(&store);
handles.push(tokio::spawn(async move {
let task = queued_task(n, 10, 100, 1_000 + n as u64);
store.create(&task).await.map(|()| task.id)
}));
}
let mut expected = Vec::new();
for handle in handles {
expected.push(handle.await.unwrap().unwrap());
}
expected.sort();
let reborn = FsBackgroundTaskStore::new(&tmp.project_path());
for id in &expected {
assert!(
reborn.get(*id).await.unwrap().is_some(),
"created task {id} must be durable"
);
}
assert_eq!(
store.registry_open_task_ids_for_agent(owner).await,
expected,
"registry must retain every concurrent create"
);
assert!(tmp.tmp_files().is_empty(), "tmp files left behind");
assert!(tmp.task_file(project).exists());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 8)]
async fn concurrent_saves_for_same_project_do_not_overwrite_each_other() {
let tmp = TempDir::new();
let store = Arc::new(FsBackgroundTaskStore::new(&tmp.project_path()));
for n in 1..=64 {
store
.create(&running_task(n, 10, 100, 1_000))
.await
.unwrap();
}
let mut handles = Vec::new();
for n in 1..=64 {
let store = Arc::clone(&store);
handles.push(tokio::spawn(async move {
let task = completed_task(n, 10, 100, 2_000 + n as u64);
store.save(&task).await.map(|()| task.id)
}));
}
let mut expected = Vec::new();
for handle in handles {
expected.push(handle.await.unwrap().unwrap());
}
expected.sort();
let reborn = FsBackgroundTaskStore::new(&tmp.project_path());
for id in &expected {
let task = reborn.get(*id).await.unwrap().unwrap();
assert_eq!(task.state, BackgroundTaskState::Completed);
}
assert!(
reborn
.list_open_for_agent(agent_id(100))
.await
.unwrap()
.is_empty(),
"all concurrently saved tasks must be terminal"
);
assert_eq!(
reborn.list_undelivered_completions().await.unwrap().len(),
expected.len()
);
assert!(tmp.tmp_files().is_empty(), "tmp files left behind");
}
#[tokio::test]
async fn reconcile_marks_running_without_live_handle_failed_and_pending() {
let tmp = TempDir::new();
let store = FsBackgroundTaskStore::new(&tmp.project_path());
let lost = running_task(1, 10, 100, 1_000);
let still_live = running_task(2, 10, 100, 1_000);
let already_pending = completed_task(3, 10, 100, 1_000);
store.create(&lost).await.unwrap();
store.create(&still_live).await.unwrap();
store.create(&already_pending).await.unwrap();
let report = store.reconcile_boot(&[still_live.id], 2_000).await.unwrap();
assert_eq!(report.failed_task_ids, vec![lost.id]);
assert_eq!(
report.delivery_pending_task_ids,
vec![lost.id, already_pending.id]
);
let lost_after = store.get(lost.id).await.unwrap().unwrap();
assert_eq!(lost_after.state, BackgroundTaskState::Failed);
assert!(lost_after.has_pending_completion_delivery());
assert!(matches!(
lost_after.result,
Some(BackgroundTaskResult::Failure {
finished_at_ms: 2_000,
exit_code: None,
..
})
));
let live_after = store.get(still_live.id).await.unwrap().unwrap();
assert_eq!(live_after.state, BackgroundTaskState::Running);
assert_eq!(
store.registry_open_task_ids_for_agent(agent_id(100)).await,
vec![still_live.id]
);
}
#[tokio::test]
async fn reconcile_uses_monotonic_timestamp_when_clock_is_behind() {
let tmp = TempDir::new();
let store = FsBackgroundTaskStore::new(&tmp.project_path());
let lost = running_task(1, 10, 100, 1_000);
store.create(&lost).await.unwrap();
store.reconcile_boot(&[], 900).await.unwrap();
let after = store.get(lost.id).await.unwrap().unwrap();
assert!(matches!(
after.result,
Some(BackgroundTaskResult::Failure {
finished_at_ms: 1_001,
..
})
));
}