use argonauts_core::TaskId;
use argonauts_core::display_names::DisplayNames;
use argonauts_core::events::RunEventKind;
use argonauts_core::events::agent_status::{FallbackEvent, JobKeeper, JobWait};
use argonauts_core::events::approval::{
AgentDownPacket, AnswerAction, AnswerOption, ApprovalDecided, ApprovalKind, ApprovalOpened,
ApprovalPacket, FreeText, PausedTask,
};
use argonauts_core::events::run::Party;
use argonauts_core::names::{ModelRef, RoleName};
use argonauts_core::policy::EvidenceFacts;
use time::OffsetDateTime;
use crate::approvals::{self, ApprovalRequest, Opening};
use crate::engine::host::Host;
use crate::engine::job_fallback::{verb_of, work_of};
use crate::error::EngineResult;
use crate::fallback::down::{listed_as, report};
use crate::fallback::rules;
use crate::roles::display_name;
use crate::state::RunState;
const MERGE_IS_YOURS: &str = "the merge is left to you";
#[derive(Debug, Clone, PartialEq)]
struct Listed {
task: TaskId,
role: RoleName,
}
impl Listed {
fn of(wait: &JobWait) -> Option<Self> {
Some(Self {
task: listed_as(&wait.job)?,
role: wait.job.role,
})
}
fn lists(&self, packet: &AgentDownPacket) -> bool {
packet.kept_by.is_none()
&& matches!(packet.paused.as_slice(), [paused] if paused.task == self.task && paused.role == self.role)
}
}
pub fn ask(host: &Host, wait: &JobWait) -> EngineResult<()> {
let Some(listed) = Listed::of(wait) else {
return Ok(());
};
let test_at = host
.services()
.health
.state(&wait.stopped.agent_id())
.next_test_at;
let latest = host.read(|state| latest(state, &listed));
let mut picking = false;
if let Some((opened, decided)) = latest {
match decided.map(|decided| decided.action) {
None | Some(AnswerAction::Candidate { .. }) => picking = !offers_a_pick(&opened),
Some(AnswerAction::Wait) if still_waits(&opened, wait, test_at) => return Ok(()),
Some(AnswerAction::OtherCandidates) => picking = true,
_ => {}
}
}
let request = request(host, wait, &listed, picking, test_at);
match approvals::open(host, request)? {
Opening::Decided(decided) => apply(host, wait, &decided),
Opening::Waiting { .. } => Ok(()),
}
}
pub fn on_decided(host: &Host, packet: &AgentDownPacket, decided: &ApprovalDecided) {
let Some(wait) = host.read(|state| waiting(state, packet)) else {
return;
};
let task = wait.job.task.clone();
match &decided.action {
AnswerAction::Candidate { worker } => {
let freed = free(host, &wait, worker, decided.by.clone());
report(
host,
task.as_ref(),
freed,
"Letting the agent you chose take a job",
);
}
AnswerAction::OtherCandidates => {
let Ok(runtime) = tokio::runtime::Handle::try_current() else {
return;
};
let host = host.clone();
runtime.spawn(async move {
let asked = ask(&host, &wait);
report(&host, task.as_ref(), asked, "Listing every agent for a job");
});
}
_ => {}
}
}
fn apply(host: &Host, wait: &JobWait, decided: &ApprovalDecided) -> EngineResult<()> {
match &decided.action {
AnswerAction::Candidate { worker } => free(host, wait, worker, decided.by.clone()),
AnswerAction::OtherCandidates => ask(host, wait),
_ => Ok(()),
}
}
fn free(host: &Host, wait: &JobWait, worker: &ModelRef, by: Party) -> EngineResult<()> {
if !wait.elsewhere.contains(worker) {
return Ok(());
}
let freed = FallbackEvent::JobFreed {
job: wait.job.clone(),
to: Some(worker.clone()),
by,
};
host.record(wait.job.task.as_ref(), None, RunEventKind::Fallback(freed))?;
Ok(())
}
fn waiting(state: &RunState, packet: &AgentDownPacket) -> Option<JobWait> {
state
.run
.waiting_jobs
.iter()
.filter(|wait| matches!(wait.kept_by, JobKeeper::BuildersProvider { .. }))
.find(|wait| Listed::of(wait).is_some_and(|listed| listed.lists(packet)))
.cloned()
}
fn latest(state: &RunState, listed: &Listed) -> Option<(ApprovalOpened, Option<ApprovalDecided>)> {
state
.approvals
.by_id
.values()
.filter(|record| record.withdrawn.is_none())
.filter(|record| match &record.opened.packet {
ApprovalPacket::AgentDown(down) => listed.lists(down),
_ => false,
})
.max_by_key(|record| record.since)
.map(|record| (record.opened.clone(), record.decided.clone()))
}
fn offers_a_pick(opened: &ApprovalOpened) -> bool {
opened
.options
.iter()
.any(|option| option.action == AnswerAction::OtherCandidates)
}
fn still_waits(opened: &ApprovalOpened, wait: &JobWait, test_at: Option<OffsetDateTime>) -> bool {
let ApprovalPacket::AgentDown(down) = &opened.packet else {
return false;
};
let seconds = |at: Option<OffsetDateTime>| at.map(OffsetDateTime::unix_timestamp);
down.fits_all.as_ref() == wait.elsewhere.first() && seconds(down.test_at) == seconds(test_at)
}
fn request(
host: &Host,
wait: &JobWait,
listed: &Listed,
picking: bool,
test_at: Option<OffsetDateTime>,
) -> ApprovalRequest {
let cli = DisplayNames::default().cli(&wait.stopped.agent);
let verb = verb_of(listed.role);
let (builders, why) = match &wait.kept_by {
JobKeeper::BuildersProvider { builders, why } => (builders.as_slice(), why.as_str()),
JobKeeper::SameCli => (&[][..], ""),
};
let evidence = format!(
"{why}, and nobody outside the builder's provider is left to {verb} {}",
work_of(builders)
);
let providers: Vec<String> = builders
.iter()
.map(|builder| host.services().registry.provider(builder))
.collect();
let theirs = |model: &ModelRef| providers.contains(&host.services().registry.provider(model));
let packet = AgentDownPacket {
agent_id: wait.stopped.agent_id(),
evidence: argonauts_core::text::sentence_case(&evidence),
test_at,
paused: vec![PausedTask {
task: listed.task.clone(),
title: wait.title.clone(),
role: listed.role,
}],
fits_all: wait.elsewhere.first().cloned(),
kept_by: None,
};
let mut options = vec![offer('w', format!("Wait for {cli}"), AnswerAction::Wait)];
options[0].recommended = true;
let detail = |model: &ModelRef| theirs(model).then(|| MERGE_IS_YOURS.to_owned());
if picking {
for (index, model) in wait.elsewhere.iter().enumerate() {
let mut pick = offer(key(index), display_name(model), take(model));
pick.detail = detail(model);
options.push(pick);
}
} else if let Some(first) = wait.elsewhere.first() {
let mut first_one = offer(
'l',
format!("Let {} {verb}", display_name(first)),
take(first),
);
first_one.detail = detail(first);
options.push(first_one);
if wait.elsewhere.len() > 1 {
let mut pick = offer(
'p',
"Pick an agent".to_owned(),
AnswerAction::OtherCandidates,
);
pick.detail = Some("from every agent that may take it".to_owned());
options.push(pick);
}
}
let seconds = test_at.map_or_else(|| "none".to_owned(), |at| at.unix_timestamp().to_string());
let offered: Vec<String> = wait.elsewhere.iter().map(ModelRef::to_string).collect();
let mut agent_outputs = vec![
format!("paused {} as {}", listed.task, listed.role.as_str()),
format!("may take it: {}", offered.join(", ")),
format!("next try of {cli}: {seconds}"),
];
let title = if picking {
agent_outputs.push("you pick an agent".to_owned());
format!("Pick an agent for {}", wait.title.trim())
} else {
format!(
"{} ยท {} waits for it",
argonauts_core::text::sentence_case(why),
wait.title.trim()
)
};
ApprovalRequest {
at: host.read(rules::current_step),
kind: ApprovalKind::AgentDown,
title,
task: wait.job.task.clone(),
packet: ApprovalPacket::AgentDown(packet),
facts: EvidenceFacts::default(),
agent_outputs,
documents: Vec::new(),
options: Some(options),
free_text: FreeText::None,
authors: Vec::new(),
}
}
fn offer(key: char, label: String, action: AnswerAction) -> AnswerOption {
AnswerOption {
key,
label,
action,
recommended: false,
detail: None,
}
}
fn take(model: &ModelRef) -> AnswerAction {
AnswerAction::Candidate {
worker: model.clone(),
}
}
fn key(index: usize) -> char {
('a'..='z')
.filter(|letter| *letter != 'w')
.nth(index)
.unwrap_or('z')
}