feat(sandbox): implement sandbox boundary algebra and domain computation
CI / lint (pull_request) Successful in 16s
CI / typecheck (pull_request) Successful in 31s
CI / security (pull_request) Successful in 29s
CI / benchmark-publish (pull_request) Has been skipped
CI / build (pull_request) Successful in 1m21s
CI / quality (pull_request) Successful in 2m33s
CI / unit_tests (pull_request) Successful in 3m10s
CI / integration_tests (pull_request) Successful in 5m17s
CI / coverage (pull_request) Successful in 4m25s
CI / docker (pull_request) Successful in 40s
CI / lint (push) Successful in 12s
CI / typecheck (push) Successful in 31s
CI / quality (push) Successful in 15s
CI / security (push) Successful in 32s
CI / build (push) Successful in 21s
CI / integration_tests (push) Successful in 3m6s
CI / unit_tests (push) Successful in 3m30s
CI / benchmark-regression (push) Has been skipped
CI / docker (push) Successful in 1m43s
CI / coverage (push) Successful in 5m21s
CI / benchmark-regression (pull_request) Successful in 31m11s
CI / benchmark-publish (push) Successful in 17m24s

Implement sandbox_boundary(r) function that walks up containment edges
in the resource DAG to the nearest sandboxable ancestor, enabling
resources sharing a boundary to share one sandbox instance.

Changes:
- Add boundary.py: is_sandbox_boundary(), sandbox_boundary(),
  compute_sandbox_domains(), BoundaryCache (thread-safe, per-execution)
- Update SandboxManager: resolve_sandbox_key() and
  get_or_create_sandbox_for_resource() key by (plan_id, boundary_id)
  instead of (plan_id, resource_id); boundary cache lifecycle methods
- Define "sandboxable" via ResourceCapabilities.sandboxable + non-none
  sandbox_strategy as per specification section 24659-24674
- Export new symbols from sandbox __init__.py
- Add vulture whitelist entries for new public API

Tests:
- 26 Behave BDD scenarios (features/sandbox_boundary_algebra.feature)
- 5 Robot Framework integration tests (robot/sandbox_boundary_algebra.robot)
- ASV benchmarks for boundary walk, domain grouping, and cache performance

ISSUES CLOSED: #548
This commit was merged in pull request #557.
This commit is contained in:
2026-03-04 04:43:52 +00:00
committed by Forgejo
parent 8e6642e8c9
commit 5935940276
9 changed files with 1613 additions and 0 deletions
+196
View File
@@ -0,0 +1,196 @@
"""ASV benchmarks for sandbox boundary algebra.
Measures the performance of:
- ``is_sandbox_boundary()`` predicate
- ``sandbox_boundary()`` walk for shallow and deep DAGs
- ``compute_sandbox_domains()`` grouping
- ``BoundaryCache`` lookup hit and miss
"""
from __future__ import annotations
import importlib
import sys
from pathlib import Path
# Ensure the local *source* tree is importable even when ASV has an
# older build of the package installed.
_SRC = str(Path(__file__).resolve().parents[1] / "src")
if _SRC not in sys.path:
sys.path.insert(0, _SRC)
# Force-reload so ASV picks up the source tree version.
import cleveragents # noqa: E402
importlib.reload(cleveragents)
from cleveragents.domain.models.core.resource import ( # noqa: E402
PhysVirt,
Resource,
ResourceCapabilities,
SandboxStrategy,
)
from cleveragents.infrastructure.sandbox.boundary import ( # noqa: E402
BoundaryCache,
compute_sandbox_domains,
is_sandbox_boundary,
sandbox_boundary,
)
# ---------------------------------------------------------------------------
# Test data builders
# ---------------------------------------------------------------------------
_COUNTER = 0
def _ulid(name: str) -> str:
global _COUNTER
_COUNTER += 1
base = abs(hash(name)) % (10**20)
raw = f"{_COUNTER:06d}{base:020d}"
charset = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
result = ""
for ch in raw:
result += charset[int(ch)]
return (result + "0" * 26)[:26]
def _resource(
name: str,
sandboxable: bool = False,
strategy: str = "none",
parents: list[str] | None = None,
) -> Resource:
return Resource(
resource_id=_ulid(name),
name=name,
resource_type_name="git-checkout" if sandboxable else "fs-file",
classification=PhysVirt.PHYSICAL,
sandbox_strategy=SandboxStrategy(strategy),
capabilities=ResourceCapabilities(
readable=True,
writable=True,
sandboxable=sandboxable,
checkpointable=False,
),
parents=parents or [],
)
# ---------------------------------------------------------------------------
# Pre-built test fixtures
# ---------------------------------------------------------------------------
def _build_shallow_dag() -> tuple[Resource, dict[str, Resource]]:
"""Build a shallow DAG: boundary -> 10 files."""
boundary = _resource("bench-co", sandboxable=True, strategy="git_worktree")
registry = {boundary.resource_id: boundary}
leaf = boundary
for i in range(10):
child = _resource(f"bench-f{i}", parents=[boundary.resource_id])
registry[child.resource_id] = child
leaf = child
return leaf, registry
def _build_deep_dag(depth: int = 50) -> tuple[Resource, dict[str, Resource]]:
"""Build a deep linear DAG: boundary -> dir1 -> dir2 -> ... -> file."""
boundary = _resource("bench-deep-co", sandboxable=True, strategy="git_worktree")
registry = {boundary.resource_id: boundary}
current = boundary
for i in range(depth):
child = _resource(f"bench-d{i}", parents=[current.resource_id])
registry[child.resource_id] = child
current = child
return current, registry
_SHALLOW_LEAF, _SHALLOW_REGISTRY = _build_shallow_dag()
_DEEP_LEAF, _DEEP_REGISTRY = _build_deep_dag()
# ---------------------------------------------------------------------------
# is_sandbox_boundary benchmarks
# ---------------------------------------------------------------------------
class TimeSandboxBoundaryCheck:
"""Benchmark is_sandbox_boundary() predicate."""
def setup(self) -> None:
self.boundary = _resource("bm-co", sandboxable=True, strategy="git_worktree")
self.non_boundary = _resource("bm-f", sandboxable=False)
def time_is_boundary_true(self) -> None:
is_sandbox_boundary(self.boundary)
def time_is_boundary_false(self) -> None:
is_sandbox_boundary(self.non_boundary)
# ---------------------------------------------------------------------------
# sandbox_boundary() walk benchmarks
# ---------------------------------------------------------------------------
class TimeSandboxBoundaryWalk:
"""Benchmark sandbox_boundary() for various DAG shapes."""
def time_self_boundary(self) -> None:
"""Walk when resource is itself the boundary (O(1))."""
boundary = _SHALLOW_REGISTRY[_SHALLOW_LEAF.parents[0]]
sandbox_boundary(boundary, _SHALLOW_REGISTRY)
def time_shallow_walk(self) -> None:
"""Walk one hop from leaf to boundary."""
sandbox_boundary(_SHALLOW_LEAF, _SHALLOW_REGISTRY)
def time_deep_walk(self) -> None:
"""Walk 50 hops from leaf to boundary."""
sandbox_boundary(_DEEP_LEAF, _DEEP_REGISTRY)
# ---------------------------------------------------------------------------
# compute_sandbox_domains benchmarks
# ---------------------------------------------------------------------------
class TimeSandboxDomains:
"""Benchmark compute_sandbox_domains() grouping."""
def setup(self) -> None:
self.boundary = _resource("dom-co", sandboxable=True, strategy="git_worktree")
self.resources = []
self.registry = {self.boundary.resource_id: self.boundary}
for i in range(100):
r = _resource(f"dom-f{i}", parents=[self.boundary.resource_id])
self.resources.append(r)
self.registry[r.resource_id] = r
def time_domains_100_resources(self) -> None:
compute_sandbox_domains(self.resources, self.registry)
# ---------------------------------------------------------------------------
# BoundaryCache benchmarks
# ---------------------------------------------------------------------------
class TimeBoundaryCache:
"""Benchmark BoundaryCache hit and miss paths."""
def setup(self) -> None:
self.cache = BoundaryCache()
self.leaf = _SHALLOW_LEAF
self.registry = _SHALLOW_REGISTRY
def time_cache_miss(self) -> None:
"""First lookup (cache miss)."""
self.cache.clear()
self.cache.get_boundary(self.leaf, self.registry)
def time_cache_hit(self) -> None:
"""Second lookup (cache hit)."""
self.cache.get_boundary(self.leaf, self.registry)
+185
View File
@@ -0,0 +1,185 @@
Feature: Sandbox Boundary Algebra
As a plan execution engine
I want to compute sandbox boundaries by walking the resource DAG
So that resources sharing a boundary share one sandbox instance
Background:
Given a resource registry for boundary algebra tests
# -- sandbox_boundary(r) basic resolution --
Scenario: A resource that is itself a sandbox boundary returns itself
Given a sandboxable resource "checkout-01" with strategy "git_worktree"
When I compute the sandbox boundary for "checkout-01"
Then the boundary should be "checkout-01"
Scenario: A child resource resolves to its sandboxable parent
Given a sandboxable resource "checkout-01" with strategy "git_worktree"
And a non-sandboxable resource "file-01" with parent "checkout-01"
When I compute the sandbox boundary for "file-01"
Then the boundary should be "checkout-01"
Scenario: A deeply nested resource resolves to a distant boundary
Given a sandboxable resource "checkout-01" with strategy "git_worktree"
And a non-sandboxable resource "dir-01" with parent "checkout-01"
And a non-sandboxable resource "subdir-01" with parent "dir-01"
And a non-sandboxable resource "file-deep" with parent "subdir-01"
When I compute the sandbox boundary for "file-deep"
Then the boundary should be "checkout-01"
Scenario: A resource with no sandboxable ancestor returns None
Given a non-sandboxable resource "orphan-01" with no parents
When I compute the sandbox boundary for "orphan-01"
Then the boundary should be None
Scenario: A resource whose parent is not in the registry returns None
Given a non-sandboxable resource "dangling-01" with missing parent "ghost-99"
When I compute the sandbox boundary for "dangling-01"
Then the boundary should be None
# -- is_sandbox_boundary checks --
Scenario: A resource with sandboxable capability and non-none strategy is a boundary
Given a sandboxable resource "boundary-res" with strategy "copy_on_write"
When I check if "boundary-res" is a sandbox boundary
Then it should be a sandbox boundary
Scenario: A resource with sandboxable capability but none strategy is not a boundary
Given a resource "non-boundary" with sandboxable true and strategy "none"
When I check if "non-boundary" is a sandbox boundary
Then it should not be a sandbox boundary
Scenario: A resource with no sandboxable capability is not a boundary
Given a non-sandboxable resource "no-cap" with no parents
When I check if "no-cap" is a sandbox boundary
Then it should not be a sandbox boundary
Scenario: Checking is_sandbox_boundary with None raises ValueError
When I check is_sandbox_boundary with None resource
Then the error should be a ValueError with text "resource cannot be None"
# -- domain grouping --
Scenario: Resources with the same boundary are grouped into one domain
Given a sandboxable resource "checkout-01" with strategy "git_worktree"
And a non-sandboxable resource "file-a" with parent "checkout-01"
And a non-sandboxable resource "file-b" with parent "checkout-01"
When I compute sandbox domains for "file-a" and "file-b"
Then both resources should be in the domain of "checkout-01"
Scenario: Multiple boundaries produce separate domains
Given a sandboxable resource "checkout-01" with strategy "git_worktree"
And a sandboxable resource "dir-root" with strategy "copy_on_write"
And a non-sandboxable resource "file-git" with parent "checkout-01"
And a non-sandboxable resource "file-fs" with parent "dir-root"
When I compute sandbox domains for "file-git" and "file-fs"
Then "file-git" should be in the domain of "checkout-01"
And "file-fs" should be in the domain of "dir-root"
Scenario: Unsandboxable resources are grouped under the special key
Given a non-sandboxable resource "orphan-a" with no parents
And a non-sandboxable resource "orphan-b" with no parents
When I compute sandbox domains for "orphan-a" and "orphan-b"
Then both resources should be in the unsandboxable domain
# -- boundary caching --
Scenario: Boundary results are cached across lookups
Given a sandboxable resource "checkout-01" with strategy "git_worktree"
And a non-sandboxable resource "file-cached" with parent "checkout-01"
And a fresh boundary cache
When I look up the boundary for "file-cached" via the cache
And I look up the boundary for "file-cached" via the cache again
Then the cache should contain 1 entry
And the cached boundary for "file-cached" should be "checkout-01"
Scenario: Cache can be cleared
Given a sandboxable resource "checkout-01" with strategy "git_worktree"
And a non-sandboxable resource "file-c" with parent "checkout-01"
And a fresh boundary cache
When I look up the boundary for "file-c" via the cache
And I clear the boundary cache
Then the cache should contain 0 entries
Scenario: Cache returns None for unsandboxable resources
Given a non-sandboxable resource "orphan-cached" with no parents
And a fresh boundary cache
When I look up the boundary for "orphan-cached" via the cache
Then the cached boundary result should be None
And the cache should contain 1 entry
# -- SandboxManager boundary-aware keying --
Scenario: Manager resolves sandbox key via boundary algebra
Given a located sandboxable resource "checkout-01" strategy "git_worktree" location "/tmp/repo"
And a non-sandboxable resource "file-01" with parent "checkout-01"
And a sandbox factory instance
And a sandbox manager with the factory
When I resolve the sandbox key for plan "plan-001" resource "file-01"
Then the sandbox key should be "checkout-01"
Scenario: Manager creates sandbox keyed by boundary ID
Given a located sandboxable resource "checkout-01" strategy "git_worktree" location "/tmp/repo"
And a non-sandboxable resource "file-01" with parent "checkout-01"
And a non-sandboxable resource "file-02" with parent "checkout-01"
And a mock sandbox factory
And a sandbox manager with the mock factory
When I get or create a boundary sandbox for plan "plan-001" resource "file-01"
And I get or create a boundary sandbox for plan "plan-001" resource "file-02"
Then both resources should share the same sandbox instance
Scenario: Manager raises NoSandboxBoundaryError for orphan resource
Given a non-sandboxable resource "orphan-mgr" with no parents
And a sandbox factory instance
And a sandbox manager with the factory
When I try to resolve the sandbox key for plan "plan-001" resource "orphan-mgr"
Then a NoSandboxBoundaryError should be raised
Scenario: Manager boundary cache is cleared on demand
Given a located sandboxable resource "checkout-01" strategy "git_worktree" location "/tmp/repo"
And a non-sandboxable resource "file-01" with parent "checkout-01"
And a sandbox factory instance
And a sandbox manager with the factory
When I resolve the sandbox key for plan "plan-001" resource "file-01"
Then the manager boundary cache should have 1 entry
When I clear the manager boundary cache
Then the manager boundary cache should have 0 entries
# -- argument validation --
Scenario: sandbox_boundary with None resource raises ValueError
When I call sandbox_boundary with None resource
Then the error should be a ValueError with text "resource cannot be None"
Scenario: sandbox_boundary with None registry raises ValueError
Given a non-sandboxable resource "dummy" with no parents
When I call sandbox_boundary with None registry for "dummy"
Then the error should be a ValueError with text "resource_registry cannot be None"
Scenario: compute_sandbox_domains with None resources raises ValueError
When I call compute_sandbox_domains with None resources
Then the error should be a ValueError with text "resources cannot be None"
Scenario: compute_sandbox_domains with None registry raises ValueError
Given a non-sandboxable resource "dummy" with no parents
When I call compute_sandbox_domains with None registry
Then the error should be a ValueError with text "resource_registry cannot be None"
Scenario: BoundaryCache with None resource raises ValueError
Given a fresh boundary cache
When I call boundary cache get_boundary with None resource
Then the error should be a ValueError with text "resource cannot be None"
Scenario: BoundaryCache with None registry raises ValueError
Given a non-sandboxable resource "dummy" with no parents
And a fresh boundary cache
When I call boundary cache get_boundary with None registry for "dummy"
Then the error should be a ValueError with text "resource_registry cannot be None"
Scenario: Boundary walk skips already-visited parent IDs
Given a sandboxable resource "checkout-01" with strategy "git_worktree"
And a non-sandboxable resource "dir-a" with parent "checkout-01"
And a non-sandboxable resource "dir-b" with parent "checkout-01"
And a non-sandboxable resource "file-shared" with parents "dir-a" and "dir-b"
When I compute the sandbox boundary for "file-shared"
Then the boundary should be "checkout-01"
@@ -0,0 +1,534 @@
"""Step definitions for sandbox boundary algebra BDD tests.
Covers:
- ``sandbox_boundary(r)`` function
- ``is_sandbox_boundary(r)`` predicate
- ``compute_sandbox_domains()`` grouping
- ``BoundaryCache`` caching behaviour
- ``SandboxManager`` boundary-aware keying
"""
from __future__ import annotations
from datetime import datetime
from typing import Any
from unittest.mock import MagicMock
from behave import given, then, when
from cleveragents.domain.models.core.resource import (
PhysVirt,
Resource,
ResourceCapabilities,
SandboxStrategy,
)
from cleveragents.infrastructure.sandbox.boundary import (
BoundaryCache,
NoSandboxBoundaryError,
compute_sandbox_domains,
is_sandbox_boundary,
sandbox_boundary,
)
from cleveragents.infrastructure.sandbox.factory import SandboxFactory
from cleveragents.infrastructure.sandbox.manager import SandboxManager
from cleveragents.infrastructure.sandbox.protocol import (
CommitResult,
SandboxContext,
SandboxStatus,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
# Counter for generating unique ULIDs in tests.
_ULID_COUNTER: int = 0
def _make_ulid(name: str) -> str:
"""Generate a deterministic ULID-like ID from a name.
Uses Crockford base-32 charset (0-9, A-H, J-K, M-N, P-T, V-Z)
to satisfy the ULID pattern ``^[0-9A-HJKMNP-TV-Z]{26}$``.
"""
global _ULID_COUNTER
_ULID_COUNTER += 1
# Pad the counter + hash of name into 26 chars of Crockford base32.
base = abs(hash(name)) % (10**20)
raw = f"{_ULID_COUNTER:06d}{base:020d}"
# Map digits to Crockford charset.
charset = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
result = ""
for ch in raw:
result += charset[int(ch)]
# Pad/truncate to 26.
return (result + "0" * 26)[:26]
def _make_resource(
name: str,
sandboxable: bool = False,
strategy: str = "none",
parents: list[str] | None = None,
location: str | None = None,
) -> Resource:
"""Create a test resource with the given properties."""
rid = _make_ulid(name)
return Resource(
resource_id=rid,
name=name,
resource_type_name="fs-file" if not sandboxable else "git-checkout",
classification=PhysVirt.PHYSICAL,
sandbox_strategy=SandboxStrategy(strategy),
capabilities=ResourceCapabilities(
readable=True,
writable=True,
sandboxable=sandboxable,
checkpointable=False,
),
parents=parents or [],
location=location,
)
# ---------------------------------------------------------------------------
# Background
# ---------------------------------------------------------------------------
@given("a resource registry for boundary algebra tests")
def step_given_resource_registry(context: Any) -> None:
"""Initialise a fresh resource registry (dict) for this scenario."""
context.resource_registry = {}
context.resources_by_name = {}
context.boundary_result = None
context.boundary_error = None
context.is_boundary_result = None
context.domain_result = None
context.boundary_cache = None
context.sandbox_key_result = None
context.boundary_sandbox_results = []
context.error = None
# ---------------------------------------------------------------------------
# Given steps - resource construction
# ---------------------------------------------------------------------------
@given('a sandboxable resource "{name}" with strategy "{strategy}"')
def step_given_sandboxable_resource(context: Any, name: str, strategy: str) -> None:
res = _make_resource(name, sandboxable=True, strategy=strategy)
context.resource_registry[res.resource_id] = res
context.resources_by_name[name] = res
@given('a located sandboxable resource "{name}" strategy "{strategy}" location "{loc}"')
def step_given_sandboxable_resource_with_location(
context: Any, name: str, strategy: str, loc: str
) -> None:
res = _make_resource(name, sandboxable=True, strategy=strategy, location=loc)
context.resource_registry[res.resource_id] = res
context.resources_by_name[name] = res
@given('a non-sandboxable resource "{name}" with parent "{parent_name}"')
def step_given_nonsandboxable_with_parent(
context: Any, name: str, parent_name: str
) -> None:
parent = context.resources_by_name[parent_name]
res = _make_resource(name, sandboxable=False, parents=[parent.resource_id])
context.resource_registry[res.resource_id] = res
context.resources_by_name[name] = res
@given('a non-sandboxable resource "{name}" with no parents')
def step_given_nonsandboxable_orphan(context: Any, name: str) -> None:
res = _make_resource(name, sandboxable=False)
context.resource_registry[res.resource_id] = res
context.resources_by_name[name] = res
@given('a non-sandboxable resource "{name}" with missing parent "{missing_parent}"')
def step_given_nonsandboxable_dangling(
context: Any, name: str, missing_parent: str
) -> None:
# Parent ID is not registered in the registry.
fake_parent_id = _make_ulid(missing_parent)
res = _make_resource(name, sandboxable=False, parents=[fake_parent_id])
context.resource_registry[res.resource_id] = res
context.resources_by_name[name] = res
@given('a resource "{name}" with sandboxable true and strategy "{strategy}"')
def step_given_sandboxable_with_strategy(
context: Any, name: str, strategy: str
) -> None:
res = _make_resource(name, sandboxable=True, strategy=strategy)
context.resource_registry[res.resource_id] = res
context.resources_by_name[name] = res
@given("a fresh boundary cache")
def step_given_boundary_cache(context: Any) -> None:
context.boundary_cache = BoundaryCache()
def _make_mock_sandbox(
resource_id: str = "res-mock",
plan_id: str = "plan-mock",
original_path: str = "/tmp/mock",
) -> MagicMock:
"""Create a mock sandbox satisfying the Sandbox protocol."""
sb = MagicMock()
sandbox_id = f"sb-{resource_id}"
sb.sandbox_id = sandbox_id
sb.status = SandboxStatus.CREATED
sb.context = SandboxContext(
sandbox_id=sandbox_id,
sandbox_path=original_path,
original_path=original_path,
resource_id=resource_id,
plan_id=plan_id,
created_at=datetime.now(),
)
sb.create.return_value = sb.context
sb.commit.return_value = CommitResult(
sandbox_id=sandbox_id,
success=True,
timestamp=datetime.now(),
)
sb.rollback.return_value = None
sb.cleanup.return_value = None
return sb
@given("a mock sandbox factory")
def step_given_mock_factory(context: Any) -> None:
"""Create a mock factory that returns mock sandboxes.
Mocks are cached by resource_id so the same mock is returned for
repeated calls with the same resource_id (mimics real dedup).
"""
factory = MagicMock(spec=SandboxFactory)
mock_cache: dict[str, MagicMock] = {}
def _create_sandbox(
resource_id: str,
original_path: str,
sandbox_strategy: str,
) -> MagicMock:
if resource_id not in mock_cache:
mock_cache[resource_id] = _make_mock_sandbox(
resource_id=resource_id,
original_path=original_path,
)
return mock_cache[resource_id]
factory.create_sandbox.side_effect = _create_sandbox
context.factory = factory
@given("a sandbox manager with the mock factory")
def step_given_manager_mock_factory(context: Any) -> None:
context.manager = SandboxManager(factory=context.factory, cleanup_on_exit=False)
context.error = None
# ---------------------------------------------------------------------------
# When steps
# ---------------------------------------------------------------------------
@when('I compute the sandbox boundary for "{name}"')
def step_when_compute_boundary(context: Any, name: str) -> None:
res = context.resources_by_name[name]
context.boundary_result = sandbox_boundary(res, context.resource_registry)
@when('I check if "{name}" is a sandbox boundary')
def step_when_check_is_boundary(context: Any, name: str) -> None:
res = context.resources_by_name[name]
context.is_boundary_result = is_sandbox_boundary(res)
@when("I check is_sandbox_boundary with None resource")
def step_when_check_is_boundary_none(context: Any) -> None:
try:
is_sandbox_boundary(None) # type: ignore[arg-type]
except ValueError as exc:
context.error = exc
@when('I compute sandbox domains for "{name_a}" and "{name_b}"')
def step_when_compute_domains(context: Any, name_a: str, name_b: str) -> None:
res_a = context.resources_by_name[name_a]
res_b = context.resources_by_name[name_b]
context.domain_result = compute_sandbox_domains(
[res_a, res_b], context.resource_registry
)
@when('I look up the boundary for "{name}" via the cache')
def step_when_cache_lookup(context: Any, name: str) -> None:
res = context.resources_by_name[name]
context.boundary_result = context.boundary_cache.get_boundary(
res, context.resource_registry
)
@when('I look up the boundary for "{name}" via the cache again')
def step_when_cache_lookup_again(context: Any, name: str) -> None:
# Same as above; exercises cache hit path.
res = context.resources_by_name[name]
context.boundary_result = context.boundary_cache.get_boundary(
res, context.resource_registry
)
@when("I clear the boundary cache")
def step_when_clear_cache(context: Any) -> None:
if context.boundary_cache is not None:
context.boundary_cache.clear()
else:
context.manager.clear_boundary_cache()
@when('I resolve the sandbox key for plan "{plan_id}" resource "{name}"')
def step_when_resolve_sandbox_key(context: Any, plan_id: str, name: str) -> None:
res = context.resources_by_name[name]
context.sandbox_key_result = context.manager.resolve_sandbox_key(
res, context.resource_registry
)
@when('I try to resolve the sandbox key for plan "{plan_id}" resource "{name}"')
def step_when_try_resolve_sandbox_key(context: Any, plan_id: str, name: str) -> None:
res = context.resources_by_name[name]
try:
context.manager.resolve_sandbox_key(res, context.resource_registry)
except NoSandboxBoundaryError as exc:
context.error = exc
@when('I get or create a boundary sandbox for plan "{plan_id}" resource "{name}"')
def step_when_get_or_create_boundary_sandbox(
context: Any, plan_id: str, name: str
) -> None:
res = context.resources_by_name[name]
sb = context.manager.get_or_create_sandbox_for_resource(
plan_id=plan_id,
resource=res,
resource_registry=context.resource_registry,
)
context.boundary_sandbox_results.append(sb)
@when("I clear the manager boundary cache")
def step_when_clear_manager_cache(context: Any) -> None:
context.manager.clear_boundary_cache()
@given('a non-sandboxable resource "{name}" with parents "{parent_a}" and "{parent_b}"')
def step_given_nonsandboxable_multi_parent(
context: Any, name: str, parent_a: str, parent_b: str
) -> None:
pa = context.resources_by_name[parent_a]
pb = context.resources_by_name[parent_b]
res = _make_resource(
name, sandboxable=False, parents=[pa.resource_id, pb.resource_id]
)
context.resource_registry[res.resource_id] = res
context.resources_by_name[name] = res
@when("I call boundary cache get_boundary with None resource")
def step_when_cache_none_resource(context: Any) -> None:
try:
context.boundary_cache.get_boundary(None, context.resource_registry) # type: ignore[arg-type]
except ValueError as exc:
context.error = exc
@when('I call boundary cache get_boundary with None registry for "{name}"')
def step_when_cache_none_registry(context: Any, name: str) -> None:
res = context.resources_by_name[name]
try:
context.boundary_cache.get_boundary(res, None) # type: ignore[arg-type]
except ValueError as exc:
context.error = exc
@when("I call sandbox_boundary with None resource")
def step_when_sandbox_boundary_none_resource(context: Any) -> None:
try:
sandbox_boundary(None, context.resource_registry) # type: ignore[arg-type]
except ValueError as exc:
context.error = exc
@when('I call sandbox_boundary with None registry for "{name}"')
def step_when_sandbox_boundary_none_registry(context: Any, name: str) -> None:
res = context.resources_by_name[name]
try:
sandbox_boundary(res, None) # type: ignore[arg-type]
except ValueError as exc:
context.error = exc
@when("I call compute_sandbox_domains with None resources")
def step_when_domains_none_resources(context: Any) -> None:
try:
compute_sandbox_domains(None, context.resource_registry) # type: ignore[arg-type]
except ValueError as exc:
context.error = exc
@when("I call compute_sandbox_domains with None registry")
def step_when_domains_none_registry(context: Any) -> None:
res = context.resources_by_name.get("dummy")
try:
compute_sandbox_domains([res] if res else [], None) # type: ignore[arg-type]
except ValueError as exc:
context.error = exc
# ---------------------------------------------------------------------------
# Then steps
# ---------------------------------------------------------------------------
@then('the boundary should be "{expected_name}"')
def step_then_boundary_is(context: Any, expected_name: str) -> None:
expected = context.resources_by_name[expected_name]
assert context.boundary_result is not None, (
f"Expected boundary '{expected_name}' but got None"
)
assert context.boundary_result.resource_id == expected.resource_id, (
f"Expected boundary {expected.resource_id}, "
f"got {context.boundary_result.resource_id}"
)
@then("the boundary should be None")
def step_then_boundary_is_none(context: Any) -> None:
assert context.boundary_result is None, (
f"Expected None boundary, got {context.boundary_result}"
)
@then("it should be a sandbox boundary")
def step_then_is_boundary(context: Any) -> None:
assert context.is_boundary_result is True, (
"Expected resource to be a sandbox boundary"
)
@then("it should not be a sandbox boundary")
def step_then_is_not_boundary(context: Any) -> None:
assert context.is_boundary_result is False, (
"Expected resource NOT to be a sandbox boundary"
)
@then('the error should be a ValueError with text "{msg}"')
def step_then_value_error_containing(context: Any, msg: str) -> None:
assert context.error is not None, "Expected a ValueError but no error was raised"
assert isinstance(context.error, ValueError), (
f"Expected ValueError, got {type(context.error).__name__}: {context.error}"
)
assert msg in str(context.error), f"Expected '{msg}' in '{context.error}'"
assert msg in str(context.error), f"Expected '{msg}' in '{context.error}'"
@then('both resources should be in the domain of "{boundary_name}"')
def step_then_both_in_domain(context: Any, boundary_name: str) -> None:
boundary = context.resources_by_name[boundary_name]
domain = context.domain_result.get(boundary.resource_id, [])
assert len(domain) == 2, (
f"Expected 2 resources in domain of '{boundary_name}', got {len(domain)}"
)
@then('"{name}" should be in the domain of "{boundary_name}"')
def step_then_resource_in_domain(context: Any, name: str, boundary_name: str) -> None:
boundary = context.resources_by_name[boundary_name]
res = context.resources_by_name[name]
domain = context.domain_result.get(boundary.resource_id, [])
ids = [r.resource_id for r in domain]
assert res.resource_id in ids, (
f"Resource '{name}' not found in domain of '{boundary_name}'"
)
@then("both resources should be in the unsandboxable domain")
def step_then_both_unsandboxable(context: Any) -> None:
domain = context.domain_result.get("__unsandboxable__", [])
assert len(domain) == 2, f"Expected 2 unsandboxable resources, got {len(domain)}"
@then("the cache should contain {count:d} entry")
def step_then_cache_size_singular(context: Any, count: int) -> None:
cache = context.boundary_cache or context.manager._boundary_cache
assert cache.size == count, f"Expected cache size {count}, got {cache.size}"
@then("the cache should contain {count:d} entries")
def step_then_cache_size(context: Any, count: int) -> None:
cache = context.boundary_cache or context.manager._boundary_cache
assert cache.size == count, f"Expected cache size {count}, got {cache.size}"
@then('the cached boundary for "{name}" should be "{boundary_name}"')
def step_then_cached_boundary(context: Any, name: str, boundary_name: str) -> None:
res = context.resources_by_name[name]
boundary = context.resources_by_name[boundary_name]
cache = context.boundary_cache
result = cache.get_boundary(res, context.resource_registry)
assert result is not None, f"Cached boundary for '{name}' is None"
assert result.resource_id == boundary.resource_id
@then("the cached boundary result should be None")
def step_then_cached_boundary_none(context: Any) -> None:
assert context.boundary_result is None
@then('the sandbox key should be "{expected_name}"')
def step_then_sandbox_key(context: Any, expected_name: str) -> None:
expected = context.resources_by_name[expected_name]
assert context.sandbox_key_result == expected.resource_id, (
f"Expected key {expected.resource_id}, got {context.sandbox_key_result}"
)
@then("both resources should share the same sandbox instance")
def step_then_same_sandbox_instance(context: Any) -> None:
assert len(context.boundary_sandbox_results) == 2
assert context.boundary_sandbox_results[0] is context.boundary_sandbox_results[1], (
"Expected both resources to share the same sandbox instance"
)
@then("a NoSandboxBoundaryError should be raised")
def step_then_no_boundary_error(context: Any) -> None:
assert context.error is not None, (
"Expected NoSandboxBoundaryError but no error was raised"
)
assert isinstance(context.error, NoSandboxBoundaryError), (
f"Expected NoSandboxBoundaryError, got {type(context.error).__name__}"
)
@then("the manager boundary cache should have {count:d} entry")
def step_then_manager_cache_singular(context: Any, count: int) -> None:
assert context.manager.boundary_cache_size == count, (
f"Expected {count} entry, got {context.manager.boundary_cache_size}"
)
@then("the manager boundary cache should have {count:d} entries")
def step_then_manager_cache(context: Any, count: int) -> None:
assert context.manager.boundary_cache_size == count, (
f"Expected {count} entries, got {context.manager.boundary_cache_size}"
)
+281
View File
@@ -0,0 +1,281 @@
"""Helper utilities for sandbox boundary algebra Robot integration tests.
Covers the integration between:
- sandbox_boundary() function
- compute_sandbox_domains() grouping
- BoundaryCache caching
- SandboxManager boundary-aware keying
"""
from __future__ import annotations
import sys
from cleveragents.domain.models.core.resource import (
PhysVirt,
Resource,
ResourceCapabilities,
SandboxStrategy,
)
from cleveragents.infrastructure.sandbox.boundary import (
BoundaryCache,
NoSandboxBoundaryError,
compute_sandbox_domains,
is_sandbox_boundary,
sandbox_boundary,
)
from cleveragents.infrastructure.sandbox.factory import SandboxFactory
from cleveragents.infrastructure.sandbox.manager import SandboxManager
_COUNTER = 0
def _ulid(name: str) -> str:
"""Generate a deterministic ULID for test resources."""
global _COUNTER
_COUNTER += 1
base = abs(hash(name)) % (10**20)
raw = f"{_COUNTER:06d}{base:020d}"
charset = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
result = ""
for ch in raw:
result += charset[int(ch)]
return (result + "0" * 26)[:26]
def _resource(
name: str,
sandboxable: bool = False,
strategy: str = "none",
parents: list[str] | None = None,
location: str | None = None,
) -> Resource:
return Resource(
resource_id=_ulid(name),
name=name,
resource_type_name="git-checkout" if sandboxable else "fs-file",
classification=PhysVirt.PHYSICAL,
sandbox_strategy=SandboxStrategy(strategy),
capabilities=ResourceCapabilities(
readable=True,
writable=True,
sandboxable=sandboxable,
checkpointable=False,
),
parents=parents or [],
location=location,
)
def _test_boundary_walk() -> None:
"""Integration: boundary walks up to nearest sandboxable ancestor."""
checkout = _resource("checkout", sandboxable=True, strategy="git_worktree")
directory = _resource("dir", sandboxable=False, parents=[checkout.resource_id])
file_res = _resource("file", sandboxable=False, parents=[directory.resource_id])
registry = {
checkout.resource_id: checkout,
directory.resource_id: directory,
file_res.resource_id: file_res,
}
assert is_sandbox_boundary(checkout)
assert not is_sandbox_boundary(directory)
assert not is_sandbox_boundary(file_res)
boundary = sandbox_boundary(file_res, registry)
assert boundary is not None
assert boundary.resource_id == checkout.resource_id
# Self-boundary
self_boundary = sandbox_boundary(checkout, registry)
assert self_boundary is not None
assert self_boundary.resource_id == checkout.resource_id
print("boundary-walk-ok")
def _test_domain_grouping() -> None:
"""Integration: domain grouping partitions resources by boundary."""
co1 = _resource("co1", sandboxable=True, strategy="git_worktree")
co2 = _resource("co2", sandboxable=True, strategy="copy_on_write")
f1 = _resource("f1", parents=[co1.resource_id])
f2 = _resource("f2", parents=[co1.resource_id])
f3 = _resource("f3", parents=[co2.resource_id])
orphan = _resource("orphan")
registry = {r.resource_id: r for r in [co1, co2, f1, f2, f3, orphan]}
domains = compute_sandbox_domains([f1, f2, f3, orphan], registry)
assert co1.resource_id in domains
assert len(domains[co1.resource_id]) == 2
assert co2.resource_id in domains
assert len(domains[co2.resource_id]) == 1
assert "__unsandboxable__" in domains
assert len(domains["__unsandboxable__"]) == 1
print("domain-grouping-ok")
def _test_boundary_cache() -> None:
"""Integration: boundary cache stores and retrieves results."""
checkout = _resource("co-cache", sandboxable=True, strategy="git_worktree")
file_res = _resource("f-cache", parents=[checkout.resource_id])
registry = {
checkout.resource_id: checkout,
file_res.resource_id: file_res,
}
cache = BoundaryCache()
assert cache.size == 0
result = cache.get_boundary(file_res, registry)
assert result is not None
assert result.resource_id == checkout.resource_id
assert cache.size == 1
# Cache hit
result2 = cache.get_boundary(file_res, registry)
assert result2 is not None
assert result2.resource_id == checkout.resource_id
assert cache.size == 1
cache.clear()
assert cache.size == 0
print("boundary-cache-ok")
def _test_manager_boundary_keying() -> None:
"""Integration: SandboxManager resolves keys via boundary algebra.
Uses ``resolve_sandbox_key`` (no real sandbox creation) and then
``get_or_create_sandbox`` with the resolved boundary to demonstrate
that two child resources share the same sandbox.
"""
checkout = _resource(
"co-mgr",
sandboxable=True,
strategy="git_worktree",
location="/tmp",
)
f1 = _resource("f1-mgr", parents=[checkout.resource_id])
f2 = _resource("f2-mgr", parents=[checkout.resource_id])
registry = {
checkout.resource_id: checkout,
f1.resource_id: f1,
f2.resource_id: f2,
}
factory = SandboxFactory()
manager = SandboxManager(factory=factory, cleanup_on_exit=False)
key1 = manager.resolve_sandbox_key(f1, registry)
key2 = manager.resolve_sandbox_key(f2, registry)
assert key1 == checkout.resource_id
assert key2 == checkout.resource_id
# Both files resolve to the same key — verify they would share a sandbox
# by using get_or_create_sandbox with the resolved key and "none" strategy
# (to avoid needing a real git repo).
sb1 = manager.get_or_create_sandbox(
plan_id="plan-1",
resource_id=key1,
original_path="/tmp",
sandbox_strategy="none",
)
sb2 = manager.get_or_create_sandbox(
plan_id="plan-1",
resource_id=key2,
original_path="/tmp",
sandbox_strategy="none",
)
assert sb1 is sb2
# Orphan raises NoSandboxBoundaryError.
orphan = _resource("orphan-mgr")
registry[orphan.resource_id] = orphan
try:
manager.resolve_sandbox_key(orphan, registry)
print("FAIL: expected NoSandboxBoundaryError")
return
except NoSandboxBoundaryError:
pass
manager.cleanup_all("plan-1")
print("manager-boundary-keying-ok")
def _test_multi_resource_plan() -> None:
"""Integration: multi-resource plan with separate sandbox domains.
Uses resolve_sandbox_key to verify two different boundaries produce
different keys, then creates sandboxes using the keys directly.
"""
co_git = _resource(
"co-git",
sandboxable=True,
strategy="git_worktree",
location="/tmp",
)
co_fs = _resource(
"co-fs",
sandboxable=True,
strategy="copy_on_write",
location="/tmp",
)
f_git = _resource("f-git", parents=[co_git.resource_id])
f_fs = _resource("f-fs", parents=[co_fs.resource_id])
registry = {r.resource_id: r for r in [co_git, co_fs, f_git, f_fs]}
factory = SandboxFactory()
manager = SandboxManager(factory=factory, cleanup_on_exit=False)
key_git = manager.resolve_sandbox_key(f_git, registry)
key_fs = manager.resolve_sandbox_key(f_fs, registry)
assert key_git != key_fs
assert key_git == co_git.resource_id
assert key_fs == co_fs.resource_id
# Create sandboxes via resolved keys using "none" strategy (test env).
sb_git = manager.get_or_create_sandbox(
plan_id="plan-2",
resource_id=key_git,
original_path="/tmp",
sandbox_strategy="none",
)
sb_fs = manager.get_or_create_sandbox(
plan_id="plan-2",
resource_id=key_fs,
original_path="/tmp",
sandbox_strategy="none",
)
# Different boundaries → different sandboxes.
assert sb_git is not sb_fs
sandboxes = manager.list_sandboxes("plan-2")
assert len(sandboxes) == 2
manager.cleanup_all("plan-2")
print("multi-resource-plan-ok")
_COMMANDS = {
"boundary-walk": _test_boundary_walk,
"domain-grouping": _test_domain_grouping,
"boundary-cache": _test_boundary_cache,
"manager-boundary-keying": _test_manager_boundary_keying,
"multi-resource-plan": _test_multi_resource_plan,
}
if __name__ == "__main__":
if len(sys.argv) < 2:
print(f"Usage: {sys.argv[0]} <command>")
print(f"Commands: {', '.join(_COMMANDS)}")
sys.exit(1)
cmd = sys.argv[1]
fn = _COMMANDS.get(cmd)
if fn is None:
print(f"Unknown command: {cmd}")
sys.exit(1)
fn()
+47
View File
@@ -0,0 +1,47 @@
*** Settings ***
Documentation Integration tests for sandbox boundary algebra: sandbox_boundary(),
... compute_sandbox_domains(), BoundaryCache, and SandboxManager
... boundary-aware keying.
... Covers M6 sandbox boundary algebra (#548).
Resource ${CURDIR}/common.resource
Suite Setup Setup Test Environment
Suite Teardown Cleanup Test Environment
*** Variables ***
${HELPER_SCRIPT} robot/helper_sandbox_boundary.py
*** Test Cases ***
Sandbox Boundary Walk To Nearest Sandboxable Ancestor
[Documentation] Verify sandbox_boundary() walks containment edges to nearest sandboxable resource
[Tags] sandbox boundary walk
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} boundary-walk cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} boundary-walk-ok
Domain Grouping Partitions Resources By Boundary
[Documentation] Verify compute_sandbox_domains() groups resources by sandbox boundary
[Tags] sandbox boundary domains
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} domain-grouping cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} domain-grouping-ok
Boundary Cache Stores And Retrieves Results
[Documentation] Verify BoundaryCache caches boundary lookups for plan execution
[Tags] sandbox boundary cache
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} boundary-cache cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} boundary-cache-ok
Manager Keys Sandboxes By Boundary ID
[Documentation] Verify SandboxManager resolves keys via boundary algebra so child resources share sandbox
[Tags] sandbox boundary manager
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} manager-boundary-keying cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} manager-boundary-keying-ok
Multi Resource Plan With Separate Sandbox Domains
[Documentation] Verify multi-resource plan with different boundaries creates separate sandboxes
[Tags] sandbox boundary multi
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} multi-resource-plan cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} multi-resource-plan-ok
@@ -5,8 +5,17 @@ and multiple strategy implementations (git worktree, filesystem copy, no-op).
Stage B3 of the implementation plan. Updated in TASK-006 (B4 rework).
Updated in M4 to add checkpoint/rollback hooks.
Updated in M6 to add sandbox boundary algebra (#548).
"""
from cleveragents.infrastructure.sandbox.boundary import (
BoundaryCache,
NoSandboxBoundaryError,
SandboxBoundaryError,
compute_sandbox_domains,
is_sandbox_boundary,
sandbox_boundary,
)
from cleveragents.infrastructure.sandbox.checkpoint import (
Checkpointable,
CheckpointManager,
@@ -33,6 +42,7 @@ from cleveragents.infrastructure.sandbox.protocol import (
)
__all__ = [
"BoundaryCache",
"CheckpointManager",
"Checkpointable",
"CommitResult",
@@ -43,7 +53,9 @@ __all__ = [
"MergeResult",
"MergeStrategy",
"NoSandbox",
"NoSandboxBoundaryError",
"Sandbox",
"SandboxBoundaryError",
"SandboxCheckpoint",
"SandboxContext",
"SandboxError",
@@ -51,4 +63,7 @@ __all__ = [
"SandboxManager",
"SandboxStatus",
"SequentialMergeStrategy",
"compute_sandbox_domains",
"is_sandbox_boundary",
"sandbox_boundary",
]
@@ -0,0 +1,228 @@
"""Sandbox boundary algebra for CleverAgents.
Implements the ``sandbox_boundary(r)`` function defined in the specification:
walk up containment edges in the resource DAG to the nearest sandboxable
ancestor. All resources sharing the same boundary share one sandbox instance.
**Definitions** (from spec §24659-24674):
* **Sandbox boundary**: A resource ``b`` where
``b.capabilities.sandboxable is True`` and
``b.sandbox_strategy != "none"``.
* **sandbox_boundary(r)**: The nearest ancestor of ``r`` (inclusive)
along ``contains`` edges that is a sandbox boundary. If ``r`` itself
is a boundary, returns ``r``. If no boundary exists in ``r``'s
ancestor chain, returns ``None`` (unsandboxable).
* **Sandbox domain**: The set ``{r : sandbox_boundary(r) == b}`` —
resources whose nearest sandbox boundary is ``b``.
Stage M6 - Sandbox Boundary Algebra (#548).
"""
from __future__ import annotations
import logging
import threading
from collections.abc import Iterable, Mapping
from cleveragents.domain.models.core.resource import Resource, SandboxStrategy
logger = logging.getLogger(__name__)
# Maximum DAG depth to prevent infinite loops from cyclic parent references.
_MAX_DAG_DEPTH = 200
class SandboxBoundaryError(Exception):
"""Raised when sandbox boundary computation encounters an error."""
class NoSandboxBoundaryError(SandboxBoundaryError):
"""Raised when a resource has no sandboxable ancestor in its chain."""
def is_sandbox_boundary(resource: Resource) -> bool:
"""Check whether *resource* is a sandbox boundary.
A resource is a sandbox boundary when it declares itself sandboxable
**and** has a non-``none`` sandbox strategy.
Args:
resource: The resource to check.
Returns:
``True`` if the resource is a sandbox boundary.
Raises:
ValueError: If *resource* is ``None``.
"""
if resource is None:
raise ValueError("resource cannot be None")
return (
resource.capabilities.sandboxable
and resource.sandbox_strategy is not None
and resource.sandbox_strategy != SandboxStrategy.NONE
)
def sandbox_boundary(
resource: Resource,
resource_registry: Mapping[str, Resource],
) -> Resource | None:
"""Walk up containment edges to the nearest sandboxable ancestor.
If *resource* itself is a sandbox boundary, it is returned immediately.
Otherwise we walk the ``parents`` list (containment edges) upward
until a boundary is found or the root is reached.
Args:
resource: Starting resource in the DAG.
resource_registry: Mapping of ``resource_id`` → ``Resource`` for
the entire DAG (or at least the ancestor chain).
Returns:
The nearest sandbox-boundary ancestor, or ``None`` if none exists.
Raises:
ValueError: If *resource* or *resource_registry* is ``None``.
"""
if resource is None:
raise ValueError("resource cannot be None")
if resource_registry is None:
raise ValueError("resource_registry cannot be None")
# Self-check first (inclusive).
if is_sandbox_boundary(resource):
return resource
visited: set[str] = {resource.resource_id}
frontier: list[str] = list(resource.parents)
depth = 0
while frontier and depth < _MAX_DAG_DEPTH:
parent_id = frontier.pop(0)
if parent_id in visited:
continue
visited.add(parent_id)
depth += 1
parent = resource_registry.get(parent_id)
if parent is None:
logger.warning(
"Parent resource %s not found in registry while computing "
"sandbox boundary for %s",
parent_id,
resource.resource_id,
)
continue
if is_sandbox_boundary(parent):
return parent
# Continue walking upward.
for grandparent_id in parent.parents:
if grandparent_id not in visited:
frontier.append(grandparent_id)
return None
def compute_sandbox_domains(
resources: Iterable[Resource],
resource_registry: Mapping[str, Resource],
) -> dict[str, list[Resource]]:
"""Group resources by their sandbox boundary.
Returns a mapping of ``boundary_resource_id`` → list of resources
in that domain. Resources with no sandbox boundary are collected
under the key ``"__unsandboxable__"``.
Args:
resources: Iterable of resources to partition.
resource_registry: Full resource registry for ancestor lookup.
Returns:
Domain mapping (boundary_id → resource list).
Raises:
ValueError: If *resources* or *resource_registry* is ``None``.
"""
if resources is None:
raise ValueError("resources cannot be None")
if resource_registry is None:
raise ValueError("resource_registry cannot be None")
domains: dict[str, list[Resource]] = {}
for res in resources:
boundary = sandbox_boundary(res, resource_registry)
key = boundary.resource_id if boundary is not None else "__unsandboxable__"
domains.setdefault(key, []).append(res)
return domains
class BoundaryCache:
"""Thread-safe cache for sandbox boundary lookups.
The resource DAG does not change during a plan execution, so boundary
results can be cached for the duration of a plan run. Call
:meth:`clear` at the start of each execution or when the DAG changes.
Thread-safe: all mutable state is protected by ``_lock``.
"""
def __init__(self) -> None:
self._cache: dict[str, str | None] = {}
self._lock: threading.RLock = threading.RLock()
def get_boundary(
self,
resource: Resource,
resource_registry: Mapping[str, Resource],
) -> Resource | None:
"""Return the sandbox boundary for *resource*, using the cache.
Args:
resource: The resource to look up.
resource_registry: Full resource registry for ancestor walk.
Returns:
The boundary resource, or ``None`` if unsandboxable.
Raises:
ValueError: If *resource* or *resource_registry* is ``None``.
"""
if resource is None:
raise ValueError("resource cannot be None")
if resource_registry is None:
raise ValueError("resource_registry cannot be None")
with self._lock:
if resource.resource_id in self._cache:
cached_id = self._cache[resource.resource_id]
if cached_id is None:
return None
return resource_registry.get(cached_id)
# Compute (outside lock to avoid holding it during DAG walk).
boundary = sandbox_boundary(resource, resource_registry)
boundary_id = boundary.resource_id if boundary is not None else None
with self._lock:
self._cache[resource.resource_id] = boundary_id
return boundary
def clear(self) -> None:
"""Clear all cached boundary lookups."""
with self._lock:
self._cache.clear()
@property
def size(self) -> int:
"""Return the number of cached entries."""
with self._lock:
return len(self._cache)
@@ -4,6 +4,10 @@ Manages the creation, tracking, and cleanup of sandbox instances across
plan executions. Thread-safe via an internal reentrant lock.
Stage B3.7 of the implementation plan.
Updated in M6 to support sandbox boundary algebra (#548):
- ``get_or_create_sandbox_for_resource`` keys by ``(plan_id, boundary_id)``
- ``resolve_sandbox_key`` computes the boundary key for a resource
- Boundary cache per plan execution
"""
from __future__ import annotations
@@ -12,8 +16,14 @@ import atexit
import contextlib
import logging
import threading
from collections.abc import Mapping
from datetime import datetime
from cleveragents.domain.models.core.resource import Resource
from cleveragents.infrastructure.sandbox.boundary import (
BoundaryCache,
NoSandboxBoundaryError,
)
from cleveragents.infrastructure.sandbox.factory import (
SandboxFactory,
SandboxStrategyStr,
@@ -74,6 +84,7 @@ class SandboxManager:
self._active_sandboxes: dict[str, dict[str, Sandbox]] = {}
self._lock: threading.RLock = threading.RLock()
self._cleanup_on_exit: bool = cleanup_on_exit
self._boundary_cache: BoundaryCache = BoundaryCache()
if cleanup_on_exit:
atexit.register(self._cleanup_on_exit_handler)
@@ -339,6 +350,109 @@ class SandboxManager:
return cleaned
# -- boundary-aware API (M6 #548) ----------------------------------------
def resolve_sandbox_key(
self,
resource: Resource,
resource_registry: Mapping[str, Resource],
) -> str:
"""Compute the sandbox key for a resource via boundary algebra.
The key is the ``resource_id`` of the resource's sandbox boundary.
If the resource itself is a boundary, its own ID is returned.
Args:
resource: The resource to resolve.
resource_registry: Full resource registry for DAG traversal.
Returns:
The ``resource_id`` of the sandbox boundary.
Raises:
ValueError: If *resource* or *resource_registry* is ``None``.
NoSandboxBoundaryError: If no sandbox boundary exists
in the resource's ancestor chain.
"""
if resource is None:
raise ValueError("resource cannot be None")
if resource_registry is None:
raise ValueError("resource_registry cannot be None")
boundary = self._boundary_cache.get_boundary(resource, resource_registry)
if boundary is None:
raise NoSandboxBoundaryError(
f"Resource {resource.resource_id} has no sandboxable ancestor"
)
return boundary.resource_id
def get_or_create_sandbox_for_resource(
self,
plan_id: str,
resource: Resource,
resource_registry: Mapping[str, Resource],
) -> Sandbox:
"""Return a sandbox for the resource, keyed by sandbox boundary.
Uses the boundary algebra to determine which sandbox boundary
governs this resource, then delegates to
:meth:`get_or_create_sandbox` with the boundary's ID and
properties. Multiple resources sharing the same boundary will
share the same sandbox instance.
Args:
plan_id: Identifier of the plan requesting the sandbox.
resource: The resource that needs sandboxing.
resource_registry: Full resource registry for DAG traversal.
Returns:
A :class:`Sandbox` instance for the resource's boundary.
Raises:
ValueError: If any required argument is empty/``None``.
NoSandboxBoundaryError: If no sandbox boundary exists.
SandboxError: If sandbox creation or initialisation fails.
"""
if not plan_id:
raise ValueError("plan_id cannot be empty")
if resource is None:
raise ValueError("resource cannot be None")
if resource_registry is None:
raise ValueError("resource_registry cannot be None")
boundary_id = self.resolve_sandbox_key(resource, resource_registry)
boundary_resource = resource_registry[boundary_id]
# Determine strategy from the boundary resource.
strategy: SandboxStrategyStr = "none"
if boundary_resource.sandbox_strategy is not None:
strategy = boundary_resource.sandbox_strategy # type: ignore[assignment]
# Determine location from the boundary resource.
original_path = boundary_resource.location or ""
if not original_path:
raise ValueError(f"Boundary resource {boundary_id} has no location")
return self.get_or_create_sandbox(
plan_id=plan_id,
resource_id=boundary_id,
original_path=original_path,
sandbox_strategy=strategy,
)
def clear_boundary_cache(self) -> None:
"""Clear the boundary cache.
Should be called at the start of each plan execution or when
the resource DAG is modified.
"""
self._boundary_cache.clear()
@property
def boundary_cache_size(self) -> int:
"""Return the number of cached boundary lookups."""
return self._boundary_cache.size
# -- internal ------------------------------------------------------------
def _cleanup_on_exit_handler(self) -> None:
+13
View File
@@ -708,3 +708,16 @@ LLM_TOTAL_COST_USD # noqa: B018, F821
LLM_AVG_LATENCY_MS # noqa: B018, F821
SUBPLAN_COUNT # noqa: B018, F821
_build_trace_service # noqa: B018, F821
# Sandbox boundary algebra — public API (M6, issue #548)
SandboxBoundaryError # noqa: B018, F821
NoSandboxBoundaryError # noqa: B018, F821
BoundaryCache # noqa: B018, F821
is_sandbox_boundary # noqa: B018, F821
sandbox_boundary # noqa: B018, F821
compute_sandbox_domains # noqa: B018, F821
resolve_sandbox_key # noqa: B018, F821
get_or_create_sandbox_for_resource # noqa: B018, F821
clear_boundary_cache # noqa: B018, F821
boundary_cache_size # noqa: B018, F821
_MAX_DAG_DEPTH # noqa: B018, F821