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

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