Merge branch 'feature/117-opencode-headless-recovery' into develop
This commit is contained in:
@ -3698,6 +3698,7 @@ pub async fn spawn_background_command(
|
||||
label: request.label,
|
||||
command,
|
||||
wake_policy,
|
||||
rendezvous: None,
|
||||
deadline_ms: request.deadline_ms,
|
||||
})
|
||||
.await
|
||||
|
||||
@ -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<BackgroundTaskRendezvousLink>,
|
||||
/// Optional absolute deadline, epoch milliseconds.
|
||||
pub deadline_ms: Option<u64>,
|
||||
}
|
||||
@ -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<BackgroundTaskRendezvousLink>,
|
||||
deadline_ms: Option<u64>,
|
||||
) -> Result<BackgroundTask, AppError> {
|
||||
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 })
|
||||
|
||||
@ -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<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 {
|
||||
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<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
|
||||
/// `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<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct ActiveRendezvous {
|
||||
link: BackgroundTaskRendezvousLink,
|
||||
background_tasks: Vec<TaskId>,
|
||||
}
|
||||
|
||||
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<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`.
|
||||
///
|
||||
/// 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<AgentId>,
|
||||
target_agent_id: AgentId,
|
||||
ticket_id: TicketId,
|
||||
conversation_id: domain::conversation::ConversationId,
|
||||
link: BackgroundTaskRendezvousLink,
|
||||
) -> Result<Option<TaskId>, 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<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(
|
||||
&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<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,
|
||||
/// dashes and underscores stripped (`"Claude Code"`, `"claude-code"`, `"claude"`
|
||||
/// → comparable forms; `claude` ⊂ ... handled by the command match above).
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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<HashMap<RuntimeAgentKey, VecDeque<InboxItem>>>,
|
||||
@ -147,6 +179,7 @@ impl AgentInbox for FakeInbox {
|
||||
struct SharedTurnState {
|
||||
busy: Mutex<HashMap<RuntimeAgentKey, AgentBusyState>>,
|
||||
tickets: Mutex<HashMap<RuntimeAgentKey, VecDeque<Ticket>>>,
|
||||
replies: Mutex<Vec<(TicketId, String)>>,
|
||||
}
|
||||
|
||||
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<HashMap<TaskId, BackgroundTask>>,
|
||||
|
||||
@ -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<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.
|
||||
#[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<BackgroundTaskRendezvousLink>,
|
||||
/// 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
|
||||
|
||||
@ -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.
|
||||
///
|
||||
|
||||
@ -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};
|
||||
|
||||
@ -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,
|
||||
|
||||
Reference in New Issue
Block a user