Skip to content

Commit 5ba826e

Browse files
authored
fix(isolation): hard-reject worktree isolation to stop silent data loss (#714) (#786)
* fix(isolation): hard-reject worktree isolation to stop silent data loss (#714) Worktree isolation force-deleted the per-task branch + worktree in cleanup() without ever calling merge_back() (zero call sites) — external-engine work was written to cf/<task_id>, then destroyed, while the task was marked COMPLETED. Real merge-back is entangled with #715 (builtin engines ignore the worktree path) and #716 (verification runs against the wrong tree) and also needs an auto-commit step (adapters leave changes uncommitted). Per the issue's sanctioned interim, fail closed until that lands: - new validate_isolation(); create_execution_context() raises for WORKTREE before creating anything (the single chokepoint for CLI/server/conductor) - cf work start / batch run reject --isolation worktree up front with a clear message, so no run is created and no task is stranded IN_PROGRESS - worktree primitives (TaskWorktree.create/merge_back/cleanup) are untouched and still unit-tested; only the wiring is gated off Closes #714 * docs/api: fix worktree docstring, export validate_isolation, note legacy paths (#714 review) * test(cli): cover --isolation worktree rejection in work start/batch (#714 review) * fix(cli): reject worktree isolation before API-key check in batch run (#714) The batch isolation guard ran after the ANTHROPIC_API_KEY check, so without a key the run failed on the key error before reaching the isolation reject (CI failure). Move it ahead of the key check, matching work start.
1 parent 8425d76 commit 5ba826e

6 files changed

Lines changed: 127 additions & 55 deletions

File tree

codeframe/cli/app.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2421,6 +2421,15 @@ def work_start(
24212421

24222422
task = matching[0]
24232423

2424+
# Reject unsupported isolation first (#714): worktree would silently
2425+
# discard agent work. Fails closed before any run is created.
2426+
from codeframe.core.sandbox.context import IsolationLevel, validate_isolation
2427+
try:
2428+
validate_isolation(IsolationLevel(isolation))
2429+
except ValueError as exc:
2430+
console.print(f"[red]Error:[/red] {exc}")
2431+
raise typer.Exit(1)
2432+
24242433
# Validate API key before creating run record (avoids dangling IN_PROGRESS state)
24252434
if execute:
24262435
from codeframe.core.engine_registry import is_external_engine
@@ -3779,6 +3788,15 @@ def batch_run(
37793788
console.print("[red]Error:[/red] Specify task IDs or use --all-ready/--all-blocked")
37803789
raise typer.Exit(1)
37813790

3791+
# Reject unsupported isolation up front (#714) — before the API-key
3792+
# check and before any run is created (worktree would discard work).
3793+
from codeframe.core.sandbox.context import IsolationLevel, validate_isolation
3794+
try:
3795+
validate_isolation(IsolationLevel(isolation))
3796+
except ValueError as exc:
3797+
console.print(f"[red]Error:[/red] {exc}")
3798+
raise typer.Exit(1)
3799+
37823800
# Show execution plan
37833801
console.print("\n[bold]Batch Execution Plan[/bold]")
37843802
console.print(f" Strategy: {strategy}")

codeframe/core/conductor.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1611,7 +1611,10 @@ def _execute_parallel(
16111611
Raises:
16121612
CycleDetectedError: If circular dependencies are detected
16131613
"""
1614-
# Clean up orphaned worktrees from crashed workers on previous runs
1614+
# Clean up orphaned worktrees from crashed workers on previous runs.
1615+
# Legacy-record path only (#714): worktree isolation is disabled, so this
1616+
# fires just for batches persisted before the fix — harmless best-effort
1617+
# cleanup of pre-existing debris.
16151618
from codeframe.core.sandbox.context import IsolationLevel as _IL
16161619
if batch.isolation == _IL.WORKTREE.value:
16171620
from codeframe.core.worktrees import WorktreeRegistry

codeframe/core/sandbox/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
ExecutionContext,
99
IsolationLevel,
1010
create_execution_context,
11+
validate_isolation,
1112
)
1213
from codeframe.core.sandbox.worktree import (
1314
MergeResult,
@@ -20,6 +21,7 @@
2021
"ExecutionContext",
2122
"IsolationLevel",
2223
"create_execution_context",
24+
"validate_isolation",
2325
"MergeResult",
2426
"TaskWorktree",
2527
"WorktreeRegistry",

codeframe/core/sandbox/context.py

Lines changed: 36 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
66
Isolation levels:
77
NONE — shared filesystem, preserves current behavior (default)
8-
WORKTREE — git worktree per task, safe for parallel execution
8+
WORKTREE — git worktree per task (DISABLED — discarded agent work; see #714)
99
CLOUD — E2B Linux VM per task (reserved, raises NotImplementedError)
1010
"""
1111

@@ -42,6 +42,36 @@ class ExecutionContext:
4242
cleanup: Callable[[], None]
4343

4444

45+
# worktree isolation is disabled until real merge-back ships (issue #714).
46+
# It force-deleted the per-task branch/worktree in cleanup() WITHOUT ever
47+
# merging the agent's work back to the base branch — silently discarding all
48+
# changes. Re-enable once merge-back (+ auto-commit of worktree changes) lands;
49+
# that work is gated behind #715 (builtin engines ignore the worktree path) and
50+
# #716 (verification runs against the wrong tree).
51+
_WORKTREE_DISABLED_MSG = (
52+
"worktree isolation is temporarily disabled: it discards agent work without "
53+
"merging it back to the base branch (silent data loss — see issue #714). "
54+
"Use --isolation none (the default) until merge-back ships."
55+
)
56+
57+
58+
def validate_isolation(isolation: IsolationLevel) -> None:
59+
"""Reject isolation levels that are not currently safe to run.
60+
61+
Raises:
62+
ValueError: If ``isolation`` is WORKTREE (see #714). Callers (CLI,
63+
server, conductor) should surface this to the user *before*
64+
creating a run so no task is stranded IN_PROGRESS.
65+
66+
Note:
67+
The server path needs no explicit guard today — no ``ui/routers/`` route
68+
accepts an ``isolation`` parameter — but ``create_execution_context``
69+
calls this, so any future server/programmatic caller is covered too.
70+
"""
71+
if isolation == IsolationLevel.WORKTREE:
72+
raise ValueError(_WORKTREE_DISABLED_MSG)
73+
74+
4575
def create_execution_context(
4676
task_id: str,
4777
isolation: IsolationLevel,
@@ -58,9 +88,12 @@ def create_execution_context(
5888
ExecutionContext with workspace_path and cleanup configured.
5989
6090
Raises:
91+
ValueError: If isolation is WORKTREE (disabled until merge-back — #714).
6192
NotImplementedError: If isolation is CLOUD (future E2B phase).
62-
subprocess.CalledProcessError: If git worktree creation fails.
6393
"""
94+
# Fail closed before creating anything: WORKTREE would destroy agent work.
95+
validate_isolation(isolation)
96+
6497
if isolation == IsolationLevel.NONE:
6598
return ExecutionContext(
6699
task_id=task_id,
@@ -69,30 +102,10 @@ def create_execution_context(
69102
cleanup=lambda: None,
70103
)
71104

72-
if isolation == IsolationLevel.WORKTREE:
73-
from codeframe.core.worktrees import TaskWorktree, WorktreeRegistry, get_base_branch
74-
75-
worktree = TaskWorktree()
76-
registry = WorktreeRegistry()
77-
base_branch = get_base_branch(repo_path)
78-
worktree_path = worktree.create(repo_path, task_id, base_branch=base_branch)
79-
registry.register(repo_path, task_id, batch_id="unknown")
80-
81-
def cleanup() -> None:
82-
worktree.cleanup(repo_path, task_id)
83-
registry.unregister(repo_path, task_id)
84-
85-
return ExecutionContext(
86-
task_id=task_id,
87-
isolation=isolation,
88-
workspace_path=worktree_path,
89-
cleanup=cleanup,
90-
)
91-
92105
if isolation == IsolationLevel.CLOUD:
93106
raise NotImplementedError(
94107
"IsolationLevel.CLOUD is reserved for the future E2B agent adapter phase. "
95-
"Use 'none' or 'worktree' instead."
108+
"Use 'none' instead."
96109
)
97110

98111
raise ValueError(f"Unknown isolation level: {isolation}")

tests/cli/test_work_exit_codes.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,3 +208,45 @@ def test_batch_with_blocked_returns_exit_one(self, workspace_with_two_ready_task
208208
)
209209

210210
assert result.exit_code == 1, f"Expected 1 for BLOCKED batch: {result.output}"
211+
212+
213+
class TestIsolationRejection:
214+
"""Issue #714: `--isolation worktree` must be rejected up front (exit 1)
215+
before any run/batch is created — worktree isolation would silently discard
216+
agent work. Covers the branch CodeRabbit flagged as untested."""
217+
218+
def _workspace_with_task(self, tmp_path):
219+
repo = tmp_path / "repo"
220+
repo.mkdir()
221+
ws = create_or_load_workspace(repo)
222+
task = tasks.create(ws, title="t", description="d", status=TaskStatus.READY)
223+
return repo, ws, task
224+
225+
def test_work_start_rejects_worktree(self, tmp_path):
226+
repo, ws, task = self._workspace_with_task(tmp_path)
227+
result = runner.invoke(
228+
app,
229+
["work", "start", task.id[:8], "--execute", "--isolation", "worktree", "-w", str(repo)],
230+
)
231+
assert result.exit_code == 1
232+
assert "worktree isolation is temporarily disabled" in result.output
233+
# No run was created; the task was not moved to IN_PROGRESS.
234+
assert tasks.get(ws, task.id).status != TaskStatus.IN_PROGRESS
235+
236+
def test_work_batch_run_rejects_worktree(self, tmp_path):
237+
repo, ws, task = self._workspace_with_task(tmp_path)
238+
result = runner.invoke(
239+
app,
240+
["work", "batch", "run", task.id[:8], "--isolation", "worktree", "-w", str(repo)],
241+
)
242+
assert result.exit_code == 1
243+
assert "worktree isolation is temporarily disabled" in result.output
244+
245+
def test_work_start_allows_none(self, tmp_path):
246+
"""--isolation none must NOT hit the rejection path (sanity)."""
247+
repo, ws, task = self._workspace_with_task(tmp_path)
248+
result = runner.invoke(
249+
app,
250+
["work", "start", task.id[:8], "--isolation", "none", "-w", str(repo)],
251+
)
252+
assert "worktree isolation is temporarily disabled" not in result.output

tests/core/test_sandbox_context.py

Lines changed: 25 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -112,43 +112,37 @@ def test_task_id_stored(self, tmp_path: Path):
112112

113113

114114
class TestCreateExecutionContextWorktree:
115-
def test_creates_worktree_directory(self, git_repo: Path):
116-
ctx = create_execution_context("task-abc", IsolationLevel.WORKTREE, git_repo)
117-
assert ctx.workspace_path.exists()
118-
assert ctx.workspace_path.is_dir()
119-
ctx.cleanup()
115+
"""Issue #714 / P0.3: worktree isolation is disabled until merge-back ships
116+
because cleanup() force-deleted the branch/worktree without merging agent
117+
work back — silent data loss. create_execution_context must fail closed and
118+
create NOTHING (no worktree dir, no branch)."""
120119

121-
def test_workspace_path_differs_from_repo_path(self, git_repo: Path):
122-
ctx = create_execution_context("task-abc", IsolationLevel.WORKTREE, git_repo)
123-
assert ctx.workspace_path != git_repo
124-
ctx.cleanup()
120+
def test_worktree_is_rejected(self, git_repo: Path):
121+
with pytest.raises(ValueError, match="worktree isolation is temporarily disabled"):
122+
create_execution_context("task-abc", IsolationLevel.WORKTREE, git_repo)
125123

126-
def test_workspace_path_is_inside_worktrees_dir(self, git_repo: Path):
127-
ctx = create_execution_context("task-abc", IsolationLevel.WORKTREE, git_repo)
128-
assert ".codeframe/worktrees" in str(ctx.workspace_path)
129-
ctx.cleanup()
124+
def test_reject_message_points_at_the_issue(self, git_repo: Path):
125+
with pytest.raises(ValueError, match="#714"):
126+
create_execution_context("task-abc", IsolationLevel.WORKTREE, git_repo)
130127

131-
def test_cleanup_removes_worktree(self, git_repo: Path):
132-
ctx = create_execution_context("task-abc", IsolationLevel.WORKTREE, git_repo)
133-
worktree_path = ctx.workspace_path
134-
assert worktree_path.exists()
135-
ctx.cleanup()
136-
assert not worktree_path.exists()
128+
def test_no_worktree_created_on_reject(self, git_repo: Path):
129+
with pytest.raises(ValueError):
130+
create_execution_context("task-abc", IsolationLevel.WORKTREE, git_repo)
131+
# Nothing was created before the guard fired.
132+
assert not (git_repo / ".codeframe" / "worktrees" / "task-abc").exists()
137133

138-
def test_isolation_level_stored(self, git_repo: Path):
139-
ctx = create_execution_context("task-abc", IsolationLevel.WORKTREE, git_repo)
140-
assert ctx.isolation == IsolationLevel.WORKTREE
141-
ctx.cleanup()
142134

143-
def test_task_id_stored(self, git_repo: Path):
144-
ctx = create_execution_context("task-abc", IsolationLevel.WORKTREE, git_repo)
145-
assert ctx.task_id == "task-abc"
146-
ctx.cleanup()
135+
class TestValidateIsolation:
136+
def test_none_is_allowed(self):
137+
from codeframe.core.sandbox.context import validate_isolation
147138

148-
def test_worktree_contains_repo_files(self, git_repo: Path):
149-
ctx = create_execution_context("task-wt", IsolationLevel.WORKTREE, git_repo)
150-
assert (ctx.workspace_path / "README.md").exists()
151-
ctx.cleanup()
139+
validate_isolation(IsolationLevel.NONE) # no raise
140+
141+
def test_worktree_raises(self):
142+
from codeframe.core.sandbox.context import validate_isolation
143+
144+
with pytest.raises(ValueError, match="#714"):
145+
validate_isolation(IsolationLevel.WORKTREE)
152146

153147

154148
class TestCreateExecutionContextCloud:

0 commit comments

Comments
 (0)