Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 26 additions & 4 deletions src/autocoder/core/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
116 changes: 59 additions & 57 deletions src/autocoder/core/worktree_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,24 +17,23 @@
(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__)


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.

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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}")
Expand All @@ -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(
Expand All @@ -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.
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -362,15 +382,17 @@ 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():
try:
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
Expand Down Expand Up @@ -399,7 +421,7 @@ def list_worktrees(self) -> list[dict]:
cwd=self.project_dir,
check=True,
capture_output=True,
text=True
text=True,
)

worktrees = []
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand All @@ -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.

Expand All @@ -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}"
Expand All @@ -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}")
Expand Down Expand Up @@ -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)")
Expand Down Expand Up @@ -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}")
Expand All @@ -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))
Expand Down
10 changes: 10 additions & 0 deletions ui/src/components/ResolveBlockersModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div key={g.key} className="neo-card p-4">
<div className="flex items-start justify-between gap-3">
Expand Down Expand Up @@ -235,6 +236,15 @@ export function ResolveBlockersModal({
Retry
</button>
) : null}
{typeof exampleId === 'number' && onOpenFeature ? (
<button
className="neo-btn neo-btn-secondary text-xs"
onClick={() => onOpenFeature(exampleId)}
title="Open an example feature from this blocker group"
>
Open example
</button>
) : null}
{g.kind === 'dependency' && depIds.length > 0 && onOpenFeature ? (
<button
className="neo-btn neo-btn-secondary text-xs"
Expand Down