use std::collections::BTreeSet;
use argonauts_core::TaskId;
use argonauts_core::display_names::DisplayNames;
use argonauts_core::events::RunEventKind;
use argonauts_core::events::agent_status::{
FallbackEvent, FallbackPlan, JobKeeper, JobRef, JobWait,
};
use argonauts_core::events::approval::{
AgentDownPacket, AnswerAction, ApprovalDecided, ApprovalKind, ApprovalOpened, ApprovalPacket,
FreeText, PausedTask,
};
use argonauts_core::events::shared::Recipient;
use argonauts_core::events::task::TaskState;
use argonauts_core::limits::HealthState;
use argonauts_core::names::{AgentId, ApprovalId, ModelRef, RoleName};
use argonauts_core::policy::EvidenceFacts;
use argonauts_core::settings::fallback::{AppliedFallback, FallbackMode};
use argonauts_core::{Note, NoteLevel};
use time::OffsetDateTime;
use crate::approvals::{self, ApprovalRequest, Opening, autopilot};
use crate::blackboard::commands;
use crate::engine::host::Host;
use crate::engine::job_fallback::Followed;
use crate::engine::shared::lock;
use crate::error::{EngineError, EngineResult};
use crate::fallback::{ask, assign, broken, builders_provider, choose, rules};
use crate::state::RunState;
use crate::state::approvals::ApprovalRecord;
use crate::state::tasks::TaskEntry;
use crate::task::support::task_events;
const SAME_CLI: &str = "same_cli";
enum Paused {
Task(Box<TaskEntry>),
Job(Box<JobWait>, TaskId),
}
impl Paused {
fn listed(&self) -> PausedTask {
match self {
Self::Task(record) => PausedTask {
task: record.spec.id.clone(),
title: record.spec.title.clone(),
role: RoleName::Worker,
},
Self::Job(wait, listed_as) => PausedTask {
task: listed_as.clone(),
title: wait.title.clone(),
role: wait.job.role,
},
}
}
fn key(&self) -> Key {
let listed = self.listed();
(listed.task, listed.role)
}
fn evidence(&self) -> String {
let (id, role) = self.key();
match self {
Self::Task(_) => format!("paused {id}"),
Self::Job(..) => format!("paused {id} as {}", role.as_str()),
}
}
fn elsewhere(&self, host: &Host) -> Vec<ModelRef> {
match self {
Self::Task(record) => elsewhere(host, record),
Self::Job(wait, _) => {
let takes_work = |model: &&ModelRef| rules::takes_work(host, model);
wait.elsewhere.iter().filter(takes_work).cloned().collect()
}
}
}
}
type Key = (TaskId, RoleName);
pub(super) fn listed_as(job: &JobRef) -> Option<TaskId> {
if let Some(task) = &job.task {
return Some(task.clone());
}
let place = match &job.step {
Some(step) => {
let path: Vec<String> = step
.path
.iter()
.map(|index| (index + 1).to_string())
.collect();
format!("phase-{}-step-{}", step.phase + 1, path.join("."))
}
None => job.role.as_str().to_owned(),
};
TaskId::new(place).ok()
}
enum Next {
Nothing,
Asked(Opening),
Answered(Box<ApprovalDecided>, Box<AgentDownPacket>),
}
pub fn ask_in_background(host: &Host, agent_id: &AgentId) -> EngineResult<()> {
let followed = Followed::Cli(agent_id.clone());
if !lock(&host.shared().job_fallbacks).follow(followed.clone()) {
return next(host, agent_id).map(drop);
}
let next = match next(host, agent_id) {
Ok(Next::Nothing) => {
lock(&host.shared().job_fallbacks).unfollow(&followed);
return Ok(());
}
Err(err) => {
lock(&host.shared().job_fallbacks).unfollow(&followed);
return Err(err);
}
Ok(next) => next,
};
let newly_asked = matches!(next, Next::Asked(_));
let (host, agent_id) = (host.clone(), agent_id.clone());
tokio::spawn(async move {
let ended = tokio::select! {
() = host.interrupted().cancelled() => None,
result = follow(&host, &agent_id, next) => Some(result),
};
lock(&host.shared().job_fallbacks).unfollow(&followed);
let Some(result) = ended else {
return;
};
let settled = newly_asked && result.is_ok();
let cli = DisplayNames::default().cli(&agent_id.0);
let what = format!("The question about {cli} being down");
report(&host, None, result, &what);
if settled {
report(
&host,
None,
ask_again(&host),
"Asking about a CLI that is down",
);
}
});
Ok(())
}
pub fn ask_again(host: &Host) -> EngineResult<()> {
let waited_for: BTreeSet<AgentId> = host.read(|state| {
let tasks = state
.tasks
.by_id
.values()
.filter(|record| record.state == TaskState::NeedsDecision)
.filter_map(|record| state.tasks.progress(&record.spec.id).waiting_for.clone());
let jobs = state.run.waiting_jobs.iter();
tasks
.chain(jobs.map(|wait| wait.stopped.agent_id()))
.collect()
});
for agent_id in waited_for {
ask_in_background(host, &agent_id)?;
}
Ok(())
}
pub fn job_waits(host: &Host, wait: &JobWait) -> EngineResult<()> {
if !host.read(|state| state.run.waiting_jobs.contains(wait)) {
let waiting = FallbackEvent::JobWaiting(wait.clone());
host.record(None, None, RunEventKind::Fallback(waiting))?;
}
match wait.kept_by {
JobKeeper::SameCli => ask_in_background(host, &wait.stopped.agent_id()),
JobKeeper::BuildersProvider { .. } => builders_provider::ask(host, wait),
}
}
pub fn on_decided(host: &Host, opened: &ApprovalOpened, decided: &ApprovalDecided) {
let ApprovalPacket::AgentDown(packet) = &opened.packet else {
return;
};
if packet.kept_by.is_some() {
let freed = free_jobs(host, packet, decided);
report(host, None, freed, "Letting a job leave its CLI");
} else {
builders_provider::on_decided(host, packet, decided);
}
let passed = pass_note_on(host, packet, decided);
report(host, None, passed, "Passing your note to the paused tasks");
}
fn pass_note_on(
host: &Host,
packet: &AgentDownPacket,
decided: &ApprovalDecided,
) -> EngineResult<()> {
let note = decided.text.as_deref().map(str::trim).unwrap_or_default();
if note.is_empty() {
return Ok(());
}
let builds = packet
.paused
.iter()
.filter(|paused| paused.role == RoleName::Worker)
.filter(|paused| host.read(|state| state.tasks.get(&paused.task).is_some()));
for paused in builds {
let task = paused.task.clone();
commands::mention_to(host, Recipient::Task { task }, note.to_owned())?;
}
Ok(())
}
fn free_jobs(host: &Host, packet: &AgentDownPacket, decided: &ApprovalDecided) -> EngineResult<()> {
let one = match (&decided.action, &packet.fits_all) {
(AnswerAction::OneForAll, one) => one.as_ref(),
(AnswerAction::PerTask, _) => None,
_ => return Ok(()),
};
let listed = listed(packet);
for paused in paused(host, &packet.agent_id) {
let Paused::Job(wait, _) = &paused else {
continue;
};
if !listed.contains(&paused.key()) {
continue;
}
let freed = FallbackEvent::JobFreed {
job: wait.job.clone(),
to: one.filter(|one| wait.elsewhere.contains(one)).cloned(),
by: decided.by.clone(),
};
host.record(None, None, RunEventKind::Fallback(freed))?;
}
Ok(())
}
pub fn withdraw(host: &Host, agent_id: &AgentId) -> EngineResult<()> {
let open = host.read(|state| {
latest(state, agent_id)
.filter(|record| record.is_open())
.map(|record| record.opened.id.clone())
});
match open {
Some(id) => {
let cli = DisplayNames::default().cli(&agent_id.0);
approvals::withdraw(host, &id, &format!("{cli} is tried again"))
}
None => Ok(()),
}
}
async fn follow(host: &Host, agent_id: &AgentId, mut next: Next) -> EngineResult<()> {
loop {
let opening = match next {
Next::Nothing => return Ok(()),
Next::Answered(decided, packet) => {
return apply(host, agent_id, &packet, &decided).await;
}
Next::Asked(opening) => opening,
};
let id = opening.id().clone();
next = match opening.decided_or_withdrawn(host).await? {
Ok(decided) => match host.read(|state| packet_of(state, &id)) {
Some(packet) => Next::Answered(Box::new(decided), Box::new(packet)),
None => Next::Nothing,
},
Err(_withdrawn) => self::next(host, agent_id)?,
};
}
}
fn next(host: &Host, agent_id: &AgentId) -> EngineResult<Next> {
let paused = paused(host, agent_id);
let health = host.services().health.state(agent_id);
let asks = !paused.is_empty() && health.state == HealthState::Down;
let latest = host.read(|state| {
latest(state, agent_id).map(|record| (record.opened.id.clone(), record.decided.clone()))
});
match latest {
Some((id, None)) if !asks => {
approvals::withdraw(host, &id, "no task waits for it any more")?;
return Ok(Next::Nothing);
}
Some((_, None)) | None => {}
Some((id, Some(decided))) => {
let Some(packet) = host.read(|state| packet_of(state, &id)) else {
return Ok(Next::Nothing);
};
let waiting = keys(&paused);
let listed = listed(&packet);
if decided.action == AnswerAction::Wait {
let this_try = seconds(packet.test_at) == seconds(health.next_test_at);
if waiting.is_subset(&listed) && this_try {
return Ok(Next::Nothing);
}
} else if !waiting.is_disjoint(&listed) {
free_jobs(host, &packet, &decided)?;
return Ok(Next::Answered(Box::new(decided), Box::new(packet)));
}
}
}
if !asks || autopilot::applies_now(host, ApprovalKind::AgentDown) {
return Ok(Next::Nothing);
}
if host.read(|state| another_is_asked_about(state, agent_id)) {
return Ok(Next::Nothing);
}
approvals::open(host, request(host, agent_id, &paused)).map(Next::Asked)
}
fn another_is_asked_about(state: &RunState, agent_id: &AgentId) -> bool {
state
.approvals
.open_for(ApprovalKind::AgentDown, None)
.is_some_and(|record| {
!matches!(
&record.opened.packet,
ApprovalPacket::AgentDown(down) if down.agent_id == *agent_id
)
})
}
async fn apply(
host: &Host,
agent_id: &AgentId,
packet: &AgentDownPacket,
decided: &ApprovalDecided,
) -> EngineResult<()> {
free_jobs(host, packet, decided)?;
let listed = listed(packet);
let waiting = paused(host, agent_id);
let still = waiting.iter().filter_map(|paused| match paused {
Paused::Task(record) if listed.contains(&paused.key()) => Some(&record.spec.id),
_ => None,
});
match (&decided.action, &packet.fits_all) {
(AnswerAction::Wait, _) => Ok(()),
(AnswerAction::OneForAll, Some(to)) => {
for task in still {
let record = rules::record(host, task)?;
let Some(stopped) = rules::worker(&record) else {
continue;
};
let note = host
.services()
.fallback
.note(&record.spec, &task_events(host, task)?);
let tag = rules::applied_for(host, &record.spec, &stopped).checkpoint;
assign::fallback(host, task, to, ¬e, tag, decided.by.clone()).await?;
}
Ok(())
}
(AnswerAction::OneForAll | AnswerAction::PerTask, _) => {
for task in still {
ask_task_in_background(host, task);
}
Ok(())
}
(other, _) => Err(EngineError::Refused(format!(
"the agent-down question doesn't offer {other:?}"
))),
}
}
fn ask_task_in_background(host: &Host, task: &TaskId) {
let followed = Followed::Task(task.clone());
if !lock(&host.shared().job_fallbacks).follow(followed.clone()) {
return;
}
let (host, task) = (host.clone(), task.clone());
tokio::spawn(async move {
let result = tokio::select! {
() = host.interrupted().cancelled() => Ok(()),
result = ask_task(&host, &task) => result,
};
lock(&host.shared().job_fallbacks).unfollow(&followed);
report(
&host,
Some(&task),
result,
&format!("The fallback question for {task}"),
);
});
}
async fn ask_task(host: &Host, task: &TaskId) -> EngineResult<()> {
let record = rules::record(host, task)?;
let Some(stopped) = rules::worker(&record) else {
return Ok(());
};
let rules = rules::applied_for(host, &record.spec, &stopped);
let note = host
.services()
.fallback
.note(&record.spec, &task_events(host, task)?);
let query = choose::query(host, &record, &stopped, true);
let plan = FallbackPlan {
task: task.clone(),
title: record.spec.title.clone(),
reason: note.why_stopped.clone(),
resets_at: host
.services()
.health
.state(&stopped.agent_id())
.next_test_at,
work: note.work.stat,
checks_ran: broken::verdict(host, task)?.is_some(),
candidates: host.services().fallback.candidates(&query),
mode: rules.mode,
strategy: rules.strategy,
race_count: rules.together,
also_moving: Vec::new(),
stopped,
};
ask::per_task(host, task, plan).await
}
pub(super) fn report(host: &Host, task: Option<&TaskId>, result: EngineResult<()>, what: &str) {
let err = match result {
Ok(()) | Err(EngineError::Interrupted) => return,
Err(err) => err,
};
let note = RunEventKind::Note(Note {
level: NoteLevel::Error,
text: format!("{what} failed: {err}"),
});
if let Err(err) = host.record(task, None, note) {
tracing::error!(%err, what, "could not record a failed question");
}
}
fn request(host: &Host, agent_id: &AgentId, paused: &[Paused]) -> ApprovalRequest {
let services = host.services();
let health = services.health.state(agent_id);
let fits_all = fits_all(host, paused);
let count = match paused.len() {
1 => "1 task waits".to_owned(),
many => format!("{many} tasks wait"),
};
let packet = AgentDownPacket {
agent_id: agent_id.clone(),
evidence: ask::models_out(&services.health.models(agent_id))
.or(health.reason)
.unwrap_or_else(|| "it stopped answering".to_owned()),
test_at: health.next_test_at,
paused: paused.iter().map(Paused::listed).collect(),
fits_all: fits_all.clone(),
kept_by: Some(SAME_CLI.to_owned()),
};
ApprovalRequest {
at: host.read(rules::current_step),
kind: ApprovalKind::AgentDown,
title: format!(
"{} is down · {count} for it",
DisplayNames::default().cli(&agent_id.0)
),
task: None,
packet: ApprovalPacket::AgentDown(packet),
facts: EvidenceFacts::default(),
agent_outputs: paused.iter().map(Paused::evidence).collect(),
documents: Vec::new(),
options: Some(ask::down_options(agent_id, fits_all.is_some())),
free_text: FreeText::Guidance,
authors: Vec::new(),
}
}
fn paused(host: &Host, agent_id: &AgentId) -> Vec<Paused> {
let (tasks, jobs): (Vec<TaskEntry>, Vec<JobWait>) = host.read(|state| {
let tasks = state
.tasks
.by_id
.values()
.filter(|record| record.state == TaskState::NeedsDecision)
.filter(|record| {
state.tasks.progress(&record.spec.id).waiting_for.as_ref() == Some(agent_id)
});
let jobs = state.run.waiting_jobs.iter();
let jobs =
jobs.filter(|wait| wait.stopped.agent_id() == *agent_id && wait.kept_by.is_same_cli());
(tasks.cloned().collect(), jobs.cloned().collect())
});
let kept = |rules: AppliedFallback| rules.same_cli && rules.mode == FallbackMode::Auto;
let tasks = tasks.into_iter().filter(|record| {
rules::worker(record)
.is_some_and(|worker| kept(rules::applied_for(host, &record.spec, &worker)))
});
let jobs = jobs
.into_iter()
.filter(|wait| kept(rules::applied(host, wait.job.role, agent_id)))
.filter_map(|wait| listed_as(&wait.job).map(|id| Paused::Job(Box::new(wait), id)));
tasks
.map(|record| Paused::Task(Box::new(record)))
.chain(jobs)
.collect()
}
fn fits_all(host: &Host, paused: &[Paused]) -> Option<ModelRef> {
let mut lists = paused.iter().map(|paused| paused.elsewhere(host));
let first = lists.next()?;
let rest: Vec<Vec<ModelRef>> = lists.collect();
first
.into_iter()
.find(|worker| rest.iter().all(|list| list.contains(worker)))
}
pub(super) fn elsewhere(host: &Host, record: &TaskEntry) -> Vec<ModelRef> {
let Some(stopped) = rules::worker(record) else {
return Vec::new();
};
let query = choose::query(host, record, &stopped, true);
host.services()
.fallback
.candidates(&query)
.into_iter()
.filter(|candidate| {
!candidate.needs_confirm && candidate.worker.agent_id() != stopped.agent_id()
})
.map(|candidate| candidate.worker)
.collect()
}
fn latest<'s>(state: &'s RunState, agent_id: &AgentId) -> Option<&'s ApprovalRecord> {
state
.approvals
.by_id
.values()
.filter(|record| record.withdrawn.is_none())
.filter(|record| {
matches!(
&record.opened.packet,
ApprovalPacket::AgentDown(down)
if down.agent_id == *agent_id && down.kept_by.is_some()
)
})
.max_by_key(|record| record.since)
}
fn packet_of(state: &RunState, id: &ApprovalId) -> Option<AgentDownPacket> {
match &state.approvals.by_id.get(id)?.opened.packet {
ApprovalPacket::AgentDown(down) => Some(down.clone()),
_ => None,
}
}
fn keys(paused: &[Paused]) -> BTreeSet<Key> {
paused.iter().map(Paused::key).collect()
}
fn listed(packet: &AgentDownPacket) -> BTreeSet<Key> {
packet
.paused
.iter()
.map(|paused| (paused.task.clone(), paused.role))
.collect()
}
fn seconds(at: Option<OffsetDateTime>) -> Option<i64> {
at.map(OffsetDateTime::unix_timestamp)
}