use anyhow::{anyhow, Result};
use chrono::Utc;
use std::collections::BTreeMap;
use crate::warehouse::codegen_judge::AnswerJudge;
use crate::warehouse::generator::{GenRequest, Generator};
use super::decompose::{run_subtask_loop, RetrievedContext, Subtask};
use super::event::{Event, NodeStatus};
use super::ids::{NodeId, PlanId};
use super::state::{Funnel, PlanNode};
use super::store::Store;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DrainOutcome {
Drained,
Blocked,
}
#[derive(Debug, Clone)]
pub struct DrainReport {
pub plan_id: String,
pub ran: usize,
pub done: usize,
pub failed: usize,
pub blocked: Vec<String>,
pub outcome: DrainOutcome,
}
impl DrainReport {
pub fn render(&self) -> String {
let head = match self.outcome {
DrainOutcome::Drained => "drained (all nodes done)",
DrainOutcome::Blocked => "BLOCKED (frontier empty with work left)",
};
let mut s = format!(
"plan {} — {}\n ran {} · done {} · failed {}\n",
self.plan_id, head, self.ran, self.done, self.failed
);
if !self.blocked.is_empty() {
s.push_str(&format!(" blocked/pending: {}\n", self.blocked.join(", ")));
}
s
}
}
fn subtask_from_node(index: usize, node: &PlanNode) -> Subtask {
let title = node
.params
.get("title")
.and_then(|v| v.as_str())
.map(str::to_string)
.or_else(|| node.prompt_excerpt.clone())
.unwrap_or_else(|| node.id.as_str().to_string());
let target_file = node.targets.first().cloned().unwrap_or_default();
Subtask {
index,
title: title.clone(),
target_file,
anchor_symbol: String::new(),
acceptance: "compiler/test oracle accepts".into(),
context: RetrievedContext { query: title, anchors: Vec::new(), retrievers: Vec::new() },
}
}
pub fn drain_plan(
store: &mut Store,
plan_id: &PlanId,
generator: &dyn Generator,
judge: &dyn AnswerJudge,
max_rounds: usize,
) -> Result<DrainReport> {
let parent_task = {
let plan = store
.funnel
.plans
.get(plan_id)
.ok_or_else(|| anyhow!("no plan {}", plan_id.as_str()))?;
store
.funnel
.ideas
.get(&plan.idea_id)
.map(|i| i.text.clone())
.unwrap_or_else(|| plan.summary.clone())
};
let (mut ran, mut done, mut failed) = (0usize, 0usize, 0usize);
loop {
store.funnel.promote_ready();
let wave: Vec<(NodeId, Subtask)> = {
let plan = store
.funnel
.plans
.get(plan_id)
.ok_or_else(|| anyhow!("plan {} vanished mid-drain", plan_id.as_str()))?;
plan.nodes
.values()
.filter(|n| n.status == NodeStatus::Ready)
.enumerate()
.map(|(i, n)| (n.id.clone(), subtask_from_node(i, n)))
.collect()
};
if wave.is_empty() {
break;
}
for (node_id, sub) in wave {
store.record(Event::NodeStatusChanged {
plan_id: plan_id.clone(),
node_id: node_id.clone(),
status: NodeStatus::InProgress,
why: Some("executor: running".into()),
ts: Utc::now(),
})?;
let verdict = run_subtask_loop(&parent_task, &sub, generator, judge, max_rounds)?;
ran += 1;
let status = verdict.node_status();
if status == NodeStatus::Done {
done += 1;
} else {
failed += 1;
}
store.record(Event::NodeStatusChanged {
plan_id: plan_id.clone(),
node_id,
status,
why: Some(format!(
"executor: {} (score {:.2}, {}/{} tests)",
verdict.best.message,
verdict.best.score,
verdict.best.tests_passed,
verdict.best.tests_total
)),
ts: Utc::now(),
})?;
}
}
let plan = store
.funnel
.plans
.get(plan_id)
.ok_or_else(|| anyhow!("plan {} vanished", plan_id.as_str()))?;
let blocked: Vec<String> = plan
.nodes
.values()
.filter(|n| n.status != NodeStatus::Done)
.map(|n| n.id.as_str().to_string())
.collect();
let outcome = if blocked.is_empty() { DrainOutcome::Drained } else { DrainOutcome::Blocked };
Ok(DrainReport {
plan_id: plan_id.as_str().to_string(),
ran,
done,
failed,
blocked,
outcome,
})
}
pub fn plan_frontier_waves(funnel: &Funnel, plan_id: &PlanId) -> Vec<Vec<String>> {
let Some(plan) = funnel.plans.get(plan_id) else {
return Vec::new();
};
let mut status: BTreeMap<NodeId, NodeStatus> =
plan.nodes.iter().map(|(id, n)| (id.clone(), n.status)).collect();
let mut waves = Vec::new();
loop {
let mut wave: Vec<NodeId> = Vec::new();
for (id, st) in &status {
if matches!(st, NodeStatus::Done | NodeStatus::Failed) {
continue;
}
let deps_done = plan
.edges
.iter()
.filter(|(_, to)| to == id)
.all(|(from, _)| matches!(status.get(from), Some(NodeStatus::Done)));
if deps_done {
wave.push(id.clone());
}
}
if wave.is_empty() {
break;
}
for id in &wave {
status.insert(id.clone(), NodeStatus::Done);
}
waves.push(wave.iter().map(|n| n.as_str().to_string()).collect());
}
waves
}
#[cfg(test)]
mod tests {
use super::{drain_plan, plan_frontier_waves, DrainOutcome};
use super::super::decompose::{store_subtasks, Decomposition, RetrievedContext, Subtask};
use super::super::event::{Event, ItemKind, NodeStatus};
use super::super::ids::{IdeaId, PlanId};
use super::super::store::Store;
use crate::warehouse::codegen_judge::{AnswerJudge, Verdict};
use crate::warehouse::generator::{Backend, GenAnswer, GenRequest, Generator};
use anyhow::Result;
use chrono::Utc;
struct EchoGen;
impl Backend for EchoGen {
fn id(&self) -> &str {
"echo"
}
fn available(&self) -> bool {
true
}
}
impl Generator for EchoGen {
fn complete(&self, req: &GenRequest) -> Result<GenAnswer> {
Ok(GenAnswer {
text: req.prompt.clone(),
tokens_in: 0,
tokens_out: 0,
tokens_per_s: 0.0,
latency_ms: 0.0,
})
}
}
struct MarkerJudge;
impl AnswerJudge for MarkerJudge {
fn judge(&self, answer: &str) -> Result<Verdict> {
if answer.contains("FAILNODE") {
Ok(Verdict::rejected("marked to fail"))
} else {
Ok(Verdict { compiled: true, tests_passed: 1, tests_total: 1, score: 1.0, message: "ok".into() })
}
}
}
fn sub(index: usize, title: &str) -> Subtask {
Subtask {
index,
title: title.into(),
target_file: format!("src/n{index}.rs"),
anchor_symbol: String::new(),
acceptance: "green".into(),
context: RetrievedContext { query: title.into(), anchors: vec![], retrievers: vec![] },
}
}
fn plan_with(titles: &[&str]) -> (Store, PlanId) {
let dir = tempfile::tempdir().unwrap().into_path();
let mut store = Store::open(dir.join("wh")).unwrap();
let idea = IdeaId::seq(store.funnel.next_idea.max(1));
store
.record(Event::IdeaSubmitted {
id: idea.clone(),
source: "test".into(),
text: "parent task".into(),
refs: vec![],
item_kind: ItemKind::Idea,
ts: Utc::now(),
})
.unwrap();
let decomp = Decomposition {
task: "parent task".into(),
subtasks: titles.iter().enumerate().map(|(i, t)| sub(i, t)).collect(),
};
let (plan_id, _nodes) = store_subtasks(&mut store, &idea, &decomp).unwrap();
(store, plan_id)
}
#[test]
fn drain_reaches_all_done_offline() {
let (mut store, plan_id) = plan_with(&["node a", "node b", "node c"]);
assert_eq!(store.funnel.plans.get(&plan_id).unwrap().nodes.len(), 3, "plan-DAG grew to 3 nodes");
let rep = drain_plan(&mut store, &plan_id, &EchoGen, &MarkerJudge, 1).unwrap();
assert_eq!(rep.outcome, DrainOutcome::Drained);
assert_eq!((rep.ran, rep.done, rep.failed), (3, 3, 0));
assert!(rep.blocked.is_empty());
assert!(store
.funnel
.plans
.get(&plan_id)
.unwrap()
.nodes
.values()
.all(|n| n.status == NodeStatus::Done));
}
#[test]
fn drain_blocks_when_a_node_fails_and_strands_dependents() {
let (mut store, plan_id) = plan_with(&["node a", "FAILNODE b", "node c"]);
let rep = drain_plan(&mut store, &plan_id, &EchoGen, &MarkerJudge, 1).unwrap();
assert_eq!(rep.outcome, DrainOutcome::Blocked);
assert_eq!(rep.done, 1, "only the root ran green");
assert_eq!(rep.failed, 1, "the marked node failed");
assert_eq!(rep.blocked.len(), 2, "the failed node + its stranded dependent");
}
#[test]
fn frontier_waves_are_topological() {
let (store, plan_id) = plan_with(&["a", "b", "c"]);
let waves = plan_frontier_waves(&store.funnel, &plan_id);
assert_eq!(waves.len(), 3);
assert!(waves.iter().all(|w| w.len() == 1));
}
}