use std::collections::{BTreeMap, BTreeSet};
use std::sync::mpsc::Sender;
use std::time::{Duration, Instant};
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>,
}
#[allow(
clippy::too_many_arguments,
reason = "one node's whole execution: the executor, the run, the launch, the node, \
its cross-repository references, its cancellation, and where to report"
)]
pub fn execute(
executor: &dyn Executor,
paths: &RunPaths,
launch: &Launch,
node: &Node,
references: &[crate::plan::CrossRepoReference],
cancel: &crate::executor::CancellationToken,
tx: &Sender<Message>,
) -> Settlement {
let attempts = engine::publication_attempts();
let mut endings: Vec<crate::vcs::Preserving> = Vec::new();
let mut node = std::borrow::Cow::Borrowed(node);
let mut attempt = std::num::NonZeroU32::MIN;
loop {
let preserved = match attempt_once(executor, paths, launch, &node, references, cancel, tx) {
Attempt::Settled(settlement) => return *settlement,
Attempt::Preserving(preserved) => preserved,
};
endings.push(preserved.outcome);
if attempt >= attempts || cancel.is_cancelled() {
return stopped_retrying(&node.id, &preserved, &endings);
}
attempt = attempt.saturating_add(1);
let _ = tx.send(Message::Redispatched(Box::new(engine::Redispatch {
node: node.id.clone(),
attempt,
attempts,
reason: format!("{}: {}", preserved.outcome.outcome(), preserved.reason),
})));
node = std::borrow::Cow::Owned(continued(&node, &preserved, attempt, attempts, &endings));
}
}
#[allow(
clippy::too_many_arguments,
reason = "one attempt's whole context, which is `execute`'s own — see the reason there"
)]
fn attempt_once(
executor: &dyn Executor,
paths: &RunPaths,
launch: &Launch,
node: &Node,
references: &[crate::plan::CrossRepoReference],
cancel: &crate::executor::CancellationToken,
tx: &Sender<Message>,
) -> Attempt {
let run = paths.run.as_str();
let vcs_filter = launch.vcs_filter.as_ref();
let Some(request) = crate::vcs::request_for(node) else {
return Attempt::settled(Settlement {
detail: Some("a lifecycle node needs a repo".into()),
..Settlement::plain(&node.id, NodeStatus::Failed, Some(engine::INVALID_NODE))
});
};
let declared_steps = node.steps.is_some();
let steps = match dispatchable_steps(node) {
Ok(steps) => steps,
Err(reason) => {
return Attempt::settled(Settlement {
detail: Some(reason),
..Settlement::plain(&node.id, NodeStatus::Failed, Some(engine::INVALID_NODE))
})
}
};
let mut session: Option<onevcs::SessionToken> = None;
let mut stream: Option<crate::vcs::Follower> = None;
let mut worktree: Option<std::path::PathBuf> = None;
let mut base: Option<String> = 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 {
check_criteria(node, worktree.as_deref(), tx);
return Attempt::settled(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_for(node, references),
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 {
let opened = crate::vcs::working_session(token);
worktree = opened.as_ref().map(|open| open.worktree.clone());
base = opened.map(|open| open.base);
stream = crate::vcs::follow(token, vcs_filter, relay_into(tx, whose.clone()));
}
}
if drained.settlement.status != NodeStatus::Done {
check_criteria(node, worktree.as_deref(), tx);
end_session(stream, tx, session.as_ref(), &whose, vcs_filter);
return Attempt::settled(Settlement {
branch,
completed_steps: completed,
..drained.settlement
});
}
if declared_steps {
completed.push(step.id.clone());
}
}
let Some(token) = session else {
return Attempt::settled(Settlement {
branch,
..Settlement::plain(&node.id, NodeStatus::Done, Some(engine::NO_CHANGES))
});
};
let attempted = publish(
executor,
paths,
launch,
node,
references,
worktree.as_deref(),
base.as_deref(),
cancel,
tx,
&token,
branch,
);
if matches!(attempted, Attempt::Settled(_)) {
check_criteria(node, worktree.as_deref(), tx);
}
end_session(stream, tx, Some(&token), &whose, vcs_filter);
attempted
}
fn check_criteria(node: &Node, worktree: Option<&std::path::Path>, tx: &Sender<Message>) {
let (Some(worktree), Some(whose)) = (worktree, crate::graph::NodeRef::of(node)) else {
return;
};
for check in crate::criteria::checkable_of(node) {
let answer = crate::criteria::answer(worktree, &check);
let _ = tx.send(Message::CriterionChecked(Box::new(
engine::CriterionChecked {
node: whose.clone(),
check,
answer,
},
)));
}
}
#[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 and base \
its record named); 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,
references: &[crate::plan::CrossRepoReference],
worktree: Option<&std::path::Path>,
base: Option<&str>,
cancel: &crate::executor::CancellationToken,
tx: &Sender<Message>,
token: &onevcs::SessionToken,
branch: Option<String>,
) -> Attempt {
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 with_undrafted = |detail: String| compose(&detail, undrafted.as_deref());
let publication_failed = |detail: String| {
Attempt::settled(Settlement {
branch: branch.clone(),
detail: Some(with_undrafted(detail)),
..Settlement::plain(
&node.id,
NodeStatus::Failed,
Some(crate::vcs::Failure::RESIDUAL),
)
})
};
let draft = crate::release::draft_reason(references);
let publication =
publish_rereading_the_merge_path(node, token, body.as_deref(), draft.as_ref(), cancel);
match publication.answered {
Ok(published) => {
if let onevcs::PublishOutcome::Failed {
kind,
reason,
retained,
} = &published.outcome
{
if crate::vcs::failure_of(*kind) == crate::vcs::Failure::Unread {
return unread_merge_path(
&node.id,
token,
branch.or_else(|| Some(published.branch.clone())),
reason,
publication.reads,
undrafted.clone(),
);
}
return failed_publication(
&node.id,
token,
branch.or_else(|| Some(published.branch.clone())),
*kind,
reason,
retained.as_ref(),
undrafted.clone(),
);
}
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,
))));
let compared = match published.outcome {
onevcs::PublishOutcome::NothingToPublish => base.map(|base| {
crate::views::one_line(&format!(
"compared against {base}: {} carries nothing it does not",
published.branch
))
}),
_ => None,
};
let drafted = matches!(published.outcome, onevcs::PublishOutcome::ChangeDraft(_))
.then(|| draft.as_ref().map(crate::release::drafted_detail))
.flatten();
Attempt::settled(Settlement {
detail: drafted
.or(compared)
.map(&with_undrafted)
.or_else(|| 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, drafted_status(&published.outcome), None)
})
}
Err(error) => publication_failed(error.to_string()),
}
}
fn drafted_status(outcome: &onevcs::PublishOutcome) -> NodeStatus {
match outcome {
onevcs::PublishOutcome::ChangeDraft(_) => NodeStatus::CompleteDraft,
_ => NodeStatus::Done,
}
}
struct Published {
answered: crate::error::Result<onevcs::Publication>,
reads: std::num::NonZeroU32,
}
fn publish_rereading_the_merge_path(
node: &Node,
token: &onevcs::SessionToken,
body: Option<&str>,
draft: Option<&onevcs::DraftReason>,
cancel: &crate::executor::CancellationToken,
) -> Published {
let publish =
|| crate::vcs::publish(token, node.merge_policy, node.title.as_deref(), body, draft);
let budget = engine::merge_path_reads();
let mut backoff = engine::merge_path_backoff();
let mut reads = std::num::NonZeroU32::MIN;
let mut answered = publish();
while reads < budget && still_unread(&answered) && waited(backoff, cancel) {
backoff = engine::doubled(backoff);
reads = reads.saturating_add(1);
answered = publish();
}
Published { answered, reads }
}
fn waited(backoff: std::time::Duration, cancel: &crate::executor::CancellationToken) -> bool {
const STEP: std::time::Duration = std::time::Duration::from_millis(50);
let until = std::time::Instant::now() + backoff;
loop {
if cancel.is_cancelled() {
return false;
}
let left = until.saturating_duration_since(std::time::Instant::now());
if left.is_zero() {
return true;
}
std::thread::sleep(left.min(STEP));
}
}
fn still_unread(answered: &crate::error::Result<onevcs::Publication>) -> bool {
let Ok(published) = answered else {
return false;
};
matches!(
&published.outcome,
onevcs::PublishOutcome::Failed { kind, .. }
if crate::vcs::failure_of(*kind) == crate::vcs::Failure::Unread
)
}
fn unread_merge_path(
node: &str,
token: &onevcs::SessionToken,
branch: Option<String>,
reason: &str,
reads: std::num::NonZeroU32,
undrafted: Option<String>,
) -> Attempt {
let how_many = format!(
"the merge path was read {reads} time{} and never answered",
if reads.get() == 1 { "" } else { "s" }
);
Attempt::settled(Settlement {
branch,
head: crate::vcs::branch_head_in(token),
detail: Some(compose(
&format!("onevcs: {reason}. {how_many}"),
undrafted.as_deref(),
)),
..Settlement::plain(node, NodeStatus::Failed, Some(crate::vcs::Failure::UNREAD))
})
}
enum Attempt {
Settled(Box<Settlement>),
Preserving(Box<Preserved>),
}
impl Attempt {
fn settled(settlement: Settlement) -> Self {
Self::Settled(Box::new(settlement))
}
}
struct Preserved {
branch: String,
outcome: crate::vcs::Preserving,
reason: String,
evidence: Vec<crate::vcs::Evidence>,
undrafted: Option<String>,
}
#[allow(
clippy::too_many_arguments,
reason = "the failure's own five values — which kind, what it said, what became of the \
branch, which branch, and which node — plus the session the evidence is read \
off and the drafting ending the settlement carries either way. Bundling them \
would name a struct whose only constructor is this call site"
)]
fn failed_publication(
node: &str,
token: &onevcs::SessionToken,
branch: Option<String>,
kind: onevcs::FailureKind,
reason: &str,
retained: Option<&onevcs::Retention>,
undrafted: Option<String>,
) -> Attempt {
let failure = crate::vcs::failure_of(kind);
let handed_back = matches!(retained, Some(onevcs::Retention::HandedBack(_)));
let settled = || {
Attempt::settled(Settlement {
branch: branch.clone(),
detail: Some(compose(&format!("onevcs: {reason}"), undrafted.as_deref())),
..Settlement::plain(node, NodeStatus::Failed, Some(failure.outcome()))
})
};
match (failure, handed_back, branch.clone()) {
(crate::vcs::Failure::Preserving(outcome), true, Some(branch)) => {
Attempt::Preserving(Box::new(Preserved {
branch,
outcome,
reason: engine::bounded(&crate::views::one_line(reason)),
evidence: crate::vcs::evidence_in(token),
undrafted,
}))
}
_ => settled(),
} }
fn compose(detail: &str, undrafted: Option<&str>) -> String {
match undrafted {
Some(why) => format!("{detail}. {why}"),
None => detail.to_owned(),
}
}
fn stopped_retrying(
node: &str,
preserved: &Preserved,
endings: &[crate::vcs::Preserving],
) -> Settlement {
let each: Vec<String> = endings
.iter()
.enumerate()
.map(|(index, ending)| format!("{} {}", index + 1, ending.outcome()))
.collect();
let roll_up = format!(
"{} publication attempt{} on {}: {}",
endings.len(),
if endings.len() == 1 { "" } else { "s" },
preserved.branch,
each.join(", ")
);
Settlement {
branch: Some(preserved.branch.clone()),
detail: Some(compose(
&format!("onevcs: {}. {roll_up}", preserved.reason),
preserved.undrafted.as_deref(),
)),
..Settlement::plain(node, NodeStatus::Failed, Some(preserved.outcome.outcome()))
}
}
fn continued(
node: &Node,
preserved: &Preserved,
attempt: std::num::NonZeroU32,
attempts: std::num::NonZeroU32,
endings: &[crate::vcs::Preserving],
) -> Node {
Node {
branch: Some(preserved.branch.clone()),
resume: None,
context: Some(diagnosis(preserved, attempt, attempts, endings)),
..node.clone()
}
}
fn diagnosis(
preserved: &Preserved,
attempt: std::num::NonZeroU32,
attempts: std::num::NonZeroU32,
endings: &[crate::vcs::Preserving],
) -> String {
let mut note = format!(
"The previous attempt's publication failed and its branch was preserved. This is \
attempt {attempt} of {attempts}, and it continues that same branch — {branch} — so \
the tree that was rejected is the tree this dispatch starts from. Change what the \
failure below is about; republishing it unaltered meets the same refusal.\n\n\
The publication ended `{ending}`, and `onevcs` said:\n\n{reason}\n",
branch = preserved.branch,
ending = preserved.outcome.outcome(),
reason = preserved.reason,
);
if endings.len() > 1 {
let each: Vec<&str> = endings.iter().map(|ending| ending.outcome()).collect();
note.push_str(&format!(
"\nEvery attempt so far ended: {}.\n",
each.join(", ")
));
}
if !preserved.evidence.is_empty() {
note.push_str(
"\nThe publication recorded this evidence, each fetched with \
`onevcs artifact cat ID`:\n",
);
for evidence in &preserved.evidence {
note.push_str(&format!("- {} — {}\n", evidence.kind.0, evidence.id.0));
}
}
note
}
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) => {
return Some(undrafted(
&node.id,
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(undrafted(
&node.id,
format!(
"the drafting dispatch settled without succeeding: {}",
first_line(&outcome.detail)
),
)),
Err(error) => Some(undrafted(
&node.id,
format!("the drafting dispatch could not be waited on: {error}"),
)),
} }
fn undrafted(node: &str, why: String) -> Drafted {
eprintln!("onepipeline: node '{node}': {why}, so it publishes with no body");
Drafted::Undrafted(Undrafted::Dispatch(why))
}
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)));
})
}
pub(crate) 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());
}
const TERMINATOR_GRACE: Duration = Duration::from_secs(5);
const TERMINATOR_POLL: Duration = Duration::from_millis(250);
fn end_session(
stream: Option<crate::vcs::Follower>,
tx: &Sender<Message>,
token: Option<&onevcs::SessionToken>,
node: &Labels,
filter: Option<&EventFilter>,
) {
let followed_through = stream.map(crate::vcs::Follower::finish).unwrap_or_default();
let refused = close(token);
relay_session_events(tx, token, node, followed_through, filter, refused);
}
fn relay_session_events(
tx: &Sender<Message>,
token: Option<&onevcs::SessionToken>,
node: &Labels,
followed_through: crate::vcs::Watermarks,
filter: Option<&EventFilter>,
refused: Option<String>,
) {
let Some(token) = token else { return };
let relay = relay_into(tx, node.clone());
let mut relayed = followed_through;
let mut refused = refused;
let deadline = Instant::now() + TERMINATOR_GRACE;
loop {
let read = crate::vcs::events(token, filter);
let ended = read.iter().any(crate::vcs::is_terminator);
for envelope in beyond(read, &relayed) {
relayed.reached(&envelope);
relay(envelope);
}
if ended {
return;
}
if Instant::now() >= deadline {
eprintln!(
"onepipeline: session {} ended with no `session-closed` record{}",
token.0,
refused
.map(|why| format!("; its close refused: {why}"))
.unwrap_or_default()
);
return;
}
if refused.is_some() {
refused = close(Some(token));
}
std::thread::sleep(TERMINATOR_POLL);
}
}
fn beyond(envelopes: Vec<Envelope>, followed_through: &crate::vcs::Watermarks) -> Vec<Envelope> {
envelopes
.into_iter()
.filter(|envelope| followed_through.beyond(envelope))
.collect()
}
fn close(token: Option<&onevcs::SessionToken>) -> Option<String> {
crate::vcs::session_close(token?)
.err()
.map(|refusal| refusal.to_string())
}
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_counting_each_stream_on_its_own() {
let wrote = |stream: &str, seq: u64| Envelope {
v: crate::event::ENVELOPE_VERSION,
ts: "2026-01-01T00:00:00.000Z".into(),
stream: stream.to_owned(),
seq,
source: crate::event::Source::Vcs,
kind: crate::event::EventKind("session-closed".into()),
phase: None,
labels: Labels::default(),
payload: serde_json::Map::new(),
artifacts: Vec::new(),
};
let session: Vec<Envelope> = (1..=4).map(|seq| wrote("s-1", seq)).collect();
let mut reached = crate::vcs::Watermarks::default();
for envelope in &session[..3] {
reached.reached(envelope);
}
let tail = beyond(session.clone(), &reached);
assert_eq!(tail.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![4]);
reached.reached(&session[3]);
assert!(beyond(session.clone(), &reached).is_empty());
let released = wrote("releases-0a1b2c3d4e5f", 2);
assert_eq!(
beyond(vec![released.clone()], &reached)
.iter()
.map(|e| e.stream.clone())
.collect::<Vec<_>>(),
vec!["releases-0a1b2c3d4e5f".to_owned()],
"a release was hidden by how far the session's own stream had got"
);
reached.reached(&released);
assert!(beyond(vec![released], &reached).is_empty());
assert_eq!(
beyond(vec![wrote("s-1", 5)], &reached)
.iter()
.map(|e| e.seq)
.collect::<Vec<_>>(),
vec![5]
);
assert_eq!(
beyond(session, &crate::vcs::Watermarks::default())
.iter()
.map(|e| e.seq)
.collect::<Vec<_>>(),
vec![1, 2, 3, 4]
);
}
#[test]
fn a_session_whose_terminator_arrives_late_still_relays_it_and_one_with_none_says_so() {
let _home = crate::vcs::scratch_home_held();
let root =
std::env::temp_dir().join(format!("onepipeline-terminator-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("streams")).expect("a scratch state root");
std::env::set_var("ONEVCS_HOME", &root);
let record = |token: &str, seq: u64, kind: &str| {
serde_json::json!({
"v": crate::event::ENVELOPE_VERSION,
"ts": "2026-01-01T00:00:00.000Z",
"stream": token,
"seq": seq,
"source": "vcs",
"kind": kind,
"labels": {},
"payload": {},
"artifacts": [],
})
.to_string()
};
let path = |token: &str| root.join("streams").join(format!("{token}.ndjson"));
let write = |token: &str, body: &str| {
std::fs::write(path(token), body).expect("the stream is written");
};
let relayed = |rx: &std::sync::mpsc::Receiver<Message>| {
let mut kinds = Vec::new();
while let Ok(message) = rx.try_recv() {
if let Message::Event(envelope) = message {
kinds.push(envelope.kind.0.clone());
}
}
kinds
};
let end = |token: &str, tx: &Sender<Message>| {
end_session(
None,
tx,
Some(&onevcs::SessionToken(token.to_owned())),
&Labels::default(),
None,
);
};
let closed = "s-terminated";
write(
closed,
&format!(
"{}\n{}\n",
record(closed, 1, "push"),
record(closed, 2, "session-closed")
),
);
let (tx, rx) = std::sync::mpsc::channel();
let began = Instant::now();
end(closed, &tx);
assert_eq!(relayed(&rx), vec!["push", "session-closed"]);
assert!(
began.elapsed() < TERMINATOR_GRACE,
"a session that had already ended was waited on anyway"
);
let late = "s-latelyclosed";
let pushed = record(late, 1, "push");
let ended = record(late, 2, "session-closed");
write(late, &format!("{pushed}\n"));
let writing = {
let path = path(late);
std::thread::spawn(move || {
std::thread::sleep(TERMINATOR_POLL * 2);
std::fs::write(&path, format!("{pushed}\n{ended}\n"))
.expect("the terminator is written");
})
};
let (tx, rx) = std::sync::mpsc::channel();
end(late, &tx);
writing.join().expect("the writer finishes");
assert_eq!(
relayed(&rx),
vec!["push", "session-closed"],
"a terminator written after the first read never reached the merged store"
);
let never = "s-neverclosed";
write(never, &format!("{}\n", record(never, 1, "push")));
let (tx, rx) = std::sync::mpsc::channel();
let began = Instant::now();
end(never, &tx);
assert!(
began.elapsed() >= TERMINATOR_GRACE,
"the wait for a terminator gave up early"
);
assert_eq!(
relayed(&rx),
vec!["push"],
"a re-read handed the same record back a second time"
);
let _ = std::fs::remove_dir_all(&root);
}
#[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);
}
}