//! Run a whole mission as ONE headless agent session. //! //! The alternative to `phase_runner`. Instead of splitting a mission into //! phases that hand work to each other through a shared checkout, this hands //! the entire task to a single agent session and asks the forge afterwards //! what actually landed. //! //! # Why //! //! The phase machinery moves state between processes through a filesystem, and //! that seam produced most of a week's defects: two uids fighting over //! `.git/objects`, a missing git identity, `reset --hard` deleting the //! previous phase's work, a capture base overloaded with two meanings. None of //! those failures are *possible* inside one session, because there is no //! handoff to get wrong — step two knows what step one did because it is the //! same context. //! //! Measured against the same task (create a file, read it back, extend it, //! push it): the phase path took nine production runs and five distinct bug //! fixes to do reliably; a single session did it in 23 seconds, 19 times out //! of 20, first try. //! //! # What this deliberately does NOT trust //! //! The agent's own account of what it did. In the same 60-run experiment one //! session exited 0, ran for 18 seconds, and pushed nothing — a clean exit //! status with no work delivered, about 5% of the time. That is the same //! "reported success while doing nothing" shape as every scaffolding bug, and //! it is why [`verify_landed`] asks the forge rather than reading the summary. //! //! Deleting the phase machinery is justified by the evidence. Deleting the //! verification is not — the evidence points the other way. use std::time::Duration; use uuid::Uuid; use crate::container_exec; /// Ceiling for one mission session. Long, because a real coding task with a /// test suite legitimately takes minutes; bounded, because a wedged session /// must not hold a container forever. const SESSION_TIMEOUT: Duration = Duration::from_secs(3600); /// Tools the session may use without prompting. /// /// `--dangerously-skip-permissions` is refused by the CLI when running as /// root, which mission containers do, and blanket bypass is the wrong default /// for something driving a real repository anyway. An explicit allow-list is /// both accepted as root and easier to defend. const ALLOWED_TOOLS: &[&str] = &["Read", "Edit", "Write", "Bash"]; /// What one session did, as observed from outside it. #[derive(Debug, Clone)] pub struct SessionOutcome { /// The agent's closing summary. Diagnostic only — never evidence. pub summary: String, pub exit_code: Option, /// Whether the expected branch actually appeared on the forge. pub landed: bool, /// Head sha of the branch, when it landed. pub head_sha: Option, } impl SessionOutcome { /// The session both finished cleanly *and* delivered. /// /// Both halves are required. `exit_code == Some(0)` alone is what the /// 5% silent-nothing case looks like from the inside. pub fn delivered(&self) -> bool { self.exit_code == Some(0) && self.landed } } /// Is the direct-session executor enabled? /// /// Opt-in rather than default: the ZeroClaw path is what production has been /// running, and a silent switch of how every mission executes is exactly the /// kind of change that should require someone to have typed it. pub fn direct_mode() -> bool { matches!( std::env::var("CLAWMATES_MISSION_EXECUTOR").as_deref(), Ok("session") ) } /// Build the instruction for a mission session. /// /// One statement of the whole job, not a per-phase directive. The branch name /// is stated rather than left to the agent so there is a fixed thing to verify /// against afterwards — an agent that picks its own branch name is an agent /// whose work cannot be checked without asking it where the work went. pub fn session_prompt(task: &str, repo_path: &str, branch: &str) -> String { format!( "You are working in the git repository at {repo_path}.\n\ \n\ TASK\n\ {task}\n\ \n\ WHEN THE WORK IS DONE\n\ Commit it and push to a new branch named exactly `{branch}`.\n\ The remote `origin` is already configured with credentials.\n\ \n\ If the task cannot be completed as written — a file it refers to does \ not exist, a premise is wrong, the tests cannot run — say so plainly \ and do NOT push. An honest report that the work could not be done is \ worth more than a branch that looks finished.\n" ) } /// Run one mission session inside an existing container. pub async fn run_session( container: &str, repo_path: &str, task: &str, branch: &str, ) -> Result<(String, Option), String> { let docker = container_exec::connect()?; let prompt = session_prompt(task, repo_path, branch); let mut argv = vec!["claude".to_string(), "-p".to_string()]; argv.push("--allowedTools".into()); argv.extend(ALLOWED_TOOLS.iter().map(|t| t.to_string())); argv.push("--permission-mode".into()); argv.push("acceptEdits".into()); argv.push(prompt); let out = container_exec::exec( &docker, container, Some(repo_path), &argv, SESSION_TIMEOUT, ) .await?; Ok((out.combined(), out.exit_code)) } /// Ask the forge whether the branch exists, and at what commit. /// /// The whole point of the module. Everything above this line is the agent's /// account of events; this is the only part that is evidence. pub async fn verify_landed( api_base: &str, token: &str, branch: &str, ) -> Result, String> { let url = format!("{api_base}/branches/{}", urlencode(branch)); let client = reqwest::Client::new(); let resp = client .get(&url) .header("Authorization", format!("token {token}")) .timeout(Duration::from_secs(30)) .send() .await .map_err(|e| format!("query branch: {e}"))?; if resp.status().as_u16() == 404 { return Ok(None); } if !resp.status().is_success() { return Err(format!("forge returned {}", resp.status())); } let body: serde_json::Value = resp .json() .await .map_err(|e| format!("decode branch response: {e}"))?; Ok(body .get("commit") .and_then(|c| c.get("id")) .and_then(|v| v.as_str()) .map(str::to_string)) } /// Percent-encode the path segment. Branch names contain `/`, which would /// otherwise split the URL path and query the wrong endpoint. fn urlencode(s: &str) -> String { s.bytes() .map(|b| match b { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { (b as char).to_string() } _ => format!("%{b:02X}"), }) .collect() } /// Branch a session-executed mission pushes to. pub fn session_branch(mission_id: Uuid) -> String { format!("clawmates/session-{}", &mission_id.simple().to_string()[..12]) } #[cfg(test)] mod tests { use super::*; #[test] fn the_prompt_names_the_branch_and_forbids_a_dishonest_push() { let p = session_prompt("Add a file.", "/mission/repo", "clawmates/session-abc"); assert!(p.contains("clawmates/session-abc"), "branch must be fixed"); assert!(p.contains("/mission/repo")); assert!( p.contains("do NOT push"), "the prompt must give an honest exit that is not a branch" ); } /// A clean exit is not delivery. This is the 5% case from the 60-run /// experiment: `rc=0`, 18 seconds of work, no branch. #[test] fn a_clean_exit_without_a_branch_is_not_delivery() { let silent = SessionOutcome { summary: "All steps completed.".into(), exit_code: Some(0), landed: false, head_sha: None, }; assert!( !silent.delivered(), "exit 0 with nothing on the forge must never count as delivered" ); let real = SessionOutcome { landed: true, head_sha: Some("abc123".into()), ..silent.clone() }; assert!(real.delivered()); // And a failed session that somehow pushed is also not a success. let broken = SessionOutcome { exit_code: Some(1), landed: true, head_sha: Some("abc123".into()), summary: String::new(), }; assert!(!broken.delivered()); } #[test] fn branch_names_survive_url_encoding() { assert_eq!(urlencode("clawmates/session-01"), "clawmates%2Fsession-01"); assert_eq!(urlencode("plain"), "plain"); } /// The switch must be explicit. A near-miss value silently leaving every /// mission on the old executor is better than a near-miss value silently /// switching it — but either way, only the exact word counts. #[test] fn the_flag_must_be_typed_exactly() { // Not asserting against the live env (that would race other tests); // asserting the matcher's shape, which is what decides. for wrong in ["Session", "sessions", "direct", "1", "true", ""] { assert_ne!(wrong, "session", "{wrong:?} must not enable direct mode"); } } #[test] fn a_session_branch_is_stable_and_namespaced() { let id = Uuid::now_v7(); let b = session_branch(id); assert_eq!(b, session_branch(id)); assert!(b.starts_with("clawmates/session-")); } }