1use crate::commands::{augment_limit_hint, build_backend, load_config};
24use crate::output;
25use crate::tail::{self, EventRenderer};
26use anyhow::{Context, Result};
27use kranz_engine::backend::AgentBackend;
28use kranz_engine::control;
29use kranz_engine::git_ops::GitRepo;
30use kranz_engine::orchestrator::{MissionEngine, PlanRequest};
31use kranz_engine::queue::{self, QueueEntry};
32use kranz_engine::ticket::Ticket;
33use kranz_engine::types::{ControlCommand, MissionConfig, MissionStatus};
34use std::io::IsTerminal;
35use std::path::{Path, PathBuf};
36use std::sync::atomic::{AtomicBool, Ordering};
37use std::sync::Arc;
38
39pub const EXIT_UNDERSPECIFIED: i32 = 3;
42
43pub const EXIT_PUSH_FAILED: i32 = 4;
47
48#[derive(Debug, Clone, PartialEq, Eq)]
51enum CheckoutPosition {
52 Branch(String),
53 Detached(String),
54}
55
56struct EnqueueCheckoutGuard {
57 repo: PathBuf,
58 original: Option<CheckoutPosition>,
59 active: bool,
60}
61
62impl EnqueueCheckoutGuard {
63 fn new(repo: &Path, active: bool) -> Self {
64 let original = active.then(|| capture_checkout_position(repo)).flatten();
65 Self {
66 repo: repo.to_path_buf(),
67 original,
68 active,
69 }
70 }
71
72 fn restore_now(&mut self) {
73 if self.active {
74 restore_enqueue_checkout(&self.repo, self.original.as_ref());
75 self.active = false;
76 }
77 }
78}
79
80impl Drop for EnqueueCheckoutGuard {
81 fn drop(&mut self) {
82 self.restore_now();
83 }
84}
85
86pub struct ExecOptions {
88 pub max_cycles: Option<u32>,
89 pub enqueue: bool,
90 pub enqueue_source: Option<ExternalEnqueueSource>,
91 pub push: Option<String>,
92 pub dangerously_allow_all: bool,
93 pub allow_unvalidated: bool,
94}
95
96pub struct ExternalEnqueueSource {
97 pub producer: String,
98 pub external_ref: String,
99}
100
101pub fn exit_code_for(status: MissionStatus) -> i32 {
109 match status {
110 MissionStatus::Complete => 0,
111 MissionStatus::Blocked => 2,
112 MissionStatus::Failed => 1,
113 _ => 1,
114 }
115}
116
117pub fn scrutiny_gate(skip_scrutiny: bool, allow_unvalidated: bool) -> Result<(), String> {
125 if skip_scrutiny && !allow_unvalidated {
126 Err(
127 "kranz exec: refusing to run an unattended mission with skipScrutiny set. \
128 A headless run has no adversarial reader when the scrutiny validator is \
129 disabled, so the mission can pass its own tautological acceptance (see \
130 docs/gascity.md lesson 3). Pass --allow-unvalidated (or set \
131 KRANZ_ALLOW_UNVALIDATED=1) to explicitly override this floor."
132 .to_string(),
133 )
134 } else {
135 Ok(())
136 }
137}
138
139pub fn parse_mission_markdown(slug: &str, markdown: &str) -> Result<Ticket> {
146 Ticket::parse(slug, markdown).with_context(|| format!("parsing mission plan file '{slug}'"))
147}
148
149fn read_mission_file(path: &Path) -> Result<Ticket> {
152 let slug = path
153 .file_stem()
154 .and_then(|s| s.to_str())
155 .unwrap_or("mission");
156 let markdown = std::fs::read_to_string(path)
157 .with_context(|| format!("reading mission plan file {}", path.display()))?;
158 parse_mission_markdown(slug, &markdown)
159}
160
161pub async fn cmd_exec(repo: PathBuf, file: PathBuf, options: ExecOptions) -> Result<i32> {
173 let ticket = read_mission_file(&file)?;
174 let cfg = load_config(&repo, options.dangerously_allow_all)?;
175
176 let allow_unvalidated = options.allow_unvalidated
177 || std::env::var("KRANZ_ALLOW_UNVALIDATED").ok().as_deref() == Some("1");
178 if let Err(msg) = scrutiny_gate(cfg.skip_scrutiny, allow_unvalidated) {
179 eprintln!("{msg}");
180 return Ok(1);
181 }
182
183 let backend = build_backend(&cfg)?;
184
185 cmd_exec_with_backend(repo, cfg, backend, ticket, file, options).await
186}
187
188async fn cmd_exec_with_backend(
192 repo: PathBuf,
193 cfg: MissionConfig,
194 backend: Arc<dyn AgentBackend>,
195 ticket: Ticket,
196 file: PathBuf,
197 options: ExecOptions,
198) -> Result<i32> {
199 let mut checkout_guard = EnqueueCheckoutGuard::new(&repo, options.enqueue);
203 let goal = ticket.mission_goal();
204 let mut engine = MissionEngine::create(backend, repo.clone(), &goal, cfg)?;
205 let mission_id = engine.mission_id().to_string();
206 eprintln!(
207 "kranz exec: mission {mission_id} created from {}",
208 file.display()
209 );
210
211 engine
214 .planning_turn(&goal)
215 .await
216 .map_err(|e| augment_limit_hint(e.into()))
217 .with_context(|| format!("seeding the orchestrator for mission {mission_id}"))?;
218 if let Some(seed) = engine.take_seed_reply() {
219 eprintln!("orchestrator: {}", output::one_line(&seed, 200));
220 }
221
222 let request = engine
223 .request_plan()
224 .await
225 .map_err(|e| augment_limit_hint(e.into()))
226 .with_context(|| format!("requesting the plan for mission {mission_id}"))?;
227
228 let plan = match request {
229 PlanRequest::Ready(plan) => plan,
230 PlanRequest::NotReady(questions) => {
231 eprintln!(
234 "kranz exec: mission underspecified — the orchestrator needs clarification \
235 that a headless run cannot provide. Answer these in {} and re-run:",
236 file.display()
237 );
238 for line in questions.lines() {
239 let line = line.trim();
240 if !line.is_empty() {
241 eprintln!(" - {line}");
242 }
243 }
244 println!(
245 "kranz exec {mission_id} UNDERSPECIFIED cost=${:.2} branch=-",
246 engine.state().total_cost_usd
247 );
248 return Ok(EXIT_UNDERSPECIFIED);
249 }
250 PlanRequest::WrongPlan { reason } => {
251 eprintln!(
255 "kranz exec: the planner escalated — it can produce a plan but believes it \
256 is likely WRONG. Reframe {} and re-run:\n {reason}",
257 file.display()
258 );
259 println!(
260 "kranz exec {mission_id} WRONG-PLAN cost=${:.2} branch=-",
261 engine.state().total_cost_usd
262 );
263 return Ok(EXIT_UNDERSPECIFIED);
264 }
265 };
266
267 engine
269 .approve_plan(plan)
270 .with_context(|| format!("approving the plan for mission {mission_id}"))?;
271 let branch = engine.state().mission.mission_branch.clone();
272 if options.enqueue {
273 eprintln!("kranz exec: plan approved on {branch}; enqueueing without a worker");
274 } else {
275 eprintln!("kranz exec: plan approved on {branch}; running headlessly");
276 }
277
278 if let Some(n) = options.max_cycles {
282 control::enqueue(
283 engine.paths(),
284 &ControlCommand::ConfigChange {
285 patch: serde_json::json!({ "maxFixCyclesPerMilestone": n }),
286 },
287 )
288 .with_context(|| format!("queuing the --max-cycles override for mission {mission_id}"))?;
289 }
290
291 if options.enqueue {
292 if let Some(source) = &options.enqueue_source {
293 queue::write_enqueue_source(
294 &repo,
295 &mission_id,
296 &source.producer,
297 &source.external_ref,
298 )?;
299 }
300 let entry = match queue::enqueue(
301 &repo,
302 QueueEntry {
303 mission_id: mission_id.clone(),
304 ticket_slug: None,
305 priority: ticket.priority,
306 seq: 0,
307 },
308 ) {
309 Ok(entry) => entry,
310 Err(error) => {
311 if options.enqueue_source.is_some() {
312 queue::remove_enqueue_source(&repo, &mission_id);
313 }
314 return Err(error.into());
315 }
316 };
317 let cost = engine.state().total_cost_usd;
318 drop(engine);
319 checkout_guard.restore_now();
320 println!(
321 "kranz exec {mission_id} QUEUED cost=${cost:.2} branch={branch} seq={}",
322 entry.seq
323 );
324 return Ok(0);
325 }
326
327 run_and_reconcile(engine, repo, mission_id, branch, options.push).await
328}
329
330fn capture_checkout_position(repo: &Path) -> Option<CheckoutPosition> {
337 let git = GitRepo::open(repo).ok()?;
338 match git.current_branch().ok()?.as_str() {
339 "HEAD" => git.head_sha().ok().map(CheckoutPosition::Detached),
340 branch => Some(CheckoutPosition::Branch(branch.to_string())),
341 }
342}
343
344fn restore_enqueue_checkout(repo: &Path, original: Option<&CheckoutPosition>) {
345 let Some(original) = original else { return };
346 let Ok(git) = GitRepo::open(repo) else { return };
347 let current = git.current_branch().unwrap_or_else(|_| "unknown".into());
348 match original {
349 CheckoutPosition::Branch(branch) if current == *branch => return,
350 CheckoutPosition::Detached(sha)
351 if current == "HEAD" && git.head_sha().ok().as_deref() == Some(sha.as_str()) =>
352 {
353 return
354 }
355 _ => {}
356 }
357 let target = match original {
358 CheckoutPosition::Branch(branch) | CheckoutPosition::Detached(branch) => branch,
359 };
360 match git.is_clean_tracked() {
361 Ok(true) => {
362 if let Err(e) = git.checkout(target) {
363 eprintln!("warning: could not restore checkout to {target}: {e}");
364 }
365 }
366 Ok(false) => eprintln!(
367 "warning: leaving checkout on {current}: tracked files have uncommitted changes"
368 ),
369 Err(e) => {
370 eprintln!("warning: could not probe the working tree ({e}); checkout left on {current}")
371 }
372 }
373}
374
375async fn run_and_reconcile(
384 mut engine: MissionEngine,
385 repo: PathBuf,
386 mission_id: String,
387 branch: String,
388 push: Option<String>,
389) -> Result<i32> {
390 let color = std::io::stderr().is_terminal();
392 let renderer = EventRenderer::seeded(engine.state(), color);
393 let stop = Arc::new(AtomicBool::new(false));
394 let printer = tokio::spawn(tail::tail_events(
395 engine.paths().events_file(),
396 engine.state().last_seq,
397 renderer,
398 Arc::clone(&stop),
399 ));
400
401 let run_result = engine.run().await;
402 let cost = engine.state().total_cost_usd;
406 drop(engine);
407 stop.store(true, Ordering::Relaxed);
408 let _ = printer.await;
409
410 let status = run_result.map_err(|e| augment_limit_hint(e.into()))?;
411 let code = exit_code_for(status);
412
413 if let Err(e) = kranz_engine::work::reconcile_ticket_for_mission(&repo, &mission_id) {
417 eprintln!("kranz exec: warning: failed to reconcile linked ticket: {e}");
418 }
419
420 let mut pushed = false;
442 let mut push_failed = false;
443 if let (Some(remote), MissionStatus::Complete) = (&push, status) {
444 match kranz_engine::git_ops::GitRepo::open(&repo)
445 .and_then(|r| r.push_mission_branch(remote, &branch))
446 {
447 Ok(()) => {
448 pushed = true;
449 eprintln!("kranz exec: pushed {branch} to {remote}");
450 }
451 Err(e) => {
452 push_failed = true;
453 eprintln!("kranz exec: WARNING failed to push {branch} to {remote}: {e}");
454 }
455 }
456 }
457
458 println!(
460 "kranz exec {mission_id} {} cost=${cost:.2} branch={branch} pushed={pushed}",
461 output::mission_status_label(status)
462 );
463 if push_failed {
464 return Ok(EXIT_PUSH_FAILED);
465 }
466 Ok(code)
467}
468
469#[cfg(test)]
470mod tests {
471 use super::*;
472 use kranz_engine::types::MissionConfig;
473
474 #[test]
475 fn exit_code_maps_terminal_statuses() {
476 assert_eq!(exit_code_for(MissionStatus::Complete), 0);
477 assert_eq!(exit_code_for(MissionStatus::Failed), 1);
478 assert_eq!(exit_code_for(MissionStatus::Blocked), 2);
479 assert_eq!(exit_code_for(MissionStatus::Running), 1);
481 assert_eq!(exit_code_for(MissionStatus::Abandoned), 1);
482 }
483
484 #[test]
491 fn composition_audit_allow_unvalidated_lifts_only_the_unattended_scrutiny_floor() {
492 let err = scrutiny_gate(true, false).unwrap_err();
494 assert!(err.contains("--allow-unvalidated"), "{err}");
495 assert!(scrutiny_gate(true, true).is_ok());
497 assert!(scrutiny_gate(false, false).is_ok());
500 assert!(scrutiny_gate(false, true).is_ok());
501 }
502
503 #[test]
504 fn push_failure_exit_code_is_distinct() {
505 assert_eq!(exit_code_for(MissionStatus::Complete), 0);
507 assert_eq!(EXIT_PUSH_FAILED, 4);
508 assert_ne!(EXIT_PUSH_FAILED, exit_code_for(MissionStatus::Complete));
509 assert_ne!(EXIT_PUSH_FAILED, exit_code_for(MissionStatus::Failed));
510 assert_ne!(EXIT_PUSH_FAILED, EXIT_UNDERSPECIFIED);
511 }
512
513 fn exit_after_push(mission_code: i32, push_requested: bool, push_ok: bool) -> (i32, bool) {
517 let mut pushed = false;
518 let mut push_failed = false;
519 if push_requested {
520 if push_ok {
521 pushed = true;
522 } else {
523 push_failed = true;
524 }
525 }
526 let code = if push_failed {
527 EXIT_PUSH_FAILED
528 } else {
529 mission_code
530 };
531 (code, pushed)
532 }
533
534 #[test]
535 fn push_failure_returns_exit_4_with_pushed_false() {
536 let (code, pushed) = exit_after_push(0, true, false);
537 assert_eq!(code, EXIT_PUSH_FAILED);
538 assert!(!pushed);
539 }
540
541 #[test]
542 fn push_success_keeps_mission_exit_and_pushed_true() {
543 let (code, pushed) = exit_after_push(0, true, true);
544 assert_eq!(code, 0);
545 assert!(pushed);
546 }
547
548 #[test]
549 fn no_push_flag_leaves_mission_exit_unchanged() {
550 let (code, pushed) = exit_after_push(0, false, false);
551 assert_eq!(code, 0);
552 assert!(!pushed);
553 }
554
555 fn reconcile_turn(reply: &str) -> Vec<kranz_engine::backend::AgentEvent> {
560 vec![
561 kranz_engine::backend_mock::mock_text(reply),
562 kranz_engine::backend_mock::mock_result_text(reply),
563 ]
564 }
565
566 fn reconcile_worker_pass() -> kranz_engine::backend_mock::MockScript {
567 kranz_engine::backend_mock::MockScript::single_shot_json(&serde_json::json!({
568 "result": "pass",
569 "summary": "implemented and tested",
570 "filesTouched": ["delivered.txt"],
571 "testsAdded": [],
572 "testEvidence": "all green",
573 "commits": []
574 }))
575 .writes_file("delivered.txt", "delivered by the mock worker\n")
576 }
577
578 fn reconcile_plan_json() -> serde_json::Value {
579 serde_json::json!({
580 "goal": "ship the demo",
581 "validationContract": [],
582 "milestones": [{
583 "title": "M1",
584 "features": [{
585 "title": "F1",
586 "spec": "build the thing",
587 "validationCriteria": ["it works"]
588 }]
589 }]
590 })
591 }
592
593 #[tokio::test]
603 async fn reconcile_on_terminal_after_cli_exec_marks_ticket_done() {
604 let tmp = tempfile::tempdir().unwrap();
605 let repo = tmp.path().to_path_buf();
606 let status = std::process::Command::new("git")
607 .args(["init", "-b", "main"])
608 .current_dir(&repo)
609 .status()
610 .unwrap();
611 assert!(status.success());
612 std::process::Command::new("git")
613 .args(["config", "user.name", "test"])
614 .current_dir(&repo)
615 .status()
616 .unwrap();
617 std::process::Command::new("git")
618 .args(["config", "user.email", "test@example.com"])
619 .current_dir(&repo)
620 .status()
621 .unwrap();
622 std::fs::write(repo.join("README.md"), "seed\n").unwrap();
623 std::process::Command::new("git")
624 .args(["add", "-A"])
625 .current_dir(&repo)
626 .status()
627 .unwrap();
628 std::process::Command::new("git")
629 .args(["commit", "-m", "seed"])
630 .current_dir(&repo)
631 .status()
632 .unwrap();
633 let repo = std::fs::canonicalize(&repo).unwrap();
634
635 let judgement = serde_json::json!({
636 "decision": "complete",
637 "guidance": "",
638 "summary": "worker did the job"
639 });
640 let orch = kranz_engine::backend_mock::MockScript::streaming(vec![
644 kranz_engine::backend_mock::mock_init("orch-session"),
645 kranz_engine::backend_mock::mock_result_text("seed-hi"),
646 ])
647 .responding(vec![
648 reconcile_turn("let's scope the demo"),
649 reconcile_turn(&reconcile_plan_json().to_string()),
650 reconcile_turn("ack"),
651 reconcile_turn(
652 &serde_json::json!({"action": "commit-as-is", "note": "worker delivered files"})
653 .to_string(),
654 ),
655 reconcile_turn(&judgement.to_string()),
656 reconcile_turn("NONE"),
657 ]);
658 let backend: Arc<dyn AgentBackend> =
659 Arc::new(kranz_engine::backend_mock::MockBackend::with_scripts(vec![
660 orch,
661 kranz_engine::backend_mock::MockScript::single_shot("ok"),
666 reconcile_worker_pass(),
667 ]));
668
669 let cfg = MissionConfig {
670 skip_scrutiny: true,
671 skip_functional: true,
672 ..Default::default()
673 };
674 let mut engine =
675 MissionEngine::create(Arc::clone(&backend), repo.clone(), "ship the demo", cfg)
676 .unwrap();
677 let mission_id = engine.mission_id().to_string();
678 engine.planning_turn("ship the demo").await.unwrap();
679 let request = engine.request_plan().await.unwrap();
680 let plan = match request {
681 PlanRequest::Ready(plan) => plan,
682 PlanRequest::NotReady(text) => panic!("expected a ready plan, got: {text}"),
683 PlanRequest::WrongPlan { reason } => {
684 panic!("expected a ready plan, got a wrong-plan escalation: {reason}")
685 }
686 };
687 engine.approve_plan(plan).unwrap();
688 let branch = engine.state().mission.mission_branch.clone();
689
690 kranz_engine::ticket::Ticket::record_mission(&repo, "my-ticket", &mission_id).unwrap();
693 kranz_engine::ticket::Ticket::write_state(
694 &repo,
695 "my-ticket",
696 kranz_engine::ticket::TicketState::Failed,
697 None,
698 )
699 .unwrap();
700
701 let exit_code = run_and_reconcile(engine, repo.clone(), mission_id, branch, None)
702 .await
703 .unwrap();
704 assert_eq!(exit_code, 0);
705
706 assert_eq!(
707 kranz_engine::ticket::Ticket::read_state(&repo, "my-ticket"),
708 kranz_engine::ticket::TicketState::Done,
709 "run_and_reconcile must reconcile the linked ticket to Done on Complete"
710 );
711 }
712
713 #[tokio::test]
714 async fn enqueue_only_exec_creates_approved_mission_without_running_worker() {
715 let tmp = tempfile::tempdir().unwrap();
716 let repo = tmp.path().to_path_buf();
717 for args in [
718 vec!["init", "-b", "main"],
719 vec!["config", "user.name", "test"],
720 vec!["config", "user.email", "test@example.com"],
721 ] {
722 assert!(std::process::Command::new("git")
723 .args(args)
724 .current_dir(&repo)
725 .status()
726 .unwrap()
727 .success());
728 }
729 std::fs::write(repo.join("README.md"), "seed\n").unwrap();
730 for args in [vec!["add", "README.md"], vec!["commit", "-m", "seed"]] {
731 assert!(std::process::Command::new("git")
732 .args(args)
733 .current_dir(&repo)
734 .status()
735 .unwrap()
736 .success());
737 }
738 let repo = std::fs::canonicalize(&repo).unwrap();
739
740 let orch = kranz_engine::backend_mock::MockScript::streaming(vec![
741 kranz_engine::backend_mock::mock_init("orch-session"),
742 kranz_engine::backend_mock::mock_result_text("seed-hi"),
743 ])
744 .responding(vec![
745 reconcile_turn("the brief is self-contained"),
746 reconcile_turn(&reconcile_plan_json().to_string()),
747 reconcile_turn("approved"),
748 ]);
749 let backend: Arc<dyn AgentBackend> =
750 Arc::new(kranz_engine::backend_mock::MockBackend::with_scripts(vec![
751 orch,
752 ]));
753 let ticket = parse_mission_markdown(
754 "gas-city-bead",
755 "---\npriority: 1\n---\n## Goal\nship the demo\n\n## Acceptance hints\nit works\n",
756 )
757 .unwrap();
758 let cfg = MissionConfig {
759 skip_scrutiny: true,
760 skip_functional: true,
761 ..Default::default()
762 };
763
764 let code = cmd_exec_with_backend(
765 repo.clone(),
766 cfg,
767 backend,
768 ticket,
769 PathBuf::from("gas-city-bead.md"),
770 ExecOptions {
771 max_cycles: Some(1),
772 enqueue: true,
773 enqueue_source: Some(ExternalEnqueueSource {
774 producer: "gascity".to_string(),
775 external_ref: "rig-1".to_string(),
776 }),
777 push: None,
778 dangerously_allow_all: false,
779 allow_unvalidated: false,
780 },
781 )
782 .await
783 .unwrap();
784
785 assert_eq!(code, 0);
786 assert_eq!(
787 GitRepo::open(&repo).unwrap().current_branch().unwrap(),
788 "main",
789 "enqueue-only exec must restore the caller's checkout"
790 );
791 let queued = queue::list(&repo);
792 assert_eq!(queued.len(), 1);
793 assert_eq!(queued[0].priority, 1);
794 assert!(queued[0].ticket_slug.is_none());
795 let source = queue::read_enqueue_source(&repo, &queued[0].mission_id).unwrap();
796 assert_eq!(source.producer, "gascity");
797 assert_eq!(source.external_ref, "rig-1");
798
799 let state_path = repo
800 .join(".kranz")
801 .join("missions")
802 .join(&queued[0].mission_id)
803 .join("state.json");
804 let state: serde_json::Value =
805 serde_json::from_str(&std::fs::read_to_string(state_path).unwrap()).unwrap();
806 assert_eq!(
807 state.pointer("/mission/status").and_then(|v| v.as_str()),
808 Some("approved")
809 );
810 assert!(
811 !repo.join("delivered.txt").exists(),
812 "enqueue-only must not spawn a worker or run the approved mission"
813 );
814 }
815
816 #[test]
817 fn enqueue_checkout_guard_restores_detached_head() {
818 let tmp = tempfile::tempdir().unwrap();
819 let repo = tmp.path();
820 for args in [
821 vec!["init", "-b", "main"],
822 vec!["config", "user.name", "test"],
823 vec!["config", "user.email", "test@example.com"],
824 ] {
825 assert!(std::process::Command::new("git")
826 .args(args)
827 .current_dir(repo)
828 .status()
829 .unwrap()
830 .success());
831 }
832 std::fs::write(repo.join("README.md"), "seed\n").unwrap();
833 for args in [vec!["add", "README.md"], vec!["commit", "-m", "seed"]] {
834 assert!(std::process::Command::new("git")
835 .args(args)
836 .current_dir(repo)
837 .status()
838 .unwrap()
839 .success());
840 }
841 let git = GitRepo::open(repo).unwrap();
842 let original_sha = git.head_sha().unwrap();
843 git.checkout(&original_sha).unwrap();
844 assert_eq!(git.current_branch().unwrap(), "HEAD");
845
846 let mut guard = EnqueueCheckoutGuard::new(repo, true);
847 git.create_branch("kranz/mission-test", None).unwrap();
848 git.checkout("kranz/mission-test").unwrap();
849 guard.restore_now();
850
851 assert_eq!(git.current_branch().unwrap(), "HEAD");
852 assert_eq!(git.head_sha().unwrap(), original_sha);
853 }
854}