use crate::commands::{augment_limit_hint, build_backend, load_config};
use crate::output;
use crate::tail::{self, EventRenderer};
use anyhow::{Context, Result};
use kranz_engine::backend::AgentBackend;
use kranz_engine::control;
use kranz_engine::git_ops::GitRepo;
use kranz_engine::orchestrator::{MissionEngine, PlanRequest};
use kranz_engine::queue::{self, QueueEntry};
use kranz_engine::ticket::Ticket;
use kranz_engine::types::{ControlCommand, MissionConfig, MissionStatus};
use std::io::IsTerminal;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
pub const EXIT_UNDERSPECIFIED: i32 = 3;
pub const EXIT_PUSH_FAILED: i32 = 4;
#[derive(Debug, Clone, PartialEq, Eq)]
enum CheckoutPosition {
Branch(String),
Detached(String),
}
struct EnqueueCheckoutGuard {
repo: PathBuf,
original: Option<CheckoutPosition>,
active: bool,
}
impl EnqueueCheckoutGuard {
fn new(repo: &Path, active: bool) -> Self {
let original = active.then(|| capture_checkout_position(repo)).flatten();
Self {
repo: repo.to_path_buf(),
original,
active,
}
}
fn restore_now(&mut self) {
if self.active {
restore_enqueue_checkout(&self.repo, self.original.as_ref());
self.active = false;
}
}
}
impl Drop for EnqueueCheckoutGuard {
fn drop(&mut self) {
self.restore_now();
}
}
pub struct ExecOptions {
pub max_cycles: Option<u32>,
pub enqueue: bool,
pub enqueue_source: Option<ExternalEnqueueSource>,
pub push: Option<String>,
pub dangerously_allow_all: bool,
pub allow_unvalidated: bool,
}
pub struct ExternalEnqueueSource {
pub producer: String,
pub external_ref: String,
}
pub fn exit_code_for(status: MissionStatus) -> i32 {
match status {
MissionStatus::Complete => 0,
MissionStatus::Blocked => 2,
MissionStatus::Failed => 1,
_ => 1,
}
}
pub fn scrutiny_gate(skip_scrutiny: bool, allow_unvalidated: bool) -> Result<(), String> {
if skip_scrutiny && !allow_unvalidated {
Err(
"kranz exec: refusing to run an unattended mission with skipScrutiny set. \
A headless run has no adversarial reader when the scrutiny validator is \
disabled, so the mission can pass its own tautological acceptance (see \
docs/gascity.md lesson 3). Pass --allow-unvalidated (or set \
KRANZ_ALLOW_UNVALIDATED=1) to explicitly override this floor."
.to_string(),
)
} else {
Ok(())
}
}
pub fn parse_mission_markdown(slug: &str, markdown: &str) -> Result<Ticket> {
Ticket::parse(slug, markdown).with_context(|| format!("parsing mission plan file '{slug}'"))
}
fn read_mission_file(path: &Path) -> Result<Ticket> {
let slug = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("mission");
let markdown = std::fs::read_to_string(path)
.with_context(|| format!("reading mission plan file {}", path.display()))?;
parse_mission_markdown(slug, &markdown)
}
pub async fn cmd_exec(repo: PathBuf, file: PathBuf, options: ExecOptions) -> Result<i32> {
let ticket = read_mission_file(&file)?;
let cfg = load_config(&repo, options.dangerously_allow_all)?;
let allow_unvalidated = options.allow_unvalidated
|| std::env::var("KRANZ_ALLOW_UNVALIDATED").ok().as_deref() == Some("1");
if let Err(msg) = scrutiny_gate(cfg.skip_scrutiny, allow_unvalidated) {
eprintln!("{msg}");
return Ok(1);
}
let backend = build_backend(&cfg)?;
cmd_exec_with_backend(repo, cfg, backend, ticket, file, options).await
}
async fn cmd_exec_with_backend(
repo: PathBuf,
cfg: MissionConfig,
backend: Arc<dyn AgentBackend>,
ticket: Ticket,
file: PathBuf,
options: ExecOptions,
) -> Result<i32> {
let mut checkout_guard = EnqueueCheckoutGuard::new(&repo, options.enqueue);
let goal = ticket.mission_goal();
let mut engine = MissionEngine::create(backend, repo.clone(), &goal, cfg)?;
let mission_id = engine.mission_id().to_string();
eprintln!(
"kranz exec: mission {mission_id} created from {}",
file.display()
);
engine
.planning_turn(&goal)
.await
.map_err(|e| augment_limit_hint(e.into()))
.with_context(|| format!("seeding the orchestrator for mission {mission_id}"))?;
if let Some(seed) = engine.take_seed_reply() {
eprintln!("orchestrator: {}", output::one_line(&seed, 200));
}
let request = engine
.request_plan()
.await
.map_err(|e| augment_limit_hint(e.into()))
.with_context(|| format!("requesting the plan for mission {mission_id}"))?;
let plan = match request {
PlanRequest::Ready(plan) => plan,
PlanRequest::NotReady(questions) => {
eprintln!(
"kranz exec: mission underspecified — the orchestrator needs clarification \
that a headless run cannot provide. Answer these in {} and re-run:",
file.display()
);
for line in questions.lines() {
let line = line.trim();
if !line.is_empty() {
eprintln!(" - {line}");
}
}
println!(
"kranz exec {mission_id} UNDERSPECIFIED cost=${:.2} branch=-",
engine.state().total_cost_usd
);
return Ok(EXIT_UNDERSPECIFIED);
}
PlanRequest::WrongPlan { reason } => {
eprintln!(
"kranz exec: the planner escalated — it can produce a plan but believes it \
is likely WRONG. Reframe {} and re-run:\n {reason}",
file.display()
);
println!(
"kranz exec {mission_id} WRONG-PLAN cost=${:.2} branch=-",
engine.state().total_cost_usd
);
return Ok(EXIT_UNDERSPECIFIED);
}
};
engine
.approve_plan(plan)
.with_context(|| format!("approving the plan for mission {mission_id}"))?;
let branch = engine.state().mission.mission_branch.clone();
if options.enqueue {
eprintln!("kranz exec: plan approved on {branch}; enqueueing without a worker");
} else {
eprintln!("kranz exec: plan approved on {branch}; running headlessly");
}
if let Some(n) = options.max_cycles {
control::enqueue(
engine.paths(),
&ControlCommand::ConfigChange {
patch: serde_json::json!({ "maxFixCyclesPerMilestone": n }),
},
)
.with_context(|| format!("queuing the --max-cycles override for mission {mission_id}"))?;
}
if options.enqueue {
if let Some(source) = &options.enqueue_source {
queue::write_enqueue_source(
&repo,
&mission_id,
&source.producer,
&source.external_ref,
)?;
}
let entry = match queue::enqueue(
&repo,
QueueEntry {
mission_id: mission_id.clone(),
ticket_slug: None,
priority: ticket.priority,
seq: 0,
},
) {
Ok(entry) => entry,
Err(error) => {
if options.enqueue_source.is_some() {
queue::remove_enqueue_source(&repo, &mission_id);
}
return Err(error.into());
}
};
let cost = engine.state().total_cost_usd;
drop(engine);
checkout_guard.restore_now();
println!(
"kranz exec {mission_id} QUEUED cost=${cost:.2} branch={branch} seq={}",
entry.seq
);
return Ok(0);
}
run_and_reconcile(engine, repo, mission_id, branch, options.push).await
}
fn capture_checkout_position(repo: &Path) -> Option<CheckoutPosition> {
let git = GitRepo::open(repo).ok()?;
match git.current_branch().ok()?.as_str() {
"HEAD" => git.head_sha().ok().map(CheckoutPosition::Detached),
branch => Some(CheckoutPosition::Branch(branch.to_string())),
}
}
fn restore_enqueue_checkout(repo: &Path, original: Option<&CheckoutPosition>) {
let Some(original) = original else { return };
let Ok(git) = GitRepo::open(repo) else { return };
let current = git.current_branch().unwrap_or_else(|_| "unknown".into());
match original {
CheckoutPosition::Branch(branch) if current == *branch => return,
CheckoutPosition::Detached(sha)
if current == "HEAD" && git.head_sha().ok().as_deref() == Some(sha.as_str()) =>
{
return
}
_ => {}
}
let target = match original {
CheckoutPosition::Branch(branch) | CheckoutPosition::Detached(branch) => branch,
};
match git.is_clean_tracked() {
Ok(true) => {
if let Err(e) = git.checkout(target) {
eprintln!("warning: could not restore checkout to {target}: {e}");
}
}
Ok(false) => eprintln!(
"warning: leaving checkout on {current}: tracked files have uncommitted changes"
),
Err(e) => {
eprintln!("warning: could not probe the working tree ({e}); checkout left on {current}")
}
}
}
async fn run_and_reconcile(
mut engine: MissionEngine,
repo: PathBuf,
mission_id: String,
branch: String,
push: Option<String>,
) -> Result<i32> {
let color = std::io::stderr().is_terminal();
let renderer = EventRenderer::seeded(engine.state(), color);
let stop = Arc::new(AtomicBool::new(false));
let printer = tokio::spawn(tail::tail_events(
engine.paths().events_file(),
engine.state().last_seq,
renderer,
Arc::clone(&stop),
));
let run_result = engine.run().await;
let cost = engine.state().total_cost_usd;
drop(engine);
stop.store(true, Ordering::Relaxed);
let _ = printer.await;
let status = run_result.map_err(|e| augment_limit_hint(e.into()))?;
let code = exit_code_for(status);
if let Err(e) = kranz_engine::work::reconcile_ticket_for_mission(&repo, &mission_id) {
eprintln!("kranz exec: warning: failed to reconcile linked ticket: {e}");
}
let mut pushed = false;
let mut push_failed = false;
if let (Some(remote), MissionStatus::Complete) = (&push, status) {
match kranz_engine::git_ops::GitRepo::open(&repo)
.and_then(|r| r.push_mission_branch(remote, &branch))
{
Ok(()) => {
pushed = true;
eprintln!("kranz exec: pushed {branch} to {remote}");
}
Err(e) => {
push_failed = true;
eprintln!("kranz exec: WARNING failed to push {branch} to {remote}: {e}");
}
}
}
println!(
"kranz exec {mission_id} {} cost=${cost:.2} branch={branch} pushed={pushed}",
output::mission_status_label(status)
);
if push_failed {
return Ok(EXIT_PUSH_FAILED);
}
Ok(code)
}
#[cfg(test)]
mod tests {
use super::*;
use kranz_engine::types::MissionConfig;
#[test]
fn exit_code_maps_terminal_statuses() {
assert_eq!(exit_code_for(MissionStatus::Complete), 0);
assert_eq!(exit_code_for(MissionStatus::Failed), 1);
assert_eq!(exit_code_for(MissionStatus::Blocked), 2);
assert_eq!(exit_code_for(MissionStatus::Running), 1);
assert_eq!(exit_code_for(MissionStatus::Abandoned), 1);
}
#[test]
fn composition_audit_allow_unvalidated_lifts_only_the_unattended_scrutiny_floor() {
let err = scrutiny_gate(true, false).unwrap_err();
assert!(err.contains("--allow-unvalidated"), "{err}");
assert!(scrutiny_gate(true, true).is_ok());
assert!(scrutiny_gate(false, false).is_ok());
assert!(scrutiny_gate(false, true).is_ok());
}
#[test]
fn push_failure_exit_code_is_distinct() {
assert_eq!(exit_code_for(MissionStatus::Complete), 0);
assert_eq!(EXIT_PUSH_FAILED, 4);
assert_ne!(EXIT_PUSH_FAILED, exit_code_for(MissionStatus::Complete));
assert_ne!(EXIT_PUSH_FAILED, exit_code_for(MissionStatus::Failed));
assert_ne!(EXIT_PUSH_FAILED, EXIT_UNDERSPECIFIED);
}
fn exit_after_push(mission_code: i32, push_requested: bool, push_ok: bool) -> (i32, bool) {
let mut pushed = false;
let mut push_failed = false;
if push_requested {
if push_ok {
pushed = true;
} else {
push_failed = true;
}
}
let code = if push_failed {
EXIT_PUSH_FAILED
} else {
mission_code
};
(code, pushed)
}
#[test]
fn push_failure_returns_exit_4_with_pushed_false() {
let (code, pushed) = exit_after_push(0, true, false);
assert_eq!(code, EXIT_PUSH_FAILED);
assert!(!pushed);
}
#[test]
fn push_success_keeps_mission_exit_and_pushed_true() {
let (code, pushed) = exit_after_push(0, true, true);
assert_eq!(code, 0);
assert!(pushed);
}
#[test]
fn no_push_flag_leaves_mission_exit_unchanged() {
let (code, pushed) = exit_after_push(0, false, false);
assert_eq!(code, 0);
assert!(!pushed);
}
fn reconcile_turn(reply: &str) -> Vec<kranz_engine::backend::AgentEvent> {
vec![
kranz_engine::backend_mock::mock_text(reply),
kranz_engine::backend_mock::mock_result_text(reply),
]
}
fn reconcile_worker_pass() -> kranz_engine::backend_mock::MockScript {
kranz_engine::backend_mock::MockScript::single_shot_json(&serde_json::json!({
"result": "pass",
"summary": "implemented and tested",
"filesTouched": ["delivered.txt"],
"testsAdded": [],
"testEvidence": "all green",
"commits": []
}))
.writes_file("delivered.txt", "delivered by the mock worker\n")
}
fn reconcile_plan_json() -> serde_json::Value {
serde_json::json!({
"goal": "ship the demo",
"validationContract": [],
"milestones": [{
"title": "M1",
"features": [{
"title": "F1",
"spec": "build the thing",
"validationCriteria": ["it works"]
}]
}]
})
}
#[tokio::test]
async fn reconcile_on_terminal_after_cli_exec_marks_ticket_done() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().to_path_buf();
let status = std::process::Command::new("git")
.args(["init", "-b", "main"])
.current_dir(&repo)
.status()
.unwrap();
assert!(status.success());
std::process::Command::new("git")
.args(["config", "user.name", "test"])
.current_dir(&repo)
.status()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(&repo)
.status()
.unwrap();
std::fs::write(repo.join("README.md"), "seed\n").unwrap();
std::process::Command::new("git")
.args(["add", "-A"])
.current_dir(&repo)
.status()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-m", "seed"])
.current_dir(&repo)
.status()
.unwrap();
let repo = std::fs::canonicalize(&repo).unwrap();
let judgement = serde_json::json!({
"decision": "complete",
"guidance": "",
"summary": "worker did the job"
});
let orch = kranz_engine::backend_mock::MockScript::streaming(vec![
kranz_engine::backend_mock::mock_init("orch-session"),
kranz_engine::backend_mock::mock_result_text("seed-hi"),
])
.responding(vec![
reconcile_turn("let's scope the demo"),
reconcile_turn(&reconcile_plan_json().to_string()),
reconcile_turn("ack"),
reconcile_turn(
&serde_json::json!({"action": "commit-as-is", "note": "worker delivered files"})
.to_string(),
),
reconcile_turn(&judgement.to_string()),
reconcile_turn("NONE"),
]);
let backend: Arc<dyn AgentBackend> =
Arc::new(kranz_engine::backend_mock::MockBackend::with_scripts(vec![
orch,
kranz_engine::backend_mock::MockScript::single_shot("ok"),
reconcile_worker_pass(),
]));
let cfg = MissionConfig {
skip_scrutiny: true,
skip_functional: true,
..Default::default()
};
let mut engine =
MissionEngine::create(Arc::clone(&backend), repo.clone(), "ship the demo", cfg)
.unwrap();
let mission_id = engine.mission_id().to_string();
engine.planning_turn("ship the demo").await.unwrap();
let request = engine.request_plan().await.unwrap();
let plan = match request {
PlanRequest::Ready(plan) => plan,
PlanRequest::NotReady(text) => panic!("expected a ready plan, got: {text}"),
PlanRequest::WrongPlan { reason } => {
panic!("expected a ready plan, got a wrong-plan escalation: {reason}")
}
};
engine.approve_plan(plan).unwrap();
let branch = engine.state().mission.mission_branch.clone();
kranz_engine::ticket::Ticket::record_mission(&repo, "my-ticket", &mission_id).unwrap();
kranz_engine::ticket::Ticket::write_state(
&repo,
"my-ticket",
kranz_engine::ticket::TicketState::Failed,
None,
)
.unwrap();
let exit_code = run_and_reconcile(engine, repo.clone(), mission_id, branch, None)
.await
.unwrap();
assert_eq!(exit_code, 0);
assert_eq!(
kranz_engine::ticket::Ticket::read_state(&repo, "my-ticket"),
kranz_engine::ticket::TicketState::Done,
"run_and_reconcile must reconcile the linked ticket to Done on Complete"
);
}
#[tokio::test]
async fn enqueue_only_exec_creates_approved_mission_without_running_worker() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().to_path_buf();
for args in [
vec!["init", "-b", "main"],
vec!["config", "user.name", "test"],
vec!["config", "user.email", "test@example.com"],
] {
assert!(std::process::Command::new("git")
.args(args)
.current_dir(&repo)
.status()
.unwrap()
.success());
}
std::fs::write(repo.join("README.md"), "seed\n").unwrap();
for args in [vec!["add", "README.md"], vec!["commit", "-m", "seed"]] {
assert!(std::process::Command::new("git")
.args(args)
.current_dir(&repo)
.status()
.unwrap()
.success());
}
let repo = std::fs::canonicalize(&repo).unwrap();
let orch = kranz_engine::backend_mock::MockScript::streaming(vec![
kranz_engine::backend_mock::mock_init("orch-session"),
kranz_engine::backend_mock::mock_result_text("seed-hi"),
])
.responding(vec![
reconcile_turn("the brief is self-contained"),
reconcile_turn(&reconcile_plan_json().to_string()),
reconcile_turn("approved"),
]);
let backend: Arc<dyn AgentBackend> =
Arc::new(kranz_engine::backend_mock::MockBackend::with_scripts(vec![
orch,
]));
let ticket = parse_mission_markdown(
"gas-city-bead",
"---\npriority: 1\n---\n## Goal\nship the demo\n\n## Acceptance hints\nit works\n",
)
.unwrap();
let cfg = MissionConfig {
skip_scrutiny: true,
skip_functional: true,
..Default::default()
};
let code = cmd_exec_with_backend(
repo.clone(),
cfg,
backend,
ticket,
PathBuf::from("gas-city-bead.md"),
ExecOptions {
max_cycles: Some(1),
enqueue: true,
enqueue_source: Some(ExternalEnqueueSource {
producer: "gascity".to_string(),
external_ref: "rig-1".to_string(),
}),
push: None,
dangerously_allow_all: false,
allow_unvalidated: false,
},
)
.await
.unwrap();
assert_eq!(code, 0);
assert_eq!(
GitRepo::open(&repo).unwrap().current_branch().unwrap(),
"main",
"enqueue-only exec must restore the caller's checkout"
);
let queued = queue::list(&repo);
assert_eq!(queued.len(), 1);
assert_eq!(queued[0].priority, 1);
assert!(queued[0].ticket_slug.is_none());
let source = queue::read_enqueue_source(&repo, &queued[0].mission_id).unwrap();
assert_eq!(source.producer, "gascity");
assert_eq!(source.external_ref, "rig-1");
let state_path = repo
.join(".kranz")
.join("missions")
.join(&queued[0].mission_id)
.join("state.json");
let state: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(state_path).unwrap()).unwrap();
assert_eq!(
state.pointer("/mission/status").and_then(|v| v.as_str()),
Some("approved")
);
assert!(
!repo.join("delivered.txt").exists(),
"enqueue-only must not spawn a worker or run the approved mission"
);
}
#[test]
fn enqueue_checkout_guard_restores_detached_head() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path();
for args in [
vec!["init", "-b", "main"],
vec!["config", "user.name", "test"],
vec!["config", "user.email", "test@example.com"],
] {
assert!(std::process::Command::new("git")
.args(args)
.current_dir(repo)
.status()
.unwrap()
.success());
}
std::fs::write(repo.join("README.md"), "seed\n").unwrap();
for args in [vec!["add", "README.md"], vec!["commit", "-m", "seed"]] {
assert!(std::process::Command::new("git")
.args(args)
.current_dir(repo)
.status()
.unwrap()
.success());
}
let git = GitRepo::open(repo).unwrap();
let original_sha = git.head_sha().unwrap();
git.checkout(&original_sha).unwrap();
assert_eq!(git.current_branch().unwrap(), "HEAD");
let mut guard = EnqueueCheckoutGuard::new(repo, true);
git.create_branch("kranz/mission-test", None).unwrap();
git.checkout("kranz/mission-test").unwrap();
guard.restore_now();
assert_eq!(git.current_branch().unwrap(), "HEAD");
assert_eq!(git.head_sha().unwrap(), original_sha);
}
}