use std::collections::BTreeMap;
use std::path::Path;
use std::sync::atomic::AtomicU32;
use std::time::Duration;
use futures::StreamExt;
use futures::stream::FuturesUnordered;
use tokio_util::sync::CancellationToken;
use argonauts_core::display_names::DisplayNames;
use argonauts_core::events::session::SessionEvent;
use argonauts_core::events::task::TaskEvent;
use argonauts_core::events::{RunEvent, RunEventKind};
use argonauts_core::limits::FallbackNote;
use argonauts_core::names::{ModelRef, RoleName, SessionKey};
use argonauts_core::session_runner::SessionControl;
use argonauts_core::settings::fallback::Pick;
use argonauts_core::steps::StepName;
use argonauts_core::{Note, NoteLevel, TaskId};
use crate::engine::host::Host;
use crate::engine::job_fallback;
use crate::error::{EngineError, EngineResult};
use crate::fallback::{assign, judge, notes, rules, upgrade};
use crate::labels;
use crate::roles::display_name;
use crate::state::RunState;
use crate::state::sessions::SessionRecord;
use crate::task::pipeline::{self, TryCall, TryEnd};
use crate::task::support::{newest_worker_session, task_events};
use crate::task::worktree::{self, TaskTree};
use crate::workflow::StepAt;
const STOP_POLL: Duration = Duration::from_millis(100);
struct Entrant {
worker: ModelRef,
tree: TaskTree,
first_slot: u32,
note: FallbackNote,
}
struct RaceLog {
since: Option<u64>,
highest: u32,
started: BTreeMap<ModelRef, u32>,
}
pub async fn run(
host: &Host,
task: &TaskId,
to: &[ModelRef],
pick: Pick,
note: &FallbackNote,
) -> EngineResult<Option<ModelRef>> {
let at = rules::step_now(host)?;
assign::write_note(host, task, note).await?;
let record = rules::record(host, task)?;
let start = match &record.branch {
Some(branch) => Some(host.git().resolve(&host.repo().root, branch).await?),
None => None,
};
let log = race_log(host, task)?;
let entrants = entrants(host, task, to, &at, &log, note).await?;
let stop = CancellationToken::new();
let rounds = AtomicU32::new(first_round(host, task)?);
let until = match pick {
Pick::Judge => Until::AllEnded,
Pick::FirstPassing | Pick::Merge => Until::FirstPassed,
};
if log.started.is_empty() {
say_who_takes_it(host, task, &entrants, until)?;
}
let (passed, error) = race(host, task, &entrants, &at, &rounds, &stop, until).await;
if matches!(error, Some(EngineError::Interrupted)) {
return Err(EngineError::Interrupted);
}
let winner = match passed.as_slice() {
[] => None,
[only] => {
say_who_passed(host, task, only, &entrants, until)?;
Some(only.clone())
}
several => {
let tries: Vec<judge::Passed<'_>> = several
.iter()
.filter_map(|worker| entrants.iter().find(|entrant| &entrant.worker == worker))
.map(|entrant| judge::Passed {
worker: &entrant.worker,
tree: &entrant.tree,
})
.collect();
Some(judge::pick(host, task, &tries).await?)
}
};
for entrant in &entrants {
if Some(&entrant.worker) != winner.as_ref() {
keep(host, task, entrant, log.since).await?;
}
}
let Some(winner) = winner else {
notes::catch_up(host, task).await?;
return error.map_or(Ok(None), Err);
};
if let Some(entrant) = entrants.iter().find(|entrant| entrant.worker == winner) {
take_the_winners_work(host, task, &entrant.tree, start.as_deref()).await?;
}
notes::catch_up(host, task).await?;
Ok(Some(winner))
}
fn say_who_takes_it(
host: &Host,
task: &TaskId,
entrants: &[Entrant],
until: Until,
) -> EngineResult<()> {
let (label, stopped) = host.read(|state| {
let stopped = newest_worker_session(state, task).cloned();
(labels::task_label(state, task), stopped)
});
let still = stopped.and_then(|session| job_fallback::still_out(host, &session));
let names: Vec<String> = entrants
.iter()
.map(|entrant| display_name(&entrant.worker))
.collect();
let together = match names.as_slice() {
[one, other] => format!("{one} and {other} both take {label}"),
names => format!("{} all take {label}", joined(names)),
};
let how = match until {
Until::FirstPassed => "the first to pass the checks wins",
Until::AllEnded => "the judge picks among those that pass the checks",
};
let text = format!("{together} · {how} · /fallback changes this");
match still {
Some(still) => note(host, task, NoteLevel::Warn, format!("{still} · {text}")),
None => note(host, task, NoteLevel::Info, text),
}
}
fn say_who_passed(
host: &Host,
task: &TaskId,
winner: &ModelRef,
entrants: &[Entrant],
until: Until,
) -> EngineResult<()> {
let label = host.read(|state| labels::task_label(state, task));
let names = DisplayNames::default();
let others: Vec<String> = entrants
.iter()
.filter(|entrant| &entrant.worker != winner)
.map(|entrant| names.short(&entrant.worker))
.collect();
let winner = display_name(winner);
let text = match until {
Until::FirstPassed => format!(
"{label} · {winner} passed first · {} stopped",
tries_of(&others)
),
Until::AllEnded => format!("{label} · only {winner}'s try passed the checks"),
};
note(host, task, NoteLevel::Info, text)
}
fn tries_of(names: &[String]) -> String {
match names {
[one] => format!("{one}'s try"),
names => format!("the tries of {}", joined(names)),
}
}
fn joined(names: &[String]) -> String {
match names {
[] => String::new(),
[one] => one.clone(),
[rest @ .., last] => format!("{} and {last}", rest.join(", ")),
}
}
fn note(host: &Host, task: &TaskId, level: NoteLevel, text: String) -> EngineResult<()> {
host.record(Some(task), None, RunEventKind::Note(Note { level, text }))?;
Ok(())
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Until {
FirstPassed,
AllEnded,
}
async fn race(
host: &Host,
task: &TaskId,
entrants: &[Entrant],
at: &StepAt,
rounds: &AtomicU32,
stop: &CancellationToken,
until: Until,
) -> (Vec<ModelRef>, Option<EngineError>) {
let mut running: FuturesUnordered<_> = entrants
.iter()
.map(|entrant| async move {
let call = TryCall {
task,
worker: &entrant.worker,
tree: &entrant.tree,
at,
first_slot: entrant.first_slot,
note: &entrant.note,
rounds,
stop,
};
(&entrant.worker, pipeline::run_try(host, call).await)
})
.collect();
let mut passed: Vec<ModelRef> = Vec::new();
let mut error = None;
let mut stopped: Vec<SessionKey> = Vec::new();
loop {
let next = if stop.is_cancelled() {
stop_losers(host, task, entrants, passed.first(), &mut stopped);
tokio::select! {
next = running.next() => next,
() = tokio::time::sleep(STOP_POLL) => continue,
}
} else {
running.next().await
};
let Some((worker, end)) = next else {
break;
};
match end {
Ok(TryEnd::Passed) if !stop.is_cancelled() => {
passed.push(worker.clone());
if until == Until::FirstPassed {
stop.cancel();
}
}
Ok(_) => {}
Err(EngineError::Interrupted) => return (passed, Some(EngineError::Interrupted)),
Err(err) => {
error.get_or_insert(err);
}
}
}
(passed, error)
}
fn stop_losers(
host: &Host,
task: &TaskId,
entrants: &[Entrant],
winner: Option<&ModelRef>,
stopped: &mut Vec<SessionKey>,
) {
let keys: Vec<SessionKey> = host.read(|state| {
state
.sessions
.running()
.filter(|session| {
session.task.as_ref() == Some(task)
&& session.started.role == RoleName::Worker
&& Some(&session.started.worker) != winner
&& entrants
.iter()
.any(|entrant| entrant.worker == session.started.worker)
&& !stopped.contains(&session.key)
})
.map(|session| session.key.clone())
.collect()
});
for key in keys {
if host.control(&key, SessionControl::Stop).is_ok() {
stopped.push(key);
}
}
}
async fn keep(
host: &Host,
task: &TaskId,
entrant: &Entrant,
since: Option<u64>,
) -> EngineResult<()> {
let session = host.read(|state| {
worker_sessions(state, task, &entrant.worker)
.filter(|session| since.is_some_and(|since| session.since > since))
.max_by_key(|session| session.since)
.map(|session| session.key.clone())
});
let by = Some(entrant.worker.clone());
worktree::commit_in(host, task, &entrant.tree, session.as_ref(), by)
.await
.map(drop)
}
async fn entrants(
host: &Host,
task: &TaskId,
to: &[ModelRef],
at: &StepAt,
log: &RaceLog,
note: &FallbackNote,
) -> EngineResult<Vec<Entrant>> {
let task_tree = rules::record(host, task)?.worktree;
let mut next = log.started.values().copied().fold(log.highest, u32::max) + 1;
let mut entrants: Vec<Entrant> = Vec::new();
for worker in to {
if entrants.iter().any(|entrant| entrant.worker == *worker) {
continue;
}
let try_number = match log.started.get(worker) {
Some(try_number) => *try_number,
None => {
while worktree::try_taken(host, task, next).await? {
next += 1;
}
next += 1;
next - 1
}
};
let tree = worktree::try_tree(host, task, try_number).await?;
let first_slot = host.read(|state| sessions_before(state, task, worker, at, log.since));
let note = own_worktree(note, task_tree.as_deref(), &tree.path).await;
entrants.push(Entrant {
worker: worker.clone(),
tree,
first_slot,
note,
});
}
Ok(entrants)
}
async fn own_worktree(note: &FallbackNote, task_tree: Option<&Path>, tree: &Path) -> FallbackNote {
let mut note = note.clone();
let Some(paths) = note.transcript.as_mut() else {
return note;
};
let Some(inside) = task_tree.and_then(|task_tree| paths.worktree.strip_prefix(task_tree).ok())
else {
return note;
};
let own = tree.join(inside);
let copied = async {
if let Some(dir) = own.parent() {
tokio::fs::create_dir_all(dir).await?;
}
tokio::fs::copy(&paths.worktree, &own).await
};
match copied.await {
Ok(_) => paths.worktree = own,
Err(error) => tracing::warn!(
path = %own.display(),
%error,
"a race try is told of the task's transcript, not a copy in its own tree"
),
}
note
}
fn race_log(host: &Host, task: &TaskId) -> EngineResult<RaceLog> {
let events = task_events(host, task)?;
let run = host.run_id().clone();
let committed = host.read(|state| {
state.tasks.get(task).map_or(0, |record| {
record
.commits
.iter()
.filter_map(|commit| worktree::try_of_branch(&run, task, &commit.branch))
.max()
.unwrap_or(0)
})
});
let ours = |event: &&RunEvent| event.task_id.as_ref() == Some(task);
let since = events
.iter()
.filter(ours)
.filter(|event| matches!(event.kind, RunEventKind::Task(TaskEvent::State { .. })))
.map(|event| event.seq)
.max();
let mut log = RaceLog {
since,
highest: committed,
started: BTreeMap::new(),
};
for event in events.iter().filter(ours) {
let (try_number, tree) = match &event.kind {
RunEventKind::Task(TaskEvent::Committed(commit)) => {
(worktree::try_of_branch(&run, task, &commit.branch), false)
}
RunEventKind::Session(SessionEvent::Input(input)) => {
(worktree::try_of_tree(task, &input.launch.cwd), true)
}
_ => (None, false),
};
let Some(try_number) = try_number else {
continue;
};
log.highest = log.highest.max(try_number);
let this_race = since.is_some_and(|since| event.seq > since);
if !(tree && this_race) {
continue;
}
let worker = event.session.as_ref().and_then(|key| {
host.read(|state| {
state
.sessions
.get(key)
.map(|session| session.started.worker.clone())
})
});
if let Some(worker) = worker {
log.started.entry(worker).or_insert(try_number);
}
}
Ok(log)
}
fn sessions_before(
state: &RunState,
task: &TaskId,
worker: &ModelRef,
at: &StepAt,
since: Option<u64>,
) -> u32 {
let round_start = state
.workflows
.by_id
.get(&at.step.workflow)
.and_then(|workflow| {
workflow
.steps
.iter()
.filter(|progress| progress.step == at.step && progress.round == at.round)
.map(|progress| progress.since)
.min()
})
.unwrap_or(0);
let before = worker_sessions(state, task, worker)
.filter(|session| {
session.started.workflow_step.as_ref() == Some(&at.step)
&& session.since >= round_start
&& since.is_none_or(|since| session.since < since)
})
.count();
u32::try_from(before).unwrap_or(u32::MAX)
}
fn worker_sessions<'a>(
state: &'a RunState,
task: &'a TaskId,
worker: &'a ModelRef,
) -> impl Iterator<Item = &'a SessionRecord> {
state.sessions.by_key.values().filter(move |session| {
session.task.as_ref() == Some(task)
&& session.started.worker == *worker
&& session.started.role == RoleName::Worker
&& session.started.step == Some(StepName::Build)
})
}
fn first_round(host: &Host, task: &TaskId) -> EngineResult<u32> {
let record = rules::record(host, task)?;
let above_own = host.settings().review.max_change_rounds + 2;
Ok(upgrade::next_round(&record.reviews).max(above_own))
}
async fn take_the_winners_work(
host: &Host,
task: &TaskId,
tree: &TaskTree,
start: Option<&str>,
) -> EngineResult<()> {
let git = host.git();
let record = rules::record(host, task)?;
let head = git.resolve(&tree.path, "HEAD").await?;
match (&record.worktree, &record.branch) {
(Some(path), _) => {
if !git.fast_forward(path, &head).await? {
git.reset_hard(path, &head).await?;
}
let own = worktree::ensure(host, task).await?;
let own_git = worktree::tree_git(host, &own.path).await?;
worktree::save_diff(host, task, &own_git, &own, &head).await?;
}
(None, Some(branch)) => {
let root = host.repo().root.clone();
let old = match start {
Some(start) => start.to_owned(),
None => git.resolve(&root, branch).await?,
};
git.update_branch(&root, branch, &head, &old).await?;
}
(None, None) => {}
}
Ok(())
}