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};
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 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
})
}
#[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: Child,
output: Output,
started_with: Vec<String>,
run_id: Option<GraphRunId>,
}
#[derive(Debug, Clone, Copy)]
pub enum GraphOutput<'a> {
Relayed,
Logged(&'a Path),
}
#[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 sets: &'a [String],
pub filter: Option<&'a EventFilter>,
pub output: GraphOutput<'a>,
}
#[derive(Debug)]
enum Output {
Relayed(Option<BufReader<std::process::ChildStdout>>),
Logged(PathBuf),
}
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 mut command = Command::new(std::env::current_exe().map_err(|e| {
sibling(format!(
"cannot find this executable to retain a driver: {e}"
))
})?);
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());
match output {
GraphOutput::Relayed => {
command.stdout(Stdio::piped()).stderr(Stdio::piped());
}
GraphOutput::Logged(path) => {
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 output = match output {
GraphOutput::Relayed => Output::Relayed(child.stdout.take().map(BufReader::new)),
GraphOutput::Logged(path) => Output::Logged(path.to_path_buf()),
};
Ok(Self {
child,
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);
self.output = Output::Relayed(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(());
}
match self.child.try_wait() {
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) = &self.output else {
return None;
};
let text = std::fs::read_to_string(path).ok()?;
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 {
match self.child.try_wait() {
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 _ = self.child.kill();
let _ = self.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) => std::fs::read_to_string(path).unwrap_or_default(),
Output::Relayed(_) => self
.child
.stderr
.take()
.map(|mut pipe| {
use std::io::Read;
let mut text = String::new();
let _ = pipe.read_to_string(&mut text);
text
})
.unwrap_or_default(),
};
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(stdout.lines())
.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 = self
.child
.wait()
.map_err(|e| sibling(format!("waiting for `{} run`: {e}", binary())))?;
let stderr = self
.child
.stderr
.take()
.map(|mut pipe| {
use std::io::Read;
let mut text = String::new();
let _ = pipe.read_to_string(&mut text);
text
})
.unwrap_or_default();
Ok(Settled {
code: status.code(),
stderr,
})
}
pub fn pid(&self) -> u32 {
self.child.id()
}
pub fn run_id(&self) -> Option<&GraphRunId> {
self.run_id.as_ref()
}
pub fn has_exited(&mut self) -> bool {
matches!(self.child.try_wait(), Ok(Some(_)))
}
}
fn export(env: &[(String, String)]) {
for (key, value) in env {
std::env::set_var(key, value);
}
}
impl GraphRun {
pub fn start(launch: &Launch<'_>) -> Result<Self> {
if matches!(launch.output, GraphOutput::Logged(_)) || 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 || {
loop {
if cancel_rx.try_recv().is_ok() {
let _ = running.cancel();
}
match running.recv_timeout(Duration::from_millis(10)) {
Ok(Some(envelope)) => {
if events_tx.send(relayed(envelope)).is_err() {
let _ = running.cancel();
break;
}
}
Ok(None) | Err(mpsc::RecvTimeoutError::Timeout) => {}
Err(mpsc::RecvTimeoutError::Disconnected) => break,
}
}
let settled = Ok(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(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),
}
}
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: &[],
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 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_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_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());
}
}