Skip to content

Commit 6483895

Browse files
authored
feat(core): ExecutionContext abstraction for task isolation (#532)
## Summary - New `codeframe/core/sandbox/` package with `IsolationLevel` enum (none|worktree|cloud) and `ExecutionContext` dataclass - `create_execution_context()` factory using existing `TaskWorktree` for WORKTREE isolation - `--isolation` flag wired into `cf work start` and `cf work batch run` - `conductor.py` creates context before each task dispatch, cleans up in `finally` - `runtime.execute_agent()` creates context, passes `effective_repo_path` to all adapters - `BatchRun.isolation: str` field with DB schema migration (ALTER TABLE guarded by try/except) - 16 new tests covering full lifecycle; default `none` preserves all existing behavior ## Validation - Review feedback: All addressed - Demo: All 6 acceptance criteria verified via Showboat - Tests: 16 new passing + 63 existing conductor tests passing - CI: All checks green - Linting: Clean Closes #532
1 parent 7d5caab commit 6483895

6 files changed

Lines changed: 397 additions & 29 deletions

File tree

codeframe/cli/app.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2319,6 +2319,12 @@ def work_start(
23192319
help="Recovery action on stall: 'blocker' (default), 'retry', or 'fail'",
23202320
click_type=click.Choice(["blocker", "retry", "fail"], case_sensitive=False),
23212321
),
2322+
isolation: str = typer.Option(
2323+
"none",
2324+
"--isolation",
2325+
help="Task execution isolation: none (default), worktree, or cloud",
2326+
click_type=click.Choice(["none", "worktree", "cloud"], case_sensitive=False),
2327+
),
23222328
) -> None:
23232329
"""Start working on a task.
23242330
@@ -2331,6 +2337,7 @@ def work_start(
23312337
codeframe work start abc123 --execute --engine plan
23322338
codeframe work start abc123 --execute --dry-run
23332339
codeframe work start abc123 --execute --verbose
2340+
codeframe work start abc123 --execute --isolation worktree
23342341
"""
23352342
from codeframe.core.workspace import get_workspace
23362343
from codeframe.core import tasks as tasks_module, runtime
@@ -2398,7 +2405,7 @@ def work_start(
23982405
state = runtime.execute_agent(
23992406
workspace, run, dry_run=dry_run, debug=debug, verbose=verbose,
24002407
engine=engine, stall_timeout_s=stall_timeout,
2401-
stall_action=stall_action,
2408+
stall_action=stall_action, isolation=isolation,
24022409
)
24032410

24042411
if state.status == AgentStatus.COMPLETED:
@@ -3550,6 +3557,12 @@ def batch_run(
35503557
help="Recovery action on stall: 'blocker' (default), 'retry', or 'fail'",
35513558
click_type=click.Choice(["blocker", "retry", "fail"], case_sensitive=False),
35523559
),
3560+
isolation: str = typer.Option(
3561+
"none",
3562+
"--isolation",
3563+
help="Task execution isolation: none (default), worktree, or cloud",
3564+
click_type=click.Choice(["none", "worktree", "cloud"], case_sensitive=False),
3565+
),
35533566
) -> None:
35543567
"""Execute multiple tasks in batch.
35553568
@@ -3565,6 +3578,7 @@ def batch_run(
35653578
codeframe work batch run --all-ready --engine plan
35663579
codeframe work batch run task1 task2 --dry-run
35673580
codeframe work batch run task1 task2 --retry 2
3581+
codeframe work batch run --all-ready --isolation worktree
35683582
"""
35693583
from codeframe.core.workspace import get_workspace
35703584
from codeframe.core import tasks as tasks_module, conductor
@@ -3663,6 +3677,7 @@ def batch_run(
36633677
engine=engine,
36643678
stall_timeout_s=stall_timeout,
36653679
stall_action=stall_action,
3680+
isolation=isolation,
36663681
)
36673682

36683683
# Show summary

codeframe/core/conductor.py

Lines changed: 107 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -538,6 +538,7 @@ class BatchRun:
538538
stall_action: str = "blocker"
539539
concurrency: ConcurrencyConfig = field(default_factory=ConcurrencyConfig)
540540
isolate: bool = True
541+
isolation: str = "none"
541542

542543

543544
def start_batch(
@@ -554,6 +555,7 @@ def start_batch(
554555
stall_action: str = "blocker",
555556
concurrency_by_status: Optional[dict[str, int]] = None,
556557
isolate: bool = True,
558+
isolation: str = "none",
557559
) -> BatchRun:
558560
"""Start a batch execution of multiple tasks.
559561
@@ -609,6 +611,7 @@ def start_batch(
609611
stall_action=stall_action,
610612
concurrency=concurrency,
611613
isolate=isolate,
614+
isolation=isolation,
612615
)
613616

614617
# Save to database
@@ -1014,8 +1017,17 @@ def _execute_serial_resume(
10141017
if on_event:
10151018
on_event("batch_task_started", {"task_id": task_id, "position": i + 1, "is_retry": True})
10161019

1017-
# Execute task via subprocess
1018-
result_status = _execute_task_subprocess(workspace, task_id, batch.id, engine=batch.engine, stall_timeout_s=batch.stall_timeout_s, stall_action=batch.stall_action)
1020+
# Execute task via subprocess (with isolation context)
1021+
from codeframe.core.sandbox.context import IsolationLevel, create_execution_context
1022+
exec_ctx = create_execution_context(task_id, IsolationLevel(batch.isolation), workspace.repo_path)
1023+
try:
1024+
result_status = _execute_task_subprocess(
1025+
workspace, task_id, batch.id, engine=batch.engine,
1026+
stall_timeout_s=batch.stall_timeout_s, stall_action=batch.stall_action,
1027+
worktree_path=exec_ctx.workspace_path if exec_ctx.workspace_path != workspace.repo_path else None,
1028+
)
1029+
finally:
1030+
exec_ctx.cleanup()
10191031

10201032
# Record result (overwrites previous result)
10211033
batch.results[task_id] = result_status
@@ -1182,8 +1194,17 @@ def _execute_retries(
11821194
print(f"\n[Retry {retry_num}, {i + 1}/{len(failed_tasks)}] {task_id}: {task_title}")
11831195
print(f" Previous: {previous_status}")
11841196

1185-
# Execute task
1186-
result_status = _execute_task_subprocess(workspace, task_id, batch.id, engine=batch.engine, stall_timeout_s=batch.stall_timeout_s, stall_action=batch.stall_action)
1197+
# Execute task (with isolation context)
1198+
from codeframe.core.sandbox.context import IsolationLevel, create_execution_context
1199+
exec_ctx = create_execution_context(task_id, IsolationLevel(batch.isolation), workspace.repo_path)
1200+
try:
1201+
result_status = _execute_task_subprocess(
1202+
workspace, task_id, batch.id, engine=batch.engine,
1203+
stall_timeout_s=batch.stall_timeout_s, stall_action=batch.stall_action,
1204+
worktree_path=exec_ctx.workspace_path if exec_ctx.workspace_path != workspace.repo_path else None,
1205+
)
1206+
finally:
1207+
exec_ctx.cleanup()
11871208

11881209
# Update result
11891210
batch.results[task_id] = result_status
@@ -1402,16 +1423,29 @@ def _execute_serial(
14021423
if on_event:
14031424
on_event("batch_task_started", {"task_id": task_id, "position": i + 1})
14041425

1405-
# Execute task via subprocess
1406-
result_status = _execute_task_subprocess(workspace, task_id, batch.id, engine=batch.engine, stall_timeout_s=batch.stall_timeout_s, stall_action=batch.stall_action)
1426+
# Execute task via subprocess (with isolation context)
1427+
from codeframe.core.sandbox.context import IsolationLevel, create_execution_context
1428+
exec_ctx = create_execution_context(task_id, IsolationLevel(batch.isolation), workspace.repo_path)
1429+
try:
1430+
result_status = _execute_task_subprocess(
1431+
workspace, task_id, batch.id, engine=batch.engine,
1432+
stall_timeout_s=batch.stall_timeout_s, stall_action=batch.stall_action,
1433+
worktree_path=exec_ctx.workspace_path if exec_ctx.workspace_path != workspace.repo_path else None,
1434+
)
14071435

1408-
# If task is BLOCKED, try supervisor resolution
1409-
if result_status == RunStatus.BLOCKED.value:
1410-
supervisor = get_supervisor(workspace)
1411-
if supervisor.try_resolve_blocked_task(task_id):
1412-
# Supervisor resolved the blocker - retry the task
1413-
print(" [Supervisor] Retrying task after auto-resolution...")
1414-
result_status = _execute_task_subprocess(workspace, task_id, batch.id, engine=batch.engine, stall_timeout_s=batch.stall_timeout_s, stall_action=batch.stall_action)
1436+
# If task is BLOCKED, try supervisor resolution
1437+
if result_status == RunStatus.BLOCKED.value:
1438+
supervisor = get_supervisor(workspace)
1439+
if supervisor.try_resolve_blocked_task(task_id):
1440+
# Supervisor resolved the blocker - retry the task
1441+
print(" [Supervisor] Retrying task after auto-resolution...")
1442+
result_status = _execute_task_subprocess(
1443+
workspace, task_id, batch.id, engine=batch.engine,
1444+
stall_timeout_s=batch.stall_timeout_s, stall_action=batch.stall_action,
1445+
worktree_path=exec_ctx.workspace_path if exec_ctx.workspace_path != workspace.repo_path else None,
1446+
)
1447+
finally:
1448+
exec_ctx.cleanup()
14151449

14161450
# Record result
14171451
batch.results[task_id] = result_status
@@ -1803,16 +1837,37 @@ def _execute_single_task(
18031837
if on_event:
18041838
on_event("batch_task_started", {"task_id": task_id, "position": position})
18051839

1806-
# Execute task via subprocess
1807-
result_status = _execute_task_subprocess(workspace, task_id, batch.id, engine=batch.engine, stall_timeout_s=batch.stall_timeout_s, stall_action=batch.stall_action)
1840+
# Create execution context (handles isolation level; NONE is a no-op)
1841+
from codeframe.core.sandbox.context import IsolationLevel, create_execution_context
1842+
exec_ctx = create_execution_context(
1843+
task_id, IsolationLevel(batch.isolation), workspace.repo_path
1844+
)
1845+
1846+
try:
1847+
# Execute task via subprocess
1848+
result_status = _execute_task_subprocess(
1849+
workspace, task_id, batch.id,
1850+
engine=batch.engine,
1851+
stall_timeout_s=batch.stall_timeout_s,
1852+
stall_action=batch.stall_action,
1853+
worktree_path=exec_ctx.workspace_path if exec_ctx.workspace_path != workspace.repo_path else None,
1854+
)
18081855

1809-
# If task is BLOCKED, try supervisor resolution
1810-
if result_status == RunStatus.BLOCKED.value:
1811-
supervisor = get_supervisor(workspace)
1812-
if supervisor.try_resolve_blocked_task(task_id):
1813-
# Supervisor resolved the blocker - retry the task
1814-
print(" [Supervisor] Retrying task after auto-resolution...")
1815-
result_status = _execute_task_subprocess(workspace, task_id, batch.id, engine=batch.engine, stall_timeout_s=batch.stall_timeout_s, stall_action=batch.stall_action)
1856+
# If task is BLOCKED, try supervisor resolution
1857+
if result_status == RunStatus.BLOCKED.value:
1858+
supervisor = get_supervisor(workspace)
1859+
if supervisor.try_resolve_blocked_task(task_id):
1860+
# Supervisor resolved the blocker - retry the task
1861+
print(" [Supervisor] Retrying task after auto-resolution...")
1862+
result_status = _execute_task_subprocess(
1863+
workspace, task_id, batch.id,
1864+
engine=batch.engine,
1865+
stall_timeout_s=batch.stall_timeout_s,
1866+
stall_action=batch.stall_action,
1867+
worktree_path=exec_ctx.workspace_path if exec_ctx.workspace_path != workspace.repo_path else None,
1868+
)
1869+
finally:
1870+
exec_ctx.cleanup()
18161871

18171872
# Record result
18181873
batch.results[task_id] = result_status
@@ -1902,8 +1957,23 @@ def execute_task(task_id: str) -> tuple[str, str]:
19021957
if on_event:
19031958
on_event("batch_task_started", {"task_id": task_id, "parallel": True})
19041959

1905-
# Execute via subprocess
1906-
result_status = _execute_task_subprocess(workspace, task_id, batch.id, engine=batch.engine, stall_timeout_s=batch.stall_timeout_s, stall_action=batch.stall_action)
1960+
# Create execution context for this task
1961+
from codeframe.core.sandbox.context import IsolationLevel, create_execution_context
1962+
exec_ctx = create_execution_context(
1963+
task_id, IsolationLevel(batch.isolation), workspace.repo_path
1964+
)
1965+
1966+
try:
1967+
# Execute via subprocess
1968+
result_status = _execute_task_subprocess(
1969+
workspace, task_id, batch.id,
1970+
engine=batch.engine,
1971+
stall_timeout_s=batch.stall_timeout_s,
1972+
stall_action=batch.stall_action,
1973+
worktree_path=exec_ctx.workspace_path if exec_ctx.workspace_path != workspace.repo_path else None,
1974+
)
1975+
finally:
1976+
exec_ctx.cleanup()
19071977

19081978
# Record result (thread-safe due to GIL for simple dict operations)
19091979
batch.results[task_id] = result_status
@@ -2062,12 +2132,21 @@ def _save_batch(workspace: Workspace, batch: BatchRun) -> None:
20622132
conn = get_db_connection(workspace)
20632133
try:
20642134
cursor = conn.cursor()
2135+
# Ensure isolation column exists (migration for existing databases)
2136+
try:
2137+
cursor.execute(
2138+
"ALTER TABLE batch_runs ADD COLUMN isolation TEXT DEFAULT 'none'"
2139+
)
2140+
conn.commit()
2141+
except Exception:
2142+
pass # Column already exists
2143+
20652144
cursor.execute(
20662145
"""
20672146
INSERT OR REPLACE INTO batch_runs
20682147
(id, workspace_id, task_ids, status, strategy, max_parallel, on_failure,
2069-
started_at, completed_at, results, engine)
2070-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2148+
started_at, completed_at, results, engine, isolation)
2149+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
20712150
""",
20722151
(
20732152
batch.id,
@@ -2081,6 +2160,7 @@ def _save_batch(workspace: Workspace, batch: BatchRun) -> None:
20812160
completed_at,
20822161
results_json,
20832162
batch.engine,
2163+
batch.isolation,
20842164
),
20852165
)
20862166
conn.commit()
@@ -2102,4 +2182,5 @@ def _row_to_batch(row: tuple) -> BatchRun:
21022182
completed_at=datetime.fromisoformat(row[8]) if row[8] else None,
21032183
results=json.loads(row[9]) if row[9] else {},
21042184
engine=row[10] if len(row) > 10 and row[10] else "plan",
2185+
isolation=row[11] if len(row) > 11 and row[11] else "none",
21052186
)

codeframe/core/runtime.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -599,6 +599,7 @@ def execute_agent(
599599
engine: str = "react",
600600
stall_timeout_s: int = 300,
601601
stall_action: str = "blocker",
602+
isolation: str = "none",
602603
) -> "AgentState":
603604
"""Execute a task using the agent orchestrator.
604605
@@ -675,6 +676,13 @@ def execute_agent(
675676
import time as _time_mod
676677
_perf_start_ms = int(_time_mod.monotonic() * 1000)
677678

679+
# Create execution context (handles isolation; NONE is a no-op)
680+
from codeframe.core.sandbox.context import IsolationLevel, create_execution_context
681+
exec_ctx = create_execution_context(
682+
run.task_id, IsolationLevel(isolation), workspace.repo_path
683+
)
684+
effective_repo_path = exec_ctx.workspace_path
685+
678686
try:
679687
# Execute before_task hook (aborts on failure)
680688
if env_config and hook_ctx:
@@ -733,7 +741,7 @@ def on_adapter_event(event: AdapterEvent) -> None:
733741
)
734742

735743
result = wrapper.run(
736-
run.task_id, packaged.prompt, workspace.repo_path,
744+
run.task_id, packaged.prompt, effective_repo_path,
737745
on_event=on_adapter_event,
738746
)
739747
else:
@@ -758,7 +766,7 @@ def on_adapter_event(event: AdapterEvent) -> None:
758766
)
759767

760768
result = adapter.run(
761-
run.task_id, "", workspace.repo_path,
769+
run.task_id, "", effective_repo_path,
762770
on_event=on_adapter_event,
763771
)
764772

@@ -866,6 +874,8 @@ def on_adapter_event(event: AdapterEvent) -> None:
866874
finally:
867875
# Always close the output logger to ensure file is properly flushed
868876
output_logger.close()
877+
# Clean up execution context (no-op for NONE, removes worktree for WORKTREE)
878+
exec_ctx.cleanup()
869879

870880

871881
def _event_type_to_category(event_type: str):

codeframe/core/sandbox/__init__.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
"""Execution environment sandbox abstraction.
2+
3+
Provides ExecutionContext and IsolationLevel for isolating task execution
4+
from the shared filesystem.
5+
"""
6+
7+
from codeframe.core.sandbox.context import (
8+
ExecutionContext,
9+
IsolationLevel,
10+
create_execution_context,
11+
)
12+
13+
__all__ = ["ExecutionContext", "IsolationLevel", "create_execution_context"]

0 commit comments

Comments
 (0)