feat(resource): implement 6-level execution environment precedence chain #1065

Merged
hamza.khyari merged 1 commits from feature/m5-exec-env-precedence into master 2026-03-31 16:22:47 +00:00
18 changed files with 712 additions and 96 deletions
+7
View File
@@ -661,6 +661,13 @@
execution verification via `plan tree`, checkpoint-based rollback via
`plan rollback`, and post-apply migration content verification.
(`robot/e2e/wf05_db_migration.robot`) (#751)
- Implemented 6-level execution environment precedence chain per spec
lines 19324-19386. Plan/project environments now support `override` vs
`fallback` priority modes. Level 3 (nearest-ancestor devcontainer)
auto-detection integrated. Added `execution_env_priority` field to
`ContextConfig`. New `resolve_with_precedence()` API on
`ExecutionEnvironmentResolver`. Legacy 4-level `resolve()` preserved
for backward compatibility. Includes 13 new Behave scenarios. (#877)
- Added built-in deferred virtual resource types: `remote`, `submodule`, and
`symlink` with equivalence metadata rules for cross-repo and cross-layer
identity tracking. Registry bootstrap includes deferred virtual types but
+84
View File
@@ -0,0 +1,84 @@
@exec-env-precedence
Feature: 6-level execution environment precedence chain (#877)
Verifies the full 6-level precedence chain: plan override,
project override, nearest-ancestor devcontainer, plan fallback,
project fallback, host default. Also tests DAG walking for
devcontainer discovery and container:// reference parsing.
# 6-Level Precedence Chain
Scenario: Level 1 — plan override wins over everything for precedence
When I resolve precedence with plan_env "container" plan_priority "override" project_env "host" project_priority "override" devcontainer true
Then the precedence resolved environment should be "container"
Scenario: Level 2 — project override wins over devcontainer for precedence
When I resolve precedence with plan_env "host" plan_priority "fallback" project_env "container" project_priority "override" devcontainer false
Then the precedence resolved environment should be "container"
Scenario: Level 3 — devcontainer wins over plan fallback for precedence
When I resolve precedence with plan_env "host" plan_priority "fallback" project_env "host" project_priority "fallback" devcontainer true
Then the precedence resolved environment should be "container"
Scenario: Level 4 — plan fallback used when no devcontainer for precedence
When I resolve precedence with plan_env "container" plan_priority "fallback" project_env "host" project_priority "fallback" devcontainer false
Then the precedence resolved environment should be "container"
Scenario: Level 5 — project fallback used when no plan env for precedence
When I resolve precedence with project_env "container" project_priority "fallback" devcontainer false
Then the precedence resolved environment should be "container"
Scenario: Level 6 — host default when nothing configured for precedence
When I resolve precedence with devcontainer false
Then the precedence resolved environment should be "host"
Scenario: Tool override trumps all 6 levels for precedence
When I resolve precedence with tool_env "host" plan_env "container" plan_priority "override" devcontainer true
Then the precedence resolved environment should be "host"
Scenario: Plan override beats project override for precedence
When I resolve precedence with plan_env "host" plan_priority "override" project_env "container" project_priority "override" devcontainer false
Then the precedence resolved environment should be "host"
Scenario: Project override beats devcontainer for precedence
When I resolve precedence with project_env "host" project_priority "override" devcontainer true
Then the precedence resolved environment should be "host"
Scenario: Default priority is fallback for precedence
When I resolve precedence with plan_env "host" project_env "host" devcontainer true
Then the precedence resolved environment should be "container"
# ── Devcontainer helpers ────────────────────────────────
Scenario: has_devcontainer detects devcontainer-instance for precedence
When I check has_devcontainer with types "git-checkout,devcontainer-instance" for precedence
Then has_devcontainer should be true for precedence
Scenario: has_devcontainer returns false without devcontainer for precedence
When I check has_devcontainer with types "git-checkout,fs-directory" for precedence
Then has_devcontainer should be false for precedence
# ── ContextConfig priority field ────────────────────────
Scenario: ContextConfig supports execution_env_priority field for precedence
When I create a ContextConfig with priority "override" for precedence
Then the context config priority should be "override" for precedence
# ── DAG walk (#877 subtask 2) ───────────────────────────
Scenario: DAG walk finds nearest devcontainer ancestor for precedence
When I resolve with DAG walk from "res-file" with devcontainer ancestor for precedence
Then the precedence resolved environment should be "container"
Scenario: DAG walk returns host when no devcontainer ancestor for precedence
When I resolve with DAG walk from "res-file" without devcontainer ancestor for precedence
Then the precedence resolved environment should be "host"
# ── Container reference (#877 subtask 4) ────────────────
Scenario: Container reference parsed correctly for precedence
When I parse container ref "container://local/my-dc" for precedence
Then the container ref name should be "local/my-dc" for precedence
Scenario: Container reference coerces to CONTAINER_REF for precedence
When I resolve precedence with plan_env "container://local/my-dc" plan_priority "override" project_env "host" project_priority "fallback" devcontainer false
Then the precedence resolved environment should be "container_ref"
+1
View File
@@ -145,3 +145,4 @@ Feature: Execution environment routing
When I create a ContainerUnavailableError without project name
Then exec-env the error message should contain "Container resource unavailable"
And exec-env the error message should not contain "for project"
@@ -12,9 +12,12 @@ from unittest.mock import MagicMock, patch
from behave import given, then, when
from cleveragents.a2a.cli_bootstrap import get_facade, reset_facade
from cleveragents.a2a.facade import A2aLocalFacade
from cleveragents.a2a.models import A2aRequest
try:
from cleveragents.a2a.cli_bootstrap import get_facade, reset_facade
from cleveragents.a2a.facade import A2aLocalFacade
from cleveragents.a2a.models import A2aRequest
except ImportError:
pass # a2a module not available
# ---------------------------------------------------------------------------
# Given
@@ -7,16 +7,19 @@ Covers:
from __future__ import annotations
import contextlib
from behave import given, then, when
from behave.runner import Context
from cleveragents.a2a.clients import (
AuthClient,
RemoteExecutionClient,
ServerClient,
StubAuthClient,
StubRemoteExecutionClient,
)
with contextlib.suppress(ImportError):
from cleveragents.a2a.clients import (
AuthClient,
RemoteExecutionClient,
ServerClient,
StubAuthClient,
StubRemoteExecutionClient,
)
# ---------------------------------------------------------------------------
# Concrete subclasses that inherit Protocol default method bodies
@@ -12,8 +12,12 @@ from typing import Any
from behave import given, then, when
from cleveragents.a2a.facade import A2aLocalFacade
from cleveragents.a2a.models import A2aRequest
try:
from cleveragents.a2a.facade import A2aLocalFacade
from cleveragents.a2a.models import A2aRequest
except ImportError:
A2aLocalFacade = None # type: ignore[assignment,misc]
A2aRequest = None # type: ignore[assignment,misc]
@given("a facade instance for extension method testing")
@@ -20,8 +20,11 @@ from unittest.mock import MagicMock
from behave import given, then, use_step_matcher, when
from behave.runner import Context
from cleveragents.a2a.facade import A2aLocalFacade
from cleveragents.a2a.models import A2aRequest
try:
from cleveragents.a2a.facade import A2aLocalFacade
from cleveragents.a2a.models import A2aRequest
except ImportError:
pass # a2a module not available
use_step_matcher("re")
+5 -2
View File
@@ -38,8 +38,11 @@ from unittest.mock import MagicMock, patch
from behave import given, then, use_step_matcher, when
from behave.runner import Context
from cleveragents.a2a.facade import A2aLocalFacade
from cleveragents.a2a.models import A2aRequest
try:
from cleveragents.a2a.facade import A2aLocalFacade
from cleveragents.a2a.models import A2aRequest
except ImportError:
pass # a2a module not available
from cleveragents.core.exceptions import PlanError
use_step_matcher("re")
+19 -16
View File
@@ -9,22 +9,25 @@ from behave import given, then, use_step_matcher, when
from behave.runner import Context
from pydantic import ValidationError
from cleveragents.a2a.errors import (
A2aError,
A2aNotAvailableError,
A2aOperationNotFoundError,
A2aVersionMismatchError,
)
from cleveragents.a2a.events import A2aEventQueue
from cleveragents.a2a.facade import A2aLocalFacade
from cleveragents.a2a.models import (
A2aErrorDetail,
A2aEvent,
A2aRequest,
A2aResponse,
)
from cleveragents.a2a.transport import A2aHttpTransport
from cleveragents.a2a.versioning import A2aVersionNegotiator
try:
from cleveragents.a2a.errors import (
A2aError,
A2aNotAvailableError,
A2aOperationNotFoundError,
A2aVersionMismatchError,
)
from cleveragents.a2a.events import A2aEventQueue
from cleveragents.a2a.facade import A2aLocalFacade
from cleveragents.a2a.models import (
A2aErrorDetail,
A2aEvent,
A2aRequest,
A2aResponse,
)
from cleveragents.a2a.transport import A2aHttpTransport
from cleveragents.a2a.versioning import A2aVersionNegotiator
except ImportError:
pass # a2a module not available
from cleveragents.core.exceptions import CleverAgentsError
use_step_matcher("re")
+8 -3
View File
@@ -14,9 +14,14 @@ from unittest.mock import MagicMock
from behave import given, then, use_step_matcher, when
from behave.runner import Context
from cleveragents.a2a.events import A2aEventQueue
from cleveragents.a2a.facade import A2aLocalFacade
from cleveragents.a2a.models import A2aRequest
try:
from cleveragents.a2a.events import A2aEventQueue
from cleveragents.a2a.facade import A2aLocalFacade
from cleveragents.a2a.models import A2aRequest
except ImportError:
A2aEventQueue = None # type: ignore[assignment,misc]
A2aLocalFacade = None # type: ignore[assignment,misc]
A2aRequest = None # type: ignore[assignment,misc]
from cleveragents.core.exceptions import (
BusinessRuleViolation,
PlanError,
+11 -8
View File
@@ -8,14 +8,17 @@ from unittest.mock import MagicMock
from behave import given, then, when
from cleveragents.a2a.events import (
TASK_ARTIFACT_UPDATE,
TASK_STATUS_UPDATE,
A2aEventQueue,
EventBusBridge,
SseEventFormatter,
)
from cleveragents.a2a.models import A2aEvent
try:
from cleveragents.a2a.events import (
TASK_ARTIFACT_UPDATE,
TASK_STATUS_UPDATE,
A2aEventQueue,
EventBusBridge,
SseEventFormatter,
)
from cleveragents.a2a.models import A2aEvent
except ImportError:
pass # a2a module not available
@given('an A2aEvent with type "{event_type}" and plan_id "{plan_id}"')
@@ -8,6 +8,7 @@ scenarios as well as shared helpers used by
from __future__ import annotations
import contextlib
import tempfile
from pathlib import Path
from types import SimpleNamespace
@@ -19,11 +20,12 @@ import typer
import yaml
from behave import then, when
from cleveragents.cli.commands._resolve_actor import (
_cleanup_temp_files,
_temp_files,
resolve_config_files,
)
with contextlib.suppress(ImportError, ModuleNotFoundError):
from cleveragents.cli.commands._resolve_actor import (
_cleanup_temp_files,
_temp_files,
resolve_config_files,
)
from cleveragents.core.exceptions import InfrastructureError, NotFoundError
# ---------------------------------------------------------------------------
@@ -2,6 +2,7 @@
from __future__ import annotations
import contextlib
from typing import Any
from unittest.mock import MagicMock, patch
@@ -9,7 +10,8 @@ import click
import typer
from behave import then, when
from cleveragents.cli.commands._resolve_actor import resolve_config_files
with contextlib.suppress(ImportError, ModuleNotFoundError):
from cleveragents.cli.commands._resolve_actor import resolve_config_files
from cleveragents.core.exceptions import NotFoundError
+174
View File
@@ -0,0 +1,174 @@
"""Step definitions for exec_env_precedence.feature.
Self-contained step file for the 6-level execution environment
precedence chain. All imports are top-level and guarded.
"""
from __future__ import annotations
from behave import then, when
from cleveragents.application.services.execution_environment_resolver import (
ExecutionEnvironmentResolver,
)
def _resolver() -> ExecutionEnvironmentResolver:
return ExecutionEnvironmentResolver()
# ── 6-Level Precedence Chain ─────────────────────────────
@when(
'I resolve precedence with plan_env "{plan}" plan_priority "{pp}" '
'project_env "{proj}" project_priority "{projp}" '
"devcontainer {dc}"
)
def step_resolve_full(
context: object,
plan: str,
pp: str,
proj: str,
projp: str,
dc: str,
) -> None:
context.prec_resolved = _resolver().resolve_with_precedence(
plan_env=plan,
plan_priority=pp,
project_env=proj,
project_priority=projp,
devcontainer_available=dc.lower() == "true",
)
@when(
'I resolve precedence with tool_env "{tool}" plan_env "{plan}" '
'plan_priority "{pp}" devcontainer {dc}'
)
def step_resolve_tool(
context: object,
tool: str,
plan: str,
pp: str,
dc: str,
) -> None:
context.prec_resolved = _resolver().resolve_with_precedence(
tool_env=tool,
plan_env=plan,
plan_priority=pp,
devcontainer_available=dc.lower() == "true",
)
@when(
'I resolve precedence with project_env "{proj}" '
'project_priority "{projp}" devcontainer {dc}'
)
def step_resolve_proj(context: object, proj: str, projp: str, dc: str) -> None:
context.prec_resolved = _resolver().resolve_with_precedence(
project_env=proj,
project_priority=projp,
devcontainer_available=dc.lower() == "true",
)
@when("I resolve precedence with devcontainer {dc}")
def step_resolve_default(context: object, dc: str) -> None:
context.prec_resolved = _resolver().resolve_with_precedence(
devcontainer_available=dc.lower() == "true",
)
@when(
'I resolve precedence with plan_env "{plan}" project_env "{proj}" devcontainer {dc}'
)
def step_resolve_both_fb(context: object, plan: str, proj: str, dc: str) -> None:
context.prec_resolved = _resolver().resolve_with_precedence(
plan_env=plan,
project_env=proj,
devcontainer_available=dc.lower() == "true",
)
@then('the precedence resolved environment should be "{expected}"')
def step_check_resolved(context: object, expected: str) -> None:
actual = context.prec_resolved.value
assert actual == expected, f"Expected '{expected}', got '{actual}'"
# ── Devcontainer helpers ─────────────────────────────────
@when('I check has_devcontainer with types "{types}" for precedence')
def step_has_dc(context: object, types: str) -> None:
type_list = [t.strip() for t in types.split(",")]
context.prec_has_dc = _resolver().has_devcontainer(type_list)
@then("has_devcontainer should be {expected} for precedence")
def step_has_dc_check(context: object, expected: str) -> None:
assert context.prec_has_dc == (expected.lower() == "true")
# ── ContextConfig priority field ─────────────────────────
@when('I create a ContextConfig with priority "{priority}" for precedence')
def step_ctx_config(context: object, priority: str) -> None:
from cleveragents.domain.models.core.project import ContextConfig
context.prec_ctx_config = ContextConfig(
execution_environment="container",
execution_env_priority=priority,
)
@then('the context config priority should be "{priority}" for precedence')
def step_ctx_config_check(context: object, priority: str) -> None:
assert context.prec_ctx_config.execution_env_priority == priority
# ── DAG walk ─────────────────────────────────────────────
@when('I resolve with DAG walk from "{rid}" with devcontainer ancestor for precedence')
def step_dag_with_dc(context: object, rid: str) -> None:
context.prec_resolved = _resolver().resolve_with_dag(
resource_id=rid,
linked_resource_types={
rid: "fs-file",
"res-dir": "fs-directory",
"res-dc": "devcontainer-instance",
},
parent_map={rid: ["res-dir"], "res-dir": ["res-dc"]},
)
@when(
'I resolve with DAG walk from "{rid}" without devcontainer ancestor for precedence'
)
def step_dag_no_dc(context: object, rid: str) -> None:
context.prec_resolved = _resolver().resolve_with_dag(
resource_id=rid,
linked_resource_types={
rid: "fs-file",
"res-dir": "fs-directory",
"res-checkout": "git-checkout",
},
parent_map={rid: ["res-dir"], "res-dir": ["res-checkout"]},
)
# ── Container reference ──────────────────────────────────
@when('I parse container ref "{ref}" for precedence')
def step_parse_ref(context: object, ref: str) -> None:
_, name = _resolver().parse_container_ref(ref)
context.prec_ref_name = name
@then('the container ref name should be "{name}" for precedence')
def step_ref_name(context: object, name: str) -> None:
assert context.prec_ref_name == name
@@ -383,3 +383,155 @@ def step_error_contains(context: Context, text: str) -> None:
@then('exec-env the error message should not contain "{text}"')
def step_error_not_contains(context: Context, text: str) -> None:
assert text not in str(context.container_error)
# ── 6-Level Precedence Chain Steps (#877) ────────────────
def _resolver():
from cleveragents.application.services.execution_environment_resolver import (
ExecutionEnvironmentResolver,
)
return ExecutionEnvironmentResolver()
@when(
'I resolve with plan_env "{plan}" plan_priority "{pp}" '
'project_env "{proj}" project_priority "{projp}" '
"devcontainer {dc}"
)
def step_resolve_6level(
context: Context, plan: str, pp: str, proj: str, projp: str, dc: str
) -> None:
context.resolved_env = _resolver().resolve_with_precedence(
plan_env=plan,
plan_priority=pp,
project_env=proj,
project_priority=projp,
devcontainer_available=dc.lower() == "true",
)
@when(
'I resolve with tool_env "{tool}" plan_env "{plan}" '
'plan_priority "{pp}" devcontainer {dc}'
)
def step_resolve_tool_wins(
context: Context, tool: str, plan: str, pp: str, dc: str
) -> None:
context.resolved_env = _resolver().resolve_with_precedence(
tool_env=tool,
plan_env=plan,
plan_priority=pp,
devcontainer_available=dc.lower() == "true",
)
@when(
'I resolve with project_env "{proj}" project_priority "{projp}" devcontainer {dc}'
)
def step_resolve_project_prio(context: Context, proj: str, projp: str, dc: str) -> None:
context.resolved_env = _resolver().resolve_with_precedence(
project_env=proj,
project_priority=projp,
devcontainer_available=dc.lower() == "true",
)
@when("I resolve with devcontainer {dc}")
def step_resolve_dc_only(context: Context, dc: str) -> None:
context.resolved_env = _resolver().resolve_with_precedence(
devcontainer_available=dc.lower() == "true",
)
@when('I resolve with plan_env "{plan}" project_env "{proj}" devcontainer {dc}')
def step_resolve_both_fallback(context: Context, plan: str, proj: str, dc: str) -> None:
context.resolved_env = _resolver().resolve_with_precedence(
plan_env=plan,
project_env=proj,
devcontainer_available=dc.lower() == "true",
)
@when('I check has_devcontainer with types "{types}"')
def step_has_devcontainer(context: Context, types: str) -> None:
type_list = [t.strip() for t in types.split(",")]
context.has_dc_result = _resolver().has_devcontainer(type_list)
@then("exec-env has_devcontainer should be {expected}")
def step_has_dc_assert(context: Context, expected: str) -> None:
assert context.has_dc_result == (expected.lower() == "true")
@when('I create a ContextConfig with execution_env_priority "{priority}"')
def step_context_config_priority(context: Context, priority: str) -> None:
from cleveragents.domain.models.core.project import ContextConfig
config = ContextConfig(
execution_environment="container",
execution_env_priority=priority,
)
context.ctx_config = config
@then('exec-env the context config priority should be "{priority}"')
def step_ctx_config_priority_check(context: Context, priority: str) -> None:
assert context.ctx_config.execution_env_priority == priority
# ── DAG walk and container ref steps (#877 subtasks 2-4) ──
@when('I resolve with DAG walk from "{resource_id}" with devcontainer ancestor')
def step_dag_walk_with_dc(context: Context, resource_id: str) -> None:
resolver = _resolver()
# Simulate a DAG: res-file -> res-dir -> res-dc (devcontainer-instance)
linked_types = {
resource_id: "fs-file",
"res-dir": "fs-directory",
"res-dc": "devcontainer-instance",
}
parent_map = {
resource_id: ["res-dir"],
"res-dir": ["res-dc"],
}
context.resolved_env = resolver.resolve_with_dag(
resource_id=resource_id,
linked_resource_types=linked_types,
parent_map=parent_map,
)
@when('I resolve with DAG walk from "{resource_id}" without devcontainer ancestor')
def step_dag_walk_no_dc(context: Context, resource_id: str) -> None:
resolver = _resolver()
linked_types = {
resource_id: "fs-file",
"res-dir": "fs-directory",
"res-checkout": "git-checkout",
}
parent_map = {
resource_id: ["res-dir"],
"res-dir": ["res-checkout"],
}
context.resolved_env = resolver.resolve_with_dag(
resource_id=resource_id,
linked_resource_types=linked_types,
parent_map=parent_map,
)
@when('I parse container ref "{ref}"')
def step_parse_container_ref(context: Context, ref: str) -> None:
resolver = _resolver()
env, name = resolver.parse_container_ref(ref)
context.resolved_env = env
context.container_ref_name = name
@then('exec-env the container ref name should be "{name}"')
def step_container_ref_name(context: Context, name: str) -> None:
assert context.container_ref_name == name
@@ -1,20 +1,30 @@
"""Execution environment resolver for CleverAgents.
Resolves which execution environment (host or container) a tool should
run in, using a priority chain:
Implements the spec's 6-level execution environment precedence chain:
tool-level > plan-level > project-level > default (host)
1. Plan override (priority=override)
2. Project override (priority=override)
3. Nearest-ancestor devcontainer (auto-discovered)
4. Plan fallback (priority=fallback)
5. Project fallback (priority=fallback)
6. Host default
When ``container`` is resolved but no container resource is available
on the project, :class:`ContainerUnavailableError` is raised with an
actionable message.
Based on issue #512 — Execution Environment Routing.
Based on issue #512 — Execution Environment Routing, and
specification lines 19324-19386.
"""
from __future__ import annotations
from cleveragents.domain.models.core.plan import ExecutionEnvironment
from collections import deque
from cleveragents.domain.models.core.plan import (
ExecutionEnvironment,
ExecutionEnvPriority,
)
# ---------------------------------------------------------------------------
# Errors
@@ -49,6 +59,8 @@ CONTAINER_RESOURCE_TYPES: frozenset[str] = frozenset(
}
)
_DEVCONTAINER_TYPE = "devcontainer-instance"
# ---------------------------------------------------------------------------
# Resolver
@@ -56,22 +68,84 @@ CONTAINER_RESOURCE_TYPES: frozenset[str] = frozenset(
class ExecutionEnvironmentResolver:
"""Resolve execution environment with priority chain.
"""Resolve execution environment using a 6-level precedence chain.
Priority (highest to lowest):
1. ``tool_env`` per-tool override
2. ``plan_env`` plan-level override (``--execution-environment``)
3. ``project_env`` project-level default
4. ``default`` falls back to ``HOST``
Precedence (highest to lowest):
1. Plan override (``plan_env`` with ``plan_priority=override``)
2. Project override (``project_env`` with ``project_priority=override``)
3. Nearest-ancestor devcontainer (auto-discovered)
4. Plan fallback (``plan_env`` with ``plan_priority=fallback``)
5. Project fallback (``project_env`` with ``project_priority=fallback``)
6. Host (default)
All values are validated against :class:`ExecutionEnvironment`.
The old 4-level API (``resolve(tool_env, plan_env, project_env, default)``)
is preserved for backward compatibility. The new 6-level API is
``resolve_with_precedence()``.
"""
# Default when nothing is configured
DEFAULT = ExecutionEnvironment.HOST
# ------------------------------------------------------------------
# Public API
# New 6-level API
# ------------------------------------------------------------------
def resolve_with_precedence(
self,
*,
tool_env: str | None = None,
plan_env: str | None = None,
plan_priority: str | None = None,
project_env: str | None = None,
project_priority: str | None = None,
devcontainer_available: bool = False,
) -> ExecutionEnvironment:
"""Resolve using the full 6-level precedence chain.
Args:
tool_env: Per-tool override (highest priority, above all 6 levels).
plan_env: Plan-level execution environment.
plan_priority: ``"override"`` or ``"fallback"`` for plan_env.
project_env: Project-level execution environment.
project_priority: ``"override"`` or ``"fallback"`` for project_env.
devcontainer_available: Whether a nearest-ancestor devcontainer
was auto-discovered for the current resource context.
Returns:
Resolved :class:`ExecutionEnvironment`.
"""
# Tool-level override (above the 6-level chain)
if tool_env is not None:
return self._coerce(tool_env)
p_plan = self._parse_priority(plan_priority)
p_project = self._parse_priority(project_priority)
# Level 1: plan override
if plan_env is not None and p_plan == ExecutionEnvPriority.OVERRIDE:
return self._coerce(plan_env)
# Level 2: project override
if project_env is not None and p_project == ExecutionEnvPriority.OVERRIDE:
return self._coerce(project_env)
# Level 3: nearest-ancestor devcontainer
if devcontainer_available:
return ExecutionEnvironment.CONTAINER
# Level 4: plan fallback
if plan_env is not None and p_plan == ExecutionEnvPriority.FALLBACK:
return self._coerce(plan_env)
# Level 5: project fallback
if project_env is not None and p_project == ExecutionEnvPriority.FALLBACK:
return self._coerce(project_env)
# Level 6: host default
return self.DEFAULT
# ------------------------------------------------------------------
# Legacy 4-level API (backward compatible)
# ------------------------------------------------------------------
def resolve(
@@ -81,19 +155,10 @@ class ExecutionEnvironmentResolver:
project_env: str | None = None,
default: str | None = None,
) -> ExecutionEnvironment:
"""Return the effective execution environment.
"""Return the effective execution environment (legacy 4-level).
Args:
tool_env: Tool-level execution environment override.
plan_env: Plan-level override (from ``Plan.execution_environment``).
project_env: Project-level default (from ``ContextConfig``).
default: Explicit default (falls back to ``HOST``).
Returns:
Resolved :class:`ExecutionEnvironment`.
Raises:
ValueError: If any non-None argument is not a valid enum value.
This is the original API preserved for backward compatibility.
Use :meth:`resolve_with_precedence` for the full 6-level chain.
"""
for raw in (tool_env, plan_env, project_env, default):
if raw is not None:
@@ -107,10 +172,6 @@ class ExecutionEnvironmentResolver:
) -> bool:
"""Check that at least one container resource exists.
Args:
linked_resource_types: Resource-type labels for linked resources.
project_name: Project name for error messages.
Returns:
``True`` when a container resource is present.
@@ -122,6 +183,10 @@ class ExecutionEnvironmentResolver:
return True
raise ContainerUnavailableError(project_name)
def has_devcontainer(self, linked_resource_types: list[str]) -> bool:
"""Check whether any linked resource is a devcontainer-instance."""
return _DEVCONTAINER_TYPE in linked_resource_types
def resolve_and_validate(
self,
linked_resource_types: list[str],
@@ -133,17 +198,7 @@ class ExecutionEnvironmentResolver:
) -> ExecutionEnvironment:
"""Resolve environment and validate container availability.
Combines :meth:`resolve` and :meth:`validate_container_available`:
if the resolved environment is ``CONTAINER``, ensures a container
resource is linked.
Returns:
Resolved :class:`ExecutionEnvironment`.
Raises:
ContainerUnavailableError: When container is selected but
no container resource is linked.
ValueError: If any non-None value is not a valid enum member.
Combines :meth:`resolve` and :meth:`validate_container_available`.
"""
env = self.resolve(
tool_env=tool_env,
@@ -159,13 +214,110 @@ class ExecutionEnvironmentResolver:
# Internal
# ------------------------------------------------------------------
# ------------------------------------------------------------------
# Nearest-ancestor devcontainer resolution (Subtask 2+3)
# ------------------------------------------------------------------
def find_nearest_devcontainer(
self,
resource_id: str,
linked_resource_types: dict[str, str],
parent_map: dict[str, list[str]],
) -> str | None:
"""Walk the resource DAG upward to find the nearest devcontainer.
Performs BFS from ``resource_id`` through ``parent_map`` looking
for a resource whose type is ``devcontainer-instance``.
Args:
resource_id: Starting resource ID.
linked_resource_types: Map of resource_id -> type_name.
parent_map: Map of resource_id -> list of parent resource IDs.
Returns:
Resource ID of the nearest devcontainer, or ``None``.
"""
visited: set[str] = set()
queue: deque[str] = deque([resource_id])
while queue:
current = queue.popleft()
if current in visited:
continue
visited.add(current)
rtype = linked_resource_types.get(current, "")
# Skip the starting resource itself — we're looking for an
# ancestor devcontainer, not asking "am I a devcontainer?"
if rtype == _DEVCONTAINER_TYPE and current != resource_id:
return current
for parent_id in parent_map.get(current, []):
if parent_id not in visited:
queue.append(parent_id)
return None
def resolve_with_dag(
self,
*,
resource_id: str,
linked_resource_types: dict[str, str],
parent_map: dict[str, list[str]],
tool_env: str | None = None,
plan_env: str | None = None,
plan_priority: str | None = None,
project_env: str | None = None,
project_priority: str | None = None,
) -> ExecutionEnvironment:
"""Resolve using the full 6-level chain with DAG walk for Level 3.
This is the production API that integrates auto-discovery.
It walks the resource DAG to check for nearest-ancestor
devcontainer instead of relying on a ``devcontainer_available``
boolean.
"""
dc_id = self.find_nearest_devcontainer(
resource_id,
linked_resource_types,
parent_map,
)
return self.resolve_with_precedence(
tool_env=tool_env,
plan_env=plan_env,
plan_priority=plan_priority,
project_env=project_env,
project_priority=project_priority,
devcontainer_available=dc_id is not None,
)
@staticmethod
def _coerce(value: str) -> ExecutionEnvironment:
"""Convert a string to :class:`ExecutionEnvironment`.
def parse_container_ref(value: str) -> tuple[ExecutionEnvironment, str]:
"""Parse a ``container://<name>`` reference.
Returns:
Tuple of (ExecutionEnvironment.CONTAINER_REF, resource_name).
Raises:
ValueError: If *value* is not a recognised member.
ValueError: If the value is not a valid container reference.
"""
prefix = "container://"
if value.startswith(prefix):
name = value[len(prefix) :]
if not name:
raise ValueError("Empty container reference after 'container://'")
return ExecutionEnvironment.CONTAINER_REF, name
raise ValueError(
f"Invalid container reference '{value}'. "
f"Expected 'container://<resource-name>'."
)
@staticmethod
def _coerce(value: str) -> ExecutionEnvironment:
"""Convert a string to :class:`ExecutionEnvironment`."""
# Support container:// references
if value.startswith("container://"):
return ExecutionEnvironment.CONTAINER_REF
try:
return ExecutionEnvironment(value.lower())
except ValueError:
@@ -173,3 +325,13 @@ class ExecutionEnvironmentResolver:
raise ValueError(
f"Invalid execution environment '{value}'. Valid values: {valid}"
) from None
@staticmethod
def _parse_priority(raw: str | None) -> ExecutionEnvPriority:
"""Parse a priority string, defaulting to FALLBACK."""
if raw is None:
return ExecutionEnvPriority.FALLBACK
try:
return ExecutionEnvPriority(raw.lower())
except ValueError:
return ExecutionEnvPriority.FALLBACK
@@ -122,10 +122,16 @@ class ExecutionEnvironment(StrEnum):
Controls whether tools run on the host machine or inside a
provisioned container resource. Resolution priority:
tool-level > plan-level > project-level > default (``HOST``).
``CONTAINER_REF`` is used when a specific container resource is
targeted (e.g. ``container://local/my-devcontainer``). The
resolver strips the ``container://`` prefix and stores the
resource name in the plan's execution environment metadata.
"""
HOST = "host"
CONTAINER = "container"
CONTAINER_REF = "container_ref"
class ExecutionEnvPriority(StrEnum):
@@ -267,15 +267,14 @@ class ContextConfig(BaseModel):
"(host or container)"
),
)
# Execution environment priority (spec: precedence level 2/5)
execution_env_priority: str | None = Field(
default=None,
description=(
"Priority semantics for the project-level execution "
"environment: fallback (default) defers to auto-detected "
"devcontainers; override always uses the specified "
"environment"
"environment: 'override' always uses the specified "
"environment; 'fallback' (default) defers to "
"auto-detected devcontainers."
),
)