use std::collections::{BTreeMap, BTreeSet};
use std::sync::mpsc::Sender;
use onevcs::SessionRequest;
use crate::controls::NodeControls;
use crate::engine::{self, Message, Settlement};
use crate::event::{Envelope, Labels};
use crate::executor::{DispatchRequest, Executor, WorkspaceSpec};
use crate::filter::EventFilter;
use crate::graph::NodeStatus;
use crate::ledger::RunPaths;
use crate::plan::{Node, NodeKind, Step};
pub const PR_AUTHOR_PERSONA: &str = "pr-author";
#[derive(Debug, Clone, Default)]
pub struct Launch {
pub node_graph: String,
pub pr_author_graph: Option<String>,
pub vcs_filter: Option<EventFilter>,
}
pub fn execute(
executor: &dyn Executor,
paths: &RunPaths,
launch: &Launch,
node: &Node,
cancel: &crate::executor::CancellationToken,
tx: &Sender<Message>,
) -> Settlement {
let run = paths.run.as_str();
let vcs_filter = launch.vcs_filter.as_ref();
let Some(request) = crate::vcs::request_for(node) else {
return Settlement {
detail: Some("a lifecycle node needs a repo".into()),
..Settlement::plain(&node.id, NodeStatus::Failed, Some("invalid-node"))
};
};
let declared_steps = node.steps.is_some();
let steps = match dispatchable_steps(node) {
Ok(steps) => steps,
Err(reason) => {
return Settlement {
detail: Some(reason),
..Settlement::plain(&node.id, NodeStatus::Failed, Some("invalid-node"))
}
}
};
let mut session: Option<String> = None;
let mut stream: Option<crate::vcs::Follower> = None;
let mut worktree: Option<std::path::PathBuf> = None;
let whose = engine::dispatch_labels(run, &node.id, None, node.persona.as_deref());
let mut branch: Option<String> = node.branch.clone();
let mut completed: Vec<String> = node
.resume
.as_ref()
.map(|resume| resume.completed_steps.clone())
.unwrap_or_default();
for (step, controls) in &steps {
if declared_steps && completed.iter().any(|id| id == &step.id) {
continue;
}
if step.kind == NodeKind::Human {
return Settlement {
branch,
completed_steps: completed,
..Settlement::plain(&node.id, NodeStatus::Waiting, None)
};
}
if step.expects_no_diff {
continue;
}
let request = SessionRequest {
branch: branch.clone().or_else(|| request.branch.clone()),
..request.clone()
};
let workspace = match &worktree {
Some(dir) => WorkspaceSpec::Path(dir.clone()),
None => WorkspaceSpec::VcsSession(request.clone()),
};
let graph = engine::node_graph(
step.agent_graph.as_ref().or(node.agent_graph.as_ref()),
&launch.node_graph,
);
let build = || DispatchRequest {
graph: graph.clone(),
task: step.rendered_task(node.context.as_deref()),
labels: engine::dispatch_labels(
run,
&node.id,
declared_steps.then_some(step.id.as_str()),
step.persona.as_deref(),
),
controls: *controls,
workspace: workspace.clone(),
cancel: cancel.clone(),
};
let drained = engine::attempt(executor, &node.id, cancel, tx, &build);
session = drained.session.or(session);
branch = drained.branch.or(branch);
if stream.is_none() {
if let Some(token) = &session {
worktree = crate::vcs::worktree_of(token);
stream = crate::vcs::follow(token, vcs_filter, relay_into(tx, whose.clone()));
}
}
if drained.settlement.status != NodeStatus::Done {
end_session(stream, tx, session.as_deref(), &whose, vcs_filter);
return Settlement {
branch,
completed_steps: completed,
..drained.settlement
};
}
if declared_steps {
completed.push(step.id.clone());
}
}
let Some(token) = session else {
return Settlement {
branch,
..Settlement::plain(&node.id, NodeStatus::Done, Some("no-changes"))
};
};
let settlement = publish(
executor,
paths,
launch,
node,
worktree.as_deref(),
cancel,
tx,
&token,
branch,
);
end_session(stream, tx, Some(&token), &whose, vcs_filter);
settlement
}
#[allow(
clippy::too_many_arguments,
reason = "publication needs the dispatch context (executor, the run's paths, what its \
launch decided, the node, cancellation, and the event stream) as well as what \
the steps left behind (the session token, its branch, and the worktree they \
worked in); the first six are the node's own dispatch identity and bundling \
them would only move the same list one indirection away"
)]
fn publish(
executor: &dyn Executor,
paths: &RunPaths,
launch: &Launch,
node: &Node,
worktree: Option<&std::path::Path>,
cancel: &crate::executor::CancellationToken,
tx: &Sender<Message>,
token: &str,
branch: Option<String>,
) -> Settlement {
let (body, undrafted) = match node.body.clone() {
Some(body) => (Some(body), None),
None => match drafted(executor, paths, launch, node, worktree, cancel, tx) {
None => (None, None),
Some(Drafted::Body(body)) => (Some(body), None),
Some(Drafted::Undrafted(ending)) => (None, Some(ending)),
},
};
let undrafted = undrafted.map(|ending| {
let why = ending.why();
let _ = tx.send(Message::BodyNotDrafted(Box::new(engine::UndraftedBody {
node: node.id.clone(),
ending,
})));
why
});
let publication_failed = |detail: String| Settlement {
branch: branch.clone(),
detail: Some(match &undrafted {
Some(why) => format!("{detail}. {why}"),
None => detail,
}),
..Settlement::plain(&node.id, NodeStatus::Failed, Some("publication-failed"))
};
match crate::vcs::publish(
token,
node.merge_policy,
node.title.as_deref(),
body.as_deref(),
) {
Ok(published) => {
if let onevcs::PublishOutcome::Failed { reason, .. } = &published.outcome {
return publication_failed(format!("onevcs: {reason}"));
}
let labels =
engine::dispatch_labels(&paths.run, &node.id, None, node.persona.as_deref());
let _ = tx.send(Message::Event(Box::new(crate::vcs::published_event(
&published, &labels,
))));
Settlement {
detail: undrafted.clone(),
branch: branch.or_else(|| Some(published.branch.clone())),
change_url: crate::vcs::change_url(&published.outcome),
outcome: Some(crate::vcs::outcome_of(&published.outcome).to_owned()),
landing: crate::vcs::landing_of(&published.outcome),
..Settlement::plain(&node.id, NodeStatus::Done, None)
}
}
Err(error) => publication_failed(error.to_string()),
}
}
const DRAFTING_TASK: &str = "Read this branch's diff and write the change request's body, \
following the repository's own template. The task this branch delivered:";
enum Drafted {
Body(String),
Undrafted(Undrafted),
}
pub(crate) enum Undrafted {
Dispatch(String),
SchemaRefused,
Bodyless,
}
impl Undrafted {
pub(crate) fn ending(&self) -> &'static str {
match self {
Self::Dispatch(_) => "dispatch-failed",
Self::SchemaRefused => "schema-refused",
Self::Bodyless => "no-body",
}
}
pub(crate) fn why(&self) -> String {
match self {
Self::Dispatch(reason) => {
format!("the change request's body was not drafted: {reason}")
}
Self::SchemaRefused => "the change request's body was not drafted: the drafting \
dispatch answered nothing the schema it was validated against accepted"
.to_owned(),
Self::Bodyless => "the change request's body was not drafted: the drafting \
dispatch succeeded and there was no body in what it answered with"
.to_owned(),
}
}
}
#[allow(
clippy::too_many_arguments,
reason = "the draft is a dispatch inside one lifecycle execution and needs that \
execution's executor, the run's own paths, what its launch decided, the node, \
the workspace, cancellation, and the event stream"
)]
fn drafted(
executor: &dyn Executor,
paths: &RunPaths,
launch: &Launch,
node: &Node,
worktree: Option<&std::path::Path>,
cancel: &crate::executor::CancellationToken,
tx: &Sender<Message>,
) -> Option<Drafted> {
let graph = launch.pr_author_graph.as_deref()?;
let Some(worktree) = worktree else {
let why = "there was no worktree to read this branch's diff in";
eprintln!(
"onepipeline: node '{}': no worktree to draft its change request in, \
so it publishes with no body",
node.id
);
return Some(Drafted::Undrafted(Undrafted::Dispatch(why.to_owned())));
};
let dispatch = executor.dispatch(DispatchRequest {
graph: oneagentgraph::config::ConfigRef(graph.to_owned()),
task: format!("{DRAFTING_TASK}\n\n{}", node.rendered_task()),
labels: engine::dispatch_labels(&paths.run, &node.id, None, Some(PR_AUTHOR_PERSONA)),
controls: NodeControls::default(),
workspace: WorkspaceSpec::Path(worktree.to_path_buf()),
cancel: cancel.clone(),
});
let mut handle = match dispatch {
Ok(handle) => handle,
Err(error) => {
eprintln!(
"onepipeline: node '{}': the drafting dispatch could not start, \
so it publishes with no body: {error}",
node.id
);
return Some(Drafted::Undrafted(Undrafted::Dispatch(format!(
"the drafting dispatch could not start: {error}"
))));
}
};
let mut retained = Vec::new();
for envelope in handle.events() {
let Ok(envelope) = envelope else { continue };
crate::report::retain(paths, &envelope);
if envelope.source == crate::event::Source::Agentgraph
&& envelope.kind.0 == crate::report::MEMBER_SETTLED
{
retained.push(paths.report_for(&envelope.stream, envelope.seq));
}
let _ = tx.send(Message::Event(Box::new(envelope)));
}
match handle.wait() {
Ok(outcome) if outcome.succeeded => {
let kept: Vec<serde_json::Value> = retained
.iter()
.filter_map(|kept| crate::report::read(kept))
.collect();
Some(match crate::report::drafted(&kept) {
crate::report::Drafted::Body(body) => Drafted::Body(body),
crate::report::Drafted::SchemaRefused => {
Drafted::Undrafted(Undrafted::SchemaRefused)
}
crate::report::Drafted::Bodyless => Drafted::Undrafted(Undrafted::Bodyless),
})
}
Ok(outcome) => Some(Drafted::Undrafted(Undrafted::Dispatch(format!(
"the drafting dispatch settled without succeeding: {}",
first_line(&outcome.detail)
)))),
Err(error) => Some(Drafted::Undrafted(Undrafted::Dispatch(format!(
"the drafting dispatch could not be waited on: {error}"
)))),
} }
fn first_line(detail: &str) -> String {
match detail.lines().find(|line| !line.trim().is_empty()) {
Some(line) => engine::bounded(line.trim()),
None => "it reported nothing".to_owned(),
}
}
fn relay_into(tx: &Sender<Message>, node: Labels) -> Box<dyn Fn(Envelope) + Send> {
let tx = tx.clone();
Box::new(move |mut envelope| {
stamp(&mut envelope.labels, &node);
let _ = tx.send(Message::Event(Box::new(envelope)));
})
}
fn stamp(labels: &mut Labels, known: &Labels) {
labels.run_id = labels.run_id.take().or_else(|| known.run_id.clone());
labels.node = labels.node.take().or_else(|| known.node.clone());
labels.persona = labels.persona.take().or_else(|| known.persona.clone());
}
fn end_session(
stream: Option<crate::vcs::Follower>,
tx: &Sender<Message>,
token: Option<&str>,
node: &Labels,
filter: Option<&EventFilter>,
) {
close(token);
let followed_through = stream.and_then(crate::vcs::Follower::finish);
relay_session_events(tx, token, node, followed_through, filter);
}
fn relay_session_events(
tx: &Sender<Message>,
token: Option<&str>,
node: &Labels,
followed_through: Option<u64>,
filter: Option<&EventFilter>,
) {
let Some(token) = token else { return };
let relay = relay_into(tx, node.clone());
for envelope in beyond(crate::vcs::events(token, filter), followed_through) {
relay(envelope);
}
}
fn beyond(envelopes: Vec<Envelope>, followed_through: Option<u64>) -> Vec<Envelope> {
envelopes
.into_iter()
.filter(|envelope| !followed_through.is_some_and(|seq| envelope.seq <= seq))
.collect()
}
fn close(token: Option<&str>) {
if let Some(token) = token {
let _ = crate::vcs::session_close(token);
}
}
fn dispatchable_steps(node: &Node) -> std::result::Result<Vec<(Step, NodeControls)>, String> {
ordered_steps(node)?
.into_iter()
.map(|step| {
NodeControls::of_step(&step)
.map(|controls| (step.clone(), controls))
.map_err(|why| format!("node '{}': step '{}': {why}", node.id, step.id))
})
.collect()
}
pub fn ordered_steps(node: &Node) -> std::result::Result<Vec<Step>, String> {
let Some(steps) = &node.steps else {
return Ok(vec![Step {
id: node.id.clone(),
kind: node.kind,
task: node.task.clone(),
persona: node.persona.clone(),
deps: Vec::new(),
max_turns: node.max_turns,
expects_no_diff: node.expects_no_diff,
executor: node.executor.clone(),
agent_graph: node.agent_graph.clone(),
}]);
};
let by_id: BTreeMap<&str, &Step> = steps.iter().map(|s| (s.id.as_str(), s)).collect();
let mut settled: BTreeSet<&str> = BTreeSet::new();
let mut order: Vec<Step> = Vec::new();
while order.len() < steps.len() {
let mut progressed = false;
for step in steps {
if settled.contains(step.id.as_str()) {
continue;
}
if step
.deps
.iter()
.all(|dep| settled.contains(dep.as_str()) || !by_id.contains_key(dep.as_str()))
{
settled.insert(step.id.as_str());
order.push(step.clone());
progressed = true;
}
}
if !progressed {
return Err(format!(
"node '{}': its steps have a dependency cycle",
node.id
));
}
}
Ok(order)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_ending_this_module_emits_is_one_the_contract_names() {
let contract = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("docs/contract.md"),
)
.expect("the contract ships");
let endings = [
Undrafted::Dispatch(String::new()),
Undrafted::SchemaRefused,
Undrafted::Bodyless,
];
for ending in &endings {
assert!(
contract.contains(&format!("`{}`", ending.ending())),
"docs/contract.md does not name the `{}` ending this module emits",
ending.ending()
);
}
let clause = contract
.split_once("carrying `ending` —")
.expect("the contract lists the endings `body-not-drafted` carries")
.1
.split_once("— and `detail`")
.expect("the clause ends where the detail begins")
.0;
let listed: Vec<&str> = clause.split('`').skip(1).step_by(2).collect();
assert_eq!(
listed,
endings
.iter()
.map(Undrafted::ending)
.collect::<Vec<&'static str>>(),
"the contract's endings are not the ones this module emits"
);
let why: std::collections::BTreeSet<String> = endings.iter().map(Undrafted::why).collect();
assert_eq!(why.len(), endings.len(), "two endings say the same thing");
}
#[test]
fn the_readmes_ending_summary_is_the_set_this_module_emits() {
let raw = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("README.md"),
)
.expect("the README ships");
let readme = raw.split_whitespace().collect::<Vec<_>>().join(" ");
let clause = readme
.split_once("under one of three endings —")
.expect("the README summarises the endings a drafting dispatch can reach")
.1
.split_once("— and the node's own settlement")
.expect("the clause ends where the settlement's own half begins")
.0;
let listed: Vec<&str> = clause.split('`').skip(1).step_by(2).collect();
assert_eq!(
listed,
[
Undrafted::Dispatch(String::new()),
Undrafted::SchemaRefused,
Undrafted::Bodyless,
]
.iter()
.map(Undrafted::ending)
.collect::<Vec<&'static str>>(),
"the README's endings are not the ones this module emits"
);
}
#[test]
fn a_step_whose_budget_no_dispatch_can_run_under_stops_the_workstream() {
let node = Node {
id: "service".into(),
repo: Some("owner/service".into()),
steps: Some(vec![
Step {
max_turns: Some(45),
..step("implement", &[])
},
Step {
max_turns: Some(0),
..step("review", &["implement"])
},
]),
..Node::default()
};
let why = dispatchable_steps(&node)
.expect_err("a step that can take no turn is not dispatchable");
assert!(why.contains("node 'service': step 'review':"), "{why}");
assert!(why.contains("no turn at all"), "{why}");
let (tx, rx) = std::sync::mpsc::channel();
let settlement = execute(
&crate::executor::LocalExecutor,
&RunPaths::under(std::path::Path::new("/nowhere"), "demo"),
&Launch {
node_graph: "graphs/node-scope.yaml".into(),
..Launch::default()
},
&node,
&crate::executor::CancellationToken::new(),
&tx,
);
assert_eq!(settlement.status, NodeStatus::Failed);
assert_eq!(settlement.outcome.as_deref(), Some("invalid-node"));
let detail = settlement.detail.expect("the settlement says why");
assert!(detail.contains("step 'review'"), "{detail}");
assert!(detail.contains("no turn at all"), "{detail}");
assert_eq!(
rx.try_iter().count(),
0,
"a workstream that could not dispatch a step opened a session anyway"
);
let node = Node {
steps: Some(vec![Step {
max_turns: Some(45),
..step("implement", &[])
}]),
..node
};
let dispatchable = dispatchable_steps(&node).expect("45 is a budget a step can run under");
assert_eq!(
dispatchable[0].1.max_turns,
std::num::NonZeroU32::new(45),
"the step's own budget did not survive the conversion"
);
}
fn step(id: &str, deps: &[&str]) -> Step {
Step {
id: id.into(),
persona: Some("engineer".into()),
task: Some("## What\ndo it".into()),
deps: deps.iter().map(|d| (*d).to_string()).collect(),
..Step::default()
}
}
fn lifecycle(steps: Option<Vec<Step>>) -> Node {
Node {
id: "service".into(),
repo: Some("owner/repo".into()),
persona: steps.is_none().then(|| "engineer".into()),
task: steps.is_none().then(|| "## What\nship".into()),
steps,
..Node::default()
}
}
#[test]
fn steps_run_serially_in_topological_order() {
let node = lifecycle(Some(vec![
step("publish", &["review"]),
step("implement", &[]),
step("review", &["implement"]),
]));
let order: Vec<String> = ordered_steps(&node)
.expect("the steps order")
.into_iter()
.map(|s| s.id)
.collect();
assert_eq!(order, vec!["implement", "review", "publish"]);
}
#[test]
fn steps_with_a_cycle_are_reported_rather_than_run_in_some_order() {
let node = lifecycle(Some(vec![step("a", &["b"]), step("b", &["a"])]));
let message = ordered_steps(&node).unwrap_err();
assert!(message.contains("dependency cycle"), "{message}");
}
#[test]
fn a_lifecycle_node_with_no_steps_is_one_implicit_step() {
let node = lifecycle(None);
let steps = ordered_steps(&node).expect("one implicit step");
assert_eq!(steps.len(), 1);
assert_eq!(steps[0].id, "service");
assert_eq!(steps[0].persona.as_deref(), Some("engineer"));
}
#[test]
fn relays_only_what_the_follow_did_not() {
let wrote = |seq: u64| Envelope {
v: crate::event::ENVELOPE_VERSION,
ts: "2026-01-01T00:00:00.000Z".into(),
stream: "s-1".into(),
seq,
source: crate::event::Source::Vcs,
kind: crate::event::EventKind("session-closed".into()),
labels: Labels::default(),
payload: serde_json::Map::new(),
artifacts: Vec::new(),
};
let stream: Vec<Envelope> = (1..=4).map(wrote).collect();
let tail = beyond(stream.clone(), Some(3));
assert_eq!(tail.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![4]);
assert!(beyond(stream.clone(), Some(4)).is_empty());
assert_eq!(
beyond(stream, None)
.iter()
.map(|e| e.seq)
.collect::<Vec<_>>(),
vec![1, 2, 3, 4]
);
}
#[test]
fn a_step_ordering_ignores_a_dependency_on_something_outside_the_node() {
let node = lifecycle(Some(vec![step("only", &["elsewhere"])]));
let steps = ordered_steps(&node).expect("an outside reference does not deadlock");
assert_eq!(steps.len(), 1);
}
}