mod alongside;
pub(crate) mod archive;
pub(super) mod told;
mod track;
#[cfg(test)]
pub(crate) mod rig;
#[cfg(test)]
mod tests;
use std::collections::{BTreeSet, HashMap, HashSet};
use std::sync::Arc;
use anyhow::Context;
use argonauts_core::api::{ApiError, RunState, RunSummary, StartRun};
use argonauts_core::app::{AppEvent, AppMode};
use argonauts_core::events::RunEvent;
use argonauts_core::events::failure::run_failure_line;
use argonauts_core::events::run::{RunMode, RunOutcome};
use argonauts_core::names::{ChoiceId, MessageId, ModelRef};
use argonauts_core::policy::NarratorLine;
use argonauts_core::settings::approvals::ApprovalMode;
use argonauts_core::settings::ui::{ConversationTrigger, NarratorMode};
use argonauts_core::task::title_from_task;
use argonauts_core::{NoteLevel, RunId, Spend, TaskId};
use super::attach::{self, Attached, Opening, Stage};
use super::header_and_bottom_line::RunStatusParts;
use super::notify::{self, NeedsYou};
use super::recap;
use super::{Ctx, Effect, Internal};
use crate::FirstView;
use crate::conversation_store::ConversationRun;
use crate::wake::{WaitingRuns, WakeCause};
pub(crate) struct RunsState {
pub(super) attached: Option<Attached>,
pub(super) attaches: u64,
focused: bool,
pub(super) approval_mode: Option<ApprovalMode>,
pub(super) queued: HashMap<RunId, Vec<Queued>>,
pub(super) choices: HashMap<ChoiceId, RunChoice>,
waking: HashSet<RunId>,
exit_when_done: Option<RunId>,
waiting: WaitingRuns,
listed: Vec<RunSummary>,
background: Vec<RunSummary>,
shown: HashSet<RunId>,
pub(super) ours: BTreeSet<RunId>,
pub(super) opened: Vec<ConversationRun>,
news: Vec<(RunId, RunNews)>,
streaming: alongside::Streaming,
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum RunNews {
Began,
Waiting { line: String },
Ended { outcome: RunOutcome, line: String },
}
#[derive(Debug, Clone, PartialEq)]
pub(super) struct Queued {
pub id: MessageId,
pub text: String,
}
#[derive(Debug, Clone, PartialEq)]
pub(super) enum RunChoice {
OverrideRule {
run: RunId,
task: TaskId,
to: ModelRef,
rule: String,
},
Rewind {
run: RunId,
task: TaskId,
checkpoint: u32,
},
}
#[derive(Debug)]
pub(crate) enum RunUpdate {
Event {
run: RunId,
event: Box<RunEvent>,
},
Ended {
run: RunId,
},
Live {
run: RunId,
live: bool,
},
Narrator {
run: RunId,
line: NarratorLine,
},
Woke {
run: RunId,
cause: WakeCause,
started: Result<(), ApiError>,
},
Listed(Result<Vec<RunSummary>, ApiError>),
}
impl RunsState {
pub(crate) fn waiting_soonest(
&self,
now: time::OffsetDateTime,
) -> Option<time::OffsetDateTime> {
self.waiting.soonest(now)
}
pub(crate) fn new(ctx: &Ctx) -> Self {
let at_terminal = ctx.env.interactive && !ctx.env.agent_session;
let approval_mode = at_terminal
.then(|| super::start_mode::at_start(&ctx.env.state_dir, &ctx.settings().approvals))
.flatten();
Self {
attached: None,
attaches: 0,
focused: true,
approval_mode,
queued: HashMap::new(),
choices: HashMap::new(),
waking: HashSet::new(),
exit_when_done: None,
waiting: WaitingRuns::default(),
listed: Vec::new(),
background: Vec::new(),
shown: HashSet::new(),
ours: BTreeSet::new(),
opened: Vec::new(),
news: Vec::new(),
streaming: alongside::Streaming::default(),
}
}
}
pub(crate) fn take_news(runs: &mut RunsState) -> Vec<(RunId, RunNews)> {
std::mem::take(&mut runs.news)
}
pub(super) async fn give_way(ctx: &mut Ctx, runs: &mut RunsState, mut old: Attached) {
if alongside::keeps_working(&old) {
match alongside::follow(ctx, runs, &mut old).await {
Ok(()) => return,
Err(err) => tracing::warn!("{err:#}"),
}
}
ctx.send(AppEvent::Detached {
run: old.run.clone(),
});
}
pub(super) fn came_on_screen(runs: &mut RunsState, run: &RunId) {
alongside::on_screen(runs, run);
}
pub(crate) fn attached(runs: &RunsState) -> Option<&RunId> {
runs.attached.as_ref().map(|attached| &attached.run)
}
pub(crate) fn followed(runs: &RunsState) -> Option<&RunId> {
match &runs.attached {
Some(attached) => (!attached.left_behind).then_some(&attached.run),
None => runs.ours.last(),
}
}
pub(crate) fn attaches(runs: &RunsState) -> u64 {
runs.attaches
}
pub(crate) fn adopt(runs: &mut RunsState, theirs: impl IntoIterator<Item = RunId>) {
runs.ours.extend(theirs);
}
pub(crate) fn take_opened(runs: &mut RunsState) -> Vec<ConversationRun> {
std::mem::take(&mut runs.opened)
}
pub(crate) fn owns_choice(runs: &RunsState, id: &ChoiceId) -> bool {
runs.choices.contains_key(id)
}
pub(crate) fn narrator_answers(runs: &RunsState, ctx: &Ctx) -> bool {
ctx.settings().ui.narrator != NarratorMode::Off
&& runs.attached.as_ref().is_some_and(|attached| {
!matches!(attached.stage, Stage::Finished(_)) && attached.narrator.is_on()
})
}
pub(crate) fn setup_answered(runs: &mut RunsState, mode: ApprovalMode) {
runs.approval_mode = Some(mode);
}
pub(crate) fn status_parts(runs: &RunsState) -> RunStatusParts {
let attached = runs.attached.as_ref();
RunStatusParts {
run: attached.map(|attached| attached.run.clone()),
spent: attached.and_then(|attached| attached.spent),
limit: attached.and_then(|attached| attached.limit),
approval_mode: match attached {
Some(attached) if !matches!(attached.stage, Stage::Finished(_)) => {
attached.approval_mode
}
_ => runs.approval_mode,
},
}
}
pub(crate) fn focus(runs: &mut RunsState, focused: bool) {
runs.focused = focused;
}
pub(crate) async fn open(
ctx: &mut Ctx,
runs: &mut RunsState,
first: FirstView,
) -> anyhow::Result<Vec<Effect>> {
let effects = vec![Effect::Status];
let list = match ctx.services.conductor.list().await {
Ok(list) => list,
Err(err) => {
ctx.error(&anyhow::Error::new(err).context("listing this repo's runs"));
Vec::new()
}
};
match first {
FirstView::Conversation
| FirstView::Continue
| FirstView::PastConversation(_)
| FirstView::CliSession(_)
| FirstView::Agent(_) => {}
FirstView::ResumePicker => show_runs(ctx, false).await?,
FirstView::Resume(run) => resume_and_attach(ctx, runs, &run).await?,
FirstView::Attach(run) => {
runs.exit_when_done = Some(run.clone());
let summary = summary_of(ctx, &run).await;
super::conversation::redraw::run(ctx, &run);
attach::attach(ctx, runs, summary, Opening::Watch).await?;
}
}
let runs_dir = ctx.env.runs_dir();
for run in list.iter().rev() {
if let Some((level, line)) = told::once(&runs_dir.join(run.run.as_str()), run) {
ctx.notice(level, line);
}
}
send_background(ctx, runs, list);
wake_waiting(ctx, runs).await?;
Ok(effects)
}
pub(crate) fn going_or_waiting(run: &RunSummary) -> bool {
run.live
|| matches!(run.state, RunState::Running | RunState::Pausing)
|| (run.state == RunState::Waiting && !run.waiting.is_empty())
}
pub(crate) fn refresh_background(ctx: &Ctx) {
let conductor = Arc::clone(&ctx.services.conductor);
let internal = ctx.internal.clone();
let runtime = tokio::runtime::Handle::current();
tokio::task::spawn_blocking(move || {
let listed = runtime.block_on(conductor.list());
let _ = internal.send(Internal::Run(RunUpdate::Listed(listed)));
});
}
fn on_listed(ctx: &mut Ctx, runs: &mut RunsState, listed: Result<Vec<RunSummary>, ApiError>) {
match listed {
Ok(list) => {
announce_background(ctx, runs, &list);
send_background(ctx, runs, list);
}
Err(err) => tracing::warn!("listing this repo's runs for the agent panel: {err}"),
}
}
fn announce_background(ctx: &mut Ctx, runs: &RunsState, list: &[RunSummary]) {
let on_screen = attached(runs);
for run in list.iter().filter(|run| Some(&run.run) != on_screen) {
let before = runs.listed.iter().find(|earlier| earlier.run == run.run);
if before.is_none() && started_elsewhere(runs, run) {
let line = started_line(run);
ctx.notice(NoteLevel::Info, line.clone());
if !runs.focused {
notify::send_os_line(ctx, &repo_name(ctx), &line);
}
continue;
}
let (trigger, kind, text) = if run.needs_you() {
let waits_more = match before {
Some(before) => run.waiting.iter().any(|id| !before.waiting.contains(id)),
None => !runs.shown.contains(&run.run),
};
if !waits_more {
continue;
}
let kind = match run.stopped_at.trim() {
"" => "a task".to_owned(),
at => at.to_owned(),
};
let text = if runs.ours.contains(&run.run) {
format!("{} needs you · ↓ to manage", run_called(run))
} else {
format!("{} needs you · /resume {}", run_called(run), run.run)
};
(ConversationTrigger::NeedsYou, kind, text)
} else if let (Some(_), Some(outcome)) = (before, outcome_of(run.state))
&& !alongside::ended_here(runs, &run.run)
{
let open = if runs.ours.contains(&run.run) {
"/resume".to_owned()
} else {
format!("/resume {}", run.run)
};
let text = format!(
"{} {open} shows it.",
track::outcome_sentence(run_called(run), outcome),
);
(
ConversationTrigger::Finished,
track::outcome_label(outcome).to_owned(),
text,
)
} else {
continue;
};
ctx.notice(NoteLevel::Info, text.clone());
let needs = NeedsYou {
repo: repo_name(ctx),
run: run.run.clone(),
approval: run.waiting.last().cloned(),
kind,
spent: Some(run.spent),
trigger,
};
notify_if_away(ctx, runs, &needs);
if trigger == ConversationTrigger::Finished
&& !runs.focused
&& !runs.ours.contains(&run.run)
{
notify::send_os_line(ctx, &needs.repo, &text);
}
}
}
fn started_elsewhere(runs: &RunsState, run: &RunSummary) -> bool {
run.live
&& run.is_open()
&& !run.needs_you()
&& !runs.ours.contains(&run.run)
&& !runs.shown.contains(&run.run)
}
fn started_line(run: &RunSummary) -> String {
match named(run) {
Some(name) => format!(
"A run started in another window: {name} · /resume {} shows it",
run.run
),
None => format!(
"A run started in another window · /resume {} shows it",
run.run
),
}
}
fn named(run: &RunSummary) -> Option<&str> {
[run.name.as_deref(), Some(run.title.as_str())]
.into_iter()
.flatten()
.map(str::trim)
.find(|name| !name.is_empty())
}
fn run_called(run: &RunSummary) -> String {
named(run).map_or_else(|| run.run.to_string(), str::to_owned)
}
fn outcome_of(state: RunState) -> Option<RunOutcome> {
match state {
RunState::Merged => Some(RunOutcome::Merged),
RunState::PullRequest => Some(RunOutcome::PullRequest),
RunState::BranchLeft => Some(RunOutcome::BranchLeft),
RunState::Stopped => Some(RunOutcome::Stopped),
RunState::Failed => Some(RunOutcome::Failed),
RunState::Abandoned => Some(RunOutcome::Abandoned),
RunState::Running
| RunState::Pausing
| RunState::Waiting
| RunState::Crashed
| RunState::NeverStarted
| RunState::FromAnotherBuild => None,
}
}
fn send_background(ctx: &Ctx, runs: &mut RunsState, list: Vec<RunSummary>) {
let on_screen = attached(runs).cloned();
if let Some(run) = &on_screen {
runs.shown.insert(run.clone());
}
runs.listed = list
.into_iter()
.filter(|run| going_or_waiting(run) && Some(&run.run) != on_screen.as_ref())
.collect();
send_rows(ctx, runs);
}
fn send_rows(ctx: &Ctx, runs: &mut RunsState) {
let on_screen = attached(runs);
let background: Vec<RunSummary> = runs
.listed
.iter()
.filter(|run| runs.ours.contains(&run.run) && Some(&run.run) != on_screen)
.cloned()
.collect();
if background == runs.background {
return;
}
runs.background.clone_from(&background);
ctx.send(AppEvent::BackgroundRuns { runs: background });
}
pub(crate) fn follow_conversation(ctx: &Ctx, runs: &mut RunsState, theirs: Vec<RunId>) {
runs.ours = theirs.into_iter().collect();
if let Some(attached) = runs.attached.as_mut()
&& matches!(attached.stage, Stage::Finished(_))
{
attached.left_behind = true;
}
if let Some(attached) = &runs.attached
&& runs.ours.insert(attached.run.clone())
{
runs.opened.push(ConversationRun {
run: attached.run.clone(),
title: attached.title.clone(),
});
}
send_rows(ctx, runs);
refresh_background(ctx);
}
pub(super) async fn show_runs(ctx: &mut Ctx, every_folder: bool) -> anyhow::Result<()> {
let runs = ctx.services.conductor.list().await?;
let conversations = super::conversation::list(ctx, &runs);
let sessions = super::conversation::cli_sessions(ctx, every_folder).await;
ctx.send(AppEvent::View {
data: Box::new(argonauts_core::app::ViewData::ResumeList {
conversations,
sessions,
}),
});
Ok(())
}
pub(super) async fn resume_and_attach(
ctx: &mut Ctx,
runs: &mut RunsState,
run: &RunId,
) -> anyhow::Result<()> {
if let Some(why) = crate::client::cant_be_resumed(&ctx.env.run_dir(run)) {
ctx.notice(NoteLevel::Info, why);
return Ok(());
}
ctx.services
.conductor
.resume(run)
.await
.map_err(resume_error)?;
let stopped_here = runs.attached.as_mut().filter(|attached| {
&attached.run == run
&& attached.ended
&& attached.replay_done
&& attached.stage == Stage::Finished(RunOutcome::Stopped)
});
if let Some(attached) = stopped_here {
attach::follow_on(ctx, attached).await?;
} else {
let streaming = runs
.attached
.as_ref()
.is_some_and(|attached| &attached.run == run && !attached.ended);
if !streaming {
let summary = summary_of(ctx, run).await;
super::conversation::redraw::run(ctx, run);
attach::attach(ctx, runs, summary, Opening::Reattach).await?;
}
}
raise_to_chosen(ctx, runs, run).await;
Ok(())
}
async fn raise_to_chosen(ctx: &Ctx, runs: &mut RunsState, run: &RunId) {
let Some(mode) = runs.approval_mode else {
return;
};
let Some(attached) = runs
.attached
.as_mut()
.filter(|attached| &attached.run == run)
else {
return;
};
if attached.approval_mode != Some(mode) && set_approval_mode(ctx, run, mode).await {
attached.approval_mode = Some(mode);
}
}
fn resume_error(err: ApiError) -> anyhow::Error {
match err {
ApiError::Merged(_) => anyhow::anyhow!(
"That run already merged, so it can't resume. To build on its result, type \
/follow-up and what you want next."
),
err => anyhow::anyhow!(
super::run_commands::plain_refusal(&err)
.unwrap_or_else(|| super::run_commands::failure(&err, "Couldn't resume that run"))
),
}
}
pub(crate) async fn start(
ctx: &mut Ctx,
runs: &mut RunsState,
mut request: StartRun,
) -> anyhow::Result<Vec<Effect>> {
let asked_supervised = request.approval_mode == Some(ApprovalMode::Supervised);
if runs.approval_mode == Some(ApprovalMode::Supervised) {
request.approval_mode = Some(ApprovalMode::Supervised);
} else if request.approval_mode != Some(ApprovalMode::Supervised) {
request.approval_mode = None;
}
let title = title_from_task(&request.request);
let mode = request.mode;
let handle = match ctx.services.conductor.start(request).await {
Ok(handle) => handle,
Err(ApiError::Refused(why)) => {
let why = why.trim().trim_end_matches('.');
ctx.notice(NoteLevel::Warn, format!("\"{title}\" didn't start: {why}"));
return Ok(Vec::new());
}
Err(err) => return Err(err).context("starting the run"),
};
if asked_supervised {
return started_as_asked(ctx, runs, handle.run, title, mode).await;
}
started(ctx, runs, handle.run, title, mode).await
}
async fn started_as_asked(
ctx: &mut Ctx,
runs: &mut RunsState,
run: RunId,
title: String,
mode: RunMode,
) -> anyhow::Result<Vec<Effect>> {
let summary = match find_summary(ctx, &run).await {
Some(summary) => summary,
None => new_summary(ctx, &run, title, mode),
};
attach::attach(ctx, runs, summary, Opening::New).await?;
if let Some(attached) = runs.attached.as_mut() {
attached.approval_mode = Some(ApprovalMode::Supervised);
}
Ok(vec![Effect::Status])
}
pub(super) async fn started(
ctx: &mut Ctx,
runs: &mut RunsState,
run: RunId,
title: String,
mode: RunMode,
) -> anyhow::Result<Vec<Effect>> {
let raised = runs
.approval_mode
.filter(|mode| *mode != ApprovalMode::Supervised);
let raised = match raised {
Some(approval) => set_approval_mode(ctx, &run, approval)
.await
.then_some(approval),
None => None,
};
let summary = match find_summary(ctx, &run).await {
Some(summary) => summary,
None => new_summary(ctx, &run, title, mode),
};
attach::attach(ctx, runs, summary, Opening::New).await?;
if let (Some(approval), Some(attached)) = (raised, runs.attached.as_mut()) {
attached.approval_mode = Some(approval);
}
Ok(vec![Effect::Status])
}
async fn set_approval_mode(ctx: &Ctx, run: &RunId, mode: ApprovalMode) -> bool {
let Some(approvals) = ctx.services.approvals.as_ref() else {
ctx.notice(NoteLevel::Warn, super::run_commands::NO_APPROVALS);
return false;
};
match approvals.set_approval_mode(run, mode).await {
Ok(()) => true,
Err(err) => {
ctx.notice(
NoteLevel::Warn,
format!("{run} stays supervised: setting the approval mode failed: {err}"),
);
false
}
}
}
pub(super) async fn summary_of(ctx: &Ctx, run: &RunId) -> RunSummary {
match find_summary(ctx, run).await {
Some(summary) => summary,
None => new_summary(ctx, run, run.to_string(), RunMode::Crew),
}
}
async fn find_summary(ctx: &Ctx, run: &RunId) -> Option<RunSummary> {
let list = ctx.services.conductor.list().await.ok()?;
list.into_iter().find(|summary| &summary.run == run)
}
fn new_summary(ctx: &Ctx, run: &RunId, title: String, mode: RunMode) -> RunSummary {
RunSummary {
run: run.clone(),
title,
mode,
workflow: ctx.settings().workflow.clone(),
state: RunState::Running,
stopped_at: String::new(),
updated: ctx.services.clock.now(),
spent: Spend::default(),
sessions_to_resume: 0,
waiting: Vec::new(),
live: true,
remind: false,
name: None,
shape_known: true,
}
}
pub(crate) async fn on_update(
ctx: &mut Ctx,
runs: &mut RunsState,
update: RunUpdate,
) -> anyhow::Result<Vec<Effect>> {
let effects = match update {
RunUpdate::Event { run, event } => on_event(ctx, runs, &run, *event).await,
RunUpdate::Live { run, live } => on_live(ctx, runs, &run, live),
RunUpdate::Ended { run } => on_ended(ctx, runs, &run).await,
RunUpdate::Narrator { run, line } => {
on_narrator(ctx, runs, &run, line);
Vec::new()
}
RunUpdate::Woke {
run,
cause,
started,
} => on_woke(ctx, runs, run, cause, started).await?,
RunUpdate::Listed(listed) => {
on_listed(ctx, runs, listed);
Vec::new()
}
};
Ok(effects)
}
fn watching<'a>(runs: &'a mut RunsState, run: &RunId) -> Option<&'a mut Attached> {
runs.attached
.as_mut()
.filter(|attached| &attached.run == run)
}
async fn on_event(
ctx: &mut Ctx,
runs: &mut RunsState,
run: &RunId,
event: RunEvent,
) -> Vec<Effect> {
let Some(attached) = watching(runs, run) else {
alongside::on_event(ctx, runs, run, event).await;
return Vec::new();
};
let history = !attached.replay_done;
let replay_ends = attached.replay_ends_at(event.seq);
attached.narrator.observe(&event);
let seen = track::track(attached, &event);
ctx.send(AppEvent::Run {
event: Box::new(crate::views::required::for_screen(&event, &ctx.env)),
});
attached.events.push(event);
if history {
if replay_ends {
attach::finish_replay(ctx, attached);
let mut effects = vec![Effect::Status];
effects.extend(exit_if_done(runs, run));
return effects;
}
return Vec::new();
}
let spent = attached.spent;
if seen.began {
runs.news.push((run.clone(), RunNews::Began));
}
if let Some(line) = seen.held.clone() {
runs.news.push((run.clone(), RunNews::Waiting { line }));
}
if let Some(outcome) = seen.finished {
let line = run_failure_line(seen.detail.as_deref());
runs.news
.push((run.clone(), RunNews::Ended { outcome, line }));
}
if let Some(delivered) = &seen.delivered {
forget_queued(runs, run, delivered);
}
let mut effects = Vec::new();
if let Some(mode) = seen.mode
&& seen.finished.is_none()
{
ctx.set_mode(mode);
}
if seen.status {
effects.push(Effect::Status);
}
if let Some(waiting) = seen.waiting {
let needs = NeedsYou {
repo: repo_name(ctx),
run: run.clone(),
approval: Some(waiting.approval),
kind: track::approval_label(waiting.kind).to_owned(),
spent,
trigger: track::trigger(waiting.kind),
};
notify_if_away(ctx, runs, &needs);
}
if let Some(outcome) = seen.finished {
finished(ctx, runs, run, outcome).await;
effects.push(Effect::Status);
effects.extend(exit_if_done(runs, run));
}
effects
}
fn exit_if_done(runs: &RunsState, run: &RunId) -> Option<Effect> {
let attached = runs.attached.as_ref()?;
let done = runs.exit_when_done.as_ref() == Some(run)
&& &attached.run == run
&& matches!(attached.stage, Stage::Finished(_));
done.then_some(Effect::Exit)
}
fn forget_queued(runs: &mut RunsState, run: &RunId, delivered: &MessageId) {
if let Some(queued) = runs.queued.get_mut(run) {
queued.retain(|message| &message.id != delivered);
}
}
async fn finished(ctx: &mut Ctx, runs: &mut RunsState, run: &RunId, outcome: RunOutcome) {
let Some(attached) = runs.attached.as_ref() else {
return;
};
let spent = attached.spent;
send_recap(ctx, run, &attached.events, outcome).await;
runs.queued.remove(run);
notify_finished(ctx, runs, run, outcome, spent);
ctx.set_mode(AppMode::Idle);
crate::behaviors::deep::tab::after_run(ctx);
}
async fn send_recap(ctx: &mut Ctx, run: &RunId, events: &[RunEvent], outcome: RunOutcome) {
if !ctx.settings().ui.recaps || outcome == RunOutcome::Failed {
return;
}
let summary = attach::status_summary(ctx, run, events).await;
let closing = recap::closing_in(events);
let mut text = recap::recap_from_summary(&summary, Some(outcome), closing);
if text.trim().is_empty() {
text = format!(
"{} Ask for another change, or /resume an older run.",
track::outcome_sentence(run, outcome)
);
}
ctx.send(AppEvent::Recap { text, model: None });
}
fn notify_finished(
ctx: &Ctx,
runs: &RunsState,
run: &RunId,
outcome: RunOutcome,
spent: Option<Spend>,
) {
let needs = NeedsYou {
repo: repo_name(ctx),
run: run.clone(),
approval: None,
kind: track::outcome_label(outcome).to_owned(),
spent,
trigger: ConversationTrigger::Finished,
};
notify_if_away(ctx, runs, &needs);
}
fn notify_if_away(ctx: &Ctx, runs: &RunsState, needs: &NeedsYou) {
if !runs.focused {
notify::send(ctx, needs);
}
}
fn repo_name(ctx: &Ctx) -> String {
ctx.env
.repo_root
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| ctx.env.display_repo())
}
fn on_live(ctx: &mut Ctx, runs: &mut RunsState, run: &RunId, live: bool) -> Vec<Effect> {
let Some(attached) = watching(runs, run) else {
return Vec::new();
};
attached.live = live;
if attach::report_crash(ctx, attached) {
let line = "its run host exited before waiting".to_owned();
let outcome = RunOutcome::Failed;
runs.news
.push((run.clone(), RunNews::Ended { outcome, line }));
return vec![Effect::Status];
}
Vec::new()
}
async fn on_ended(ctx: &mut Ctx, runs: &mut RunsState, run: &RunId) -> Vec<Effect> {
let Some(attached) = watching(runs, run) else {
alongside::on_ended(runs, run);
return Vec::new();
};
attached.ended = true;
let mut effects = Vec::new();
if !attached.replay_done {
attach::finish_replay(ctx, attached);
effects.push(Effect::Status);
}
if attached.crashed || attach::report_crash(ctx, attached) {
return effects;
}
if attached.stage == Stage::Going {
ctx.notice(
NoteLevel::Warn,
format!("The event stream of {run} closed. /resume {run} reattaches."),
);
}
effects.extend(exit_if_done(runs, run));
effects
}
fn on_narrator(ctx: &mut Ctx, runs: &mut RunsState, run: &RunId, line: NarratorLine) {
let Some(attached) = watching(runs, run) else {
return;
};
if let Err(err) = attached.narrator.record(&line) {
ctx.notice(
NoteLevel::Warn,
format!("Narrator lines aren't being saved: {err:#}"),
);
}
ctx.send(AppEvent::Narrator { line });
}
pub(crate) async fn wake_waiting(ctx: &mut Ctx, runs: &mut RunsState) -> anyhow::Result<()> {
let now = ctx.services.clock.now();
let mut waiting = std::mem::take(&mut runs.waiting);
let env = Arc::clone(&ctx.env);
let settings = ctx.settings().clone();
let health = Arc::clone(&ctx.services.health);
let (waiting, due) = tokio::task::spawn_blocking(move || {
let due = waiting.due(&env, &settings, health.as_ref(), now);
(waiting, due)
})
.await
.context("checking waiting runs for a wake")?;
runs.waiting = waiting;
for (run, cause) in due {
if !runs.waking.insert(run.clone()) {
continue;
}
let conductor = Arc::clone(&ctx.services.conductor);
let internal = ctx.internal.clone();
tokio::spawn(async move {
let started = conductor.resume(&run).await;
let woke = RunUpdate::Woke {
run,
cause,
started,
};
let _ = internal.send(Internal::Run(woke));
});
}
Ok(())
}
async fn on_woke(
ctx: &mut Ctx,
runs: &mut RunsState,
run: RunId,
cause: WakeCause,
started: Result<(), ApiError>,
) -> anyhow::Result<Vec<Effect>> {
runs.waking.remove(&run);
let summary = summary_of(ctx, &run).await;
let called = run_called(&summary);
if let Err(err) = started {
ctx.notice(NoteLevel::Warn, format!("Couldn't wake {called}: {err}"));
return Ok(Vec::new());
}
let why = match cause {
WakeCause::AgentBack => "the agent it waited for is back",
WakeCause::Timer => "its wait ended, so it tries again",
WakeCause::AskTimeout => "its fallback question timed out, so the role's fallback applies",
};
ctx.notice(NoteLevel::Info, format!("{called} woke up: {why}."));
let reattach = runs
.attached
.as_ref()
.is_some_and(|attached| attached.run == run && attached.ended);
if !reattach {
return Ok(Vec::new());
}
attach::attach(ctx, runs, summary, Opening::Reattach).await?;
raise_to_chosen(ctx, runs, &run).await;
Ok(vec![Effect::Status])
}
pub(crate) async fn settings_applied(
ctx: &mut Ctx,
runs: &mut RunsState,
keys: &[String],
) -> anyhow::Result<Vec<Effect>> {
let _ = keys;
if let Some(attached) = runs.attached.as_ref()
&& attached.narrator.apply(ctx)
{
ctx.send(AppEvent::NarratorStatus {
status: attached.narrator.status(),
});
}
Ok(Vec::new())
}