use crate::action::gate;
use crate::gateway::ledger;
use crate::run::boxed;
use crate::work::tms;
use std::collections::HashSet;
use std::time::Duration;
use tokio::task::JoinSet;
type BErr = Box<dyn std::error::Error>;
const POLL_SECS: u64 = 2;
pub async fn dispatch_loop(cap: usize, once: bool) -> Result<(), BErr> {
if cap == 0 {
return Err("--cap wants a positive integer".into());
}
let mut set: JoinSet<(tms::Task, Result<boxed::Outcome, String>)> = JoinSet::new();
let mut in_flight: HashSet<String> = HashSet::new();
reclaim(&mut set, &mut in_flight);
let mut credential_warned = false;
loop {
let config = match crate::config::snapshot() {
Ok(c) => c,
Err(e) => {
eprintln!("dispatch paused — config invalid:\n{e}");
if once {
return Err("config invalid".into());
}
tokio::time::sleep(Duration::from_secs(POLL_SECS)).await;
continue;
}
};
let creds_ok = boxed::model_credentials_available();
if !creds_ok && !credential_warned {
eprintln!(
"dispatch holding — no model credential; tasks stay queued. \
Connect one: roster connection add anthropic (or openai-codex)"
);
credential_warned = true;
}
if creds_ok && credential_warned {
eprintln!("dispatch resuming — a model credential is available");
credential_warned = false;
}
if !creds_ok && once && set.is_empty() {
return Err("no model credential — tasks cannot run. Connect one: \
roster connection add anthropic (or openai-codex)"
.into());
}
let mut idle = true;
'workers: for due in tms::due(&config.heartbeats) {
if !creds_ok {
break;
}
for task in due.claimable {
if set.len() >= cap {
break 'workers;
}
if in_flight.contains(&task.id) {
continue;
}
if task.proactive() {
let limits = crate::gateway::budget::load_budget().limits;
if ledger::over_any_limit(&task.subject(), &limits, crate::util::now_ms())
.is_some()
{
continue;
}
}
let run_id = boxed::new_run_id();
let task = match tms::claim(&due.worker, &task.id, &run_id) {
Ok(t) => t,
Err(e) => {
eprintln!("dispatch: could not claim {}: {e}", task.id);
continue;
}
};
idle = false;
in_flight.insert(task.id.clone());
let memory_context = task_memory_context(&task);
let _ = crate::worker::memory::save_run_context(&run_id, &memory_context);
eprintln!(
"dispatch {} [{}] run {run_id} — {}",
task.id,
task.worker,
first_line(&task.prompt)
);
let reply_to = if memory_context.channel_id.is_none() {
task.tags
.provider
.clone()
.zip(task.tags.channel.clone())
.map(|(provider, channel)| crate::worker::context::ReplyTo {
provider,
channel,
})
} else {
None
};
let context_task = crate::worker::context::TaskInput {
task_id: Some(task.id.clone()),
origin: task.created_by.clone(),
text: task.prompt.clone(),
continuation: task.context.get("resolved_gate").cloned(),
reply_to,
};
let t = task.clone();
set.spawn(async move {
let spec = boxed::RunSpec {
worker: &t.worker,
run_id: &run_id,
task_id: &t.id,
ceiling_min: t.ceiling_min,
run_context: &memory_context,
};
let out = boxed::dispatch(spec, context_task)
.await
.map_err(|e| e.to_string());
(t, out)
});
}
}
if set.is_empty() {
if once {
if idle {
break;
}
continue;
}
tokio::time::sleep(Duration::from_secs(POLL_SECS)).await;
continue;
}
let joined =
match tokio::time::timeout(Duration::from_secs(POLL_SECS), set.join_next()).await {
Ok(j) => j,
Err(_) => continue, };
if let Some(joined) = joined {
match joined {
Ok((task, outcome)) => {
in_flight.remove(&task.id);
finalize(task, outcome);
}
Err(e) => eprintln!("supervise: a run panicked: {e}"),
}
}
}
Ok(())
}
fn reclaim(
set: &mut JoinSet<(tms::Task, Result<boxed::Outcome, String>)>,
in_flight: &mut HashSet<String>,
) {
for t in tms::list_all().into_iter().filter(|t| t.state == "claimed") {
let run_id = t.run_id.clone().unwrap_or_default();
if !run_id.is_empty() && boxed::box_alive(&run_id) {
eprintln!("reclaim: adopting live box {run_id} for {}", t.id);
in_flight.insert(t.id.clone());
let task = t.clone();
set.spawn(async move {
let out = boxed::adopt(&run_id).await;
(task, out)
});
} else if tms::finish(&t.worker, &t.id, "pending").is_ok() {
eprintln!("reclaim: {} → pending (no live box)", t.id);
}
}
}
fn task_memory_context(task: &tms::Task) -> crate::worker::memory::RunContext {
let d = task
.context
.get("discord")
.unwrap_or(&serde_json::Value::Null);
crate::worker::memory::RunContext {
provider: if d.is_null() {
String::new()
} else {
"discord".into()
},
channel_id: d
.get("channel_id")
.and_then(|v| v.as_str())
.map(crate::channel::links::logical_of),
surface_id: d
.get("channel_id")
.and_then(|v| v.as_str())
.map(String::from),
user_id: d
.get("author_id")
.and_then(|v| v.as_str())
.map(String::from),
message_id: d
.get("message_id")
.and_then(|v| v.as_str())
.map(String::from),
thread_ts: d
.get("thread_ts")
.and_then(|v| v.as_str())
.map(String::from),
role: d
.get("role")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
is_dm: d.get("is_dm").and_then(|v| v.as_bool()).unwrap_or(false),
inbound: task.context.get("inbound").is_some(),
}
}
fn finalize(task: tms::Task, outcome: Result<boxed::Outcome, String>) {
let (next, error) = match outcome {
Err(e) => {
eprintln!("task {} failed to run: {e}", task.id);
("failed", Some(e))
}
Ok(o) => {
let report = task
.run_id
.as_deref()
.and_then(crate::run::runlog::outcome_report);
let refusals = task
.run_id
.as_deref()
.map(crate::gateway::proxy::take_refusals)
.unwrap_or(0);
if o.ended_by == "ceiling" {
(
"failed",
Some(format!(
"hit the {:.0}-minute wall-clock ceiling",
task.ceiling_min
)),
)
} else if o.exit_code != Some(0) {
eprintln!("task {} box exited {:?}", task.id, o.exit_code);
(
"failed",
Some(match o.exit_code {
Some(code) => format!("the box exited with code {code}"),
None => "the box was killed before it exited".to_string(),
}),
)
} else if !gate::pending_for_task(&task.id).is_empty() {
("needs-review", None)
} else {
match report {
Some((status, note)) if status == "failed" => (
"failed",
Some(note.unwrap_or_else(|| "the worker reported failure".into())),
),
Some(_) => ("completed", None),
None if refusals > 0 => (
"failed",
Some(format!(
"ended without reporting an outcome after {refusals} refused gateway call(s) — transcript: roster server runs show {}",
task.run_id.as_deref().unwrap_or("?")
)),
),
None => ("completed", None),
}
}
}
};
if next == "failed" {
if let Some((provider, channel)) = reply_route(&task) {
let reason = error
.clone()
.unwrap_or_else(|| "the run did not complete".into());
let (worker, task_id) = (task.worker.clone(), task.id.clone());
let text = format!("⚠️ I couldn't finish task `{task_id}` — {reason}");
tokio::spawn(async move {
crate::action::deliver_notice(&provider, &channel, &worker, &text).await;
});
}
}
if let Err(e) = tms::finish_with(&task.worker, &task.id, next, error) {
eprintln!("supervise: could not update task {}: {e}", task.id);
} else {
eprintln!("task {} → {next}", task.id);
}
}
fn reply_route(task: &tms::Task) -> Option<(String, String)> {
if let (Some(provider), Some(channel)) = (&task.tags.provider, &task.tags.channel) {
return Some((provider.clone(), channel.clone()));
}
let channel = task.context.get("discord")?.get("channel_id")?.as_str()?;
Some(("discord".into(), channel.to_string()))
}
fn first_line(s: &str) -> String {
let line = s.lines().next().unwrap_or("");
if line.chars().count() > 60 {
format!("{}…", line.chars().take(60).collect::<String>())
} else {
line.to_string()
}
}