Rename remaining attractor references to arc

- AttractorError → ArcError across 30 source files
- .attractor/ → .arc/ for artifacts and skills paths
- ATTRACTOR_NODE_ID → ARC_NODE_ID env var
- attractor-rust → arc in Cargo.toml repository URLs
- attractor-spec.md → arc-spec.md with content updates
- Update server banner, test comments, README examples, and docs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-01 12:11:24 -05:00
parent 99ad7ffe29
commit 8aa56285f4
43 changed files with 909 additions and 264 deletions

View file

@ -4,7 +4,7 @@ edition.workspace = true
version.workspace = true
license.workspace = true
description = "A programmable agentic loop for coding agents"
repository = "https://github.com/brynary/attractor-rust"
repository = "https://github.com/brynary/arc"
readme = "README.md"
keywords = ["llm", "ai", "agent", "coding"]
categories = ["api-bindings"]

View file

@ -209,11 +209,11 @@ pub fn default_skill_dirs(home_dir: Option<&str>, git_root: Option<&str>) -> Vec
let mut dirs = Vec::new();
if let Some(home) = home_dir {
dirs.push(format!("{home}/.attractor/skills"));
dirs.push(format!("{home}/.arc/skills"));
}
if let Some(root) = git_root {
dirs.push(format!("{root}/.attractor/skills"));
dirs.push(format!("{root}/.arc/skills"));
dirs.push(format!("{root}/skills"));
}
@ -521,8 +521,8 @@ name: trimmed
assert_eq!(
dirs,
vec![
"/home/user/.attractor/skills",
"/repo/.attractor/skills",
"/home/user/.arc/skills",
"/repo/.arc/skills",
"/repo/skills",
]
);
@ -531,7 +531,7 @@ name: trimmed
#[test]
fn default_dirs_without_git_root() {
let dirs = default_skill_dirs(Some("/home/user"), None);
assert_eq!(dirs, vec!["/home/user/.attractor/skills"]);
assert_eq!(dirs, vec!["/home/user/.arc/skills"]);
}
// --- make_use_skill_tool tests ---

View file

@ -116,7 +116,7 @@ pub async fn serve_command(args: ServeArgs, styles: &'static Styles) -> anyhow::
let listener = TcpListener::bind(&addr).await?;
eprintln!(
"{bold}Attractor server listening on {green}{addr}{reset}",
"{bold}Arc server listening on {green}{addr}{reset}",
bold = styles.bold,
green = styles.green,
reset = styles.reset,

View file

@ -245,7 +245,7 @@ async fn start_pipeline(
Ok(_) => {
pipeline.status = PipelineStatus::Completed;
}
Err(arc_workflows::error::AttractorError::Cancelled) => {
Err(arc_workflows::error::ArcError::Cancelled) => {
pipeline.status = PipelineStatus::Cancelled;
}
Err(e) => {

View file

@ -328,7 +328,7 @@ fn agent_invalid_permissions_value() {
.stderr(predicate::str::contains("invalid value"));
}
// == Attractor: validate ======================================================
// == Arc: validate ======================================================
#[test]
fn validate_simple() {
@ -392,7 +392,7 @@ fn validate_invalid() {
.failure();
}
// == Attractor: serve =========================================================
// == Arc: serve =========================================================
#[test]
fn serve_help() {
@ -407,7 +407,7 @@ fn serve_help() {
.stdout(predicate::str::contains("--provider"));
}
// == Attractor: run --dry-run =================================================
// == Arc: run --dry-run =================================================
#[test]
fn dry_run_simple() {

View file

@ -4,7 +4,7 @@ edition.workspace = true
version.workspace = true
license.workspace = true
description = "Store structured data in git without touching the working directory"
repository = "https://github.com/brynary/attractor-rust"
repository = "https://github.com/brynary/arc"
[lib]
doctest = false

View file

@ -4,7 +4,7 @@ edition.workspace = true
version.workspace = true
license.workspace = true
description = "A unified client library for multiple LLM providers"
repository = "https://github.com/brynary/attractor-rust"
repository = "https://github.com/brynary/arc"
readme = "README.md"
keywords = ["llm", "ai", "openai", "anthropic"]
categories = ["api-bindings"]

View file

@ -4,7 +4,7 @@ edition.workspace = true
version.workspace = true
license.workspace = true
description = "A DOT-based pipeline runner for multi-stage AI workflows"
repository = "https://github.com/brynary/attractor-rust"
repository = "https://github.com/brynary/arc"
keywords = ["llm", "ai", "pipeline", "workflow", "dot"]
categories = ["development-tools"]
readme = "README.md"

View file

@ -1,4 +1,4 @@
# attractor
# arc-workflows
A DOT-based pipeline runner for multi-stage AI workflows. Define workflows as Graphviz `digraph` files and execute them with pluggable handlers, conditional routing, human-in-the-loop gates, parallel branching, retry policies, and checkpoint-based recovery.
@ -41,7 +41,7 @@ digraph MyPipeline {
### Parsing and Validating a Pipeline
```rust
use attractor::pipeline::prepare_pipeline;
use arc_workflows::pipeline::prepare_pipeline;
let dot_source = r#"digraph Simple {
graph [goal="Run tests"]
@ -62,13 +62,13 @@ assert_eq!(graph.goal(), "Run tests");
### Running a Pipeline
```rust
use attractor::engine::{PipelineEngine, RunConfig};
use attractor::event::EventEmitter;
use attractor::handler::HandlerRegistry;
use attractor::handler::start::StartHandler;
use attractor::handler::exit::ExitHandler;
use attractor::handler::codergen::CodergenHandler;
use attractor::pipeline::prepare_pipeline;
use arc_workflows::engine::{PipelineEngine, RunConfig};
use arc_workflows::event::EventEmitter;
use arc_workflows::handler::HandlerRegistry;
use arc_workflows::handler::start::StartHandler;
use arc_workflows::handler::exit::ExitHandler;
use arc_workflows::handler::codergen::CodergenHandler;
use arc_workflows::pipeline::prepare_pipeline;
let graph = prepare_pipeline(dot_source).unwrap();
@ -90,11 +90,11 @@ let config = RunConfig {
Implement the `Handler` trait to add custom node behavior:
```rust
use attractor::handler::Handler;
use attractor::context::Context;
use attractor::graph::{Graph, Node};
use attractor::outcome::Outcome;
use attractor::error::AttractorError;
use arc_workflows::handler::Handler;
use arc_workflows::context::Context;
use arc_workflows::graph::{Graph, Node};
use arc_workflows::outcome::Outcome;
use arc_workflows::error::ArcError;
use async_trait::async_trait;
use std::path::Path;
@ -108,7 +108,7 @@ impl Handler for MyHandler {
context: &Context,
graph: &Graph,
logs_root: &Path,
) -> Result<Outcome, AttractorError> {
) -> Result<Outcome, ArcError> {
// Custom logic here
Ok(Outcome::success())
}

View file

@ -8,7 +8,7 @@ use serde_json::Value;
use arc_agent::ExecutionEnvironment;
use crate::error::{AttractorError, Result};
use crate::error::{ArcError, Result};
/// Threshold above which artifacts are stored on disk instead of in memory (100KB).
const FILE_BACKING_THRESHOLD: usize = 100 * 1024;
@ -66,7 +66,7 @@ impl ArtifactStore {
let id = id.into();
let name = name.into();
let serialized = serde_json::to_string(&data)
.map_err(|e| AttractorError::Engine(format!("artifact serialize failed: {e}")))?;
.map_err(|e| ArcError::Engine(format!("artifact serialize failed: {e}")))?;
let size_bytes = serialized.len();
let is_file_backed = size_bytes > FILE_BACKING_THRESHOLD && self.base_dir.is_some();
@ -112,7 +112,7 @@ impl ArtifactStore {
let guard = self.artifacts.read().expect("artifact lock poisoned");
let (_, stored) = guard
.get(id)
.ok_or_else(|| AttractorError::Engine(format!("artifact not found: {id}")))?;
.ok_or_else(|| ArcError::Engine(format!("artifact not found: {id}")))?;
match stored {
StoredData::InMemory(v) => Ok(v.clone()),
@ -120,12 +120,12 @@ impl ArtifactStore {
let path = path.clone();
drop(guard);
let data = std::fs::read_to_string(&path).map_err(|e| {
AttractorError::Engine(format!(
ArcError::Engine(format!(
"failed to read file-backed artifact {id}: {e}"
))
})?;
serde_json::from_str(&data).map_err(|e| {
AttractorError::Engine(format!(
ArcError::Engine(format!(
"failed to deserialize file-backed artifact {id}: {e}"
))
})
@ -254,7 +254,7 @@ pub fn format_artifact_reference(path: &str) -> String {
///
/// For each `file://` pointer in `updates`, checks whether the file is accessible
/// in `env`. If not, reads the local file and uploads it via `env.write_file`,
/// placing it at `{working_directory}/.attractor/artifacts/{filename}`. The pointer
/// placing it at `{working_directory}/.arc/artifacts/{filename}`. The pointer
/// is rewritten to reference the remote path.
///
/// # Errors
@ -274,14 +274,14 @@ pub async fn sync_artifacts_to_env(
Ok(true) => continue,
Ok(false) => {}
Err(e) => {
return Err(AttractorError::Engine(format!(
return Err(ArcError::Engine(format!(
"failed to check artifact existence: {e}"
)));
}
}
let content = std::fs::read_to_string(&local_path).map_err(|e| {
AttractorError::Engine(format!("failed to read local artifact {local_path}: {e}"))
ArcError::Engine(format!("failed to read local artifact {local_path}: {e}"))
})?;
let filename = std::path::Path::new(&local_path)
@ -290,12 +290,12 @@ pub async fn sync_artifacts_to_env(
.unwrap_or("artifact.json");
let remote_path = format!(
"{}/.attractor/artifacts/{filename}",
"{}/.arc/artifacts/{filename}",
env.working_directory()
);
env.write_file(&remote_path, &content).await.map_err(|e| {
AttractorError::Engine(format!("failed to write artifact to remote env: {e}"))
ArcError::Engine(format!("failed to write artifact to remote env: {e}"))
})?;
*value = Value::String(format!("{ARTIFACT_POINTER_PREFIX}{remote_path}"));
@ -592,14 +592,14 @@ mod tests {
assert_eq!(written.len(), 1);
assert_eq!(
written[0].0,
"/workspace/.attractor/artifacts/response.plan.json"
"/workspace/.arc/artifacts/response.plan.json"
);
assert_eq!(written[0].1, r#""hello from artifact""#);
let new_pointer = updates["response.plan"].as_str().unwrap();
assert_eq!(
new_pointer,
"file:///workspace/.attractor/artifacts/response.plan.json"
"file:///workspace/.arc/artifacts/response.plan.json"
);
}

View file

@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::context::Context;
use crate::error::{AttractorError, Result};
use crate::error::{ArcError, Result};
use crate::outcome::Outcome;
/// Serializable snapshot of execution state for crash recovery and resume.
@ -59,7 +59,7 @@ impl Checkpoint {
/// Returns an error if serialization or file writing fails.
pub fn save(&self, path: &Path) -> Result<()> {
let json = serde_json::to_string_pretty(self)
.map_err(|e| AttractorError::Checkpoint(format!("serialize failed: {e}")))?;
.map_err(|e| ArcError::Checkpoint(format!("serialize failed: {e}")))?;
std::fs::write(path, json)?;
Ok(())
}
@ -72,7 +72,7 @@ impl Checkpoint {
pub fn load(path: &Path) -> Result<Self> {
let data = std::fs::read_to_string(path)?;
let checkpoint: Self = serde_json::from_str(&data)
.map_err(|e| AttractorError::Checkpoint(format!("deserialize failed: {e}")))?;
.map_err(|e| ArcError::Checkpoint(format!("deserialize failed: {e}")))?;
Ok(checkpoint)
}
}

View file

@ -13,7 +13,7 @@ use arc_llm::provider::Provider;
use arc_util::terminal::Styles;
use crate::context::Context;
use crate::error::AttractorError;
use crate::error::ArcError;
use crate::graph::Node;
use crate::handler::codergen::{CodergenBackend, CodergenResult};
use crate::outcome::StageUsage;
@ -51,10 +51,10 @@ impl AgentBackend {
&self,
node: &Node,
execution_env: &Arc<dyn ExecutionEnvironment>,
) -> Result<Session, AttractorError> {
) -> Result<Session, ArcError> {
let client = Client::from_env()
.await
.map_err(|e| AttractorError::Handler(format!("Failed to create LLM client: {e}")))?;
.map_err(|e| ArcError::Handler(format!("Failed to create LLM client: {e}")))?;
let mut profile = self.build_profile();
@ -120,10 +120,10 @@ impl CodergenBackend for AgentBackend {
node: &Node,
prompt: &str,
stage_dir: &std::path::Path,
) -> Result<CodergenResult, AttractorError> {
) -> Result<CodergenResult, ArcError> {
let client = Client::from_env()
.await
.map_err(|e| AttractorError::Handler(format!("Failed to create LLM client: {e}")))?;
.map_err(|e| ArcError::Handler(format!("Failed to create LLM client: {e}")))?;
let model = node.llm_model().unwrap_or(&self.model);
let provider = node
@ -157,7 +157,7 @@ impl CodergenBackend for AgentBackend {
let response = client
.complete(&request)
.await
.map_err(|e| AttractorError::Handler(format!("one_shot LLM call failed: {e}")))?;
.map_err(ArcError::Llm)?;
if let Ok(json) = serde_json::to_string_pretty(&response) {
let _ = tokio::fs::write(stage_dir.join("api_response.json"), json).await;
@ -199,7 +199,7 @@ impl CodergenBackend for AgentBackend {
emitter: &Arc<crate::event::EventEmitter>,
stage_dir: &std::path::Path,
execution_env: &Arc<dyn ExecutionEnvironment>,
) -> Result<CodergenResult, AttractorError> {
) -> Result<CodergenResult, ArcError> {
let fidelity = context.get_string("internal.fidelity", "");
let reuse_key = if fidelity == "full" {
thread_id.map(String::from)
@ -344,7 +344,12 @@ impl CodergenBackend for AgentBackend {
}
let result = session.process_input(prompt).await.map_err(|e| {
AttractorError::Handler(format!("Agent session failed: {e}"))
use arc_agent::AgentError;
match e {
AgentError::Llm(sdk_err) => ArcError::Llm(sdk_err),
AgentError::Aborted => ArcError::Cancelled,
other => ArcError::Handler(format!("Agent session failed: {other}")),
}
});
// On error, drop the session (don't cache failed state).

View file

@ -6,7 +6,7 @@ use async_trait::async_trait;
use arc_llm::provider::Provider;
use crate::context::Context;
use crate::error::AttractorError;
use crate::error::ArcError;
use crate::event::EventEmitter;
use crate::graph::Node;
use crate::handler::codergen::{CodergenBackend, CodergenResult};
@ -260,16 +260,16 @@ impl CodergenBackend for CliBackend {
_emitter: &Arc<EventEmitter>,
stage_dir: &Path,
execution_env: &Arc<dyn ExecutionEnvironment>,
) -> Result<CodergenResult, AttractorError> {
) -> Result<CodergenResult, ArcError> {
// 1. Snapshot git state before the CLI run
let files_before = self.detect_changed_files(execution_env).await;
// 2. Write prompt to temp file
let prompt_path = "/tmp/attractor_cli_prompt.txt";
let prompt_path = "/tmp/arc_cli_prompt.txt";
execution_env
.write_file(prompt_path, prompt)
.await
.map_err(|e| AttractorError::Handler(format!("Failed to write prompt file: {e}")))?;
.map_err(|e| ArcError::Handler(format!("Failed to write prompt file: {e}")))?;
// 3. Build and execute CLI command
let model = node.llm_model().unwrap_or(&self.model);
@ -293,7 +293,7 @@ impl CodergenBackend for CliBackend {
let result = execution_env
.exec_command(&command, 600_000, None, None, None)
.await
.map_err(|e| AttractorError::Handler(format!("CLI command failed: {e}")))?;
.map_err(|e| ArcError::Handler(format!("CLI command failed: {e}")))?;
if let Ok(json) = serde_json::to_string_pretty(&serde_json::json!({
"exit_code": result.exit_code,
@ -305,7 +305,7 @@ impl CodergenBackend for CliBackend {
}
if result.exit_code != 0 {
return Err(AttractorError::Handler(format!(
return Err(ArcError::Handler(format!(
"CLI command exited with code {}: {}",
result.exit_code,
result.stderr.chars().take(500).collect::<String>()
@ -314,7 +314,7 @@ impl CodergenBackend for CliBackend {
// 4. Parse the CLI output
let parsed = parse_cli_response(provider, &result.stdout).ok_or_else(|| {
AttractorError::Handler("Failed to parse CLI output".to_string())
ArcError::Handler("Failed to parse CLI output".to_string())
})?;
// 5. Detect changed files
@ -387,7 +387,7 @@ impl CodergenBackend for BackendRouter {
emitter: &Arc<EventEmitter>,
stage_dir: &Path,
execution_env: &Arc<dyn ExecutionEnvironment>,
) -> Result<CodergenResult, AttractorError> {
) -> Result<CodergenResult, ArcError> {
if self.should_use_cli(node) {
self.cli_backend
.run(node, prompt, context, thread_id, emitter, stage_dir, execution_env)
@ -404,7 +404,7 @@ impl CodergenBackend for BackendRouter {
node: &Node,
prompt: &str,
stage_dir: &Path,
) -> Result<CodergenResult, AttractorError> {
) -> Result<CodergenResult, ArcError> {
// CLI backend doesn't support one_shot, always route to API
self.api_backend.one_shot(node, prompt, stage_dir).await
}
@ -619,7 +619,7 @@ mod tests {
_emitter: &Arc<EventEmitter>,
_stage_dir: &Path,
_execution_env: &Arc<dyn ExecutionEnvironment>,
) -> Result<CodergenResult, AttractorError> {
) -> Result<CodergenResult, ArcError> {
Ok(CodergenResult::Text {
text: "stub".to_string(),
usage: None,

View file

@ -126,7 +126,7 @@ pub async fn run_command(args: RunArgs, styles: &'static Styles) -> anyhow::Resu
let logs_dir = args.logs_dir.unwrap_or_else(|| {
let base = dirs::home_dir()
.expect("could not determine home directory")
.join(".attractor")
.join(".arc")
.join("logs");
base.join(format!(
"arc-run-{}",
@ -732,7 +732,7 @@ async fn run_from_branch(args: RunArgs, run_branch: &str, styles: &'static Style
let logs_dir = args.logs_dir.unwrap_or_else(|| {
let base = dirs::home_dir()
.expect("could not determine home directory")
.join(".attractor")
.join(".arc")
.join("logs");
base.join(format!(
"arc-resume-{}",

View file

@ -244,7 +244,7 @@ environment = "daytona"
auto_stop_interval = 60
[execution.daytona.sandbox.labels]
project = "attractor"
project = "arc"
[execution.daytona.snapshot]
name = "my-snapshot"
@ -260,7 +260,7 @@ dockerfile = "FROM rust:1.85-slim-bookworm\nRUN apt-get update"
let daytona = execution.daytona.unwrap();
assert_eq!(daytona.sandbox.auto_stop_interval, Some(60));
let labels = daytona.sandbox.labels.unwrap();
assert_eq!(labels["project"], "attractor");
assert_eq!(labels["project"], "arc");
let snapshot = daytona.snapshot.unwrap();
assert_eq!(snapshot.name, "my-snapshot");

View file

@ -3,7 +3,7 @@
/// Grammar: `ConditionExpr ::= Clause ('&&' Clause)*`, `Clause ::= Key Op Literal`,
/// `Op ::= '=' | '!='`.
use crate::context::Context;
use crate::error::AttractorError;
use crate::error::ArcError;
use crate::outcome::Outcome;
#[derive(Debug, Clone, PartialEq, Eq)]
@ -20,7 +20,7 @@ enum Op {
Truthy,
}
fn parse_clauses(expr: &str) -> Result<Vec<Clause>, AttractorError> {
fn parse_clauses(expr: &str) -> Result<Vec<Clause>, ArcError> {
let expr = expr.trim();
if expr.is_empty() {
return Ok(Vec::new());
@ -34,7 +34,7 @@ fn parse_clauses(expr: &str) -> Result<Vec<Clause>, AttractorError> {
let key = part[..pos].trim().to_string();
let value = part[pos + 2..].trim().to_string();
if key.is_empty() {
return Err(AttractorError::Parse(format!(
return Err(ArcError::Parse(format!(
"empty key in condition clause: {part:?}"
)));
}
@ -47,7 +47,7 @@ fn parse_clauses(expr: &str) -> Result<Vec<Clause>, AttractorError> {
let key = part[..pos].trim().to_string();
let value = part[pos + 1..].trim().to_string();
if key.is_empty() {
return Err(AttractorError::Parse(format!(
return Err(ArcError::Parse(format!(
"empty key in condition clause: {part:?}"
)));
}
@ -60,7 +60,7 @@ fn parse_clauses(expr: &str) -> Result<Vec<Clause>, AttractorError> {
// Bare key: truthiness check
let key = part.to_string();
if key.is_empty() {
return Err(AttractorError::Parse(format!(
return Err(ArcError::Parse(format!(
"empty key in condition clause: {part:?}"
)));
}
@ -79,7 +79,7 @@ fn parse_clauses(expr: &str) -> Result<Vec<Clause>, AttractorError> {
/// # Errors
///
/// Returns an error if the expression contains invalid syntax.
pub fn parse_condition(expr: &str) -> Result<(), AttractorError> {
pub fn parse_condition(expr: &str) -> Result<(), ArcError> {
parse_clauses(expr)?;
Ok(())
}
@ -323,4 +323,45 @@ mod tests {
&context
));
}
#[test]
fn context_failure_class_matches_when_set() {
let outcome = make_outcome(StageStatus::Fail);
let context = Context::new();
context.set("failure_class", serde_json::json!("budget_exhausted"));
assert!(evaluate_condition(
"context.failure_class=budget_exhausted",
&outcome,
&context
));
}
#[test]
fn context_failure_class_not_equals_on_success() {
let outcome = make_outcome(StageStatus::Success);
let context = Context::new();
context.set("failure_class", serde_json::json!(""));
assert!(evaluate_condition(
"context.failure_class!=transient_infra",
&outcome,
&context
));
}
#[test]
fn context_failure_class_combined_with_outcome() {
let outcome = make_outcome(StageStatus::Fail);
let context = Context::new();
context.set("failure_class", serde_json::json!("transient_infra"));
assert!(evaluate_condition(
"outcome=fail && context.failure_class=transient_infra",
&outcome,
&context
));
assert!(!evaluate_condition(
"outcome=fail && context.failure_class=deterministic",
&outcome,
&context
));
}
}

View file

@ -722,24 +722,24 @@ mod tests {
#[test]
fn ssh_url_to_https_converts_git_at_syntax() {
assert_eq!(
ssh_url_to_https("git@github.com:brynary/attractor-rust.git"),
"https://github.com/brynary/attractor-rust.git"
ssh_url_to_https("git@github.com:brynary/arc.git"),
"https://github.com/brynary/arc.git"
);
}
#[test]
fn ssh_url_to_https_converts_ssh_protocol() {
assert_eq!(
ssh_url_to_https("ssh://git@github.com/brynary/attractor-rust.git"),
"https://github.com/brynary/attractor-rust.git"
ssh_url_to_https("ssh://git@github.com/brynary/arc.git"),
"https://github.com/brynary/arc.git"
);
}
#[test]
fn ssh_url_to_https_passes_through_https() {
assert_eq!(
ssh_url_to_https("https://github.com/brynary/attractor-rust.git"),
"https://github.com/brynary/attractor-rust.git"
ssh_url_to_https("https://github.com/brynary/arc.git"),
"https://github.com/brynary/arc.git"
);
}

View file

@ -16,7 +16,7 @@ use crate::artifact::{offload_large_values, sync_artifacts_to_env, ArtifactStore
use crate::checkpoint::Checkpoint;
use crate::condition::evaluate_condition;
use crate::context::Context;
use crate::error::{AttractorError, Result};
use crate::error::{ArcError, FailureClass, Result, classify_failure_reason};
use crate::event::{EventEmitter, PipelineEvent};
use crate::graph::{Edge, Graph, Node};
use crate::handler::{EngineServices, HandlerRegistry};
@ -29,6 +29,37 @@ fn millis_u64(d: std::time::Duration) -> u64 {
u64::try_from(d.as_millis()).unwrap_or(u64::MAX)
}
/// Classify the failure mode of a completed outcome.
///
/// Returns `None` for `Success`, `PartialSuccess`, and `Skipped` outcomes.
/// For failures, checks (in priority order):
/// 1. Handler hint in `context_updates["failure_class"]`
/// 2. String heuristics on `failure_reason`
/// 3. Default to `Deterministic`
#[must_use]
fn classify_outcome(outcome: &Outcome) -> Option<FailureClass> {
match outcome.status {
StageStatus::Success | StageStatus::PartialSuccess | StageStatus::Skipped => None,
StageStatus::Fail | StageStatus::Retry => {
// Check handler hint in context_updates
if let Some(hint) = outcome.context_updates.get("failure_class") {
if let Some(s) = hint.as_str() {
if let Ok(fc) = s.parse::<FailureClass>() {
return Some(fc);
}
}
}
// Fall back to string heuristics on failure_reason
if let Some(ref reason) = outcome.failure_reason {
return Some(classify_failure_reason(reason));
}
Some(FailureClass::Deterministic)
}
}
}
// --- Retry policy types ---
/// Configuration for exponential backoff between retry attempts.
@ -699,7 +730,7 @@ impl PipelineEngine {
let panic_dir = node_dir(logs_root, &node.id, visit);
let _ = std::fs::create_dir_all(&panic_dir);
let _ = std::fs::write(panic_dir.join("panic.txt"), &msg);
Err(AttractorError::Handler(msg))
Err(ArcError::Handler(msg))
}
}
};
@ -716,7 +747,7 @@ impl PipelineEngine {
error: e.to_string(),
will_retry: true,
failure_reason: None,
failure_class: Some("transient".into()),
failure_class: Some(e.failure_class().to_string()),
});
self.services.emitter.emit(&PipelineEvent::StageRetrying {
name: node.label().to_string(),
@ -728,7 +759,12 @@ impl PipelineEngine {
tokio::time::sleep(delay).await;
continue;
}
return Ok((Outcome::fail(e.to_string()), attempt));
let mut fail_outcome = Outcome::fail(e.to_string());
fail_outcome.context_updates.insert(
"failure_class".to_string(),
serde_json::json!(e.failure_class().to_string()),
);
return Ok((fail_outcome, attempt));
}
};
@ -917,7 +953,7 @@ impl PipelineEngine {
let start_node = graph
.find_start_node()
.ok_or_else(|| AttractorError::Engine("no start node found".to_string()))?;
.ok_or_else(|| ArcError::Engine("no start node found".to_string()))?;
current_node_id = start_node.id.clone();
}
@ -931,19 +967,19 @@ impl PipelineEngine {
// Check for cancellation before processing each node
if let Some(ref token) = config.cancel_token {
if token.load(Ordering::Relaxed) {
return Err(AttractorError::Cancelled);
return Err(ArcError::Cancelled);
}
}
let node = graph.nodes.get(&current_node_id).ok_or_else(|| {
AttractorError::Engine(format!("node not found: {current_node_id}"))
ArcError::Engine(format!("node not found: {current_node_id}"))
})?;
// Always track visit count (used for stage directory naming)
let count = node_visits.entry(current_node_id.clone()).or_insert(0);
*count += 1;
if max_node_visits > 0 && *count > max_node_visits {
return Err(AttractorError::Engine(format!(
return Err(ArcError::Engine(format!(
"node \"{}\" exceeded max visit limit of {max_node_visits}",
current_node_id
)));
@ -1057,6 +1093,8 @@ impl PipelineEngine {
let stage_duration_ms = millis_u64(stage_start.elapsed());
let outcome_failure_class = classify_outcome(&outcome);
if outcome.status == StageStatus::Fail {
self.services.emitter.emit(&PipelineEvent::StageFailed {
name: node.label().to_string(),
@ -1068,7 +1106,7 @@ impl PipelineEngine {
.to_string(),
will_retry: false,
failure_reason: outcome.failure_reason.clone(),
failure_class: Some("terminal".into()),
failure_class: outcome_failure_class.map(|fc| fc.to_string()),
});
} else {
self.services.emitter.emit(&PipelineEvent::StageCompleted {
@ -1084,7 +1122,7 @@ impl PipelineEngine {
files_touched: outcome.files_touched.clone(),
attempt: usize::try_from(attempts_used).unwrap_or(usize::MAX),
max_attempts: usize::try_from(retry_policy.max_attempts).unwrap_or(usize::MAX),
failure_class: None,
failure_class: outcome_failure_class.map(|fc| fc.to_string()),
});
self.inform(
&format!("Stage completed: {}", node.label()),
@ -1117,6 +1155,12 @@ impl PipelineEngine {
// Step 4: Apply context updates from outcome
context.apply_updates(&outcome.context_updates);
context.set("outcome", serde_json::json!(outcome.status.to_string()));
context.set(
"failure_class",
serde_json::json!(
outcome_failure_class.map_or(String::new(), |fc| fc.to_string())
),
);
if let Some(ref pref) = outcome.preferred_label {
context.set("preferred_label", serde_json::json!(pref));
}
@ -1258,7 +1302,7 @@ impl PipelineEngine {
duration_ms,
git_commit_sha: last_git_sha.clone(),
});
return Err(AttractorError::Engine(error_msg));
return Err(ArcError::Engine(error_msg));
}
break;
}
@ -1352,7 +1396,7 @@ mod tests {
_graph: &Graph,
_logs_root: &Path,
_services: &crate::handler::EngineServices,
) -> std::result::Result<Outcome, AttractorError> {
) -> std::result::Result<Outcome, ArcError> {
Ok(Outcome::fail("always fails"))
}
}
@ -1371,7 +1415,7 @@ mod tests {
_graph: &Graph,
_logs_root: &Path,
_services: &crate::handler::EngineServices,
) -> std::result::Result<Outcome, AttractorError> {
) -> std::result::Result<Outcome, ArcError> {
tokio::time::sleep(Duration::from_millis(self.sleep_ms)).await;
Ok(Outcome::success())
}
@ -2785,7 +2829,7 @@ mod tests {
};
let result = engine.run(&g, &config).await;
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), AttractorError::Cancelled));
assert!(matches!(result.unwrap_err(), ArcError::Cancelled));
}
#[tokio::test]
@ -2848,7 +2892,7 @@ mod tests {
// The engine should detect cancellation at the next loop iteration
// after the slow handler completes
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), AttractorError::Cancelled));
assert!(matches!(result.unwrap_err(), ArcError::Cancelled));
}
// --- max_node_visits tests ---
@ -3015,7 +3059,7 @@ mod tests {
_graph: &Graph,
_logs_root: &Path,
_services: &crate::handler::EngineServices,
) -> std::result::Result<Outcome, AttractorError> {
) -> std::result::Result<Outcome, ArcError> {
panic!("test panic message");
}
}
@ -3068,4 +3112,92 @@ mod tests {
"panic.txt should contain the panic message, got: {content}"
);
}
// --- classify_outcome tests ---
#[test]
fn classify_outcome_returns_none_for_success() {
assert!(classify_outcome(&Outcome::success()).is_none());
}
#[test]
fn classify_outcome_returns_none_for_skipped() {
assert!(classify_outcome(&Outcome::skipped()).is_none());
}
#[test]
fn classify_outcome_returns_none_for_partial_success() {
let outcome = Outcome {
status: StageStatus::PartialSuccess,
..Outcome::success()
};
assert!(classify_outcome(&outcome).is_none());
}
#[test]
fn classify_outcome_respects_handler_hint() {
let mut outcome = Outcome::fail("some error");
outcome.context_updates.insert(
"failure_class".to_string(),
serde_json::json!("budget_exhausted"),
);
assert_eq!(
classify_outcome(&outcome),
Some(FailureClass::BudgetExhausted)
);
}
#[test]
fn classify_outcome_ignores_invalid_handler_hint() {
let mut outcome = Outcome::fail("timeout occurred");
outcome.context_updates.insert(
"failure_class".to_string(),
serde_json::json!("not_a_valid_class"),
);
// Falls through to string heuristics on failure_reason
assert_eq!(
classify_outcome(&outcome),
Some(FailureClass::TransientInfra)
);
}
#[test]
fn classify_outcome_uses_failure_reason_heuristics() {
let outcome = Outcome::fail("rate limited by provider");
assert_eq!(
classify_outcome(&outcome),
Some(FailureClass::TransientInfra)
);
}
#[test]
fn classify_outcome_defaults_to_deterministic() {
let outcome = Outcome::fail("something went wrong");
assert_eq!(
classify_outcome(&outcome),
Some(FailureClass::Deterministic)
);
}
#[test]
fn classify_outcome_fail_no_reason_is_deterministic() {
let outcome = Outcome {
status: StageStatus::Fail,
failure_reason: None,
..Outcome::success()
};
assert_eq!(
classify_outcome(&outcome),
Some(FailureClass::Deterministic)
);
}
#[test]
fn classify_outcome_retry_status_uses_heuristics() {
let outcome = Outcome::retry("connection refused");
assert_eq!(
classify_outcome(&outcome),
Some(FailureClass::TransientInfra)
);
}
}

View file

@ -1,7 +1,132 @@
use std::fmt;
use std::str::FromStr;
use arc_llm::error::{ProviderErrorKind, SdkError};
use serde::{Deserialize, Serialize};
use thiserror::Error;
/// Classification of failure modes for pipeline edge conditions.
///
/// Pipeline authors can write edge conditions like `context.failure_class=budget_exhausted`
/// to route execution based on the nature of the failure.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FailureClass {
/// Temporary infrastructure failure (rate limit, timeout, network, 5xx).
TransientInfra,
/// Permanent failure (auth, bad config, code bug).
Deterministic,
/// Context length, token/turn limit, quota exceeded.
BudgetExhausted,
/// Reserved for future loop detection.
CompilationLoop,
/// User/system cancellation.
Canceled,
/// Reserved for future scope enforcement.
Structural,
}
impl fmt::Display for FailureClass {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
Self::TransientInfra => "transient_infra",
Self::Deterministic => "deterministic",
Self::BudgetExhausted => "budget_exhausted",
Self::CompilationLoop => "compilation_loop",
Self::Canceled => "canceled",
Self::Structural => "structural",
};
write!(f, "{s}")
}
}
impl FromStr for FailureClass {
type Err = String;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
match s {
"transient_infra" => Ok(Self::TransientInfra),
"deterministic" => Ok(Self::Deterministic),
"budget_exhausted" => Ok(Self::BudgetExhausted),
"compilation_loop" => Ok(Self::CompilationLoop),
"canceled" => Ok(Self::Canceled),
"structural" => Ok(Self::Structural),
other => Err(format!("unknown failure class: {other}")),
}
}
}
/// Classify an `SdkError` into a `FailureClass` based on its structure.
#[must_use]
pub fn classify_sdk_error(err: &SdkError) -> FailureClass {
match err {
SdkError::Provider { kind, .. } => match kind {
ProviderErrorKind::RateLimit | ProviderErrorKind::Server => {
FailureClass::TransientInfra
}
ProviderErrorKind::ContextLength | ProviderErrorKind::QuotaExceeded => {
FailureClass::BudgetExhausted
}
ProviderErrorKind::Authentication
| ProviderErrorKind::AccessDenied
| ProviderErrorKind::NotFound
| ProviderErrorKind::InvalidRequest
| ProviderErrorKind::ContentFilter => FailureClass::Deterministic,
},
SdkError::RequestTimeout { .. } | SdkError::Network { .. } | SdkError::Stream { .. } => {
FailureClass::TransientInfra
}
SdkError::Abort { .. } => FailureClass::Canceled,
SdkError::InvalidToolCall { .. }
| SdkError::NoObjectGenerated { .. }
| SdkError::Configuration { .. }
| SdkError::UnsupportedToolChoice { .. } => FailureClass::Deterministic,
}
}
/// Classify a failure reason string using heuristics.
///
/// This is the fallback when structured error information is not available
/// (e.g. for `Handler(String)` or `Engine(String)` errors).
#[must_use]
pub fn classify_failure_reason(reason: &str) -> FailureClass {
let lower = reason.to_lowercase();
if lower.contains("cancel") || lower.contains("abort") {
return FailureClass::Canceled;
}
if lower.contains("turn limit")
|| lower.contains("token limit")
|| lower.contains("context length")
|| lower.contains("budget")
|| lower.contains("quota exceeded")
{
return FailureClass::BudgetExhausted;
}
if lower.contains("scope violation") {
return FailureClass::Structural;
}
if lower.contains("timeout")
|| lower.contains("timed out")
|| lower.contains("rate limit")
|| lower.contains("rate limited")
|| lower.contains("connection refused")
|| lower.contains("connection reset")
|| lower.contains("503")
|| lower.contains("502")
|| lower.contains("500")
{
return FailureClass::TransientInfra;
}
FailureClass::Deterministic
}
#[derive(Error, Debug, Clone)]
pub enum AttractorError {
pub enum ArcError {
#[error("Parse error: {0}")]
Parse(String),
@ -14,6 +139,9 @@ pub enum AttractorError {
#[error("Handler error: {0}")]
Handler(String),
#[error("LLM error: {0}")]
Llm(SdkError),
#[error("Checkpoint error: {0}")]
Checkpoint(String),
@ -27,17 +155,18 @@ pub enum AttractorError {
Cancelled,
}
impl AttractorError {
impl ArcError {
/// Whether this error category is retryable (transient) or terminal.
///
/// Retryable: Handler (transient handler failures), Engine (could be transient),
/// Io (network/disk issues are often transient).
/// Io (network/disk issues are often transient), Llm (delegates to SdkError).
/// Terminal: Parse, Validation, Stylesheet (configuration errors),
/// Checkpoint (storage integrity), Cancelled (explicit cancellation).
#[must_use]
pub const fn is_retryable(&self) -> bool {
pub fn is_retryable(&self) -> bool {
match self {
Self::Handler(_) | Self::Engine(_) | Self::Io(_) => true,
Self::Llm(sdk_err) => sdk_err.retryable(),
Self::Parse(_)
| Self::Validation(_)
| Self::Stylesheet(_)
@ -45,61 +174,82 @@ impl AttractorError {
| Self::Cancelled => false,
}
}
/// Classify this error into a `FailureClass`.
#[must_use]
pub fn failure_class(&self) -> FailureClass {
match self {
Self::Cancelled => FailureClass::Canceled,
Self::Llm(sdk_err) => classify_sdk_error(sdk_err),
Self::Io(_) => FailureClass::TransientInfra,
Self::Parse(_) | Self::Validation(_) | Self::Stylesheet(_) | Self::Checkpoint(_) => {
FailureClass::Deterministic
}
Self::Handler(msg) | Self::Engine(msg) => classify_failure_reason(msg),
}
}
}
impl From<std::io::Error> for AttractorError {
impl From<std::io::Error> for ArcError {
fn from(err: std::io::Error) -> Self {
Self::Io(err.to_string())
}
}
pub type Result<T> = std::result::Result<T, AttractorError>;
impl From<SdkError> for ArcError {
fn from(err: SdkError) -> Self {
Self::Llm(err)
}
}
pub type Result<T> = std::result::Result<T, ArcError>;
#[cfg(test)]
mod tests {
use super::*;
use arc_llm::error::ProviderErrorDetail;
#[test]
fn parse_error_display() {
let err = AttractorError::Parse("unexpected token".to_string());
let err = ArcError::Parse("unexpected token".to_string());
assert_eq!(err.to_string(), "Parse error: unexpected token");
}
#[test]
fn validation_error_display() {
let err = AttractorError::Validation("missing start node".to_string());
let err = ArcError::Validation("missing start node".to_string());
assert_eq!(err.to_string(), "Validation error: missing start node");
}
#[test]
fn engine_error_display() {
let err = AttractorError::Engine("no outgoing edge".to_string());
let err = ArcError::Engine("no outgoing edge".to_string());
assert_eq!(err.to_string(), "Engine error: no outgoing edge");
}
#[test]
fn handler_error_display() {
let err = AttractorError::Handler("LLM call failed".to_string());
let err = ArcError::Handler("LLM call failed".to_string());
assert_eq!(err.to_string(), "Handler error: LLM call failed");
}
#[test]
fn checkpoint_error_display() {
let err = AttractorError::Checkpoint("file not found".to_string());
let err = ArcError::Checkpoint("file not found".to_string());
assert_eq!(err.to_string(), "Checkpoint error: file not found");
}
#[test]
fn io_error_display() {
let err = AttractorError::Io("permission denied".to_string());
let err = ArcError::Io("permission denied".to_string());
assert_eq!(err.to_string(), "I/O error: permission denied");
}
#[test]
fn io_error_from_std() {
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "not found");
let err = AttractorError::from(io_err);
assert!(matches!(err, AttractorError::Io(_)));
let err = ArcError::from(io_err);
assert!(matches!(err, ArcError::Io(_)));
assert!(err.to_string().contains("not found"));
}
@ -108,33 +258,350 @@ mod tests {
let ok: Result<i32> = Ok(42);
assert!(ok.is_ok());
let err: Result<i32> = Err(AttractorError::Parse("bad".to_string()));
let err: Result<i32> = Err(ArcError::Parse("bad".to_string()));
assert!(err.is_err());
}
#[test]
fn cancelled_error_display() {
let err = AttractorError::Cancelled;
let err = ArcError::Cancelled;
assert_eq!(err.to_string(), "Pipeline cancelled");
}
#[test]
fn cancelled_is_not_retryable() {
assert!(!AttractorError::Cancelled.is_retryable());
assert!(!ArcError::Cancelled.is_retryable());
}
#[test]
fn is_retryable_terminal_errors() {
assert!(!AttractorError::Parse("bad".to_string()).is_retryable());
assert!(!AttractorError::Validation("bad".to_string()).is_retryable());
assert!(!AttractorError::Stylesheet("bad".to_string()).is_retryable());
assert!(!AttractorError::Checkpoint("bad".to_string()).is_retryable());
assert!(!ArcError::Parse("bad".to_string()).is_retryable());
assert!(!ArcError::Validation("bad".to_string()).is_retryable());
assert!(!ArcError::Stylesheet("bad".to_string()).is_retryable());
assert!(!ArcError::Checkpoint("bad".to_string()).is_retryable());
}
#[test]
fn is_retryable_transient_errors() {
assert!(AttractorError::Handler("timeout".to_string()).is_retryable());
assert!(AttractorError::Engine("transient".to_string()).is_retryable());
assert!(AttractorError::Io("connection reset".to_string()).is_retryable());
assert!(ArcError::Handler("timeout".to_string()).is_retryable());
assert!(ArcError::Engine("transient".to_string()).is_retryable());
assert!(ArcError::Io("connection reset".to_string()).is_retryable());
}
// --- FailureClass Display/FromStr/serde tests ---
#[test]
fn failure_class_display_all_values() {
assert_eq!(FailureClass::TransientInfra.to_string(), "transient_infra");
assert_eq!(FailureClass::Deterministic.to_string(), "deterministic");
assert_eq!(FailureClass::BudgetExhausted.to_string(), "budget_exhausted");
assert_eq!(FailureClass::CompilationLoop.to_string(), "compilation_loop");
assert_eq!(FailureClass::Canceled.to_string(), "canceled");
assert_eq!(FailureClass::Structural.to_string(), "structural");
}
#[test]
fn failure_class_from_str_all_values() {
assert_eq!(
"transient_infra".parse::<FailureClass>().unwrap(),
FailureClass::TransientInfra
);
assert_eq!(
"deterministic".parse::<FailureClass>().unwrap(),
FailureClass::Deterministic
);
assert_eq!(
"budget_exhausted".parse::<FailureClass>().unwrap(),
FailureClass::BudgetExhausted
);
assert_eq!(
"compilation_loop".parse::<FailureClass>().unwrap(),
FailureClass::CompilationLoop
);
assert_eq!(
"canceled".parse::<FailureClass>().unwrap(),
FailureClass::Canceled
);
assert_eq!(
"structural".parse::<FailureClass>().unwrap(),
FailureClass::Structural
);
}
#[test]
fn failure_class_from_str_invalid() {
assert!("unknown".parse::<FailureClass>().is_err());
}
#[test]
fn failure_class_serde_roundtrip() {
let values = [
FailureClass::TransientInfra,
FailureClass::Deterministic,
FailureClass::BudgetExhausted,
FailureClass::CompilationLoop,
FailureClass::Canceled,
FailureClass::Structural,
];
for fc in values {
let json = serde_json::to_string(&fc).unwrap();
let parsed: FailureClass = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, fc);
}
}
// --- Llm variant tests ---
#[test]
fn llm_error_display() {
let sdk_err = SdkError::Network {
message: "connection refused".into(),
};
let err = ArcError::Llm(sdk_err);
assert_eq!(err.to_string(), "LLM error: Network error: connection refused");
}
#[test]
fn llm_error_retryable_delegates_to_sdk() {
let retryable = ArcError::Llm(SdkError::Network {
message: "timeout".into(),
});
assert!(retryable.is_retryable());
let non_retryable = ArcError::Llm(SdkError::Configuration {
message: "bad config".into(),
});
assert!(!non_retryable.is_retryable());
}
#[test]
fn llm_error_from_sdk_error() {
let sdk_err = SdkError::Stream {
message: "broken pipe".into(),
};
let err = ArcError::from(sdk_err);
assert!(matches!(err, ArcError::Llm(_)));
}
// --- failure_class() method tests ---
#[test]
fn failure_class_cancelled() {
assert_eq!(
ArcError::Cancelled.failure_class(),
FailureClass::Canceled
);
}
#[test]
fn failure_class_io() {
assert_eq!(
ArcError::Io("disk full".into()).failure_class(),
FailureClass::TransientInfra
);
}
#[test]
fn failure_class_parse() {
assert_eq!(
ArcError::Parse("bad syntax".into()).failure_class(),
FailureClass::Deterministic
);
}
#[test]
fn failure_class_handler_with_timeout() {
assert_eq!(
ArcError::Handler("request timed out".into()).failure_class(),
FailureClass::TransientInfra
);
}
#[test]
fn failure_class_handler_deterministic() {
assert_eq!(
ArcError::Handler("invalid configuration".into()).failure_class(),
FailureClass::Deterministic
);
}
#[test]
fn failure_class_llm_rate_limit() {
let err = ArcError::Llm(SdkError::Provider {
kind: ProviderErrorKind::RateLimit,
detail: Box::new(ProviderErrorDetail::new("too fast", "openai")),
});
assert_eq!(err.failure_class(), FailureClass::TransientInfra);
}
#[test]
fn failure_class_llm_context_length() {
let err = ArcError::Llm(SdkError::Provider {
kind: ProviderErrorKind::ContextLength,
detail: Box::new(ProviderErrorDetail::new("too long", "openai")),
});
assert_eq!(err.failure_class(), FailureClass::BudgetExhausted);
}
#[test]
fn failure_class_llm_auth() {
let err = ArcError::Llm(SdkError::Provider {
kind: ProviderErrorKind::Authentication,
detail: Box::new(ProviderErrorDetail::new("bad key", "openai")),
});
assert_eq!(err.failure_class(), FailureClass::Deterministic);
}
#[test]
fn failure_class_llm_abort() {
let err = ArcError::Llm(SdkError::Abort {
message: "user cancelled".into(),
});
assert_eq!(err.failure_class(), FailureClass::Canceled);
}
#[test]
fn failure_class_llm_timeout() {
let err = ArcError::Llm(SdkError::RequestTimeout {
message: "timed out".into(),
});
assert_eq!(err.failure_class(), FailureClass::TransientInfra);
}
// --- classify_sdk_error tests ---
#[test]
fn classify_sdk_rate_limit() {
let err = SdkError::Provider {
kind: ProviderErrorKind::RateLimit,
detail: Box::new(ProviderErrorDetail::new("too fast", "openai")),
};
assert_eq!(classify_sdk_error(&err), FailureClass::TransientInfra);
}
#[test]
fn classify_sdk_server() {
let err = SdkError::Provider {
kind: ProviderErrorKind::Server,
detail: Box::new(ProviderErrorDetail::new("500", "openai")),
};
assert_eq!(classify_sdk_error(&err), FailureClass::TransientInfra);
}
#[test]
fn classify_sdk_context_length() {
let err = SdkError::Provider {
kind: ProviderErrorKind::ContextLength,
detail: Box::new(ProviderErrorDetail::new("too long", "openai")),
};
assert_eq!(classify_sdk_error(&err), FailureClass::BudgetExhausted);
}
#[test]
fn classify_sdk_quota_exceeded() {
let err = SdkError::Provider {
kind: ProviderErrorKind::QuotaExceeded,
detail: Box::new(ProviderErrorDetail::new("out of quota", "openai")),
};
assert_eq!(classify_sdk_error(&err), FailureClass::BudgetExhausted);
}
#[test]
fn classify_sdk_auth() {
let err = SdkError::Provider {
kind: ProviderErrorKind::Authentication,
detail: Box::new(ProviderErrorDetail::new("bad key", "openai")),
};
assert_eq!(classify_sdk_error(&err), FailureClass::Deterministic);
}
#[test]
fn classify_sdk_request_timeout() {
let err = SdkError::RequestTimeout {
message: "timed out".into(),
};
assert_eq!(classify_sdk_error(&err), FailureClass::TransientInfra);
}
#[test]
fn classify_sdk_abort() {
let err = SdkError::Abort {
message: "cancelled".into(),
};
assert_eq!(classify_sdk_error(&err), FailureClass::Canceled);
}
#[test]
fn classify_sdk_invalid_tool_call() {
let err = SdkError::InvalidToolCall {
message: "bad tool".into(),
};
assert_eq!(classify_sdk_error(&err), FailureClass::Deterministic);
}
// --- classify_failure_reason tests ---
#[test]
fn classify_reason_timeout() {
assert_eq!(
classify_failure_reason("request timed out after 30s"),
FailureClass::TransientInfra
);
}
#[test]
fn classify_reason_rate_limit() {
assert_eq!(
classify_failure_reason("rate limited by provider"),
FailureClass::TransientInfra
);
}
#[test]
fn classify_reason_connection_refused() {
assert_eq!(
classify_failure_reason("connection refused"),
FailureClass::TransientInfra
);
}
#[test]
fn classify_reason_turn_limit() {
assert_eq!(
classify_failure_reason("exceeded turn limit of 10"),
FailureClass::BudgetExhausted
);
}
#[test]
fn classify_reason_token_limit() {
assert_eq!(
classify_failure_reason("token limit reached"),
FailureClass::BudgetExhausted
);
}
#[test]
fn classify_reason_cancel() {
assert_eq!(
classify_failure_reason("operation cancelled by user"),
FailureClass::Canceled
);
}
#[test]
fn classify_reason_scope_violation() {
assert_eq!(
classify_failure_reason("scope violation detected"),
FailureClass::Structural
);
}
#[test]
fn classify_reason_default_deterministic() {
assert_eq!(
classify_failure_reason("invalid configuration parameter"),
FailureClass::Deterministic
);
}
}

View file

@ -7,10 +7,10 @@ use arc_git_storage::trailerlink::{self, Trailer};
use git2::{Repository, Signature};
use crate::checkpoint::Checkpoint;
use crate::error::{AttractorError, Result};
use crate::error::{ArcError, Result};
fn git_error(msg: impl Into<String>) -> AttractorError {
AttractorError::Engine(msg.into())
fn git_error(msg: impl Into<String>) -> ArcError {
ArcError::Engine(msg.into())
}
/// Assert the working directory is a clean git repo (no uncommitted changes).
@ -250,7 +250,7 @@ impl MetadataStore {
match Self::read_file(repo_path, run_id, "checkpoint.json")? {
Some(bytes) => {
let cp: Checkpoint = serde_json::from_slice(&bytes)
.map_err(|e| AttractorError::Checkpoint(format!("deserialize failed: {e}")))?;
.map_err(|e| ArcError::Checkpoint(format!("deserialize failed: {e}")))?;
Ok(Some(cp))
}
None => Ok(None),

View file

@ -3,7 +3,7 @@ use std::time::Duration;
use serde::{Deserialize, Serialize};
use crate::error::AttractorError;
use crate::error::ArcError;
/// Whether a codergen node runs as a multi-turn agent loop or a single LLM call.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@ -13,11 +13,11 @@ pub enum CodergenMode {
}
impl CodergenMode {
pub fn parse(s: &str) -> Result<Self, AttractorError> {
pub fn parse(s: &str) -> Result<Self, ArcError> {
match s {
"agent_loop" => Ok(Self::AgentLoop),
"one_shot" => Ok(Self::OneShot),
other => Err(AttractorError::Validation(format!(
other => Err(ArcError::Validation(format!(
"invalid codergen_mode: {other:?} (expected \"agent_loop\" or \"one_shot\")"
))),
}
@ -231,7 +231,7 @@ impl Node {
}
/// Returns the codergen mode for this node. Defaults to `AgentLoop` when absent.
pub fn codergen_mode(&self) -> Result<CodergenMode, AttractorError> {
pub fn codergen_mode(&self) -> Result<CodergenMode, ArcError> {
match self.str_attr("codergen_mode") {
Some(s) => CodergenMode::parse(s),
None => Ok(CodergenMode::AgentLoop),

View file

@ -5,7 +5,7 @@ use arc_agent::ExecutionEnvironment;
use async_trait::async_trait;
use crate::context::Context;
use crate::error::AttractorError;
use crate::error::ArcError;
use crate::event::EventEmitter;
use crate::graph::{CodergenMode, Graph, Node};
use crate::outcome::{Outcome, StageUsage};
@ -36,7 +36,7 @@ pub trait CodergenBackend: Send + Sync {
emitter: &Arc<EventEmitter>,
stage_dir: &Path,
execution_env: &Arc<dyn ExecutionEnvironment>,
) -> Result<CodergenResult, AttractorError>;
) -> Result<CodergenResult, ArcError>;
/// Run a single LLM call with no tools (one_shot mode).
async fn one_shot(
@ -44,8 +44,8 @@ pub trait CodergenBackend: Send + Sync {
_node: &Node,
_prompt: &str,
_stage_dir: &Path,
) -> Result<CodergenResult, AttractorError> {
Err(AttractorError::Validation(
) -> Result<CodergenResult, ArcError> {
Err(ArcError::Validation(
"one_shot mode not supported by this backend".into(),
))
}
@ -180,7 +180,7 @@ fn run_hook(command: &str, node_id: &str, work_dir: Option<&Path>) -> bool {
let mut cmd = std::process::Command::new("sh");
cmd.arg("-c")
.arg(command)
.env("ATTRACTOR_NODE_ID", node_id);
.env("ARC_NODE_ID", node_id);
if let Some(wd) = work_dir {
cmd.current_dir(wd);
}
@ -199,7 +199,7 @@ impl Handler for CodergenHandler {
graph: &Graph,
logs_root: &Path,
services: &EngineServices,
) -> Result<Outcome, AttractorError> {
) -> Result<Outcome, ArcError> {
// 1. Build prompt (prepend fidelity preamble if present)
let raw_prompt = node
.prompt()
@ -570,7 +570,7 @@ mod tests {
_emitter: &Arc<EventEmitter>,
_stage_dir: &Path,
_execution_env: &Arc<dyn ExecutionEnvironment>,
) -> Result<CodergenResult, AttractorError> {
) -> Result<CodergenResult, ArcError> {
*self.captured_thread_id.lock().unwrap() =
Some(thread_id.map(String::from));
Ok(CodergenResult::Text { text: "ok".to_string(), usage: None, files_touched: Vec::new() })
@ -618,7 +618,7 @@ mod tests {
_emitter: &Arc<EventEmitter>,
_stage_dir: &Path,
_execution_env: &Arc<dyn ExecutionEnvironment>,
) -> Result<CodergenResult, AttractorError> {
) -> Result<CodergenResult, ArcError> {
*self.captured_thread_id.lock().unwrap() =
Some(thread_id.map(String::from));
Ok(CodergenResult::Text { text: "ok".to_string(), usage: None, files_touched: Vec::new() })
@ -661,8 +661,8 @@ mod tests {
_emitter: &Arc<EventEmitter>,
_stage_dir: &Path,
_execution_env: &Arc<dyn ExecutionEnvironment>,
) -> Result<CodergenResult, AttractorError> {
Err(AttractorError::Handler("Request timed out".to_string()))
) -> Result<CodergenResult, ArcError> {
Err(ArcError::Handler("Request timed out".to_string()))
}
}
@ -775,7 +775,7 @@ Some text in between.
_emitter: &Arc<EventEmitter>,
_stage_dir: &Path,
_execution_env: &Arc<dyn ExecutionEnvironment>,
) -> Result<CodergenResult, AttractorError> {
) -> Result<CodergenResult, ArcError> {
panic!("run() should not be called in one_shot mode");
}
@ -784,7 +784,7 @@ Some text in between.
_node: &Node,
_prompt: &str,
_stage_dir: &Path,
) -> Result<CodergenResult, AttractorError> {
) -> Result<CodergenResult, ArcError> {
Ok(CodergenResult::Text {
text: "one-shot response".to_string(),
usage: None,
@ -879,8 +879,8 @@ Some text in between.
_emitter: &Arc<EventEmitter>,
_stage_dir: &Path,
_execution_env: &Arc<dyn ExecutionEnvironment>,
) -> Result<CodergenResult, AttractorError> {
Err(AttractorError::Validation("bad config".to_string()))
) -> Result<CodergenResult, ArcError> {
Err(ArcError::Validation("bad config".to_string()))
}
}
@ -917,7 +917,7 @@ Some text in between.
_emitter: &Arc<EventEmitter>,
_stage_dir: &std::path::Path,
_execution_env: &Arc<dyn ExecutionEnvironment>,
) -> Result<CodergenResult, AttractorError> {
) -> Result<CodergenResult, ArcError> {
*self.captured_prompt.lock().unwrap() = Some(prompt.to_string());
Ok(CodergenResult::Text {
text: "ok".to_string(),
@ -985,7 +985,7 @@ Some text in between.
_emitter: &Arc<EventEmitter>,
_stage_dir: &std::path::Path,
_execution_env: &Arc<dyn ExecutionEnvironment>,
) -> Result<CodergenResult, AttractorError> {
) -> Result<CodergenResult, ArcError> {
*self.captured_prompt.lock().unwrap() = Some(prompt.to_string());
Ok(CodergenResult::Text {
text: "ok".to_string(),
@ -1039,7 +1039,7 @@ Some text in between.
_emitter: &Arc<EventEmitter>,
_stage_dir: &std::path::Path,
_execution_env: &Arc<dyn ExecutionEnvironment>,
) -> Result<CodergenResult, AttractorError> {
) -> Result<CodergenResult, ArcError> {
panic!("run() should not be called in one_shot mode");
}
@ -1048,7 +1048,7 @@ Some text in between.
_node: &Node,
prompt: &str,
_stage_dir: &std::path::Path,
) -> Result<CodergenResult, AttractorError> {
) -> Result<CodergenResult, ArcError> {
*self.captured_prompt.lock().unwrap() = Some(prompt.to_string());
Ok(CodergenResult::Text {
text: "classified".to_string(),

View file

@ -3,7 +3,7 @@ use std::path::Path;
use async_trait::async_trait;
use crate::context::Context;
use crate::error::AttractorError;
use crate::error::ArcError;
use crate::graph::{Graph, Node};
use crate::outcome::Outcome;
@ -22,7 +22,7 @@ impl Handler for ConditionalHandler {
_graph: &Graph,
_logs_root: &Path,
_services: &EngineServices,
) -> Result<Outcome, AttractorError> {
) -> Result<Outcome, ArcError> {
let mut outcome = Outcome::success();
outcome.notes = Some(format!("Conditional node evaluated: {}", node.id));
Ok(outcome)

View file

@ -3,7 +3,7 @@ use std::path::Path;
use async_trait::async_trait;
use crate::context::Context;
use crate::error::AttractorError;
use crate::error::ArcError;
use crate::graph::{Graph, Node};
use crate::outcome::Outcome;
@ -21,7 +21,7 @@ impl Handler for ExitHandler {
_graph: &Graph,
_logs_root: &Path,
_services: &EngineServices,
) -> Result<Outcome, AttractorError> {
) -> Result<Outcome, ArcError> {
Ok(Outcome::success())
}
}

View file

@ -5,7 +5,7 @@ use arc_agent::ExecutionEnvironment;
use async_trait::async_trait;
use crate::context::Context;
use crate::error::AttractorError;
use crate::error::ArcError;
use crate::event::EventEmitter;
use crate::graph::{Graph, Node};
use crate::outcome::Outcome;
@ -34,7 +34,7 @@ impl Handler for FanInHandler {
_graph: &Graph,
logs_root: &Path,
services: &EngineServices,
) -> Result<Outcome, AttractorError> {
) -> Result<Outcome, ArcError> {
let results = context.get("parallel.results");
let Some(results) = results else {
return Ok(Outcome::fail("No parallel results to evaluate"));
@ -159,7 +159,7 @@ async fn llm_evaluate(
node_id: &str,
emitter: &Arc<EventEmitter>,
execution_env: &Arc<dyn ExecutionEnvironment>,
) -> Result<Candidate, AttractorError> {
) -> Result<Candidate, ArcError> {
let results_text = serde_json::to_string_pretty(results)
.unwrap_or_else(|_| results.to_string());
@ -380,7 +380,7 @@ mod tests {
_emitter: &Arc<EventEmitter>,
_stage_dir: &std::path::Path,
_execution_env: &Arc<dyn ExecutionEnvironment>,
) -> Result<CodergenResult, AttractorError> {
) -> Result<CodergenResult, ArcError> {
// Return text that contains the ID "branch_b"
Ok(CodergenResult::Text { text: "The best candidate is branch_b".to_string(), usage: None, files_touched: Vec::new() })
}

View file

@ -5,7 +5,7 @@ use async_trait::async_trait;
use crate::condition::evaluate_condition;
use crate::context::Context;
use crate::error::AttractorError;
use crate::error::ArcError;
use crate::graph::{Graph, Node};
use crate::outcome::{Outcome, StageStatus};
@ -20,15 +20,15 @@ pub trait ChildObserver: Send + Sync {
_dotfile: &str,
_workdir: &str,
_context: &Context,
) -> Result<(), AttractorError> {
) -> Result<(), ArcError> {
Ok(())
}
/// Ingest child telemetry into the context.
async fn observe(&self, context: &Context) -> Result<(), AttractorError>;
async fn observe(&self, context: &Context) -> Result<(), ArcError>;
/// Optionally steer the child pipeline (e.g., write intervention instructions).
async fn steer(&self, context: &Context, node: &Node) -> Result<(), AttractorError>;
async fn steer(&self, context: &Context, node: &Node) -> Result<(), ArcError>;
}
/// Orchestrates observe/steer/wait cycles over a child pipeline.
@ -74,7 +74,7 @@ impl Handler for ManagerLoopHandler {
_graph: &Graph,
_logs_root: &Path,
_services: &EngineServices,
) -> Result<Outcome, AttractorError> {
) -> Result<Outcome, ArcError> {
let poll_interval = node
.attrs
.get("manager.poll_interval")

View file

@ -17,7 +17,7 @@ use arc_agent::ExecutionEnvironment;
use async_trait::async_trait;
use crate::context::Context;
use crate::error::AttractorError;
use crate::error::ArcError;
use crate::event::EventEmitter;
use crate::graph::{shape_to_handler_type, Graph, Node};
use crate::interviewer::Interviewer;
@ -40,11 +40,11 @@ pub trait Handler: Send + Sync {
graph: &Graph,
logs_root: &Path,
services: &EngineServices,
) -> Result<Outcome, AttractorError>;
) -> Result<Outcome, ArcError>;
/// Determines whether an error should be retried.
/// Default implementation retries transient errors only.
fn should_retry(&self, err: &AttractorError) -> bool {
fn should_retry(&self, err: &ArcError) -> bool {
err.is_retryable()
}
}
@ -147,7 +147,7 @@ mod tests {
_graph: &Graph,
_logs_root: &Path,
_services: &EngineServices,
) -> Result<Outcome, AttractorError> {
) -> Result<Outcome, ArcError> {
Ok(Outcome::success())
}
}
@ -211,8 +211,8 @@ mod tests {
let handler = TestHandler {
_name: "test".to_string(),
};
assert!(handler.should_retry(&AttractorError::Handler("timeout".to_string())));
assert!(!handler.should_retry(&AttractorError::Parse("bad".to_string())));
assert!(handler.should_retry(&ArcError::Handler("timeout".to_string())));
assert!(!handler.should_retry(&ArcError::Parse("bad".to_string())));
}
struct NeverRetryHandler;
@ -226,11 +226,11 @@ mod tests {
_graph: &Graph,
_logs_root: &Path,
_services: &EngineServices,
) -> Result<Outcome, AttractorError> {
) -> Result<Outcome, ArcError> {
Ok(Outcome::success())
}
fn should_retry(&self, _err: &AttractorError) -> bool {
fn should_retry(&self, _err: &ArcError) -> bool {
false
}
}
@ -238,8 +238,8 @@ mod tests {
#[test]
fn custom_should_retry_override() {
let handler = NeverRetryHandler;
assert!(!handler.should_retry(&AttractorError::Handler("timeout".to_string())));
assert!(!handler.should_retry(&AttractorError::Io("connection reset".to_string())));
assert!(!handler.should_retry(&ArcError::Handler("timeout".to_string())));
assert!(!handler.should_retry(&ArcError::Io("connection reset".to_string())));
}
#[test]

View file

@ -6,7 +6,7 @@ use async_trait::async_trait;
use tokio::sync::Semaphore;
use crate::context::Context;
use crate::error::AttractorError;
use crate::error::ArcError;
use crate::event::PipelineEvent;
use crate::graph::{Graph, Node};
use crate::outcome::{Outcome, StageStatus};
@ -99,7 +99,7 @@ impl Handler for ParallelHandler {
graph: &Graph,
logs_root: &Path,
services: &EngineServices,
) -> Result<Outcome, AttractorError> {
) -> Result<Outcome, ArcError> {
let parallel_start = Instant::now();
let branches = graph.outgoing_edges(&node.id);
if branches.is_empty() {
@ -147,7 +147,7 @@ impl Handler for ParallelHandler {
let handle = tokio::spawn(async move {
let _permit = sem.acquire().await.map_err(|e| {
AttractorError::Handler(format!("semaphore error: {e}"))
ArcError::Handler(format!("semaphore error: {e}"))
})?;
emitter.emit(&PipelineEvent::ParallelBranchStarted {
@ -187,7 +187,7 @@ impl Handler for ParallelHandler {
status: outcome.status.to_string(),
});
Ok::<BranchResult, AttractorError>(BranchResult {
Ok::<BranchResult, ArcError>(BranchResult {
id: target_id,
outcome,
})

View file

@ -3,7 +3,7 @@ use std::path::Path;
use async_trait::async_trait;
use crate::context::Context;
use crate::error::AttractorError;
use crate::error::ArcError;
use crate::graph::{Graph, Node};
use crate::outcome::Outcome;
@ -25,7 +25,7 @@ impl Handler for ScriptHandler {
_graph: &Graph,
logs_root: &Path,
_services: &EngineServices,
) -> Result<Outcome, AttractorError> {
) -> Result<Outcome, ArcError> {
let script = node
.attrs
.get("script")
@ -94,7 +94,7 @@ impl Handler for ScriptHandler {
)
.await?;
return Err(AttractorError::Handler(format!(
return Err(ArcError::Handler(format!(
"Script timed out after {}ms: {script}",
timeout_dur.as_millis()
)));
@ -158,7 +158,7 @@ impl Handler for ScriptHandler {
Ok(outcome)
}
}
Err(e) => Err(AttractorError::Handler(format!("Failed to spawn script: {e}"))),
Err(e) => Err(ArcError::Handler(format!("Failed to spawn script: {e}"))),
}
}
}
@ -647,7 +647,7 @@ mod tests {
//
// Pragmatic approach: verify the error construction matches what the
// handler produces. The timeout test covers the other Err path.
let err = AttractorError::Handler(format!("Failed to spawn script: {}", "No such file"));
let err = ArcError::Handler(format!("Failed to spawn script: {}", "No such file"));
assert!(err.to_string().contains("Failed to spawn script"));
}

View file

@ -3,7 +3,7 @@ use std::path::Path;
use async_trait::async_trait;
use crate::context::Context;
use crate::error::AttractorError;
use crate::error::ArcError;
use crate::graph::{Graph, Node};
use crate::outcome::Outcome;
@ -21,7 +21,7 @@ impl Handler for StartHandler {
_graph: &Graph,
_logs_root: &Path,
_services: &EngineServices,
) -> Result<Outcome, AttractorError> {
) -> Result<Outcome, ArcError> {
Ok(Outcome::success())
}
}

View file

@ -5,7 +5,7 @@ use async_trait::async_trait;
use crate::context::Context;
use crate::engine::select_edge;
use crate::error::AttractorError;
use crate::error::ArcError;
use crate::event::PipelineEvent;
use crate::graph::{Graph, Node};
use crate::outcome::Outcome;
@ -36,7 +36,7 @@ impl Handler for SubPipelineHandler {
_graph: &Graph,
logs_root: &Path,
services: &EngineServices,
) -> Result<Outcome, AttractorError> {
) -> Result<Outcome, ArcError> {
// 1. Get DOT source from node attribute
let dot_source = match node.attrs.get("sub_pipeline.dot_source").and_then(|v| v.as_str()) {
Some(s) if !s.is_empty() => s,
@ -246,7 +246,7 @@ mod tests {
_graph: &Graph,
_logs_root: &Path,
_services: &EngineServices,
) -> Result<Outcome, AttractorError> {
) -> Result<Outcome, ArcError> {
context.set("sub.result", serde_json::json!("from_sub"));
Ok(Outcome::success())
}
@ -305,7 +305,7 @@ mod tests {
_graph: &Graph,
_logs_root: &Path,
_services: &EngineServices,
) -> Result<Outcome, AttractorError> {
) -> Result<Outcome, ArcError> {
Ok(Outcome::fail("sub-pipeline failure"))
}
}

View file

@ -5,7 +5,7 @@ use std::time::Instant;
use async_trait::async_trait;
use crate::context::Context;
use crate::error::AttractorError;
use crate::error::ArcError;
use crate::event::{EventEmitter, PipelineEvent};
use crate::graph::{Graph, Node};
use crate::interviewer::{
@ -106,7 +106,7 @@ impl Handler for WaitHumanHandler {
graph: &Graph,
_logs_root: &Path,
_services: &EngineServices,
) -> Result<Outcome, AttractorError> {
) -> Result<Outcome, ArcError> {
// 1. Derive choices from outgoing edges
let edges = graph.outgoing_edges(&node.id);
let mut freeform_target: Option<String> = None;

View file

@ -4,7 +4,7 @@ use std::sync::Mutex;
use async_trait::async_trait;
use super::{Answer, Interviewer, Question};
use crate::error::AttractorError;
use crate::error::ArcError;
/// Wraps another interviewer and records all question-answer pairs.
pub struct RecordingInterviewer {
@ -32,26 +32,26 @@ impl RecordingInterviewer {
///
/// # Errors
/// Returns an error if serialization fails.
pub fn to_json(&self) -> Result<String, AttractorError> {
pub fn to_json(&self) -> Result<String, ArcError> {
let recordings = self.recordings();
serde_json::to_string_pretty(&recordings)
.map_err(|e| AttractorError::Io(e.to_string()))
.map_err(|e| ArcError::Io(e.to_string()))
}
/// Deserializes recordings from a JSON string.
///
/// # Errors
/// Returns an error if deserialization fails.
pub fn from_json(json: &str) -> Result<Vec<(Question, Answer)>, AttractorError> {
pub fn from_json(json: &str) -> Result<Vec<(Question, Answer)>, ArcError> {
serde_json::from_str(json)
.map_err(|e| AttractorError::Io(e.to_string()))
.map_err(|e| ArcError::Io(e.to_string()))
}
/// Saves recordings to a file as JSON.
///
/// # Errors
/// Returns an error if serialization or file writing fails.
pub fn save_to_file(&self, path: &Path) -> Result<(), AttractorError> {
pub fn save_to_file(&self, path: &Path) -> Result<(), ArcError> {
let json = self.to_json()?;
std::fs::write(path, json)?;
Ok(())
@ -61,7 +61,7 @@ impl RecordingInterviewer {
///
/// # Errors
/// Returns an error if file reading or deserialization fails.
pub fn load_from_file(path: &Path) -> Result<Vec<(Question, Answer)>, AttractorError> {
pub fn load_from_file(path: &Path) -> Result<Vec<(Question, Answer)>, ArcError> {
let json = std::fs::read_to_string(path)?;
Self::from_json(&json)
}

View file

@ -3,7 +3,7 @@ pub mod grammar;
pub mod lexer;
pub mod semantic;
use crate::error::AttractorError;
use crate::error::ArcError;
use crate::graph::types::Graph;
/// Parse a DOT source string into a semantic `Graph`.
@ -15,14 +15,14 @@ use crate::graph::types::Graph;
///
/// Returns an error if the input is not valid DOT syntax or contains
/// trailing content after the graph definition.
pub fn parse(input: &str) -> Result<Graph, AttractorError> {
pub fn parse(input: &str) -> Result<Graph, ArcError> {
let stripped = lexer::strip_comments(input);
let (rest, dot_graph) = grammar::parse_dot_graph(&stripped)
.map_err(|e| AttractorError::Parse(format!("grammar error: {e}")))?;
.map_err(|e| ArcError::Parse(format!("grammar error: {e}")))?;
let remaining = rest.trim();
if !remaining.is_empty() {
return Err(AttractorError::Parse(format!(
return Err(ArcError::Parse(format!(
"unexpected trailing content: {:?}",
&remaining[..remaining.len().min(50)]
)));

View file

@ -1,7 +1,7 @@
use std::collections::HashMap;
use std::time::Duration;
use crate::error::AttractorError;
use crate::error::ArcError;
use crate::graph::types::{AttrValue, Edge, Graph, Node};
use crate::parser::ast::{AstValue, AttrBlock, DotGraph, Statement};
@ -239,7 +239,7 @@ impl SemanticState {
/// # Errors
///
/// Returns an error if the AST cannot be converted to a valid graph.
pub fn ast_to_graph(dot: &DotGraph) -> Result<Graph, AttractorError> {
pub fn ast_to_graph(dot: &DotGraph) -> Result<Graph, ArcError> {
let mut state = SemanticState::new(dot.name.clone());
let empty = HashMap::new();
state.process_statements(&dot.statements, None, &empty, &empty);

View file

@ -1,4 +1,4 @@
use crate::error::AttractorError;
use crate::error::ArcError;
use crate::graph::Graph;
use crate::transform::{StylesheetApplicationTransform, Transform, VariableExpansionTransform};
use crate::validation::{self, Diagnostic};
@ -28,7 +28,7 @@ impl PipelineBuilder {
/// # Errors
///
/// Returns an error if parsing or validation fails.
pub fn prepare(&self, dot_source: &str) -> Result<(Graph, Vec<Diagnostic>), AttractorError> {
pub fn prepare(&self, dot_source: &str) -> Result<(Graph, Vec<Diagnostic>), ArcError> {
let mut graph = crate::parser::parse(dot_source)?;
// Built-in transforms (PreambleTransform moved to engine execution time)
@ -56,7 +56,7 @@ impl Default for PipelineBuilder {
/// # Errors
///
/// Returns an error if parsing fails or if validation produces Error-severity diagnostics.
pub fn prepare_pipeline(dot_source: &str) -> Result<Graph, AttractorError> {
pub fn prepare_pipeline(dot_source: &str) -> Result<Graph, ArcError> {
let builder = PipelineBuilder::new();
let (graph, diagnostics) = builder.prepare(dot_source)?;
@ -66,7 +66,7 @@ pub fn prepare_pipeline(dot_source: &str) -> Result<Graph, AttractorError> {
.collect();
if !errors.is_empty() {
let messages: Vec<String> = errors.iter().map(|d| d.message.clone()).collect();
return Err(AttractorError::Validation(messages.join("; ")));
return Err(ArcError::Validation(messages.join("; ")));
}
Ok(graph)

View file

@ -1,4 +1,4 @@
use crate::error::AttractorError;
use crate::error::ArcError;
use crate::graph::types::{AttrValue, Graph};
/// A parsed stylesheet selector.
@ -51,7 +51,7 @@ pub struct Stylesheet {
/// # Errors
///
/// Returns an error if the input contains invalid stylesheet syntax.
pub fn parse_stylesheet(input: &str) -> Result<Stylesheet, AttractorError> {
pub fn parse_stylesheet(input: &str) -> Result<Stylesheet, ArcError> {
let input = input.trim();
if input.is_empty() {
return Ok(Stylesheet { rules: Vec::new() });
@ -65,7 +65,7 @@ pub fn parse_stylesheet(input: &str) -> Result<Stylesheet, AttractorError> {
let selector = parse_selector(&mut remaining)?;
if !remaining.starts_with('{') {
return Err(AttractorError::Stylesheet(format!(
return Err(ArcError::Stylesheet(format!(
"expected '{{' after selector, got: {:?}",
&remaining[..remaining.len().min(20)]
)));
@ -84,7 +84,7 @@ pub fn parse_stylesheet(input: &str) -> Result<Stylesheet, AttractorError> {
Ok(Stylesheet { rules })
}
fn parse_selector(remaining: &mut &str) -> Result<Selector, AttractorError> {
fn parse_selector(remaining: &mut &str) -> Result<Selector, ArcError> {
if remaining.starts_with('*') {
*remaining = remaining[1..].trim();
Ok(Selector::Universal)
@ -94,7 +94,7 @@ fn parse_selector(remaining: &mut &str) -> Result<Selector, AttractorError> {
.find(|c: char| !c.is_ascii_alphanumeric() && c != '_' && c != '-')
.unwrap_or(remaining.len());
if end == 0 {
return Err(AttractorError::Stylesheet(
return Err(ArcError::Stylesheet(
"expected identifier after '#'".into(),
));
}
@ -107,7 +107,7 @@ fn parse_selector(remaining: &mut &str) -> Result<Selector, AttractorError> {
.find(|c: char| !c.is_ascii_lowercase() && !c.is_ascii_digit() && c != '-')
.unwrap_or(remaining.len());
if end == 0 {
return Err(AttractorError::Stylesheet(
return Err(ArcError::Stylesheet(
"expected class name after '.'".into(),
));
}
@ -120,7 +120,7 @@ fn parse_selector(remaining: &mut &str) -> Result<Selector, AttractorError> {
.find(|c: char| !c.is_ascii_alphanumeric() && c != '_' && c != '-')
.unwrap_or(remaining.len());
if end == 0 {
return Err(AttractorError::Stylesheet(format!(
return Err(ArcError::Stylesheet(format!(
"expected selector ('*', '#id', '.class', or shape name), got: {:?}",
&remaining[..remaining.len().min(20)]
)));
@ -131,11 +131,11 @@ fn parse_selector(remaining: &mut &str) -> Result<Selector, AttractorError> {
}
}
fn parse_declarations(remaining: &mut &str) -> Result<Vec<Declaration>, AttractorError> {
fn parse_declarations(remaining: &mut &str) -> Result<Vec<Declaration>, ArcError> {
let mut declarations = Vec::new();
while !remaining.starts_with('}') {
if remaining.is_empty() {
return Err(AttractorError::Stylesheet(
return Err(ArcError::Stylesheet(
"unexpected end of stylesheet, expected '}'".into(),
));
}
@ -151,7 +151,7 @@ fn parse_declarations(remaining: &mut &str) -> Result<Vec<Declaration>, Attracto
*remaining = remaining[prop_end..].trim();
if !remaining.starts_with(':') {
return Err(AttractorError::Stylesheet(format!(
return Err(ArcError::Stylesheet(format!(
"expected ':' after property name '{property}'"
)));
}
@ -164,7 +164,7 @@ fn parse_declarations(remaining: &mut &str) -> Result<Vec<Declaration>, Attracto
*remaining = remaining[val_end..].trim();
if value.is_empty() {
return Err(AttractorError::Stylesheet(format!(
return Err(ArcError::Stylesheet(format!(
"empty value for property '{property}'"
)));
}

View file

@ -2,7 +2,7 @@ pub mod rules;
use serde::{Deserialize, Serialize};
use crate::error::AttractorError;
use crate::error::ArcError;
use crate::graph::Graph;
/// Severity level for validation diagnostics.
@ -48,11 +48,11 @@ pub fn validate(graph: &Graph, extra_rules: &[&dyn LintRule]) -> Vec<Diagnostic>
/// diagnostics are found.
///
/// # Errors
/// Returns `AttractorError::Validation` if any Error-severity diagnostics are found.
/// Returns `ArcError::Validation` if any Error-severity diagnostics are found.
pub fn validate_or_raise(
graph: &Graph,
extra_rules: &[&dyn LintRule],
) -> Result<Vec<Diagnostic>, AttractorError> {
) -> Result<Vec<Diagnostic>, ArcError> {
let diagnostics = validate(graph, extra_rules);
let errors: Vec<&Diagnostic> = diagnostics
.iter()
@ -60,7 +60,7 @@ pub fn validate_or_raise(
.collect();
if !errors.is_empty() {
let messages: Vec<String> = errors.iter().map(|d| d.message.clone()).collect();
return Err(AttractorError::Validation(messages.join("; ")));
return Err(ArcError::Validation(messages.join("; ")));
}
Ok(diagnostics)
}

View file

@ -1,7 +1,7 @@
//! Integration tests for `DaytonaExecutionEnvironment`.
//!
//! These tests require a `DAYTONA_API_KEY` environment variable and network access.
//! Run with: `cargo test --package attractor -- --ignored daytona`
//! Run with: `cargo test --package arc-workflows -- --ignored daytona`
use std::collections::HashMap;
use std::path::Path;
@ -13,7 +13,7 @@ use arc_workflows::checkpoint::Checkpoint;
use arc_workflows::context::Context;
use arc_workflows::daytona_env::{DaytonaConfig, DaytonaExecutionEnvironment};
use arc_workflows::engine::{PipelineEngine, RunConfig};
use arc_workflows::error::AttractorError;
use arc_workflows::error::ArcError;
use arc_workflows::event::EventEmitter;
use arc_workflows::graph::{AttrValue, Edge, Graph, Node};
use arc_workflows::handler::exit::ExitHandler;
@ -179,7 +179,7 @@ async fn daytona_artifact_sync_uploads_and_rewrites_pointer() {
// Pointer should be rewritten to the Daytona working directory
let new_pointer = updates["response.plan"].as_str().unwrap();
let expected_prefix = format!(
"file://{}/.attractor/artifacts/",
"file://{}/.arc/artifacts/",
env.working_directory()
);
assert!(
@ -220,7 +220,7 @@ impl Handler for LargeOutputHandler {
_graph: &Graph,
_logs_root: &Path,
_services: &arc_workflows::handler::EngineServices,
) -> Result<Outcome, AttractorError> {
) -> Result<Outcome, ArcError> {
let mut outcome = Outcome::success();
let large_value = "x".repeat(150 * 1024);
outcome.context_updates.insert(
@ -289,7 +289,7 @@ async fn daytona_pipeline_artifact_offload_and_sync() {
.expect("context should have response.big_output");
let pointer_str = pointer_value.as_str().expect("pointer should be a string");
let expected_prefix = format!(
"file://{}/.attractor/artifacts/",
"file://{}/.arc/artifacts/",
env.working_directory()
);
assert!(
@ -336,7 +336,7 @@ impl Handler for FileWriterHandler {
_graph: &Graph,
_logs_root: &Path,
services: &arc_workflows::handler::EngineServices,
) -> Result<Outcome, AttractorError> {
) -> Result<Outcome, ArcError> {
let content = format!("output from {}", node.id);
let cmd = format!("echo '{content}' > {}.txt", node.id);
let _ = services.execution_env.exec_command(&cmd, 10_000, None, None, None).await;

View file

@ -5,7 +5,7 @@ use std::sync::Arc;
use arc_workflows::checkpoint::Checkpoint;
use arc_workflows::context::Context;
use arc_workflows::engine::{PipelineEngine, RunConfig};
use arc_workflows::error::AttractorError;
use arc_workflows::error::ArcError;
use arc_workflows::event::{EventEmitter, PipelineEvent};
use arc_workflows::graph::{AttrValue, Edge, Graph, Node};
use arc_workflows::handler::codergen::{CodergenBackend, CodergenHandler, CodergenResult};
@ -460,7 +460,7 @@ impl Handler for AlwaysFailHandler {
_graph: &Graph,
_logs_root: &Path,
_services: &arc_workflows::handler::EngineServices,
) -> Result<Outcome, arc_workflows::error::AttractorError> {
) -> Result<Outcome, arc_workflows::error::ArcError> {
Ok(Outcome::fail(format!("forced failure for {}", node.id)))
}
}
@ -574,7 +574,7 @@ async fn goal_gate_routes_to_retry_target_when_present() {
_graph: &Graph,
_logs_root: &Path,
_services: &arc_workflows::handler::EngineServices,
) -> Result<Outcome, arc_workflows::error::AttractorError> {
) -> Result<Outcome, arc_workflows::error::ArcError> {
let count = self
.call_count
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
@ -896,7 +896,7 @@ async fn retry_on_failure_then_succeed() {
_graph: &Graph,
_logs_root: &Path,
_services: &arc_workflows::handler::EngineServices,
) -> Result<Outcome, AttractorError> {
) -> Result<Outcome, ArcError> {
let count = self
.call_count
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
@ -1104,7 +1104,7 @@ impl CodergenBackend for MockCodergenBackend {
_emitter: &Arc<EventEmitter>,
_stage_dir: &std::path::Path,
_execution_env: &Arc<dyn arc_agent::ExecutionEnvironment>,
) -> Result<CodergenResult, AttractorError> {
) -> Result<CodergenResult, ArcError> {
Ok(CodergenResult::Text {
text: format!(
"Response for {}: processed prompt '{}'",
@ -1136,7 +1136,7 @@ impl Handler for CounterHandler {
_graph: &Graph,
_logs_root: &Path,
_services: &arc_workflows::handler::EngineServices,
) -> Result<Outcome, AttractorError> {
) -> Result<Outcome, ArcError> {
let count = self
.call_count
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
@ -1160,7 +1160,7 @@ impl Handler for LargeOutputHandler {
_graph: &Graph,
_logs_root: &Path,
_services: &arc_workflows::handler::EngineServices,
) -> Result<Outcome, AttractorError> {
) -> Result<Outcome, ArcError> {
let mut outcome = Outcome::success();
// 150KB string — well above the 100KB artifact threshold
let large_value = "x".repeat(150 * 1024);
@ -1184,7 +1184,7 @@ impl Handler for ContextSetterHandler {
_graph: &Graph,
_logs_root: &Path,
_services: &arc_workflows::handler::EngineServices,
) -> Result<Outcome, AttractorError> {
) -> Result<Outcome, ArcError> {
let mut outcome = Outcome::success();
outcome
.context_updates
@ -1947,7 +1947,7 @@ async fn branching_loop_back_on_failure() {
_graph: &Graph,
_logs_root: &Path,
_services: &arc_workflows::handler::EngineServices,
) -> Result<Outcome, AttractorError> {
) -> Result<Outcome, ArcError> {
let count = self
.call_count
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
@ -2263,7 +2263,7 @@ async fn scenario_node_retries_on_retry_status() {
_graph: &Graph,
_logs_root: &Path,
_services: &arc_workflows::handler::EngineServices,
) -> Result<Outcome, AttractorError> {
) -> Result<Outcome, ArcError> {
let count = self
.call_count
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
@ -2508,7 +2508,7 @@ async fn manager_loop_stop_condition_satisfied_e2e() {
_graph: &Graph,
_logs_root: &Path,
_services: &arc_workflows::handler::EngineServices,
) -> Result<Outcome, AttractorError> {
) -> Result<Outcome, ArcError> {
let mut outcome = Outcome::success();
outcome
.context_updates
@ -2980,7 +2980,7 @@ async fn custom_handler_registration_and_execution() {
_graph: &Graph,
_logs_root: &Path,
_services: &arc_workflows::handler::EngineServices,
) -> Result<Outcome, AttractorError> {
) -> Result<Outcome, ArcError> {
let mut outcome = Outcome::success();
outcome
.context_updates
@ -3184,7 +3184,7 @@ async fn manager_loop_with_child_observer_e2e() {
_dotfile: &str,
_workdir: &str,
_context: &arc_workflows::context::Context,
) -> Result<(), arc_workflows::error::AttractorError> {
) -> Result<(), arc_workflows::error::ArcError> {
self.launch_count.fetch_add(1, Ordering::SeqCst);
Ok(())
}
@ -3192,7 +3192,7 @@ async fn manager_loop_with_child_observer_e2e() {
async fn observe(
&self,
context: &arc_workflows::context::Context,
) -> Result<(), arc_workflows::error::AttractorError> {
) -> Result<(), arc_workflows::error::ArcError> {
let cycle = self.observe_count.fetch_add(1, Ordering::SeqCst);
if cycle >= 2 {
context.set(
@ -3211,7 +3211,7 @@ async fn manager_loop_with_child_observer_e2e() {
&self,
_context: &arc_workflows::context::Context,
_node: &arc_workflows::graph::Node,
) -> Result<(), arc_workflows::error::AttractorError> {
) -> Result<(), arc_workflows::error::ArcError> {
Ok(())
}
}
@ -3496,7 +3496,7 @@ impl Handler for FidelityCapturingHandler {
_graph: &Graph,
_logs_root: &Path,
_services: &arc_workflows::handler::EngineServices,
) -> Result<Outcome, AttractorError> {
) -> Result<Outcome, ArcError> {
let fidelity = context.get_string("internal.fidelity", "none");
self.captures
.fidelities
@ -4993,7 +4993,7 @@ mod real_llm {
use async_trait::async_trait;
use arc_workflows::context::Context;
use arc_workflows::error::AttractorError;
use arc_workflows::error::ArcError;
use arc_workflows::graph::Node;
use arc_workflows::handler::codergen::{CodergenBackend, CodergenHandler, CodergenResult};
@ -5016,7 +5016,7 @@ mod real_llm {
_emitter: &Arc<EventEmitter>,
_stage_dir: &std::path::Path,
_execution_env: &Arc<dyn arc_agent::ExecutionEnvironment>,
) -> Result<CodergenResult, AttractorError> {
) -> Result<CodergenResult, ArcError> {
let request = Request {
model: self.model.clone(),
messages: vec![Message::user(prompt)],
@ -5036,7 +5036,7 @@ mod real_llm {
.client
.complete(&request)
.await
.map_err(|e| AttractorError::Handler(e.to_string()))?;
.map_err(|e| ArcError::Handler(e.to_string()))?;
Ok(CodergenResult::Text { text: response.text(), usage: None, files_touched: Vec::new() })
}
}
@ -6485,11 +6485,11 @@ async fn tool_hooks_node_level_overrides_graph_level() {
#[tokio::test]
async fn tool_hooks_pre_receives_node_id_env_var() {
// Use a pre-hook that writes the ATTRACTOR_NODE_ID env var to a file
// Use a pre-hook that writes the ARC_NODE_ID env var to a file
let dir = tempfile::tempdir().unwrap();
let marker_path = dir.path().join("node_id.txt");
let hook_cmd = format!(
"echo $ATTRACTOR_NODE_ID > {}",
"echo $ARC_NODE_ID > {}",
marker_path.display()
);
@ -6524,7 +6524,7 @@ async fn tool_hooks_pre_receives_node_id_env_var() {
assert_eq!(
written.trim(),
"my_step",
"ATTRACTOR_NODE_ID should contain the node id"
"ARC_NODE_ID should contain the node id"
);
}
@ -6566,7 +6566,7 @@ static TEST_STYLES: Styles = Styles::new(false);
#[tokio::test]
#[ignore = "requires ANTHROPIC_API_KEY"]
async fn attractor_e2e_with_real_llm() {
async fn arc_e2e_with_real_llm() {
dotenvy::dotenv().ok();
let dir = tempfile::tempdir().unwrap();
@ -7057,7 +7057,7 @@ async fn artifact_pointers_rewritten_for_remote_execution_env() {
.expect("context should have response.big_output");
let pointer_str = pointer_value.as_str().expect("pointer should be a string");
assert!(
pointer_str.starts_with("file:///sandbox/.attractor/artifacts/"),
pointer_str.starts_with("file:///sandbox/.arc/artifacts/"),
"pointer should reference remote path, got: {pointer_str}"
);
@ -7094,7 +7094,7 @@ async fn node_dir_uses_visit_count_on_revisit() {
_graph: &Graph,
_logs_root: &Path,
_services: &arc_workflows::handler::EngineServices,
) -> Result<Outcome, arc_workflows::error::AttractorError> {
) -> Result<Outcome, arc_workflows::error::ArcError> {
let n = self.call_count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
if n == 0 {
Ok(Outcome::fail("first attempt fails"))
@ -7338,7 +7338,7 @@ async fn cli_backend_run_writes_prompt_and_calls_exec() {
// Verify prompt was written
let written = test_env.recorded_written_files();
assert_eq!(written.len(), 1, "should write exactly one file (the prompt)");
assert_eq!(written[0].0, "/tmp/attractor_cli_prompt.txt");
assert_eq!(written[0].0, "/tmp/arc_cli_prompt.txt");
assert_eq!(written[0].1, "Fix the authentication bug");
// Verify the CLI command was called
@ -7346,7 +7346,7 @@ async fn cli_backend_run_writes_prompt_and_calls_exec() {
let cli_cmd = commands.iter().find(|c| c.contains("claude")).expect("should call claude CLI");
assert!(cli_cmd.contains("-p"), "should use pipe mode");
assert!(cli_cmd.contains("claude-opus-4-6"), "should use correct model");
assert!(cli_cmd.contains("/tmp/attractor_cli_prompt.txt"), "should reference prompt file");
assert!(cli_cmd.contains("/tmp/arc_cli_prompt.txt"), "should reference prompt file");
// Verify parsed response
match result {

View file

@ -1,6 +1,6 @@
# Spec Format Reference
Common format shared by `specs/unified-llm-spec.md`, `specs/coding-agent-loop-spec.md` (agent crate), and `specs/attractor-spec.md`.
Common format shared by `specs/unified-llm-spec.md`, `specs/coding-agent-loop-spec.md` (agent crate), and `specs/arc-spec.md`.
---
@ -38,7 +38,7 @@ An ASCII art box diagram showing the layers/components and how they connect. All
### 4.5 Relationship to Companion Specs
When the spec depends on another (agent depends on unified-llm; attractor depends on agent), it states this explicitly with the types it imports and how the layering works.
When the spec depends on another (agent depends on unified-llm; arc depends on agent), it states this explicitly with the types it imports and how the layering works.
## 5. Core Technical Sections

View file

@ -1,21 +1,21 @@
# Attractor
# Arc
This repository contains [NLSpecs](#terminology) to build your own version of Attractor to create your own software factory.
This repository contains [NLSpecs](#terminology) to build your own version of Arc to create your own software factory.
Although bringing your own agentic loop and unified LLM SDK is not required to build your own Attractor, we highly recommend controlling the stack so you have a strong foundation.
Although bringing your own agentic loop and unified LLM SDK is not required to build your own Arc, we highly recommend controlling the stack so you have a strong foundation.
## Specs
- [Attractor Specification](./attractor-spec.md)
- [Arc Specification](./arc-spec.md)
- [Agent Specification](./coding-agent-loop-spec.md)
- [Unified LLM Client Specification](./unified-llm-spec.md)
## Building Attractor
## Building Arc
Supply the following prompt to a modern coding agent (Claude Code, Codex, OpenCode, Amp, Cursor, etc):
```
codeagent> Implement Attractor as described by https://factory.strongdm.ai/
codeagent> Implement Arc as described by https://factory.strongdm.ai/
```
## Terminology

View file

@ -1,4 +1,4 @@
# Attractor Specification
# Arc Specification
A DOT-based pipeline runner that uses directed graphs (defined in Graphviz DOT syntax) to orchestrate multi-stage AI workflows. Each node in the graph is an AI task (LLM call, human review, conditional branch, parallel fan-out, etc.) and edges define the flow between them.
@ -26,7 +26,7 @@ A DOT-based pipeline runner that uses directed graphs (defined in Graphviz DOT s
AI-powered software workflows -- code generation, code review, testing, deployment planning -- often require multiple LLM calls chained together with conditional logic, human approvals, and parallel execution. Without a structured orchestration layer, developers either write fragile imperative scripts or build ad-hoc state machines that are difficult to visualize, version, or debug.
Attractor solves this by letting pipeline authors define multi-stage AI workflows as directed graphs using Graphviz DOT syntax. The graph is the workflow: nodes are tasks, edges are transitions, and attributes configure behavior. The result is a declarative, visual, version-controllable pipeline definition that an execution engine can traverse deterministically.
Arc solves this by letting pipeline authors define multi-stage AI workflows as directed graphs using Graphviz DOT syntax. The graph is the workflow: nodes are tasks, edges are transitions, and attributes configure behavior. The result is a declarative, visual, version-controllable pipeline definition that an execution engine can traverse deterministically.
### 1.2 Why DOT Syntax
@ -53,11 +53,11 @@ For reference on DOT syntax, see the Graphviz DOT language specification: https:
### 1.4 Layering and LLM Backends
Attractor defines the orchestration layer: graph definition, traversal, state management, and extensibility. It does NOT require any specific LLM integration. The codergen handler (Section 4.5) needs a way to call an LLM and get a response -- how you provide that is up to you.
Arc defines the orchestration layer: graph definition, traversal, state management, and extensibility. It does NOT require any specific LLM integration. The codergen handler (Section 4.5) needs a way to call an LLM and get a response -- how you provide that is up to you.
The codergen handler takes a backend that conforms to the `CodergenBackend` interface (Section 4.5). What that backend does internally is entirely up to the implementor -- use the companion [Agent](./coding-agent-loop-spec.md) and [Unified LLM Client](./unified-llm-spec.md) specs, spawn CLI agents (Claude Code, Codex, Gemini CLI) in subprocesses, run agents in tmux panes with a manager attaching to them, call an LLM API directly, or anything else. The pipeline definition (the DOT file) does not change regardless of backend choice.
Attractor pipelines are driven by an event stream (Section 9.6). TUI, web, and IDE frontends consume events and submit human-in-the-loop answers. The pipeline engine is headless; the presentation layer is separate.
Arc pipelines are driven by an event stream (Section 9.6). TUI, web, and IDE frontends consume events and submit human-in-the-loop answers. The pipeline engine is headless; the presentation layer is separate.
---
@ -65,7 +65,7 @@ Attractor pipelines are driven by an event stream (Section 9.6). TUI, web, and I
### 2.1 Supported Subset
Attractor accepts a strict subset of the Graphviz DOT language. The restrictions exist for predictability: one graph per file, directed edges only, no HTML labels, and typed attributes with defaults.
Arc accepts a strict subset of the Graphviz DOT language. The restrictions exist for predictability: one graph per file, directed edges only, no HTML labels, and typed attributes with defaults.
### 2.2 BNF-Style Grammar
@ -1277,7 +1277,7 @@ Each pipeline execution produces a directory tree for logging, checkpoints, and
### 6.1 Interviewer Interface
All human interaction in Attractor goes through an Interviewer interface. This abstraction allows the pipeline to present questions to a human and receive answers through any frontend: CLI, web UI, Slack bot, or a programmatic queue for testing.
All human interaction in Arc goes through an Interviewer interface. This abstraction allows the pipeline to present questions to a human and receive answers through any frontend: CLI, web UI, Slack bot, or a programmatic queue for testing.
```
INTERFACE Interviewer:
@ -1624,7 +1624,7 @@ Custom transforms run after built-in transforms. Order of custom transforms foll
### 9.4 Pipeline Composition
Attractor supports combining multiple DOT graphs through:
Arc supports combining multiple DOT graphs through:
**Sub-pipeline nodes:** A node whose handler runs an entire sub-graph as its execution. The manager loop handler (Section 4.11) is an example of this pattern.