Merge branch 'feature/117-opencode-headless-recovery' into develop

This commit is contained in:
2026-07-31 15:05:14 +02:00
9 changed files with 433 additions and 39 deletions

View File

@ -3698,6 +3698,7 @@ pub async fn spawn_background_command(
label: request.label, label: request.label,
command, command,
wake_policy, wake_policy,
rendezvous: None,
deadline_ms: request.deadline_ms, deadline_ms: request.deadline_ms,
}) })
.await .await

View File

@ -20,8 +20,8 @@ use domain::ports::{
IdGenerator, SpawnSpec, IdGenerator, SpawnSpec,
}; };
use domain::{ use domain::{
AgentId, BackgroundTask, BackgroundTaskKind, BackgroundTaskResult, BackgroundTaskState, AgentId, BackgroundTask, BackgroundTaskKind, BackgroundTaskRendezvousLink,
BackgroundTaskWakePolicy, ProjectId, TaskId, BackgroundTaskResult, BackgroundTaskState, BackgroundTaskWakePolicy, ProjectId, TaskId,
}; };
use crate::error::AppError; use crate::error::AppError;
@ -52,6 +52,8 @@ pub struct SpawnBackgroundCommandInput {
pub command: SpawnSpec, pub command: SpawnSpec,
/// Completion wake policy. /// Completion wake policy.
pub wake_policy: BackgroundTaskWakePolicy, pub wake_policy: BackgroundTaskWakePolicy,
/// Optional composite rendezvous this task participates in.
pub rendezvous: Option<BackgroundTaskRendezvousLink>,
/// Optional absolute deadline, epoch milliseconds. /// Optional absolute deadline, epoch milliseconds.
pub deadline_ms: Option<u64>, pub deadline_ms: Option<u64>,
} }
@ -103,6 +105,7 @@ impl SpawnBackgroundCommand {
input.label, input.label,
input.command, input.command,
input.wake_policy, input.wake_policy,
input.rendezvous,
input.deadline_ms, input.deadline_ms,
) )
.await?; .await?;
@ -117,12 +120,13 @@ impl SpawnBackgroundCommand {
label: String, label: String,
command: SpawnSpec, command: SpawnSpec,
wake_policy: BackgroundTaskWakePolicy, wake_policy: BackgroundTaskWakePolicy,
rendezvous: Option<BackgroundTaskRendezvousLink>,
deadline_ms: Option<u64>, deadline_ms: Option<u64>,
) -> Result<BackgroundTask, AppError> { ) -> Result<BackgroundTask, AppError> {
let task_id = TaskId::from_uuid(self.ids.new_uuid()); 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 now = u64::try_from(self.clock.now_millis().max(0)).unwrap_or(0);
let task = BackgroundTask::new( let mut task = BackgroundTask::new(
task_id, task_id,
project_id, project_id,
owner_agent_id, owner_agent_id,
@ -132,6 +136,9 @@ impl SpawnBackgroundCommand {
deadline_ms, deadline_ms,
) )
.map_err(|e| AppError::Invalid(e.to_string()))?; .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)?; self.store.create(&task).await.map_err(map_port_err)?;
let running = task let running = task
@ -287,6 +294,7 @@ impl RetryBackgroundTask {
command, command,
old.wake_policy, old.wake_policy,
None, None,
None,
) )
.await?; .await?;
Ok(SpawnBackgroundCommandOutput { task }) Ok(SpawnBackgroundCommandOutput { task })

View File

@ -27,9 +27,10 @@ use domain::mailbox::{Ticket, TicketId};
use domain::ports::{BackgroundTaskStore, Clock, EventBus, ProfileStore, PtyHandle}; use domain::ports::{BackgroundTaskStore, Clock, EventBus, ProfileStore, PtyHandle};
use domain::project::ProjectPath; use domain::project::ProjectPath;
use domain::{ use domain::{
AgentId, AgentProfile, BackgroundTask, BackgroundTaskKind, BackgroundTaskResult, AgentId, AgentProfile, BackgroundTask, BackgroundTaskKind, BackgroundTaskRendezvousLink,
BackgroundTaskState, BackgroundTaskWakePolicy, DomainEvent, OrchestratorCommand, BackgroundTaskResult, BackgroundTaskState, BackgroundTaskWakePolicy, DomainEvent,
OrchestratorVisibility, ProfileId, Project, RuntimeAgentKey, TaskId, OrchestratorCommand, OrchestratorVisibility, ProfileId, Project, RendezvousId, RuntimeAgentKey,
TaskId,
}; };
use crate::conversation::RecordTurn; use crate::conversation::RecordTurn;
@ -128,6 +129,13 @@ fn resolve_background_cwd(
} }
fn rendezvous_context_for_task(task: &BackgroundTask) -> Option<domain::RendezvousContext> { fn rendezvous_context_for_task(task: &BackgroundTask) -> Option<domain::RendezvousContext> {
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 { match &task.kind {
BackgroundTaskKind::HeadlessRendezvous { BackgroundTaskKind::HeadlessRendezvous {
requester_agent_id, requester_agent_id,
@ -413,6 +421,10 @@ pub struct OrchestratorService {
/// Séparé de [`WaitForGraph`] qui reste un objet domaine minimal de détection de /// Séparé de [`WaitForGraph`] qui reste un objet domaine minimal de détection de
/// cycle, sans API de traversal. /// cycle, sans API de traversal.
active_waits: StdMutex<Vec<(AgentId, AgentId)>>, active_waits: StdMutex<Vec<(AgentId, AgentId)>>,
/// 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<HashMap<RuntimeAgentKey, ActiveRendezvous>>,
/// Bus d'événements pour publier [`DomainEvent::AgentReplied`] à l'issue d'un /// 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 /// `ask` réussi (§17.4). Injecté via [`Self::with_events`] ; `None` ⇒ pas de
/// publication (l'`ask` fonctionne quand même). /// publication (l'`ask` fonctionne quand même).
@ -541,6 +553,12 @@ pub struct OrchestratorOutcome {
pub reply: Option<String>, pub reply: Option<String>,
} }
#[derive(Debug, Clone)]
struct ActiveRendezvous {
link: BackgroundTaskRendezvousLink,
background_tasks: Vec<TaskId>,
}
impl OrchestratorService { impl OrchestratorService {
/// Builds the service from the use cases and ports it dispatches to. /// Builds the service from the use cases and ports it dispatches to.
#[must_use] #[must_use]
@ -569,6 +587,7 @@ impl OrchestratorService {
conversations: None, conversations: None,
wait_for: StdMutex::new(WaitForGraph::new()), wait_for: StdMutex::new(WaitForGraph::new()),
active_waits: StdMutex::new(Vec::new()), active_waits: StdMutex::new(Vec::new()),
active_rendezvous: StdMutex::new(HashMap::new()),
events: None, events: None,
ask_locks: StdMutex::new(HashMap::new()), ask_locks: StdMutex::new(HashMap::new()),
mcp_runtime_provider: None, mcp_runtime_provider: None,
@ -681,6 +700,47 @@ impl OrchestratorService {
Arc::clone(locks.entry(*agent_id).or_default()) Arc::clone(locks.entry(*agent_id).or_default())
} }
fn active_rendezvous_link(
&self,
agent: RuntimeAgentKey,
) -> Option<BackgroundTaskRendezvousLink> {
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`. /// 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 /// Used by user-driven cancellation: stopping A while A waits on B should also
@ -750,10 +810,7 @@ impl OrchestratorService {
&self, &self,
project: &Project, project: &Project,
owner_agent_id: AgentId, owner_agent_id: AgentId,
requester_agent_id: Option<AgentId>, link: BackgroundTaskRendezvousLink,
target_agent_id: AgentId,
ticket_id: TicketId,
conversation_id: domain::conversation::ConversationId,
) -> Result<Option<TaskId>, AppError> { ) -> Result<Option<TaskId>, AppError> {
let Some(store) = &self.background_tasks else { let Some(store) = &self.background_tasks else {
return Ok(None); return Ok(None);
@ -765,16 +822,17 @@ impl OrchestratorService {
project.id, project.id,
owner_agent_id, owner_agent_id,
BackgroundTaskKind::HeadlessRendezvous { BackgroundTaskKind::HeadlessRendezvous {
requester_agent_id, requester_agent_id: link.requester_agent_id,
target_agent_id, target_agent_id: link.target_agent_id,
ticket_id, ticket_id: link.ticket_id,
conversation_id, conversation_id: link.conversation_id,
}, },
BackgroundTaskWakePolicy::RecordOnly, BackgroundTaskWakePolicy::RecordOnly,
now, now,
None, None,
) )
.map_err(|err| AppError::Internal(err.to_string()))? .map_err(|err| AppError::Internal(err.to_string()))?
.with_rendezvous(link)
.transition(BackgroundTaskState::Running, now) .transition(BackgroundTaskState::Running, now)
.map_err(|err| AppError::Internal(err.to_string()))?; .map_err(|err| AppError::Internal(err.to_string()))?;
store store
@ -812,6 +870,27 @@ impl OrchestratorService {
.await .await
} }
async fn mark_rendezvous_task_waiting(&self, task_id: Option<TaskId>) -> 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( async fn complete_rendezvous_task_failure(
&self, &self,
project: &Project, project: &Project,
@ -1223,6 +1302,8 @@ impl OrchestratorService {
AppError::Invalid("background command runner is not configured".to_owned()) AppError::Invalid("background command runner is not configured".to_owned())
})?; })?;
let cwd = resolve_background_cwd(&project.root, cwd)?; 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 let output = spawn
.execute(SpawnBackgroundCommandInput { .execute(SpawnBackgroundCommandInput {
project_id: project.id, project_id: project.id,
@ -1237,9 +1318,17 @@ impl OrchestratorService {
sandbox: None, sandbox: None,
}, },
wake_policy: BackgroundTaskWakePolicy::WakeOwner, wake_policy: BackgroundTaskWakePolicy::WakeOwner,
rendezvous: rendezvous.clone(),
deadline_ms, deadline_ms,
}) })
.await?; .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 { let state = match output.task.state {
BackgroundTaskState::Queued => "Queued", BackgroundTaskState::Queued => "Queued",
BackgroundTaskState::Running => "Running", BackgroundTaskState::Running => "Running",
@ -1813,17 +1902,19 @@ impl OrchestratorService {
// Timeout de tour piloté par profil (lot 2) + armement du seuil de stall, AVANT // 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é). // 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 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 let rendezvous_task = self
.start_rendezvous_task( .start_rendezvous_task(project, agent_id, rendezvous_link.clone())
project,
agent_id,
requester,
agent_id,
ticket_id,
conversation_id,
)
.await?; .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 // 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 // cette transition d'`ask` acceptée (chemin structuré). `task` est encore vivant
// ici (utilisé par le drain plus bas), on le distille directement. // 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. // 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) .run_ask_with_watchdog(wait, turn_timeout, &project.root, agent_id, target, started)
.await .await
{ {
WatchdogOutcome::Resolved(Ok(content)) => { WatchdogOutcome::Resolved(Ok(content)) => {
self.complete_rendezvous_task_success(project, agent_id, rendezvous_task, &content)
.await?;
crate::diag!( 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={}", (agent {agent_id}) ticket={ticket_id} after_ms={} reply_len={}",
started.elapsed().as_millis(), started.elapsed().as_millis(),
content.len(), 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 // 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. // comptabilité (aucun `idea_reply` ne le fera), puis on désarme le garde RAII.
mailbox.cancel_head(agent_key, ticket_id); mailbox.cancel_head(agent_key, ticket_id);
@ -2916,6 +3066,53 @@ impl Drop for WaitEdgeGuard<'_> {
} }
} }
struct ActiveRendezvousGuard<'a> {
active: &'a StdMutex<HashMap<RuntimeAgentKey, ActiveRendezvous>>,
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, /// Normalises a profile reference for tolerant matching: lowercased, with spaces,
/// dashes and underscores stripped (`"Claude Code"`, `"claude-code"`, `"claude"` /// dashes and underscores stripped (`"Claude Code"`, `"claude-code"`, `"claude"`
/// → comparable forms; `claude` ⊂ ... handled by the command match above). /// → comparable forms; `claude` ⊂ ... handled by the command match above).

View File

@ -134,10 +134,33 @@ impl AgentWakeService {
owner_agent_id: agent, 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 .await
.map_err(|err| WakeError::Session(err.to_string()))?; .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.mailbox.cancel_head(key, delivery.ticket_id);
self.input.mark_idle(key); self.input.mark_idle(key);
guard.disarm(); guard.disarm();

View File

@ -4,10 +4,10 @@ use std::sync::{Arc, Mutex};
use application::{AgentWakeService, WakeSessionProvider}; use application::{AgentWakeService, WakeSessionProvider};
use async_trait::async_trait; use async_trait::async_trait;
use domain::background_task::{ use domain::background_task::{
BackgroundTask, BackgroundTaskKind, BackgroundTaskResult, BackgroundTaskState, BackgroundTask, BackgroundTaskKind, BackgroundTaskRendezvousLink, BackgroundTaskResult,
BackgroundTaskWakePolicy, BackgroundTaskState, BackgroundTaskWakePolicy,
}; };
use domain::ids::{AgentId, ProjectId, RuntimeAgentKey, SessionId, TaskId}; use domain::ids::{AgentId, ProjectId, RendezvousId, RuntimeAgentKey, SessionId, TaskId};
use domain::inbox::{ use domain::inbox::{
AgentInbox, AgentInboxSnapshot, InboxError, InboxItem, InboxItemKind, InboxReceipt, AgentInbox, AgentInboxSnapshot, InboxError, InboxItem, InboxItemKind, InboxReceipt,
InboxReceiptStatus, InboxSource, InboxReceiptStatus, InboxSource,
@ -20,6 +20,7 @@ use domain::ports::{
}; };
use domain::project::{Project, ProjectPath}; use domain::project::{Project, ProjectPath};
use domain::remote::RemoteRef; use domain::remote::RemoteRef;
use domain::ConversationId;
use uuid::Uuid; use uuid::Uuid;
fn id(n: u128) -> Uuid { fn id(n: u128) -> Uuid {
@ -95,6 +96,37 @@ fn completed_task(
.unwrap() .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)] #[derive(Default)]
struct FakeInbox { struct FakeInbox {
queues: Mutex<HashMap<RuntimeAgentKey, VecDeque<InboxItem>>>, queues: Mutex<HashMap<RuntimeAgentKey, VecDeque<InboxItem>>>,
@ -147,6 +179,7 @@ impl AgentInbox for FakeInbox {
struct SharedTurnState { struct SharedTurnState {
busy: Mutex<HashMap<RuntimeAgentKey, AgentBusyState>>, busy: Mutex<HashMap<RuntimeAgentKey, AgentBusyState>>,
tickets: Mutex<HashMap<RuntimeAgentKey, VecDeque<Ticket>>>, tickets: Mutex<HashMap<RuntimeAgentKey, VecDeque<Ticket>>>,
replies: Mutex<Vec<(TicketId, String)>>,
} }
impl SharedTurnState { impl SharedTurnState {
@ -168,6 +201,10 @@ impl SharedTurnState {
.map(VecDeque::len) .map(VecDeque::len)
.unwrap_or_default() .unwrap_or_default()
} }
fn replies(&self) -> Vec<(TicketId, String)> {
self.replies.lock().unwrap().clone()
}
} }
impl InputMediator for SharedTurnState { impl InputMediator for SharedTurnState {
@ -220,6 +257,25 @@ impl AgentMailbox for SharedTurnState {
Ok(()) 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) { fn cancel_head(&self, agent: RuntimeAgentKey, ticket_id: TicketId) {
let mut tickets = self.tickets.lock().unwrap(); let mut tickets = self.tickets.lock().unwrap();
if let Some(queue) = tickets.get_mut(&agent) { 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)] #[derive(Default)]
struct FakeTaskStore { struct FakeTaskStore {
tasks: Mutex<HashMap<TaskId, BackgroundTask>>, tasks: Mutex<HashMap<TaskId, BackgroundTask>>,

View File

@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize};
use thiserror::Error; use thiserror::Error;
use crate::conversation::ConversationId; use crate::conversation::ConversationId;
use crate::ids::{AgentId, ProjectId, ScheduleId, TaskId}; use crate::ids::{AgentId, ProjectId, RendezvousId, ScheduleId, TaskId};
use crate::mailbox::TicketId; use crate::mailbox::TicketId;
/// Maximum length for human-facing task labels. /// Maximum length for human-facing task labels.
@ -59,6 +59,22 @@ pub enum BackgroundTaskWakePolicy {
RecordOnly, 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<AgentId>,
/// 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. /// Kind-specific payload of a background task.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "kind")] #[serde(rename_all = "camelCase", tag = "kind")]
@ -107,6 +123,9 @@ pub struct BackgroundTask {
pub owner_agent_id: AgentId, pub owner_agent_id: AgentId,
/// Kind-specific payload. /// Kind-specific payload.
pub kind: BackgroundTaskKind, pub kind: BackgroundTaskKind,
/// Optional composite rendezvous this task participates in.
#[serde(default)]
pub rendezvous: Option<BackgroundTaskRendezvousLink>,
/// Current lifecycle state. /// Current lifecycle state.
pub state: BackgroundTaskState, pub state: BackgroundTaskState,
/// Completion wake policy. /// Completion wake policy.
@ -143,6 +162,7 @@ impl BackgroundTask {
project_id, project_id,
owner_agent_id, owner_agent_id,
kind, kind,
rendezvous: None,
state: BackgroundTaskState::Queued, state: BackgroundTaskState::Queued,
wake_policy, wake_policy,
created_at_ms: now_ms, created_at_ms: now_ms,
@ -155,6 +175,13 @@ impl BackgroundTask {
Ok(task) 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. /// Returns a copy moved to a non-terminal state.
/// ///
/// Terminal transitions must use [`Self::complete`] so the result/state /// Terminal transitions must use [`Self::complete`] so the result/state

View File

@ -115,6 +115,10 @@ typed_id!(
/// Identifies a first-class background task. /// Identifies a first-class background task.
TaskId TaskId
); );
typed_id!(
/// Identifies one composite inter-agent business rendezvous.
RendezvousId
);
/// Runtime-only key for an agent scoped by its project. /// Runtime-only key for an agent scoped by its project.
/// ///

View File

@ -79,8 +79,8 @@ pub use error::DomainError;
pub use events::RendezvousContext; pub use events::RendezvousContext;
pub use ids::{ pub use ids::{
AgentId, IssueId, LayoutId, LocalModelServerId, NodeId, ProfileId, ProjectId, RuntimeAgentKey, AgentId, IssueId, LayoutId, LocalModelServerId, NodeId, ProfileId, ProjectId, RendezvousId,
ScheduleId, SessionId, SkillId, SprintId, TabId, TaskId, TemplateId, WindowId, RuntimeAgentKey, ScheduleId, SessionId, SkillId, SprintId, TabId, TaskId, TemplateId, WindowId,
}; };
pub use project::{Project, ProjectPath}; pub use project::{Project, ProjectPath};
@ -95,9 +95,10 @@ pub use mcp_tool_permissions::{
}; };
pub use background_task::{ pub use background_task::{
BackgroundTask, BackgroundTaskError, BackgroundTaskKind, BackgroundTaskResult, BackgroundTask, BackgroundTaskError, BackgroundTaskKind, BackgroundTaskRendezvousLink,
BackgroundTaskState, BackgroundTaskWakePolicy, BACKGROUND_TASK_LABEL_MAX_CHARS, BackgroundTaskResult, BackgroundTaskState, BackgroundTaskWakePolicy,
BACKGROUND_TASK_OUTPUT_TAIL_MAX_BYTES, BACKGROUND_TASK_TEXT_MAX_BYTES, BACKGROUND_TASK_LABEL_MAX_CHARS, BACKGROUND_TASK_OUTPUT_TAIL_MAX_BYTES,
BACKGROUND_TASK_TEXT_MAX_BYTES,
}; };
pub use skill::{Skill, SkillRef, SkillScope}; pub use skill::{Skill, SkillRef, SkillScope};

View File

@ -1,8 +1,9 @@
//! Pure invariants for first-class background tasks. //! Pure invariants for first-class background tasks.
use domain::{ use domain::{
BackgroundTask, BackgroundTaskError, BackgroundTaskKind, BackgroundTaskResult, BackgroundTask, BackgroundTaskError, BackgroundTaskKind, BackgroundTaskRendezvousLink,
BackgroundTaskState, BackgroundTaskWakePolicy, BackgroundTaskResult, BackgroundTaskState, BackgroundTaskWakePolicy, ConversationId,
RendezvousId, TicketId,
}; };
use uuid::Uuid; 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 { fn failure_result(at: u64) -> BackgroundTaskResult {
BackgroundTaskResult::Failure { BackgroundTaskResult::Failure {
finished_at_ms: at, finished_at_ms: at,