Skip to content

Commit ee07919

Browse files
authored
feat(isolation): re-enable worktree isolation with real merge-back (#787) (#867)
* feat(isolation): re-enable worktree isolation with real merge-back (#787) Re-enables --isolation worktree for the single-run path (cf work start) with auto-commit + merge-back, reversing the #714 interim hard-reject. - worktrees: TaskWorktree.auto_commit() stages+commits worktree changes - sandbox/context: rebased_workspace() keeps state on main / code on worktree; ExecutionContext gains merge_back/preserve; validate_isolation no longer rejects WORKTREE (CLOUD still raises); create_execution_context builds a real worktree (not registered so orphan cleanup can't nuke a preserved branch) - builtin adapters (#715): react/plan run against workspace_path - verification_wrapper (#716): gates + quick-fixes run against the worktree - runtime: merge-back on success; conflict -> blocker + preserve; fail -> preserve - cli: work start accepts worktree; work batch run rejects it (subprocess can't reach the gitignored .codeframe DB from a worktree) with a batch-specific message Tests: new test_worktree_isolation.py (file-on-base after success, conflict->blocker +preserve, fail->preserve, #715/#716 threading); updated sandbox/exit-code tests from the #714 disable contract to the #787 enable contract. * fix(isolation): harden worktree merge-back per review (#787) Addresses defects found by opencode/GLM + CI review bots: - auto_commit fails loud (critical): git add/commit now raise on failure instead of reporting success — a failed stage/commit preserves the branch rather than merging nothing then deleting it (the #714 silent-data-loss class). - Preserved-branch collision (major): create_execution_context moved inside execute_agent's try + a clear ValueError when a leftover cf/<task> branch or worktree exists — a retry becomes a handled FAILED (preserved), not a stranded IN_PROGRESS run. - engine_stats miscount (major): re-sync agent_status to the final state so a merge-back conflict is recorded BLOCKED, not COMPLETED. - Concurrent merge-back (major): serialize the checkout+merge on the shared main tree with a cross-process flock (_main_tree_lock). - Dirty-tree checkout failure now routes through the merge-back error -> blocker + preserve (try/except around merge_back()). Tests: auto_commit-raises regression, preserved-branch-collision, registry-empty assertion; fixed mock-workspace path mismatches in test_builtin / test_verification_wrapper / test_engine_registry_extended (rebased_workspace calls dataclasses.replace, which needs workspace_path==repo_path for the mock no-op).
1 parent beaa871 commit ee07919

13 files changed

Lines changed: 745 additions & 123 deletions

codeframe/cli/app.py

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2489,12 +2489,12 @@ def work_start(
24892489

24902490
task = matching[0]
24912491

2492-
# Reject unsupported isolation first (#714): worktree would silently
2493-
# discard agent work. Fails closed before any run is created.
2492+
# Reject unsupported isolation first (before any run is created). WORKTREE
2493+
# is now accepted for this single-run path (#787); CLOUD still raises.
24942494
from codeframe.core.sandbox.context import IsolationLevel, validate_isolation
24952495
try:
24962496
validate_isolation(IsolationLevel(isolation))
2497-
except ValueError as exc:
2497+
except (ValueError, NotImplementedError) as exc:
24982498
console.print(f"[red]Error:[/red] {exc}")
24992499
raise typer.Exit(1)
25002500

@@ -3800,7 +3800,6 @@ def batch_run(
38003800
codeframe work batch run --all-ready --engine plan
38013801
codeframe work batch run task1 task2 --dry-run
38023802
codeframe work batch run task1 task2 --retry 2
3803-
codeframe work batch run --all-ready --isolation worktree
38043803
"""
38053804
from codeframe.core.workspace import get_workspace
38063805
from codeframe.core import tasks as tasks_module, conductor
@@ -3859,12 +3858,23 @@ def batch_run(
38593858
console.print("[red]Error:[/red] Specify task IDs or use --all-ready/--all-blocked")
38603859
raise typer.Exit(1)
38613860

3862-
# Reject unsupported isolation up front (#714) — before the API-key
3863-
# check and before any run is created (worktree would discard work).
3861+
# Reject unsupported isolation up front — before the API-key check and
3862+
# before any batch is created. CLOUD is not implemented; WORKTREE is
3863+
# enabled only for the single-run path (`cf work start`, #787) — the
3864+
# batch subprocess path can't reach the gitignored .codeframe DB from a
3865+
# worktree, so it stays rejected here until that's solved.
38643866
from codeframe.core.sandbox.context import IsolationLevel, validate_isolation
3867+
if IsolationLevel(isolation) == IsolationLevel.WORKTREE:
3868+
console.print(
3869+
"[red]Error:[/red] worktree isolation is not yet supported for "
3870+
"batch runs (subprocess workers can't reach the workspace state "
3871+
"DB in a worktree). Use it with a single task: "
3872+
"`cf work start <task> --execute --isolation worktree`."
3873+
)
3874+
raise typer.Exit(1)
38653875
try:
38663876
validate_isolation(IsolationLevel(isolation))
3867-
except ValueError as exc:
3877+
except (ValueError, NotImplementedError) as exc:
38683878
console.print(f"[red]Error:[/red] {exc}")
38693879
raise typer.Exit(1)
38703880

codeframe/core/adapters/builtin.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,15 +74,21 @@ def run(
7474
) -> AgentResult:
7575
"""Run the ReactAgent with stall retry, map AgentStatus to AgentResult."""
7676
from codeframe.core.react_agent import ReactAgent
77+
from codeframe.core.sandbox.context import rebased_workspace
7778
from codeframe.core.stall_detector import StallDetectedError
7879

80+
# #715: when isolated in a worktree, run against workspace_path so code
81+
# I/O and gates land in the worktree; task/blocker state stays on the
82+
# main-repo DB (rebased_workspace keeps state_dir unchanged).
83+
run_workspace = rebased_workspace(self._workspace, workspace_path)
84+
7985
def _bridge_event(event_type: str, data: dict) -> None:
8086
if on_event:
8187
on_event(AgentEvent(type=event_type, data=data))
8288

8389
def _build_agent() -> ReactAgent:
8490
kwargs: dict = {
85-
"workspace": self._workspace,
91+
"workspace": run_workspace,
8692
"llm_provider": self._llm_provider,
8793
"stall_timeout_s": self._stall_timeout_s,
8894
"event_publisher": self._event_publisher,
@@ -179,14 +185,18 @@ def run(
179185
) -> AgentResult:
180186
"""Run the plan-based Agent with supervisor retry, map to AgentResult."""
181187
from codeframe.core.agent import Agent, AgentStatus
188+
from codeframe.core.sandbox.context import rebased_workspace
189+
190+
# #715: run against the worktree path when isolated (state stays on main).
191+
run_workspace = rebased_workspace(self._workspace, workspace_path)
182192

183193
def _bridge_event(event_type: str, data: dict) -> None:
184194
if on_event:
185195
on_event(AgentEvent(type=event_type, data=data))
186196

187197
def _build_agent() -> Agent:
188198
return Agent(
189-
workspace=self._workspace,
199+
workspace=run_workspace,
190200
llm_provider=self._llm_provider,
191201
dry_run=self._dry_run,
192202
on_event=_bridge_event,

codeframe/core/adapters/verification_wrapper.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,10 @@ def __init__(
5353
) -> None:
5454
self._inner = inner
5555
self._workspace = workspace
56+
# Workspace that verification gates + quick fixes run against. Rebased to
57+
# the worktree in run() when isolated (#716); blocker creation still uses
58+
# self._workspace so blockers land in the main-repo state DB.
59+
self._verify_workspace = workspace
5660
self._max_correction_rounds = max_correction_rounds
5761
self._gate_names = gate_names # None = use default gates
5862
self._verbose = verbose
@@ -70,6 +74,10 @@ def run(
7074
on_event: Callable[[AgentEvent], None] | None = None,
7175
) -> AgentResult:
7276
"""Run the inner adapter, then verify with gates. Self-correct on failure."""
77+
from codeframe.core.sandbox.context import rebased_workspace
78+
79+
# #716: verify against the worktree when isolated (no-op for NONE).
80+
self._verify_workspace = rebased_workspace(self._workspace, workspace_path)
7381

7482
# Initial run
7583
result = self._inner.run(task_id, prompt, workspace_path, on_event)
@@ -90,7 +98,7 @@ def run(
9098
))
9199

92100
gate_result = run_gates(
93-
self._workspace,
101+
self._verify_workspace,
94102
gates=self._gate_names,
95103
verbose=self._verbose,
96104
)
@@ -160,7 +168,7 @@ def run(
160168

161169
# Final gate check after all correction rounds
162170
gate_result = run_gates(
163-
self._workspace,
171+
self._verify_workspace,
164172
gates=self._gate_names,
165173
verbose=self._verbose,
166174
)
@@ -179,11 +187,11 @@ def _try_quick_fix(self, error_summary: str) -> bool:
179187
180188
Returns True if a fix was successfully applied.
181189
"""
182-
fix = find_quick_fix(error_summary, repo_path=self._workspace.repo_path)
190+
fix = find_quick_fix(error_summary, repo_path=self._verify_workspace.repo_path)
183191
if fix is None:
184192
return False
185193

186-
success, msg = apply_quick_fix(fix, self._workspace.repo_path)
194+
success, msg = apply_quick_fix(fix, self._verify_workspace.repo_path)
187195
if success:
188196
self._verbose_print(f"[VerificationWrapper] Quick fix: {msg}")
189197
return success

codeframe/core/conductor.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1701,9 +1701,9 @@ def _execute_parallel(
17011701
CycleDetectedError: If circular dependencies are detected
17021702
"""
17031703
# Clean up orphaned worktrees from crashed workers on previous runs.
1704-
# Legacy-record path only (#714): worktree isolation is disabled, so this
1705-
# fires just for batches persisted before the fix — harmless best-effort
1706-
# cleanup of pre-existing debris.
1704+
# Batch worktree isolation stays rejected at the CLI (#787 enables it only
1705+
# for the single-run path), so this fires just for legacy batches persisted
1706+
# with worktree isolation — harmless best-effort cleanup of pre-existing debris.
17071707
from codeframe.core.sandbox.context import IsolationLevel as _IL
17081708
if batch.isolation == _IL.WORKTREE.value:
17091709
from codeframe.core.worktrees import WorktreeRegistry

codeframe/core/runtime.py

Lines changed: 84 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -710,14 +710,27 @@ def execute_agent(
710710
import time as _time_mod
711711
_perf_start_ms = int(_time_mod.monotonic() * 1000)
712712

713-
# Create execution context (handles isolation; NONE is a no-op)
714-
from codeframe.core.sandbox.context import IsolationLevel, create_execution_context
715-
exec_ctx = create_execution_context(
716-
run.task_id, IsolationLevel(isolation), workspace.repo_path
713+
from codeframe.core.sandbox.context import (
714+
ExecutionContext,
715+
IsolationLevel,
716+
create_execution_context,
717717
)
718-
effective_repo_path = exec_ctx.workspace_path
718+
# Execution context is created INSIDE the try (#787): building a worktree runs
719+
# git, which can fail (e.g. a preserved cf/<task_id> branch from a prior run).
720+
# Creating it here would let that exception escape execute_agent and strand the
721+
# run IN_PROGRESS; inside the try it becomes a handled FAILED with preservation.
722+
exec_ctx: "ExecutionContext | None" = None
723+
effective_repo_path = workspace.repo_path
724+
# Worktree isolation (#787): tracks whether agent work was merged back so the
725+
# finally block cleans up (merged) vs preserves the branch (failed/conflict).
726+
worktree_merged = False
719727

720728
try:
729+
exec_ctx = create_execution_context(
730+
run.task_id, IsolationLevel(isolation), workspace.repo_path
731+
)
732+
effective_repo_path = exec_ctx.workspace_path
733+
721734
# Execute before_task hook (aborts on failure)
722735
if env_config and hook_ctx:
723736
try:
@@ -830,6 +843,61 @@ def on_adapter_event(event: AdapterEvent) -> None:
830843
)
831844
state.blocker = blocker_obj
832845

846+
# Worktree isolation (#787): on a successful run, auto-commit + merge the
847+
# task branch back to base. A merge conflict (or an auto-commit failure)
848+
# becomes a blocker and the branch is preserved (downgrade COMPLETED →
849+
# BLOCKED so the run reflects it before the status transition below).
850+
# worktree_merged is only set True on a clean merge, so any other outcome
851+
# — including an exception — routes the finally block to preserve().
852+
# Runs here only when exec_ctx has a worktree (merge_back is None for NONE).
853+
if exec_ctx.merge_back is not None and state.status == AgentStatus.COMPLETED:
854+
from codeframe.core import blockers as blockers_mod
855+
try:
856+
merge_result = exec_ctx.merge_back()
857+
except Exception as merge_exc:
858+
# Auto-commit / merge raised (e.g. commit hook rejection, git
859+
# error). Never proceed to cleanup — that would discard the
860+
# agent's uncommitted work (the #714 class of bug).
861+
block_q = (
862+
f"Worktree merge-back failed for cf/{run.task_id}: {merge_exc}. "
863+
"The branch and worktree have been preserved for recovery."
864+
)
865+
blocker_obj = blockers_mod.create(
866+
workspace, task_id=run.task_id, question=block_q,
867+
)
868+
state = AgentState(status=AgentStatus.BLOCKED)
869+
state.blocker = blocker_obj
870+
run_logger.error(
871+
LogCategory.BLOCKER,
872+
f"Worktree merge-back error for {run.task_id}",
873+
{"error": str(merge_exc)[:500]},
874+
)
875+
else:
876+
if not merge_result.success:
877+
conflict_q = (
878+
f"Worktree merge-back conflicted merging cf/{run.task_id} "
879+
"into the base branch. Resolve manually — the branch and "
880+
"worktree have been preserved for recovery.\n\n"
881+
f"{merge_result.conflict_details[:1000]}"
882+
)
883+
blocker_obj = blockers_mod.create(
884+
workspace, task_id=run.task_id, question=conflict_q,
885+
)
886+
state = AgentState(status=AgentStatus.BLOCKED)
887+
state.blocker = blocker_obj
888+
run_logger.warning(
889+
LogCategory.BLOCKER,
890+
f"Worktree merge-back conflict for {run.task_id}",
891+
{"conflict": merge_result.conflict_details[:500]},
892+
)
893+
else:
894+
worktree_merged = True
895+
896+
# Re-sync agent_status to the final state: merge-back may have downgraded
897+
# a COMPLETED run to BLOCKED, and engine_stats.record_run below keys off
898+
# agent_status — without this a conflicted run is miscounted as COMPLETED.
899+
agent_status = state.status
900+
833901
# Log final status
834902
if state.status == AgentStatus.COMPLETED:
835903
run_logger.info(LogCategory.STATE_CHANGE, "Agent completed successfully")
@@ -928,8 +996,17 @@ def on_adapter_event(event: AdapterEvent) -> None:
928996
finally:
929997
# Always close the output logger to ensure file is properly flushed
930998
output_logger.close()
931-
# Clean up execution context (no-op for NONE, removes worktree for WORKTREE)
932-
exec_ctx.cleanup()
999+
# Clean up execution context. For NONE this is a harmless no-op. For a
1000+
# WORKTREE run: remove the worktree + branch only when work was merged
1001+
# back; otherwise preserve them for recovery (failure/blocked/conflict/
1002+
# exception — never silently discard agent work, the #714 bug).
1003+
# exec_ctx is None when create_execution_context itself failed (e.g. a
1004+
# preserved branch collision) — nothing to clean up or preserve here.
1005+
if exec_ctx is not None:
1006+
if exec_ctx.merge_back is not None and not worktree_merged:
1007+
exec_ctx.preserve()
1008+
else:
1009+
exec_ctx.cleanup()
9331010

9341011

9351012
def _event_type_to_category(event_type: str):

0 commit comments

Comments
 (0)