use std::collections::{BTreeMap, BTreeSet};
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 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 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 = 3;
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>,
}
#[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 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,
completed_steps: Vec::new(),
}
}
}
pub(crate) enum Message {
Event(Box<Envelope>),
Redispatched(Box<Redispatch>),
Cancelling(Box<Cancelling>),
Settled(Box<Settlement>),
}
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: u32,
pub attempts: u32,
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 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 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, &mut in_flight)?;
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 paused = paused_by(&decisions);
start_ready(
paths,
journal,
state,
&rules,
launch,
&tx,
&mut in_flight,
&paused,
)?;
if in_flight.is_empty() {
let statuses = state.statuses();
if graph::is_terminal(&statuses) {
break;
}
if !any_node_can_still_move(&statuses) {
break;
}
}
match rx.recv_timeout(POLL) {
Ok(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)?;
}
Ok(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))),
]),
)?,
Ok(Message::Cancelling(step)) => raise(paths, journal, cancelling_surface(&step))?,
Ok(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));
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
Err(mpsc::RecvTimeoutError::Disconnected) => break,
}
watch_for_quiet(paths, journal, stall_after, &mut in_flight)?;
}
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,
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, 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))]),
)?,
_ => {}
}
}
if author == crate::channel::Author::Monitor {
raise(paths, journal, monitor_edit(command))?;
}
*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,
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(),
..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 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>,
) -> 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))]),
)?;
spawn(paths, rules, launch, &node, 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(())
}
fn spawn(
paths: &RunPaths,
rules: &ExecutorRules,
launch: &LaunchRecord,
node: &Node,
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 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, &cancel, &tx)
} else {
execute_direct(
executor.as_ref(),
&run,
&launched.node_graph,
&node,
&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,
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(),
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<String>,
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 {
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 {
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: attempt + 1,
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(), 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_deref()),
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()),
}
}
fn failed_task(node: &str, outcome: &DispatchOutcome, session: Option<&str>) -> Settlement {
let detail = (!outcome.detail.is_empty()).then(|| outcome.detail.clone());
let Some(url) = session.and_then(crate::vcs::change_opened_in) else {
return Settlement {
detail,
..failed(node, "task-failed")
};
};
Settlement {
detail,
change_url: Some(url),
..failed(node, "task-failed-change-open")
}
}
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() -> u32 {
std::env::var(BOUNDARY_ATTEMPTS_ENV)
.ok()
.and_then(|value| value.parse().ok())
.filter(|attempts| *attempts > 0)
.unwrap_or(DEFAULT_BOUNDARY_ATTEMPTS)
}
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))
}
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())
}
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(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) -> Surface {
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 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(),
}
})
.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;
const RUN_RESULT_GOLDEN: &str = include_str!("../tests/golden/run-result-v3.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,
}
}
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")
},
settled("built"),
],
}
}
#[test]
fn a_schema_3_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-v3.json together"
);
}
#[test]
fn a_schema_3_run_result_round_trips_and_omits_a_landing_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, 3);
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, 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()),
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());
}
}