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 launched = node;
let mut node = std::borrow::Cow::Borrowed(node);
let mut attempt = std::num::NonZeroU32::MIN;
let mut published: Option<String> = None;
let mut notes: Vec<crate::note::RecordedNote> = Vec::new();
loop {
let preserved = match attempt_once(
executor, paths, launch, &node, references, ¬es, cancel, tx,
) {
Attempt::Settled(settlement) => return *settlement,
Attempt::Preserving(preserved) => preserved,
};
endings.push(preserved.outcome);
if let Some(same) = republished(published.as_deref(), &preserved.tip) {
return republished_the_same_commit(&node.id, &preserved, &endings, &same, attempt);
}
published = match &preserved.tip {
crate::vcs::SessionTip::At(commit) => Some(commit.as_str().to_owned()),
crate::vcs::SessionTip::Unmoved => published,
crate::vcs::SessionTip::Unknown => None,
};
if attempt >= attempts || cancel.is_cancelled() {
return stopped_retrying(&node.id, &preserved, &endings);
}
attempt = attempt.saturating_add(1);
notes = match crate::note::standing_for(paths, &node.id) {
Ok(standing) => standing.notes(),
Err(why) => {
return Settlement {
detail: Some(format!(
"the node was not dispatched again: {why}. The branch {} still \
carries the work; re-issue any note the earlier attempt was given \
and `retry` the node",
preserved.branch
)),
branch: Some(preserved.branch.clone()),
..Settlement::plain(
&node.id,
NodeStatus::Failed,
Some(engine::INFRASTRUCTURE_FAILURE),
)
};
} };
let _ = tx.send(Message::Redispatched(Box::new(engine::Redispatch {
node: node.id.clone(),
attempt,
attempts,
reason: format!("{}: {}", preserved.outcome.outcome(), preserved.reason),
carried: notes.clone(),
})));
node =
std::borrow::Cow::Owned(continued(launched, &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],
notes: &[crate::note::RecordedNote],
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 began = std::time::SystemTime::now();
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_carrying(node, references, notes),
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(),
};
if worktree.is_none() {
crate::vcs::wait_out_the_second(began);
}
let drained = engine::attempt(executor, node, 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);
let change_url = drained
.settlement
.change_url
.clone()
.or_else(|| session.as_ref().and_then(crate::vcs::change_opened_in));
end_session(stream, tx, session.as_ref(), &whose, vcs_filter);
return Attempt::settled(Settlement {
branch,
completed_steps: completed,
change_url,
..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(),
began,
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, the worktree and base \
its record named, and when the dispatch began); 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>,
began: std::time::SystemTime,
cancel: &crate::executor::CancellationToken,
tx: &Sender<Message>,
token: &onevcs::SessionToken,
branch: Option<String>,
) -> Attempt {
if let Some(level) = worktree
.zip(base)
.and_then(|(worktree, base)| crate::vcs::level_with_base(worktree, base, began))
{
return level_branch_settlement(node, &level, branch);
}
let held = match crate::vcs::session_change(token) {
Ok(held) => held,
Err(error) => {
eprintln!(
"onepipeline: node '{}': onevcs could not say whether session {} holds a change \
request, so the closeout publishes as it always has: {error}",
node.id, token.0
);
None
}
};
let worker_drafted = held.is_some() && crate::vcs::change_drafted_in(token);
let (body, undrafted) = match node.body.clone() {
Some(body) => (Some(body), None),
None => match drafted(
executor,
paths,
launch,
node,
worktree,
held.as_ref(),
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 (described, undescribed) = match (&held, &body) {
(Some(change), Some(body)) => {
match crate::vcs::describe_change(token, node.title.as_deref(), body) {
Ok(_) => (true, None),
Err(error) => {
let why = format!(
"the drafted description was not written onto {}: {error}",
change.url
);
eprintln!("onepipeline: node '{}': {why}", node.id);
(false, Some(why))
}
}
}
_ => (false, None),
};
let body_aside = undrafted.or(undescribed);
let with_aside = |detail: String| compose(&detail, body_aside.as_deref());
let publication_failed = |detail: String| {
Attempt::settled(Settlement {
branch: branch.clone(),
detail: Some(with_aside(detail)),
..Settlement::plain(
&node.id,
NodeStatus::Failed,
Some(crate::vcs::Failure::RESIDUAL),
)
})
};
let draft = crate::release::draft_reason(references)
.or_else(|| node.draft.then(|| crate::release::held_reason(&node.id)));
let opening_body = if held.is_some() {
None
} else {
body.as_deref()
};
let publication =
publish_rereading_the_merge_path(node, token, opening_body, 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,
body_aside.clone(),
);
}
return failed_publication(
&node.id,
token,
branch.or_else(|| Some(published.branch.clone())),
*kind,
reason,
retained.as_ref(),
body_aside.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 left_as_draft = matches!(published.outcome, onevcs::PublishOutcome::ChangeDraft(_));
let drafted = left_as_draft
.then(|| draft.as_ref().map(crate::release::drafted_detail))
.flatten();
let finished = held
.as_ref()
.map(|change| finished_detail(change, worker_drafted, described, left_as_draft));
let detail: Vec<String> = [drafted, finished, compared]
.into_iter()
.flatten()
.collect();
Attempt::settled(Settlement {
detail: (!detail.is_empty())
.then(|| with_aside(detail.join(". ")))
.or_else(|| body_aside.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, draft.as_ref()),
None,
)
})
}
Err(error) => publication_failed(error.to_string()),
}
}
fn level_branch_settlement(
node: &Node,
level: &crate::vcs::LevelBranch,
branch: Option<String>,
) -> Attempt {
let branch = branch.unwrap_or_else(|| level.branch.clone());
let compared = format!(
"compared against {}: {branch} carries nothing it does not",
level.base
);
let carried = level.wrote == crate::vcs::Wrote::ACommitTheBaseCarries;
if node.expects_no_diff || carried {
let detail = if carried {
format!(
"{compared}; the base already carries what this dispatch committed to it, so \
there was nothing to draft or publish"
)
} else {
compared
};
return Attempt::settled(Settlement {
branch: Some(branch),
detail: Some(crate::views::one_line(&detail)),
..Settlement::plain(&node.id, NodeStatus::Done, Some(engine::NO_CHANGES))
});
}
Attempt::settled(Settlement {
branch: Some(branch.clone()),
detail: Some(crate::views::one_line(&format!(
"{compared}. This dispatch committed nothing to it and the node does not declare \
`expects_no_diff`, so nothing was drafted or published. If no diff was ever \
expected, retry the node with `expects_no_diff: true`, which settles it done \
without a dispatch; otherwise amend its task and retry it to produce one"
))),
..Settlement::plain(&node.id, NodeStatus::Failed, Some(engine::EMPTY_BRANCH))
})
}
fn drafted_status(
outcome: &onevcs::PublishOutcome,
reason: Option<&onevcs::DraftReason>,
) -> NodeStatus {
match (outcome, reason) {
(
onevcs::PublishOutcome::ChangeDraft(_),
Some(onevcs::DraftReason::AwaitingRelease { .. }),
) => NodeStatus::CompleteDraft,
_ => NodeStatus::Done,
}
}
fn finished_detail(
change: &onevcs::SessionChange,
worker_drafted: bool,
described: bool,
left_as_draft: bool,
) -> String {
let opened = match (change.draft, worker_drafted) {
(true, true) => "the worker opened the change request as a draft",
(true, false) => "an earlier publication of this branch left the change request as a draft",
(false, _) => {
"the change request was already open from an earlier publication of this branch"
}
};
let description = if described {
"the closeout wrote the drafted description onto it"
} else {
"the closeout left the description as the worker left it"
};
let lifted = match (change.draft, left_as_draft) {
(true, false) => " and marked it ready for review",
(true, true) => " and left it as a draft",
(false, _) => "",
};
format!("{opened}; {description}{lifted}")
}
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,
body_aside: 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}"),
body_aside.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>,
body_aside: Option<String>,
tip: crate::vcs::SessionTip,
}
#[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>,
body_aside: 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}"), body_aside.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),
body_aside,
tip: crate::vcs::session_tip(token),
}))
}
_ => settled(),
} }
fn compose(detail: &str, body_aside: Option<&str>) -> String {
match body_aside {
Some(why) => format!("{detail}. {why}"),
None => detail.to_owned(),
}
}
fn republished(published: Option<&str>, tip: &crate::vcs::SessionTip) -> Option<String> {
let published = published?;
match tip {
crate::vcs::SessionTip::At(commit) => {
(commit.as_str() == published).then(|| published.to_owned())
}
crate::vcs::SessionTip::Unmoved => Some(published.to_owned()),
crate::vcs::SessionTip::Unknown => None,
}
}
fn republished_the_same_commit(
node: &str,
preserved: &Preserved,
endings: &[crate::vcs::Preserving],
head: &str,
attempt: std::num::NonZeroU32,
) -> Settlement {
let attempts = engine::publication_attempts();
let unspent = attempts
.get()
.saturating_sub(attempt.get().saturating_sub(1));
let roll_up = format!(
"{count} publication attempt{plural} on {branch} published the same commit, {head}, \
and ended {endings}: the refusal is not about the branch, so no further attempt on \
it could answer differently. {unspent} of {attempts} publication attempts are \
unspent",
count = endings.len(),
plural = if endings.len() == 1 { "" } else { "s" },
branch = preserved.branch,
endings = endings
.iter()
.map(|ending| ending.outcome())
.collect::<Vec<_>>()
.join(", "),
);
Settlement {
branch: Some(preserved.branch.clone()),
head: Some(head.to_owned()),
detail: Some(compose(
&format!("onevcs: {}. {roll_up}", preserved.reason),
preserved.body_aside.as_deref(),
)),
..Settlement::plain(
node,
NodeStatus::Failed,
Some(crate::vcs::Failure::RESIDUAL),
)
}
}
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.body_aside.as_deref(),
)),
..Settlement::plain(node, NodeStatus::Failed, Some(preserved.outcome.outcome()))
}
}
fn continued(
launched: &Node,
preserved: &Preserved,
attempt: std::num::NonZeroU32,
attempts: std::num::NonZeroU32,
endings: &[crate::vcs::Preserving],
) -> Node {
let diagnosis = diagnosis(preserved, attempt, attempts, endings);
let context = match launched.context.as_deref().map(str::trim) {
Some(note) if !note.is_empty() => format!("{note}\n\n{diagnosis}"),
_ => diagnosis,
};
Node {
branch: Some(preserved.branch.clone()),
resume: None,
context: Some(context),
..launched.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:";
const CHANGE_REQUEST_HEADING: &str = "## Change request";
const HELD_AS_DRAFT_LINE: &str = "Held as a draft by the worker:";
const DESCRIPTION_HEADING: &str = "### Description as the worker left it";
const NO_DESCRIPTION: &str = "(the worker left no description)";
const WORKER_TRANSCRIPT_HEADING: &str = "## Worker transcript";
fn drafting_task(node: &Node, run: &str, held: Option<&onevcs::SessionChange>) -> String {
let mut task = format!("{DRAFTING_TASK}\n\n{}", node.rendered_task());
if let Some(change) = held {
let description = if change.body.trim().is_empty() {
NO_DESCRIPTION
} else {
change.body.as_str()
};
task.push_str(&format!(
"\n\n{CHANGE_REQUEST_HEADING}\n{url}\n{HELD_AS_DRAFT_LINE} {held}\n\n\
{DESCRIPTION_HEADING}\n{description}",
url = change.url,
held = if change.draft { "yes" } else { "no" },
));
}
task.push_str(&format!(
"\n\n{WORKER_TRANSCRIPT_HEADING}\n\
`onepipeline transcript {run} {node}` renders every tool call the worker made on this \
branch and what each returned; `{run_env}` and `{runs_dir_env}` in this dispatch's \
environment are what that command reads. Read it to find evidence the worker \
produced that belongs in the description — a demonstration change request it opened, \
a comment a bot posted, a check it triggered.",
node = node.id,
run_env = crate::agentgraph::RUN_ID_ENV,
runs_dir_env = crate::agentgraph::RUNS_DIR_ENV,
));
task
}
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, the change request the session holds, cancellation, and the \
event stream"
)]
fn drafted(
executor: &dyn Executor,
paths: &RunPaths,
launch: &Launch,
node: &Node,
worktree: Option<&std::path::Path>,
held: Option<&onevcs::SessionChange>,
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: drafting_task(node, &paths.run, held),
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 a_tip_nothing_could_read_is_not_evidence_that_the_branch_did_not_move() {
let commit = |sha: &str| crate::vcs::Commit::of(sha).expect("a commit this crate carries");
let published = Some("c0ffee");
assert_eq!(
republished(published, &crate::vcs::SessionTip::At(commit("c0ffee"))),
Some("c0ffee".to_string()),
"the same commit, published twice, was read as two"
);
assert_eq!(
republished(published, &crate::vcs::SessionTip::At(commit("decaf"))),
None,
"a branch that moved was read as one that stood still"
);
assert_eq!(
republished(published, &crate::vcs::SessionTip::Unmoved),
Some("c0ffee".to_string()),
"a session that committed nothing moved the branch it did not touch"
);
assert_eq!(
republished(published, &crate::vcs::SessionTip::Unknown),
None,
"a record nothing could read was taken as proof the branch stood still"
);
assert_eq!(republished(None, &crate::vcs::SessionTip::Unmoved), None);
}
#[test]
fn a_continuation_keeps_the_launched_note_and_carries_one_diagnosis() {
let launched = Node {
id: "service".into(),
context: Some("the reviewer asked for a smaller diff".into()),
..Node::default()
};
let preserved = |reason: &str| Preserved {
branch: "feat/service".into(),
outcome: crate::vcs::Preserving::ChecksFailed,
reason: reason.into(),
evidence: Vec::new(),
body_aside: None,
tip: crate::vcs::SessionTip::Unknown,
};
let two = std::num::NonZeroU32::new(2).expect("two");
let three = std::num::NonZeroU32::new(3).expect("three");
let endings = [
crate::vcs::Preserving::ChecksFailed,
crate::vcs::Preserving::ChecksFailed,
];
let second = continued(
&launched,
&preserved("llmlint red"),
two,
three,
&endings[..1],
);
let context = second
.context
.as_deref()
.expect("the continuation carries context");
assert!(
context.starts_with("the reviewer asked for a smaller diff\n\n"),
"the launched note does not lead the continuation's context:\n{context}"
);
assert!(context.contains("attempt 2 of 3") && context.contains("llmlint red"));
assert_eq!(second.branch.as_deref(), Some("feat/service"));
assert!(second.resume.is_none());
let third = continued(
&launched,
&preserved("llmlint still red"),
three,
three,
&endings,
);
let context = third
.context
.as_deref()
.expect("the continuation carries context");
assert_eq!(
context
.matches("the reviewer asked for a smaller diff")
.count(),
1
);
assert_eq!(
context
.matches("The previous attempt's publication failed")
.count(),
1
);
assert!(!context.contains("llmlint red\n"), "{context}");
assert!(context.contains("attempt 3 of 3") && context.contains("llmlint still red"));
assert!(
context.contains("Every attempt so far ended: checks-failed, checks-failed."),
"{context}"
);
let bare = continued(
&Node::default(),
&preserved("red"),
two,
three,
&endings[..1],
);
assert!(bare
.context
.as_deref()
.is_some_and(|context| context.starts_with("The previous attempt's publication")));
}
#[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 the_drafting_task_and_the_dispatch_environment_are_what_the_divergence_record_names() {
let record = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("docs/contract-divergences.md"),
)
.expect("the divergence record ships");
let entry = record
.split("\n## ")
.find(|entry| entry.starts_with("69."))
.expect("the record still carries entry 69");
let block: serde_json::Value = entry
.split("```json")
.nth(1)
.and_then(|rest| rest.split("```").next())
.and_then(|block| serde_json::from_str(block).ok())
.expect("entry 69 carries the json block this test drives");
let task = &block["drafting_task"];
for (key, constant) in [
("opening", DRAFTING_TASK),
("change_request_heading", CHANGE_REQUEST_HEADING),
("held_as_draft_line", HELD_AS_DRAFT_LINE),
("description_heading", DESCRIPTION_HEADING),
("no_description", NO_DESCRIPTION),
("worker_transcript_heading", WORKER_TRANSCRIPT_HEADING),
] {
assert_eq!(
task[key].as_str(),
Some(constant),
"entry 69's `{key}` is not the literal this build composes with"
);
}
let environment: Vec<String> =
serde_json::from_value(block["environment"].clone()).expect("entry 69 names the names");
assert_eq!(
environment,
vec![
crate::agentgraph::SESSION_ENV.to_string(),
crate::agentgraph::RUNS_DIR_ENV.to_string()
],
"entry 69 names environment variables this build does not compose"
);
let node = lifecycle(None);
let held = onevcs::SessionChange {
url: onevcs::Url::parse("https://github.com/owner/service/pull/7").expect("a url"),
id: onevcs::ChangeId("7".to_owned()),
base: "main".to_owned(),
draft: true,
title: "wip: the worker's".to_owned(),
body: String::new(),
};
let with = drafting_task(&node, "run-1", Some(&held));
let without = drafting_task(&node, "run-1", None);
assert!(with.starts_with(DRAFTING_TASK) && without.starts_with(DRAFTING_TASK));
let positions: Vec<usize> = [
DRAFTING_TASK,
CHANGE_REQUEST_HEADING,
HELD_AS_DRAFT_LINE,
DESCRIPTION_HEADING,
NO_DESCRIPTION,
WORKER_TRANSCRIPT_HEADING,
]
.iter()
.map(|literal| {
with.find(literal)
.unwrap_or_else(|| panic!("the composed task lacks {literal:?}:\n{with}"))
})
.collect();
assert!(
positions.windows(2).all(|pair| pair[0] < pair[1]),
"the sections are out of order:\n{with}"
);
assert!(with.contains(&format!(
"{CHANGE_REQUEST_HEADING}\n{}\n{HELD_AS_DRAFT_LINE} yes",
held.url
)));
assert!(with.contains("`onepipeline transcript run-1 service`"));
assert!(
!without.contains(CHANGE_REQUEST_HEADING)
&& without.contains(WORKER_TRANSCRIPT_HEADING),
"a session holding no change request was shown one:\n{without}"
);
let described = drafting_task(
&node,
"run-1",
Some(&onevcs::SessionChange {
body: "## What\nHalf written.".to_owned(),
draft: false,
..held
}),
);
assert!(described.contains(&format!("{DESCRIPTION_HEADING}\n## What\nHalf written.")));
assert!(described.contains(&format!("{HELD_AS_DRAFT_LINE} no")));
assert!(!described.contains(NO_DESCRIPTION));
}
#[test]
fn a_level_branch_settles_on_the_commit_and_the_declaration_rather_than_the_count() {
let level = |wrote: crate::vcs::Wrote| crate::vcs::LevelBranch {
branch: "work/service".into(),
base: "origin/main".into(),
wrote,
};
let (nothing, a_commit) = (
crate::vcs::Wrote::Nothing,
crate::vcs::Wrote::ACommitTheBaseCarries,
);
let settled = |node: &Node, level: &crate::vcs::LevelBranch| match level_branch_settlement(
node, level, None,
) {
Attempt::Settled(settlement) => *settlement,
Attempt::Preserving(_) => panic!("a level branch is an answer, not an attempt"),
};
let node = lifecycle(None);
let empty = settled(&node, &level(nothing));
assert_eq!(empty.status, NodeStatus::Failed);
assert_eq!(empty.outcome.as_deref(), Some(engine::EMPTY_BRANCH));
assert_eq!(empty.branch.as_deref(), Some("work/service"));
let detail = empty.detail.expect("the settlement says why");
for claim in [
"compared against origin/main: work/service carries nothing it does not",
"committed nothing",
"does not declare `expects_no_diff`",
"nothing was drafted or published",
"retry the node with `expects_no_diff: true`",
] {
assert!(detail.contains(claim), "{detail}");
}
for declared in [false, true] {
let node = Node {
expects_no_diff: declared,
..node.clone()
};
let carried = settled(&node, &level(a_commit));
assert_eq!(carried.status, NodeStatus::Done, "declared: {declared}");
assert_eq!(carried.outcome.as_deref(), Some(engine::NO_CHANGES));
let detail = carried
.detail
.expect("the settlement says what it compared");
assert!(detail.contains("compared against origin/main"), "{detail}");
assert!(
detail.contains("already carries what this dispatch committed"),
"{detail}"
);
}
let declared = settled(
&Node {
expects_no_diff: true,
..node
},
&level(nothing),
);
assert_eq!(declared.status, NodeStatus::Done);
assert_eq!(declared.outcome.as_deref(), Some(engine::NO_CHANGES));
assert_eq!(
declared.detail.as_deref(),
Some("compared against origin/main: work/service carries nothing it does not")
);
let named =
match level_branch_settlement(&lifecycle(None), &level(nothing), Some("kept".into())) {
Attempt::Settled(settlement) => settlement.branch,
Attempt::Preserving(_) => unreachable!(),
};
assert_eq!(named.as_deref(), Some("kept"));
}
#[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);
}
}