use crate::workspace::Workspace;
use ostraka_core::identity::ActorId;
use ostraka_core::task::TaskSpec;
use ostraka_runtime::index::{self, RunSummary};
use ostraka_runtime::orchestrator::{Places, RunReport};
use ostraka_runtime::progress::Watcher;
use ostraka_runtime::{orchestrator, route};
use std::path::Path;
use std::sync::atomic::{AtomicUsize, Ordering};
static NEXT_RUN: AtomicUsize = AtomicUsize::new(1);
pub const AUTHOR: &str = "author";
pub const REVIEWER: &str = "reviewer";
pub const BASE_REF: &str = "HEAD";
pub const ATTEMPTS: usize = 3;
pub fn task_id() -> String {
format!(
"t{}-{}",
std::process::id(),
NEXT_RUN.fetch_add(1, Ordering::Relaxed)
)
}
#[derive(Clone)]
pub struct Args {
pub prompt: String,
pub repository: Option<String>,
pub author: String,
pub reviewer: String,
pub adapter: Option<String>,
pub review_adapter: Option<String>,
pub base_ref: String,
pub from: Option<String>,
pub model: Option<String>,
pub attempts: usize,
}
impl Args {
pub fn for_task(prompt: String) -> Self {
Self {
prompt,
repository: None,
author: AUTHOR.to_string(),
reviewer: REVIEWER.to_string(),
adapter: None,
review_adapter: None,
base_ref: BASE_REF.to_string(),
from: None,
model: None,
attempts: 1,
}
}
}
fn finished_run(
workspace: &Workspace,
run_id: &str,
) -> Result<RunSummary, Box<dyn std::error::Error>> {
let runs = index::list(&workspace.records())?;
let Some(run) = runs.into_iter().find(|r| r.run_id == run_id) else {
return Err(format!("no run {run_id:?} is recorded in this workspace").into());
};
if !run.approved() {
let said = match &run.outcome {
Some(outcome) => format!("{outcome:?}").to_lowercase(),
None => "never finished".to_string(),
};
return Err(format!(
"run {run_id} was {said}, so there is nothing to continue from — \
a change the gate would not take is not a base to build on"
)
.into());
}
Ok(run)
}
fn continue_from(repo: &Path, run_id: &str) -> Result<String, Box<dyn std::error::Error>> {
match index::commit_branch(repo, run_id)? {
Some(branch) => Ok(branch),
None => Err(format!(
"run {run_id} was approved but its commit is not on a branch here; \
the branch it was on has been deleted"
)
.into()),
}
}
type Outcome = Result<bool, Box<dyn std::error::Error>>;
pub fn execute(
workspace: &Workspace,
args: &Args,
watcher: Option<Box<dyn Watcher>>,
stop: &ostraka_adapter::interrupt::Stop,
) -> Result<RunReport, Box<dyn std::error::Error>> {
let continued = args.from.as_deref().map(|id| finished_run(workspace, id));
let continued = continued.transpose()?;
let named = continued
.as_ref()
.map(|r| r.repository.clone())
.or_else(|| args.repository.clone());
if let (Some(run), Some(asked)) = (continued.as_ref(), args.repository.as_deref()) {
if run.repository != asked {
return Err(format!(
"run {} was made in {:?}, not in {asked:?}",
run.run_id, run.repository
)
.into());
}
}
let repo = workspace.repository(named.as_deref())?;
let config = workspace.config_for(&repo)?;
config.validate()?;
let profiles = workspace.profiles()?;
let vendor_home = workspace.ostraka().join("vendor-home");
let review_adapter = args.review_adapter.clone().or_else(|| {
preferred_reviewer(
&workspace.reviewers(),
&profiles,
args.adapter.as_deref(),
|profile| {
ostraka_adapter::VendorAdapter::probe(
&ostraka_adapter::process::ProcessAdapter::new(profile.clone()),
)
.is_ready()
},
)
});
let routing = route::select_until(
&profiles,
args.adapter.as_deref(),
review_adapter.as_deref(),
&vendor_home,
config
.policy
.timeout_secs
.map(std::time::Duration::from_secs),
stop,
);
let routing = match routing {
Ok(routing) => routing,
Err(e) => {
let ids: Vec<String> = profiles.iter().map(|p| p.id.clone()).collect();
return Err(Box::new(crate::discover::NoAdapter {
said: e.to_string(),
found: crate::discover::unconfigured(&ids),
}));
}
};
let task = TaskSpec {
id: task_id(),
prompt: args.prompt.clone(),
adapter: routing_author_id(&routing),
author: ActorId::new(identity(&args.author, AUTHOR, &routing_author_id(&routing))),
base_ref: match continued.as_ref() {
Some(run) => continue_from(&repo.path, &run.run_id)?,
None => args.base_ref.clone(),
},
model: args.model.clone(),
};
let worktrees = workspace.worktrees(&config);
let notes = workspace.notes_if_present();
let skills = workspace.skills_if_present();
let records = workspace.records();
let places = Places {
repo: &repo.path,
worktrees: &worktrees,
records: &records,
name: &repo.name,
notes: notes.as_deref(),
skills: skills.as_deref(),
};
Ok(orchestrator::run_task_until(
&places,
&config,
&routing,
&task,
&ActorId::new(identity(
&args.reviewer,
REVIEWER,
ostraka_adapter::VendorAdapter::id(routing.reviewer.as_ref()),
)),
watcher,
stop,
)?)
}
pub fn worth_retrying(refusal: &ostraka_runtime::gate::Refusal) -> bool {
use ostraka_runtime::gate::Refusal;
matches!(
refusal,
Refusal::ChecksFailed { .. } | Refusal::Rejected { .. } | Refusal::NoChange
)
}
const FEEDBACK_TAIL: usize = 1500;
pub fn feedback(task: &str, refusal: &ostraka_runtime::gate::Refusal) -> String {
use ostraka_runtime::gate::Refusal;
let mut said = format!("{task}\n\n----- the last attempt at this was not kept -----\n\n");
match refusal {
Refusal::ChecksFailed { failed, records } => {
said.push_str(&format!(
"The project's checks failed: {}.\n",
failed.join(", ")
));
for record in records.iter().filter(|r| !r.passed()) {
let output = format!("{}{}", record.stdout, record.stderr);
let mut from = output.len().saturating_sub(FEEDBACK_TAIL);
while !output.is_char_boundary(from) {
from += 1;
}
said.push_str(&format!(
"\n`{}` ended with:\n{}\n",
record.cmd,
output[from..].trim_end()
));
}
}
Refusal::Rejected { reason } => {
said.push_str(&format!("It was sent back, and this is why: {reason}\n"));
}
Refusal::NoChange => {
said.push_str("It finished without changing any file, so there was nothing to keep.\n");
}
other => said.push_str(&format!("{}\n", describe(other))),
}
said.push_str(
"\nNothing from that attempt is in this worktree. Start from the task again, \
and deal with what went wrong.\n",
);
said
}
pub fn execute_looping(
workspace: &Workspace,
args: &Args,
mut watcher: impl FnMut() -> Option<Box<dyn Watcher>>,
stop: &ostraka_adapter::interrupt::Stop,
mut again: impl FnMut(usize, &ostraka_runtime::gate::Refusal),
) -> Result<RunReport, Box<dyn std::error::Error>> {
let mut attempt = args.clone();
let mut report = execute(workspace, &attempt, watcher(), stop)?;
for n in 2..=args.attempts.max(1) {
let Some(refusal) = report.refusal.as_ref().filter(|r| worth_retrying(r)) else {
break;
};
if stop.requested() {
break;
}
again(n, refusal);
attempt.prompt = feedback(&args.prompt, refusal);
report = execute(workspace, &attempt, watcher(), stop)?;
}
Ok(report)
}
pub fn run(workspace: &Workspace, args: &Args, json: bool) -> Outcome {
run_with(workspace, args, json, |_| {})
}
fn attempted(workspace: &Workspace, args: &Args) -> Result<RunReport, Box<dyn std::error::Error>> {
execute_looping(
workspace,
args,
|| None,
&ostraka_adapter::interrupt::Stop::new(),
|n, refusal| {
eprintln!(
"attempt {n} of {}: the last one was not kept \u{2014} {}",
args.attempts,
describe(refusal)
);
},
)
}
fn run_with(
workspace: &Workspace,
args: &Args,
json: bool,
seen: impl FnOnce(&crate::run::RunReport),
) -> Outcome {
let interrupts = std::sync::atomic::AtomicUsize::new(0);
let _ = ctrlc::set_handler(move || {
if interrupts.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0 {
eprintln!("\nstopping the run — press Ctrl-C again to give up on it");
ostraka_adapter::interrupt::request();
} else {
std::process::exit(130);
}
});
let report = match attempted(workspace, args) {
Ok(report) => report,
Err(e) => {
let Some(problem) = e.downcast_ref::<crate::discover::NoAdapter>() else {
return Err(e);
};
let choice = crate::offer::profiles(
workspace,
problem,
&mut std::io::stdin().lock(),
&mut std::io::stderr(),
!json && crate::offer::at_a_terminal(),
)?;
match choice {
crate::offer::Choice::Wrote => attempted(workspace, args)?,
crate::offer::Choice::Declined | crate::offer::Choice::NotAsked => return Err(e),
}
}
};
if json {
let out = serde_json::json!({
"run_id": report.record.run_id,
"approved": report.approved(),
"outcome": report.record.outcome,
"checks": report.record.checks.iter().map(|c| serde_json::json!({
"name": c.name,
"passed": c.passed(),
"exit_code": c.exit_code,
"duration_ms": c.duration_ms,
})).collect::<Vec<_>>(),
"refusal": report.refusal.as_ref().map(|r| format!("{r:?}")),
});
println!("{}", serde_json::to_string_pretty(&out)?);
} else {
println!("run {}", report.record.run_id);
for c in &report.record.checks {
let mark = if c.passed() { "pass" } else { "FAIL" };
println!(" {mark} {:<8} {}ms", c.name, c.duration_ms);
}
match (&report.token, &report.refusal) {
(Some(token), _) => println!(
"approved — written by {}, reviewed by {}",
token.author(),
token.reviewer()
),
(None, Some(refusal)) => println!("rejected — {}", describe(refusal)),
(None, None) => println!("rejected"),
}
println!("record: {}", workspace.records().join("runs").display());
}
seen(&report);
Ok(report.approved())
}
pub fn run_reporting(
workspace: &Workspace,
args: &Args,
json: bool,
) -> Result<(bool, String), Box<dyn std::error::Error>> {
let mut id = String::new();
let approved = run_with(workspace, args, json, |report| {
id = report.record.run_id.clone();
})?;
Ok((approved, id))
}
pub fn preferred_reviewer(
preferred: &[String],
profiles: &[ostraka_adapter::Profile],
author: Option<&str>,
ready: impl Fn(&ostraka_adapter::Profile) -> bool,
) -> Option<String> {
preferred
.iter()
.filter(|id| Some(id.as_str()) != author)
.filter_map(|id| profiles.iter().find(|p| &p.id == id))
.find(|profile| ready(profile))
.map(|profile| profile.id.clone())
}
pub fn identity<'a>(given: &'a str, unchosen: &str, profile: &'a str) -> &'a str {
if given == unchosen { profile } else { given }
}
fn routing_author_id(routing: &route::Routing) -> String {
ostraka_adapter::VendorAdapter::id(routing.author.as_ref()).to_string()
}
pub fn describe(refusal: &ostraka_runtime::gate::Refusal) -> String {
use ostraka_runtime::gate::Refusal;
match refusal {
Refusal::ChecksFailed { failed, .. } => {
format!("checks failed: {}", failed.join(", "))
}
Refusal::Rejected { reason } => format!("reviewer rejected: {reason}"),
Refusal::AuthorFailed { code, diagnostics } => match diagnostics {
Some(d) => format!("the author could not run (exit {code}): {d}"),
None => format!("the author could not run (exit {code}), and said nothing"),
},
Refusal::PolicyViolation { reason } => reason.clone(),
Refusal::SetupFailed { step, reason } => {
format!("the worktree could not be prepared ({step}): {reason}")
}
Refusal::Interrupted => "stopped by the operator".to_string(),
Refusal::TimedOut { after_secs } => {
format!("the author was still running after {after_secs}s and was stopped")
}
Refusal::NoChange => {
"the author ran cleanly and changed nothing; there is nothing to review".to_string()
}
Refusal::SelfApproval { actor } => {
format!("{actor} cannot approve a change {actor} wrote")
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use ostraka_core::gate::CheckRecord;
use ostraka_runtime::gate::Refusal;
fn profile(id: &str) -> ostraka_adapter::Profile {
ostraka_adapter::Profile::parse(&format!(
"id = \"{id}\"\ncommand = \"{id}\"\nargs = [\"{{{{prompt}}}}\"]\n"
))
.expect("profile")
}
#[test]
fn a_workspace_preference_picks_the_first_reviewer_that_can_review() {
let profiles = [profile("alpha"), profile("beta"), profile("gamma")];
let list = |ids: &[&str]| ids.iter().map(|s| s.to_string()).collect::<Vec<_>>();
let ready = |p: &ostraka_adapter::Profile| p.id != "beta";
assert_eq!(
preferred_reviewer(&list(&["gamma", "alpha"]), &profiles, None, ready),
Some("gamma".to_string())
);
assert_eq!(
preferred_reviewer(
&list(&["missing", "gamma", "beta", "alpha"]),
&profiles,
Some("gamma"),
ready
),
Some("alpha".to_string())
);
assert_eq!(
preferred_reviewer(&list(&["beta"]), &profiles, None, ready),
None
);
assert_eq!(preferred_reviewer(&[], &profiles, None, ready), None);
}
#[test]
fn an_identity_nobody_chose_is_the_profile_that_did_the_work() {
assert_eq!(identity(AUTHOR, AUTHOR, "codex"), "codex");
assert_eq!(identity(REVIEWER, REVIEWER, "claude-code"), "claude-code");
assert_eq!(identity("archon", AUTHOR, "codex"), "archon");
}
#[test]
fn only_what_is_about_the_change_is_tried_again() {
assert!(worth_retrying(&Refusal::NoChange));
assert!(worth_retrying(&Refusal::Rejected {
reason: "no tests".into()
}));
assert!(!worth_retrying(&Refusal::Interrupted));
assert!(!worth_retrying(&Refusal::TimedOut { after_secs: 60 }));
assert!(!worth_retrying(&Refusal::PolicyViolation {
reason: "wrote outside src".into()
}));
assert!(!worth_retrying(&Refusal::AuthorFailed {
code: "1".into(),
diagnostics: None
}));
}
#[test]
fn the_next_attempt_is_told_the_task_and_why_the_last_was_not_kept() {
let failing = CheckRecord {
name: "test".into(),
cmd: "cargo test".into(),
exit_code: Some(101),
stdout: format!("{}assertion failed: left == right", "x".repeat(4000)),
stderr: String::new(),
duration_ms: 3,
};
let said = feedback(
"add a flag",
&Refusal::ChecksFailed {
failed: vec!["test".into()],
records: vec![failing],
},
);
assert!(said.starts_with("add a flag"));
assert!(said.contains("`cargo test` ended with"));
assert!(said.contains("assertion failed: left == right"));
assert!(
said.len() < 2500,
"the whole output was pasted: {}",
said.len()
);
let sent_back = feedback(
"add a flag",
&Refusal::Rejected {
reason: "it has no test".into(),
},
);
assert!(sent_back.contains("it has no test"));
assert!(!sent_back.contains("VERDICT"));
}
}