Skip to content

Commit 017d8be

Browse files
authored
feat(core): worktree-per-task isolation for parallel batch execution (#418)
## Summary - TaskWorktree class: create/merge_back/cleanup lifecycle for git worktrees - MergeResult dataclass tracking success/conflicts - Worktrees at .codeframe/worktrees/<task-id> on branch cf/<task-id> - BatchRun.isolate field + start_batch(isolate=) parameter - _execute_task_subprocess accepts worktree_path for cwd override ## Validation - Tests: 11 unit tests with real git worktree operations - CI: All checks green (fixed branch name detection for CI environments) - Review: CodeRabbit findings addressed (base_branch wiring) Closes #418
1 parent e75afed commit 017d8be

3 files changed

Lines changed: 406 additions & 1 deletion

File tree

codeframe/core/conductor.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from concurrent.futures import ThreadPoolExecutor, as_completed
1818
from dataclasses import dataclass, field
1919
from datetime import datetime, timezone
20+
from pathlib import Path
2021
from enum import Enum
2122
from typing import Callable, Optional
2223

@@ -533,6 +534,7 @@ class BatchRun:
533534
stall_timeout_s: int = 300
534535
stall_action: str = "blocker"
535536
concurrency: ConcurrencyConfig = field(default_factory=ConcurrencyConfig)
537+
isolate: bool = True
536538

537539

538540
def start_batch(
@@ -548,6 +550,7 @@ def start_batch(
548550
stall_timeout_s: int = 300,
549551
stall_action: str = "blocker",
550552
concurrency_by_status: Optional[dict[str, int]] = None,
553+
isolate: bool = True,
551554
) -> BatchRun:
552555
"""Start a batch execution of multiple tasks.
553556
@@ -602,6 +605,7 @@ def start_batch(
602605
stall_timeout_s=stall_timeout_s,
603606
stall_action=stall_action,
604607
concurrency=concurrency,
608+
isolate=isolate,
605609
)
606610

607611
# Save to database
@@ -1882,6 +1886,7 @@ def _execute_task_subprocess(
18821886
engine: str = "react",
18831887
stall_timeout_s: int = 300,
18841888
stall_action: str = "blocker",
1889+
worktree_path: Optional[Path] = None,
18851890
) -> str:
18861891
"""Execute a single task via subprocess.
18871892
@@ -1912,7 +1917,7 @@ def _execute_task_subprocess(
19121917
# Use Popen instead of run for process tracking
19131918
process = subprocess.Popen(
19141919
cmd,
1915-
cwd=workspace.repo_path,
1920+
cwd=str(worktree_path) if worktree_path else workspace.repo_path,
19161921
stdout=None, # Let output flow to terminal
19171922
stderr=None,
19181923
text=True,

codeframe/core/worktrees.py

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
"""Worktree-per-task isolation for parallel batch execution.
2+
3+
Creates isolated git worktrees so parallel agents don't modify files in the
4+
same working directory. Each task gets its own branch and working tree,
5+
then merges back to the base branch on completion.
6+
7+
Lifecycle:
8+
1. create(workspace_path, task_id) → worktree path
9+
2. Agent runs with cwd set to worktree
10+
3. merge_back(workspace_path, task_id) → MergeResult
11+
4. cleanup(workspace_path, task_id)
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import logging
17+
import subprocess
18+
from dataclasses import dataclass
19+
from pathlib import Path
20+
from typing import Optional
21+
22+
logger = logging.getLogger(__name__)
23+
24+
WORKTREE_DIR = ".codeframe/worktrees"
25+
26+
27+
@dataclass
28+
class MergeResult:
29+
"""Result from merging a worktree branch back to base."""
30+
31+
task_id: str
32+
success: bool
33+
conflict_details: str
34+
merge_commit: Optional[str]
35+
36+
37+
class TaskWorktree:
38+
"""Manages git worktrees for isolated parallel task execution."""
39+
40+
def create(
41+
self,
42+
workspace_path: Path,
43+
task_id: str,
44+
base_branch: str = "main",
45+
) -> Path:
46+
"""Create an isolated worktree for a task.
47+
48+
Args:
49+
workspace_path: Root of the git repository
50+
task_id: Task identifier (used for branch and directory name)
51+
base_branch: Branch to base the worktree on
52+
53+
Returns:
54+
Path to the created worktree directory
55+
56+
Raises:
57+
subprocess.CalledProcessError: If git worktree creation fails
58+
"""
59+
worktree_path = workspace_path / WORKTREE_DIR / task_id
60+
worktree_path.parent.mkdir(parents=True, exist_ok=True)
61+
branch_name = f"cf/{task_id}"
62+
63+
subprocess.run(
64+
["git", "worktree", "add", str(worktree_path), "-b", branch_name, base_branch],
65+
cwd=str(workspace_path),
66+
capture_output=True,
67+
text=True,
68+
check=True,
69+
)
70+
71+
logger.info("Created worktree for %s at %s", task_id, worktree_path)
72+
return worktree_path
73+
74+
def merge_back(
75+
self,
76+
workspace_path: Path,
77+
task_id: str,
78+
base_branch: str = "main",
79+
) -> MergeResult:
80+
"""Merge worktree branch back to base branch.
81+
82+
Args:
83+
workspace_path: Root of the git repository
84+
task_id: Task identifier
85+
base_branch: Branch to merge into
86+
87+
Returns:
88+
MergeResult with success status and optional conflict details
89+
"""
90+
branch_name = f"cf/{task_id}"
91+
92+
# Checkout base branch
93+
subprocess.run(
94+
["git", "checkout", base_branch],
95+
cwd=str(workspace_path),
96+
capture_output=True,
97+
text=True,
98+
check=True,
99+
)
100+
101+
# Attempt merge
102+
result = subprocess.run(
103+
["git", "merge", branch_name, "--no-ff", "-m", f"Merge {branch_name} into {base_branch}"],
104+
cwd=str(workspace_path),
105+
capture_output=True,
106+
text=True,
107+
)
108+
109+
if result.returncode == 0:
110+
# Get merge commit hash
111+
head = subprocess.run(
112+
["git", "rev-parse", "HEAD"],
113+
cwd=str(workspace_path),
114+
capture_output=True,
115+
text=True,
116+
)
117+
merge_commit = head.stdout.strip() if head.returncode == 0 else None
118+
119+
logger.info("Merged %s back to %s", branch_name, base_branch)
120+
return MergeResult(
121+
task_id=task_id,
122+
success=True,
123+
conflict_details="",
124+
merge_commit=merge_commit,
125+
)
126+
else:
127+
# Merge conflict — abort and report
128+
conflict_output = result.stdout + result.stderr
129+
subprocess.run(
130+
["git", "merge", "--abort"],
131+
cwd=str(workspace_path),
132+
capture_output=True,
133+
)
134+
135+
logger.warning("Merge conflict for %s: %s", branch_name, conflict_output[:200])
136+
return MergeResult(
137+
task_id=task_id,
138+
success=False,
139+
conflict_details=conflict_output[:2000],
140+
merge_commit=None,
141+
)
142+
143+
def cleanup(
144+
self,
145+
workspace_path: Path,
146+
task_id: str,
147+
) -> None:
148+
"""Remove worktree and delete task branch.
149+
150+
Never raises — cleanup failures are logged as warnings.
151+
"""
152+
worktree_path = workspace_path / WORKTREE_DIR / task_id
153+
branch_name = f"cf/{task_id}"
154+
155+
# Remove worktree
156+
try:
157+
subprocess.run(
158+
["git", "worktree", "remove", str(worktree_path), "--force"],
159+
cwd=str(workspace_path),
160+
capture_output=True,
161+
text=True,
162+
)
163+
except Exception as exc:
164+
logger.warning("Failed to remove worktree for %s: %s", task_id, exc)
165+
166+
# Delete branch
167+
try:
168+
subprocess.run(
169+
["git", "branch", "-D", branch_name],
170+
cwd=str(workspace_path),
171+
capture_output=True,
172+
text=True,
173+
)
174+
except Exception as exc:
175+
logger.warning("Failed to delete branch %s: %s", branch_name, exc)

0 commit comments

Comments
 (0)