use crate::action::gate;
use crate::gateway::ledger;
use crate::run::boxed;
use crate::work::queue;
use crate::work::trigger;
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());
}
reclaim();
let mut set: JoinSet<(queue::Task, Result<boxed::Outcome, String>)> = JoinSet::new();
loop {
if let Err(e) = crate::config::snapshot() {
eprintln!("dispatch paused — config invalid:\n{e}");
if once {
return Err("config invalid".into());
}
tokio::time::sleep(Duration::from_secs(POLL_SECS)).await;
continue;
}
trigger::fire();
while set.len() < cap {
let Some(mut task) = queue::claim_next() else {
break;
};
if task.proactive {
let limits = crate::gateway::budget::load_budget().limits;
if let Some(reason) =
ledger::over_any_limit(&task.subject(), &limits, crate::util::now_ms())
{
eprintln!("defer {} (proactive, {reason})", task.id);
let _ = queue::set_state(&mut task, "deferred");
continue;
}
}
let run_id = boxed::new_run_id();
task.run_id = Some(run_id.clone());
let _ = queue::save(&task);
let memory_context = task_memory_context(&task);
let _ = crate::imp::memory::save_run_context(&run_id, &memory_context);
eprintln!(
"dispatch {} [{}] run {run_id} — {}",
task.id,
task.imp,
first_line(&task.prompt)
);
let t = task.clone();
let context_task = crate::imp::context::TaskInput {
task_id: Some(task.id.clone()),
origin: task.origin.clone(),
text: task.prompt.clone(),
continuation: task.context.get("resolved_gate").cloned(),
};
set.spawn(async move {
let code = t.repo.as_ref().map(|r| boxed::CodeSpec {
repo: r.clone(),
base: t.base.clone().unwrap_or_else(|| "main".into()),
});
let spec = boxed::RunSpec {
imp: &t.imp,
run_id: &run_id,
task_id: &t.id,
ceiling_min: t.ceiling_min,
code: code.as_ref(),
run_context: &memory_context,
knowledge_mode: &t.knowledge_mode,
};
let out = boxed::dispatch(spec, context_task)
.await
.map_err(|e| e.to_string());
(t, out)
});
}
if set.is_empty() {
if once {
break;
}
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)) => finalize(task, outcome),
Err(e) => eprintln!("supervise: a run panicked: {e}"),
}
}
}
Ok(())
}
fn reclaim() {
for mut t in queue::list_all()
.into_iter()
.filter(|t| t.state == "running")
{
if t.run_id.as_deref().map(boxed::box_alive).unwrap_or(false) {
eprintln!(
"reclaim: {} still has a live box ({}) — leaving it",
t.id,
t.run_id.as_deref().unwrap_or("")
);
} else if queue::set_state(&mut t, "waiting").is_ok() {
eprintln!("reclaim: {} → waiting (no live box)", t.id);
}
}
}
fn task_memory_context(task: &queue::Task) -> crate::imp::memory::RunContext {
let d = task
.context
.get("discord")
.unwrap_or(&serde_json::Value::Null);
crate::imp::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(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),
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(mut task: queue::Task, outcome: Result<boxed::Outcome, String>) {
let next = match outcome {
Err(e) => {
eprintln!("task {} failed to run: {e}", task.id);
"failed"
}
Ok(o) => {
task.run_id = Some(o.run_id.clone());
if o.ended_by == "ceiling" {
"failed"
} else if o.exit_code != Some(0) {
eprintln!("task {} box exited {:?}", task.id, o.exit_code);
"failed"
} else if !gate::pending_for_task(&task.id).is_empty() {
"needs-review"
} else {
"done"
}
}
};
if let Err(e) = queue::set_state(&mut task, next) {
eprintln!("supervise: could not update task {}: {e}", task.id);
} else {
eprintln!("task {} → {next}", task.id);
}
}
fn first_line(s: &str) -> String {
let line = s.lines().next().unwrap_or("");
if line.len() > 60 {
format!("{}…", &line[..60])
} else {
line.to_string()
}
}