diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 9b4a4e32d4..ad19f6ccd6 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -640,6 +640,7 @@ dependencies = [ "tokio", "tracing", "tracing-subscriber", + "uuid", ] [[package]] diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index 943788157b..3bed0b35da 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -36,3 +36,4 @@ tokio = { version = "1", features = [ ] } tracing = "0.1.41" tracing-subscriber = "0.3.19" +uuid = { version = "1", features = ["v4"] } \ No newline at end of file diff --git a/codex-rs/cli/src/concurrent/mod.rs b/codex-rs/cli/src/concurrent/mod.rs new file mode 100644 index 0000000000..f378728a12 --- /dev/null +++ b/codex-rs/cli/src/concurrent/mod.rs @@ -0,0 +1,331 @@ +use std::fs::File; +use std::path::PathBuf; +use std::process::{Command, Stdio}; + +use anyhow::Context; +use codex_common::ApprovalModeCliArg; +use codex_tui::Cli as TuiCli; + +/// Attempt to handle a concurrent background run. Returns Ok(true) if a background exec +/// process was spawned (in which case the caller should NOT start the TUI), or Ok(false) +/// to proceed with normal interactive execution. +pub fn maybe_spawn_concurrent( + tui_cli: &mut TuiCli, + root_raw_overrides: &[String], + concurrent: bool, + concurrent_automerge: Option, + concurrent_branch_name: &Option, +) -> anyhow::Result { + if !concurrent { return Ok(false); } + + // Enforce autonomous execution conditions when running interactive mode. + // Validate git repository presence (required for --concurrent) only if we're in interactive path. + { + let dir_to_check = tui_cli + .cwd + .clone() + .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))); + let status = Command::new("git") + .arg("-C") + .arg(&dir_to_check) + .arg("rev-parse") + .arg("--git-dir") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + if status.as_ref().map(|s| !s.success()).unwrap_or(true) { + eprintln!( + "Error: --concurrent requires a git repository (directory {:?} is not managed by git).", + dir_to_check + ); + std::process::exit(2); + } + } + + let ap = tui_cli.approval_policy; + let approval_on_failure = matches!(ap, Some(ApprovalModeCliArg::OnFailure)); + let autonomous = tui_cli.full_auto + || tui_cli.dangerously_bypass_approvals_and_sandbox + || approval_on_failure; + if !autonomous { + eprintln!( + "Error: --concurrent requires autonomous mode. Use one of: --full-auto, --ask-for-approval on-failure, or --dangerously-bypass-approvals-and-sandbox." + ); + std::process::exit(2); + } + if tui_cli.prompt.is_none() { + eprintln!( + "Error: --concurrent requires a prompt argument so the agent does not wait for interactive input." + ); + std::process::exit(2); + } + + // Build exec args from interactive CLI for autonomous run without TUI (background). + let mut exec_args: Vec = Vec::new(); + if !tui_cli.images.is_empty() { + exec_args.push("--image".into()); + exec_args.push(tui_cli.images.iter().map(|p| p.display().to_string()).collect::>().join(",")); + } + if let Some(model) = &tui_cli.model { exec_args.push("--model".into()); exec_args.push(model.clone()); } + if let Some(profile) = &tui_cli.config_profile { exec_args.push("--profile".into()); exec_args.push(profile.clone()); } + if let Some(sandbox) = &tui_cli.sandbox_mode { exec_args.push("--sandbox".into()); exec_args.push(format!("{sandbox:?}").to_lowercase().replace('_', "-")); } + if tui_cli.full_auto { exec_args.push("--full-auto".into()); } + if tui_cli.dangerously_bypass_approvals_and_sandbox { exec_args.push("--dangerously-bypass-approvals-and-sandbox".into()); } + if tui_cli.skip_git_repo_check { exec_args.push("--skip-git-repo-check".into()); } + for raw in root_raw_overrides { exec_args.push("-c".into()); exec_args.push(raw.clone()); } + + // Derive a single slug (shared by worktree branch & log filename) from the prompt. + let raw_prompt = tui_cli.prompt.as_deref().unwrap_or(""); + let snippet = raw_prompt.chars().take(32).collect::(); + let mut slug: String = snippet + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '-' }) + .collect(); + while slug.contains("--") { slug = slug.replace("--", "-"); } + slug = slug.trim_matches('-').to_string(); + if slug.is_empty() { slug = "prompt".into(); } + + // Determine concurrent defaults from env (no config file), then apply CLI precedence. + let env_automerge = parse_env_bool("CONCURRENT_AUTOMERGE"); + let env_branch_name = std::env::var("CONCURRENT_BRANCH_NAME").ok(); + let effective_automerge = concurrent_automerge.or(env_automerge).unwrap_or(true); + let user_branch_name_opt = concurrent_branch_name.clone().or(env_branch_name); + let branch_name_effective = if let Some(bn_raw) = user_branch_name_opt.as_ref() { + let bn_trim = bn_raw.trim(); + if bn_trim.is_empty() { format!("codex/{slug}") } else { bn_trim.to_string() } + } else { + format!("codex/{slug}") + }; + + // Unique job id for this concurrent run (used for log file naming instead of slug). + let job_id = uuid::Uuid::new_v4().to_string(); + + // If user did NOT specify an explicit cwd, create an isolated git worktree. + let mut created_worktree: Option<(PathBuf, String)> = None; // (path, branch) + let mut original_branch: Option = None; + let mut original_commit: Option = None; + if tui_cli.cwd.is_none() { + // Capture original branch & commit (best-effort). + original_branch = git_capture(["rev-parse", "--abbrev-ref", "HEAD"]).ok(); + original_commit = git_capture(["rev-parse", "HEAD"]).ok(); + // Use branch_name_effective for branch/worktree name. + match create_concurrent_worktree(&branch_name_effective) { + Ok(Some((worktree_path, branch_name))) => { + println!( + "Created git worktree at {} (branch {}) for concurrent run", + worktree_path.display(), branch_name + ); + exec_args.push("--cd".into()); + exec_args.push(worktree_path.display().to_string()); + created_worktree = Some((worktree_path, branch_name)); + } + Ok(None) => { + eprintln!("Warning: Not a git repository (skipping worktree creation); running in current directory."); + } + Err(e) => { + eprintln!("Error: failed to create git worktree for --concurrent: {e}"); + eprintln!("Hint: remove or rename existing branch '{branch_name_effective}', or pass --concurrent-branch-name to choose a unique name."); + std::process::exit(3); + } + } + } else if let Some(explicit) = &tui_cli.cwd { + exec_args.push("--cd".into()); + exec_args.push(explicit.display().to_string()); + } + + // Prompt (safe to unwrap due to earlier validation). + if let Some(prompt) = tui_cli.prompt.clone() { exec_args.push(prompt); } + + // Prepare log file path using stable job id (UUID) rather than prompt slug. + let log_dir = match codex_base_dir() { + Ok(base) => { + let d = base.join("log"); + let _ = std::fs::create_dir_all(&d); + d + } + Err(_) => PathBuf::from("/tmp"), + }; + let log_path = log_dir.join(format!("codex-logs-{}.log", job_id)); + + match File::create(&log_path) { + Ok(file) => { + let file_err = file.try_clone().ok(); + let mut cmd = Command::new( + std::env::current_exe().unwrap_or_else(|_| PathBuf::from("codex")) + ); + cmd.arg("exec"); + for a in &exec_args { cmd.arg(a); } + // Provide metadata for auto merge if we created a worktree. + if let Some((wt_path, branch)) = &created_worktree { + if effective_automerge { cmd.env("CODEX_CONCURRENT_AUTOMERGE", "1"); } + cmd.env("CODEX_CONCURRENT_BRANCH", branch); + cmd.env("CODEX_CONCURRENT_WORKTREE", wt_path); + if let Some(ob) = &original_branch { cmd.env("CODEX_ORIGINAL_BRANCH", ob); } + if let Some(oc) = &original_commit { cmd.env("CODEX_ORIGINAL_COMMIT", oc); } + if let Ok(orig_root) = std::env::current_dir() { cmd.env("CODEX_ORIGINAL_ROOT", orig_root); } + } + cmd.stdout(Stdio::from(file)); + if let Some(f2) = file_err { cmd.stderr(Stdio::from(f2)); } + match cmd.spawn() { + Ok(child) => { + if let Some((wt_path, wt_branch)) = &created_worktree { + println!( + "Background Codex exec started in worktree. PID={} job_id={} log={} worktree={} branch={} original_branch={} automerge={}", + child.id(), job_id, log_path.display(), wt_path.display(), wt_branch, + original_branch.as_deref().unwrap_or("?"), effective_automerge + ); + } else { + println!( + "Background Codex exec started. PID={} job_id={} log={} automerge={}", + child.id(), job_id, log_path.display(), effective_automerge + ); + } + + // Record job metadata to CODEX_HOME/jobs.jsonl (JSON Lines file). + let record_time = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + if let Ok(base) = codex_base_dir() { + let jobs_path = base.join("jobs.jsonl"); + let record = serde_json::json!({ + "job_id": job_id, + "pid": child.id(), + "worktree": created_worktree.as_ref().map(|(p, _)| p.display().to_string()), + "branch": created_worktree.as_ref().map(|(_, b)| b.clone()), + "original_branch": original_branch, + "original_commit": original_commit, + "log_path": log_path.display().to_string(), + "prompt": raw_prompt, + "start_time": record_time, + "automerge": effective_automerge, + "explicit_branch_name": user_branch_name_opt, + }); + if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&jobs_path) { + use std::io::Write; + if let Err(e) = writeln!(f, "{}", record.to_string()) { + eprintln!("Warning: failed writing job record to {}: {e}", jobs_path.display()); + } + } else { + eprintln!("Warning: could not open jobs log file at {}", jobs_path.display()); + } + } + return Ok(true); // background spawned + } + Err(e) => { + eprintln!("Failed to start background exec: {e}. Falling back to interactive mode."); + } + } + } + Err(e) => { + eprintln!( + "Failed to create log file {}: {e}. Falling back to interactive mode.", + log_path.display() + ); + } + } + + Ok(false) +} + +/// Return the base Codex directory under the user's home (~/.codex), creating it if necessary. +fn codex_base_dir() -> anyhow::Result { + if let Ok(val) = std::env::var("CODEX_HOME") { + if !val.is_empty() { + return Ok(PathBuf::from(val).canonicalize()?); + } + } + let home = std::env::var_os("HOME").ok_or_else(|| anyhow::anyhow!("Could not find home directory"))?; + let base = PathBuf::from(home).join(".codex"); + std::fs::create_dir_all(&base)?; + Ok(base) +} + +/// Attempt to create a git worktree for an isolated concurrent run. +/// Returns Ok(Some((worktree_path, branch_name))) on success, Ok(None) if not a git repo, and Err on failure. +fn create_concurrent_worktree(branch_name: &str) -> anyhow::Result> { + // Determine repository root. + let output = Command::new("git").arg("rev-parse").arg("--show-toplevel").output(); + let repo_root = match output { + Ok(out) if out.status.success() => { + let s = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if s.is_empty() { return Ok(None); } + PathBuf::from(s) + } + _ => return Ok(None), + }; + + // Derive repo name from root directory. + let repo_name = repo_root + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("repo"); + + // Fast-fail if branch already exists. + if Command::new("git") + .current_dir(&repo_root) + .arg("rev-parse") + .arg("--verify") + .arg(branch_name) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) { + anyhow::bail!("branch '{branch_name}' already exists"); + } + + // Construct worktree directory under ~/.codex/worktrees//. + let base_dir = codex_base_dir()?.join("worktrees").join(repo_name); + std::fs::create_dir_all(&base_dir)?; + let mut worktree_path = base_dir.join(branch_name.replace('/', "-")); + + // Ensure uniqueness if path already exists. + if worktree_path.exists() { + for i in 1..1000 { // arbitrary cap + let candidate = base_dir.join(format!("{}-{}", branch_name.replace('/', "-"), i)); + if !candidate.exists() { worktree_path = candidate; break; } + } + } + + // Run: git worktree add -b HEAD + let status = Command::new("git") + .current_dir(&repo_root) + .arg("worktree") + .arg("add") + .arg("-b") + .arg(&branch_name) + .arg(&worktree_path) + .arg("HEAD") + .status()?; + + if !status.success() { + anyhow::bail!("git worktree add failed with status {status}"); + } + + Ok(Some((worktree_path, branch_name.to_string()))) +} + +/// Helper: capture trimmed stdout of a git command. +fn git_capture(args: I) -> anyhow::Result +where + I: IntoIterator, + S: AsRef, +{ + let mut cmd = Command::new("git"); + for a in args { cmd.arg(a.as_ref()); } + let out = cmd.output().context("running git command")?; + if !out.status.success() { anyhow::bail!("git command failed"); } + Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) +} + +/// Parse common boolean environment variable representations. +fn parse_env_bool(name: &str) -> Option { + let raw = std::env::var(name).ok()?; + let lower = raw.to_ascii_lowercase(); + match lower.as_str() { + "1" | "true" | "yes" | "on" => Some(true), + "0" | "false" | "no" | "off" => Some(false), + _ => None, + } +} \ No newline at end of file diff --git a/codex-rs/cli/src/lib.rs b/codex-rs/cli/src/lib.rs index c6d80c0adf..8498f50617 100644 --- a/codex-rs/cli/src/lib.rs +++ b/codex-rs/cli/src/lib.rs @@ -1,3 +1,4 @@ +pub mod concurrent; pub mod debug_sandbox; mod exit_status; pub mod login; diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 7e23782d75..f7296732cd 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -4,6 +4,7 @@ use clap_complete::Shell; use clap_complete::generate; use codex_chatgpt::apply_command::ApplyCommand; use codex_chatgpt::apply_command::run_apply_command; +use codex_cli::concurrent::maybe_spawn_concurrent; use codex_cli::LandlockCommand; use codex_cli::SeatbeltCommand; use codex_cli::login::run_login_with_chatgpt; @@ -32,6 +33,21 @@ struct MultitoolCli { #[clap(flatten)] interactive: TuiCli, + /// Autonomous mode: run the command in the background & concurrently using a git worktree. + /// Requires the current directory (or --cd provided path) to be a git repository. + #[clap(long)] + concurrent: bool, + + /// Control whether the concurrent run auto-merges the worktree branch back into the original branch. + /// Defaults to true (may also be set via CONCURRENT_AUTOMERGE env var). + #[clap(long = "concurrent-automerge", value_name = "BOOL")] + concurrent_automerge: Option, + + /// Explicit branch name to use for the concurrent worktree instead of the default `codex/`. + /// May also be set via CONCURRENT_BRANCH_NAME env var. + #[clap(long = "concurrent-branch-name", value_name = "BRANCH")] + concurrent_branch_name: Option, + #[clap(subcommand)] subcommand: Option, } @@ -104,8 +120,21 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() match cli.subcommand { None => { let mut tui_cli = cli.interactive; + let root_raw_overrides = cli.config_overrides.raw_overrides.clone(); prepend_config_flags(&mut tui_cli.config_overrides, cli.config_overrides); - codex_tui::run_main(tui_cli, codex_linux_sandbox_exe)?; + // Attempt concurrent background spawn; if it returns true we skip launching the TUI. + if let Ok(spawned) = maybe_spawn_concurrent( + &mut tui_cli, + &root_raw_overrides, + cli.concurrent, + cli.concurrent_automerge, + &cli.concurrent_branch_name, + ) { + if !spawned { codex_tui::run_main(tui_cli, codex_linux_sandbox_exe)?; } + } else { + // On error fallback to interactive. + codex_tui::run_main(tui_cli, codex_linux_sandbox_exe)?; + } } Some(Subcommand::Exec(mut exec_cli)) => { prepend_config_flags(&mut exec_cli.config_overrides, cli.config_overrides); diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 620ab82327..f62313d0b1 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -237,6 +237,13 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any } } + // If running in concurrent auto-merge mode, attempt to commit and merge original branch. + if std::env::var("CODEX_CONCURRENT_AUTOMERGE").ok().as_deref() == Some("1") { + if let Err(e) = auto_commit_and_fast_forward_original_branch() { + eprintln!("[codex-concurrent] Auto-merge skipped: {e}"); + } + } + Ok(()) } @@ -261,3 +268,88 @@ fn handle_last_message( } Ok(()) } + +/// Auto-commit changes in the concurrent worktree branch and integrate them back into the original branch. +/// Strategy: +/// 1. Commit any pending changes on the concurrent branch. +/// 2. Checkout the original branch in the original root and perform a --no-ff merge. +/// Safety: Only performs merge operations if repository state allows; on conflicts it aborts and reports. +fn auto_commit_and_fast_forward_original_branch() -> anyhow::Result<()> { + use std::process::Command; + let concurrent_branch = std::env::var("CODEX_CONCURRENT_BRANCH").ok().ok_or_else(|| anyhow::anyhow!("missing concurrent branch env"))?; + let original_branch = std::env::var("CODEX_ORIGINAL_BRANCH").ok().ok_or_else(|| anyhow::anyhow!("missing original branch env"))?; + let original_commit = std::env::var("CODEX_ORIGINAL_COMMIT").ok().ok_or_else(|| anyhow::anyhow!("missing original commit env"))?; + let worktree_dir_env = std::env::var("CODEX_CONCURRENT_WORKTREE").ok(); + let original_root_env = std::env::var("CODEX_ORIGINAL_ROOT").ok(); + + // Determine directory to run git commit for concurrent branch (worktree if provided, else repo root from rev-parse). + let worktree_dir = if let Some(wt) = worktree_dir_env.clone() { + std::path::PathBuf::from(wt) + } else { + let repo_root = Command::new("git").args(["rev-parse", "--show-toplevel"]).output()?; + if !repo_root.status.success() { anyhow::bail!("not a git repo"); } + std::path::PathBuf::from(String::from_utf8_lossy(&repo_root.stdout).trim().to_string()) + }; + + // Commit pending changes (git add ., git commit -m ...). + let status_out = Command::new("git") + .current_dir(&worktree_dir) + .args(["status", "--porcelain"]).output()?; + if !status_out.status.success() { anyhow::bail!("git status failed"); } + if !status_out.stdout.is_empty() { + let add_status = Command::new("git") + .current_dir(&worktree_dir) + .args(["add", "."]).status()?; + if !add_status.success() { anyhow::bail!("git add failed"); } + let commit_msg = format!("Codex concurrent run auto-commit on branch {concurrent_branch}"); + let commit_status = Command::new("git") + .current_dir(&worktree_dir) + .args(["commit", "-m", &commit_msg]).status()?; + if !commit_status.success() { anyhow::bail!("git commit failed"); } + eprintln!("[codex-concurrent] Created commit in {concurrent_branch}."); + } else { + eprintln!("[codex-concurrent] No changes to commit in {concurrent_branch}."); + } + + // Capture head of concurrent branch (for potential future use / diagnostics). + let concurrent_head_out = Command::new("git") + .current_dir(&worktree_dir) + .args(["rev-parse", &concurrent_branch]).output()?; + if !concurrent_head_out.status.success() { anyhow::bail!("failed to rev-parse concurrent branch"); } + + // Determine where to integrate (original root if known, else worktree). + let integration_dir = if let Some(root) = original_root_env.clone() { std::path::PathBuf::from(root) } else { worktree_dir.clone() }; + + // Checkout original branch. + let co_status = Command::new("git") + .current_dir(&integration_dir) + .args(["checkout", &original_branch]) + .status()?; + if !co_status.success() { anyhow::bail!("git checkout {original_branch} failed in original root"); } + + // Check if concurrent branch already merged (ancestor test). + let ancestor_status = Command::new("git") + .current_dir(&integration_dir) + .args(["merge-base", "--is-ancestor", &concurrent_branch, &original_branch]) + .status(); + if let Ok(code) = ancestor_status { + if code.success() { + eprintln!("[codex-concurrent] {concurrent_branch} already merged into {original_branch}; skipping."); + return Ok(()); + } + } + + // Perform a --no-ff merge. + let merge_msg = format!("Merge concurrent Codex branch {concurrent_branch} (base {original_commit})"); + let merge_status = Command::new("git") + .current_dir(&integration_dir) + .args(["merge", "--no-ff", &concurrent_branch, "-m", &merge_msg]) + .status()?; + if !merge_status.success() { + let _ = Command::new("git").current_dir(&integration_dir).args(["merge", "--abort"]).status(); + anyhow::bail!("git merge --no-ff failed (conflicts?)"); + } + eprintln!("[codex-concurrent] Merged {concurrent_branch} into {original_branch} in original root: {}", integration_dir.display()); + + Ok(()) +}