use std::collections::BTreeMap;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{mpsc, Arc, Mutex};
use std::time::{Duration, Instant};
use crate::error::{Error, Result};
use crate::event::{Envelope, Labels};
use crate::filter::EventFilter;
pub const BINARY_ENV: &str = "ONEPIPELINE_ONEAGENTGRAPH_BIN";
pub const DEFAULT_BINARY: &str = "oneagentgraph";
pub const DRIVE_VERB: &str = "drive";
const STATE_DIR_ENV: &str = "ONEAGENTGRAPH_STATE_DIR";
const ONEHARNESS_BIN_ENV: &str = "ONEAGENTGRAPH_ONEHARNESS_BIN";
fn process_env() -> BTreeMap<String, String> {
std::env::vars_os()
.filter_map(|(key, value)| Some((key.into_string().ok()?, value.into_string().ok()?)))
.collect()
}
fn state_dir(env: &BTreeMap<String, String>) -> PathBuf {
env.get(STATE_DIR_ENV).map_or_else(
|| {
env.get("HOME")
.map_or_else(std::env::temp_dir, PathBuf::from)
.join(".local/state/oneagentgraph/runs")
},
PathBuf::from,
)
}
fn oneharness_bin(env: &BTreeMap<String, String>) -> String {
env.get(ONEHARNESS_BIN_ENV)
.cloned()
.unwrap_or_else(|| "oneharness".into())
}
pub const RUN_ID_ENV: &str = "ONEPIPELINE_RUN_ID";
pub const CHECK_IN_MEMBER: &str = "check-in";
pub const LABEL_PREFIX: &str = "onepipeline.";
pub const DEFAULT_STARTUP_TIMEOUT_SECONDS: u64 = 30;
pub const STARTUP_TIMEOUT_ENV: &str = "ONEPIPELINE_STARTUP_TIMEOUT_SECONDS";
fn startup_timeout() -> Duration {
let seconds = std::env::var(STARTUP_TIMEOUT_ENV)
.ok()
.and_then(|value| value.parse().ok())
.filter(|seconds| *seconds > 0)
.unwrap_or(DEFAULT_STARTUP_TIMEOUT_SECONDS);
Duration::from_secs(seconds)
}
const LAUNCH_POLL: Duration = Duration::from_millis(10);
const EVIDENCE_CHARS: usize = crate::event::MAX_PAYLOAD_TEXT_BYTES / 4;
fn report_skipped(skipped: usize) {
if skipped > 0 {
eprintln!("onepipeline: skipped {skipped} oneagentgraph line(s) this build cannot read");
}
}
pub fn binary() -> String {
overriding_binary().unwrap_or_else(|| DEFAULT_BINARY.to_string())
}
fn overridden() -> bool {
overriding_binary().is_some()
}
fn overriding_binary() -> Option<String> {
std::env::var(BINARY_ENV)
.ok()
.filter(|value| !value.is_empty())
}
fn sibling(message: impl Into<String>) -> Error {
Error::Sibling {
tool: "oneagentgraph",
message: message.into(),
}
}
fn exit_for(error: &oneagentgraph::error::Error) -> i32 {
match error {
oneagentgraph::error::Error::InvalidConfig(_) => oneagentgraph::error::EXIT_INVALID_CONFIG,
_ => oneagentgraph::error::EXIT_MEMBER_FAILED,
}
}
fn ended(status: &std::process::ExitStatus) -> String {
status.code().map_or_else(
|| "was ended by a signal".to_string(),
|code| format!("exited {code}"),
)
}
fn envelope_of(line: &str) -> Option<Envelope> {
serde_json::from_str::<Envelope>(line.trim()).ok()
}
fn is_envelope(line: &str) -> bool {
envelope_of(line).is_some()
}
fn announced_run(envelope: &Envelope) -> Option<GraphRunId> {
GraphRunId::parse(envelope.labels.run_id.as_deref()?.trim()).ok()
}
pub type GraphRunId = oneagentgraph::run::RunId;
pub fn recorded_graph_run(recorded: &str, run: &str) -> Result<GraphRunId> {
let recorded = recorded.trim();
if recorded.is_empty() {
return Err(Error::Invalid(format!(
"run '{run}' records no agent-graph run to address it by"
)));
}
GraphRunId::parse(recorded)
.map_err(|error| sibling(format!("run '{run}' records '{recorded}': {error}")))
}
pub fn graph_run_ended(recorded: &str, run: &str) -> bool {
let root = state_dir(&process_env());
recorded_graph_run(recorded, run)
.ok()
.and_then(|graph_run| {
let record = oneagentgraph::history::show(&root, graph_run.as_str()).ok()?;
Some(
record.finished_ms.is_some()
|| oneagentgraph::scratch::reclaimable(&root.join(&graph_run)).is_ok(),
)
})
.unwrap_or(false)
}
pub fn label_args(labels: &Labels) -> Vec<String> {
let mut args = Vec::new();
let mut push = |key: &str, value: String| args.push(format!("{LABEL_PREFIX}{key}={value}"));
if let Some(run) = &labels.run_id {
push("run_id", run.clone());
}
if let Some(node) = &labels.node {
push("node", node.clone());
}
if let Some(step) = &labels.step {
push("step", step.clone());
}
if let Some(persona) = &labels.persona {
push("persona", persona.clone());
}
args
}
pub fn adopt_labels(labels: &mut Labels) {
let stamped = |key: &str| {
labels
.extra
.get(&format!("{LABEL_PREFIX}{key}"))
.and_then(|value| value.as_str())
.map(str::to_string)
};
let (run, node, step, persona) = (
stamped("run_id"),
stamped("node"),
stamped("step"),
stamped("persona"),
);
labels.run_id = labels.run_id.take().or(run);
labels.node = labels.node.take().or(node);
labels.step = labels.step.take().or(step);
labels.persona = labels.persona.take().or(persona);
}
fn relayed(envelope: oneagentgraph::event::Envelope) -> Result<Envelope> {
serde_json::to_value(envelope)
.map_err(|error| sibling(format!("serializing graph event: {error}")))
.and_then(|value| {
serde_json::from_value::<Envelope>(value)
.map_err(|error| sibling(format!("reading graph event: {error}")))
})
.map(|mut envelope| {
adopt_labels(&mut envelope.labels);
envelope
})
}
fn terminal_settlement(envelope: &oneagentgraph::event::Envelope) -> Option<Settled> {
(envelope.kind == oneagentgraph::event::EventKind::GraphSettled)
.then(|| envelope.payload.get("exit_code")?.as_i64())
.flatten()
.and_then(|code| i32::try_from(code).ok())
.map(|code| Settled {
code: Some(code),
stderr: String::new(),
})
}
#[derive(Debug)]
pub struct GraphRun {
backend: GraphBackend,
}
#[derive(Debug)]
enum GraphBackend {
Library(LibraryGraphRun),
Process(ProcessGraphRun),
}
#[derive(Debug)]
struct LibraryGraphRun {
events: Option<mpsc::Receiver<Result<Envelope>>>,
settled: mpsc::Receiver<Result<Settled>>,
cancel: mpsc::Sender<()>,
run_id: GraphRunId,
exited: Arc<AtomicBool>,
}
#[derive(Debug)]
struct ProcessGraphRun {
child: Arc<Mutex<Child>>,
pid: u32,
output: Output,
started_with: Vec<String>,
run_id: Option<GraphRunId>,
}
const RELAY_POLL: Duration = Duration::from_millis(250);
const SAID_PATIENCE: Duration = Duration::from_secs(2);
const SAID_POLL: Duration = Duration::from_millis(10);
#[derive(Debug, Clone)]
struct Said {
bytes: Arc<Mutex<Vec<u8>>>,
ended: Arc<AtomicBool>,
}
impl Said {
fn draining(pipe: Option<std::process::ChildStderr>) -> Self {
let said = Self {
bytes: Arc::new(Mutex::new(Vec::new())),
ended: Arc::new(AtomicBool::new(false)),
};
let drain = Drained(said.clone());
let _ = std::thread::Builder::new()
.name(format!("{}-stderr", binary()))
.spawn(move || {
use std::io::Read;
let mut buffer = [0u8; 4096];
if let Some(mut pipe) = pipe {
loop {
match pipe.read(&mut buffer) {
Ok(0) => break,
Ok(read) => held(&drain.0.bytes).extend_from_slice(&buffer[..read]),
Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {}
Err(_) => break,
}
}
}
});
said
}
fn settled(&self) -> String {
let deadline = Instant::now() + SAID_PATIENCE;
while !self.ended.load(Ordering::Acquire) && Instant::now() < deadline {
std::thread::sleep(SAID_POLL);
}
String::from_utf8_lossy(&held(&self.bytes)).into_owned()
}
}
struct Drained(Said);
impl Drop for Drained {
fn drop(&mut self) {
self.0.ended.store(true, Ordering::Release);
}
}
fn held<T>(lock: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
lock.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn over(child: &Mutex<Child>) -> bool {
child
.try_lock()
.is_ok_and(|mut child| matches!(child.try_wait(), Ok(Some(_))))
}
fn relayed_lines(
reader: BufReader<std::process::ChildStdout>,
child: Arc<Mutex<Child>>,
) -> impl Iterator<Item = std::io::Result<String>> + Send {
let (lines, arriving) = mpsc::channel();
let _ = std::thread::Builder::new()
.name(format!("{}-relay", binary()))
.spawn(move || {
for line in reader.lines() {
if lines.send(line).is_err() {
return;
}
}
});
std::iter::from_fn(move || loop {
match arriving.recv_timeout(RELAY_POLL) {
Ok(line) => return Some(line),
Err(mpsc::RecvTimeoutError::Disconnected) => return None,
Err(mpsc::RecvTimeoutError::Timeout) if over(&child) => return None,
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
})
}
#[derive(Debug, Clone, Copy)]
pub enum GraphOutput<'a> {
Relayed,
Logged(&'a Path),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Environment {
Shared,
PerLaunch,
}
#[derive(Debug, Clone, Copy)]
pub struct Launch<'a> {
pub graph: &'a str,
pub task: &'a str,
pub dir: &'a Path,
pub labels: &'a Labels,
pub env: &'a [(String, String)],
pub environment: Environment,
pub sets: &'a [String],
pub filter: Option<&'a EventFilter>,
pub output: GraphOutput<'a>,
}
#[derive(Debug)]
enum Output {
Relayed {
stdout: Option<BufReader<std::process::ChildStdout>>,
stderr: Said,
},
Logged {
path: PathBuf,
from: u64,
},
}
fn logged_since(path: &Path, from: u64) -> String {
use std::io::{Read, Seek};
let mut said = String::new();
let _ = std::fs::File::open(path).and_then(|mut file| {
file.seek(std::io::SeekFrom::Start(from))?;
file.read_to_string(&mut said)
});
said
}
static SPEAKS_THIS_CLI: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
pub fn speaks_this_cli(exe: PathBuf) {
let _ = SPEAKS_THIS_CLI.set(exe);
}
fn retainable() -> bool {
overridden() || SPEAKS_THIS_CLI.get().is_some()
}
fn retained_command(
graph: &str,
task: &str,
dir: &Path,
labels: &[String],
sets: &[String],
filter: Option<&EventFilter>,
) -> Result<Command> {
let mut command = match overridden() {
true => {
let mut command = Command::new(binary());
command.arg("run").arg(graph);
command.arg("--output").arg("json");
command
}
false => {
let exe = SPEAKS_THIS_CLI.get().cloned().map_or_else(
|| {
std::env::current_exe().map_err(|e| {
sibling(format!(
"cannot find this executable to retain a driver: {e}"
))
})
},
Ok,
)?;
let mut command = Command::new(exe);
command.arg(DRIVE_VERB).arg(graph);
command
}
};
command.arg("--task").arg(task);
command.arg("--dir").arg(dir);
for label in labels {
command.arg("--label").arg(label);
}
for value in sets {
command.arg("--set").arg(value);
}
if let Some(filter) = filter {
command.arg("--event-filter").arg(
serde_json::to_string(filter)
.map_err(|error| sibling(format!("rendering the event filter: {error}")))?,
);
}
Ok(command)
}
fn sibling_filter(filter: &EventFilter) -> Result<oneagentgraph::event::EventFilter> {
let document = serde_json::to_string(filter)
.map_err(|error| sibling(format!("rendering the event filter: {error}")))?;
serde_json::from_str(&document)
.map_err(|error| sibling(format!("`oneagentgraph` refused the event filter: {error}")))
}
impl ProcessGraphRun {
pub fn start(launch: &Launch<'_>) -> Result<Self> {
let output = launch.output;
let mut command = retained_command(
launch.graph,
launch.task,
launch.dir,
&label_args(launch.labels),
launch.sets,
launch.filter,
)?;
for (key, value) in launch.env {
command.env(key, value);
}
command.stdin(Stdio::null());
let mut logged_from = 0;
match output {
GraphOutput::Relayed => {
command.stdout(Stdio::piped()).stderr(Stdio::piped());
}
GraphOutput::Logged(path) => {
logged_from = std::fs::metadata(path).map(|meta| meta.len()).unwrap_or(0);
let log = |path: &Path| {
std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)
.map_err(|e| {
sibling(format!(
"cannot open {} for the driver: {e}",
path.display()
))
})
};
command.stdout(log(path)?).stderr(log(path)?);
}
}
let mut child = command
.spawn()
.map_err(|e| sibling(format!("cannot start `{} run`: {e}", binary())))?;
let pid = child.id();
let output = match output {
GraphOutput::Relayed => Output::Relayed {
stdout: child.stdout.take().map(BufReader::new),
stderr: Said::draining(child.stderr.take()),
},
GraphOutput::Logged(path) => Output::Logged {
path: path.to_path_buf(),
from: logged_from,
},
};
Ok(Self {
child: Arc::new(Mutex::new(child)),
pid,
output,
started_with: Vec::new(),
run_id: None,
})
}
pub fn confirm_started(&mut self) -> Result<()> {
let piped = match &mut self.output {
Output::Relayed { stdout, .. } => stdout.take(),
Output::Logged { .. } => return self.await_logged_line(),
};
match piped {
Some(reader) => self.await_first_line(reader),
None => self.settle_unstarted(),
}
}
fn await_first_line(&mut self, reader: BufReader<std::process::ChildStdout>) -> Result<()> {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::Builder::new()
.name(format!("{}-handshake", binary()))
.spawn(move || {
let mut reader = reader;
let mut read = Vec::new();
loop {
let mut line = String::new();
match reader.read_line(&mut line) {
Err(error) => break tx.send((Some(error), read, reader)),
Ok(0) => break tx.send((None, read, reader)),
Ok(_) => {
let announced = is_envelope(&line);
read.push(line);
if announced {
break tx.send((None, read, reader));
}
}
}
}
})
.map_err(|e| sibling(format!("cannot wait for `{} run` to start: {e}", binary())))?;
match rx.recv_timeout(startup_timeout()) {
Ok((error, read, reader)) => {
let announcement = read.last().and_then(|line| envelope_of(line));
let announced = announcement.is_some();
self.run_id = announcement.as_ref().and_then(announced_run);
if let Output::Relayed { stdout, .. } = &mut self.output {
*stdout = Some(reader);
}
self.started_with = read;
match error {
Some(error) => Err(sibling(format!(
"cannot read `{} run`'s first envelope: {error}",
binary()
))),
None if announced => Ok(()),
None => self.settle_unstarted(),
}
}
Err(_) => self.gave_no_answer(),
}
}
fn await_logged_line(&mut self) -> Result<()> {
let deadline = Instant::now() + startup_timeout();
loop {
if let Some(announcement) = self.logged_envelope() {
self.run_id = announced_run(&announcement);
return Ok(());
}
let waited = held(&self.child).try_wait();
match waited {
Err(error) => {
return Err(sibling(format!(
"cannot tell whether `{} run` started: {error}",
binary()
)))
}
Ok(Some(status)) if status.success() => return Ok(()),
Ok(Some(status)) => return self.refused(status),
Ok(None) => {}
}
if Instant::now() >= deadline {
return self.gave_no_answer();
}
std::thread::sleep(LAUNCH_POLL);
}
}
fn logged_envelope(&self) -> Option<Envelope> {
let Output::Logged { path, from } = &self.output else {
return None;
};
let text = logged_since(path, *from);
text.split_inclusive('\n')
.filter(|line| line.ends_with('\n'))
.find_map(envelope_of)
}
fn settle_unstarted(&mut self) -> Result<()> {
let deadline = Instant::now() + startup_timeout();
loop {
let waited = held(&self.child).try_wait();
match waited {
Err(error) => {
return Err(sibling(format!(
"cannot tell whether `{} run` started: {error}",
binary()
)))
}
Ok(Some(status)) if status.success() => return Ok(()),
Ok(Some(status)) => return self.refused(status),
Ok(None) if Instant::now() >= deadline => return self.gave_no_answer(),
Ok(None) => std::thread::sleep(LAUNCH_POLL),
}
}
}
fn refused(&mut self, status: std::process::ExitStatus) -> Result<()> {
Err(sibling(format!(
"`{} run` {} instead of driving the run: {}",
binary(),
ended(&status),
self.evidence()
)))
}
fn gave_no_answer(&mut self) -> Result<()> {
{
let mut child = held(&self.child);
let _ = child.kill();
let _ = child.wait();
}
Err(sibling(format!(
"`{} run` neither started nor exited within {}s, so nothing is driving the run: {}",
binary(),
startup_timeout().as_secs(),
self.evidence()
)))
}
fn evidence(&mut self) -> String {
let text = match &self.output {
Output::Logged { path, from } => logged_since(path, *from),
Output::Relayed { .. } => self.said(),
};
let trimmed = text.trim();
if trimmed.is_empty() {
return "it said nothing".to_string();
}
let mut tail: Vec<char> = trimmed.chars().rev().take(EVIDENCE_CHARS).collect();
tail.reverse();
tail.into_iter().collect()
}
pub fn events(&mut self) -> Box<dyn Iterator<Item = Result<Envelope>> + Send> {
let piped = match &mut self.output {
Output::Relayed { stdout, .. } => stdout.take(),
Output::Logged { .. } => None,
};
let Some(stdout) = piped else {
return Box::new(std::iter::empty());
};
let announced: Vec<_> = std::mem::take(&mut self.started_with);
Box::new(
announced
.into_iter()
.map(Ok)
.chain(relayed_lines(stdout, Arc::clone(&self.child)))
.filter_map(|line| match line {
Err(error) => Some(Err(sibling(format!(
"reading `{} run` output: {error}",
binary()
)))),
Ok(line) if line.trim().is_empty() => None,
Ok(line) => match serde_json::from_str::<Envelope>(&line) {
Ok(mut envelope) => {
adopt_labels(&mut envelope.labels);
Some(Ok(envelope))
}
Err(_) => {
report_skipped(1);
None
}
},
}),
)
}
pub fn wait(&mut self) -> Result<Settled> {
let status = held(&self.child)
.wait()
.map_err(|e| sibling(format!("waiting for `{} run`: {e}", binary())))?;
Ok(Settled {
code: status.code(),
stderr: self.said(),
})
}
fn said(&self) -> String {
match &self.output {
Output::Relayed { stderr, .. } => stderr.settled(),
Output::Logged { .. } => String::new(),
}
}
pub fn pid(&self) -> u32 {
self.pid
}
pub fn run_id(&self) -> Option<&GraphRunId> {
self.run_id.as_ref()
}
pub fn has_exited(&mut self) -> bool {
matches!(held(&self.child).try_wait(), Ok(Some(_)))
}
}
fn export(env: &[(String, String)]) {
for (key, value) in env {
if std::env::var(key).is_ok_and(|held| &held == value) {
continue;
}
std::env::set_var(key, value);
}
}
impl GraphRun {
pub fn start(launch: &Launch<'_>) -> Result<Self> {
if matches!(launch.output, GraphOutput::Logged(_))
|| (launch.environment == Environment::PerLaunch && retainable())
|| std::env::var_os(BINARY_ENV).is_some()
{
return ProcessGraphRun::start(launch).map(|run| Self {
backend: GraphBackend::Process(run),
});
}
Self::in_library(
launch.graph,
launch.task,
launch.dir,
&label_args(launch.labels),
launch.env,
launch.sets,
launch.filter,
)
}
fn in_library(
graph: &str,
task: &str,
dir: &Path,
labels: &[String],
env: &[(String, String)],
sets: &[String],
filter: Option<&EventFilter>,
) -> Result<Self> {
let mut run_env = process_env();
run_env.extend(env.iter().cloned());
export(env);
let labels = labels
.iter()
.map(|label| {
oneagentgraph::run::parse_label(label).map_err(|error| sibling(error.to_string()))
})
.collect::<Result<Vec<_>>>()?;
let overrides = sets
.iter()
.map(|value| {
oneagentgraph::run::parse_set(value).map_err(|error| sibling(error.to_string()))
})
.collect::<Result<Vec<_>>>()?;
let state_dir = state_dir(&run_env);
let request = oneagentgraph::run::Request {
graph: oneagentgraph::config::ConfigRef(graph.to_string()),
task: Some(task.to_string()),
dir: dir.to_path_buf(),
labels,
overrides,
filter: filter.map(sibling_filter).transpose()?,
state_dir,
oneharness_bin: oneharness_bin(&run_env),
};
let running = oneagentgraph::run::start(&request, &run_env)
.map_err(|error| sibling(error.to_string()))?;
let run_id = running.started().run_id.clone();
let (events_tx, events_rx) = mpsc::channel();
let (settled_tx, settled_rx) = mpsc::channel();
let (cancel_tx, cancel_rx) = mpsc::channel();
let exited = Arc::new(AtomicBool::new(false));
let thread_exited = Arc::clone(&exited);
std::thread::Builder::new()
.name("oneagentgraph-relay".into())
.spawn(move || {
let mut graph_settled = None;
loop {
if cancel_rx.try_recv().is_ok() {
let _ = running.cancel();
}
match running.recv_timeout(Duration::from_millis(10)) {
Ok(Some(envelope)) => {
graph_settled = terminal_settlement(&envelope);
let envelope = relayed(envelope);
if events_tx.send(envelope).is_err() {
let _ = running.cancel();
break;
}
if graph_settled.is_some() {
break;
}
}
Ok(None) | Err(mpsc::RecvTimeoutError::Timeout) => {}
Err(mpsc::RecvTimeoutError::Disconnected) => break,
}
}
let settled = graph_settled.unwrap_or_else(|| match running.wait() {
Ok(code) => Settled {
code: Some(code),
stderr: String::new(),
},
Err(error) => Settled {
code: Some(exit_for(&error)),
stderr: error.to_string(),
},
});
thread_exited.store(true, Ordering::Release);
let _ = settled_tx.send(Ok(settled));
})
.map_err(|error| sibling(format!("cannot start graph relay: {error}")))?;
Ok(Self {
backend: GraphBackend::Library(LibraryGraphRun {
events: Some(events_rx),
settled: settled_rx,
cancel: cancel_tx,
run_id,
exited,
}),
})
}
pub fn confirm_started(&mut self) -> Result<()> {
match &mut self.backend {
GraphBackend::Library(_) => Ok(()),
GraphBackend::Process(run) => run.confirm_started(),
}
}
pub fn events(&mut self) -> Box<dyn Iterator<Item = Result<Envelope>> + Send> {
match &mut self.backend {
GraphBackend::Library(run) => run.events.take().map_or_else(
|| {
Box::new(std::iter::empty())
as Box<dyn Iterator<Item = Result<Envelope>> + Send>
},
|events| Box::new(events.into_iter()),
),
GraphBackend::Process(run) => run.events(),
}
}
pub fn wait(&mut self) -> Result<Settled> {
match &mut self.backend {
GraphBackend::Library(run) => run
.settled
.recv()
.map_err(|error| sibling(format!("waiting for graph run: {error}")))?,
GraphBackend::Process(run) => run.wait(),
}
}
pub fn run_id(&self) -> Option<&GraphRunId> {
match &self.backend {
GraphBackend::Library(run) => Some(&run.run_id),
GraphBackend::Process(run) => run.run_id(),
}
}
pub fn process(&self) -> Option<u32> {
match &self.backend {
GraphBackend::Library(_) => None,
GraphBackend::Process(run) => Some(run.pid()),
}
}
pub fn has_exited(&mut self) -> bool {
match &mut self.backend {
GraphBackend::Library(run) => run.exited.load(Ordering::Acquire),
GraphBackend::Process(run) => run.has_exited(),
}
}
pub fn cancel(&self) {
match &self.backend {
GraphBackend::Library(run) => {
let _ = run.cancel.send(());
}
GraphBackend::Process(run) => {
let _ = crate::sys::stop(run.pid(), crate::sys::Stop::Now);
}
}
}
}
pub fn drive(
graph: &str,
task: &str,
dir: &Path,
labels: &[String],
sets: &[String],
filter: Option<&str>,
) -> Result<i32> {
use std::io::Write;
let filter = filter.map(EventFilter::read).transpose()?;
let mut run = GraphRun::in_library(graph, task, dir, labels, &[], sets, filter.as_ref())?;
let mut out = std::io::stdout();
for envelope in run.events() {
let envelope = envelope?;
let line = serde_json::to_string(&envelope)
.map_err(|error| sibling(format!("rendering graph event: {error}")))?;
writeln!(out, "{line}")
.map_err(|error| sibling(format!("relaying graph event: {error}")))?;
out.flush()
.map_err(|error| sibling(format!("relaying graph event: {error}")))?;
}
let settled = run.wait()?;
let said = settled.stderr.trim();
if !said.is_empty() {
eprintln!("{said}");
}
Ok(settled
.code
.unwrap_or(oneagentgraph::error::EXIT_MEMBER_FAILED))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Settled {
pub code: Option<i32>,
pub stderr: String,
}
impl Settled {
pub fn succeeded(&self) -> bool {
self.code == Some(0)
}
}
pub fn reset_timer(run: &GraphRunId, member: &str) -> Result<()> {
if std::env::var_os(BINARY_ENV).is_some() {
return reset_timer_by_process(run, member);
}
let member_name = oneagentgraph::run::MemberName::parse(member)
.map_err(|error| sibling(format!("reset-timer {run} {member}: {error}")))?;
oneagentgraph::run::signal(
&state_dir(&process_env()),
run,
&member_name,
oneagentgraph::run::Signal::Reset,
)
.map_err(|error| sibling(format!("reset-timer {run} {member}: {error}")))
}
fn reset_timer_by_process(run: &GraphRunId, member: &str) -> Result<()> {
let output = Command::new(binary())
.arg("reset-timer")
.arg(run.as_str())
.arg(member)
.stdin(Stdio::null())
.output()
.map_err(|e| sibling(format!("cannot start `{} reset-timer`: {e}", binary())))?;
if output.status.success() {
return Ok(());
}
Err(sibling(format!(
"reset-timer {run} {member} exited {}: {}",
output.status.code().unwrap_or(-1),
String::from_utf8_lossy(&output.stderr).trim()
)))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TurnAddress {
run: String,
member: String,
}
impl TurnAddress {
pub fn of(run: &str, member: &str) -> Option<Self> {
let (run, member) = (run.trim(), member.trim());
(!run.is_empty() && oneagentgraph::config::is_member_name(member)).then(|| Self {
run: run.to_string(),
member: member.to_string(),
})
}
pub fn run(&self) -> &str {
&self.run
}
pub fn member(&self) -> &str {
&self.member
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Interrupted {
Delivered,
NoTurn(String),
Failed(String),
}
#[derive(Debug, Clone, PartialEq)]
pub struct Interrupt {
pub outcome: Interrupted,
pub events: Vec<Envelope>,
}
pub fn interrupt(address: &TurnAddress, input: &str) -> Interrupt {
if std::env::var_os(BINARY_ENV).is_some() {
return interrupt_by_process(address, input);
}
let env = process_env();
let addressed = oneagentgraph::run::RunId::parse(address.run())
.map_err(|error| error.to_string())
.and_then(|run_id| {
oneagentgraph::run::MemberName::parse(address.member())
.map_err(|error| error.to_string())
.map(|member| (run_id, member))
});
let (run_id, member) = match addressed {
Ok(addressed) => addressed,
Err(reason) => {
return Interrupt {
outcome: Interrupted::Failed(format!(
"`oneagentgraph interrupt {} {}` was refused: {reason}",
address.run(),
address.member()
)),
events: Vec::new(),
}
}
};
let delivered = oneagentgraph::control::interrupt(
&state_dir(&env),
&run_id,
&member,
Some(input),
&oneharness_bin(&env),
);
let (outcome, reason) = match delivered {
Ok(oneagentgraph::control::Delivery::Delivered) => (Interrupted::Delivered, None),
Ok(oneagentgraph::control::Delivery::NoTurn(reason)) => {
(Interrupted::NoTurn(reason.clone()), Some(reason))
}
Ok(oneagentgraph::control::Delivery::Failed(reason)) => (
Interrupted::Failed(format!(
"`oneagentgraph interrupt {} {}` could not deliver: {reason}",
address.run(),
address.member()
)),
Some(reason),
),
Ok(oneagentgraph::control::Delivery::Invalid(reason)) => {
return Interrupt {
outcome: Interrupted::Failed(format!("--input: {reason}")),
events: Vec::new(),
}
}
Err(error) => {
return Interrupt {
outcome: Interrupted::Failed(format!(
"`oneagentgraph interrupt {} {}` was refused: {error}",
address.run(),
address.member()
)),
events: Vec::new(),
}
}
}; Interrupt {
outcome,
events: published(&run_id, address.member(), input.len() as u64, reason),
}
}
pub fn note(
address: &TurnAddress,
note: &oneagentgraph::note::Note,
) -> std::result::Result<oneagentgraph::note::Accepted, oneagentgraph::note::Undelivered> {
if let Some(named) = std::env::var_os(BINARY_ENV) {
return Err(oneagentgraph::note::Undelivered::NoConversation {
reason: format!(
"this run composes the `oneagentgraph` executable named at {BINARY_ENV} ({}), \
and the note seam is a library call that command line has no verb for",
Path::new(&named).display()
),
});
}
let env = process_env();
let addressed = oneagentgraph::run::RunId::parse(address.run())
.map_err(|error| error.to_string())
.and_then(|run_id| {
oneagentgraph::run::MemberName::parse(address.member())
.map_err(|error| error.to_string())
.map(|member| (run_id, member))
});
let (run_id, member) = match addressed {
Ok(addressed) => addressed,
Err(reason) => {
return Err(oneagentgraph::note::Undelivered::NoConversation {
reason: format!(
"'{} {}' is not a member this run can address: {reason}",
address.run(),
address.member()
),
})
}
};
match oneagentgraph::control::note(
&state_dir(&env),
&run_id,
&member,
note,
&oneharness_bin(&env),
) {
Ok(oneagentgraph::control::NoteDelivery::Accepted(accepted)) => Ok(accepted),
Ok(oneagentgraph::control::NoteDelivery::Undelivered(undelivered)) => Err(undelivered),
Err(error) => Err(oneagentgraph::note::Undelivered::NoConversation {
reason: error.to_string(),
}),
}
}
fn published(
run_id: &oneagentgraph::run::RunId,
member: &str,
input_bytes: u64,
reason: Option<String>,
) -> Vec<Envelope> {
let sink = Captured::new();
let emitter = oneagentgraph::event::Emitter::new(
format!("{run_id}-interrupt-{}", std::process::id()),
Box::new(sink.clone()),
)
.with_labels(oneagentgraph::event::Labels {
run_id: Some(run_id.to_string()),
member: Some(member.to_string()),
..oneagentgraph::event::Labels::default()
});
let payload = oneagentgraph::event::TurnInterrupted {
member: member.to_string(),
delivered: reason.is_none(),
input_bytes,
reason,
};
emitter.emit(
oneagentgraph::event::EventKind::TurnInterrupted,
match serde_json::to_value(&payload) {
Ok(serde_json::Value::Object(map)) => map,
_ => serde_json::Map::new(),
},
);
read_envelopes(&sink.written())
}
#[derive(Debug, Clone)]
struct Captured(Arc<std::sync::Mutex<Vec<u8>>>);
impl Captured {
fn new() -> Self {
Self(Arc::new(std::sync::Mutex::new(Vec::new())))
}
fn written(&self) -> String {
self.0.lock().map_or_else(
|held| String::from_utf8_lossy(&held.into_inner()).into_owned(),
|held| String::from_utf8_lossy(&held).into_owned(),
)
}
}
impl std::io::Write for Captured {
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
if let Ok(mut held) = self.0.lock() {
held.extend_from_slice(bytes);
}
Ok(bytes.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
fn read_envelopes(text: &str) -> Vec<Envelope> {
let mut skipped = 0;
let envelopes: Vec<Envelope> = text
.lines()
.filter(|line| !line.trim().is_empty())
.filter_map(|line| match serde_json::from_str::<Envelope>(line.trim()) {
Ok(envelope) => Some(envelope),
Err(_) => {
skipped += 1;
None
}
})
.collect();
report_skipped(skipped);
envelopes
}
fn interrupt_by_process(address: &TurnAddress, input: &str) -> Interrupt {
let output = Command::new(binary())
.arg("interrupt")
.arg(address.run())
.arg(address.member())
.arg("--input")
.arg(input)
.stdin(Stdio::null())
.output();
let output = match output {
Ok(output) => output,
Err(error) => {
return Interrupt {
outcome: Interrupted::Failed(format!(
"cannot start `{} interrupt`: {error}",
binary()
)),
events: Vec::new(),
}
} };
let events = read_envelopes(&String::from_utf8_lossy(&output.stdout));
let reason = || {
events
.iter()
.rev()
.find_map(|event| event.payload.get("reason").and_then(|v| v.as_str()))
.map(str::to_string)
.unwrap_or_else(|| {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
if stderr.is_empty() {
"it said nothing".to_string()
} else {
stderr
}
})
};
let outcome = match output.status.code() {
Some(oneagentgraph::error::EXIT_SUCCESS) => Interrupted::Delivered,
Some(oneagentgraph::error::EXIT_NO_CONTROLLABLE_TURN) => Interrupted::NoTurn(reason()),
_ => Interrupted::Failed(format!(
"`{} interrupt {} {}` {}: {}",
binary(),
address.run(),
address.member(),
ended(&output.status),
reason()
)),
};
Interrupt { outcome, events }
}
pub fn health() -> Option<String> {
if std::env::var_os(BINARY_ENV).is_some() {
let output = Command::new(binary())
.arg("health")
.stdin(Stdio::null())
.output()
.ok()?;
if !output.status.success() {
return None;
}
let text = String::from_utf8_lossy(&output.stdout).trim().to_string();
return (!text.is_empty()).then_some(text);
}
oneagentgraph::health::read()
.ok()
.and_then(|report| serde_json::to_string_pretty(&report).ok())
}
#[cfg(test)]
mod tests {
use super::*;
fn state_dir_holding(run: &str, members: &[&str]) -> PathBuf {
static NTH: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
let root = std::env::temp_dir().join(format!(
"op-graphstate-{}-{}",
std::process::id(),
NTH.fetch_add(1, Ordering::SeqCst)
));
let run_id = oneagentgraph::run::RunId::parse(run).expect("a run id the sibling accepts");
let dir = root.join(run_id.as_str());
std::fs::create_dir_all(&dir).expect("a run directory");
let record = oneagentgraph::run::Record {
schema_version: oneagentgraph::run::RECORD_SCHEMA_VERSION,
run_id,
graph: "node-scope.yaml".into(),
name: "node-scope".into(),
started_ms: 1_786_304_152_340,
finished_ms: None,
exit_code: None,
members: std::collections::BTreeMap::new(),
declared_members: members.iter().map(|m| (*m).to_string()).collect(),
refs: Vec::new(),
events_path: dir
.join(oneagentgraph::run::EVENTS_FILE)
.display()
.to_string(),
};
std::fs::write(
dir.join(oneagentgraph::run::RECORD_FILE),
serde_json::to_string(&record).expect("the sibling's record serialises"),
)
.expect("the run record is written");
std::env::set_var(STATE_DIR_ENV, &root);
std::env::remove_var(BINARY_ENV);
root
}
#[test]
fn a_graphs_env_block_is_exported_into_this_process_and_not_into_the_run_alone() {
let root = state_dir_holding("node-scope-1786304152340-30", &["worker"]);
let probe = "ONEPIPELINE_GRAPH_ENV_PROBE";
std::env::remove_var(probe);
std::env::set_var(ONEHARNESS_BIN_ENV, "oneharness-that-is-not-installed");
std::fs::write(
root.join("oneharness.toml"),
"run_mode = \"fallback\"\nharnesses = [\"claude-code\"]\n",
)
.expect("the harness config is written");
let graph = root.join("exports.yaml");
std::fs::write(
&graph,
format!(
"version: 1\nname: exports\nenv:\n {probe}: \"from the graph\"\nmembers:\n \
worker:\n kind: oneharness\n oneharness_config: ./oneharness.toml\n"
),
)
.expect("the graph config is written");
let started = GraphRun::start(&Launch {
graph: &graph.to_string_lossy(),
task: "## What\nNothing.\n\n## Why\nThe export is the subject.\n\n## Acceptance \
criteria\n- None.",
dir: &root,
labels: &Labels::default(),
env: &[],
environment: Environment::Shared,
sets: &[],
filter: None,
output: GraphOutput::Relayed,
});
if let Ok(mut run) = started {
run.cancel();
let _ = run.wait();
}
assert_eq!(
std::env::var(probe).ok().as_deref(),
Some("from the graph"),
"the graph's env block did not reach this process — if upstream has confined it to \
the run, this test has done its job and the concurrency note above is stale"
);
std::env::remove_var(probe);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn the_linked_oneagentgraph_produces_the_session_conversation_this_crate_relays() {
let run_id =
oneagentgraph::run::RunId::parse("node-scope-1786304152340-19").expect("a run id");
let [envelope] = &published(&run_id, "worker", 12, None)[..] else {
panic!("an interrupt publishes exactly one envelope");
};
let conversation = format!("{}.worker", envelope.stream);
assert_eq!(
envelope
.labels
.extra
.get("session")
.and_then(serde_json::Value::as_str),
Some(conversation.as_str()),
"the linked oneagentgraph stamps no `session` on a turn it names: the \
session-conversation producer ships in 0.3.3, and `Cargo.toml` requires the \
newest release, which is above that floor — so `Cargo.lock` is behind the \
manifest too and `cargo update -p oneagentgraph` is the whole of the fix"
);
assert!(
serde_json::from_value::<oneagentgraph::event::EventKind>(serde_json::Value::String(
"oneharness-session".to_string()
))
.is_ok(),
"the linked oneagentgraph does not know the `oneharness-session` kind, so no run \
this engine drives can say where an agent's conversation was written down: that \
event ships in 0.3.3 and `Cargo.lock` predates it — `cargo update -p \
oneagentgraph`"
);
}
#[test]
fn the_linked_oneagentgraph_produces_the_whole_turn_this_crate_relays() {
const MOVE_THE_LOCK: &str = "`Cargo.toml` requires the newest release, which is \
above this floor, so a resolution that fails here is behind the manifest too and \
`cargo update -p oneagentgraph` is the whole of the fix; `just engines-current` \
names it without running the suite";
assert!(
serde_json::from_value::<oneagentgraph::event::EventKind>(serde_json::Value::String(
"turn-message".to_string()
))
.is_ok(),
"the linked oneagentgraph does not know the `turn-message` kind, so no dispatch \
this engine drives relays a word any party said while it was saying it: that kind \
ships in 0.3.6 and the resolution predates it. {MOVE_THE_LOCK}"
);
serde_json::from_value::<oneagentgraph::event::TurnActivity>(serde_json::json!({
"kind": "tool_result",
"name": null,
"detail": "",
"output": "ok",
"tool_call_id": "toolu_1",
"index": 1,
}))
.unwrap_or_else(|error| {
panic!(
"the linked oneagentgraph has no reading of the observation that answered a \
tool call, so a relayed turn carries what the agent asked for and never what \
came back: {error}. {MOVE_THE_LOCK}"
)
});
serde_json::from_value::<oneagentgraph::event::TurnCompleted>(serde_json::json!({
"turn": 1,
"role": "assistant",
"usage": {"input_tokens": 1, "output_tokens": 1, "cost_usd": 0.0},
"started_at": "2026-08-21T09:15:02.847Z",
"finished_at": "2026-08-21T09:15:04.912Z",
}))
.unwrap_or_else(|error| {
panic!(
"the linked oneagentgraph does not close a turn on that turn's own account, so \
a run this engine drives cannot say which turn spent what: {error}. \
{MOVE_THE_LOCK}"
)
});
}
#[test]
fn the_linked_onevcs_reads_the_release_declaration_schema_this_repository_writes() {
use onevcs::declaration::{FILE, SCHEMA_VERSION};
fn declared_with(find: &str, replace: &str) -> String {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(FILE);
let document = std::fs::read_to_string(&path)
.unwrap_or_else(|error| panic!("{FILE} is checked in at this root: {error}"));
assert_eq!(
document.matches(find).count(),
1,
"{FILE} no longer carries `{find}` exactly once, so this mutates nothing"
);
document.replace(find, replace)
}
const MOVE_THE_LOCK: &str = "`Cargo.toml` requires the newest release, which is \
above this floor, so a resolution that fails here is behind the manifest too and \
`cargo update -p onevcs` is the whole of the fix; `just engines-current` names \
it without running the suite";
let scoped = declared_with(
"id = \"npm:onepipeline-cli\"",
"id = \"npm:@onepipeline/cli\"",
);
let read = onevcs::validate_release_declaration(&scoped, FILE).unwrap_or_else(|error| {
panic!(
"the linked onevcs refuses `npm:@onepipeline/cli`, a name npm genuinely \
serves, so a producer here cannot declare a scoped package it publishes: \
{error}. {MOVE_THE_LOCK}"
)
});
assert!(
read.targets
.iter()
.any(|target| target.id.name() == "@onepipeline/cli"),
"the scoped identifier was read as some other name, so what a consumer would wait \
on is not what the document declared"
);
assert_eq!(
SCHEMA_VERSION, 3,
"the linked onevcs writes a release-declaration schema this repository's own \
document is not written against. {MOVE_THE_LOCK}"
);
let older = declared_with("\nschema_version = 3\n", "\nschema_version = 1\n");
let read = onevcs::validate_release_declaration(&older, FILE).unwrap_or_else(|error| {
panic!(
"the linked onevcs refuses a schema_version 1 declaration, so a consumer \
reading the repositories that have not moved theirs learns nothing about \
what they publish: {error}"
)
});
assert_eq!(read.schema_version, 1);
}
fn shipped_engineer_stance() -> String {
let document = oneagentgraph::persona::shipped("engineer")
.expect("the linked oneagentgraph ships the role this crate dispatches under");
let persona = oneagentgraph::persona::Persona::parse(document, "engineer")
.expect("the shipped engineer role loads");
let effective = oneagentgraph::persona::merge("{}\n", "an empty base config", &persona)
.expect("the shipped engineer role layers onto a base config");
effective["user"]["persona"]
.as_str()
.expect("the engineer role hands its judge a stance")
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
#[test]
fn the_linked_oneagentgraph_holds_the_engineer_bar_to_what_a_dispatch_can_prove() {
let stance = shipped_engineer_stance();
assert!(
!stance.contains("proven end to end"),
"the linked oneagentgraph's `engineer` bar still refuses to accept work until it is \
proven end to end, which no dispatch can satisfy from inside its own run: the \
correction ships in 0.3.5, and `Cargo.toml` requires the newest release, which is \
above that floor — so `Cargo.lock` is behind the manifest too and \
`cargo update -p oneagentgraph` is the whole of the fix:\n{stance}"
);
for demand in [
"the task's acceptance criteria are met",
"proven at the level this run can reach",
"no regression is introduced in what it touched",
] {
assert!(
stance.contains(demand),
"the linked oneagentgraph's `engineer` bar no longer demands {demand:?}, so \
narrowing what it may ask for has softened what it must:\n{stance}"
);
}
}
#[test]
fn the_linked_oneagentgraph_releases_a_dispatch_only_on_a_blocker_it_cannot_retry_past() {
let stance = shipped_engineer_stance();
assert!(
!stance.contains(
"When the worker clearly reports a terminal blocker it cannot resolve within \
this run"
),
"the linked oneagentgraph's `engineer` bar still releases a dispatch on whatever the \
worker calls terminal, so a run this engine drives is settled `failed` on a timed \
out command or a tool call that would have answered on the retry: the definition \
ships in 0.3.14, and `Cargo.toml` requires the newest release, which is above that \
floor — so `Cargo.lock` is behind the manifest too and `cargo update -p \
oneagentgraph` is the whole of the fix:\n{stance}"
);
assert!(
stance.contains(
"A blocker is terminal only when nothing the worker can do inside this run would \
clear it"
),
"the linked oneagentgraph's `engineer` bar does not say what makes a blocker \
terminal, so nothing holds a judge to the distinction: `cargo update -p \
oneagentgraph`, which `just engines-current` names without running this \
suite:\n{stance}"
);
assert!(
stance.contains("anything else the worker could simply run again are not terminal"),
"the linked oneagentgraph's `engineer` bar defines a terminal blocker without ruling \
the retryable failures out of it, which is the half that costs a node its finished \
work:\n{stance}"
);
assert!(
stance.contains("Begin that verdict's reason with `terminal blocker reported:`"),
"the linked oneagentgraph's `engineer` bar no longer releases a genuinely blocked \
worker at all, so narrowing what counts as terminal has stranded the case it was \
narrowed around:\n{stance}"
);
}
#[test]
fn a_relayed_envelope_is_the_same_whether_it_crossed_as_a_value_or_as_a_line() {
let mut labels = oneagentgraph::event::Labels {
run_id: Some("node-scope-1786304152340-19".into()),
member: Some("worker".into()),
..oneagentgraph::event::Labels::default()
};
labels
.extra
.insert("onepipeline.node".into(), "build".into());
labels
.extra
.insert("onepipeline.step".into(), "implement".into());
let produced = oneagentgraph::event::Envelope {
v: 1,
ts: "2026-08-13T09:15:00.123Z".into(),
stream: "node-scope-1786304152340-19".into(),
seq: 7,
source: oneagentgraph::event::Source::Agentgraph,
kind: oneagentgraph::event::EventKind::TurnActivity,
labels,
payload: serde_json::Map::new(),
artifacts: Vec::new(),
};
let line = serde_json::to_string(&produced).expect("the sibling's envelope serialises");
let [off_the_wire] = &read_envelopes(&line)[..] else {
panic!("the sibling's own NDJSON did not read back as one envelope: {line}");
};
let mut off_the_wire = off_the_wire.clone();
adopt_labels(&mut off_the_wire.labels);
let in_process = relayed(produced).expect("the library path relays it");
assert_eq!(
in_process, off_the_wire,
"the same envelope reaches the merged stream differently depending on which path \
relayed it"
);
assert_eq!(in_process.labels.node.as_deref(), Some("build"));
assert_eq!(in_process.labels.step.as_deref(), Some("implement"));
}
#[test]
fn a_library_run_settles_on_graph_settled_without_waiting_for_channel_disconnect() {
let (sent, events) = std::sync::mpsc::channel();
sent.send(
serde_json::from_value::<oneagentgraph::event::Envelope>(serde_json::json!({
"v": 1,
"ts": "2026-08-25T00:00:00.000Z",
"stream": "node-scope-1",
"seq": 9,
"source": "agentgraph",
"kind": "graph-settled",
"labels": {},
"payload": {"exit_code": 0},
"artifacts": []
}))
.expect("the sibling's terminal envelope reads"),
)
.expect("the terminal event is sent");
let envelope = events.recv().expect("the terminal event arrives");
let settled = terminal_settlement(&envelope)
.expect("the terminal event settles the library run before teardown");
assert!(settled.succeeded());
assert!(
matches!(events.try_recv(), Err(std::sync::mpsc::TryRecvError::Empty)),
"the source disconnected, so the test did not exercise a final teardown still running"
);
drop(sent);
}
#[test]
fn a_reset_leaves_the_signal_the_run_watches_for() {
let root = state_dir_holding("node-scope-1786304152340-19", &[CHECK_IN_MEMBER]);
let graph_run = recorded_graph_run("node-scope-1786304152340-19", "demo")
.expect("the sibling accepts its own run id");
reset_timer(&graph_run, CHECK_IN_MEMBER)
.expect("the sibling accepts a reset for a member it declared");
assert!(
root.join("node-scope-1786304152340-19")
.join(oneagentgraph::run::SIGNAL_DIR)
.join(format!("{CHECK_IN_MEMBER}.reset"))
.is_file(),
"the reset left no signal where the run watches for one"
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_recorded_value_that_is_not_an_address_leaves_the_observer_watching() {
let root = state_dir_holding("dag-scope-1786304152340-19", &["monitor"]);
for recorded in [" ", "../elsewhere"] {
assert!(
!graph_run_ended(recorded, "demo"),
"'{recorded}' was read as a graph run that had ended"
);
}
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_recorded_graph_run_is_an_address_only_if_the_sibling_would_answer_to_it() {
assert_eq!(
recorded_graph_run("node-scope-1786304152340-19", "demo")
.expect("the sibling's own alphabet")
.as_str(),
"node-scope-1786304152340-19"
);
let absent = recorded_graph_run(" ", "demo").expect_err("no address at all");
assert!(
absent.to_string().contains("records no agent-graph run"),
"{absent} does not say the record named none"
);
for interfered in ["../elsewhere", "Node-Scope-1", "a/b"] {
let refused = recorded_graph_run(interfered, "demo")
.expect_err("a string the sibling would not answer to");
assert!(
refused.to_string().contains(interfered),
"{refused} does not name the value it refused"
);
}
}
#[test]
fn a_reset_for_a_member_the_run_never_declared_is_refused() {
let root = state_dir_holding("node-scope-1786304152340-20", &["worker"]);
let graph_run = recorded_graph_run("node-scope-1786304152340-20", "demo")
.expect("the sibling accepts its own run id");
let refused = reset_timer(&graph_run, CHECK_IN_MEMBER)
.expect_err("a member the run does not have is not resettable");
assert!(
refused.to_string().contains(CHECK_IN_MEMBER),
"{refused} does not name the member that could not be reset"
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn an_interrupt_against_a_run_that_is_not_there_is_a_failed_delivery_that_publishes_nothing() {
let root = state_dir_holding("node-scope-1786304152340-21", &["worker"]);
let interrupt = interrupt(
&TurnAddress::of("node-scope-1786304152340-99", "worker").expect("an address"),
"the fixture moved",
);
assert!(
matches!(&interrupt.outcome, Interrupted::Failed(reason)
if reason.contains("node-scope-1786304152340-99")),
"{:?} does not name the run that could not be reached",
interrupt.outcome
);
assert!(
interrupt.events.is_empty(),
"a delivery that was never addressed published an event anyway"
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn an_interrupt_with_no_turn_to_reach_still_publishes_what_the_lever_did() {
let root = state_dir_holding("node-scope-1786304152340-22", &["worker"]);
let interrupt = interrupt(
&TurnAddress::of("node-scope-1786304152340-22", "worker").expect("an address"),
"the fixture moved",
);
let Interrupted::NoTurn(reason) = &interrupt.outcome else {
panic!(
"{:?} is not the no-controllable-turn answer a member with no lever gives",
interrupt.outcome
);
};
assert!(!reason.is_empty(), "the answer carried no reason");
let [published] = &interrupt.events[..] else {
panic!(
"an interrupt published {} envelopes, not the one the contract names",
interrupt.events.len()
);
};
assert_eq!(published.kind.0, "turn-interrupted");
assert_eq!(published.payload["member"], serde_json::json!("worker"));
assert_eq!(published.payload["delivered"], serde_json::json!(false));
assert_eq!(
published.payload["input_bytes"],
serde_json::json!("the fixture moved".len())
);
assert_eq!(
published.payload["reason"],
serde_json::json!(reason),
"the envelope's reason and the answer's are the same fact"
);
assert_eq!(
published.labels.run_id.as_deref(),
Some("node-scope-1786304152340-22"),
"the envelope does not say which run's lever was pulled"
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn the_binary_comes_from_the_environment_or_falls_back() {
assert_eq!(
std::env::var(BINARY_ENV)
.ok()
.filter(|v| !v.is_empty())
.unwrap_or_else(|| DEFAULT_BINARY.to_string()),
binary()
);
}
#[test]
fn only_the_reserved_labels_the_contract_names_are_rendered_and_each_is_namespaced() {
let labels = Labels {
run_id: Some("demo".into()),
round: Some(2),
node: Some("build".into()),
step: Some("implement".into()),
persona: Some("engineer".into()),
extra: serde_json::Map::new(),
};
assert_eq!(
label_args(&labels),
vec![
"onepipeline.run_id=demo",
"onepipeline.node=build",
"onepipeline.step=implement",
"onepipeline.persona=engineer",
]
);
assert!(label_args(&Labels::default()).is_empty());
}
#[test]
fn every_label_this_crate_sends_is_one_oneagentgraph_accepts() {
let labels = Labels {
run_id: Some("demo".into()),
node: Some("build".into()),
step: Some("implement".into()),
persona: Some("engineer".into()),
..Labels::default()
};
for arg in label_args(&labels) {
let parsed = oneagentgraph::run::parse_label(&arg)
.unwrap_or_else(|error| panic!("oneagentgraph refuses `--label {arg}`: {error}"));
assert!(
parsed.key().starts_with(LABEL_PREFIX),
"{} escaped the namespace",
parsed.key()
);
}
}
#[test]
fn a_relayed_envelopes_namespaced_labels_are_adopted_without_rewriting_the_producers() {
let mut labels = Labels {
run_id: Some("node-scope-1786304152340-19".into()),
..Labels::default()
};
for (key, value) in [
("onepipeline.run_id", "demo"),
("onepipeline.round", "2"),
("onepipeline.node", "build"),
("onepipeline.step", "implement"),
("onepipeline.persona", "engineer"),
] {
labels.extra.insert(key.into(), value.into());
}
adopt_labels(&mut labels);
assert_eq!(
labels.run_id.as_deref(),
Some("node-scope-1786304152340-19"),
"the graph run's own id was overwritten"
);
assert_eq!(labels.node.as_deref(), Some("build"));
assert_eq!(labels.step.as_deref(), Some("implement"));
assert_eq!(labels.persona.as_deref(), Some("engineer"));
assert_eq!(
labels.extra["onepipeline.run_id"], "demo",
"the namespaced copy is what tells the two runs apart"
);
assert_eq!(labels.round, None, "a retired label was adopted");
assert_eq!(labels.extra["onepipeline.round"], "2");
}
#[test]
fn a_relayed_envelope_stamped_with_nothing_of_this_crates_is_left_as_it_arrived() {
let mut labels = Labels {
run_id: Some("elsewhere".into()),
..Labels::default()
};
labels.extra.insert("member".into(), "worker".into());
let untouched = labels.clone();
adopt_labels(&mut labels);
assert_eq!(labels, untouched);
}
#[test]
fn an_oneagentgraph_that_cannot_be_started_is_a_failed_delivery() {
std::env::set_var(BINARY_ENV, "oneagentgraph-that-is-not-installed");
let interrupt = interrupt(
&TurnAddress {
run: "node-scope-1".into(),
member: "worker".into(),
},
"the fixture moved",
);
assert!(
matches!(&interrupt.outcome, Interrupted::Failed(reason)
if reason.contains("oneagentgraph-that-is-not-installed")),
"{:?} does not name the binary that could not be started",
interrupt.outcome
);
assert!(
interrupt.events.is_empty(),
"a delivery nothing ran produced envelopes"
);
}
#[test]
fn a_settled_run_reports_only_a_zero_exit_as_success() {
assert!(Settled {
code: Some(0),
stderr: String::new()
}
.succeeded());
assert!(!Settled {
code: Some(1),
stderr: String::new()
}
.succeeded());
assert!(!Settled {
code: None,
stderr: String::new()
}
.succeeded());
}
}