use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use oneagentgraph::config::ConfigRef;
use onevcs::SessionRequest;
use crate::agentgraph::{GraphOutput, GraphRun, Launch};
use crate::controls::{NodeControls, WORKER_MEMBER};
use crate::error::{Error, Result};
use crate::event::{Envelope, Labels};
pub trait Executor {
fn name(&self) -> &str;
fn capabilities(&self) -> Capabilities;
fn capacity(&self) -> CapacityReport;
fn dispatch(&self, req: DispatchRequest) -> Result<Box<dyn DispatchHandle>>;
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
pub struct Capabilities {
pub vcs_sessions: bool,
}
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct CapacityReport {
pub slots_free: u32,
pub load1: f64,
pub mem_free_bytes: u64,
}
#[derive(Debug, Clone, PartialEq)]
pub struct DispatchRequest {
pub graph: ConfigRef,
pub task: String,
pub labels: Labels,
pub controls: NodeControls,
pub workspace: WorkspaceSpec,
pub cancel: CancellationToken,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WorkspaceSpec {
Path(PathBuf),
VcsSession(SessionRequest),
}
#[derive(Debug, Clone, Default)]
pub struct CancellationToken(Arc<AtomicBool>);
impl CancellationToken {
pub fn new() -> Self {
Self::default()
}
pub fn cancel(&self) {
self.0.store(true, Ordering::SeqCst);
}
pub fn is_cancelled(&self) -> bool {
self.0.load(Ordering::SeqCst)
}
}
impl PartialEq for CancellationToken {
fn eq(&self, other: &Self) -> bool {
self.is_cancelled() == other.is_cancelled()
}
}
pub trait DispatchHandle {
fn events(&mut self) -> EventStream;
fn wait(&mut self) -> Result<DispatchOutcome>;
fn cancel(&self, mode: CancelMode);
}
pub type EventStream = Box<dyn Iterator<Item = Result<Envelope>> + Send>;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum CancelMode {
Cooperative,
Kill,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
#[non_exhaustive]
pub struct DispatchOutcome {
pub succeeded: bool,
pub detail: String,
pub session: Option<String>,
pub branch: Option<String>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
pub struct LocalExecutor;
impl Executor for LocalExecutor {
fn name(&self) -> &str {
"local"
}
fn capabilities(&self) -> Capabilities {
Capabilities { vcs_sessions: true }
}
fn capacity(&self) -> CapacityReport {
let load1 = load_average().unwrap_or(0.0);
let cores = std::thread::available_parallelism()
.map(std::num::NonZeroUsize::get)
.unwrap_or(1);
let busy = load1.ceil().max(0.0);
let busy = if busy.is_finite() { busy as u64 } else { 0 };
CapacityReport {
slots_free: u32::try_from(u64::try_from(cores).unwrap_or(1).saturating_sub(busy))
.unwrap_or(u32::MAX),
load1,
mem_free_bytes: available_memory().unwrap_or(u64::MAX),
}
}
fn dispatch(&self, req: DispatchRequest) -> Result<Box<dyn DispatchHandle>> {
let (dir, session) = match &req.workspace {
WorkspaceSpec::Path(path) => (path.clone(), None),
WorkspaceSpec::VcsSession(request) => {
let session = crate::vcs::session_open(request)?;
(session.worktree.clone(), Some(session))
}
};
let node_sets = node_sets(&req.labels, &req.controls)?;
let filters = launched_with(&req.labels)?
.map(|record| record.filters)
.unwrap_or_default();
let run = GraphRun::start(&Launch {
graph: &req.graph.0,
task: &req.task,
dir: &dir,
labels: &req.labels,
env: &[],
sets: &node_sets,
filter: filters.agentgraph.as_ref(),
output: GraphOutput::Relayed,
})?;
Ok(Box::new(LocalDispatch {
run,
cancel: req.cancel,
labels: req.labels,
session,
}))
}
}
fn node_sets(labels: &Labels, controls: &NodeControls) -> Result<Vec<String>> {
let mut sets = launched_with(labels)?.map_or_else(Vec::new, |record| record.node_sets);
if let Some(persona) = &labels.persona {
sets.push(format!("members.{WORKER_MEMBER}.persona={persona}"));
}
sets.extend(controls.overrides().map_err(Error::Invalid)?);
Ok(sets)
}
fn launched_with(labels: &Labels) -> Result<Option<crate::ledger::LaunchRecord>> {
let Some(run) = labels.run_id.as_deref() else {
return Ok(None);
};
let paths = crate::ledger::RunPaths::under(&crate::ledger::runs_root(), run);
crate::ledger::read_json::<crate::ledger::LaunchRecord>(&paths.launch()).map(Some)
}
#[derive(Debug)]
struct LocalDispatch {
run: GraphRun,
cancel: CancellationToken,
labels: Labels,
session: Option<onevcs::Session>,
}
impl DispatchHandle for LocalDispatch {
fn events(&mut self) -> EventStream {
let opened = self.session.as_ref().map(|session| {
Ok(crate::vcs::session_opened_event(session, &self.labels))
});
match opened {
Some(event) => Box::new(std::iter::once(event).chain(self.run.events())),
None => self.run.events(),
}
}
fn wait(&mut self) -> Result<DispatchOutcome> {
let settled = self.run.wait()?;
Ok(DispatchOutcome {
succeeded: settled.succeeded(),
detail: settled.stderr.trim().to_string(),
session: self.session.as_ref().map(|s| s.token.0.clone()),
branch: self.session.as_ref().map(|s| s.branch.clone()),
})
}
fn cancel(&self, mode: CancelMode) {
self.cancel.cancel();
if mode == CancelMode::Kill {
self.run.cancel();
} }
}
fn load_average() -> Option<f64> {
let text = std::fs::read_to_string("/proc/loadavg").ok()?;
text.split_whitespace()
.next()?
.parse::<f64>()
.ok()
.filter(|value| value.is_finite() && *value >= 0.0)
}
fn available_memory() -> Option<u64> {
let text = std::fs::read_to_string("/proc/meminfo").ok()?;
for line in text.lines() {
if let Some(rest) = line.strip_prefix("MemAvailable:") {
let kib = rest.split_whitespace().next()?.parse::<u64>().ok()?;
return kib.checked_mul(1024);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_local_executor_is_named_and_capable_of_both_workspaces() {
let executor = LocalExecutor;
assert_eq!(executor.name(), "local");
assert!(executor.capabilities().vcs_sessions);
}
#[test]
fn the_capacity_probe_reports_finite_numbers_on_any_host() {
let report = LocalExecutor.capacity();
assert!(
report.load1.is_finite() && report.load1 >= 0.0,
"{report:?}"
);
assert!(report.mem_free_bytes > 0, "{report:?}");
}
#[test]
fn a_cancellation_signal_is_shared_between_the_two_sides() {
let token = CancellationToken::new();
let observer = token.clone();
assert!(!observer.is_cancelled());
token.cancel();
assert!(
observer.is_cancelled(),
"the signal did not reach the dispatch"
);
assert_eq!(token, observer);
assert_ne!(CancellationToken::new(), observer);
}
#[test]
fn a_dispatch_request_carries_both_siblings_types() {
let request = DispatchRequest {
graph: ConfigRef("./graphs/node-scope.yaml".into()),
task: "## What\ndo it".into(),
labels: Labels::default(),
controls: NodeControls::default(),
workspace: WorkspaceSpec::VcsSession(SessionRequest {
repo: "owner/repo".into(),
branch: None,
base: None,
execution_checkout: None,
}),
cancel: CancellationToken::new(),
};
assert!(matches!(request.workspace, WorkspaceSpec::VcsSession(_)));
assert_eq!(request.graph.0, "./graphs/node-scope.yaml");
}
#[test]
fn a_dispatch_with_no_run_still_carries_its_nodes_own_controls() {
let sets = node_sets(
&Labels {
persona: Some("engineer".into()),
..Labels::default()
},
&NodeControls {
max_turns: std::num::NonZeroU32::new(45),
},
)
.expect("both are appliable");
assert_eq!(
sets,
vec![
"members.worker.persona=engineer".to_string(),
"members.worker.max_turns=45".to_string(),
],
"the node's own control must apply after the run-wide ones"
);
}
}