feat(infrastructure): store, sink de complétion et mailbox des tâches de fond (B2-B4)

Adapters de persistance des BackgroundTask (store), sink de récupération
de complétion post-tour et boîte de réception (mailbox) pour les messages
concurrents, câblés sur l'entrée. Couvert par background_task_store,
background_completion_sink, agent_inbox et orchestrator_watcher (verts).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 15:47:28 +02:00
parent f4a55e9988
commit c537da54ef
9 changed files with 1889 additions and 67 deletions

View File

@ -0,0 +1,212 @@
//! B4 tests for the bounded AgentInbox facade over MediatedInbox.
use std::sync::Arc;
use std::time::Duration;
use domain::{
AgentId, AgentInbox, InboxError, InboxItem, InboxItemKind, InboxReceiptStatus, InboxSource,
InputMediator, Ticket, TicketId,
};
use infrastructure::{
start_background_ready_inbox_bridge, BackgroundTaskReadyToDeliver, InMemoryMailbox,
MediatedInbox, MillisClock,
};
use tokio::sync::mpsc::unbounded_channel;
use uuid::Uuid;
struct FixedClock(u64);
impl MillisClock for FixedClock {
fn now_ms(&self) -> u64 {
self.0
}
}
fn agent(n: u128) -> AgentId {
AgentId::from_uuid(Uuid::from_u128(n))
}
fn ticket_id(n: u128) -> TicketId {
TicketId::from_uuid(Uuid::from_u128(n))
}
fn task_id(n: u128) -> domain::TaskId {
domain::TaskId::from_uuid(Uuid::from_u128(n))
}
fn inbox(capacity: usize) -> MediatedInbox {
MediatedInbox::new(
Arc::new(InMemoryMailbox::new()),
Arc::new(FixedClock(1_000)),
)
.with_inbox_capacity(capacity)
}
fn user_item(id: u128, target: AgentId, body: &str) -> InboxItem {
InboxItem {
id: ticket_id(id),
agent_id: target,
source: InboxSource::Human,
kind: InboxItemKind::UserMessage,
body: body.into(),
created_at_ms: 1_000,
correlation_id: None,
}
}
fn completion_item(id: u128, target: AgentId, task: u128) -> InboxItem {
InboxItem {
id: ticket_id(id),
agent_id: target,
source: InboxSource::BackgroundTask {
task_id: task_id(task),
},
kind: InboxItemKind::BackgroundCompletion,
body: "done".into(),
created_at_ms: 1_000,
correlation_id: Some(task_id(task).to_string()),
}
}
fn make_busy(inbox: &MediatedInbox, target: AgentId) {
let ticket = Ticket::new(ticket_id(900), "active", "active turn");
let _pending = inbox.enqueue(target, ticket);
}
#[test]
fn enqueue_message_while_busy_is_queued_not_rejected() {
let inbox = inbox(10);
let target = agent(1);
make_busy(&inbox, target);
let receipt = inbox
.enqueue_message(target, user_item(1, target, "queued"))
.unwrap();
assert_eq!(receipt.status, InboxReceiptStatus::Queued);
assert_eq!(receipt.depth, 2);
let snapshot = inbox.snapshot(target);
assert_eq!(snapshot.depth, 2);
assert_eq!(snapshot.items.len(), 1);
assert_eq!(snapshot.items[0].body, "queued");
}
#[test]
fn inbox_fifo_drains_two_entrants_in_order() {
let inbox = inbox(10);
let target = agent(1);
let first = user_item(1, target, "first");
let second = user_item(2, target, "second");
inbox.enqueue_message(target, first.clone()).unwrap();
inbox.enqueue_message(target, second.clone()).unwrap();
assert_eq!(inbox.dequeue_next(target), Some(first));
assert_eq!(inbox.dequeue_next(target), Some(second));
assert_eq!(inbox.dequeue_next(target), None);
}
#[test]
fn overflow_normal_message_returns_inbox_full() {
let inbox = inbox(1);
let target = agent(1);
make_busy(&inbox, target);
let err = inbox
.enqueue_message(target, user_item(1, target, "overflow"))
.unwrap_err();
assert_eq!(
err,
InboxError::InboxFull {
agent_id: target,
capacity: 1,
}
);
}
#[test]
fn overflow_completion_is_deferred_not_lost_or_enqueued() {
let inbox = inbox(1);
let target = agent(1);
make_busy(&inbox, target);
let receipt = inbox
.enqueue_message(target, completion_item(1, target, 42))
.unwrap();
assert_eq!(receipt.status, InboxReceiptStatus::Deferred);
assert_eq!(receipt.depth, 1);
assert!(inbox.snapshot(target).items.is_empty());
}
#[test]
fn snapshot_exposes_queue_depth_and_items() {
let inbox = inbox(10);
let target = agent(1);
inbox
.enqueue_message(target, user_item(1, target, "first"))
.unwrap();
inbox
.enqueue_message(target, user_item(2, target, "second"))
.unwrap();
let snapshot = inbox.snapshot(target);
assert_eq!(snapshot.agent_id, target);
assert_eq!(snapshot.depth, 2);
assert_eq!(
snapshot
.items
.iter()
.map(|item| item.body.as_str())
.collect::<Vec<_>>(),
vec!["first", "second"]
);
}
#[test]
fn drain_removes_only_one_item_at_a_time() {
let inbox = inbox(10);
let target = agent(1);
inbox
.enqueue_message(target, user_item(1, target, "first"))
.unwrap();
inbox
.enqueue_message(target, user_item(2, target, "second"))
.unwrap();
assert_eq!(inbox.dequeue_next(target).unwrap().body, "first");
let snapshot = inbox.snapshot(target);
assert_eq!(snapshot.depth, 1);
assert_eq!(snapshot.items[0].body, "second");
}
#[tokio::test]
async fn ready_bridge_enqueues_background_completion_item() {
let inbox = Arc::new(inbox(10));
let target = agent(1);
let (tx, rx) = unbounded_channel();
let handle = start_background_ready_inbox_bridge(rx, inbox.clone() as Arc<dyn AgentInbox>);
tx.send(BackgroundTaskReadyToDeliver {
task_id: task_id(42),
project_id: domain::ProjectId::from_uuid(Uuid::from_u128(7)),
owner_agent_id: target,
})
.unwrap();
tokio::time::sleep(Duration::from_millis(20)).await;
let snapshot = inbox.snapshot(target);
assert_eq!(snapshot.depth, 1);
assert_eq!(snapshot.items[0].kind, InboxItemKind::BackgroundCompletion);
assert_eq!(
snapshot.items[0].source,
InboxSource::BackgroundTask {
task_id: task_id(42)
}
);
drop(tx);
handle.await.unwrap();
}

View File

@ -0,0 +1,368 @@
//! B3 tests for the background completion sink.
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::{mpsc, Arc, Mutex};
use async_trait::async_trait;
use domain::ports::{
BackgroundCompletionStream, BackgroundTaskCompletion, BackgroundTaskHandle,
BackgroundTaskRunner, BackgroundTaskSpec,
};
use domain::{
AgentId, BackgroundTask, BackgroundTaskKind, BackgroundTaskPortError, BackgroundTaskResult,
BackgroundTaskState, BackgroundTaskStore, BackgroundTaskWakePolicy, ProjectId, ProjectPath,
TaskId,
};
use infrastructure::{
BackgroundCompletionSink, BackgroundCompletionSinkError, BackgroundCompletionSinkOutcome,
FsBackgroundTaskStore,
};
use tokio::sync::mpsc::unbounded_channel;
use uuid::Uuid;
struct TempDir(PathBuf);
impl TempDir {
fn new() -> Self {
let path = std::env::temp_dir().join(format!("idea-b3-completion-sink-{}", 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()
}
}
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 running_task(id: u128) -> BackgroundTask {
BackgroundTask::new(
task_id(id),
project_id(10),
agent_id(100),
BackgroundTaskKind::Command {
label: format!("task-{id}"),
},
BackgroundTaskWakePolicy::WakeOwner,
1_000,
None,
)
.unwrap()
.transition(BackgroundTaskState::Running, 1_010)
.unwrap()
}
fn success_completion(id: u128) -> BackgroundTaskCompletion {
BackgroundTaskCompletion {
task_id: task_id(id),
result: BackgroundTaskResult::Success {
finished_at_ms: 1_100,
exit_code: Some(0),
summary: "ok".into(),
stdout_tail: Some("done".into()),
stderr_tail: None,
},
}
}
#[tokio::test]
async fn runner_stream_completion_is_persisted_then_signalled() {
let tmp = TempDir::new();
let store = Arc::new(FsBackgroundTaskStore::new(&tmp.project_path()));
let task = running_task(1);
store.create(&task).await.unwrap();
let (ready_tx, mut ready_rx) = unbounded_channel();
let sink = Arc::new(BackgroundCompletionSink::new(store.clone(), ready_tx));
let (completion_tx, completion_rx) = mpsc::channel();
let runner = Arc::new(FakeRunner::new(completion_rx));
let handle = sink.start_from_runner(runner);
completion_tx.send(success_completion(1)).unwrap();
drop(completion_tx);
let ready = tokio::time::timeout(std::time::Duration::from_secs(2), ready_rx.recv())
.await
.unwrap()
.unwrap();
assert_eq!(ready.task_id, task.id);
let persisted = store.get(task.id).await.unwrap().unwrap();
assert_eq!(persisted.state, BackgroundTaskState::Completed);
assert!(persisted.has_pending_completion_delivery());
handle.await.unwrap();
}
#[tokio::test]
async fn persistence_happens_before_ready_signal() {
let task = running_task(1);
let store = Arc::new(RecordingStore::with_task(task.clone()));
let order = store.order.clone();
let (ready_tx, mut ready_rx) = unbounded_channel();
let sink = BackgroundCompletionSink::new(store, ready_tx);
let receiver_order = order.clone();
let receiver = tokio::spawn(async move {
let _ = ready_rx.recv().await;
receiver_order.lock().unwrap().push("ready");
});
let outcome = sink
.process_completion(success_completion(1))
.await
.unwrap();
assert!(matches!(
outcome,
BackgroundCompletionSinkOutcome::PersistedAndSignalled(_)
));
receiver.await.unwrap();
assert_eq!(*order.lock().unwrap(), vec!["save", "ready"]);
}
#[tokio::test]
async fn duplicate_completion_same_task_signals_once() {
let tmp = TempDir::new();
let store = Arc::new(FsBackgroundTaskStore::new(&tmp.project_path()));
let task = running_task(1);
store.create(&task).await.unwrap();
let (ready_tx, mut ready_rx) = unbounded_channel();
let sink = BackgroundCompletionSink::new(store.clone(), ready_tx);
let first = sink
.process_completion(success_completion(1))
.await
.unwrap();
let second = sink
.process_completion(success_completion(1))
.await
.unwrap();
assert!(matches!(
first,
BackgroundCompletionSinkOutcome::PersistedAndSignalled(_)
));
assert_eq!(
second,
BackgroundCompletionSinkOutcome::IgnoredDuplicate { task_id: task.id }
);
assert!(ready_rx.recv().await.is_some());
assert!(ready_rx.try_recv().is_err());
let persisted = store.get(task.id).await.unwrap().unwrap();
assert_eq!(persisted.state, BackgroundTaskState::Completed);
assert!(persisted.has_pending_completion_delivery());
}
#[tokio::test]
async fn completion_for_already_terminal_task_is_ignored() {
let tmp = TempDir::new();
let store = Arc::new(FsBackgroundTaskStore::new(&tmp.project_path()));
let completed = running_task(1)
.complete(success_completion(1).result)
.unwrap();
store.create(&completed).await.unwrap();
let (ready_tx, mut ready_rx) = unbounded_channel();
let sink = BackgroundCompletionSink::new(store.clone(), ready_tx);
let outcome = sink
.process_completion(success_completion(1))
.await
.unwrap();
assert_eq!(
outcome,
BackgroundCompletionSinkOutcome::IgnoredAlreadyTerminal {
task_id: completed.id
}
);
assert!(ready_rx.try_recv().is_err());
assert_eq!(store.get(completed.id).await.unwrap(), Some(completed));
}
#[tokio::test]
async fn failed_persistence_does_not_signal_ready() {
let task = running_task(1);
let store = Arc::new(RecordingStore::with_task(task.clone()).failing_save());
let (ready_tx, mut ready_rx) = unbounded_channel();
let sink = BackgroundCompletionSink::new(store, ready_tx);
let err = sink
.process_completion(success_completion(1))
.await
.unwrap_err();
assert_eq!(
err,
BackgroundCompletionSinkError::Store(BackgroundTaskPortError::Store("boom".into()))
);
assert!(ready_rx.try_recv().is_err());
}
#[tokio::test]
async fn failed_persistence_releases_task_for_later_retry() {
let task = running_task(1);
let store = Arc::new(RecordingStore::with_task(task.clone()).fail_first_save());
let (ready_tx, mut ready_rx) = unbounded_channel();
let sink = BackgroundCompletionSink::new(store, ready_tx);
assert!(sink
.process_completion(success_completion(1))
.await
.is_err());
let second = sink
.process_completion(success_completion(1))
.await
.unwrap();
assert!(matches!(
second,
BackgroundCompletionSinkOutcome::PersistedAndSignalled(_)
));
assert!(ready_rx.recv().await.is_some());
}
struct FakeRunner {
rx: Mutex<Option<mpsc::Receiver<BackgroundTaskCompletion>>>,
}
impl FakeRunner {
fn new(rx: mpsc::Receiver<BackgroundTaskCompletion>) -> Self {
Self {
rx: Mutex::new(Some(rx)),
}
}
}
#[async_trait]
impl BackgroundTaskRunner for FakeRunner {
async fn spawn(
&self,
_spec: BackgroundTaskSpec,
) -> Result<BackgroundTaskHandle, BackgroundTaskPortError> {
Err(BackgroundTaskPortError::Runner("not implemented".into()))
}
async fn cancel(&self, _task_id: TaskId) -> Result<(), BackgroundTaskPortError> {
Err(BackgroundTaskPortError::Runner("not implemented".into()))
}
fn subscribe_completions(&self) -> BackgroundCompletionStream {
let rx = self.rx.lock().unwrap().take().unwrap();
Box::new(rx.into_iter())
}
}
struct RecordingStore {
tasks: Mutex<HashMap<TaskId, BackgroundTask>>,
order: Arc<Mutex<Vec<&'static str>>>,
fail_saves_remaining: Mutex<usize>,
}
impl RecordingStore {
fn with_task(task: BackgroundTask) -> Self {
Self {
tasks: Mutex::new(HashMap::from([(task.id, task)])),
order: Arc::new(Mutex::new(Vec::new())),
fail_saves_remaining: Mutex::new(0),
}
}
fn failing_save(self) -> Self {
*self.fail_saves_remaining.lock().unwrap() = usize::MAX;
self
}
fn fail_first_save(self) -> Self {
*self.fail_saves_remaining.lock().unwrap() = 1;
self
}
}
#[async_trait]
impl BackgroundTaskStore for RecordingStore {
async fn create(&self, task: &BackgroundTask) -> Result<(), BackgroundTaskPortError> {
self.tasks.lock().unwrap().insert(task.id, task.clone());
Ok(())
}
async fn get(&self, id: TaskId) -> Result<Option<BackgroundTask>, BackgroundTaskPortError> {
Ok(self.tasks.lock().unwrap().get(&id).cloned())
}
async fn save(&self, task: &BackgroundTask) -> Result<(), BackgroundTaskPortError> {
let mut failures = self.fail_saves_remaining.lock().unwrap();
if *failures > 0 {
if *failures != usize::MAX {
*failures -= 1;
}
return Err(BackgroundTaskPortError::Store("boom".into()));
}
drop(failures);
self.tasks.lock().unwrap().insert(task.id, task.clone());
self.order.lock().unwrap().push("save");
Ok(())
}
async fn list_open_for_agent(
&self,
agent_id: AgentId,
) -> Result<Vec<BackgroundTask>, BackgroundTaskPortError> {
Ok(self
.tasks
.lock()
.unwrap()
.values()
.filter(|task| task.owner_agent_id == agent_id && !task.is_terminal())
.cloned()
.collect())
}
async fn list_undelivered_completions(
&self,
) -> Result<Vec<BackgroundTask>, BackgroundTaskPortError> {
Ok(self
.tasks
.lock()
.unwrap()
.values()
.filter(|task| task.has_pending_completion_delivery())
.cloned()
.collect())
}
async fn mark_completion_delivered(
&self,
task_id: TaskId,
) -> Result<(), BackgroundTaskPortError> {
let mut tasks = self.tasks.lock().unwrap();
let task = tasks
.get(&task_id)
.cloned()
.ok_or(BackgroundTaskPortError::NotFound)?;
tasks.insert(
task_id,
task.mark_completion_delivered()
.map_err(|e| BackgroundTaskPortError::Invalid(e.to_string()))?,
);
Ok(())
}
}

View File

@ -0,0 +1,258 @@
//! B2 integration tests for [`FsBackgroundTaskStore`].
use std::path::PathBuf;
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"))
}
}
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]
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,
..
})
));
}

View File

@ -16,15 +16,15 @@ use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use domain::agent::{AgentManifest, ManifestEntry};
use domain::events::{DomainEvent, OrchestrationSource};
use domain::ids::NodeId;
use domain::ids::SkillId;
use domain::ids::{AgentId, ProfileId, ProjectId};
use domain::markdown::MarkdownDoc;
use domain::ports::{
AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream,
ExitStatus, FileSystem, FsError, IdGenerator, OutputStream, PreparedContext, ProfileStore,
PtyError, PtyHandle, PtyPort, RemotePath, RuntimeError, SessionPlan, SkillStore, SpawnSpec,
StoreError,
AgentContextStore, AgentRuntime, AgentSession, AgentSessionError, AgentSessionFactory,
BackgroundTaskPortError, BackgroundTaskStore, Clock, ContextInjectionPlan, DirEntry, EventBus,
EventStream, ExitStatus, FileSystem, FsError, IdGenerator, OutputStream, PreparedContext,
ProfileStore, PtyError, PtyHandle, PtyPort, RemotePath, ReplyEvent, ReplyStream, RuntimeError,
SessionPlan, SkillStore, SpawnSpec, StoreError,
};
use domain::profile::{
AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport,
@ -33,13 +33,14 @@ use domain::profile::{
use domain::project::{Project, ProjectPath};
use domain::remote::RemoteRef;
use domain::skill::{Skill, SkillScope};
use domain::terminal::{SessionKind, TerminalSession};
use domain::{PtySize, SessionId};
use domain::{
BackgroundTask, BackgroundTaskResult, BackgroundTaskState, PtySize, SessionId, TaskId,
};
use uuid::Uuid;
use application::{
CloseTerminal, CreateAgentFromScratch, CreateSkill, LaunchAgent, ListAgents,
OrchestratorService, TerminalSessions, UpdateAgentContext,
OrchestratorService, StructuredSessions, TerminalSessions, UpdateAgentContext,
};
use infrastructure::{
process_request_file, FsOrchestratorWatcher, InMemoryMailbox, OrchestratorResponse,
@ -316,6 +317,172 @@ impl IdGenerator for SeqIds {
}
}
struct FixedClock(i64);
impl Clock for FixedClock {
fn now_millis(&self) -> i64 {
self.0
}
}
#[derive(Default)]
struct InMemoryBackgroundTaskStore {
tasks: Mutex<HashMap<TaskId, BackgroundTask>>,
}
impl InMemoryBackgroundTaskStore {
fn all(&self) -> Vec<BackgroundTask> {
self.tasks.lock().unwrap().values().cloned().collect()
}
}
#[async_trait]
impl BackgroundTaskStore for InMemoryBackgroundTaskStore {
async fn create(&self, task: &BackgroundTask) -> Result<(), BackgroundTaskPortError> {
let mut tasks = self.tasks.lock().unwrap();
if tasks.contains_key(&task.id) {
return Err(BackgroundTaskPortError::AlreadyExists);
}
tasks.insert(task.id, task.clone());
Ok(())
}
async fn get(&self, id: TaskId) -> Result<Option<BackgroundTask>, BackgroundTaskPortError> {
Ok(self.tasks.lock().unwrap().get(&id).cloned())
}
async fn save(&self, task: &BackgroundTask) -> Result<(), BackgroundTaskPortError> {
self.tasks.lock().unwrap().insert(task.id, task.clone());
Ok(())
}
async fn list_open_for_agent(
&self,
agent_id: AgentId,
) -> Result<Vec<BackgroundTask>, BackgroundTaskPortError> {
Ok(self
.tasks
.lock()
.unwrap()
.values()
.filter(|task| task.owner_agent_id == agent_id && !task.is_terminal())
.cloned()
.collect())
}
async fn list_undelivered_completions(
&self,
) -> Result<Vec<BackgroundTask>, BackgroundTaskPortError> {
Ok(self
.tasks
.lock()
.unwrap()
.values()
.filter(|task| task.has_pending_completion_delivery())
.cloned()
.collect())
}
async fn mark_completion_delivered(
&self,
task_id: TaskId,
) -> Result<(), BackgroundTaskPortError> {
let mut tasks = self.tasks.lock().unwrap();
let task = tasks
.get(&task_id)
.cloned()
.ok_or(BackgroundTaskPortError::NotFound)?;
let delivered = task
.mark_completion_delivered()
.map_err(|err| BackgroundTaskPortError::Invalid(err.to_string()))?;
tasks.insert(task_id, delivered);
Ok(())
}
}
#[derive(Clone)]
struct BlockingReplyFactory {
outcome: Arc<Mutex<BlockingReplyOutcome>>,
notify: Arc<tokio::sync::Notify>,
}
impl BlockingReplyFactory {
fn new(reply: impl Into<String>) -> Self {
Self {
outcome: Arc::new(Mutex::new(BlockingReplyOutcome::Final(reply.into()))),
notify: Arc::new(tokio::sync::Notify::new()),
}
}
fn no_reply(&self) {
*self.outcome.lock().unwrap() = BlockingReplyOutcome::NoReply;
}
fn release(&self) {
self.notify.notify_waiters();
}
}
#[derive(Clone)]
enum BlockingReplyOutcome {
Final(String),
NoReply,
}
struct BlockingReplySession {
id: SessionId,
outcome: Arc<Mutex<BlockingReplyOutcome>>,
notify: Arc<tokio::sync::Notify>,
}
#[async_trait]
impl AgentSession for BlockingReplySession {
fn id(&self) -> SessionId {
self.id
}
fn conversation_id(&self) -> Option<String> {
None
}
async fn send(&self, _prompt: &str) -> Result<ReplyStream, AgentSessionError> {
self.notify.notified().await;
match self.outcome.lock().unwrap().clone() {
BlockingReplyOutcome::Final(content) => {
Ok(Box::new(vec![ReplyEvent::Final { content }].into_iter()))
}
BlockingReplyOutcome::NoReply => Err(AgentSessionError::Io(
"target returned without a structured final".to_owned(),
)),
}
}
async fn shutdown(&self) -> Result<(), AgentSessionError> {
Ok(())
}
}
#[async_trait]
impl AgentSessionFactory for BlockingReplyFactory {
fn supports(&self, profile: &AgentProfile) -> bool {
profile.structured_adapter.is_some()
}
async fn start(
&self,
_profile: &AgentProfile,
_ctx: &PreparedContext,
_cwd: &ProjectPath,
_session: &SessionPlan,
_sandbox: Option<&domain::sandbox::SandboxPlan>,
) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
Ok(Arc::new(BlockingReplySession {
id: SessionId::from_uuid(Uuid::new_v4()),
outcome: Arc::clone(&self.outcome),
notify: Arc::clone(&self.notify),
}))
}
}
fn project() -> Project {
Project::new(
ProjectId::from_uuid(Uuid::from_u128(1000)),
@ -396,6 +563,8 @@ fn build_service_with_mailbox(
Arc<OrchestratorService>,
Arc<InMemoryMailbox>,
Arc<TerminalSessions>,
BlockingReplyFactory,
Arc<InMemoryBackgroundTaskStore>,
) {
// Profil Claude **complet** (adaptateur structuré + capacité MCP `.mcp.json`) :
// seul profil que la garde F2 (`guard_mcp_bridge_supported`) accepte comme cible
@ -417,6 +586,9 @@ fn build_service_with_mailbox(
McpTransport::Stdio,
))])));
let sessions = Arc::new(TerminalSessions::new());
let structured = Arc::new(StructuredSessions::new());
let structured_factory = BlockingReplyFactory::new("the answer is 42");
let background_tasks = Arc::new(InMemoryBackgroundTaskStore::default());
let mailbox = Arc::new(InMemoryMailbox::new());
let bus = Arc::new(NoopBus);
let create = Arc::new(CreateAgentFromScratch::new(
@ -424,19 +596,25 @@ fn build_service_with_mailbox(
Arc::new(SeqIds(Mutex::new(1))),
bus.clone(),
));
let launch = Arc::new(LaunchAgent::new(
Arc::new(contexts.clone()),
Arc::clone(&profiles) as Arc<dyn ProfileStore>,
Arc::new(FakeRuntime),
Arc::new(FakeFs),
Arc::new(FakePty),
Arc::new(FakeSkills),
Arc::clone(&sessions),
bus.clone(),
Arc::new(SeqIds(Mutex::new(1))),
Arc::new(FakeRecall),
None,
));
let launch = Arc::new(
LaunchAgent::new(
Arc::new(contexts.clone()),
Arc::clone(&profiles) as Arc<dyn ProfileStore>,
Arc::new(FakeRuntime),
Arc::new(FakeFs),
Arc::new(FakePty),
Arc::new(FakeSkills),
Arc::clone(&sessions),
bus.clone(),
Arc::new(SeqIds(Mutex::new(1))),
Arc::new(FakeRecall),
None,
)
.with_structured(
Arc::new(structured_factory.clone()) as Arc<dyn AgentSessionFactory>,
Arc::clone(&structured),
),
);
let list = Arc::new(ListAgents::new(Arc::new(contexts.clone())));
let close = Arc::new(CloseTerminal::new(Arc::new(FakePty), Arc::clone(&sessions)));
let update = Arc::new(UpdateAgentContext::new(Arc::new(contexts)));
@ -466,23 +644,20 @@ fn build_service_with_mailbox(
.with_conversations(
Arc::new(infrastructure::InMemoryConversationRegistry::new())
as Arc<dyn domain::conversation::ConversationRegistry>,
)
.with_structured(Arc::clone(&structured))
.with_background_tasks(
Arc::clone(&background_tasks) as Arc<dyn BackgroundTaskStore>,
Arc::new(FixedClock(1_700_000_000_000)) as Arc<dyn Clock>,
),
);
(service, mailbox, sessions)
}
/// Pre-seeds a live PTY terminal session for `agent_id` so `ask_agent` reuses it.
fn seed_live_pty(sessions: &TerminalSessions, agent_id: AgentId, session_id: SessionId) {
sessions.insert(
PtyHandle { session_id },
TerminalSession::starting(
session_id,
NodeId::from_uuid(Uuid::from_u128(7)),
ProjectPath::new("/home/me/proj").unwrap(),
SessionKind::Agent { agent_id },
PtySize { rows: 24, cols: 80 },
),
);
(
service,
mailbox,
sessions,
structured_factory,
background_tasks,
)
}
fn read_response(request_path: &std::path::Path) -> OrchestratorResponse {
@ -637,21 +812,15 @@ async fn unknown_action_yields_error_response() {
/// alongside `ok: true`.
#[tokio::test]
async fn ask_request_surfaces_reply_alongside_detail() {
// Option 1 (B-3/B-4): an `agent.message` request blocks awaiting the target's
// `idea_reply`. Over the file protocol we drive the ask request on a task, wait
// for the mailbox to hold a ticket, then process a separate `agent.reply` request
// (carrying the target's id as `requestedBy`, the handshake identity) which
// resolves it. The ask's `*.response.json` then carries reply + detail.
// B6: an `agent.message` request is still synchronous for the caller, but the
// target turn is driven through the structured/headless session. The mailbox still
// receives exactly one silent ticket for turn accounting; the response comes from
// `ReplyEvent::Final`, not from a separate `agent.reply` request.
let tmp = TempDir::new();
let contexts = FakeContexts::new();
let agent_id = contexts.seed_agent("architect");
let (service, mailbox, sessions) = build_service_with_mailbox(contexts.clone());
// Target already live in the PTY registry, so the ask reuses its terminal.
seed_live_pty(
&sessions,
agent_id,
SessionId::from_uuid(Uuid::from_u128(4242)),
);
let (service, mailbox, _sessions, structured_reply, tasks) =
build_service_with_mailbox(contexts.clone());
let req = tmp.0.join("ask-req.json");
std::fs::write(
@ -674,19 +843,7 @@ async fn ask_request_surfaces_reply_alongside_detail() {
.await
.expect("ask must enqueue a ticket");
// The target renders its result via an `agent.reply` request whose `requestedBy`
// is its own id (the handshake identity the MCP server would inject as `from`).
let reply_req = tmp.0.join("reply-req.json");
std::fs::write(
&reply_req,
format!(
r#"{{ "type": "agent.reply", "requestedBy": "{agent_id}", "result": "the answer is 42" }}"#
)
.as_bytes(),
)
.unwrap();
let reply_resp = process_request_file(&reply_req, &project(), &service).await;
assert!(reply_resp.ok, "reply request ok: {reply_resp:?}");
structured_reply.release();
let response = tokio::time::timeout(std::time::Duration::from_secs(10), ask)
.await
@ -718,6 +875,74 @@ async fn ask_request_surfaces_reply_alongside_detail() {
assert_eq!(json["reply"], serde_json::json!("the answer is 42"));
assert!(json.get("detail").is_some());
assert!(!req.exists(), "request file must be removed");
let stored = tasks.all();
assert_eq!(stored.len(), 1, "one rendezvous task persisted: {stored:?}");
let task = &stored[0];
assert_eq!(task.owner_agent_id, agent_id);
assert_eq!(task.state, BackgroundTaskState::Completed);
assert!(matches!(
task.kind,
domain::BackgroundTaskKind::HeadlessRendezvous { .. }
));
assert!(matches!(
task.result,
Some(BackgroundTaskResult::Success { .. })
));
}
#[tokio::test]
async fn ask_request_no_reply_persists_failed_rendezvous_task() {
let tmp = TempDir::new();
let contexts = FakeContexts::new();
let agent_id = contexts.seed_agent("architect");
let (service, mailbox, _sessions, structured_reply, tasks) =
build_service_with_mailbox(contexts.clone());
structured_reply.no_reply();
let req = tmp.0.join("ask-no-reply.json");
std::fs::write(
&req,
br#"{ "type": "agent.message", "requestedBy": "Main", "targetAgent": "architect", "task": "Please forget to finalise" }"#,
)
.unwrap();
let svc = Arc::clone(&service);
let proj = project();
let ask_req = req.clone();
let ask = tokio::spawn(async move { process_request_file(&ask_req, &proj, &svc).await });
tokio::time::timeout(std::time::Duration::from_secs(10), async {
while mailbox.pending(&agent_id) == 0 {
tokio::task::yield_now().await;
}
})
.await
.expect("ask must enqueue a ticket before no-reply");
structured_reply.release();
let response = tokio::time::timeout(std::time::Duration::from_secs(10), ask)
.await
.expect("ask completes after no-reply")
.expect("join ok");
assert!(!response.ok, "expected no-reply error, got {response:?}");
let stored = tasks.all();
assert_eq!(stored.len(), 1, "one rendezvous task persisted: {stored:?}");
let task = &stored[0];
assert_eq!(task.owner_agent_id, agent_id);
assert_eq!(task.state, BackgroundTaskState::Failed);
match task.result.as_ref() {
Some(BackgroundTaskResult::Failure { error, .. }) => {
assert!(
error.contains("NoReply"),
"error should classify no-reply: {error}"
);
}
other => panic!("expected failure result, got {other:?}"),
}
assert_eq!(mailbox.pending(&agent_id), 0, "turn-lock mailbox is freed");
}
/// Point 2 — a non-`ask` command (here `spawn_agent`, reply `None`) ⇒ the `reply`