diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index d26340b..6fccdab 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -3698,6 +3698,7 @@ pub async fn spawn_background_command( label: request.label, command, wake_policy, + rendezvous: None, deadline_ms: request.deadline_ms, }) .await diff --git a/crates/application/src/background/mod.rs b/crates/application/src/background/mod.rs index 9617edf..12ba5bf 100644 --- a/crates/application/src/background/mod.rs +++ b/crates/application/src/background/mod.rs @@ -20,8 +20,8 @@ use domain::ports::{ IdGenerator, SpawnSpec, }; use domain::{ - AgentId, BackgroundTask, BackgroundTaskKind, BackgroundTaskResult, BackgroundTaskState, - BackgroundTaskWakePolicy, ProjectId, TaskId, + AgentId, BackgroundTask, BackgroundTaskKind, BackgroundTaskRendezvousLink, + BackgroundTaskResult, BackgroundTaskState, BackgroundTaskWakePolicy, ProjectId, TaskId, }; use crate::error::AppError; @@ -52,6 +52,8 @@ pub struct SpawnBackgroundCommandInput { pub command: SpawnSpec, /// Completion wake policy. pub wake_policy: BackgroundTaskWakePolicy, + /// Optional composite rendezvous this task participates in. + pub rendezvous: Option, /// Optional absolute deadline, epoch milliseconds. pub deadline_ms: Option, } @@ -103,6 +105,7 @@ impl SpawnBackgroundCommand { input.label, input.command, input.wake_policy, + input.rendezvous, input.deadline_ms, ) .await?; @@ -117,12 +120,13 @@ impl SpawnBackgroundCommand { label: String, command: SpawnSpec, wake_policy: BackgroundTaskWakePolicy, + rendezvous: Option, deadline_ms: Option, ) -> Result { let task_id = TaskId::from_uuid(self.ids.new_uuid()); let now = u64::try_from(self.clock.now_millis().max(0)).unwrap_or(0); - let task = BackgroundTask::new( + let mut task = BackgroundTask::new( task_id, project_id, owner_agent_id, @@ -132,6 +136,9 @@ impl SpawnBackgroundCommand { deadline_ms, ) .map_err(|e| AppError::Invalid(e.to_string()))?; + if let Some(link) = rendezvous { + task = task.with_rendezvous(link); + } self.store.create(&task).await.map_err(map_port_err)?; let running = task @@ -287,6 +294,7 @@ impl RetryBackgroundTask { command, old.wake_policy, None, + None, ) .await?; Ok(SpawnBackgroundCommandOutput { task }) diff --git a/crates/application/src/orchestrator/service.rs b/crates/application/src/orchestrator/service.rs index 55b9124..57f5752 100644 --- a/crates/application/src/orchestrator/service.rs +++ b/crates/application/src/orchestrator/service.rs @@ -27,9 +27,10 @@ use domain::mailbox::{Ticket, TicketId}; use domain::ports::{BackgroundTaskStore, Clock, EventBus, ProfileStore, PtyHandle}; use domain::project::ProjectPath; use domain::{ - AgentId, AgentProfile, BackgroundTask, BackgroundTaskKind, BackgroundTaskResult, - BackgroundTaskState, BackgroundTaskWakePolicy, DomainEvent, OrchestratorCommand, - OrchestratorVisibility, ProfileId, Project, RuntimeAgentKey, TaskId, + AgentId, AgentProfile, BackgroundTask, BackgroundTaskKind, BackgroundTaskRendezvousLink, + BackgroundTaskResult, BackgroundTaskState, BackgroundTaskWakePolicy, DomainEvent, + OrchestratorCommand, OrchestratorVisibility, ProfileId, Project, RendezvousId, RuntimeAgentKey, + TaskId, }; use crate::conversation::RecordTurn; @@ -128,6 +129,13 @@ fn resolve_background_cwd( } fn rendezvous_context_for_task(task: &BackgroundTask) -> Option { + if let Some(link) = &task.rendezvous { + return Some(domain::RendezvousContext { + requester_agent_id: link.requester_agent_id, + target_agent_id: link.target_agent_id, + conversation_id: link.conversation_id, + }); + } match &task.kind { BackgroundTaskKind::HeadlessRendezvous { requester_agent_id, @@ -413,6 +421,10 @@ pub struct OrchestratorService { /// Séparé de [`WaitForGraph`] qui reste un objet domaine minimal de détection de /// cycle, sans API de traversal. active_waits: StdMutex>, + /// Composite business rendezvous currently driven by a target agent. Used to + /// structurally attach `idea_run_in_background` tasks launched during + /// `idea_ask_agent` without parsing the model's textual `Final`. + active_rendezvous: StdMutex>, /// Bus d'événements pour publier [`DomainEvent::AgentReplied`] à l'issue d'un /// `ask` réussi (§17.4). Injecté via [`Self::with_events`] ; `None` ⇒ pas de /// publication (l'`ask` fonctionne quand même). @@ -541,6 +553,12 @@ pub struct OrchestratorOutcome { pub reply: Option, } +#[derive(Debug, Clone)] +struct ActiveRendezvous { + link: BackgroundTaskRendezvousLink, + background_tasks: Vec, +} + impl OrchestratorService { /// Builds the service from the use cases and ports it dispatches to. #[must_use] @@ -569,6 +587,7 @@ impl OrchestratorService { conversations: None, wait_for: StdMutex::new(WaitForGraph::new()), active_waits: StdMutex::new(Vec::new()), + active_rendezvous: StdMutex::new(HashMap::new()), events: None, ask_locks: StdMutex::new(HashMap::new()), mcp_runtime_provider: None, @@ -681,6 +700,47 @@ impl OrchestratorService { Arc::clone(locks.entry(*agent_id).or_default()) } + fn active_rendezvous_link( + &self, + agent: RuntimeAgentKey, + ) -> Option { + self.active_rendezvous + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(&agent) + .map(|r| r.link.clone()) + } + + fn attach_background_to_active_rendezvous( + &self, + agent: RuntimeAgentKey, + rendezvous_id: RendezvousId, + task_id: TaskId, + ) { + if let Some(active) = self + .active_rendezvous + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get_mut(&agent) + .filter(|active| active.link.rendezvous_id == rendezvous_id) + { + active.background_tasks.push(task_id); + } + } + + fn active_rendezvous_has_background_tasks( + &self, + agent: RuntimeAgentKey, + rendezvous_id: RendezvousId, + ) -> bool { + self.active_rendezvous + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(&agent) + .filter(|active| active.link.rendezvous_id == rendezvous_id) + .is_some_and(|active| !active.background_tasks.is_empty()) + } + /// Returns the transitive set of agents currently waited on by `agent`. /// /// Used by user-driven cancellation: stopping A while A waits on B should also @@ -750,10 +810,7 @@ impl OrchestratorService { &self, project: &Project, owner_agent_id: AgentId, - requester_agent_id: Option, - target_agent_id: AgentId, - ticket_id: TicketId, - conversation_id: domain::conversation::ConversationId, + link: BackgroundTaskRendezvousLink, ) -> Result, AppError> { let Some(store) = &self.background_tasks else { return Ok(None); @@ -765,16 +822,17 @@ impl OrchestratorService { project.id, owner_agent_id, BackgroundTaskKind::HeadlessRendezvous { - requester_agent_id, - target_agent_id, - ticket_id, - conversation_id, + requester_agent_id: link.requester_agent_id, + target_agent_id: link.target_agent_id, + ticket_id: link.ticket_id, + conversation_id: link.conversation_id, }, BackgroundTaskWakePolicy::RecordOnly, now, None, ) .map_err(|err| AppError::Internal(err.to_string()))? + .with_rendezvous(link) .transition(BackgroundTaskState::Running, now) .map_err(|err| AppError::Internal(err.to_string()))?; store @@ -812,6 +870,27 @@ impl OrchestratorService { .await } + async fn mark_rendezvous_task_waiting(&self, task_id: Option) -> Result<(), AppError> { + let (Some(store), Some(task_id)) = (&self.background_tasks, task_id) else { + return Ok(()); + }; + let task = store + .get(task_id) + .await + .map_err(|err| AppError::Store(err.to_string()))? + .ok_or_else(|| AppError::Store(format!("background task {task_id} not found")))?; + if task.is_terminal() || task.state == BackgroundTaskState::Waiting { + return Ok(()); + } + let waiting = task + .transition(BackgroundTaskState::Waiting, self.now_ms()) + .map_err(|err| AppError::Internal(err.to_string()))?; + store + .save(&waiting) + .await + .map_err(|err| AppError::Store(err.to_string())) + } + async fn complete_rendezvous_task_failure( &self, project: &Project, @@ -1223,6 +1302,8 @@ impl OrchestratorService { AppError::Invalid("background command runner is not configured".to_owned()) })?; let cwd = resolve_background_cwd(&project.root, cwd)?; + let agent_key = runtime_key(project, owner); + let rendezvous = self.active_rendezvous_link(agent_key); let output = spawn .execute(SpawnBackgroundCommandInput { project_id: project.id, @@ -1237,9 +1318,17 @@ impl OrchestratorService { sandbox: None, }, wake_policy: BackgroundTaskWakePolicy::WakeOwner, + rendezvous: rendezvous.clone(), deadline_ms, }) .await?; + if let Some(link) = rendezvous { + self.attach_background_to_active_rendezvous( + agent_key, + link.rendezvous_id, + output.task.id, + ); + } let state = match output.task.state { BackgroundTaskState::Queued => "Queued", BackgroundTaskState::Running => "Running", @@ -1813,17 +1902,19 @@ impl OrchestratorService { // Timeout de tour piloté par profil (lot 2) + armement du seuil de stall, AVANT // l'enqueue qui démarre le tour (le médiateur arme alors sa fenêtre de vivacité). let turn_timeout = self.turn_timeout_for(project, agent_id).await; - let _pending = input.enqueue_silent(agent_key, ticket); + let pending = input.enqueue_silent(agent_key, ticket); + let rendezvous_link = BackgroundTaskRendezvousLink { + rendezvous_id: RendezvousId::new_random(), + ticket_id, + requester_agent_id: requester, + target_agent_id: agent_id, + conversation_id, + }; let rendezvous_task = self - .start_rendezvous_task( - project, - agent_id, - requester, - agent_id, - ticket_id, - conversation_id, - ) + .start_rendezvous_task(project, agent_id, rendezvous_link.clone()) .await?; + let _active_rendezvous = + ActiveRendezvousGuard::new(self, agent_key, rendezvous_link.clone()); // Auto-update live-state (lot LS3), best-effort : la cible passe `Working` sur // cette transition d'`ask` acceptée (chemin structuré). `task` est encore vivant // ici (utilisé par le drain plus bas), on le distille directement. @@ -1919,15 +2010,13 @@ impl OrchestratorService { }; // Borne par la fenêtre d'inactivité (réarmée sur signe de vie) sous plafond absolu. - let result = match self + let initial_result = match self .run_ask_with_watchdog(wait, turn_timeout, &project.root, agent_id, target, started) .await { WatchdogOutcome::Resolved(Ok(content)) => { - self.complete_rendezvous_task_success(project, agent_id, rendezvous_task, &content) - .await?; crate::diag!( - "[rendezvous] ask resolved (structured): target={target} \ + "[rendezvous] ask turn finalized (structured): target={target} \ (agent {agent_id}) ticket={ticket_id} after_ms={} reply_len={}", started.elapsed().as_millis(), content.len(), @@ -2013,6 +2102,67 @@ impl OrchestratorService { } }; + let result = if self + .active_rendezvous_has_background_tasks(agent_key, rendezvous_link.rendezvous_id) + { + self.mark_rendezvous_task_waiting(rendezvous_task).await?; + crate::diag!( + "[rendezvous] ask waiting on background: target={target} \ + (agent {agent_id}) ticket={ticket_id} rendezvous={} first_final_len={}", + rendezvous_link.rendezvous_id, + initial_result.len(), + ); + let remaining = self.ask_ceiling.saturating_sub(started.elapsed()); + match tokio::time::timeout(remaining, pending).await { + Ok(Ok(domain::mailbox::TurnResolution::Replied(content))) => content, + Ok(Ok(domain::mailbox::TurnResolution::ReturnedToPromptNoReply)) => { + let err = AppError::TargetReturnedNoReply(target.to_owned()); + self.complete_rendezvous_task_failure( + project, + agent_id, + rendezvous_task, + format!("NoReply: {err}"), + ) + .await?; + self.mark_target_done_best_effort(&project.root, agent_id, ticket_id) + .await; + return Err(err); + } + Ok(Err(err)) => { + let err = AppError::Process(err.to_string()); + self.complete_rendezvous_task_failure( + project, + agent_id, + rendezvous_task, + format!("NoReply: {err}"), + ) + .await?; + self.mark_target_done_best_effort(&project.root, agent_id, ticket_id) + .await; + return Err(err); + } + Err(_elapsed) => { + self.complete_rendezvous_task_failure( + project, + agent_id, + rendezvous_task, + format!( + "Timeout: composite rendezvous ceiling reached for target {target}" + ), + ) + .await?; + self.mark_target_done_best_effort(&project.root, agent_id, ticket_id) + .await; + return Err(AppError::TargetCeilingActive(target.to_owned())); + } + } + } else { + initial_result + }; + + self.complete_rendezvous_task_success(project, agent_id, rendezvous_task, &result) + .await?; + // Succès : le `Final` a rendu la réponse. On retire explicitement le ticket de // comptabilité (aucun `idea_reply` ne le fera), puis on désarme le garde RAII. mailbox.cancel_head(agent_key, ticket_id); @@ -2916,6 +3066,53 @@ impl Drop for WaitEdgeGuard<'_> { } } +struct ActiveRendezvousGuard<'a> { + active: &'a StdMutex>, + agent: RuntimeAgentKey, + rendezvous_id: RendezvousId, +} + +impl<'a> ActiveRendezvousGuard<'a> { + fn new( + service: &'a OrchestratorService, + agent: RuntimeAgentKey, + link: BackgroundTaskRendezvousLink, + ) -> Self { + let rendezvous_id = link.rendezvous_id; + service + .active_rendezvous + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert( + agent, + ActiveRendezvous { + link, + background_tasks: Vec::new(), + }, + ); + Self { + active: &service.active_rendezvous, + agent, + rendezvous_id, + } + } +} + +impl Drop for ActiveRendezvousGuard<'_> { + fn drop(&mut self) { + let mut active = self + .active + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if active + .get(&self.agent) + .is_some_and(|entry| entry.link.rendezvous_id == self.rendezvous_id) + { + active.remove(&self.agent); + } + } +} + /// Normalises a profile reference for tolerant matching: lowercased, with spaces, /// dashes and underscores stripped (`"Claude Code"`, `"claude-code"`, `"claude"` /// → comparable forms; `claude` ⊂ ... handled by the command match above). diff --git a/crates/application/src/orchestrator/wake.rs b/crates/application/src/orchestrator/wake.rs index 3cf82a5..5aeb0d7 100644 --- a/crates/application/src/orchestrator/wake.rs +++ b/crates/application/src/orchestrator/wake.rs @@ -134,10 +134,33 @@ impl AgentWakeService { owner_agent_id: agent, }); } - drain_reply_stream_with_readiness(stream, self.input.as_ref(), key) + let final_content = drain_reply_stream_with_readiness(stream, self.input.as_ref(), key) .await .map_err(|err| WakeError::Session(err.to_string()))?; + if let Some(task_id) = delivery.delivered_task_id { + let task = self.load_task(task_id).await?; + if let Some(link) = task.rendezvous.as_ref() { + let still_waiting = self + .tasks + .list_open_for_agent(agent) + .await + .map_err(|err| WakeError::Store(err.to_string()))? + .into_iter() + .any(|open| { + open.id != task_id + && open.rendezvous.as_ref().is_some_and(|open_link| { + open_link.rendezvous_id == link.rendezvous_id + }) + }); + if !still_waiting { + self.mailbox + .resolve_ticket(key, link.ticket_id, final_content) + .map_err(|err| WakeError::Task(err.to_string()))?; + } + } + } + self.mailbox.cancel_head(key, delivery.ticket_id); self.input.mark_idle(key); guard.disarm(); diff --git a/crates/application/tests/agent_wake.rs b/crates/application/tests/agent_wake.rs index c698cae..2105f4c 100644 --- a/crates/application/tests/agent_wake.rs +++ b/crates/application/tests/agent_wake.rs @@ -4,10 +4,10 @@ use std::sync::{Arc, Mutex}; use application::{AgentWakeService, WakeSessionProvider}; use async_trait::async_trait; use domain::background_task::{ - BackgroundTask, BackgroundTaskKind, BackgroundTaskResult, BackgroundTaskState, - BackgroundTaskWakePolicy, + BackgroundTask, BackgroundTaskKind, BackgroundTaskRendezvousLink, BackgroundTaskResult, + BackgroundTaskState, BackgroundTaskWakePolicy, }; -use domain::ids::{AgentId, ProjectId, RuntimeAgentKey, SessionId, TaskId}; +use domain::ids::{AgentId, ProjectId, RendezvousId, RuntimeAgentKey, SessionId, TaskId}; use domain::inbox::{ AgentInbox, AgentInboxSnapshot, InboxError, InboxItem, InboxItemKind, InboxReceipt, InboxReceiptStatus, InboxSource, @@ -20,6 +20,7 @@ use domain::ports::{ }; use domain::project::{Project, ProjectPath}; use domain::remote::RemoteRef; +use domain::ConversationId; use uuid::Uuid; fn id(n: u128) -> Uuid { @@ -95,6 +96,37 @@ fn completed_task( .unwrap() } +fn completed_rendezvous_task( + project: &Project, + owner: AgentId, + task_id: TaskId, + link: BackgroundTaskRendezvousLink, +) -> BackgroundTask { + BackgroundTask::new( + task_id, + project.id, + owner, + BackgroundTaskKind::Command { + label: "build".to_owned(), + }, + BackgroundTaskWakePolicy::WakeOwner, + 1, + None, + ) + .unwrap() + .with_rendezvous(link) + .transition(BackgroundTaskState::Running, 10) + .unwrap() + .complete(BackgroundTaskResult::Success { + finished_at_ms: 20, + exit_code: Some(0), + summary: "done".to_owned(), + stdout_tail: None, + stderr_tail: None, + }) + .unwrap() +} + #[derive(Default)] struct FakeInbox { queues: Mutex>>, @@ -147,6 +179,7 @@ impl AgentInbox for FakeInbox { struct SharedTurnState { busy: Mutex>, tickets: Mutex>>, + replies: Mutex>, } impl SharedTurnState { @@ -168,6 +201,10 @@ impl SharedTurnState { .map(VecDeque::len) .unwrap_or_default() } + + fn replies(&self) -> Vec<(TicketId, String)> { + self.replies.lock().unwrap().clone() + } } impl InputMediator for SharedTurnState { @@ -220,6 +257,25 @@ impl AgentMailbox for SharedTurnState { Ok(()) } + fn resolve_ticket( + &self, + agent: RuntimeAgentKey, + ticket_id: TicketId, + result: String, + ) -> Result<(), MailboxError> { + let mut tickets = self.tickets.lock().unwrap(); + let queue = tickets + .get_mut(&agent) + .ok_or(MailboxError::NoPendingRequest(agent.agent_id))?; + let pos = queue + .iter() + .position(|ticket| ticket.id == ticket_id) + .ok_or(MailboxError::NoPendingRequest(agent.agent_id))?; + queue.remove(pos); + self.replies.lock().unwrap().push((ticket_id, result)); + Ok(()) + } + fn cancel_head(&self, agent: RuntimeAgentKey, ticket_id: TicketId) { let mut tickets = self.tickets.lock().unwrap(); if let Some(queue) = tickets.get_mut(&agent) { @@ -230,6 +286,65 @@ impl AgentMailbox for SharedTurnState { } } +#[tokio::test] +async fn rendezvous_background_completion_resolves_original_ticket_with_business_final() { + let project = project(); + let owner = agent(1); + let requester = agent(2); + let task_id = task_id(10); + let rendezvous_ticket = ticket(99); + let conversation_id = ConversationId::from_uuid(id(300)); + let rendezvous_id = RendezvousId::from_uuid(id(400)); + let link = BackgroundTaskRendezvousLink { + rendezvous_id, + ticket_id: rendezvous_ticket, + requester_agent_id: Some(requester), + target_agent_id: owner, + conversation_id, + }; + let inbox = Arc::new(FakeInbox::default()); + let turns = Arc::new(SharedTurnState::default()); + let tasks = Arc::new(FakeTaskStore::default()); + let session = Arc::new(FakeSession::with_events(vec![ReplyEvent::Final { + content: "real business answer".to_owned(), + }])); + let sessions = Arc::new(FakeSessionProvider::with_session(session)); + + turns.enqueue_silent( + runtime_key(owner), + Ticket::from_agent( + rendezvous_ticket, + requester, + conversation_id, + "Requester", + "original ask", + ), + ); + turns.mark_idle(runtime_key(owner)); + tasks.insert(completed_rendezvous_task(&project, owner, task_id, link)); + inbox + .enqueue_message( + runtime_key(owner), + completion_item(owner, task_id, ticket(20)), + ) + .unwrap(); + + service(inbox, turns.clone(), tasks, sessions) + .wake_agent( + &project, + owner, + WakeReason::BackgroundCompletion { task_id }, + ) + .await + .unwrap(); + + assert_eq!( + turns.replies(), + vec![(rendezvous_ticket, "real business answer".to_owned())] + ); + assert_eq!(turns.ticket_depth(owner), 0); +} + #[derive(Default)] struct FakeTaskStore { tasks: Mutex>, diff --git a/crates/domain/src/background_task.rs b/crates/domain/src/background_task.rs index 3256491..9b48fff 100644 --- a/crates/domain/src/background_task.rs +++ b/crates/domain/src/background_task.rs @@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize}; use thiserror::Error; use crate::conversation::ConversationId; -use crate::ids::{AgentId, ProjectId, ScheduleId, TaskId}; +use crate::ids::{AgentId, ProjectId, RendezvousId, ScheduleId, TaskId}; use crate::mailbox::TicketId; /// Maximum length for human-facing task labels. @@ -59,6 +59,22 @@ pub enum BackgroundTaskWakePolicy { RecordOnly, } +/// Structural link from a background task to a composite inter-agent rendezvous. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BackgroundTaskRendezvousLink { + /// Stable business rendezvous id, independent from any task id. + pub rendezvous_id: RendezvousId, + /// Mailbox ticket that must receive the eventual business `Final`. + pub ticket_id: TicketId, + /// Agent that requested the rendezvous, when known. + pub requester_agent_id: Option, + /// Target agent doing the work. + pub target_agent_id: AgentId, + /// Conversation entered by the target turn. + pub conversation_id: ConversationId, +} + /// Kind-specific payload of a background task. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", tag = "kind")] @@ -107,6 +123,9 @@ pub struct BackgroundTask { pub owner_agent_id: AgentId, /// Kind-specific payload. pub kind: BackgroundTaskKind, + /// Optional composite rendezvous this task participates in. + #[serde(default)] + pub rendezvous: Option, /// Current lifecycle state. pub state: BackgroundTaskState, /// Completion wake policy. @@ -143,6 +162,7 @@ impl BackgroundTask { project_id, owner_agent_id, kind, + rendezvous: None, state: BackgroundTaskState::Queued, wake_policy, created_at_ms: now_ms, @@ -155,6 +175,13 @@ impl BackgroundTask { Ok(task) } + /// Returns a copy linked to a composite inter-agent rendezvous. + #[must_use] + pub fn with_rendezvous(mut self, link: BackgroundTaskRendezvousLink) -> Self { + self.rendezvous = Some(link); + self + } + /// Returns a copy moved to a non-terminal state. /// /// Terminal transitions must use [`Self::complete`] so the result/state diff --git a/crates/domain/src/ids.rs b/crates/domain/src/ids.rs index c6a67da..0fcaabf 100644 --- a/crates/domain/src/ids.rs +++ b/crates/domain/src/ids.rs @@ -115,6 +115,10 @@ typed_id!( /// Identifies a first-class background task. TaskId ); +typed_id!( + /// Identifies one composite inter-agent business rendezvous. + RendezvousId +); /// Runtime-only key for an agent scoped by its project. /// diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index 58956b6..d9b42e8 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -79,8 +79,8 @@ pub use error::DomainError; pub use events::RendezvousContext; pub use ids::{ - AgentId, IssueId, LayoutId, LocalModelServerId, NodeId, ProfileId, ProjectId, RuntimeAgentKey, - ScheduleId, SessionId, SkillId, SprintId, TabId, TaskId, TemplateId, WindowId, + AgentId, IssueId, LayoutId, LocalModelServerId, NodeId, ProfileId, ProjectId, RendezvousId, + RuntimeAgentKey, ScheduleId, SessionId, SkillId, SprintId, TabId, TaskId, TemplateId, WindowId, }; pub use project::{Project, ProjectPath}; @@ -95,9 +95,10 @@ pub use mcp_tool_permissions::{ }; pub use background_task::{ - BackgroundTask, BackgroundTaskError, BackgroundTaskKind, BackgroundTaskResult, - BackgroundTaskState, BackgroundTaskWakePolicy, BACKGROUND_TASK_LABEL_MAX_CHARS, - BACKGROUND_TASK_OUTPUT_TAIL_MAX_BYTES, BACKGROUND_TASK_TEXT_MAX_BYTES, + BackgroundTask, BackgroundTaskError, BackgroundTaskKind, BackgroundTaskRendezvousLink, + BackgroundTaskResult, BackgroundTaskState, BackgroundTaskWakePolicy, + BACKGROUND_TASK_LABEL_MAX_CHARS, BACKGROUND_TASK_OUTPUT_TAIL_MAX_BYTES, + BACKGROUND_TASK_TEXT_MAX_BYTES, }; pub use skill::{Skill, SkillRef, SkillScope}; diff --git a/crates/domain/tests/background_task.rs b/crates/domain/tests/background_task.rs index 8b1ee49..beac463 100644 --- a/crates/domain/tests/background_task.rs +++ b/crates/domain/tests/background_task.rs @@ -1,8 +1,9 @@ //! Pure invariants for first-class background tasks. use domain::{ - BackgroundTask, BackgroundTaskError, BackgroundTaskKind, BackgroundTaskResult, - BackgroundTaskState, BackgroundTaskWakePolicy, + BackgroundTask, BackgroundTaskError, BackgroundTaskKind, BackgroundTaskRendezvousLink, + BackgroundTaskResult, BackgroundTaskState, BackgroundTaskWakePolicy, ConversationId, + RendezvousId, TicketId, }; use uuid::Uuid; @@ -43,6 +44,23 @@ fn success_result(at: u64) -> BackgroundTaskResult { } } +#[test] +fn rendezvous_link_roundtrips_on_background_task() { + let link = BackgroundTaskRendezvousLink { + rendezvous_id: RendezvousId::from_uuid(Uuid::from_u128(9)), + ticket_id: TicketId::from_uuid(Uuid::from_u128(10)), + requester_agent_id: Some(agent_id(4)), + target_agent_id: agent_id(3), + conversation_id: ConversationId::from_uuid(Uuid::from_u128(11)), + }; + let task = command_task().with_rendezvous(link.clone()); + + let json = serde_json::to_string(&task).unwrap(); + let decoded: BackgroundTask = serde_json::from_str(&json).unwrap(); + + assert_eq!(decoded.rendezvous, Some(link)); +} + fn failure_result(at: u64) -> BackgroundTaskResult { BackgroundTaskResult::Failure { finished_at_ms: at,