use anyhow::{bail, Context, Result};
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::{Arc, Mutex};
use std::time::{SystemTime, UNIX_EPOCH};
use crate::Gap;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FaultMode {
SpawnFail,
Exit1,
Exit0NoPr,
MonitorTimeout,
}
pub fn active_fault_mode() -> Option<FaultMode> {
let raw = std::env::var("CHUMP_FAULT_INJECT").ok()?;
for token in raw.split(',') {
match token.trim() {
"spawn_fail" => return Some(FaultMode::SpawnFail),
"exit_1" => return Some(FaultMode::Exit1),
"exit_0_no_pr" => return Some(FaultMode::Exit0NoPr),
"monitor_timeout" => return Some(FaultMode::MonitorTimeout),
_ => {}
}
}
None
}
pub const STDERR_TAIL_CAP: usize = 64;
pub type StderrTail = Arc<Mutex<Vec<String>>>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DispatchBackend {
Claude,
ChumpLocal,
}
impl DispatchBackend {
pub fn from_env() -> Self {
match std::env::var("CHUMP_DISPATCH_BACKEND")
.ok()
.as_deref()
.map(str::trim)
{
Some("") | None => DispatchBackend::Claude,
Some("claude") => DispatchBackend::Claude,
Some("chump-local") | Some("chump_local") | Some("local") => {
DispatchBackend::ChumpLocal
}
Some(other) => {
eprintln!(
"[dispatch] WARN unknown CHUMP_DISPATCH_BACKEND={other:?}; \
falling back to 'claude' (valid: claude | chump-local)"
);
DispatchBackend::Claude
}
}
}
pub fn label(self) -> &'static str {
match self {
DispatchBackend::Claude => "claude",
DispatchBackend::ChumpLocal => "chump-local",
}
}
}
pub type SpawnResult = (Option<Child>, Option<StderrTail>);
#[derive(Debug)]
pub struct DispatchHandle {
pub gap_id: String,
pub worktree_path: PathBuf,
pub branch_name: String,
pub child_pid: Option<u32>,
pub started_at_unix: u64,
pub child: Option<Child>,
pub stderr_tail: Option<StderrTail>,
pub backend: DispatchBackend,
}
impl DispatchHandle {
pub fn stderr_tail_snapshot(&self) -> String {
match &self.stderr_tail {
Some(buf) => match buf.lock() {
Ok(g) => g.join("\n"),
Err(_) => String::new(),
},
None => String::new(),
}
}
}
pub trait Spawner {
fn create_worktree(&self, worktree: &Path, branch: &str, base: &str) -> Result<()>;
fn claim_gap(&self, worktree: &Path, gap_id: &str) -> Result<()>;
fn spawn_claude(&self, worktree: &Path, prompt: &str) -> Result<SpawnResult>;
}
pub struct RealSpawner;
impl Spawner for RealSpawner {
fn create_worktree(&self, worktree: &Path, branch: &str, base: &str) -> Result<()> {
let status = Command::new("git")
.args([
"worktree",
"add",
worktree.to_str().context("worktree path not utf-8")?,
"-b",
branch,
base,
])
.status()
.context("spawning git worktree add")?;
if !status.success() {
bail!("git worktree add failed for {}", worktree.display());
}
Ok(())
}
fn claim_gap(&self, worktree: &Path, gap_id: &str) -> Result<()> {
let script = worktree.join("scripts").join("gap-claim.sh");
let status = Command::new("bash")
.arg(script)
.arg(gap_id)
.current_dir(worktree)
.status()
.context("spawning gap-claim.sh")?;
if !status.success() {
bail!("gap-claim.sh failed for {gap_id} in {}", worktree.display());
}
Ok(())
}
fn spawn_claude(&self, worktree: &Path, prompt: &str) -> Result<SpawnResult> {
if let Some(fault) = active_fault_mode() {
return spawn_fault_process(fault);
}
match DispatchBackend::from_env() {
DispatchBackend::Claude => self.spawn_claude_cli(worktree, prompt),
DispatchBackend::ChumpLocal => self.spawn_chump_local(worktree, prompt),
}
}
}
pub fn spawn_fault_process(fault: FaultMode) -> Result<SpawnResult> {
match fault {
FaultMode::SpawnFail => {
bail!("[fault-inject] spawn_fail: dispatch returns error immediately");
}
FaultMode::Exit1 => {
let child = Command::new("sh")
.args(["-c", "sleep 0.1; exit 1"])
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.context("[fault-inject] exit_1: spawning sh")?;
Ok((Some(child), None))
}
FaultMode::Exit0NoPr => {
let child = Command::new("sh")
.args(["-c", "sleep 0.1; exit 0"])
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.context("[fault-inject] exit_0_no_pr: spawning sh")?;
Ok((Some(child), None))
}
FaultMode::MonitorTimeout => {
let child = Command::new("sh")
.args(["-c", "sleep 3600"])
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.context("[fault-inject] monitor_timeout: spawning sh")?;
Ok((Some(child), None))
}
}
}
impl RealSpawner {
fn spawn_claude_cli(&self, worktree: &Path, prompt: &str) -> Result<SpawnResult> {
let claude_bin = std::env::var("CHUMP_CLAUDE_BIN").unwrap_or_else(|_| {
let wrapper = std::env::current_dir()
.ok()
.map(|d| d.join("scripts").join("claude-retry.sh"));
match wrapper {
Some(p) if p.exists() => p.to_string_lossy().into_owned(),
_ => "claude".to_string(),
}
});
let mut child = Command::new(&claude_bin)
.arg("-p")
.arg(prompt)
.arg("--dangerously-skip-permissions")
.current_dir(worktree)
.env("CHUMP_DISPATCH_DEPTH", "1")
.env("GIT_AUTHOR_NAME", "Chump Dispatched")
.env("GIT_AUTHOR_EMAIL", "chump-dispatch@chump.bot")
.env("GIT_COMMITTER_NAME", "Chump Dispatched")
.env("GIT_COMMITTER_EMAIL", "chump-dispatch@chump.bot")
.stderr(Stdio::piped())
.spawn()
.with_context(|| format!("spawning claude CLI via {claude_bin}"))?;
let buf: StderrTail = Arc::new(Mutex::new(Vec::new()));
if let Some(stderr) = child.stderr.take() {
let buf_thread = Arc::clone(&buf);
std::thread::Builder::new()
.name("orchestrator-stderr-tail".into())
.spawn(move || {
let reader = BufReader::new(stderr);
for line in reader.lines().map_while(Result::ok) {
let upper = line.to_uppercase();
if upper.contains("ERROR")
|| upper.contains("WARN")
|| upper.contains("FAIL")
|| upper.contains("PANIC")
{
if let Ok(mut g) = buf_thread.lock() {
if g.len() >= STDERR_TAIL_CAP {
g.remove(0);
}
g.push(line);
}
}
}
})
.ok(); }
Ok((Some(child), Some(buf)))
}
fn spawn_chump_local(&self, worktree: &Path, prompt: &str) -> Result<SpawnResult> {
let gap_id = parse_gap_id_from_prompt(prompt).ok_or_else(|| {
anyhow::anyhow!(
"spawn_chump_local: could not extract gap id from prompt — \
expected `working on gap <ID>` token"
)
})?;
let bin = resolve_chump_local_bin(worktree);
let mut cmd = Command::new(&bin);
cmd.arg("--execute-gap")
.arg(&gap_id)
.current_dir(worktree)
.env("CHUMP_DISPATCH_DEPTH", "1")
.env("CHUMP_DISPATCH_BACKEND_LABEL", "chump-local")
.env("GIT_AUTHOR_NAME", "Chump Dispatched")
.env("GIT_AUTHOR_EMAIL", "chump-dispatch@chump.bot")
.env("GIT_COMMITTER_NAME", "Chump Dispatched")
.env("GIT_COMMITTER_EMAIL", "chump-dispatch@chump.bot")
.stderr(Stdio::piped());
for var in ["OPENAI_API_BASE", "OPENAI_MODEL", "OPENAI_API_KEY"] {
if let Ok(v) = std::env::var(var) {
cmd.env(var, v);
}
}
let mut child = cmd
.spawn()
.with_context(|| format!("spawning chump-local backend via {}", bin.display()))?;
let buf: StderrTail = Arc::new(Mutex::new(Vec::new()));
if let Some(stderr) = child.stderr.take() {
let buf_thread = Arc::clone(&buf);
std::thread::Builder::new()
.name("orchestrator-stderr-tail-chump-local".into())
.spawn(move || {
let reader = BufReader::new(stderr);
for line in reader.lines().map_while(Result::ok) {
let upper = line.to_uppercase();
if upper.contains("ERROR")
|| upper.contains("WARN")
|| upper.contains("FAIL")
|| upper.contains("PANIC")
{
if let Ok(mut g) = buf_thread.lock() {
if g.len() >= STDERR_TAIL_CAP {
g.remove(0);
}
g.push(line);
}
}
}
})
.ok();
}
Ok((Some(child), Some(buf)))
}
}
fn resolve_chump_local_bin(worktree: &Path) -> PathBuf {
if let Ok(p) = std::env::var("CHUMP_LOCAL_BIN") {
return PathBuf::from(p);
}
let mut probe = worktree.to_path_buf();
for _ in 0..5 {
let release = probe.join("target/release/chump");
if release.is_file() {
return release;
}
let debug = probe.join("target/debug/chump");
if debug.is_file() {
return debug;
}
if !probe.pop() {
break;
}
}
PathBuf::from("chump")
}
fn parse_gap_id_from_prompt(prompt: &str) -> Option<String> {
let marker = "working on gap ";
let start = prompt.find(marker)? + marker.len();
let rest = &prompt[start..];
let end = rest
.find(|c: char| !(c.is_ascii_uppercase() || c.is_ascii_digit() || c == '-'))
.unwrap_or(rest.len());
let id = &rest[..end];
if id.is_empty() {
None
} else {
Some(id.to_string())
}
}
pub fn build_prompt(gap_id: &str, repo_root: &Path) -> String {
let rules =
std::fs::read_to_string(repo_root.join("docs/CHUMP_DISPATCH_RULES.md")).unwrap_or_default();
let rules_block = if rules.is_empty() {
String::new()
} else {
format!("{rules}\n\n---\n\n")
};
format!(
"{rules}You are a Chump dispatched agent working on gap {gap}. \
The gap is already claimed in this worktree. \
Read the gap entry in docs/gaps.yaml for full acceptance criteria. \
Do the work, then ship via:\n scripts/bot-merge.sh --gap {gap} --auto-merge\n\
After ship, exit. Reply ONLY with the PR number.",
rules = rules_block,
gap = gap_id
)
}
pub fn dispatch_paths(repo_root: &Path, gap_id: &str) -> (PathBuf, String) {
let slug = gap_id.to_ascii_lowercase().replace('_', "-");
let worktree = repo_root.join(".claude").join("worktrees").join(&slug);
let branch = format!("claude/{slug}");
(worktree, branch)
}
pub fn dispatch_gap_with<S: Spawner>(
spawner: &S,
gap: &Gap,
repo_root: &Path,
base_ref: &str,
) -> Result<DispatchHandle> {
let (worktree, branch) = dispatch_paths(repo_root, &gap.id);
spawner
.create_worktree(&worktree, &branch, base_ref)
.with_context(|| format!("creating worktree {} for {}", worktree.display(), gap.id))?;
spawner
.claim_gap(&worktree, &gap.id)
.with_context(|| format!("claiming lease for {} in {}", gap.id, worktree.display()))?;
let prompt = build_prompt(&gap.id, repo_root);
let (child, stderr_tail) = spawner
.spawn_claude(&worktree, &prompt)
.with_context(|| format!("spawning claude for {}", gap.id))?;
let pid = child.as_ref().map(|c| c.id());
let started_at_unix = SystemTime::now()
.duration_since(UNIX_EPOCH)
.context("system clock is before UNIX epoch")?
.as_secs();
Ok(DispatchHandle {
gap_id: gap.id.clone(),
worktree_path: worktree,
branch_name: branch,
child_pid: pid,
started_at_unix,
child,
stderr_tail,
backend: DispatchBackend::from_env(),
})
}
pub fn dispatch_gap(gap: &Gap, repo_root: &Path, base_ref: &str) -> Result<DispatchHandle> {
dispatch_gap_with(&RealSpawner, gap, repo_root, base_ref)
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
#[derive(Default)]
struct RecordingSpawner {
calls: RefCell<Vec<String>>,
}
impl Spawner for RecordingSpawner {
fn create_worktree(&self, worktree: &Path, branch: &str, base: &str) -> Result<()> {
self.calls.borrow_mut().push(format!(
"worktree:{}:{}:{}",
worktree.display(),
branch,
base
));
Ok(())
}
fn claim_gap(&self, worktree: &Path, gap_id: &str) -> Result<()> {
self.calls
.borrow_mut()
.push(format!("claim:{}:{}", worktree.display(), gap_id));
Ok(())
}
fn spawn_claude(&self, worktree: &Path, prompt: &str) -> Result<SpawnResult> {
self.calls
.borrow_mut()
.push(format!("spawn:{}:{}", worktree.display(), prompt.len()));
Ok((None, None))
}
}
fn fake_gap(id: &str) -> Gap {
Gap {
id: id.into(),
title: "t".into(),
priority: "P1".into(),
effort: "m".into(),
status: "open".into(),
depends_on: None,
}
}
#[test]
fn dispatch_paths_lowercases_and_replaces_underscores() {
let (wt, branch) = dispatch_paths(Path::new("/repo"), "AUTO_013");
assert_eq!(wt, PathBuf::from("/repo/.claude/worktrees/auto-013"));
assert_eq!(branch, "claude/auto-013");
}
#[test]
fn dispatch_paths_preserves_repo_root_case() {
let (wt, branch) = dispatch_paths(Path::new("/Users/JeffAdkins/Projects/Chump"), "MEM-007");
assert_eq!(
wt,
PathBuf::from("/Users/JeffAdkins/Projects/Chump/.claude/worktrees/mem-007")
);
assert_eq!(branch, "claude/mem-007");
assert!(
wt.to_str()
.unwrap()
.starts_with("/Users/JeffAdkins/Projects/Chump"),
"repo root case must be preserved; got {}",
wt.display()
);
}
#[test]
fn dispatch_calls_steps_in_order() {
let spawner = RecordingSpawner::default();
let gap = fake_gap("AUTO-013");
let handle = dispatch_gap_with(&spawner, &gap, Path::new("/repo"), "origin/main").unwrap();
let calls = spawner.calls.borrow();
assert_eq!(calls.len(), 3);
assert!(calls[0].starts_with("worktree:"), "first call = worktree");
assert!(calls[1].starts_with("claim:"), "second call = claim");
assert!(calls[2].starts_with("spawn:"), "third call = spawn");
assert_eq!(handle.gap_id, "AUTO-013");
assert_eq!(handle.branch_name, "claude/auto-013");
assert_eq!(
handle.worktree_path,
PathBuf::from("/repo/.claude/worktrees/auto-013")
);
assert!(handle.child_pid.is_none(), "recording spawner = no pid");
assert!(handle.started_at_unix > 0);
}
#[test]
fn claim_receives_exact_gap_id() {
let spawner = RecordingSpawner::default();
let gap = fake_gap("EVAL-031");
let _ = dispatch_gap_with(&spawner, &gap, Path::new("/repo"), "origin/main").unwrap();
let calls = spawner.calls.borrow();
assert!(
calls[1].ends_with(":EVAL-031"),
"claim must pass exact gap id, got {}",
calls[1]
);
}
#[test]
fn build_prompt_contains_gap_id_and_ship_command() {
let prompt = build_prompt("AUTO-013", Path::new("/nonexistent"));
assert!(prompt.contains("AUTO-013"));
assert!(prompt.contains("scripts/bot-merge.sh --gap AUTO-013 --auto-merge"));
assert!(prompt.contains("PR number"));
}
#[test]
fn build_prompt_injects_dispatch_rules_when_file_present() {
let dir = tempfile::tempdir().unwrap();
let docs = dir.path().join("docs");
std::fs::create_dir(&docs).unwrap();
std::fs::write(
docs.join("CHUMP_DISPATCH_RULES.md"),
"## rule\n- do the thing\n",
)
.unwrap();
let prompt = build_prompt("MEM-001", dir.path());
assert!(
prompt.contains("do the thing"),
"rules block should be injected"
);
assert!(prompt.contains("MEM-001"));
}
#[test]
fn build_prompt_gracefully_missing_rules_file() {
let prompt = build_prompt("EVAL-001", Path::new("/nonexistent"));
assert!(prompt.contains("EVAL-001"));
assert!(prompt.contains("bot-merge.sh"));
}
#[test]
fn stderr_tail_snapshot_returns_empty_when_no_buffer() {
let h = DispatchHandle {
gap_id: "X".into(),
worktree_path: PathBuf::from("/tmp"),
branch_name: "claude/x".into(),
child_pid: None,
started_at_unix: 0,
child: None,
stderr_tail: None,
backend: DispatchBackend::Claude,
};
assert_eq!(h.stderr_tail_snapshot(), "");
}
#[test]
fn stderr_tail_snapshot_joins_lines_with_newlines() {
let buf = Arc::new(Mutex::new(vec![
"ERROR: foo".to_string(),
"WARN: bar".to_string(),
]));
let h = DispatchHandle {
gap_id: "X".into(),
worktree_path: PathBuf::from("/tmp"),
branch_name: "claude/x".into(),
child_pid: None,
started_at_unix: 0,
child: None,
stderr_tail: Some(buf),
backend: DispatchBackend::Claude,
};
assert_eq!(h.stderr_tail_snapshot(), "ERROR: foo\nWARN: bar");
}
fn with_backend_env(value: Option<&str>, f: impl FnOnce()) {
let prev = std::env::var("CHUMP_DISPATCH_BACKEND").ok();
match value {
Some(v) => std::env::set_var("CHUMP_DISPATCH_BACKEND", v),
None => std::env::remove_var("CHUMP_DISPATCH_BACKEND"),
}
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
match prev {
Some(v) => std::env::set_var("CHUMP_DISPATCH_BACKEND", v),
None => std::env::remove_var("CHUMP_DISPATCH_BACKEND"),
}
if let Err(e) = result {
std::panic::resume_unwind(e);
}
}
#[test]
#[serial_test::serial]
fn backend_default_is_claude() {
with_backend_env(None, || {
assert_eq!(DispatchBackend::from_env(), DispatchBackend::Claude);
});
}
#[test]
#[serial_test::serial]
fn backend_recognises_chump_local() {
with_backend_env(Some("chump-local"), || {
assert_eq!(DispatchBackend::from_env(), DispatchBackend::ChumpLocal);
});
with_backend_env(Some("chump_local"), || {
assert_eq!(DispatchBackend::from_env(), DispatchBackend::ChumpLocal);
});
with_backend_env(Some("local"), || {
assert_eq!(DispatchBackend::from_env(), DispatchBackend::ChumpLocal);
});
}
#[test]
#[serial_test::serial]
fn backend_unknown_falls_back_to_claude() {
with_backend_env(Some("ollama-direct"), || {
assert_eq!(DispatchBackend::from_env(), DispatchBackend::Claude);
});
with_backend_env(Some(""), || {
assert_eq!(DispatchBackend::from_env(), DispatchBackend::Claude);
});
}
#[test]
fn backend_label_is_stable() {
assert_eq!(DispatchBackend::Claude.label(), "claude");
assert_eq!(DispatchBackend::ChumpLocal.label(), "chump-local");
}
#[test]
#[serial_test::serial]
fn dispatch_handle_records_backend() {
with_backend_env(Some("chump-local"), || {
let spawner = RecordingSpawner::default();
let gap = fake_gap("COG-025");
let h = dispatch_gap_with(&spawner, &gap, Path::new("/repo"), "origin/main").unwrap();
assert_eq!(h.backend, DispatchBackend::ChumpLocal);
});
with_backend_env(None, || {
let spawner = RecordingSpawner::default();
let gap = fake_gap("COG-025");
let h = dispatch_gap_with(&spawner, &gap, Path::new("/repo"), "origin/main").unwrap();
assert_eq!(h.backend, DispatchBackend::Claude);
});
}
#[test]
fn parse_gap_id_from_prompt_extracts_canonical() {
let prompt = build_prompt("COG-025", Path::new("/nonexistent"));
assert_eq!(
parse_gap_id_from_prompt(&prompt).as_deref(),
Some("COG-025")
);
let p2 = build_prompt("AUTO-013", Path::new("/nonexistent"));
assert_eq!(parse_gap_id_from_prompt(&p2).as_deref(), Some("AUTO-013"));
}
#[test]
fn parse_gap_id_from_prompt_returns_none_on_unknown_shape() {
assert_eq!(parse_gap_id_from_prompt("hello world"), None);
assert_eq!(parse_gap_id_from_prompt(""), None);
assert_eq!(
parse_gap_id_from_prompt("working on gap "),
None,
"empty id after marker should yield None"
);
}
#[test]
fn resolve_chump_local_bin_honors_env_override() {
std::env::set_var("CHUMP_LOCAL_BIN", "/opt/custom/chump");
let p = resolve_chump_local_bin(Path::new("/nonexistent/worktree"));
std::env::remove_var("CHUMP_LOCAL_BIN");
assert_eq!(p, PathBuf::from("/opt/custom/chump"));
}
#[test]
fn resolve_chump_local_bin_falls_back_to_path_when_no_target() {
std::env::remove_var("CHUMP_LOCAL_BIN");
let p = resolve_chump_local_bin(Path::new("/tmp/cog-025-no-target-here-xyz"));
assert_eq!(p, PathBuf::from("chump"));
}
#[test]
fn worktree_create_failure_aborts_claim_and_spawn() {
struct FailingWorktree;
impl Spawner for FailingWorktree {
fn create_worktree(&self, _w: &Path, _b: &str, _r: &str) -> Result<()> {
bail!("worktree add failed");
}
fn claim_gap(&self, _w: &Path, _g: &str) -> Result<()> {
panic!("must not be called");
}
fn spawn_claude(&self, _w: &Path, _p: &str) -> Result<SpawnResult> {
panic!("must not be called");
}
}
let gap = fake_gap("AUTO-013");
let err = dispatch_gap_with(&FailingWorktree, &gap, Path::new("/repo"), "origin/main")
.unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("worktree add failed"), "got: {msg}");
}
}