diff --git a/src/autocoder/core/orchestrator.py b/src/autocoder/core/orchestrator.py index a4b940c3..380a1044 100644 --- a/src/autocoder/core/orchestrator.py +++ b/src/autocoder/core/orchestrator.py @@ -1023,10 +1023,32 @@ def _spawn_agents(self, count: int) -> list[str]: branch_name=claimed_branch, ) except Exception as e: - logger.error(f" ❌ Failed to create worktree for {agent_id}: {e}") - self.database.mark_feature_failed( - feature_id=feature_id, reason="Worktree creation failed" - ) + detail = "" + with contextlib.suppress(Exception): + if isinstance(e, subprocess.CalledProcessError): + stdout = str(getattr(e, "stdout", "") or "").strip() + stderr = str(getattr(e, "stderr", "") or "").strip() + detail = stderr or stdout + if not detail: + detail = str(e).strip() + + logger.error(f" ❌ Failed to create worktree for {agent_id}: {detail or e}") + with contextlib.suppress(Exception): + self.database.add_activity_event( + event_type="worktree.create_failed", + level="ERROR", + message=f"Failed to create worktree for {agent_id} (feature #{feature_id})", + agent_id=agent_id, + feature_id=int(feature_id), + data={ + "error": str(detail or e), + "branch": str(claimed_branch), + }, + ) + reason = "Worktree creation failed" + if detail: + reason = reason + "\n" + detail + self.database.mark_feature_failed(feature_id=feature_id, reason=reason) # Release ports on failure self.port_allocator.release_ports(agent_id) continue diff --git a/src/autocoder/core/worktree_manager.py b/src/autocoder/core/worktree_manager.py index ed7ae6ea..f6e61b7a 100644 --- a/src/autocoder/core/worktree_manager.py +++ b/src/autocoder/core/worktree_manager.py @@ -17,16 +17,15 @@ (only the working files are duplicated, not the git history) """ +import json +import logging import os -import subprocess import shutil -import json import stat +import subprocess import time -from pathlib import Path from datetime import datetime -from typing import Optional -import logging +from pathlib import Path logger = logging.getLogger(__name__) @@ -34,7 +33,7 @@ class WorktreeManager: """Manages git worktrees for parallel agent isolation.""" - def __init__(self, project_dir: str, worktrees_base_dir: Optional[str] = None): + def __init__(self, project_dir: str, worktrees_base_dir: str | None = None): """ Initialize the worktree manager. @@ -179,9 +178,9 @@ def backoff_s(attempts: int) -> float: def create_worktree( self, agent_id: str, - feature_id: Optional[int] = None, - feature_name: Optional[str] = None, - branch_name: Optional[str] = None, + feature_id: int | None = None, + feature_name: str | None = None, + branch_name: str | None = None, ) -> dict | str: """ Create a new isolated worktree for an agent. @@ -227,6 +226,12 @@ def create_worktree( if worktree_path.exists(): logger.warning(f"Worktree already exists, removing: {worktree_path}") self.delete_worktree(agent_id, force=True) + if worktree_path.exists(): + # On Windows, file locks can prevent deletion. Don't proceed with `git worktree add` + # because it will fail with confusing/partial stderr. Surface a clear message. + raise RuntimeError( + f"Worktree path still exists after cleanup (likely locked): {worktree_path}" + ) # Create the worktree logger.info(f"Creating worktree: {worktree_path}") @@ -245,7 +250,7 @@ def branch_exists(name: str) -> bool: return False # Prefer creating feature branches from main/master rather than whatever the repo is currently on. - base_ref: Optional[str] = None + base_ref: str | None = None for candidate in ("main", "master"): try: subprocess.run( @@ -260,6 +265,16 @@ def branch_exists(name: str) -> bool: continue try: + # Prune stale worktree metadata (e.g., after crashes) before trying to add a new one. + # This avoids "branch is already checked out at ..." false-positives. + subprocess.run( + ["git", "worktree", "prune"], + cwd=self.project_dir, + check=False, + capture_output=True, + text=True, + ) + # Git worktree add command: # - If branch already exists, attach worktree to it (resume). # - Otherwise, create a new branch from base_ref. @@ -270,27 +285,30 @@ def branch_exists(name: str) -> bool: cmd.extend(["-b", branch_name]) if base_ref: cmd.append(base_ref) - subprocess.run( - cmd, - cwd=self.project_dir, - check=True, - capture_output=True, - text=True - ) + subprocess.run(cmd, cwd=self.project_dir, check=True, capture_output=True, text=True) - logger.info(f"✅ Worktree created successfully") + logger.info("✅ Worktree created successfully") result = { "worktree_path": str(worktree_path), "branch_name": branch_name, "relative_path": worktree_path.relative_to(self.project_dir.parent), - "created_at": datetime.now().isoformat() + "created_at": datetime.now().isoformat(), } return result["worktree_path"] if legacy_return_path_only else result except subprocess.CalledProcessError as e: - logger.error(f"Failed to create worktree: {e.stderr}") + stdout = (e.stdout or "").strip() + stderr = (e.stderr or "").strip() + msg = stderr or stdout or str(e) + logger.error( + "Failed to create worktree (returncode=%s): %s\nstdout:\n%s\nstderr:\n%s", + getattr(e, "returncode", None), + msg, + stdout, + stderr, + ) raise def delete_worktree(self, agent_id: str, force: bool = False) -> bool: @@ -330,7 +348,7 @@ def delete_worktree(self, agent_id: str, force: bool = False) -> bool: cwd=self.project_dir, check=True, capture_output=True, - text=True + text=True, ) # Force delete directory if git command failed @@ -341,13 +359,15 @@ def delete_worktree(self, agent_id: str, force: bool = False) -> bool: self._rmtree_force(worktree_path) except Exception as e: logger.warning(f"Deferred cleanup for locked worktree: {e}") - self._enqueue_cleanup(worktree_path, reason="force-delete failed after git worktree remove") + self._enqueue_cleanup( + worktree_path, reason="force-delete failed after git worktree remove" + ) return True else: - logger.error(f"Directory still exists after git worktree remove") + logger.error("Directory still exists after git worktree remove") return False - logger.info(f"✅ Worktree deleted successfully") + logger.info("✅ Worktree deleted successfully") return True except subprocess.CalledProcessError as e: @@ -362,7 +382,7 @@ def delete_worktree(self, agent_id: str, force: bool = False) -> bool: ["git", "worktree", "prune"], cwd=self.project_dir, check=True, - capture_output=True + capture_output=True, ) # Delete directory if worktree_path.exists(): @@ -370,7 +390,9 @@ def delete_worktree(self, agent_id: str, force: bool = False) -> bool: self._rmtree_force(worktree_path) except Exception as cleanup_error: logger.warning(f"Deferred cleanup for locked worktree: {cleanup_error}") - self._enqueue_cleanup(worktree_path, reason="force-cleanup failed after prune") + self._enqueue_cleanup( + worktree_path, reason="force-cleanup failed after prune" + ) return True logger.info("✅ Force cleanup successful") return True @@ -399,7 +421,7 @@ def list_worktrees(self) -> list[dict]: cwd=self.project_dir, check=True, capture_output=True, - text=True + text=True, ) worktrees = [] @@ -428,7 +450,7 @@ def list_worktrees(self) -> list[dict]: logger.error(f"Failed to list worktrees: {e.stderr}") return [] - def get_worktree_path(self, agent_id: str) -> Optional[Path]: + def get_worktree_path(self, agent_id: str) -> Path | None: """ Get the worktree path for a specific agent. @@ -468,7 +490,7 @@ def is_worktree_clean(self, agent_id: str) -> bool: cwd=worktree_path, check=True, capture_output=True, - text=True + text=True, ) # Empty output = clean @@ -477,12 +499,7 @@ def is_worktree_clean(self, agent_id: str) -> bool: except subprocess.CalledProcessError: return False - def commit_checkpoint( - self, - agent_id: str, - message: str, - allow_dirty: bool = False - ) -> bool: + def commit_checkpoint(self, agent_id: str, message: str, allow_dirty: bool = False) -> bool: """ Create a checkpoint commit in the agent's worktree. @@ -505,12 +522,7 @@ def commit_checkpoint( try: # Add all changes - subprocess.run( - ["git", "add", "-A"], - cwd=worktree_path, - check=True, - capture_output=True - ) + subprocess.run(["git", "add", "-A"], cwd=worktree_path, check=True, capture_output=True) # Commit commit_message = f"Checkpoint: {message}" @@ -520,7 +532,7 @@ def commit_checkpoint( cwd=worktree_path, check=True, capture_output=True, - text=True + text=True, ) logger.info(f"✅ Checkpoint committed in {agent_id}: {message}") @@ -562,7 +574,7 @@ def rollback_to_last_checkpoint(self, agent_id: str, steps: int = 1) -> bool: cwd=worktree_path, check=True, capture_output=True, - text=True + text=True, ) logger.info(f"✅ Rolled back {agent_id} by {steps} checkpoint(s)") @@ -598,7 +610,7 @@ def push_branch(self, agent_id: str, branch_name: str) -> bool: cwd=worktree_path, check=True, capture_output=True, - text=True + text=True, ) logger.info(f"✅ Branch pushed: {branch_name}") @@ -621,24 +633,14 @@ def test_worktree_manager(): # Initialize git repo subprocess.run(["git", "init"], cwd=test_repo, check=True) subprocess.run( - ["git", "config", "user.email", "test@example.com"], - cwd=test_repo, - check=True - ) - subprocess.run( - ["git", "config", "user.name", "Test User"], - cwd=test_repo, - check=True + ["git", "config", "user.email", "test@example.com"], cwd=test_repo, check=True ) + subprocess.run(["git", "config", "user.name", "Test User"], cwd=test_repo, check=True) # Create initial commit (test_repo / "README.md").write_text("# Test Repo\n") subprocess.run(["git", "add", "."], cwd=test_repo, check=True) - subprocess.run( - ["git", "commit", "-m", "Initial commit"], - cwd=test_repo, - check=True - ) + subprocess.run(["git", "commit", "-m", "Initial commit"], cwd=test_repo, check=True) # Test worktree manager manager = WorktreeManager(str(test_repo)) diff --git a/ui/src/components/ResolveBlockersModal.tsx b/ui/src/components/ResolveBlockersModal.tsx index d9b71583..17df2a39 100644 --- a/ui/src/components/ResolveBlockersModal.tsx +++ b/ui/src/components/ResolveBlockersModal.tsx @@ -199,6 +199,7 @@ export function ResolveBlockersModal({ {groups.map((g) => { const badge = kindBadge(g.kind) const depIds = (g.depends_on ?? []).map((d) => d.id) + const exampleId = g.example_feature?.id return (