取代上一版 Option B(执行器共用仓库根 + 串行 1 任务)。每个任务从本 agent 自己的 clone 切出独立 git worktree(完整仓库内容 + 独立分支/索引),执行器在其中读写真实源码, 任务级 GitOperations 在该 worktree 提交/推送结果分支,完成后回收 worktree。 - 单 agent 可并发多任务(MAX_CONCURRENT_TASKS 恢复 4),互不共用 checkout/index - worktree 置于仓外 /tmp/agent-worktrees(AGENT_WORKTREE_BASE 可配),主 checkout 不受污染 - _git_admin_lock 仅串行 worktree add/remove 等共享 .git plumbing,任务执行仍并行 - 跨 agent 隔离不变:每个 agent 仍各自 clone 一份(各自 pod) - 根治 "Empty workspace: no files detected"(worktree 自带仓库文件,已端到端验证) GitOperations 新增 add_task_worktree / remove_task_worktree。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
260 lines
11 KiB
Python
260 lines
11 KiB
Python
"""Git operations for the agent workspace.
|
|
|
|
Merged from agent_swarm_v4:
|
|
- Adds repository-root discovery (`repo_root`) so per-task child workspaces can still
|
|
operate inside a parent Git checkout (all git commands run at the repo toplevel)
|
|
- Keeps the original branch/commit/push API so the protocol stays compatible
|
|
"""
|
|
import asyncio
|
|
import logging
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
from urllib.parse import quote, urlsplit, urlunsplit
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class GitOperations:
|
|
"""Handles Git operations for agent workspace."""
|
|
|
|
def __init__(self, workspace_dir: str, agent_id: str):
|
|
self.workspace_dir = str(Path(workspace_dir))
|
|
self.agent_id = agent_id
|
|
self.result_branch = f"agent-{agent_id}-results"
|
|
self.base_branch = os.getenv("GIT_BASE_BRANCH", "main")
|
|
|
|
def _authenticated_repo_url(self, repo_url: str) -> str:
|
|
username = os.getenv("GIT_USERNAME")
|
|
password = os.getenv("GIT_PASSWORD") or os.getenv("GIT_TOKEN")
|
|
if not username or not password:
|
|
return repo_url
|
|
|
|
parsed = urlsplit(repo_url)
|
|
if parsed.scheme not in {"http", "https"} or "@" in parsed.netloc:
|
|
return repo_url
|
|
|
|
userinfo = f"{quote(username)}:{quote(password)}"
|
|
return urlunsplit((
|
|
parsed.scheme,
|
|
f"{userinfo}@{parsed.netloc}",
|
|
parsed.path,
|
|
parsed.query,
|
|
parsed.fragment,
|
|
))
|
|
|
|
async def repo_root(self) -> Optional[str]:
|
|
result = await self._run_git_command(
|
|
["git", "rev-parse", "--show-toplevel"],
|
|
cwd=self.workspace_dir,
|
|
)
|
|
if result.returncode != 0:
|
|
return None
|
|
return result.stdout.strip() or None
|
|
|
|
async def is_git_workspace(self) -> bool:
|
|
result = await self._run_git_command(
|
|
["git", "rev-parse", "--is-inside-work-tree"],
|
|
cwd=self.workspace_dir,
|
|
)
|
|
return result.returncode == 0 and result.stdout.strip() == "true"
|
|
|
|
async def clone_workspace(self, repo_url: str) -> bool:
|
|
try:
|
|
logger.info(f"Cloning repository from {repo_url}")
|
|
|
|
workspace = Path(self.workspace_dir)
|
|
workspace.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
if workspace.exists():
|
|
if await self.is_git_workspace():
|
|
logger.info("Workspace already contains a Git checkout; refreshing existing clone")
|
|
return await self._refresh_existing_workspace(repo_url)
|
|
|
|
if any(workspace.iterdir()):
|
|
logger.warning("Workspace directory exists and is not empty; clearing before clone")
|
|
self._clear_workspace_dir(workspace)
|
|
|
|
clone_url = self._authenticated_repo_url(repo_url)
|
|
result = await self._run_git_command([
|
|
"git", "clone", clone_url, self.workspace_dir
|
|
])
|
|
|
|
if result.returncode != 0:
|
|
logger.error(f"Git clone failed: {result.stderr}")
|
|
return False
|
|
|
|
logger.info(f"Successfully cloned repository to {self.workspace_dir}")
|
|
await self._configure_git_user()
|
|
return True
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error cloning workspace: {e}")
|
|
return False
|
|
|
|
def _clear_workspace_dir(self, workspace: Path):
|
|
for child in workspace.iterdir():
|
|
if child.is_dir() and not child.is_symlink():
|
|
shutil.rmtree(child)
|
|
else:
|
|
child.unlink()
|
|
|
|
async def _configure_git_user(self):
|
|
cwd = await self.repo_root() or self.workspace_dir
|
|
await self._run_git_command([
|
|
"git", "config", "user.name", f"Agent {self.agent_id}"
|
|
], cwd=cwd)
|
|
await self._run_git_command([
|
|
"git", "config", "user.email", f"{self.agent_id}@agent.local"
|
|
], cwd=cwd)
|
|
|
|
async def _refresh_existing_workspace(self, repo_url: str) -> bool:
|
|
cwd = await self.repo_root() or self.workspace_dir
|
|
clone_url = self._authenticated_repo_url(repo_url)
|
|
await self._run_git_command(["git", "remote", "set-url", "origin", clone_url], cwd=cwd)
|
|
await self._configure_git_user()
|
|
|
|
fetch = await self._run_git_command(["git", "fetch", "origin"], cwd=cwd)
|
|
if fetch.returncode != 0:
|
|
logger.error(f"Git fetch failed: {fetch.stderr}")
|
|
return False
|
|
|
|
base_ref = f"origin/{self.base_branch}"
|
|
base_check = await self._run_git_command(["git", "rev-parse", "--verify", base_ref], cwd=cwd)
|
|
if base_check.returncode == 0:
|
|
reset = await self._run_git_command(["git", "reset", "--hard", base_ref], cwd=cwd)
|
|
if reset.returncode != 0:
|
|
logger.error(f"Git reset failed: {reset.stderr}")
|
|
return False
|
|
|
|
clean = await self._run_git_command(["git", "clean", "-fd"], cwd=cwd)
|
|
if clean.returncode != 0:
|
|
logger.error(f"Git clean failed: {clean.stderr}")
|
|
return False
|
|
|
|
return True
|
|
|
|
async def create_result_branch(self, task_id: Optional[str] = None) -> bool:
|
|
try:
|
|
cwd = await self.repo_root() or self.workspace_dir
|
|
if task_id:
|
|
safe_task_id = task_id.replace("/", "-")[:12]
|
|
timestamp = int(time.time())
|
|
self.result_branch = f"agent/{self.agent_id}/{safe_task_id}-{timestamp}"
|
|
|
|
await self._run_git_command(["git", "fetch", "origin"], cwd=cwd)
|
|
|
|
base_ref = f"origin/{self.base_branch}"
|
|
base_check = await self._run_git_command(["git", "rev-parse", "--verify", base_ref], cwd=cwd)
|
|
if base_check.returncode != 0:
|
|
base_ref = "HEAD"
|
|
logger.warning(f"Base branch origin/{self.base_branch} not found; creating result branch from HEAD")
|
|
|
|
result = await self._run_git_command(["git", "checkout", "-B", self.result_branch, base_ref], cwd=cwd)
|
|
if result.returncode != 0:
|
|
logger.error(f"Failed to create branch: {result.stderr}")
|
|
return False
|
|
|
|
logger.info(f"Created result branch: {self.result_branch}")
|
|
return True
|
|
except Exception as e:
|
|
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/<base> 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
|
|
status_result = await self._run_git_command(["git", "status", "--porcelain"], cwd=cwd)
|
|
if not status_result.stdout.strip():
|
|
logger.info("No changes to commit")
|
|
return None
|
|
|
|
await self._run_git_command(["git", "add", "-A"], cwd=cwd)
|
|
commit_result = await self._run_git_command(["git", "commit", "-m", message], cwd=cwd)
|
|
if commit_result.returncode != 0:
|
|
logger.error(f"Git commit failed: {commit_result.stderr}")
|
|
return None
|
|
|
|
sha_result = await self._run_git_command(["git", "rev-parse", "HEAD"], cwd=cwd)
|
|
commit_sha = sha_result.stdout.strip()
|
|
logger.info(f"Committed changes: {commit_sha[:8]} - {message}")
|
|
return commit_sha
|
|
except Exception as e:
|
|
logger.error(f"Error committing changes: {e}")
|
|
return None
|
|
|
|
async def push_results(self) -> Optional[str]:
|
|
try:
|
|
cwd = await self.repo_root() or self.workspace_dir
|
|
result = await self._run_git_command(["git", "push", "-u", "origin", self.result_branch], cwd=cwd)
|
|
if result.returncode != 0:
|
|
logger.error(f"Git push failed: {result.stderr}")
|
|
return None
|
|
logger.info(f"Pushed results to branch: {self.result_branch}")
|
|
return self.result_branch
|
|
except Exception as e:
|
|
logger.error(f"Error pushing results: {e}")
|
|
return None
|
|
|
|
async def _run_git_command(
|
|
self,
|
|
command: list[str],
|
|
cwd: Optional[str] = None,
|
|
) -> subprocess.CompletedProcess:
|
|
process = await asyncio.create_subprocess_exec(
|
|
*command,
|
|
cwd=cwd,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
)
|
|
stdout, stderr = await process.communicate()
|
|
return subprocess.CompletedProcess(
|
|
args=command,
|
|
returncode=process.returncode,
|
|
stdout=stdout.decode(),
|
|
stderr=stderr.decode(),
|
|
)
|