diff --git a/agent/git_operations.py b/agent/git_operations.py index a00455e..cffa18d 100644 --- a/agent/git_operations.py +++ b/agent/git_operations.py @@ -164,6 +164,46 @@ class GitOperations: logger.error(f"Error creating result branch: {e}") return False + async def add_task_worktree(self, path: str, task_id: str) -> Optional[str]: + """Create an isolated git worktree at `path` on a fresh per-task result branch. + + Cut from this agent's clone (off origin/ or HEAD), the worktree has the full repo + contents plus its own index/HEAD, so one agent can run several tasks concurrently without + them clobbering a shared checkout. Returns the branch name, or None on failure. + """ + try: + root = await self.repo_root() or self.workspace_dir + safe_task_id = task_id.replace("/", "-")[:12] + branch = f"agent/{self.agent_id}/{safe_task_id}-{int(time.time())}" + + await self._run_git_command(["git", "fetch", "origin"], cwd=root) + base_ref = f"origin/{self.base_branch}" + if (await self._run_git_command(["git", "rev-parse", "--verify", base_ref], cwd=root)).returncode != 0: + base_ref = "HEAD" + logger.warning(f"Base branch origin/{self.base_branch} not found; worktree from HEAD") + + Path(path).parent.mkdir(parents=True, exist_ok=True) + result = await self._run_git_command( + ["git", "worktree", "add", "-b", branch, path, base_ref], cwd=root, + ) + if result.returncode != 0: + logger.error(f"git worktree add failed: {result.stderr}") + return None + logger.info(f"Created task worktree {path} on branch {branch}") + return branch + except Exception as e: + logger.error(f"Error creating task worktree: {e}") + return None + + async def remove_task_worktree(self, path: str) -> None: + """Tear down a per-task worktree (best-effort). The branch ref is kept (already pushed).""" + try: + root = await self.repo_root() or self.workspace_dir + await self._run_git_command(["git", "worktree", "remove", "--force", path], cwd=root) + logger.info(f"Removed task worktree {path}") + except Exception as e: + logger.warning(f"Failed to remove task worktree {path}: {e}") + async def commit_changes(self, message: str) -> Optional[str]: try: cwd = await self.repo_root() or self.workspace_dir diff --git a/agent/main.py b/agent/main.py index 9ecb370..1b20594 100644 --- a/agent/main.py +++ b/agent/main.py @@ -72,11 +72,10 @@ class AgentRuntimeDisconnected(RuntimeError): class Agent: """Agent that connects to orchestrator and executes tasks.""" - # One task at a time per agent: tasks execute against the shared cloned repo root (see - # _execute_assignment, Option B 2026-06-16) and the per-agent git checkout (workspace_git, - # branch/commit/push) is single-repo and not concurrency-safe. Swarm parallelism comes from - # MULTIPLE agents, not multiple tasks per agent. Override via env only on isolated workspaces. - MAX_CONCURRENT_TASKS = int(os.getenv("MAX_CONCURRENT_TASKS", "1")) + # An agent may run several tasks at once; each executes in its OWN git worktree cut from this + # agent's clone (see _execute_assignment / GitOperations.add_task_worktree), so concurrent tasks + # never share a checkout/index. Swarm parallelism = many agents × this per-agent concurrency. + MAX_CONCURRENT_TASKS = int(os.getenv("MAX_CONCURRENT_TASKS", "4")) TASK_TIMEOUT_SECONDS = int(os.getenv("TASK_TIMEOUT_SECONDS", "60")) PEER_REPLY_TIMEOUT_SECONDS = int(os.getenv("PEER_REPLY_TIMEOUT_SECONDS", "20")) HEARTBEAT_INTERVAL_SECONDS = 15 @@ -115,14 +114,20 @@ class Agent: self._peer_executor: Optional[TaskExecutor] = None self.workspace_git = GitOperations(str(self.workspace_dir), self.agent_id) + # Serializes only the brief shared-repo git plumbing (worktree add/remove + fetch) so + # concurrent tasks don't race on .git locks. Task EXECUTION stays parallel. + self._git_admin_lock = asyncio.Lock() def available_slots(self) -> int: # Return how many additional tasks this agent can currently accept. return max(0, self.MAX_CONCURRENT_TASKS - len(self.active_tasks)) def task_workspace(self, task_id: str) -> Path: - # Compute the dedicated per-task working directory inside the agent workspace. - return self.workspace_dir / ".agent_tasks" / task_id + # Per-task git worktree path. Kept OUTSIDE the repo root (default /tmp/agent-worktrees) so + # the main checkout never sees it as untracked, and namespaced by agent_id to avoid + # collisions. `git worktree add` requires the leaf to not pre-exist, so we don't mkdir it. + base = Path(os.getenv("AGENT_WORKTREE_BASE", "/tmp/agent-worktrees")) + return base / self.agent_id / task_id.replace("/", "-") async def safe_send(self, payload: dict): # Serialize and send a websocket message while holding a lock to prevent concurrent writes. @@ -386,22 +391,28 @@ class Agent: await self.send_status_update("busy", task_id, "Starting task execution") start_time = time.time() + task_workspace = self.task_workspace(task_id) git_enabled = False + task_git = None try: - # Execute against the cloned repository root (Option B, 2026-06-16). The previous - # per-task subdir (/workspace/.agent_tasks/) was created empty and never - # seeded with the repo, so the executor saw an empty workspace and every SWE task - # failed with "Empty workspace: no files detected". Using the repo root means the - # model reads the real source and git (workspace_git, rooted at the same dir) - # commits the actual edits. One-task-per-agent (MAX_CONCURRENT_TASKS) keeps this safe. - executor = TaskExecutor(agent_id=self.agent_id, workspace_dir=str(self.workspace_dir)) - git_enabled = await self.workspace_git.is_git_workspace() - if git_enabled: - branch_created = await self.workspace_git.create_result_branch(task_id) - if not branch_created: - logger.warning("Failed to create task result branch; continuing without git push") - git_enabled = False + # Each task runs in its OWN git worktree cut from this agent's clone, so an agent can + # run several tasks concurrently without sharing a checkout/index. The worktree holds + # the full repo contents on a fresh result branch; the executor reads/writes the real + # source there and the per-task GitOperations (task_git) commits/pushes that branch. + # This fixes the prior empty per-task subdir → "Empty workspace: no files detected". + # Falls back to the repo root (no isolated branch) only if worktree creation fails. + if await self.workspace_git.is_git_workspace(): + async with self._git_admin_lock: # serialize shared-repo git plumbing only + result_branch = await self.workspace_git.add_task_worktree(str(task_workspace), task_id) + if result_branch: + git_enabled = True + task_git = GitOperations(str(task_workspace), self.agent_id) + task_git.result_branch = result_branch + else: + logger.warning("Failed to create task worktree; executing on repo root without git push") + exec_dir = str(task_workspace) if git_enabled else str(self.workspace_dir) + executor = TaskExecutor(agent_id=self.agent_id, workspace_dir=exec_dir) result = await asyncio.wait_for( executor.execute_task( @@ -409,7 +420,7 @@ class Agent: description=description, context={ **context, - "workspace_dir": str(self.workspace_dir), + "workspace_dir": exec_dir, "repo_workspace_dir": str(self.workspace_dir), "git_repo_url": self.git_repo_url, "agent_id": self.agent_id, @@ -425,12 +436,12 @@ class Agent: if result.get("success"): self.last_summary = self._summarize_result(result) or self.last_summary if result.get("success") and not awaiting_handoff: - if git_enabled: - commit_sha = await self.workspace_git.commit_changes( + if git_enabled and task_git: + commit_sha = await task_git.commit_changes( message=f"Task {task_id}: {description[:50]}" ) if commit_sha: - branch_name = await self.workspace_git.push_results() + branch_name = await task_git.push_results() result["git_branch"] = branch_name result["commit_sha"] = commit_sha else: @@ -473,6 +484,9 @@ class Agent: await self.send_task_result(task_id, False, {"error": str(e), "success": False}) await self.send_status_update("idle", None, f"Task failed: {e}") finally: + if git_enabled: + async with self._git_admin_lock: + await self.workspace_git.remove_task_worktree(str(task_workspace)) self.active_tasks.pop(task_id, None) ACTIVE_TASKS.set(len(self.active_tasks)) self.current_task_id = None