use std::path::{Path, PathBuf};
use std::process::Command;
use super::{
CancelToken, Check, CheckResult, ChunkOutcome, ChunkRequest, ChunkResult, HarnessCapabilities,
HarnessError, Usage, HARNESS_CONTRACT_VERSION,
};
use crate::proc::{run_with_control, CappedStream, ControlledOutcome, StopReason};
pub(super) const OUTPUT_CAP: usize = 8 * 1024 * 1024;
pub(super) trait AgentLaunch {
fn capabilities(&self) -> HarnessCapabilities;
fn commits_in_agent(&self) -> bool {
true
}
fn check_credentials(&self) -> Result<(), HarnessError>;
fn build_prompt(&self, req: &ChunkRequest) -> String;
fn build_command(
&self,
worktree: &Path,
brief_file: &Path,
prompt: &str,
req: &ChunkRequest,
) -> Command;
fn parse_usage(&self, transcript: &str) -> Option<Usage>;
fn tool_label(&self) -> &'static str;
fn bin_display(&self) -> String;
}
pub(super) fn commit_framed_prompt(req: &ChunkRequest) -> String {
use std::fmt::Write as _;
let mut p = String::new();
p.push_str(req.brief.trim_end());
p.push_str("\n\n---\n\n");
p.push_str(
"You are running NON-INTERACTIVELY inside a throwaway git worktree. \
Implement the task above by editing files in the current working directory.\n\n",
);
if !req.checks.is_empty() {
p.push_str(
"Before you commit, run each of these self-check commands and make sure it passes:\n",
);
for c in &req.checks {
let _ = writeln!(p, " - {} — `{}`", c.desc, c.run);
}
p.push('\n');
}
p.push_str(
"When the work is complete, stage and commit ALL of your changes on the CURRENT branch \
with `git add -A && git commit`. Do NOT push and do NOT merge — commit only. \
If there is genuinely nothing to change, make no commit.\n",
);
p
}
pub(super) fn credential_present(var: &str) -> bool {
std::env::var_os(var).is_some_and(|v| !v.is_empty())
}
pub(super) fn git_bin() -> String {
std::env::var("GIT_BIN").unwrap_or_else(|_| "git".to_string())
}
pub(super) fn parse_json_usage(transcript: &str) -> Option<Usage> {
let objects: Vec<serde_json::Value> = transcript
.lines()
.filter_map(|l| {
let t = l.trim();
t.starts_with('{')
.then(|| serde_json::from_str::<serde_json::Value>(t).ok())
.flatten()
})
.collect();
let pick = |objs: &[serde_json::Value]| {
objs.iter()
.rev()
.find(|v| v.get("type").and_then(serde_json::Value::as_str) == Some("result"))
.and_then(usage_from_value)
.or_else(|| objs.iter().rev().find_map(usage_from_value))
};
pick(&objects).or_else(|| {
serde_json::from_str::<serde_json::Value>(transcript.trim())
.ok()
.as_ref()
.and_then(usage_from_value)
})
}
fn usage_from_value(v: &serde_json::Value) -> Option<Usage> {
let nested = v.get("usage");
let u = nested.unwrap_or(v);
let get_u64 = |obj: &serde_json::Value, keys: &[&str]| {
keys.iter()
.find_map(|k| obj.get(*k).and_then(serde_json::Value::as_u64))
};
let get_f64 = |obj: &serde_json::Value, keys: &[&str]| {
keys.iter()
.find_map(|k| obj.get(*k).and_then(serde_json::Value::as_f64))
};
let (input_keys, output_keys, total_keys): (&[&str], &[&str], &[&str]) = if nested.is_some() {
(
&["input_tokens", "prompt_tokens", "input"],
&["output_tokens", "completion_tokens", "output"],
&["total_tokens", "tokens", "totalTokens"],
)
} else {
(
&["input_tokens", "prompt_tokens"],
&["output_tokens", "completion_tokens"],
&["total_tokens", "tokens"],
)
};
let input = get_u64(u, input_keys);
let output = get_u64(u, output_keys);
let nested_cost = u
.get("cost")
.and_then(|c| c.get("total"))
.and_then(serde_json::Value::as_f64);
let cost = get_f64(v, &["total_cost_usd", "cost_usd"])
.or_else(|| get_f64(u, &["total_cost_usd", "cost_usd"]))
.or(nested_cost)
.or_else(|| get_f64(v, &["cost"]))
.or_else(|| get_f64(u, &["cost"]));
let total = get_u64(u, total_keys)
.or_else(|| input.and_then(|i| output.and_then(|o| i.checked_add(o))));
if input.is_none() && output.is_none() && total.is_none() && cost.is_none() {
return None;
}
Some(Usage {
input_tokens: input,
output_tokens: output,
total_tokens: total,
cost_usd: cost,
})
}
fn git(worktree: &Path, args: &[&str]) -> Result<String, HarnessError> {
let out = Command::new(git_bin())
.arg("-C")
.arg(worktree)
.args(args)
.output()
.map_err(|e| HarnessError::InvalidWorktree {
message: format!("could not run git in {}: {e}", worktree.display()),
})?;
if !out.status.success() {
return Err(HarnessError::InvalidWorktree {
message: format!(
"git {} failed in {}: {}",
args.join(" "),
worktree.display(),
String::from_utf8_lossy(&out.stderr).trim()
),
});
}
Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
}
fn commit_leftover_changes(worktree: &Path, req: &ChunkRequest, tool: &str) {
match worktree_status(worktree) {
Ok(status) if status.is_empty() => return,
Ok(_) => {}
Err(e) => {
tracing::warn!(error = %e, tool, "could not read worktree status before adapter commit");
return;
}
}
if let Err(e) = git(worktree, &["add", "-A", "--", ".", ":(exclude).aider*"]) {
tracing::warn!(error = %e, tool, "adapter `git add` failed; leaving tree for the Failed mapping");
return;
}
let message = format!(
"{tool}: chunk {chunk} attempt {attempt} (adapter-committed)",
chunk = req.chunk_id,
attempt = req.attempt_id,
);
let args = [
"-c",
"user.name=orchestratectl",
"-c",
"user.email=orchestratectl@localhost",
"commit",
"-q",
"--no-verify",
"--no-gpg-sign",
"-m",
message.as_str(),
];
if let Err(e) = git(worktree, &args) {
tracing::warn!(error = %e, tool, "adapter `git commit` failed; leaving tree for the Failed mapping");
}
}
fn head_oid(worktree: &Path) -> Result<String, HarnessError> {
let sha = git(worktree, &["rev-parse", "HEAD"])?;
let ok = matches!(sha.len(), 40 | 64) && sha.chars().all(|c| c.is_ascii_hexdigit());
if ok {
Ok(sha)
} else {
Err(HarnessError::InvalidWorktree {
message: format!("`git rev-parse HEAD` returned a non-oid value: {sha:?}"),
})
}
}
fn worktree_status(worktree: &Path) -> Result<String, HarnessError> {
git(worktree, &["status", "--porcelain"])
}
fn changed_files(worktree: &Path, base: &str, head: &str) -> Result<Vec<PathBuf>, HarnessError> {
let out = git(
worktree,
&["diff", "--name-only", "-z", &format!("{base}..{head}")],
)?;
Ok(out
.split('\0')
.filter(|l| !l.is_empty())
.map(PathBuf::from)
.collect())
}
fn is_ancestor(worktree: &Path, ancestor: &str, descendant: &str) -> Result<bool, HarnessError> {
let out = Command::new(git_bin())
.arg("-C")
.arg(worktree)
.args(["merge-base", "--is-ancestor", ancestor, descendant])
.output()
.map_err(|e| HarnessError::InvalidWorktree {
message: format!(
"could not run git merge-base in {}: {e}",
worktree.display()
),
})?;
match out.status.code() {
Some(0) => Ok(true),
Some(1) => Ok(false),
_ => Err(HarnessError::InvalidWorktree {
message: format!(
"git merge-base --is-ancestor failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
),
}),
}
}
fn resolve_commit(worktree: &Path, rev: &str) -> Result<String, HarnessError> {
git(
worktree,
&["rev-parse", "--verify", &format!("{rev}^{{commit}}")],
)
.map_err(|_| HarnessError::InvalidWorktree {
message: format!("base_commit {rev:?} does not resolve to a commit in the worktree"),
})
}
fn artifact_dir(req: &ChunkRequest) -> PathBuf {
std::env::temp_dir()
.join("octl-harness")
.join(sanitize(&req.run_id))
.join(sanitize(&req.chunk_id))
.join(sanitize(&req.attempt_id))
}
fn sanitize(s: &str) -> String {
s.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect()
}
fn run_check(worktree: &Path, check: &Check, cancel: &CancelToken) -> CheckResult {
let mut cmd = Command::new("sh");
cmd.arg("-c").arg(&check.run).current_dir(worktree);
match run_with_control(cmd, check.timeout, &|| cancel.is_cancelled(), OUTPUT_CAP) {
ControlledOutcome::Exited {
status,
stdout,
stderr,
} => CheckResult {
check_id: check.id.clone(),
desc: check.desc.clone(),
run: check.run.clone(),
passed: status.success(),
exit_code: status.code(),
stdout: render_stream(&stdout),
stderr: render_stream(&stderr),
},
ControlledOutcome::Stopped {
reason,
stdout,
stderr,
} => {
let note = match reason {
StopReason::Timeout => {
"[orchestratectl: check exceeded its timeout and was killed]"
}
StopReason::Cancelled => "[orchestratectl: check was cancelled and killed]",
};
let mut stderr = render_stream(&stderr);
if !stderr.is_empty() && !stderr.ends_with('\n') {
stderr.push('\n');
}
stderr.push_str(note);
stderr.push('\n');
CheckResult {
check_id: check.id.clone(),
desc: check.desc.clone(),
run: check.run.clone(),
passed: false,
exit_code: None,
stdout: render_stream(&stdout),
stderr,
}
}
ControlledOutcome::SpawnErr(e) => CheckResult {
check_id: check.id.clone(),
desc: check.desc.clone(),
run: check.run.clone(),
passed: false,
exit_code: None,
stdout: String::new(),
stderr: format!("could not spawn check: {e}"),
},
}
}
fn skipped_check(check: &Check) -> CheckResult {
CheckResult {
check_id: check.id.clone(),
desc: check.desc.clone(),
run: check.run.clone(),
passed: false,
exit_code: None,
stdout: String::new(),
stderr: "[orchestratectl: check not run — chunk cancelled]\n".to_string(),
}
}
pub(super) fn render_stream(stream: &CappedStream) -> String {
use std::fmt::Write as _;
let mut s = String::from_utf8_lossy(&stream.bytes).into_owned();
if stream.truncated {
if !s.is_empty() && !s.ends_with('\n') {
s.push('\n');
}
let _ = writeln!(
s,
"[orchestratectl: output truncated at {OUTPUT_CAP} bytes]"
);
}
s
}
fn render_transcript(stdout: &CappedStream, stderr: &CappedStream) -> String {
let mut t = render_stream(stdout);
if !t.is_empty() && !t.ends_with('\n') {
t.push('\n');
}
t.push_str(&render_stream(stderr));
t
}
fn write_transcript(path: &Path, transcript: &str) -> Option<PathBuf> {
match std::fs::write(path, transcript) {
Ok(()) => Some(path.to_path_buf()),
Err(e) => {
tracing::warn!(error = %e, path = %path.display(), "failed to persist harness transcript");
None
}
}
}
fn stopped_result(
reason: StopReason,
transcript_ref: Option<PathBuf>,
usage: Option<Usage>,
) -> ChunkResult {
let outcome = match reason {
StopReason::Timeout => ChunkOutcome::Timeout,
StopReason::Cancelled => ChunkOutcome::Cancelled,
};
ChunkResult {
schema_version: HARNESS_CONTRACT_VERSION,
outcome,
resulting_commit: None,
changed_files: Vec::new(),
check_results: Vec::new(),
transcript_ref,
usage,
}
}
pub(super) fn run_chunk(
launch: &dyn AgentLaunch,
req: &ChunkRequest,
cancel: &CancelToken,
) -> Result<ChunkResult, HarnessError> {
let worktree = req.worktree_path.as_path();
if cancel.is_cancelled() {
return Ok(stopped_result(StopReason::Cancelled, None, None));
}
launch.check_credentials()?;
let status = worktree_status(worktree)?;
if !status.is_empty() {
return Err(HarnessError::DirtyWorktree { details: status });
}
let base_head = head_oid(worktree)?;
let want_base = resolve_commit(worktree, &req.base_commit)?;
if base_head != want_base {
return Err(HarnessError::InvalidWorktree {
message: format!("worktree HEAD ({base_head}) != declared base_commit ({want_base})"),
});
}
let dir = artifact_dir(req);
std::fs::create_dir_all(&dir).map_err(|e| HarnessError::Internal {
message: format!("could not create artifact dir {}: {e}", dir.display()),
})?;
let prompt = launch.build_prompt(req);
let brief_file = dir.join("brief.md");
std::fs::write(&brief_file, &prompt).map_err(|e| HarnessError::Internal {
message: format!("could not write brief {}: {e}", brief_file.display()),
})?;
let transcript_file = dir.join("transcript.log");
let cmd = launch.build_command(worktree, &brief_file, &prompt, req);
let run = run_with_control(cmd, req.timeout, &|| cancel.is_cancelled(), OUTPUT_CAP);
let (status, stdout, stderr) = match run {
ControlledOutcome::Exited {
status,
stdout,
stderr,
} => (status, stdout, stderr),
ControlledOutcome::Stopped {
reason,
stdout,
stderr,
} => {
let partial = render_transcript(&stdout, &stderr);
let usage = launch.parse_usage(&partial);
let transcript_ref = write_transcript(&transcript_file, &partial);
return Ok(stopped_result(reason, transcript_ref, usage));
}
ControlledOutcome::SpawnErr(e) => {
return Err(HarnessError::ProviderFailure {
message: format!(
"could not run {} ({}): {e}",
launch.tool_label(),
launch.bin_display()
),
})
}
};
let transcript = render_transcript(&stdout, &stderr);
let transcript_ref = write_transcript(&transcript_file, &transcript);
if !launch.commits_in_agent() && status.success() {
commit_leftover_changes(worktree, req, launch.tool_label());
}
let new_head = head_oid(worktree)?;
let dirty_after = !worktree_status(worktree)?.is_empty();
let tool = launch.tool_label();
let outcome = if new_head == base_head {
if dirty_after {
ChunkOutcome::Failed {
reason: format!("{tool} left uncommitted changes without producing a commit"),
}
} else if status.success() {
ChunkOutcome::NoChange
} else {
ChunkOutcome::Failed {
reason: format!(
"{tool} exited {} with no commit produced",
status
.code()
.map_or("signal".to_string(), |c| c.to_string())
),
}
}
} else if is_ancestor(worktree, &base_head, &new_head)? {
ChunkOutcome::Committed {
commit: new_head.clone(),
}
} else {
ChunkOutcome::Failed {
reason: format!(
"{tool} moved HEAD to {new_head}, which is not a descendant of \
base_commit {base_head} (history was rewritten)"
),
}
};
let (resulting_commit, files) = match &outcome {
ChunkOutcome::Committed { commit } => (
Some(commit.clone()),
changed_files(worktree, &base_head, &new_head)?,
),
_ => (None, Vec::new()),
};
let check_results: Vec<CheckResult> = req
.checks
.iter()
.map(|c| {
if cancel.is_cancelled() {
skipped_check(c)
} else {
run_check(worktree, c, cancel)
}
})
.collect();
let usage = launch.parse_usage(&transcript);
Ok(ChunkResult {
schema_version: HARNESS_CONTRACT_VERSION,
outcome,
resulting_commit,
changed_files: files,
check_results,
transcript_ref,
usage,
})
}
#[cfg(test)]
pub(crate) mod test_env {
use std::sync::{Mutex, MutexGuard, PoisonError};
static ENV_LOCK: Mutex<()> = Mutex::new(());
pub(crate) fn lock() -> MutexGuard<'static, ()> {
ENV_LOCK.lock().unwrap_or_else(PoisonError::into_inner)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn cap(bytes: &[u8]) -> CappedStream {
CappedStream {
bytes: bytes.to_vec(),
truncated: false,
}
}
#[test]
fn credential_present_treats_empty_as_absent() {
let _g = test_env::lock();
std::env::set_var("OCTL_TEST_CRED", "sk-real");
assert!(credential_present("OCTL_TEST_CRED"));
std::env::set_var("OCTL_TEST_CRED", "");
assert!(
!credential_present("OCTL_TEST_CRED"),
"empty must be absent"
);
std::env::remove_var("OCTL_TEST_CRED");
assert!(!credential_present("OCTL_TEST_CRED"));
}
#[test]
fn parse_json_usage_takes_last_when_no_result_tag() {
let t = "{\"usage\":{\"input_tokens\":5}}\n\
{\"usage\":{\"input_tokens\":900,\"output_tokens\":100}}\n";
let u = parse_json_usage(t).unwrap();
assert_eq!(u.input_tokens, Some(900));
assert_eq!(u.output_tokens, Some(100));
assert_eq!(u.total_tokens, Some(1000));
}
#[test]
fn parse_json_usage_total_does_not_overflow() {
let t = "{\"usage\":{\"input_tokens\":18446744073709551615,\"output_tokens\":1}}";
let u = parse_json_usage(t).unwrap();
assert_eq!(u.input_tokens, Some(u64::MAX));
assert_eq!(u.output_tokens, Some(1));
assert_eq!(
u.total_tokens, None,
"overflowing sum is dropped, not wrapped"
);
}
#[test]
fn parse_json_usage_reads_explicit_total_and_nested_cost_keys() {
let t = "{\"usage\":{\"input\":80,\"output\":40,\"total_tokens\":120},\"cost_usd\":0.002}";
let u = parse_json_usage(t).unwrap();
assert_eq!(u.input_tokens, Some(80));
assert_eq!(u.output_tokens, Some(40));
assert_eq!(u.total_tokens, Some(120));
assert_eq!(u.cost_usd, Some(0.002));
}
#[test]
fn parse_json_usage_claude_snake_case_still_parses() {
let t = "{\"type\":\"result\",\"usage\":{\"input_tokens\":100,\"output_tokens\":50},\
\"total_cost_usd\":0.0125}";
let u = parse_json_usage(t).expect("claude usage parses");
assert_eq!(u.input_tokens, Some(100));
assert_eq!(u.output_tokens, Some(50));
assert_eq!(u.total_tokens, Some(150));
assert_eq!(u.cost_usd, Some(0.0125));
}
#[test]
fn parse_json_usage_surfaces_a_standalone_total() {
let u = parse_json_usage("{\"usage\":{\"totalTokens\":1540}}").expect("total parses");
assert_eq!(u.total_tokens, Some(1540));
assert_eq!(u.input_tokens, None);
assert_eq!(u.output_tokens, None);
}
#[test]
fn parse_json_usage_ignores_bare_input_without_a_usage_block() {
assert_eq!(parse_json_usage("{\"input\":999,\"output\":42}"), None);
}
#[test]
fn parse_json_usage_maps_pi_082_json_shape() {
let t = "{\"type\":\"result\",\"usage\":{\"input\":1200,\"output\":340,\
\"totalTokens\":1540,\"cost\":{\"total\":0.0173}}}";
let u = parse_json_usage(t).expect("pi 0.82 usage shape parses");
assert_eq!(u.input_tokens, Some(1200));
assert_eq!(u.output_tokens, Some(340));
assert_eq!(u.total_tokens, Some(1540));
assert_eq!(u.cost_usd, Some(0.0173));
}
#[test]
fn render_transcript_separates_streams_so_final_json_line_parses() {
let stdout = cap(
b"working...\n{\"type\":\"result\",\"usage\":{\"input_tokens\":7,\"output_tokens\":3}}",
);
let stderr = cap(b"a stderr warning\n");
let t = render_transcript(&stdout, &stderr);
assert!(
t.contains("}\n"),
"a newline must separate the JSON line from stderr: {t:?}"
);
let u = parse_json_usage(&t).expect("usage still parses after separation");
assert_eq!(u.total_tokens, Some(10));
}
}