Skip to content

Commit d0e6346

Browse files
authored
Merge pull request #2 from Agent-Field/fix/concurrent-build-isolation
fix: isolate concurrent builds via build_id namespace on git resources
2 parents 54f4b24 + a60db81 commit d0e6346

File tree

6 files changed

+134
-15
lines changed

6 files changed

+134
-15
lines changed

swe_af/app.py

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import os
1313
import re
1414
import subprocess
15+
import uuid
1516

1617
from swe_af.reasoners import router
1718
from swe_af.reasoners.pipeline import _assign_sequence_numbers, _compute_levels, _validate_file_conflicts
@@ -76,7 +77,8 @@ async def build(
7677
raise ValueError("Either repo_path or repo_url must be provided")
7778

7879
# Clone if repo_url is set and target doesn't exist yet
79-
if cfg.repo_url and not os.path.exists(os.path.join(repo_path, ".git")):
80+
git_dir = os.path.join(repo_path, ".git")
81+
if cfg.repo_url and not os.path.exists(git_dir):
8082
app.note(f"Cloning {cfg.repo_url}{repo_path}", tags=["build", "clone"])
8183
os.makedirs(repo_path, exist_ok=True)
8284
clone_result = subprocess.run(
@@ -88,6 +90,58 @@ async def build(
8890
err = clone_result.stderr.strip()
8991
app.note(f"Clone failed (exit {clone_result.returncode}): {err}", tags=["build", "clone", "error"])
9092
raise RuntimeError(f"git clone failed (exit {clone_result.returncode}): {err}")
93+
elif cfg.repo_url and os.path.exists(git_dir):
94+
# Repo already cloned by a prior build — reset to remote default branch
95+
# so git_init creates the integration branch from a clean baseline.
96+
default_branch = cfg.github_pr_base or "main"
97+
app.note(
98+
f"Repo already exists at {repo_path} — resetting to origin/{default_branch}",
99+
tags=["build", "clone", "reset"],
100+
)
101+
102+
# Remove stale worktrees on disk before touching branches
103+
worktrees_dir = os.path.join(repo_path, ".worktrees")
104+
if os.path.isdir(worktrees_dir):
105+
import shutil
106+
shutil.rmtree(worktrees_dir, ignore_errors=True)
107+
subprocess.run(
108+
["git", "worktree", "prune"],
109+
cwd=repo_path, capture_output=True, text=True,
110+
)
111+
112+
# Fetch latest remote state
113+
fetch = subprocess.run(
114+
["git", "fetch", "origin"],
115+
cwd=repo_path, capture_output=True, text=True,
116+
)
117+
if fetch.returncode != 0:
118+
app.note(f"git fetch failed: {fetch.stderr.strip()}", tags=["build", "clone", "error"])
119+
120+
# Force-checkout default branch (handles dirty working tree from crashed builds)
121+
subprocess.run(
122+
["git", "checkout", "-f", default_branch],
123+
cwd=repo_path, capture_output=True, text=True,
124+
)
125+
reset = subprocess.run(
126+
["git", "reset", "--hard", f"origin/{default_branch}"],
127+
cwd=repo_path, capture_output=True, text=True,
128+
)
129+
if reset.returncode != 0:
130+
# Hard reset failed — nuke and re-clone as last resort
131+
app.note(
132+
f"Reset to origin/{default_branch} failed — re-cloning",
133+
tags=["build", "clone", "reclone"],
134+
)
135+
import shutil
136+
shutil.rmtree(repo_path, ignore_errors=True)
137+
os.makedirs(repo_path, exist_ok=True)
138+
clone_result = subprocess.run(
139+
["git", "clone", cfg.repo_url, repo_path],
140+
capture_output=True, text=True,
141+
)
142+
if clone_result.returncode != 0:
143+
err = clone_result.stderr.strip()
144+
raise RuntimeError(f"git re-clone failed: {err}")
91145
else:
92146
# Ensure repo_path exists even when no repo_url is provided (fresh init case)
93147
# This is needed because planning agents may need to read the repo in parallel with git_init
@@ -105,7 +159,11 @@ async def build(
105159
# Resolve runtime + flat model config once for this build.
106160
resolved = cfg.resolved_models()
107161

108-
app.note("Build starting", tags=["build", "start"])
162+
# Unique ID for this build — namespaces git branches/worktrees to prevent
163+
# collisions when multiple builds run concurrently on the same repository.
164+
build_id = uuid.uuid4().hex[:8]
165+
166+
app.note(f"Build starting (build_id={build_id})", tags=["build", "start"])
109167

110168
# Compute absolute artifacts directory path for logging
111169
abs_artifacts_dir = os.path.join(os.path.abspath(repo_path), artifacts_dir)
@@ -151,6 +209,7 @@ async def build(
151209
permission_mode=cfg.permission_mode,
152210
ai_provider=cfg.ai_provider,
153211
previous_error=previous_error,
212+
build_id=build_id,
154213
)
155214

156215
# Run planning only on first attempt, then just git_init on retries
@@ -223,6 +282,7 @@ async def build(
223282
execute_fn_target=cfg.execute_fn_target,
224283
config=exec_config,
225284
git_config=git_config,
285+
build_id=build_id,
226286
), "execute")
227287

228288
# 3. VERIFY
@@ -634,6 +694,7 @@ async def execute(
634694
config: dict | None = None,
635695
git_config: dict | None = None,
636696
resume: bool = False,
697+
build_id: str = "",
637698
) -> dict:
638699
"""Execute a planned DAG with self-healing replanning.
639700
@@ -675,6 +736,7 @@ async def execute_fn(issue, dag_state):
675736
node_id=NODE_ID,
676737
git_config=git_config,
677738
resume=resume,
739+
build_id=build_id,
678740
)
679741
return state.model_dump()
680742

swe_af/execution/dag_executor.py

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ async def _setup_worktrees(
5656
node_id: str,
5757
config: ExecutionConfig,
5858
note_fn: Callable | None = None,
59+
build_id: str = "",
5960
) -> list[dict]:
6061
"""Create git worktrees for parallel issue isolation.
6162
@@ -78,6 +79,7 @@ async def _setup_worktrees(
7879
level=dag_state.current_level,
7980
model=config.git_model,
8081
ai_provider=config.ai_provider,
82+
build_id=build_id,
8183
)
8284

8385
if not setup.get("success"):
@@ -365,7 +367,7 @@ def _load_checkpoint(artifacts_dir: str) -> DAGState | None:
365367

366368

367369
def _init_dag_state(
368-
plan_result: dict, repo_path: str, git_config: dict | None = None,
370+
plan_result: dict, repo_path: str, git_config: dict | None = None, build_id: str = "",
369371
) -> DAGState:
370372
"""Extract DAGState from a PlanResult dict.
371373
@@ -424,6 +426,7 @@ def _init_dag_state(
424426
architecture_summary=architecture_summary,
425427
all_issues=all_issues,
426428
levels=levels,
429+
build_id=build_id,
427430
**git_kwargs,
428431
)
429432

@@ -978,6 +981,7 @@ async def run_dag(
978981
node_id: str = "swe-planner",
979982
git_config: dict | None = None,
980983
resume: bool = False,
984+
build_id: str = "",
981985
) -> DAGState:
982986
"""Execute a planned DAG with self-healing replanning.
983987
@@ -1022,7 +1026,7 @@ async def call_fn(target: str, **kwargs):
10221026
result = await _raw_call_fn(target, **kwargs)
10231027
return unwrap_call_result(result, target)
10241028

1025-
dag_state = _init_dag_state(plan_result, repo_path, git_config=git_config)
1029+
dag_state = _init_dag_state(plan_result, repo_path, git_config=git_config, build_id=build_id)
10261030
dag_state.max_replans = config.max_replans
10271031

10281032
# Resume from checkpoint if requested
@@ -1094,6 +1098,7 @@ async def _memory_fn(action: str, key: str, value=None):
10941098
if call_fn and dag_state.git_integration_branch:
10951099
active_issues = await _setup_worktrees(
10961100
dag_state, active_issues, call_fn, node_id, config, note_fn,
1101+
build_id=dag_state.build_id,
10971102
)
10981103

10991104
# Track in-flight issues and checkpoint before execution (Bug 4 fix)
@@ -1145,8 +1150,16 @@ async def _memory_fn(action: str, key: str, value=None):
11451150
)
11461151

11471152
# Start cleanup in background (doesn't affect replan decisions)
1153+
# Use branch_name if injected by _setup_worktrees (includes build_id prefix),
1154+
# otherwise derive from build_id + sequence + name.
1155+
_bid = dag_state.build_id
11481156
branches_to_clean = [
1149-
f"issue/{str(i.get('sequence_number') or 0).zfill(2)}-{i['name']}" for i in active_issues
1157+
i["branch_name"] if i.get("branch_name") else (
1158+
f"issue/{_bid}-{str(i.get('sequence_number') or 0).zfill(2)}-{i['name']}"
1159+
if _bid else
1160+
f"issue/{str(i.get('sequence_number') or 0).zfill(2)}-{i['name']}"
1161+
)
1162+
for i in active_issues
11501163
]
11511164
cleanup_task = asyncio.create_task(
11521165
_cleanup_worktrees(
@@ -1323,8 +1336,12 @@ async def _memory_fn(action: str, key: str, value=None):
13231336

13241337
# Final worktree sweep — catch anything the per-level cleanup missed
13251338
if call_fn and dag_state.worktrees_dir and dag_state.git_integration_branch:
1326-
# Collect all issue branches that should have been cleaned
1339+
# Collect all issue branches that should have been cleaned.
1340+
# Must use the same build_id-prefixed format that workspace setup created.
1341+
_bid = dag_state.build_id
13271342
all_branches = [
1343+
f"issue/{_bid}-{str(i.get('sequence_number') or 0).zfill(2)}-{i['name']}"
1344+
if _bid else
13281345
f"issue/{str(i.get('sequence_number') or 0).zfill(2)}-{i['name']}"
13291346
for i in dag_state.all_issues
13301347
]

swe_af/execution/schemas.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,7 @@ class DAGState(BaseModel):
183183
merged_branches: list[str] = []
184184
unmerged_branches: list[str] = [] # branches that failed to merge
185185
worktrees_dir: str = "" # e.g. repo_path/.worktrees
186+
build_id: str = "" # unique per build() call; namespaces git branches/worktrees
186187

187188
# --- Merge/test history ---
188189
merge_results: list[dict] = []

swe_af/prompts/git_init.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,9 @@
2828
2929
1. Record the current branch as `original_branch`.
3030
2. Ensure the working tree is clean (warn if not, but proceed).
31-
3. Create an integration branch: `git checkout -b feature/<goal-slug>` from HEAD.
31+
3. Create an integration branch from HEAD:
32+
- If a **Build ID** is provided in the task: `git checkout -b feature/<build-id>-<goal-slug>`
33+
- Otherwise: `git checkout -b feature/<goal-slug>`
3234
4. Record the initial commit SHA (HEAD before any work).
3335
3436
## Worktrees Directory
@@ -82,13 +84,15 @@
8284
"""
8385

8486

85-
def git_init_task_prompt(repo_path: str, goal: str) -> str:
87+
def git_init_task_prompt(repo_path: str, goal: str, build_id: str = "") -> str:
8688
"""Build the task prompt for the git initialization agent."""
8789
sections: list[str] = []
8890

8991
sections.append("## Repository Setup Task")
9092
sections.append(f"- **Repository path**: `{repo_path}`")
9193
sections.append(f"- **Project goal**: {goal}")
94+
if build_id:
95+
sections.append(f"- **Build ID**: `{build_id}` (prefix integration branch slug with this)")
9296

9397
sections.append(
9498
"\n## Your Task\n"

swe_af/prompts/workspace.py

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,22 @@
1515
1616
## Your Responsibilities
1717
18-
For each issue in this level, create a worktree:
18+
For each issue in this level, create a worktree using the **exact command format specified in the task**.
19+
The task will provide either a plain format or a Build-ID-prefixed format — always follow the task.
1920
21+
Default (no Build ID):
2022
```bash
2123
git worktree add <worktrees_dir>/issue-<NN>-<name> -b issue/<NN>-<name> <integration_branch>
2224
```
2325
26+
With Build ID (when the task specifies one — CRITICAL: you MUST use this form):
27+
```bash
28+
git worktree add <worktrees_dir>/issue-<BUILD_ID>-<NN>-<name> -b issue/<BUILD_ID>-<NN>-<name> <integration_branch>
29+
```
30+
2431
This creates:
25-
- A new directory at `<worktrees_dir>/issue-<NN>-<name>`
26-
- A new branch `issue/<NN>-<name>` starting from the integration branch
32+
- A new directory at the worktrees path
33+
- A new branch starting from the integration branch
2734
- An isolated working copy where the coder agent can freely edit files
2835
2936
## Output
@@ -34,7 +41,7 @@
3441
3542
## Constraints
3643
37-
- If a branch `issue/<NN>-<name>` already exists, remove the old worktree first and recreate.
44+
- If a branch with the target name already exists, remove the old worktree first and recreate.
3845
- All worktree operations must be run from the main repository directory.
3946
- Do NOT modify any source files — only git worktree commands.
4047
@@ -95,6 +102,7 @@ def workspace_setup_task_prompt(
95102
integration_branch: str,
96103
issues: list[dict],
97104
worktrees_dir: str,
105+
build_id: str = "",
98106
) -> str:
99107
"""Build the task prompt for the workspace setup agent."""
100108
sections: list[str] = []
@@ -103,6 +111,8 @@ def workspace_setup_task_prompt(
103111
sections.append(f"- **Repository path**: `{repo_path}`")
104112
sections.append(f"- **Integration branch**: `{integration_branch}`")
105113
sections.append(f"- **Worktrees directory**: `{worktrees_dir}`")
114+
if build_id:
115+
sections.append(f"- **Build ID**: `{build_id}`")
106116

107117
sections.append("\n### Issues to create worktrees for:")
108118
for issue in issues:
@@ -111,16 +121,38 @@ def workspace_setup_task_prompt(
111121
seq = str(issue.get("sequence_number") or 0).zfill(2)
112122
sections.append(f"- issue_name=`{name}`, seq=`{seq}`, title: {title}")
113123

114-
sections.append(
124+
if build_id:
125+
worktree_cmd = (
126+
f"git worktree add <worktrees_dir>/issue-{build_id}-<NN>-<name>"
127+
f" -b issue/{build_id}-<NN>-<name> <integration_branch>"
128+
)
129+
branch_note = (
130+
f" Branch names MUST be prefixed with the Build ID: `issue/{build_id}-<NN>-<name>`\n"
131+
f" Worktree dirs MUST be prefixed with the Build ID: `issue-{build_id}-<NN>-<name>`\n"
132+
" This prevents collisions with other concurrent builds on the same repository."
133+
)
134+
else:
135+
worktree_cmd = (
136+
"git worktree add <worktrees_dir>/issue-<NN>-<name>"
137+
" -b issue/<NN>-<name> <integration_branch>"
138+
)
139+
branch_note = ""
140+
141+
task = (
115142
"\n## Your Task\n"
116143
"1. Ensure you are in the main repository directory.\n"
117144
"2. For each issue, create a worktree:\n"
118-
" `git worktree add <worktrees_dir>/issue-<NN>-<name> -b issue/<NN>-<name> <integration_branch>`\n"
145+
f" `{worktree_cmd}`\n"
146+
)
147+
if branch_note:
148+
task += branch_note + "\n"
149+
task += (
119150
"3. Verify each worktree was created successfully.\n"
120151
"4. Return a JSON object with `workspaces` and `success`.\n\n"
121152
"IMPORTANT: In the output JSON, `issue_name` must be the canonical name "
122153
"(e.g. `value-copy-trait`), NOT the sequence-prefixed name (e.g. `01-value-copy-trait`)."
123154
)
155+
sections.append(task)
124156

125157
return "\n".join(sections)
126158

swe_af/reasoners/execution_agents.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -520,6 +520,7 @@ async def run_git_init(
520520
permission_mode: str = "",
521521
ai_provider: str = "claude",
522522
previous_error: str | None = None,
523+
build_id: str = "",
523524
) -> dict:
524525
"""Initialize git repo and create integration branch for feature work.
525526
@@ -538,7 +539,7 @@ async def run_git_init(
538539
tags=["git_init", "start"],
539540
)
540541

541-
task_prompt = git_init_task_prompt(repo_path=repo_path, goal=goal)
542+
task_prompt = git_init_task_prompt(repo_path=repo_path, goal=goal, build_id=build_id)
542543

543544
# Build system prompt with error context if retrying
544545
system_prompt = GIT_INIT_SYSTEM_PROMPT
@@ -604,6 +605,7 @@ async def run_workspace_setup(
604605
model: str = "sonnet",
605606
permission_mode: str = "",
606607
ai_provider: str = "claude",
608+
build_id: str = "",
607609
) -> dict:
608610
"""Create git worktrees for parallel issue isolation.
609611
@@ -623,6 +625,7 @@ async def run_workspace_setup(
623625
integration_branch=integration_branch,
624626
issues=issues,
625627
worktrees_dir=worktrees_dir,
628+
build_id=build_id,
626629
)
627630

628631
class WorkspaceSetupResult(BaseModel):

0 commit comments

Comments
 (0)