use std::collections::{BTreeMap, BTreeSet};
use std::num::{NonZeroU32, NonZeroU64};
use std::sync::mpsc::{self, Receiver, Sender};
use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
use serde_json::json;
use crate::agentgraph::{self, Interrupted, TurnAddress};
use crate::channel::{ChannelState, Command, CommandOutcome, Deliver, Surface};
use crate::edits::{self, Frontier};
use crate::error::{Error, Result};
use crate::event::{Envelope, Labels};
use crate::executor::{
CancelMode, CancellationToken, DispatchHandle, DispatchOutcome, DispatchRequest, Executor,
WorkspaceSpec,
};
use crate::graph::{self, Graph, GraphState, Landing, NodeStatus};
use crate::journal::{self, Journal};
use crate::ledger::{self, LaunchRecord, OwnershipLock, RunPaths};
use crate::plan::{Node, NodeKind};
use crate::projection::{self, RunState};
use crate::rules::ExecutorRules;
use crate::sys;
pub const NODE_GRAPH_ENV: &str = "ONEPIPELINE_NODE_GRAPH";
pub const DEFAULT_NODE_GRAPH: &str = "graphs/node-scope.yaml";
pub const EXECUTOR_RULES_ENV: &str = "ONEPIPELINE_EXECUTOR_RULES";
pub const NODE_VALIDATOR_ENV: &str = "ONEPIPELINE_NODE_VALIDATOR";
pub const PROJECT_DIR_ENV: &str = "ONEPIPELINE_PROJECT_DIR";
pub const DRIVE_VERB: &str = "drive-run";
pub const STALL_AFTER_ENV: &str = "ONEPIPELINE_STALL_AFTER_SECONDS";
pub const DEFAULT_STALL_AFTER_SECONDS: u64 = 2_400;
pub const BOUNDARY_ATTEMPTS_ENV: &str = "ONEPIPELINE_BOUNDARY_ATTEMPTS";
pub const BOUNDARY_BACKOFF_ENV: &str = "ONEPIPELINE_BOUNDARY_BACKOFF_SECONDS";
pub const DEFAULT_BOUNDARY_ATTEMPTS: u32 = 3;
pub const DEFAULT_BOUNDARY_BACKOFF_SECONDS: u64 = 5;
const BOUNDARY_BACKOFF_CEILING: Duration = Duration::from_secs(120);
pub const PUBLICATION_ATTEMPTS_ENV: &str = "ONEPIPELINE_PUBLICATION_ATTEMPTS";
pub const DEFAULT_PUBLICATION_ATTEMPTS: NonZeroU32 = NonZeroU32::new(3).unwrap();
pub const MERGE_PATH_READS_ENV: &str = "ONEPIPELINE_MERGE_PATH_READS";
pub const MERGE_PATH_BACKOFF_ENV: &str = "ONEPIPELINE_MERGE_PATH_BACKOFF_SECONDS";
pub const DEFAULT_MERGE_PATH_READS: NonZeroU32 = NonZeroU32::new(3).unwrap();
pub const DEFAULT_MERGE_PATH_BACKOFF_SECONDS: u64 = 5;
pub const CANCEL_GRACE_ENV: &str = "ONEPIPELINE_CANCEL_GRACE_SECONDS";
pub const DEFAULT_CANCEL_GRACE_SECONDS: u64 = 300;
pub const CANCEL_INPUT: &str = "Stop this task now. Do not start any new work, and do not begin \
another file, command, or tool call. Commit anything you have not \
committed yet, then end your turn.";
const POLL: Duration = Duration::from_millis(25);
pub const RUN_RESULT_SCHEMA_VERSION: u32 = 4;
fn readable_run_result_version<'de, D: serde::Deserializer<'de>>(
reader: D,
) -> std::result::Result<u32, D::Error> {
let found = u32::deserialize(reader)?;
if found != RUN_RESULT_SCHEMA_VERSION {
return Err(serde::de::Error::custom(format!(
"run result schema_version {found}, and this build reads \
{RUN_RESULT_SCHEMA_VERSION}"
)));
}
Ok(found)
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(into = "RunResultWire", from = "RunResultWire")]
pub struct RunResult {
pub run_id: String,
pub state: GraphState,
pub nodes: Vec<NodeResult>,
}
impl RunResult {
pub fn ok(&self) -> bool {
self.state == GraphState::Complete
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct RunResultWire {
#[serde(deserialize_with = "readable_run_result_version")]
schema_version: u32,
run_id: String,
state: GraphState,
ok: bool,
nodes: Vec<NodeResult>,
}
impl From<RunResult> for RunResultWire {
fn from(result: RunResult) -> Self {
Self {
schema_version: RUN_RESULT_SCHEMA_VERSION,
ok: result.ok(),
run_id: result.run_id,
state: result.state,
nodes: result.nodes,
}
}
}
impl From<RunResultWire> for RunResult {
fn from(wire: RunResultWire) -> Self {
Self {
run_id: wire.run_id,
state: wire.state,
nodes: wire.nodes,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct NodeResult {
pub id: String,
pub status: NodeStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub outcome: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub landing: Option<Landing>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub action: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub unblocks: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub blocked_by: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub branch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub change_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cause: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub head: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Settlement {
pub node: String,
pub status: NodeStatus,
pub outcome: Option<String>,
pub landing: Option<Landing>,
pub detail: Option<String>,
pub branch: Option<String>,
pub change_url: Option<String>,
pub cause: Option<String>,
pub head: Option<String>,
pub completed_steps: Vec<String>,
}
impl Settlement {
pub fn plain(node: &str, status: NodeStatus, outcome: Option<&str>) -> Self {
Self {
node: node.to_string(),
status,
outcome: outcome.map(str::to_string),
landing: None,
detail: None,
branch: None,
change_url: None,
cause: None,
head: None,
completed_steps: Vec::new(),
}
}
}
pub(crate) enum Message {
Event(Box<Envelope>),
Redispatched(Box<Redispatch>),
Cancelling(Box<Cancelling>),
BodyNotDrafted(Box<UndraftedBody>),
Settled(Box<Settlement>),
}
pub(crate) struct UndraftedBody {
pub node: String,
pub ending: crate::lifecycle::Undrafted,
}
pub(crate) struct Cancelling {
pub node: String,
pub phase: CancelPhase,
pub detail: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CancelPhase {
Interrupted,
Killed,
}
impl CancelPhase {
fn kind(self) -> &'static str {
match self {
Self::Interrupted => "dispatch-interrupted",
Self::Killed => "dispatch-killed",
}
}
}
pub(crate) struct Redispatch {
pub node: String,
pub attempt: NonZeroU32,
pub attempts: NonZeroU32,
pub reason: String,
}
struct Dispatch {
node: Node,
cancel: CancellationToken,
started: Instant,
last_progress: Instant,
reported_quiet: bool,
control: Option<TurnAddress>,
}
impl Dispatch {
fn live(&self) -> edits::LiveDispatch {
edits::LiveDispatch {
graph_run: self.control.as_ref().map(|at| at.run().to_string()),
running_for_seconds: self.started.elapsed().as_secs(),
}
}
}
pub fn claim(paths: &RunPaths) -> Result<OwnershipLock> {
OwnershipLock::acquire(paths, "drive")
}
pub fn drive_holding(paths: &RunPaths, lock: OwnershipLock) -> Result<GraphState> {
let launch: LaunchRecord = ledger::read_json(&paths.launch())?;
if launch.node_graph.is_empty() {
return Err(Error::Invalid(format!(
"launch record for run '{}' has no resolved node graph",
paths.run
)));
}
let mut journal = Journal::open(paths);
let mut state = projection::fold(&journal::read(&paths.journal()));
report_unreadable_records(paths, &state);
let outcome = converge(paths, &mut journal, &mut state, &launch)?;
record_result(paths, &state, outcome)?;
lock.release();
Ok(outcome)
}
fn converge(
paths: &RunPaths,
journal: &mut Journal,
state: &mut RunState,
launch: &LaunchRecord,
) -> Result<GraphState> {
let writeback = crate::taskgraph::Store::resolve()
.ok()
.and_then(|store| crate::writeback::Writeback::start(store.binary(), paths, launch));
let channel = ChannelState::new(paths);
let rules = executor_rules()?;
let (tx, rx): (Sender<Message>, Receiver<Message>) = mpsc::channel();
let mut in_flight: BTreeMap<String, Dispatch> = BTreeMap::new();
let stall_after = Duration::from_secs(stall_after_seconds());
let mut upstreams = crate::crossdag::Observer::of_run(paths, state);
let mut releases = crate::release::Watch::of_run(paths);
let mut announced_ready: BTreeSet<String> = BTreeSet::new();
let mut held: BTreeMap<DecisionRef, Decision> = state
.decisions_pending
.iter()
.map(|(reference, pending)| {
(
DecisionRef::of_wire(reference),
Decision {
reference: DecisionRef::of_wire(reference),
kind: pending.kind.clone(),
unblocks: pending.unblocks.clone(),
},
)
})
.collect();
loop {
reconcile_edits(paths, journal, state, &channel, launch, &mut in_flight)?;
if let Some(writeback) = &writeback {
writeback.publish(paths, launch, state);
}
state.cross_dag = upstreams.resolve(&state.graph, paths, journal)?;
let statuses = state.statuses();
announce_ready(paths, journal, &statuses, &mut announced_ready)?;
let decisions = decisions_now(state, &statuses, &channel);
report_decisions(paths, journal, &decisions, &mut held)?;
let mut paused = paused_by(&decisions);
let watching = crate::release::watching(
state,
&statuses,
&in_flight.keys().cloned().collect::<BTreeSet<String>>(),
);
releases.refresh(paths, state, &watching);
let held_for_release = releases.held(&watching);
releases.report(paths, journal, &held_for_release, &watching)?;
releases.relay_releases(paths, journal, state, launch.filters.vcs.as_ref())?;
paused.extend(held_for_release);
adopt_releases(paths, journal, state, &mut releases, &in_flight)?;
start_ready(
paths,
journal,
state,
&rules,
launch,
&tx,
&mut in_flight,
&paused,
&releases,
)?;
if let Some(writeback) = &writeback {
writeback.publish(paths, launch, state);
}
if in_flight.is_empty() {
let statuses = state.statuses();
if graph::is_terminal(&statuses) {
break;
}
if !any_node_can_still_move(&statuses) {
break;
}
}
let mut batch: Vec<Message> = Vec::new();
match rx.recv_timeout(POLL) {
Ok(message) => batch.push(message),
Err(mpsc::RecvTimeoutError::Timeout) => {}
Err(mpsc::RecvTimeoutError::Disconnected) => break,
}
let drain_started = Instant::now();
while drain_started.elapsed() < POLL {
let Ok(message) = rx.try_recv() else {
break;
};
batch.push(message);
}
for message in batch {
match message {
Message::Event(envelope) => {
if let Some(node) = envelope.labels.node.clone() {
if let Some(dispatch) = in_flight.get_mut(&node) {
if projection::evidences_progress(&envelope) {
dispatch.last_progress = Instant::now();
dispatch.reported_quiet = false;
}
if let Some(address) = addressed_by(&envelope) {
dispatch.control = Some(address);
}
}
}
journal.relay(&envelope)?;
}
Message::Redispatched(again) => journal.emit(
journal::PipelineKind::NodeDispatched,
journal::labels(&paths.run, Some(&again.node)),
journal::payload(&[
("attempt", json!(again.attempt)),
("attempts", json!(again.attempts)),
("reason", json!(bounded(&again.reason))),
]),
)?,
Message::Cancelling(step) => raise(paths, journal, cancelling_surface(&step))?,
Message::BodyNotDrafted(undrafted) => journal.emit(
journal::PipelineKind::BodyNotDrafted,
journal::labels(&paths.run, Some(&undrafted.node)),
journal::payload(&[
("ending", json!(undrafted.ending.ending())),
("detail", json!(undrafted.ending.why())),
]),
)?,
Message::Settled(settlement) => {
in_flight.remove(&settlement.node);
settle(paths, journal, &settlement)?;
*state = projection::fold(&journal::read(&paths.journal()));
announced_ready
.retain(|id| state.statuses().get(id).copied() == Some(NodeStatus::Ready));
}
}
}
watch_for_quiet(paths, journal, stall_after, &mut in_flight)?;
}
if let Some(writeback) = &writeback {
writeback.publish(paths, launch, state);
writeback.wait_briefly();
}
Ok(graph::state_of(&state.statuses()))
}
fn report_unreadable_records(paths: &RunPaths, state: &RunState) {
if state.strict && !journal::has_unreadable_lines(&paths.journal()) {
return;
}
eprintln!(
"onepipeline: run '{}' has a journal record this build cannot read; the graph \
it is driving may be missing a committed edit.",
paths.run
);
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct Decision {
reference: DecisionRef,
kind: String,
unblocks: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
enum DecisionRef {
Attestation(String),
Surface(u64),
}
impl DecisionRef {
fn as_wire(&self) -> String {
match self {
Self::Attestation(node) => node.clone(),
Self::Surface(id) => format!("surface:{id}"),
}
}
fn of_wire(reference: &str) -> Self {
reference
.strip_prefix("surface:")
.and_then(|id| id.parse().ok())
.map_or_else(|| Self::Attestation(reference.to_string()), Self::Surface)
}
}
fn decisions_now(
state: &RunState,
statuses: &BTreeMap<String, NodeStatus>,
channel: &ChannelState,
) -> BTreeMap<DecisionRef, Decision> {
let mut decisions = BTreeMap::new();
for (id, status) in statuses {
if *status != NodeStatus::Waiting {
continue;
}
decisions.insert(
DecisionRef::Attestation(id.clone()),
Decision {
reference: DecisionRef::Attestation(id.clone()),
kind: "attestation".to_string(),
unblocks: descendants(&state.graph, std::slice::from_ref(id)),
},
);
}
let queue = channel.queue();
for surface in queue.waiting.iter().chain(queue.pending.iter()) {
if !surface.blocking {
continue;
}
let reference = DecisionRef::Surface(surface.id);
let unblocks = surface
.workstream
.clone()
.map(|node| descendants(&state.graph, std::slice::from_ref(&node)))
.unwrap_or_default();
decisions.insert(
reference.clone(),
Decision {
reference,
kind: surface.kind.clone(),
unblocks,
},
);
}
decisions
}
fn descendants(graph: &Graph, roots: &[String]) -> Vec<String> {
let mut seen: BTreeSet<String> = BTreeSet::new();
let mut pending: Vec<String> = roots.to_vec();
while let Some(current) = pending.pop() {
for dependent in graph.dependents_of(¤t) {
if seen.insert(dependent.clone()) {
pending.push(dependent);
}
}
}
graph
.ids()
.filter(|id| seen.contains(*id))
.cloned()
.collect()
}
fn report_decisions(
paths: &RunPaths,
journal: &mut Journal,
decisions: &BTreeMap<DecisionRef, Decision>,
held: &mut BTreeMap<DecisionRef, Decision>,
) -> Result<()> {
for (reference, decision) in decisions {
if held.get(reference) == Some(decision) {
continue;
}
journal.emit(
journal::PipelineKind::DecisionPending,
journal::labels(&paths.run, Some(&decision.reference.as_wire())),
journal::payload(&[
("reference", json!(decision.reference.as_wire())),
("kind", json!(decision.kind)),
("unblocks", json!(decision.unblocks)),
]),
)?;
}
let cleared: Vec<Decision> = held
.iter()
.filter(|(reference, _)| !decisions.contains_key(*reference))
.map(|(_, decision)| decision.clone())
.collect();
for decision in cleared {
journal.emit(
journal::PipelineKind::DecisionCleared,
journal::labels(&paths.run, Some(&decision.reference.as_wire())),
journal::payload(&[
("reference", json!(decision.reference.as_wire())),
("kind", json!(decision.kind)),
("released", json!(decision.unblocks)),
]),
)?;
}
*held = decisions.clone();
Ok(())
}
fn paused_by(decisions: &BTreeMap<DecisionRef, Decision>) -> BTreeSet<String> {
decisions
.values()
.flat_map(|decision| decision.unblocks.iter().cloned())
.collect()
}
fn announce_ready(
paths: &RunPaths,
journal: &mut Journal,
statuses: &BTreeMap<String, NodeStatus>,
announced: &mut BTreeSet<String>,
) -> Result<()> {
announced.retain(|id| statuses.get(id).copied() == Some(NodeStatus::Ready));
let fresh: Vec<String> = statuses
.iter()
.filter(|(_, status)| **status == NodeStatus::Ready)
.map(|(id, _)| id.clone())
.filter(|id| !announced.contains(id))
.collect();
for id in fresh {
journal.emit(
journal::PipelineKind::NodeReady,
journal::labels(&paths.run, Some(&id)),
journal::payload(&[]),
)?;
announced.insert(id);
}
Ok(())
}
fn addressed_by(envelope: &Envelope) -> Option<TurnAddress> {
if envelope.source != crate::event::Source::Agentgraph {
return None;
}
TurnAddress::of(
envelope.labels.run_id.as_deref()?,
envelope.labels.extra.get("member")?.as_str()?,
)
}
fn any_node_can_still_move(statuses: &BTreeMap<String, NodeStatus>) -> bool {
statuses
.values()
.any(|status| matches!(status, NodeStatus::Ready | NodeStatus::Running))
}
fn reconcile_edits(
paths: &RunPaths,
journal: &mut Journal,
state: &mut RunState,
channel: &ChannelState,
launch: &LaunchRecord,
in_flight: &mut BTreeMap<String, Dispatch>,
) -> Result<()> {
for envelope in channel.claim_commands()? {
let author = envelope.author;
let mut applied = true;
let mut reason = None;
for command in &envelope.commands {
let compiled = crate::channel::allows(author, command)
.and_then(|()| compile_and_deliver(journal, state, command, launch, in_flight));
match compiled {
Ok(operations) => {
for target in cancelled_by(command) {
if let Some(dispatch) = in_flight.get(&target) {
dispatch.cancel.cancel();
}
}
journal.emit(
journal::PipelineKind::EditCommitted,
journal::labels(&paths.run, None),
journal::payload(&[
("author", json!(author)),
("command", json!(command)),
("operations", json!(operations)),
]),
)?;
for operation in &operations {
match operation {
edits::Operation::CompletionRequested { reason } => journal.emit(
journal::PipelineKind::CompletionRequested,
journal::labels(&paths.run, None),
journal::payload(&[("reason", json!(reason))]),
)?,
edits::Operation::HumanAttested { node } => journal.emit(
journal::PipelineKind::HumanAttested,
journal::labels(&paths.run, Some(node)),
journal::payload(&[("ref", json!(node))]),
)?,
edits::Operation::FindingRaised {
node,
message,
blocking,
} => raise(
paths,
journal,
finding_surface(author, node.clone(), message, *blocking),
)?,
_ => {}
}
}
if author == crate::channel::Author::Monitor {
if let Some(surface) = monitor_edit(command) {
raise(paths, journal, surface)?;
}
}
*state = projection::fold(&journal::read(&paths.journal()));
}
Err(error) => {
applied = false;
reason = Some(error.to_string());
journal.emit(
journal::PipelineKind::EditRejected,
journal::labels(&paths.run, None),
journal::payload(&[
("author", json!(author)),
("command", json!(command)),
("reason", json!(error.to_string())),
]),
)?;
raise(
paths,
journal,
Surface {
id: 0,
kind: "edit-rejected".into(),
message: format!("reconciler: rejected — {error}"),
source: crate::channel::source::RECONCILER.into(),
blocking: false,
queued_at: sys::now_millis(),
workstream: None,
},
)?;
break;
}
}
}
channel.answer_commands(&CommandOutcome {
id: envelope.id,
applied,
reason,
})?;
}
Ok(())
}
fn compile_and_deliver(
journal: &mut Journal,
state: &RunState,
command: &Command,
launch: &LaunchRecord,
in_flight: &BTreeMap<String, Dispatch>,
) -> Result<Vec<edits::Operation>> {
let frontier = Frontier {
in_flight: in_flight
.iter()
.map(|(id, dispatch)| (id.clone(), dispatch.live()))
.collect(),
node_validator: launch.node_validator().map(str::to_owned),
..state.frontier()
};
let mut candidate = state.graph.clone();
let operations = edits::compile(&mut candidate, &frontier, command)?;
let Command::Context { id, note, deliver } = command else {
return Ok(operations);
};
let delivery = deliver_note(journal, *deliver, id, note, in_flight)?;
if delivery == edits::Delivery::Deferred {
return Ok(operations);
}
let mut candidate = state.graph.clone();
edits::compile_with(&mut candidate, &frontier, command, delivery)
}
fn deliver_note(
journal: &mut Journal,
deliver: Deliver,
id: &str,
note: &str,
in_flight: &BTreeMap<String, Dispatch>,
) -> Result<edits::Delivery> {
if deliver == Deliver::Next {
return Ok(edits::Delivery::Deferred);
}
let Some(address) = in_flight
.get(id)
.and_then(|dispatch| dispatch.control.clone())
else {
return not_live(
deliver,
id,
"it has no turn this run can address: nothing of its dispatch has \
reported a member yet, or it has no dispatch at all",
);
};
let interrupt = agentgraph::interrupt(&address, note);
for event in interrupt.events {
let mut event = event;
if event.labels.node.is_none() {
event.labels.node = Some(id.to_string());
}
journal.relay(&event)?;
}
match interrupt.outcome {
Interrupted::Delivered => Ok(edits::Delivery::Live),
Interrupted::NoTurn(reason) => not_live(deliver, id, &reason),
Interrupted::Failed(reason) => Err(Error::Refused(format!(
"context: delivering the note to node '{id}' failed: {reason}"
))),
}
}
fn not_live(deliver: Deliver, id: &str, reason: &str) -> Result<edits::Delivery> {
match deliver {
Deliver::Live => Err(Error::Refused(format!(
"context: node '{id}' has no controllable turn in flight, so the note \
cannot be delivered live: {reason}"
))),
Deliver::Auto | Deliver::Next => Ok(edits::Delivery::Deferred),
}
}
fn adopt_releases(
paths: &RunPaths,
journal: &mut Journal,
state: &mut RunState,
releases: &mut crate::release::Watch,
in_flight: &BTreeMap<String, Dispatch>,
) -> Result<()> {
let running: Vec<Node> = in_flight
.values()
.map(|dispatch| dispatch.node.clone())
.collect();
let ready = releases.ready_to_adopt(&running);
if ready.is_empty() {
return Ok(());
}
for (node, released) in ready {
let note = crate::release::arrival_note(&released);
let delivery = match deliver_note(journal, Deliver::Auto, &node, ¬e, in_flight) {
Ok(delivery) => delivery,
Err(error) => {
eprintln!(
"onepipeline: the release note for node '{node}' was not delivered: {error}"
);
continue;
}
};
releases.adopted(&node);
journal.emit(
journal::PipelineKind::ReleaseAdopted,
journal::labels(&paths.run, Some(&node)),
journal::payload(&[
("node", json!(node)),
(
"delivery",
json!(match delivery {
edits::Delivery::Live => "live",
edits::Delivery::Deferred => "next",
}),
),
(
"versions",
json!(released
.iter()
.map(crate::release::Released::payload)
.collect::<Vec<_>>()),
),
]),
)?;
}
*state = projection::fold(&journal::read(&paths.journal()));
Ok(())
}
fn cancelled_by(command: &Command) -> Vec<String> {
match command {
Command::Drop { id, .. } | Command::Retry { id, .. } | Command::Cancel { id } => {
vec![id.clone()]
}
_ => Vec::new(),
}
}
#[allow(
clippy::too_many_arguments,
reason = "the reconcile loop's borrowed state, which cannot be bundled without \
taking one mutable borrow where three independent ones are needed"
)]
fn start_ready(
paths: &RunPaths,
journal: &mut Journal,
state: &mut RunState,
rules: &ExecutorRules,
launch: &LaunchRecord,
tx: &Sender<Message>,
in_flight: &mut BTreeMap<String, Dispatch>,
paused: &BTreeSet<String>,
releases: &crate::release::Watch,
) -> Result<()> {
let statuses = state.statuses();
let concurrency = state.graph.concurrency as usize;
let actionable: Vec<Node> = state
.graph
.iter()
.filter(|node| match statuses.get(&node.id) {
Some(NodeStatus::Ready) => true,
Some(NodeStatus::Waiting) => !state.recorded.contains_key(&node.id),
_ => false,
})
.filter(|node| !in_flight.contains_key(&node.id))
.filter(|node| !paused.contains(&node.id))
.cloned()
.collect();
let mut settled_here = false;
for node in actionable {
if node.kind != NodeKind::Human && in_flight.len() >= concurrency {
break;
}
if node.expects_no_diff {
settle(
paths,
journal,
&Settlement::plain(&node.id, NodeStatus::Done, Some(NO_CHANGES)),
)?;
settled_here = true;
continue;
}
if node.kind == NodeKind::Human {
settle(
paths,
journal,
&Settlement::plain(&node.id, NodeStatus::Waiting, None),
)?;
settled_here = true;
continue;
}
let cancel = CancellationToken::new();
journal.emit(
journal::PipelineKind::NodeDispatched,
journal::labels(&paths.run, Some(&node.id)),
journal::payload(&[("persona", json!(node.persona)), ("attempt", json!(1))]),
)?;
let references = releases.references(&node);
spawn(
paths,
rules,
launch,
&node,
&references,
cancel.clone(),
tx.clone(),
)?;
let now = Instant::now();
in_flight.insert(
node.id.clone(),
Dispatch {
node,
cancel,
started: now,
last_progress: now,
reported_quiet: false,
control: None,
},
);
settled_here = true;
}
if settled_here {
*state = projection::fold(&journal::read(&paths.journal()));
}
Ok(())
}
#[allow(
clippy::too_many_arguments,
reason = "one dispatch's whole context: the run, the rules, the launch, the node, \
its cross-repository references, its cancellation, and where to report"
)]
fn spawn(
paths: &RunPaths,
rules: &ExecutorRules,
launch: &LaunchRecord,
node: &Node,
references: &[crate::plan::CrossRepoReference],
cancel: CancellationToken,
tx: Sender<Message>,
) -> Result<()> {
let labels = dispatch_labels(&paths.run, &node.id, None, node.persona.as_deref());
let executor_name = rules.select(node.executor.as_deref(), &labels, &|name| {
rules
.executors
.iter()
.find(|entry| entry.name == name)
.map(|entry| crate::rules::executor_for(entry).capacity())
.unwrap_or_default()
})?;
let entry = rules
.executors
.iter()
.find(|entry| entry.name == executor_name)
.ok_or_else(|| Error::Invalid(format!("executor '{executor_name}' is not declared")))?
.clone();
let run = paths.run.clone();
let node = node.clone();
let references = references.to_vec();
let paths = paths.clone();
let launched = crate::lifecycle::Launch {
node_graph: launch.node_graph.clone(),
pr_author_graph: launch.pr_author_graph().map(str::to_owned),
vcs_filter: launch.filters.vcs.clone(),
};
std::thread::Builder::new()
.name(format!("dispatch-{}", node.id))
.spawn(move || {
let executor = crate::rules::executor_for(&entry);
let settlement = if node.repo.is_some() {
crate::lifecycle::execute(
executor.as_ref(),
&paths,
&launched,
&node,
&references,
&cancel,
&tx,
)
} else {
execute_direct(
executor.as_ref(),
&run,
&launched.node_graph,
&node,
&references,
&cancel,
&tx,
)
};
let _ = tx.send(Message::Settled(Box::new(settlement)));
})
.map_err(|e| Error::Invalid(format!("cannot start a dispatch thread: {e}")))?;
Ok(())
}
fn execute_direct(
executor: &dyn Executor,
run: &str,
default_graph: &str,
node: &Node,
references: &[crate::plan::CrossRepoReference],
cancel: &CancellationToken,
tx: &Sender<Message>,
) -> Settlement {
let graph = node_graph(node.agent_graph.as_ref(), default_graph);
let controls = match crate::controls::NodeControls::of_node(node) {
Ok(controls) => controls,
Err(why) => {
return Settlement {
detail: Some(why),
..Settlement::plain(&node.id, NodeStatus::Failed, Some(INVALID_NODE))
}
}
}; let request = || DispatchRequest {
graph: graph.clone(),
task: node.rendered_task_with(references),
labels: dispatch_labels(run, &node.id, None, node.persona.as_deref()),
controls,
workspace: WorkspaceSpec::Path(project_dir()),
cancel: cancel.clone(),
};
attempt(executor, &node.id, cancel, tx, &request).settlement
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Reached {
NotStarted,
Silence,
Speech,
}
pub(crate) struct Drained {
pub settlement: Settlement,
pub reached: Reached,
pub session: Option<onevcs::SessionToken>,
pub branch: Option<String>,
}
pub(crate) fn attempt(
executor: &dyn Executor,
node: &str,
cancel: &CancellationToken,
tx: &Sender<Message>,
request: &dyn Fn() -> DispatchRequest,
) -> Drained {
let attempts = boundary_attempts();
let mut backoff = Duration::from_secs(boundary_backoff_seconds());
let mut last = Drained {
settlement: failed(node, INFRASTRUCTURE_FAILURE),
reached: Reached::NotStarted,
session: None,
branch: None,
};
for attempt in 1..=attempts.get() {
let drained = match executor.dispatch(request()) {
Ok(mut handle) => drain(handle.as_mut(), tx, node, cancel),
Err(error) => Drained {
settlement: Settlement {
detail: Some(error.to_string()),
..failed(node, INFRASTRUCTURE_FAILURE)
},
reached: Reached::NotStarted,
session: None,
branch: None,
},
};
if drained.settlement.status != NodeStatus::Failed
|| drained.reached == Reached::Speech
|| cancel.is_cancelled()
{
return drained;
}
last = drained;
if attempt == attempts.get() {
if last.reached != Reached::NotStarted {
last.settlement = Settlement {
detail: last.settlement.detail.clone(),
..failed(node, NO_AGENT_PROGRESS)
};
}
break;
}
std::thread::sleep(backoff);
backoff = (backoff * 2).min(BOUNDARY_BACKOFF_CEILING);
let _ = tx.send(Message::Redispatched(Box::new(Redispatch {
node: node.to_string(),
attempt: NonZeroU32::MIN.saturating_add(attempt),
attempts,
reason: last.settlement.detail.clone().unwrap_or_default(),
})));
}
last
}
pub(crate) fn drain(
handle: &mut dyn DispatchHandle,
tx: &Sender<Message>,
node: &str,
cancel: &CancellationToken,
) -> Drained {
let grace = Duration::from_secs(cancel_grace_seconds());
let (relayed, arriving) = mpsc::channel();
let events = handle.events();
let _ = std::thread::Builder::new()
.name(format!("relay-{node}"))
.spawn(move || {
for envelope in events {
if relayed.send(envelope).is_err() {
return;
}
}
});
let mut spoke = false;
let mut addresses: Vec<TurnAddress> = Vec::new();
let mut asked_at: Option<Instant> = None;
let mut killed = false;
loop {
match arriving.recv_timeout(POLL) {
Ok(Ok(envelope)) => {
spoke = true;
if let Some(address) = addressed_by(&envelope) {
if !addresses.contains(&address) {
addresses.push(address);
}
}
let _ = tx.send(Message::Event(Box::new(envelope)));
}
Ok(Err(_)) => {}
Err(mpsc::RecvTimeoutError::Timeout) => {}
Err(mpsc::RecvTimeoutError::Disconnected) => break,
}
match asked_at {
None if cancel.is_cancelled() => {
handle.cancel(CancelMode::Cooperative);
let said = interrupt_turns(tx, node, &addresses, grace);
report(tx, node, CancelPhase::Interrupted, said);
asked_at = Some(Instant::now());
}
Some(asked) if !killed && asked.elapsed() >= grace => {
killed = true;
handle.cancel(CancelMode::Kill);
report(
tx,
node,
CancelPhase::Killed,
format!(
"the dispatch had not exited {}s after it was asked to stop, so it \
was killed and its process tree reaped; anything its turn had not \
committed is gone",
grace.as_secs()
),
);
}
_ => {}
}
}
let waited = handle.wait();
let (session, branch) = match &waited {
Ok(outcome) => (
outcome.session.clone().map(onevcs::SessionToken),
outcome.branch.clone(),
),
Err(_) => (None, None),
};
let settlement = match waited {
Ok(outcome) if outcome.succeeded && !cancel.is_cancelled() => {
Settlement::plain(node, NodeStatus::Done, None)
}
Ok(_) if cancel.is_cancelled() => Settlement {
detail: asked_at.map(|_| stopped_how(killed, grace)),
..Settlement::plain(node, NodeStatus::Cancelled, None)
},
Ok(outcome) => failed_task(node, &outcome, session.as_ref()),
Err(error) => Settlement {
detail: Some(error.to_string()),
..failed(node, INFRASTRUCTURE_FAILURE)
},
};
Drained {
settlement,
reached: if spoke {
Reached::Speech
} else {
Reached::Silence
},
session,
branch,
}
}
fn interrupt_turns(
tx: &Sender<Message>,
node: &str,
addresses: &[TurnAddress],
grace: Duration,
) -> String {
if addresses.is_empty() {
return format!(
"nothing of this dispatch has named a turn to interrupt, so there was nothing to \
ask; it is killed in {}s if it has not exited by then",
grace.as_secs()
);
}
let mut answers = Vec::new();
for address in addresses {
let interrupt = agentgraph::interrupt(address, CANCEL_INPUT);
for mut event in interrupt.events {
if event.labels.node.is_none() {
event.labels.node = Some(node.to_string());
}
let _ = tx.send(Message::Event(Box::new(event)));
}
answers.push(format!(
"{}: {}",
address.member(),
answered(&interrupt.outcome)
));
}
format!(
"asked {} turn(s) to stop, commit, and end without starting new work — {}; the \
dispatch is killed in {}s if it has not exited by then",
addresses.len(),
answers.join("; "),
grace.as_secs()
)
}
fn answered(outcome: &Interrupted) -> String {
match outcome {
Interrupted::Delivered => "the running turn took the redirection".to_string(),
Interrupted::NoTurn(reason) => format!("no turn to redirect ({reason})"),
Interrupted::Failed(reason) => format!("the lever failed ({reason})"),
}
}
fn stopped_how(killed: bool, grace: Duration) -> String {
if killed {
format!(
"the dispatch was asked to stop and had not exited {}s later, so it was killed",
grace.as_secs()
)
} else {
"the dispatch stopped after its turn was asked to commit and end".to_string()
}
}
fn report(tx: &Sender<Message>, node: &str, phase: CancelPhase, detail: String) {
let _ = tx.send(Message::Cancelling(Box::new(Cancelling {
node: node.to_string(),
phase,
detail,
})));
}
fn cancelling_surface(step: &Cancelling) -> Surface {
Surface {
id: 0,
kind: step.phase.kind().into(),
message: format!("{}: {}", step.phase.kind(), bounded(&step.detail)),
source: crate::channel::source::RECONCILER.into(),
blocking: false,
queued_at: sys::now_millis(),
workstream: Some(step.node.clone()),
}
}
pub const DISPATCH_DIED: &str = "dispatch-died";
pub const INVALID_NODE: &str = "invalid-node";
pub const NO_CHANGES: &str = "no-changes";
pub const INFRASTRUCTURE_FAILURE: &str = "infrastructure-failure";
pub const NO_AGENT_PROGRESS: &str = "no-agent-progress";
pub const TASK_FAILED: &str = "task-failed";
pub const TASK_FAILED_CHANGE_OPEN: &str = "task-failed-change-open";
const MACHINERY: [&str; 3] = ["harness", "provider", "spawn"];
const CLASSIFIED_IN: [(char, char); 2] = [('(', ')'), ('[', ']')];
fn dispatch_death_cause(detail: &str) -> Option<String> {
let lowered = detail.to_ascii_lowercase();
if !MACHINERY.iter().any(|word| lowered.contains(word)) {
return None;
}
CLASSIFIED_IN
.into_iter()
.filter_map(|(open, close)| delimited(detail, open, close))
.max_by_key(|(at, _)| *at)
.map(|(_, word)| word)
}
const CLASSIFICATION_LIMIT: usize = 64;
fn delimited(detail: &str, open: char, close: char) -> Option<(usize, String)> {
let mut found = None;
let mut at = 0;
let mut rest = detail;
while let Some(start) = rest.find(open) {
let after = &rest[start + open.len_utf8()..];
let Some(end) = after.find(close) else { break };
let inside = &after[..end];
if is_a_classification(inside) {
found = Some((at + start, inside.to_owned()));
}
at += start + open.len_utf8() + end + close.len_utf8();
rest = &after[end + close.len_utf8()..];
}
found
}
pub(crate) fn is_a_classification(word: &str) -> bool {
!word.is_empty()
&& word.len() <= CLASSIFICATION_LIMIT
&& !word.chars().any(|c| c.is_whitespace() || c.is_control())
}
fn failed_task(
node: &str,
outcome: &DispatchOutcome,
session: Option<&onevcs::SessionToken>,
) -> Settlement {
let detail = (!outcome.detail.is_empty()).then(|| outcome.detail.clone());
if let Some(url) = session.and_then(crate::vcs::change_opened_in) {
return Settlement {
detail,
change_url: Some(url),
..failed(node, TASK_FAILED_CHANGE_OPEN)
};
}
let Some(cause) = dispatch_death_cause(&outcome.detail) else {
return Settlement {
detail,
..failed(node, TASK_FAILED)
};
};
Settlement {
detail,
cause: Some(cause),
head: session.and_then(crate::vcs::branch_head_in),
..failed(node, DISPATCH_DIED)
}
}
fn cancel_grace_seconds() -> u64 {
std::env::var(CANCEL_GRACE_ENV)
.ok()
.and_then(|value| value.parse().ok())
.filter(|seconds| *seconds > 0)
.unwrap_or(DEFAULT_CANCEL_GRACE_SECONDS)
}
fn boundary_attempts() -> NonZeroU32 {
std::env::var(BOUNDARY_ATTEMPTS_ENV)
.ok()
.and_then(|value| value.parse().ok())
.or_else(|| NonZeroU32::new(DEFAULT_BOUNDARY_ATTEMPTS))
.unwrap_or(NonZeroU32::MIN)
}
pub(crate) fn publication_attempts() -> NonZeroU32 {
std::env::var(PUBLICATION_ATTEMPTS_ENV)
.ok()
.and_then(|value| value.parse().ok())
.unwrap_or(DEFAULT_PUBLICATION_ATTEMPTS)
}
pub(crate) fn merge_path_reads() -> NonZeroU32 {
std::env::var(MERGE_PATH_READS_ENV)
.ok()
.and_then(|value| value.parse().ok())
.unwrap_or(DEFAULT_MERGE_PATH_READS)
}
pub(crate) fn merge_path_backoff() -> Duration {
Duration::from_secs(
std::env::var(MERGE_PATH_BACKOFF_ENV)
.ok()
.and_then(|value| value.parse::<NonZeroU64>().ok())
.map_or(DEFAULT_MERGE_PATH_BACKOFF_SECONDS, NonZeroU64::get),
)
.min(BOUNDARY_BACKOFF_CEILING)
}
pub(crate) fn doubled(backoff: Duration) -> Duration {
(backoff * 2).min(BOUNDARY_BACKOFF_CEILING)
}
fn boundary_backoff_seconds() -> u64 {
std::env::var(BOUNDARY_BACKOFF_ENV)
.ok()
.and_then(|value| value.parse().ok())
.unwrap_or(DEFAULT_BOUNDARY_BACKOFF_SECONDS)
}
fn failed(node: &str, outcome: &str) -> Settlement {
Settlement::plain(node, NodeStatus::Failed, Some(outcome))
}
pub(crate) fn bounded(reason: &str) -> String {
reason
.chars()
.take(crate::event::MAX_PAYLOAD_TEXT_BYTES / 4)
.collect()
}
pub(crate) fn dispatch_labels(
run: &str,
node: &str,
step: Option<&str>,
persona: Option<&str>,
) -> Labels {
Labels {
run_id: Some(run.to_string()),
round: None,
node: Some(node.to_string()),
step: step.map(str::to_string),
persona: persona.map(str::to_string),
extra: serde_json::Map::new(),
}
}
pub(crate) fn node_graph(
override_ref: Option<&oneagentgraph::config::ConfigRef>,
default_graph: &str,
) -> oneagentgraph::config::ConfigRef {
override_ref
.cloned()
.unwrap_or_else(|| oneagentgraph::config::ConfigRef(default_graph.to_string()))
}
pub(crate) fn configured_node_graph() -> String {
std::env::var(NODE_GRAPH_ENV)
.ok()
.filter(|value| !value.is_empty())
.unwrap_or_else(|| DEFAULT_NODE_GRAPH.to_string())
}
pub(crate) fn configured_node_validator() -> Result<Option<String>> {
match std::env::var(NODE_VALIDATOR_ENV) {
Ok(value) => Ok(Some(value)),
Err(std::env::VarError::NotPresent) => Ok(None),
Err(std::env::VarError::NotUnicode(_)) => Err(Error::Invalid(format!(
"{NODE_VALIDATOR_ENV} holds something this build cannot read as text, so the \
command it names cannot be resolved — set it to the command, or unset it to \
declare that this launch has none"
))),
}
}
fn project_dir() -> std::path::PathBuf {
std::env::var_os(PROJECT_DIR_ENV)
.map(std::path::PathBuf::from)
.filter(|path| !path.as_os_str().is_empty())
.unwrap_or_else(|| std::path::PathBuf::from("."))
}
fn stall_after_seconds() -> u64 {
std::env::var(STALL_AFTER_ENV)
.ok()
.and_then(|value| value.parse().ok())
.filter(|seconds| *seconds > 0)
.unwrap_or(DEFAULT_STALL_AFTER_SECONDS)
}
fn executor_rules() -> Result<ExecutorRules> {
match std::env::var_os(EXECUTOR_RULES_ENV) {
Some(path) if !path.is_empty() => ExecutorRules::load(std::path::Path::new(&path)),
_ => Ok(ExecutorRules::shipped_default()),
}
}
fn settle(paths: &RunPaths, journal: &mut Journal, settlement: &Settlement) -> Result<()> {
let mut payload = journal::settled_payload(
settlement.status.as_str(),
settlement.outcome.as_deref(),
settlement.detail.as_deref(),
);
if let Some(branch) = &settlement.branch {
payload.insert("branch".into(), json!(branch));
}
if let Some(url) = &settlement.change_url {
payload.insert("change_url".into(), json!(url));
}
if let Some(cause) = &settlement.cause {
payload.insert(journal::SETTLED_CAUSE.into(), json!(cause));
}
if let Some(head) = &settlement.head {
payload.insert(journal::SETTLED_HEAD.into(), json!(head));
}
if let Some(landing) = settlement.landing {
payload.insert(journal::SETTLED_LANDING.into(), json!(landing.as_str()));
}
if !settlement.completed_steps.is_empty() {
payload.insert("completed_steps".into(), json!(settlement.completed_steps));
}
journal.emit(
journal::PipelineKind::NodeSettled,
journal::labels(&paths.run, Some(&settlement.node)),
payload,
)
}
pub(crate) fn monitor_edit(command: &Command) -> Option<Surface> {
if matches!(command, Command::Finding { .. }) {
return None;
}
Some(Surface {
id: 0,
kind: "monitor-edit".into(),
message: format!(
"monitor applied an edit: {}",
bounded(&serde_json::to_string(command).unwrap_or_default())
),
source: crate::channel::source::MONITOR.into(),
blocking: false,
queued_at: sys::now_millis(),
workstream: crate::channel::target_of(command),
})
}
pub(crate) fn finding_surface(
author: crate::channel::Author,
node: Option<String>,
message: &str,
blocking: bool,
) -> Surface {
Surface {
id: 0,
kind: crate::channel::SurfaceKind::Finding.as_str().into(),
message: message.to_string(),
source: match author {
crate::channel::Author::Monitor => crate::channel::source::MONITOR,
crate::channel::Author::Planner => crate::channel::source::PROPOSAL,
}
.into(),
blocking,
queued_at: sys::now_millis(),
workstream: node,
}
}
pub(crate) fn raise(paths: &RunPaths, journal: &mut Journal, surface: Surface) -> Result<()> {
let queued = ChannelState::new(paths).push(surface)?;
journal.emit(
journal::PipelineKind::PlannerSurfaceQueued,
journal::labels(&paths.run, queued.workstream.as_deref()),
journal::payload(&[
("kind", json!(queued.kind)),
("message", json!(queued.message)),
("source", json!(queued.source)),
("blocking", json!(queued.blocking)),
]),
)
}
fn watch_for_quiet(
paths: &RunPaths,
journal: &mut Journal,
stall_after: Duration,
in_flight: &mut BTreeMap<String, Dispatch>,
) -> Result<()> {
let quiet: Vec<(String, u64, bool, String)> = in_flight
.iter()
.filter(|(_, dispatch)| !dispatch.reported_quiet)
.filter(|(_, dispatch)| dispatch.last_progress.elapsed() > stall_after)
.map(|(id, dispatch)| {
(
id.clone(),
dispatch.last_progress.elapsed().as_secs(),
dispatch.last_progress == dispatch.started,
dispatch.node.persona.clone().unwrap_or_else(|| "-".into()),
)
})
.collect();
for (node, quiet_for, never_spoke, persona) in quiet {
if let Some(dispatch) = in_flight.get_mut(&node) {
dispatch.reported_quiet = true;
}
let last = if never_spoke {
"nothing recorded since it was dispatched".to_string()
} else {
format!("last activity {quiet_for}s ago")
};
journal.emit(
journal::PipelineKind::QuietWorker,
journal::labels(&paths.run, Some(&node)),
journal::payload(&[
("quiet_for_seconds", json!(quiet_for)),
("threshold_seconds", json!(stall_after.as_secs())),
("persona", json!(persona)),
]),
)?;
raise(
paths,
journal,
Surface {
id: 0,
kind: "quiet-worker".into(),
message: format!(
"quiet-worker: no activity for {quiet_for}s (threshold {}s); {last}. \
The dispatch has not failed — decide whether to cancel it, retry it, \
or let it run.",
stall_after.as_secs()
),
source: crate::channel::source::PROPOSAL.into(),
blocking: false,
queued_at: sys::now_millis(),
workstream: Some(node.clone()),
},
)?;
}
Ok(())
}
fn record_result(paths: &RunPaths, state: &RunState, settled: GraphState) -> Result<RunResult> {
let statuses = state.statuses();
let nodes = state
.graph
.iter()
.map(|node| {
let status = statuses
.get(&node.id)
.copied()
.unwrap_or(NodeStatus::Pending);
NodeResult {
id: node.id.clone(),
status,
outcome: state.outcomes.get(&node.id).cloned(),
landing: state.landings.get(&node.id).copied(),
action: (status == NodeStatus::Waiting)
.then(|| node.task.clone())
.flatten(),
unblocks: if status == NodeStatus::Waiting {
graph::unblocks(&state.graph, &node.id)
} else {
Vec::new()
},
blocked_by: if status == NodeStatus::Blocked {
gating_humans(state, &statuses, &node.id)
} else {
Vec::new()
},
branch: state
.branches
.get(&node.id)
.cloned()
.or_else(|| node.branch.clone()),
change_url: state.change_urls.get(&node.id).cloned(),
cause: state.causes.get(&node.id).cloned(),
head: state.heads.get(&node.id).cloned(),
}
})
.collect();
let result = RunResult {
run_id: paths.run.clone(),
state: settled,
nodes,
};
ledger::write_json(&paths.result(), &result)?;
Ok(result)
}
fn gating_humans(
state: &RunState,
statuses: &BTreeMap<String, NodeStatus>,
id: &str,
) -> Vec<String> {
let mut gates = BTreeSet::new();
let mut seen = BTreeSet::new();
let mut pending = vec![id.to_string()];
while let Some(current) = pending.pop() {
if !seen.insert(current.clone()) {
continue;
}
let Some(node) = state.graph.get(¤t) else {
continue;
};
for dep in &node.deps {
match statuses.get(dep) {
Some(NodeStatus::Waiting) => {
gates.insert(dep.clone());
}
Some(NodeStatus::Blocked) => pending.push(dep.clone()),
_ => {}
}
}
}
gates.into_iter().collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::plan::{Plan, PLAN_SCHEMA_VERSION};
use crate::projection::Recorded;
#[test]
fn the_publication_budget_is_the_one_the_contract_and_the_readme_publish() {
let contract = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("docs/contract.md"),
)
.expect("the contract ships");
assert!(
contract.contains(&format!("`{PUBLICATION_ATTEMPTS_ENV}`")),
"docs/contract.md does not name the {PUBLICATION_ATTEMPTS_ENV} bound"
);
assert_eq!(DEFAULT_PUBLICATION_ATTEMPTS.get(), 3);
assert!(
contract.contains("and three by default"),
"docs/contract.md does not state the default this build ships"
);
assert_eq!(publication_attempts(), DEFAULT_PUBLICATION_ATTEMPTS);
let readme = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("README.md"),
)
.expect("the README ships");
let prose = readme.split_whitespace().collect::<Vec<_>>().join(" ");
assert!(
prose.contains(&format!("`{PUBLICATION_ATTEMPTS_ENV}`, three by default")),
"the README does not state the {PUBLICATION_ATTEMPTS_ENV} bound and its default"
);
assert!(
prose.contains(&format!(
"settles `{}` under the last failure's word",
NodeStatus::Failed.as_str()
)),
"the README does not say what spending the budget settles the node as"
);
}
#[test]
fn the_wait_between_merge_path_reads_falls_back_and_is_held_to_the_ceiling() {
assert_eq!(
merge_path_backoff(),
Duration::from_secs(DEFAULT_MERGE_PATH_BACKOFF_SECONDS)
);
for unusable in ["", "not a number", "-1", "5.5", "0"] {
std::env::set_var(MERGE_PATH_BACKOFF_ENV, unusable);
assert_eq!(
merge_path_backoff(),
Duration::from_secs(DEFAULT_MERGE_PATH_BACKOFF_SECONDS),
"{unusable:?} was read as a wait rather than falling back"
);
}
std::env::set_var(MERGE_PATH_BACKOFF_ENV, "1000000");
assert_eq!(
merge_path_backoff(),
BOUNDARY_BACKOFF_CEILING,
"a value nobody meant holds a node open for as long as it says"
);
std::env::set_var(MERGE_PATH_BACKOFF_ENV, "1");
assert_eq!(merge_path_backoff(), Duration::from_secs(1));
std::env::remove_var(MERGE_PATH_BACKOFF_ENV);
}
#[test]
fn the_wait_between_merge_path_reads_grows_to_the_ceiling_and_stops_there() {
assert_eq!(doubled(Duration::from_secs(5)), Duration::from_secs(10));
assert_eq!(
doubled(BOUNDARY_BACKOFF_CEILING / 2),
BOUNDARY_BACKOFF_CEILING
);
assert_eq!(
doubled(BOUNDARY_BACKOFF_CEILING),
BOUNDARY_BACKOFF_CEILING,
"the wait grew past the ceiling every backoff in this crate shares"
);
}
#[test]
fn the_merge_path_read_budget_is_the_one_the_contract_and_the_readme_publish() {
let contract = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("docs/contract.md"),
)
.expect("the contract ships");
assert!(
contract.contains(&format!("`{MERGE_PATH_READS_ENV}`")),
"docs/contract.md does not name the {MERGE_PATH_READS_ENV} bound"
);
assert_eq!(DEFAULT_MERGE_PATH_READS.get(), 3);
assert_eq!(merge_path_reads(), DEFAULT_MERGE_PATH_READS);
assert!(
contract.contains(&format!(
"`{MERGE_PATH_READS_ENV}`, three by default and the whole budget"
)),
"docs/contract.md does not state the default this build ships"
);
let readme = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("README.md"),
)
.expect("the README ships");
let prose = readme.split_whitespace().collect::<Vec<_>>().join(" ");
assert!(
prose.contains(&format!("`{MERGE_PATH_READS_ENV}`, three by default")),
"the README does not state the {MERGE_PATH_READS_ENV} bound and its default"
);
assert!(
prose.contains("reads that never get one settle it `failed`"),
"the README does not say what spending the read budget settles the node as"
);
}
#[test]
fn the_words_this_crate_publishes_are_one_vocabulary() {
use crate::lifecycle::Undrafted;
use crate::vcs::outcome_of;
use onevcs::PublishOutcome;
let publications: std::collections::BTreeSet<&str> = [
PublishOutcome::Merged(onevcs::Sha("abc".into())),
PublishOutcome::ChangeOpen(url()),
PublishOutcome::Queued(url()),
PublishOutcome::NothingToPublish,
]
.iter()
.map(outcome_of)
.chain(EVERY_PUBLICATION_FAILURE.iter().map(|kind| {
outcome_of(&PublishOutcome::Failed {
kind: *kind,
reason: String::new(),
retained: None,
})
}))
.collect();
let draftings: std::collections::BTreeSet<&str> = [
Undrafted::Dispatch(String::new()),
Undrafted::SchemaRefused,
Undrafted::Bodyless,
]
.iter()
.map(Undrafted::ending)
.collect();
let mut seen: std::collections::BTreeMap<&str, usize> = std::collections::BTreeMap::new();
let settlements: std::collections::BTreeSet<&str> =
SETTLEMENT_OUTCOMES.iter().copied().collect();
assert_eq!(
settlements.len(),
SETTLEMENT_OUTCOMES.len(),
"one settlement outcome is spelled twice in the list itself"
);
for word in settlements
.iter()
.copied()
.chain(publications.iter().copied())
.chain(draftings.iter().copied())
{
*seen.entry(word).or_default() += 1;
}
assert_eq!(
seen.remove(NO_CHANGES),
Some(2),
"`{NO_CHANGES}` is documented as the one word two vocabularies share"
);
let collided: Vec<&str> = seen
.iter()
.filter(|(_, times)| **times > 1)
.map(|(word, _)| *word)
.collect();
assert!(
collided.is_empty(),
"these words mean two things to a reader who cannot tell which: {collided:?}"
);
assert!(SETTLEMENT_OUTCOMES.contains(&DISPATCH_DIED));
assert!(draftings.contains(&"dispatch-failed"));
}
const SETTLEMENT_OUTCOMES: [&str; 7] = [
INVALID_NODE,
NO_CHANGES,
INFRASTRUCTURE_FAILURE,
NO_AGENT_PROGRESS,
TASK_FAILED,
TASK_FAILED_CHANGE_OPEN,
DISPATCH_DIED,
];
const EVERY_PUBLICATION_FAILURE: &[onevcs::FailureKind] = &[
onevcs::FailureKind::Gate,
onevcs::FailureKind::Invalid,
onevcs::FailureKind::SyncConflict,
onevcs::FailureKind::NotImplemented,
onevcs::FailureKind::ChecksFailed,
onevcs::FailureKind::ChecksUnsettled,
onevcs::FailureKind::PushRejected,
onevcs::FailureKind::PushedUnverified,
];
fn url() -> onevcs::Url {
"https://example.invalid/pull/7".parse().expect("a URL")
}
#[test]
fn a_dispatch_death_is_classified_out_of_the_detail_and_a_task_failure_is_not() {
for (detail, cause) in [
(
"oneagentgraph: member 'worker' failed: provider error (respond): harness failed (rate_limit)",
"rate_limit",
),
("harness failed (auth)", "auth"),
("provider error (quota)", "quota"),
(
"no candidate ran the turn: claude-code [auth], codex [spawn-error]",
"spawn-error",
),
(
"provider error (respond): no candidate ran the turn: codex [quota]",
"quota",
),
(
"the harness chain [claude-code, codex] ended: harness failed (overloaded)",
"overloaded",
),
] {
assert_eq!(
dispatch_death_cause(detail).as_deref(),
Some(cause),
"{detail:?} was not classified as the machinery stopping"
);
}
for verdict in [
"the node failed its gate",
"the judge refused the report form",
"the gate failed (clippy)",
"",
] {
assert_eq!(
dispatch_death_cause(verdict),
None,
"{verdict:?} was read as a dispatch that died rather than a task that failed"
);
}
}
const RUN_RESULT_GOLDEN: &str = include_str!("../tests/golden/run-result-v4.json");
use serde_json::Value;
fn settled(id: &str) -> NodeResult {
NodeResult {
id: id.into(),
status: NodeStatus::Done,
outcome: None,
landing: None,
action: None,
unblocks: Vec::new(),
blocked_by: Vec::new(),
branch: None,
change_url: None,
cause: None,
head: None,
}
}
fn run_result_golden() -> RunResult {
RunResult {
run_id: "golden".into(),
state: GraphState::Complete,
nodes: vec![
NodeResult {
id: "merged".into(),
status: NodeStatus::Done,
outcome: Some("merged".into()),
landing: Some(Landing::Landed),
branch: Some("onepipeline/merged".into()),
..settled("merged")
},
NodeResult {
id: "opened".into(),
status: NodeStatus::Done,
outcome: Some("change-open".into()),
landing: Some(Landing::Unlanded),
branch: Some("onepipeline/opened".into()),
change_url: Some("https://example.invalid/pull/7".into()),
..settled("opened")
},
NodeResult {
id: "died".into(),
status: NodeStatus::Failed,
outcome: Some(DISPATCH_DIED.into()),
branch: Some("onepipeline/died".into()),
cause: Some("rate_limit".into()),
head: Some("0123456789abcdef0123456789abcdef01234567".into()),
..settled("died")
},
settled("built"),
],
}
}
#[test]
fn a_schema_4_run_result_is_the_shape_the_golden_pins() {
let rendered = serde_json::to_string_pretty(&run_result_golden()).expect("it serialises");
assert_eq!(
rendered.trim(),
RUN_RESULT_GOLDEN.trim(),
"the run result changed shape. If that was deliberate, bump \
RUN_RESULT_SCHEMA_VERSION and update tests/golden/run-result-v4.json together"
);
}
#[test]
fn a_schema_4_run_result_round_trips_and_omits_what_it_does_not_have() {
let value = run_result_golden();
let read: RunResult =
serde_json::from_str(RUN_RESULT_GOLDEN).expect("the golden reads back into the types");
assert_eq!(read, value);
let again: RunResult =
serde_json::from_str(&serde_json::to_string(&value).expect("it serialises"))
.expect("it reads back");
assert_eq!(again, value);
let document: Value =
serde_json::from_str(&serde_json::to_string(&value).expect("it serialises"))
.expect("it is JSON");
assert_eq!(document["nodes"][0]["landing"], json!("landed"));
assert_eq!(document["nodes"][1]["landing"], json!("unlanded"));
assert!(
document["nodes"][2].get("landing").is_none(),
"a node with no change to land carries a landing key anyway: {}",
document["nodes"][2]
);
}
#[test]
fn the_run_result_schema_version_and_the_golden_name_the_same_number() {
assert_eq!(RUN_RESULT_SCHEMA_VERSION, 4);
let document: Value = serde_json::from_str(RUN_RESULT_GOLDEN).expect("the golden is JSON");
assert_eq!(document["schema_version"], RUN_RESULT_SCHEMA_VERSION);
assert!(
document.get("round").is_none(),
"the run's own result document names a round: {document}"
);
let written: Value = serde_json::from_str(
&serde_json::to_string(&run_result_golden()).expect("it serialises"),
)
.expect("it is JSON");
assert_eq!(written["schema_version"], RUN_RESULT_SCHEMA_VERSION);
}
#[test]
fn only_this_build_s_run_result_version_reads_and_every_other_is_refused_by_name() {
let document = serde_json::to_value(run_result_golden()).expect("it serialises");
let edit = |document: &Value, each: &dyn Fn(&mut serde_json::Map<String, Value>)| {
let mut copy = document.clone();
each(copy.as_object_mut().expect("it is an object"));
copy
};
for outside in [RUN_RESULT_SCHEMA_VERSION + 1, 3, 2, 1, 0] {
let claimed = edit(&document, &|object| {
object.insert("schema_version".into(), json!(outside));
});
let refused = serde_json::from_value::<RunResult>(claimed)
.expect_err("a result this build never wrote was read as one it did");
let refusal = refused.to_string();
assert!(
refusal.contains(&outside.to_string())
&& refusal.contains(&RUN_RESULT_SCHEMA_VERSION.to_string()),
"the refusal of {outside} names neither version: {refusal}"
);
}
let unversioned = edit(&document, &|object| {
object.remove("schema_version");
});
let refusal = serde_json::from_value::<RunResult>(unversioned)
.expect_err("an unversioned result was read as this build's version")
.to_string();
assert!(
refusal.contains("schema_version"),
"the refusal of an unversioned result does not name the field: {refusal}"
);
}
fn agent(id: &str, deps: &[&str]) -> Node {
Node {
id: id.into(),
persona: Some("engineer".into()),
task: Some("## What\ndo it".into()),
deps: deps.iter().map(|d| (*d).to_string()).collect(),
..Node::default()
}
}
#[test]
fn a_node_whose_budget_no_dispatch_can_run_under_settles_rather_than_launching() {
let (tx, rx) = std::sync::mpsc::channel();
let node = Node {
max_turns: Some(0),
..agent("build", &[])
};
let settlement = execute_direct(
&crate::executor::LocalExecutor,
"demo",
"graphs/node-scope.yaml",
&node,
&[],
&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("no turn at all"), "{detail}");
assert!(
rx.try_iter().count() == 0,
"a node that was never dispatched reported turns"
);
}
fn state_of(nodes: Vec<Node>, recorded: &[(&str, NodeStatus)]) -> RunState {
let plan = Plan {
schema_version: PLAN_SCHEMA_VERSION,
goal: None,
name: Some("demo".into()),
concurrency: 4,
tasks: nodes,
};
RunState {
graph: Graph::from_plan(&plan),
plan: Some(plan),
recorded: recorded
.iter()
.map(|(id, status)| ((*id).to_string(), Recorded::At(*status)))
.collect(),
..RunState::default()
}
}
#[test]
fn only_a_siblings_envelope_naming_a_run_and_a_member_addresses_a_turn() {
let envelope = |source: crate::event::Source, run: Option<&str>, member: Option<&str>| {
let mut labels = Labels {
run_id: run.map(str::to_string),
node: Some("build".into()),
..Labels::default()
};
if let Some(member) = member {
labels.extra.insert("member".into(), member.into());
}
Envelope {
v: crate::event::ENVELOPE_VERSION,
ts: "2026-08-12T00:00:00.000Z".into(),
stream: "oneagentgraph-1".into(),
seq: 0,
source,
kind: crate::event::EventKind("turn-started".into()),
phase: None,
labels,
payload: serde_json::Map::new(),
artifacts: Vec::new(),
}
};
use crate::event::Source;
assert_eq!(
addressed_by(&envelope(
Source::Agentgraph,
Some("node-scope-1786304152340-19"),
Some("worker")
)),
TurnAddress::of("node-scope-1786304152340-19", "worker")
);
for unaddressable in [
envelope(Source::Pipeline, Some("demo"), Some("worker")),
envelope(Source::Agentgraph, Some("graph-1"), None),
envelope(Source::Agentgraph, None, Some("worker")),
envelope(Source::Agentgraph, Some("graph-1"), Some(" ")),
envelope(Source::Agentgraph, Some(""), Some("worker")),
envelope(Source::Agentgraph, Some("graph-1"), Some("../elsewhere")),
] {
assert_eq!(
addressed_by(&unaddressable),
None,
"{unaddressable:?} was read as an address"
);
}
}
#[test]
fn a_blocked_node_names_the_ready_human_gating_it_transitively() {
let human = Node {
id: "approve".into(),
kind: NodeKind::Human,
task: Some("approve it".into()),
..Node::default()
};
let state = state_of(
vec![
human,
agent("ship", &["approve"]),
agent("after", &["ship"]),
],
&[("approve", NodeStatus::Waiting)],
);
let statuses = state.statuses();
assert_eq!(statuses["after"], NodeStatus::Blocked);
assert_eq!(
gating_humans(&state, &statuses, "after"),
vec!["approve".to_string()]
);
}
#[test]
fn a_waiting_human_holds_its_own_subtree_and_no_other_branch() {
let human = Node {
id: "approve".into(),
kind: NodeKind::Human,
task: Some("approve it".into()),
deps: vec!["seed".into()],
..Node::default()
};
let state = state_of(
vec![
agent("seed", &[]),
human,
agent("ship", &["approve"]),
agent("after", &["ship"]),
agent("probe", &[]),
agent("report", &["probe"]),
],
&[("seed", NodeStatus::Done), ("approve", NodeStatus::Waiting)],
);
let statuses = state.statuses();
let decisions = decisions_now(
&state,
&statuses,
&ChannelState::new(&RunPaths::under(
std::path::Path::new("/nonexistent"),
"demo",
)),
);
let held = decisions
.get(&DecisionRef::Attestation("approve".into()))
.expect("the human action holds");
assert_eq!(held.kind, "attestation");
assert_eq!(
held.unblocks,
vec!["ship".to_string(), "after".to_string()],
"the decision held more than its own subtree"
);
let paused = paused_by(&decisions);
assert!(
!paused.contains("probe"),
"an independent branch was paused"
);
assert!(
!paused.contains("report"),
"an independent branch was paused"
);
}
#[test]
fn a_decision_reference_spells_which_of_the_two_things_clears_it() {
assert_eq!(
DecisionRef::Attestation("approve".into()).as_wire(),
"approve"
);
assert_eq!(DecisionRef::Surface(7).as_wire(), "surface:7");
}
#[test]
fn a_decision_reference_reads_back_as_the_thing_that_wrote_it() {
for reference in [
DecisionRef::Attestation("approve".into()),
DecisionRef::Surface(7),
] {
assert_eq!(DecisionRef::of_wire(&reference.as_wire()), reference);
}
assert_eq!(
DecisionRef::of_wire("surface-check"),
DecisionRef::Attestation("surface-check".into())
);
}
#[test]
fn a_decision_is_reported_when_it_begins_holding_and_again_when_it_releases() {
let root = std::env::temp_dir().join(format!("onepipeline-decisions-{}", sys::pid()));
let _ = std::fs::remove_dir_all(&root);
let paths = RunPaths::under(&root, "demo");
paths.create().expect("the run directory");
let mut journal = Journal::open(&paths);
let decision = Decision {
reference: DecisionRef::Attestation("approve".into()),
kind: "attestation".into(),
unblocks: vec!["ship".into()],
};
let mut held = BTreeMap::new();
let pending: BTreeMap<DecisionRef, Decision> = [(
DecisionRef::Attestation("approve".to_string()),
decision.clone(),
)]
.into();
report_decisions(&paths, &mut journal, &pending, &mut held).expect("reported");
report_decisions(&paths, &mut journal, &pending, &mut held).expect("reported");
report_decisions(&paths, &mut journal, &BTreeMap::new(), &mut held).expect("reported");
let kinds: Vec<String> = journal::read(&paths.journal())
.iter()
.map(|event| event.kind.0.clone())
.collect();
assert_eq!(
kinds,
vec![
journal::PipelineKind::DecisionPending.as_str().to_string(),
journal::PipelineKind::DecisionCleared.as_str().to_string(),
]
);
let cleared = &journal::read(&paths.journal())[1];
assert_eq!(cleared.payload["released"], json!(["ship"]));
assert_eq!(cleared.labels.node.as_deref(), Some("approve"));
assert_eq!(cleared.labels.round, None, "a round was stamped");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_node_is_announced_ready_once_and_again_when_it_becomes_ready_again() {
let root = std::env::temp_dir().join(format!("onepipeline-ready-{}", sys::pid()));
let _ = std::fs::remove_dir_all(&root);
let paths = RunPaths::under(&root, "demo");
paths.create().expect("the run directory");
let mut journal = Journal::open(&paths);
let mut announced = BTreeSet::new();
let ready: BTreeMap<String, NodeStatus> = [("build".to_string(), NodeStatus::Ready)].into();
let running: BTreeMap<String, NodeStatus> =
[("build".to_string(), NodeStatus::Running)].into();
announce_ready(&paths, &mut journal, &ready, &mut announced).expect("announced");
announce_ready(&paths, &mut journal, &ready, &mut announced).expect("announced");
announce_ready(&paths, &mut journal, &running, &mut announced).expect("announced");
announce_ready(&paths, &mut journal, &ready, &mut announced).expect("announced");
assert_eq!(
journal::read(&paths.journal()).len(),
2,
"a node was announced ready more than once per time it became ready"
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn the_stall_threshold_falls_back_when_the_environment_is_unusable() {
assert!(stall_after_seconds() > 0);
}
#[test]
fn a_node_names_its_own_agent_graph_or_takes_the_shipped_default() {
let pinned = oneagentgraph::config::ConfigRef("./custom.yaml".into());
assert_eq!(node_graph(Some(&pinned), "default"), pinned);
assert_eq!(node_graph(None, "default").0, "default");
}
#[test]
fn only_the_commands_that_stop_a_dispatch_name_a_node_to_cancel() {
assert_eq!(
cancelled_by(&Command::Cancel { id: "a".into() }),
vec!["a".to_string()]
);
assert_eq!(
cancelled_by(&Command::Drop {
id: "a".into(),
dependents: crate::channel::Dependents::Detach
}),
vec!["a".to_string()]
);
assert!(cancelled_by(&Command::Complete { reason: "r".into() }).is_empty());
}
#[test]
fn dispatch_labels_carry_only_the_reserved_keys_and_never_a_round() {
let labels = dispatch_labels("demo", "build", Some("implement"), Some("engineer"));
assert_eq!(labels.run_id.as_deref(), Some("demo"));
assert_eq!(labels.node.as_deref(), Some("build"));
assert_eq!(labels.step.as_deref(), Some("implement"));
assert_eq!(labels.round, None, "a dispatch was stamped with a round");
assert!(labels.extra.is_empty());
}
}