adding concurrent option using worktree

This commit is contained in:
pap
2025-07-22 12:03:52 -07:00
parent 4082246f6a
commit 2ca44b46d6
6 changed files with 456 additions and 1 deletions

1
codex-rs/Cargo.lock generated
View File

@@ -640,6 +640,7 @@ dependencies = [
"tokio",
"tracing",
"tracing-subscriber",
"uuid",
]
[[package]]

View File

@@ -36,3 +36,4 @@ tokio = { version = "1", features = [
] }
tracing = "0.1.41"
tracing-subscriber = "0.3.19"
uuid = { version = "1", features = ["v4"] }

View File

@@ -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<bool>,
concurrent_branch_name: &Option<String>,
) -> anyhow::Result<bool> {
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<String> = 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::<Vec<_>>().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::<String>();
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<String> = None;
let mut original_commit: Option<String> = 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<PathBuf> {
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<Option<(PathBuf, String)>> {
// 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/<repo_name>/.
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 <branch_name> <path> 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<I, S>(args: I) -> anyhow::Result<String>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
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<bool> {
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,
}
}

View File

@@ -1,3 +1,4 @@
pub mod concurrent;
pub mod debug_sandbox;
mod exit_status;
pub mod login;

View File

@@ -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<bool>,
/// Explicit branch name to use for the concurrent worktree instead of the default `codex/<slug>`.
/// May also be set via CONCURRENT_BRANCH_NAME env var.
#[clap(long = "concurrent-branch-name", value_name = "BRANCH")]
concurrent_branch_name: Option<String>,
#[clap(subcommand)]
subcommand: Option<Subcommand>,
}
@@ -104,8 +120,21 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> 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);

View File

@@ -237,6 +237,13 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> 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(())
}