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,
command,
wake_policy,
rendezvous: None,
deadline_ms: request.deadline_ms,
})
.await

View File

@ -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 })

View File

@ -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).

View File

@ -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();

View File

@ -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>>,

View File

@ -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

View File

@ -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.
///

View File

@ -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};

View File

@ -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,