Skip to main content

kranz_cli/
exec.rs

1//! `kranz exec -f <mission.md>` — fully headless missions for CI (roadmap M5).
2//!
3//! Plan file in, exit code out. There is no human on the other end: the
4//! mission.md is a ticket-shaped markdown that must be self-sufficient, and
5//! approval is automatic. The one place a headless run can't proceed is when
6//! the orchestrator answers `request_plan` with clarifying questions instead
7//! of a plan — that means the plan file was underspecified, so exec fails with
8//! a distinct exit code (3) and prints the questions to stderr for the CI log.
9//!
10//! Flow (mirrors `cmd_draft`'s non-interactive seed→plan path, but then runs
11//! the mission instead of parking it):
12//!   parse file → `Ticket::mission_goal()` → build backend →
13//!   `MissionEngine::create` → one `planning_turn` seeding the whole ticket →
14//!   `request_plan()` → auto-approve (or exit 3) → `run()` to a terminal state.
15//!
16//! Events stream to stderr live (the same [`tail::tail_events`] feed `kranz
17//! run` uses) so CI logs show progress; the only thing on stdout is the final
18//! one-line machine-readable summary:
19//!   `kranz exec <id> <STATUS> cost=$X.XX branch=<b>`
20//!
21//! stdin is never read and no TUI is ever opened.
22
23use 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
39/// Exit code exec fails with when the plan file is underspecified: the
40/// orchestrator wanted clarification it cannot get headlessly (`NotReady`).
41pub const EXIT_UNDERSPECIFIED: i32 = 3;
42
43/// Exit code when the mission COMPLETE'd but `--push` failed. Distinct from
44/// mission failure (1) and underspecified (3) so CI can tell delivery apart
45/// from the run itself. Stdout still reports `pushed=false`.
46pub const EXIT_PUSH_FAILED: i32 = 4;
47
48/// Restores the caller's branch whenever enqueue-only planning exits —
49/// including error and underspecified paths that return before queueing.
50#[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
86/// Non-file inputs to one headless exec invocation.
87pub 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
101/// Map a terminal mission status to the process exit code exec returns.
102///
103/// `Complete` → 0, `Failed` → 1, `Blocked` → 2. Any other status is not a
104/// terminal outcome of a headless run (the engine only returns Complete /
105/// Failed / Blocked from `run()`), so it is treated as a failure (1). The
106/// underspecified case ([`EXIT_UNDERSPECIFIED`]) is handled before the run
107/// starts and never reaches this function.
108pub 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
117/// The unattended scrutiny floor: `exec` runs with no human present, so a
118/// mission whose config disables the scrutiny validator (`skipScrutiny`) has
119/// no adversarial reader at all and can satisfy its own acceptance
120/// tautologically (docs/gascity.md lesson 3 records exactly this incident).
121/// Interactive `run`/`plan` are not gated — a human is present there. Passing
122/// `--allow-unvalidated` (or setting `KRANZ_ALLOW_UNVALIDATED=1`) is an
123/// explicit, auditable acknowledgment that overrides the floor.
124pub 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
139/// Parse a ticket-shaped plan file into a [`Ticket`]. The slug is derived from
140/// the file stem (like [`Ticket::load`]), so the folded [`Ticket::mission_goal`]
141/// carries the goal, scoping answers, acceptance hints, and context verbatim.
142///
143/// Pure over `(slug, markdown)` so the parse path is unit-tested without touching
144/// the filesystem; [`read_mission_file`] is the thin I/O wrapper exec calls.
145pub 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
149/// Read + parse a plan file from disk. The slug is the file stem; a path with
150/// no usable stem falls back to `"mission"`.
151fn 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
161/// `kranz exec -f <mission.md> [--repo <path>] [--yes] [--max-cycles N]
162/// [--enqueue] [--allow-unvalidated]`.
163///
164/// `--yes` is accepted for symmetry with the interactive commands but is a
165/// no-op: a headless run always auto-approves. `--max-cycles`, when given,
166/// overrides `maxFixCyclesPerMilestone` for the run (recorded as a
167/// config.changed event via the control inbox) so CI can bound spend.
168///
169/// Immediately after config loads and before any mission directory is
170/// created, [`scrutiny_gate`] enforces the unattended scrutiny floor: see its
171/// doc comment for the rationale.
172pub 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
188/// The body of [`cmd_exec`], parameterized on the backend so tests can drive
189/// it with [`kranz_engine::backend_mock::MockBackend`] instead of discovering
190/// a real `claude` binary.
191async 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    // Declared before the engine so Rust drops the engine first on every
200    // early return/unwind, then restores the checkout after its Git handles
201    // are out of the way.
202    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    // Seed the orchestrator with the whole ticket, then demand the plan — the
212    // same single-turn seed the non-interactive draft path uses.
213    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            // No human to answer: the plan file was underspecified. Signal CI
232            // with a distinct exit code and surface the questions on stderr.
233            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            // The planner CAN plan but judges the plan likely wrong — the same
252            // "cannot proceed headlessly" class as underspecified (exit 3),
253            // with the escalation reason on stderr for the CI log.
254            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    // Auto-approve: commits plan.json/plan.md on the mission branch.
268    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    // A --max-cycles override is applied via the control inbox so it lands as a
279    // config.changed event the run loop drains (never mutating config out of
280    // band). Enqueued before the engine's run() drains the inbox.
281    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
330/// Put an enqueue-only invocation back on the branch from which it started.
331///
332/// Without this, the non-worktree engine parks the primary checkout on the
333/// mission branch even though the caller only asked to queue future work.
334/// Keep stdout reserved for the one-line `exec` receipt; recovery warnings
335/// belong on stderr.
336fn 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
375/// The tail of [`cmd_exec_with_backend`]: run the (already planned and
376/// approved) `engine` to a terminal state, reconcile the linked ticket, then
377/// handle the `--push` handoff and print the machine-readable summary line.
378///
379/// Split out so tests can drive it against an `engine` whose mission id was
380/// already used to link a ticket — proving the `reconcile_ticket_for_mission`
381/// call actually fires from this code path, not merely that the helper works
382/// in isolation.
383async 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    // Live stderr feed for CI logs: tail events.jsonl from the pre-run head seq.
391    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    // Read the final cost off state before dropping the engine, then drop it
403    // (flushes buffered deltas + releases the lock) so the printer's catch-up
404    // read sees every event.
405    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    // Reconcile the linked ticket's .status sidecar to match the mission's
414    // terminal/blocked status. Non-fatal: a reconcile failure must never
415    // change the exit code or the push behaviour below.
416    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    // Cloud handoff: on a COMPLETE run, push the mission's kranz/* branch to the
421    // requested remote so a human reviews it and opens the PR. GitRepo enforces
422    // the kranz/* guard — this never pushes main or force-pushes. A push failure
423    // keeps stdout `pushed=false` and returns [`EXIT_PUSH_FAILED`] (distinct
424    // from the mission's own exit code) so CI can detect a delivery miss.
425    //
426    // `GitRepo::open` is hardened (audit H3): the tree being pushed is the one
427    // the worker just wrote, so an unhardened handle would run a planted
428    // `pre-push` hook outside every sandbox with the CLI's full ambient
429    // environment, including whatever credential the remote is authenticated
430    // with.
431    //
432    // The push is a NETWORK operation, so the hardening splits (audit F-10 /
433    // F-11): the operator's own `~/.gitconfig` stays in force — nulling it
434    // would leave an https push with no credential helper, no `insteadOf`
435    // rewrite and no `http.proxy` — while `push_mission_branch` REFUSES
436    // outright if this repository's own config, the scope the worker can
437    // write, carries a credential helper, an ssh command, a URL rewrite or a
438    // transport hook. Such a key in that scope is an attack signal, not a
439    // setting to work around, and the refusal surfaces here as a push
440    // failure naming every offending key.
441    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    // The only line on stdout: machine-readable, one line, always emitted.
459    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        // Non-terminal statuses (should not arise from run()) map to failure.
480        assert_eq!(exit_code_for(MissionStatus::Running), 1);
481        assert_eq!(exit_code_for(MissionStatus::Abandoned), 1);
482    }
483
484    /// Composition audit (ticket `config-fail-open-audit`):
485    /// `--allow-unvalidated` lifts EXACTLY the unattended scrutiny floor —
486    /// its blast radius is one refuse-to-run gate, never a validator's deny
487    /// list or the validators themselves (`skipScrutiny` stays an operator
488    /// config decision; the flag only acknowledges it for a headless run,
489    /// and changes nothing when no floor was tripped).
490    #[test]
491    fn composition_audit_allow_unvalidated_lifts_only_the_unattended_scrutiny_floor() {
492        // The floor holds without the flag, and the refusal names it.
493        let err = scrutiny_gate(true, false).unwrap_err();
494        assert!(err.contains("--allow-unvalidated"), "{err}");
495        // The flag acknowledges the floor.
496        assert!(scrutiny_gate(true, true).is_ok());
497        // With validators enabled, the flag is inert — identical outcomes
498        // with and without it.
499        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        // Mission COMPLETE → 0; push failure must not reuse that (or 1/2/3).
506        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    /// Pure helper mirroring the post-run push decision in [`cmd_exec`]: when
514    /// `--push` is set and the push Errs after COMPLETE, the process exit is
515    /// [`EXIT_PUSH_FAILED`] while the summary still reports `pushed=false`.
516    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    // -----------------------------------------------------------------------
556    // reconcile-on-terminal: `kranz exec`'s post-run step heals a linked ticket
557    // -----------------------------------------------------------------------
558
559    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    /// `run_and_reconcile`'s post-run reconcile call must heal the linked
594    /// ticket's stale `.status` sidecar once the headless mission reaches
595    /// Complete — proving the f-1-3 wiring in `exec.rs` (not just the
596    /// engine-level helper unit tests). Links the ticket to the mission and
597    /// seeds it at Failed (a stale mismatch) before calling the function
598    /// under test, so the assertion only passes if the reconcile call
599    /// actually ran, not merely if the ticket happened to already be Done.
600    /// Fails if the `reconcile_ticket_for_mission` call is removed from
601    /// `run_and_reconcile`.
602    #[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        // A single continuous orchestrator session: `cmd_exec_with_backend`
641        // never drops/resumes the engine between planning and run, unlike
642        // `kranz run`'s loop.
643        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                // The run-phase auth probe (orchestrator.rs:2711) fires on the
662                // first worker spawn and consumes a script of its own — a
663                // single-shot, or the worker's script is eaten and the worker
664                // errors on an empty queue.
665                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        // Link a ticket to this mission and stamp it Failed — a stale
691        // mismatch the drove-to-Complete run must heal.
692        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}