Files
g3/crates/studio/src/main.rs
Dhanji R. Prasanna bc2860dd3a studio sdlc: merge worktree on completion, move state to .g3/
- Add merge step before worktree cleanup when pipeline completes
- On success with commits: merge to main, then cleanup
- On failure: preserve worktree for debugging, print path
- On merge conflict: preserve worktree, print resolution instructions
- Move pipeline.json from analysis/sdlc/ to .g3/sdlc/ (gitignored)
2026-02-05 13:03:54 +11:00

974 lines
31 KiB
Rust

use anyhow::{anyhow, bail, Context, Result};
use clap::{Parser, Subcommand};
use std::env;
use std::fs;
use std::io::{BufRead, BufReader};
use std::path::Path;
use std::path::PathBuf;
use std::process::{Command, Stdio};
use termimad::MadSkin;
mod git;
mod sdlc;
mod session;
use git::GitWorktree;
use session::{Session, SessionStatus};
/// Studio - Multi-agent workspace manager for g3
#[derive(Parser)]
#[command(name = "studio", subcommand_required = false)]
#[command(about = "Manage multiple g3 agent sessions using git worktrees")]
struct Cli {
#[command(subcommand)]
command: Option<Commands>,
}
#[derive(Subcommand)]
enum Commands {
/// Start a new interactive g3 session in an isolated worktree
#[command(alias = "c")]
Cli,
/// Resume a paused interactive session
#[command(alias = "r")]
Resume {
/// Session ID to resume
session_id: String,
},
/// Run a new g3 session (tails output until complete)
Run {
/// Agent name (e.g., carmack, torvalds). If omitted, runs g3 in one-shot mode.
#[arg(long)]
agent: Option<String>,
/// Automatically accept the session if it completes with commits
#[arg(long)]
accept: bool,
/// Additional arguments to pass to g3
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
g3_args: Vec<String>,
},
/// Execute a g3 agent session in detached mode (for future use)
Exec {
/// Agent name (e.g., carmack, torvalds). If omitted, runs g3 in one-shot mode.
#[arg(long)]
agent: Option<String>,
/// Additional arguments to pass to g3
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
g3_args: Vec<String>,
},
/// List all active sessions
List,
/// Show status of a session
Status {
/// Session ID
session_id: String,
},
/// Accept a session: merge to main and cleanup
Accept {
/// Session ID
session_id: String,
},
/// Discard a session: delete without merging
Discard {
/// Session ID
session_id: String,
},
/// Run the SDLC maintenance pipeline
Sdlc {
#[command(subcommand)]
action: SdlcAction,
},
}
#[derive(Subcommand)]
enum SdlcAction {
/// Run the SDLC pipeline (or resume if interrupted)
Run {
/// Number of commits to process per stage (default: 10)
#[arg(long, short, default_value = "10")]
commits: u32,
/// Set the commit cursor to start from (skips commits before this)
#[arg(long)]
from: Option<String>,
},
/// Show current pipeline status
Status,
/// Reset pipeline state (start fresh)
Reset,
}
fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command.unwrap_or(Commands::Cli) {
Commands::Cli => cmd_cli(),
Commands::Resume { session_id } => cmd_resume(&session_id),
Commands::Run { agent, accept, g3_args } => cmd_run(agent.as_deref(), accept, &g3_args),
Commands::Exec { agent, g3_args } => cmd_exec(agent.as_deref(), &g3_args),
Commands::List => cmd_list(),
Commands::Status { session_id } => cmd_status(&session_id),
Commands::Accept { session_id } => cmd_accept(&session_id),
Commands::Discard { session_id } => cmd_discard(&session_id),
Commands::Sdlc { action } => match action {
SdlcAction::Run { commits, from } => cmd_sdlc_run(commits, from),
SdlcAction::Status => cmd_sdlc_status(),
SdlcAction::Reset => cmd_sdlc_reset(),
},
}
}
/// Start a new interactive g3 session
fn cmd_cli() -> Result<()> {
let g3_binary = get_g3_binary_path()?;
let repo_root = get_repo_root()?;
let session = Session::new_interactive();
// Create worktree
let worktree = GitWorktree::new(&repo_root);
let worktree_path = worktree.create(&session)?;
println!("📁 Worktree: {}", worktree_path.display());
println!("🌿 Branch: {}", session.branch_name());
println!("🆔 Session: {}", session.id);
println!();
// Save session metadata
session.save(&repo_root, &worktree_path)?;
// Build g3 command with inherited stdio for interactive use
let mut cmd = Command::new(&g3_binary);
cmd.arg("--workspace").arg(&worktree_path);
cmd.current_dir(&worktree_path);
cmd.stdin(Stdio::inherit());
cmd.stdout(Stdio::inherit());
cmd.stderr(Stdio::inherit());
// Spawn and wait
let status = cmd.status().context("Failed to run g3")?;
// Mark session as paused (user can resume later or accept/discard)
session.mark_paused(&repo_root)?;
println!();
println!("Session {} paused.", session.id);
println!();
println!("Next steps:");
println!(" studio resume {} - Continue working", session.id);
println!(" studio accept {} - Merge changes to main", session.id);
println!(" studio discard {} - Discard changes", session.id);
if !status.success() {
std::process::exit(status.code().unwrap_or(1));
}
Ok(())
}
/// Resume a paused interactive session
fn cmd_resume(session_id: &str) -> Result<()> {
let g3_binary = get_g3_binary_path()?;
let repo_root = get_repo_root()?;
let session = Session::load(&repo_root, session_id)?;
let worktree_path = session.worktree_path.as_ref()
.ok_or_else(|| anyhow!("Session {} has no worktree path", session_id))?;
if !worktree_path.exists() {
bail!("Worktree for session {} no longer exists at {}", session_id, worktree_path.display());
}
println!("📁 Resuming session {} in {}", session_id, worktree_path.display());
println!();
// Build g3 command with inherited stdio
let mut cmd = Command::new(&g3_binary);
cmd.arg("--workspace").arg(worktree_path);
cmd.current_dir(worktree_path);
cmd.stdin(Stdio::inherit());
cmd.stdout(Stdio::inherit());
cmd.stderr(Stdio::inherit());
// Spawn and wait
let status = cmd.status().context("Failed to run g3")?;
// Mark session as paused again
session.mark_paused(&repo_root)?;
println!();
println!("Session {} paused.", session_id);
println!();
println!("Next steps:");
println!(" studio resume {} - Continue working", session_id);
println!(" studio accept {} - Merge changes to main", session_id);
println!(" studio discard {} - Discard changes", session_id);
if !status.success() {
std::process::exit(status.code().unwrap_or(1));
}
Ok(())
}
/// Get the path to the g3 binary (same directory as studio)
fn get_g3_binary_path() -> Result<PathBuf> {
let current_exe = env::current_exe().context("Failed to get current executable path")?;
let exe_dir = current_exe
.parent()
.ok_or_else(|| anyhow!("Failed to get executable directory"))?;
let g3_path = exe_dir.join("g3");
if !g3_path.exists() {
bail!(
"g3 binary not found at {:?}. Ensure g3 is built and in the same directory as studio.",
g3_path
);
}
Ok(g3_path)
}
/// Get the repository root (where .git is)
fn get_repo_root() -> Result<PathBuf> {
let output = Command::new("git")
.args(["rev-parse", "--show-toplevel"])
.output()
.context("Failed to run git rev-parse")?;
if !output.status.success() {
bail!("Not in a git repository");
}
let path = String::from_utf8(output.stdout)
.context("Invalid UTF-8 in git output")?
.trim()
.to_string();
Ok(PathBuf::from(path))
}
/// Filter --accept from g3_args and return (filtered_args, was_accept_present)
/// This handles the case where --accept is passed after positional args,
/// which clap's trailing_var_arg captures instead of parsing as our flag.
fn filter_accept_flag(g3_args: &[String]) -> (Vec<String>, bool) {
let accept_in_args = g3_args.iter().any(|arg| arg == "--accept");
let filtered: Vec<String> = g3_args
.iter()
.filter(|arg| *arg != "--accept")
.cloned()
.collect();
(filtered, accept_in_args)
}
/// Run a new g3 session (foreground, tails output)
fn cmd_run(agent: Option<&str>, auto_accept: bool, g3_args: &[String]) -> Result<()> {
let (g3_args, accept_in_args) = filter_accept_flag(g3_args);
let auto_accept = auto_accept || accept_in_args;
let g3_binary = get_g3_binary_path()?;
let repo_root = get_repo_root()?;
// Use "single" as the agent name for non-agent runs
let agent_name = agent.unwrap_or("single");
let session = Session::new(agent_name);
// Create worktree
let worktree = GitWorktree::new(&repo_root);
let worktree_path = worktree.create(&session)?;
// Print compact session info: "studio:" in bold green, session id in inline-code orange
println!("\x1b[1;32mstudio:\x1b[0m new session \x1b[38;2;216;177;114m{}\x1b[0m", session.id);
// Build g3 command with --workspace prepended
let mut cmd = Command::new(&g3_binary);
cmd.arg("--workspace").arg(&worktree_path);
// Only add --agent if an agent was specified
if let Some(a) = agent {
cmd.arg("--agent").arg(a);
}
cmd.args(g3_args);
cmd.current_dir(&worktree_path);
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
// Save session metadata
session.save(&repo_root, &worktree_path)?;
println!();
// Spawn and tail output
let mut child = cmd.spawn().context("Failed to spawn g3 process")?;
// Update session with PID
session.update_pid(&repo_root, child.id())?;
// Tail stdout in a separate thread
let stdout = child.stdout.take().expect("Failed to capture stdout");
let stderr = child.stderr.take().expect("Failed to capture stderr");
let stdout_handle = std::thread::spawn(move || {
let reader = BufReader::new(stdout);
for line in reader.lines() {
if let Ok(line) = line {
println!("{}", line);
}
}
});
let stderr_handle = std::thread::spawn(move || {
let reader = BufReader::new(stderr);
for line in reader.lines() {
if let Ok(line) = line {
eprintln!("{}", line);
}
}
});
// Wait for process to complete
let status = child.wait().context("Failed to wait for g3 process")?;
stdout_handle.join().ok();
stderr_handle.join().ok();
println!();
// Update session status
session.mark_complete(&repo_root, status.success())?;
if status.success() {
// Auto-accept if flag is set and there are commits on the branch
if auto_accept {
if has_commits_on_branch(&worktree_path, &session.branch_name())? {
return accept_session(&session.id, "\x1b[1;32mstudio:\x1b[0m");
} else {
println!();
println!("⚠️ --accept flag set but no commits on branch, skipping auto-accept");
}
}
println!();
println!("Next steps:");
println!(" studio accept {} - Merge changes to main", session.id);
println!(" studio discard {} - Discard changes", session.id);
} else {
println!("❌ Session {} failed (exit code: {:?})", session.id, status.code());
}
Ok(())
}
/// Check if a branch has commits ahead of main
fn has_commits_on_branch(worktree_path: &Path, branch_name: &str) -> Result<bool> {
// Count commits on branch that aren't on main
let output = Command::new("git")
.current_dir(worktree_path)
.args(["rev-list", "--count", &format!("main..{}", branch_name)])
.output()
.context("Failed to check commits on branch")?;
if !output.status.success() {
return Ok(false);
}
let count: u32 = String::from_utf8_lossy(&output.stdout).trim().parse().unwrap_or(0);
Ok(count > 0)
}
/// Execute a g3 session in detached mode (placeholder for future)
fn cmd_exec(agent: Option<&str>, g3_args: &[String]) -> Result<()> {
// For now, just print what would happen
println!("exec command not yet implemented");
match agent {
Some(a) => println!("Would run agent '{}' with args: {:?}", a, g3_args),
None => println!("Would run one-shot session with args: {:?}", g3_args),
}
Ok(())
}
/// List all sessions
fn cmd_list() -> Result<()> {
let repo_root = get_repo_root()?;
let sessions = Session::list_all(&repo_root)?;
if sessions.is_empty() {
println!("No active sessions.");
return Ok(());
}
println!("{:<12} {:<12} {:<10} {:<20}", "SESSION", "AGENT", "STATUS", "CREATED");
println!("{}", "".repeat(60));
for session in sessions {
let status_str = match session.status {
SessionStatus::Running => "🔄 running",
SessionStatus::Paused => "⏸️ paused",
SessionStatus::Complete => "✅ complete",
SessionStatus::Failed => "❌ failed",
};
println!(
"{:<12} {:<12} {:<10} {:<20}",
session.id,
session.agent,
status_str,
session.created_at.format("%Y-%m-%d %H:%M")
);
}
Ok(())
}
/// Show status of a specific session
fn cmd_status(session_id: &str) -> Result<()> {
let repo_root = get_repo_root()?;
let session = Session::load(&repo_root, session_id)?;
println!("Session: {}", session.id);
println!("Agent: {}", session.agent);
println!("Branch: {}", session.branch_name());
println!("Created: {}", session.created_at.format("%Y-%m-%d %H:%M:%S"));
println!("Status: {:?}", session.status);
if let Some(path) = &session.worktree_path {
println!("Worktree: {}", path.display());
}
// Check if process is still running
if session.status == SessionStatus::Running {
if let Some(pid) = session.pid {
let is_running = is_process_running(pid);
if is_running {
println!("Process: Running (PID {})", pid);
} else {
println!("Process: Not running (stale session)");
}
}
}
// Try to extract summary from session logs if complete
if session.status != SessionStatus::Running {
if let Some(summary) = extract_session_summary(&session) {
println!();
println!("Summary:");
println!("{}", "".repeat(60));
let skin = MadSkin::default();
skin.print_text(&summary);
}
}
Ok(())
}
/// Accept a session: merge to main and cleanup
fn cmd_accept(session_id: &str) -> Result<()> {
accept_session(session_id, ">")
}
/// Internal function to accept a session with a custom prefix
fn accept_session(session_id: &str, prefix: &str) -> Result<()> {
let repo_root = get_repo_root()?;
let session = Session::load(&repo_root, session_id)?;
// Check session is not still running
if session.status == SessionStatus::Running {
if let Some(pid) = session.pid {
if is_process_running(pid) {
bail!("Session {} is still running (PID {}). Wait for it to complete or kill it first.", session_id, pid);
}
}
}
let worktree = GitWorktree::new(&repo_root);
let branch_name = session.branch_name();
// Print status line without newline, then complete after operations
use std::io::Write;
print!("{} session {} ... ", prefix, session_id);
std::io::stdout().flush().ok();
// Merge the branch to main
worktree.merge_to_main(&branch_name)?;
// Remove worktree and branch
worktree.remove(&session)?;
// Remove session metadata
session.delete(&repo_root)?;
println!("[\x1b[1;32mmerged\x1b[0m]");
Ok(())
}
/// Discard a session: delete without merging
fn cmd_discard(session_id: &str) -> Result<()> {
let repo_root = get_repo_root()?;
let session = Session::load(&repo_root, session_id)?;
// Check session is not still running
if session.status == SessionStatus::Running {
if let Some(pid) = session.pid {
if is_process_running(pid) {
bail!("Session {} is still running (PID {}). Wait for it to complete or kill it first.", session_id, pid);
}
}
}
let worktree = GitWorktree::new(&repo_root);
// Print status line without newline, then complete after operations
use std::io::Write;
print!("> session {} ... ", session_id);
std::io::stdout().flush().ok();
// Remove worktree and branch
worktree.remove(&session)?;
// Remove session metadata
session.delete(&repo_root)?;
println!("[\x1b[1;33mdiscarded\x1b[0m]");
Ok(())
}
/// Run the SDLC pipeline
fn cmd_sdlc_run(commits_per_run: u32, from_commit: Option<String>) -> Result<()> {
let repo_root = get_repo_root()?;
// Load or create pipeline state
let mut state = match sdlc::PipelineState::load(&repo_root)? {
Some(mut existing) => {
// Resume from where we left off
existing.resume();
println!("\x1b[1;32msdlc:\x1b[0m resuming pipeline run \x1b[38;2;216;177;114m{}\x1b[0m", existing.run_id);
existing
}
None => {
let mut state = sdlc::PipelineState::new(commits_per_run);
// If --from is specified, set the cursor
if let Some(ref from) = from_commit {
// Resolve the commit hash
let resolved = resolve_commit(&repo_root, from)?;
state.commit_cursor = Some(resolved.clone());
println!("\x1b[1;32msdlc:\x1b[0m starting new pipeline run \x1b[38;2;216;177;114m{}\x1b[0m (from {})",
state.run_id, &resolved[..8.min(resolved.len())]);
} else {
println!("\x1b[1;32msdlc:\x1b[0m starting new pipeline run \x1b[38;2;216;177;114m{}\x1b[0m", state.run_id);
}
state
}
};
// Get current HEAD commit
let head_commit = get_head_commit(&repo_root)?;
// Check if there are commits to process
let commits_to_process = if let Some(cursor) = &state.commit_cursor {
count_commits_between(&repo_root, cursor, &head_commit)?
} else {
// First run - use commits_per_run as the count
commits_per_run
};
if commits_to_process == 0 {
println!("\x1b[1;32msdlc:\x1b[0m no new commits since last run");
return Ok(());
}
println!("\x1b[1;32msdlc:\x1b[0m {} commits to process", commits_to_process.min(commits_per_run));
// Display the pipeline
sdlc::display_pipeline(&state);
// Create a dedicated worktree for SDLC
let g3_binary = get_g3_binary_path()?;
let sdlc_session = Session::new("sdlc");
let worktree = GitWorktree::new(&repo_root);
let worktree_path = worktree.create(&sdlc_session)?;
// Save session info for crash recovery
state.session_id = Some(sdlc_session.id.clone());
sdlc_session.save(&repo_root, &worktree_path)?;
state.save(&repo_root)?;
// Run each stage
while !state.is_complete() && state.current_stage < sdlc::PIPELINE_STAGES.len() {
let stage = &sdlc::PIPELINE_STAGES[state.current_stage];
// Display current stage
sdlc::display_current_stage(&state);
println!();
// Mark as running and save
state.mark_running();
state.save(&repo_root)?;
let start_time = std::time::Instant::now();
// Build the task prompt for this agent
let task = format!(
"Focus on changes in the past {} commits (up to {}). {}",
commits_per_run.min(commits_to_process),
&head_commit[..8.min(head_commit.len())],
stage.focus
);
// Run the agent
let result = run_agent_in_worktree(
&g3_binary,
&worktree_path,
stage.name,
&task,
);
let duration = start_time.elapsed().as_secs();
match result {
Ok(true) => {
// Success
state.mark_complete(duration, commits_to_process.min(commits_per_run), &head_commit);
println!();
println!("\x1b[1;32msdlc:\x1b[0m stage \x1b[1m{}\x1b[0m complete in {}",
stage.name, format_duration_short(duration));
}
Ok(false) => {
// Agent completed but with non-zero exit
state.mark_failed("Agent exited with non-zero status");
println!();
println!("\x1b[1;31msdlc:\x1b[0m stage \x1b[1m{}\x1b[0m failed", stage.name);
state.save(&repo_root)?;
break;
}
Err(e) => {
// Error running agent
state.mark_failed(&e.to_string());
println!();
println!("\x1b[1;31msdlc:\x1b[0m stage \x1b[1m{}\x1b[0m error: {}", stage.name, e);
state.save(&repo_root)?;
break;
}
}
state.save(&repo_root)?;
// Display updated pipeline
sdlc::display_pipeline(&state);
}
// Handle completion: merge if successful, otherwise preserve for debugging
if state.is_complete() {
// Check if there are commits to merge
let branch_name = sdlc_session.branch_name();
if has_commits_on_branch(&worktree_path, &branch_name)? {
// Merge to main before cleanup
print!("\x1b[1;32msdlc:\x1b[0m merging changes to main ... ");
std::io::Write::flush(&mut std::io::stdout()).ok();
match worktree.merge_to_main(&branch_name) {
Ok(()) => {
println!("[\x1b[1;32mmerged\x1b[0m]");
}
Err(e) => {
println!("[\x1b[1;31mfailed\x1b[0m]");
println!("\x1b[1;31msdlc:\x1b[0m merge failed: {}", e);
println!(" Worktree preserved at: {}", worktree_path.display());
println!(" Resolve conflicts manually, then run 'studio accept {}'", sdlc_session.id);
state.save(&repo_root)?;
return Ok(());
}
}
}
// Cleanup worktree after successful merge (or if no commits)
worktree.remove(&sdlc_session)?;
sdlc_session.delete(&repo_root)?;
}
// If not complete (failures), preserve worktree for debugging/retry
// Generate and display summary
if state.is_complete() {
let summary = sdlc::generate_summary(&state);
println!("{}", summary);
// Save summary to .g3/sessions/sdlc/
let summary_dir = repo_root.join(".g3").join("sessions").join("sdlc");
fs::create_dir_all(&summary_dir).ok();
let summary_path = summary_dir.join(format!("run-{}.md", state.run_id));
fs::write(&summary_path, &summary).ok();
println!("\x1b[1;32msdlc:\x1b[0m pipeline complete!");
} else if state.has_failures() {
println!();
println!("\x1b[1;32msdlc:\x1b[0m worktree preserved at: {}", worktree_path.display());
println!("\x1b[1;33msdlc:\x1b[0m pipeline paused due to failures");
println!(" Run 'studio sdlc run' to retry failed stages");
}
Ok(())
}
/// Show SDLC pipeline status
fn cmd_sdlc_status() -> Result<()> {
let repo_root = get_repo_root()?;
match sdlc::PipelineState::load(&repo_root)? {
Some(state) => {
println!("\x1b[1;32msdlc:\x1b[0m pipeline run \x1b[38;2;216;177;114m{}\x1b[0m", state.run_id);
sdlc::display_pipeline(&state);
if state.is_complete() {
println!("Status: \x1b[1;32mComplete\x1b[0m");
} else if state.has_failures() {
println!("Status: \x1b[1;31mFailed\x1b[0m (run 'studio sdlc run' to retry)");
} else {
println!("Status: \x1b[1;33mIn Progress\x1b[0m (stage {}/{})",
state.current_stage + 1, sdlc::PIPELINE_STAGES.len());
}
if let Some(cursor) = &state.commit_cursor {
println!("Commit cursor: {}", cursor);
}
}
None => {
println!("\x1b[1;32msdlc:\x1b[0m no active pipeline");
println!();
println!("Run 'studio sdlc run' to start a new pipeline");
}
}
Ok(())
}
/// Reset SDLC pipeline state
fn cmd_sdlc_reset() -> Result<()> {
let repo_root = get_repo_root()?;
if sdlc::PipelineState::load(&repo_root)?.is_some() {
sdlc::PipelineState::delete(&repo_root)?;
println!("\x1b[1;32msdlc:\x1b[0m pipeline state reset");
} else {
println!("\x1b[1;32msdlc:\x1b[0m no pipeline state to reset");
}
Ok(())
}
/// Format duration in short form
fn format_duration_short(secs: u64) -> String {
if secs < 60 {
format!("{}s", secs)
} else if secs < 3600 {
format!("{}m {}s", secs / 60, secs % 60)
} else {
format!("{}h {}m", secs / 3600, (secs % 3600) / 60)
}
}
/// Resolve a commit reference (hash, branch, tag, etc.) to a full hash
fn resolve_commit(repo_root: &Path, commit_ref: &str) -> Result<String> {
let output = Command::new("git")
.current_dir(repo_root)
.args(["rev-parse", commit_ref])
.output()
.context("Failed to resolve commit")?;
if !output.status.success() {
bail!("Failed to resolve commit '{}'", commit_ref);
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
/// Get the current HEAD commit hash
fn get_head_commit(repo_root: &Path) -> Result<String> {
let output = Command::new("git")
.current_dir(repo_root)
.args(["rev-parse", "HEAD"])
.output()
.context("Failed to get HEAD commit")?;
if !output.status.success() {
bail!("Failed to get HEAD commit");
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
/// Count commits between two refs
fn count_commits_between(repo_root: &Path, from: &str, to: &str) -> Result<u32> {
let output = Command::new("git")
.current_dir(repo_root)
.args(["rev-list", "--count", &format!("{}..{}", from, to)])
.output()
.context("Failed to count commits")?;
if !output.status.success() {
// If the from commit doesn't exist (first run), return a large number
return Ok(u32::MAX);
}
let count: u32 = String::from_utf8_lossy(&output.stdout)
.trim()
.parse()
.unwrap_or(0);
Ok(count)
}
/// Run a g3 agent in a worktree
fn run_agent_in_worktree(
g3_binary: &Path,
worktree_path: &Path,
agent: &str,
task: &str,
) -> Result<bool> {
let mut cmd = Command::new(g3_binary);
cmd.arg("--workspace").arg(worktree_path);
cmd.arg("--agent").arg(agent);
cmd.arg(task);
cmd.current_dir(worktree_path);
cmd.stdout(Stdio::inherit());
cmd.stderr(Stdio::inherit());
let status = cmd.status().context("Failed to run g3 agent")?;
// If the agent made commits, commit them
if status.success() {
// Stage and commit any changes made by the agent
let _ = Command::new("git")
.current_dir(worktree_path)
.args(["add", "-A"])
.output();
}
Ok(status.success())
}
/// Check if a process is running by PID
fn is_process_running(pid: u32) -> bool {
// Use kill -0 to check if process exists
Command::new("kill")
.args(["-0", &pid.to_string()])
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
/// Extract summary from session logs
fn extract_session_summary(session: &Session) -> Option<String> {
// Look for session logs in the worktree's .g3 directory
let worktree_path = session.worktree_path.as_ref()?;
let session_dir = worktree_path.join(".g3").join("sessions");
if !session_dir.exists() {
return None;
}
// Find the most recent session log
let mut latest_log: Option<(PathBuf, std::time::SystemTime)> = None;
if let Ok(entries) = fs::read_dir(&session_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
let log_file = path.join("session.json");
if log_file.exists() {
if let Ok(metadata) = log_file.metadata() {
if let Ok(modified) = metadata.modified() {
if latest_log.is_none() || modified > latest_log.as_ref().unwrap().1 {
latest_log = Some((log_file, modified));
}
}
}
}
}
}
}
let log_file = latest_log?.0;
let content = fs::read_to_string(&log_file).ok()?;
// Parse JSON and extract the last assistant message as summary
let json: serde_json::Value = serde_json::from_str(&content).ok()?;
// Try the new format first: context_window.conversation_history
// Fall back to old format: messages
let messages = json
.get("context_window")
.and_then(|cw| cw.get("conversation_history"))
.and_then(|ch| ch.as_array())
.or_else(|| json.get("messages").and_then(|m| m.as_array()))?;
// Find the last assistant message
for msg in messages.iter().rev() {
if msg.get("role")?.as_str()? == "assistant" {
if let Some(content) = msg.get("content") {
if let Some(text) = content.as_str() {
// Return the full summary text
return Some(text.to_string());
}
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_filter_accept_flag_removes_accept() {
let args = vec!["task".to_string(), "--accept".to_string()];
let (filtered, found) = filter_accept_flag(&args);
assert!(found);
assert_eq!(filtered, vec!["task".to_string()]);
}
#[test]
fn test_filter_accept_flag_no_accept() {
let args = vec!["task".to_string(), "--verbose".to_string()];
let (filtered, found) = filter_accept_flag(&args);
assert!(!found);
assert_eq!(filtered, vec!["task".to_string(), "--verbose".to_string()]);
}
#[test]
fn test_filter_accept_flag_empty() {
let args: Vec<String> = vec![];
let (filtered, found) = filter_accept_flag(&args);
assert!(!found);
assert!(filtered.is_empty());
}
#[test]
fn test_filter_accept_flag_accept_in_middle() {
let args = vec![
"task".to_string(),
"--accept".to_string(),
"--other".to_string(),
];
let (filtered, found) = filter_accept_flag(&args);
assert!(found);
assert_eq!(filtered, vec!["task".to_string(), "--other".to_string()]);
}
#[test]
fn test_filter_accept_flag_only_accept() {
let args = vec!["--accept".to_string()];
let (filtered, found) = filter_accept_flag(&args);
assert!(found);
assert!(filtered.is_empty());
}
}