From 57741f6b4e50310e6eb73165a87f72622e31d033 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sat, 27 Jun 2026 12:48:38 -0700 Subject: [PATCH 1/6] feat: add vacuous-gate detection and close fleet dispatch infeasibility bypass A capability gate whose guarded operations were all pruned by skip_when_false is vacuous and should not block dispatch. _compute_capability_feasibility now receives skip_resolutions and pre_prune_steps from load_and_validate, enabling it to distinguish gates that protect live steps from gates that guard nothing. dispatch_food_truck and _run_dispatch now inject _build_capability_overrides into load_and_validate's ingredient_overrides and check dispatch_feasible before proceeding, closing the bypass where capability-DOA recipes could still spawn subprocesses. Arch tests verify all four content-serving surfaces gate on dispatch_feasible, load_and_validate passes skip_resolutions, and dispatch_food_truck injects capability overrides. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/autoskillit/core/__init__.pyi | 1 + src/autoskillit/core/types/_type_constants.py | 8 ++ src/autoskillit/fleet/__init__.py | 2 + src/autoskillit/fleet/_api.py | 16 +++ src/autoskillit/recipe/_api.py | 2 + src/autoskillit/recipe/_recipe_composition.py | 56 ++++++++- .../server/tools/tools_fleet_dispatch.py | 18 ++- .../arch/test_capability_admission_control.py | 71 ++++++++++- .../test_dispatch_ingredient_validation.py | 45 +++++++ .../test_bundled_recipes_dispatch_ready.py | 43 +++++-- .../test_open_kitchen_deferred_recall.py | 36 ++++++ .../test_tools_fleet_dispatch_preflight.py | 116 ++++++++++++++++++ 12 files changed, 397 insertions(+), 17 deletions(-) diff --git a/src/autoskillit/core/__init__.pyi b/src/autoskillit/core/__init__.pyi index 6cac18ecf5..3c264d2473 100644 --- a/src/autoskillit/core/__init__.pyi +++ b/src/autoskillit/core/__init__.pyi @@ -134,6 +134,7 @@ from .types import AUTOSKILLIT_WRITE_GUARD_TOOL_NAMES as AUTOSKILLIT_WRITE_GUARD from .types import BACKEND_CAPABILITY_INGREDIENTS as BACKEND_CAPABILITY_INGREDIENTS from .types import CAMPAIGN_ID_ENV_VAR as CAMPAIGN_ID_ENV_VAR from .types import CAPABILITY_GATE_CALLABLES as CAPABILITY_GATE_CALLABLES +from .types import CAPABILITY_INGREDIENT_TO_SKIP_GUARD as CAPABILITY_INGREDIENT_TO_SKIP_GUARD from .types import CAPTURE_VALID_VALUE_TYPES as CAPTURE_VALID_VALUE_TYPES from .types import CATEGORY_TAGS as CATEGORY_TAGS from .types import CLAUDE_CODE_CAPABILITIES as CLAUDE_CODE_CAPABILITIES diff --git a/src/autoskillit/core/types/_type_constants.py b/src/autoskillit/core/types/_type_constants.py index f35a7b56fb..0d3fe447f1 100644 --- a/src/autoskillit/core/types/_type_constants.py +++ b/src/autoskillit/core/types/_type_constants.py @@ -34,6 +34,7 @@ "SCOPE_DIRECTION_SOURCE_TYPES", "WORKTREE_SKILLS", "BACKEND_CAPABILITY_INGREDIENTS", + "CAPABILITY_INGREDIENT_TO_SKIP_GUARD", "CAPABILITY_GATE_CALLABLES", "SkillFamilyDef", "GITHUB_API_SKILL_FAMILIES", @@ -97,6 +98,13 @@ } ) +# Maps each BACKEND_CAPABILITY_INGREDIENTS key to its corresponding +# skip_when_false ingredient reference string. Used by capability-feasibility +# to detect vacuous gates — a gate whose guarded steps were all pruned. +CAPABILITY_INGREDIENT_TO_SKIP_GUARD: dict[str, str] = { + "backend_supports_git_write": "inputs.backend_supports_git_write", +} + # Bare (un-dotted) callable names whose run_python steps are capability gates. # Each entry corresponds to a callable in smoke_utils that reads a # BACKEND_CAPABILITY_INGREDIENTS key and returns a verdict dict. When a diff --git a/src/autoskillit/fleet/__init__.py b/src/autoskillit/fleet/__init__.py index b883fc115f..6f741b8f91 100644 --- a/src/autoskillit/fleet/__init__.py +++ b/src/autoskillit/fleet/__init__.py @@ -4,6 +4,7 @@ ``autoskillit.fleet``, not from sub-modules. """ +from ._api import _build_capability_overrides as _build_capability_overrides from ._api import _write_pid as _write_pid from ._api import execute_dispatch from ._capture import CaptureCompletenessError @@ -101,6 +102,7 @@ ) __all__ = [ + "_build_capability_overrides", "_write_pid", "cleanup_orphaned_labels", "discover_campaign_state_files", diff --git a/src/autoskillit/fleet/_api.py b/src/autoskillit/fleet/_api.py index e7521643e2..15a422dd9c 100644 --- a/src/autoskillit/fleet/_api.py +++ b/src/autoskillit/fleet/_api.py @@ -347,10 +347,13 @@ async def _run_dispatch( ) try: + _capability_overrides = _build_capability_overrides(tool_ctx.backend) + _merged_ingredients = {**(ingredients or {}), **_capability_overrides} validation_result = tool_ctx.recipes.load_and_validate( recipe, tool_ctx.project_dir, suppressed=tool_ctx.config.migration.suppressed if tool_ctx.config else None, + ingredient_overrides=_merged_ingredients, temp_dir=tool_ctx.temp_dir, backend_name=tool_ctx.backend.name if tool_ctx.backend else None, ) @@ -388,6 +391,19 @@ async def _run_dispatch( per_dispatch_state_path=None, ) + if not validation_result.get("dispatch_feasible", True): + infeasible_steps = validation_result.get("infeasible_steps", []) + return DispatchResult( + DispatchRejected( + error_code=FleetErrorCode.FLEET_RECIPE_INVALID, + message=( + f"Recipe '{recipe}' is dispatch-infeasible: " + f"infeasible_steps={infeasible_steps}" + ), + ), + per_dispatch_state_path=None, + ) + try: full_recipe = tool_ctx.recipes.load(recipe_obj.path) except Exception as exc: diff --git a/src/autoskillit/recipe/_api.py b/src/autoskillit/recipe/_api.py index cbdbdc25cb..da34f69846 100644 --- a/src/autoskillit/recipe/_api.py +++ b/src/autoskillit/recipe/_api.py @@ -407,6 +407,8 @@ def load_and_validate( ingredient_overrides or {}, capability_ingredient_keys=BACKEND_CAPABILITY_INGREDIENTS, capability_gate_callables=CAPABILITY_GATE_CALLABLES, + skip_resolutions=_skip_resolutions, + pre_prune_steps=_pre_prune_steps, ) t0 = _t("capability_feasibility", t0, name) diff --git a/src/autoskillit/recipe/_recipe_composition.py b/src/autoskillit/recipe/_recipe_composition.py index 22b37627ae..c2dcce87a0 100644 --- a/src/autoskillit/recipe/_recipe_composition.py +++ b/src/autoskillit/recipe/_recipe_composition.py @@ -10,6 +10,7 @@ import regex as re from autoskillit.core import YAMLError, load_yaml +from autoskillit.core.types import CAPABILITY_INGREDIENT_TO_SKIP_GUARD from autoskillit.recipe._contracts_types import INPUT_REF_RE from autoskillit.recipe.io import find_sub_recipe_by_name from autoskillit.recipe.io import load_recipe as _load_recipe_from_path @@ -112,6 +113,8 @@ def _compute_capability_feasibility( *, capability_ingredient_keys: frozenset[str], capability_gate_callables: frozenset[str], + skip_resolutions: dict[str, bool | None] | None = None, + pre_prune_steps: dict[str, Any] | None = None, ) -> tuple[bool, list[str]]: """Detect dead-on-arrival pipelines caused by capability-gated run_python steps. @@ -124,6 +127,10 @@ def _compute_capability_feasibility( 3. The step's on_result / on_failure routes the falsy case to a terminal failure target ("escalate") rather than recovery. + A gate is vacuous (and removed from infeasible) when all steps gated by + the same capability ingredient were pruned by skip_when_false and no + surviving step references that capability. + Returns (is_feasible, infeasible_step_names). """ if not post_prune_recipe or not getattr(post_prune_recipe, "steps", None): @@ -154,12 +161,57 @@ def _compute_capability_feasibility( continue on_failure = getattr(step, "on_failure", None) or "escalate" on_exhausted = getattr(step, "on_exhausted", None) or "escalate" - if on_failure == "escalate" or on_exhausted == "escalate": - infeasible.append(step_name) + if on_failure != "escalate" and on_exhausted != "escalate": + continue + if _is_vacuous_gate( + gate_input_keys, + skip_resolutions=skip_resolutions, + pre_prune_steps=pre_prune_steps, + post_prune_steps=steps, + ): + continue + infeasible.append(step_name) return (not infeasible), infeasible +def _is_vacuous_gate( + gate_input_keys: set[str], + *, + skip_resolutions: dict[str, bool | None] | None, + pre_prune_steps: dict[str, Any] | None, + post_prune_steps: dict[str, Any], +) -> bool: + """Return True if the gate is vacuous — its guarded operations were all pruned.""" + if skip_resolutions is None or pre_prune_steps is None: + return False + for cap_key in gate_input_keys: + guard_ref = CAPABILITY_INGREDIENT_TO_SKIP_GUARD.get(cap_key) + if guard_ref is None: + return False + pruned_for_capability = False + for pre_step_name, pre_step in pre_prune_steps.items(): + skip_guard = getattr(pre_step, "skip_when_false", None) + if skip_guard != guard_ref: + continue + if skip_resolutions.get(pre_step_name) is False: + pruned_for_capability = True + break + if not pruned_for_capability: + return False + live_uses_capability = False + for s in post_prune_steps.values(): + if s is None: + continue + with_args = getattr(s, "with_args", None) or {} + if cap_key in with_args: + live_uses_capability = True + break + if live_uses_capability: + return False + return True + + def _drop_sub_recipe_step(recipe: Any, step_name: str) -> Any: """Return a new Recipe with the named sub_recipe placeholder step removed.""" new_steps = {k: v for k, v in recipe.steps.items() if k != step_name} diff --git a/src/autoskillit/server/tools/tools_fleet_dispatch.py b/src/autoskillit/server/tools/tools_fleet_dispatch.py index 7f574dbe53..d624746899 100755 --- a/src/autoskillit/server/tools/tools_fleet_dispatch.py +++ b/src/autoskillit/server/tools/tools_fleet_dispatch.py @@ -32,6 +32,7 @@ DispatchRejected, DispatchResult, DispatchStatus, + _build_capability_overrides, _build_food_truck_prompt, evaluate_skip_when, execute_dispatch, @@ -321,17 +322,32 @@ async def dispatch_food_truck( _fleet_load_result: dict[str, Any] = {} if tool_ctx.recipes is not None: try: + _capability_overrides = _build_capability_overrides(tool_ctx.backend) + _merged_ingredients = {**(ingredients or {}), **_capability_overrides} _fleet_load_result = tool_ctx.recipes.load_and_validate( recipe, tool_ctx.project_dir, suppressed=tool_ctx.config.migration.suppressed if tool_ctx.config else None, - ingredient_overrides=ingredients, + ingredient_overrides=_merged_ingredients, temp_dir=tool_ctx.temp_dir, backend_name=tool_ctx.backend.name if tool_ctx.backend else None, ) except Exception: logger.warning("dispatch_food_truck_preflight_load_failed", exc_info=True) + if not _fleet_load_result.get("dispatch_feasible", True): + _infeasible_steps = _fleet_load_result.get("infeasible_steps", []) + _infeasible_msg = ( + f"Recipe '{recipe}' is dispatch-infeasible: capability gate(s) " + f"blocked at preflight. Infeasible steps: {_infeasible_steps}" + ) + logger.warning( + "dispatch_food_truck_capability_infeasible", + recipe=recipe, + infeasible_steps=_infeasible_steps, + ) + return fleet_error(FleetErrorCode.FLEET_RECIPE_INVALID, _infeasible_msg) + _active_recipe_steps: dict[str, Any] | None = None if _fleet_load_result and tool_ctx.recipes is not None: try: diff --git a/tests/arch/test_capability_admission_control.py b/tests/arch/test_capability_admission_control.py index d6ed7c06a9..3086625db6 100644 --- a/tests/arch/test_capability_admission_control.py +++ b/tests/arch/test_capability_admission_control.py @@ -1,10 +1,12 @@ """Architectural structural tests for capability admission control. Verifies that: -- _compute_capability_feasibility is called inside load_and_validate +- _compute_capability_feasibility is called inside load_and_validate with skip_resolutions - All four content-serving surfaces gate on dispatch_feasible + (open_kitchen, get_recipe, load_recipe, dispatch_food_truck) - CAPABILITY_GATE_CALLABLES entries have a corresponding BACKEND_CAPABILITY_INGREDIENTS input - Every run_python step using a CAPABILITY_GATE_CALLABLES callable reads a capability ingredient +- dispatch_food_truck injects _build_capability_overrides for backend capability signals """ from __future__ import annotations @@ -104,6 +106,73 @@ def test_load_recipe_gates_on_dispatch_feasible() -> None: pytest.fail("load_recipe function not found in tools_recipe.py") +def test_dispatch_food_truck_gates_on_dispatch_feasible() -> None: + """dispatch_food_truck must check dispatch_feasible before executing.""" + fleet_path = SRC_ROOT / "server" / "tools" / "tools_fleet_dispatch.py" + src = fleet_path.read_text(encoding="utf-8") + tree = ast.parse(src) + for node in ast.walk(tree): + if isinstance(node, ast.AsyncFunctionDef) and node.name == "dispatch_food_truck": + found = False + for child in ast.walk(node): + if isinstance(child, ast.Constant) and child.value == "dispatch_feasible": + found = True + break + assert found, ( + "dispatch_food_truck must reference 'dispatch_feasible' to gate " + "capability-DOA fleet dispatches before subprocess launch." + ) + return + pytest.fail("dispatch_food_truck function not found in tools_fleet_dispatch.py") + + +def test_compute_capability_feasibility_receives_skip_resolutions() -> None: + """load_and_validate must pass skip_resolutions to _compute_capability_feasibility + so the vacuous-gate detection has access to pruning context.""" + api_path = SRC_ROOT / "recipe" / "_api.py" + tree = ast.parse(api_path.read_text(encoding="utf-8")) + for node in ast.walk(tree): + if ( + isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + and node.name == "load_and_validate" + ): + for child in ast.walk(node): + if ( + isinstance(child, ast.Call) + and isinstance(child.func, ast.Name) + and child.func.id == "_compute_capability_feasibility" + ): + kw_names = {kw.arg for kw in child.keywords if kw.arg is not None} + assert "skip_resolutions" in kw_names, ( + "load_and_validate must pass skip_resolutions kwarg " + "to _compute_capability_feasibility for vacuous-gate detection." + ) + return + pytest.fail("_compute_capability_feasibility call not found in load_and_validate") + pytest.fail("load_and_validate function not found in _api.py") + + +def test_dispatch_food_truck_injects_capability_overrides() -> None: + """dispatch_food_truck must reference _build_capability_overrides to inject + backend capability signals into the load_and_validate call.""" + fleet_path = SRC_ROOT / "server" / "tools" / "tools_fleet_dispatch.py" + src = fleet_path.read_text(encoding="utf-8") + tree = ast.parse(src) + for node in ast.walk(tree): + if isinstance(node, ast.AsyncFunctionDef) and node.name == "dispatch_food_truck": + found = False + for child in ast.walk(node): + if isinstance(child, ast.Name) and child.id == "_build_capability_overrides": + found = True + break + assert found, ( + "dispatch_food_truck must reference _build_capability_overrides " + "to inject backend capability signals into load_and_validate." + ) + return + pytest.fail("dispatch_food_truck function not found in tools_fleet_dispatch.py") + + def test_capability_gate_callables_have_matching_ingredient() -> None: """Every CAPABILITY_GATE_CALLABLES callable must map to a BACKEND_CAPABILITY_INGREDIENTS input. diff --git a/tests/fleet/test_dispatch_ingredient_validation.py b/tests/fleet/test_dispatch_ingredient_validation.py index 227ebb6524..a5e60230f1 100644 --- a/tests/fleet/test_dispatch_ingredient_validation.py +++ b/tests/fleet/test_dispatch_ingredient_validation.py @@ -2,6 +2,8 @@ from __future__ import annotations +from unittest.mock import AsyncMock, MagicMock + import pytest from tests.fleet._helpers import ( @@ -548,3 +550,46 @@ def test_capability_overrides_dict_covers_registry_keys(): assert not missing, ( f"Capability registry keys missing from dispatch capability_overrides: {missing}" ) + + +@pytest.mark.anyio +async def test_run_dispatch_rejects_dispatch_infeasible(tool_ctx): + """When _run_dispatch's inner load_and_validate returns dispatch_feasible=False, + it must return DispatchRejected without proceeding to subprocess launch.""" + from unittest.mock import patch + + tool_ctx.recipes = MagicMock() + tool_ctx.recipes.load_and_validate.return_value = { + "valid": True, + "dispatch_feasible": False, + "infeasible_steps": ["gate_backend_write"], + } + tool_ctx.recipes.find.return_value = MagicMock() + tool_ctx.recipes.load.return_value = MagicMock() + + with patch( + "autoskillit.fleet._api.execute_dispatch", + new_callable=AsyncMock, + ) as mock_exec: + from autoskillit.fleet._api import _run_dispatch + + result = await _run_dispatch( + tool_ctx=tool_ctx, + recipe="test-recipe", + task="test task", + ingredients={}, + dispatch_name=None, + timeout_sec=None, + prompt_builder=lambda **kw: "prompt", + quota_checker=_no_sleep_quota_checker, + quota_refresher=_noop_quota_refresher, + ) + + mock_exec.assert_not_called() + from autoskillit.fleet.state_types import DispatchRejected + + assert isinstance(result.outcome, DispatchRejected) + assert ( + "gate_backend_write" in result.outcome.message + or "dispatch" in result.outcome.message.lower() + ) diff --git a/tests/recipe/test_bundled_recipes_dispatch_ready.py b/tests/recipe/test_bundled_recipes_dispatch_ready.py index 8e9635bef9..e5e07061f7 100644 --- a/tests/recipe/test_bundled_recipes_dispatch_ready.py +++ b/tests/recipe/test_bundled_recipes_dispatch_ready.py @@ -266,19 +266,33 @@ def test_card_and_semantic_rules_agree_on_errors(recipe_name: str, tmp_path: Pat def test_codex_implementation_dispatch_infeasible() -> None: - """Codex backend + implementation recipe must report dispatch_feasible=False. + """Codex backend + implementation recipe must report dispatch_feasible=True + when gate_backend_write is vacuous (all guarded steps were pruned).""" + result = load_and_validate( + "implementation", + project_dir=_PROJECT_ROOT, + ingredient_overrides={"backend_supports_git_write": "false"}, + backend_name="codex", + ) + assert result["valid"] is True + assert result.get("dispatch_feasible") is True, ( + "When all backend_supports_git_write-gated steps are pruned, " + "gate_backend_write is vacuous and should NOT block dispatch." + ) - gate_backend_write routes to terminal failure when backend_capable=false. - """ + +def test_vacuous_gate_does_not_block_dispatch() -> None: + """When gate_backend_write's downstream operations are all pruned, + the gate is vacuous and dispatch_feasible must be True.""" result = load_and_validate( "implementation", project_dir=_PROJECT_ROOT, ingredient_overrides={"backend_supports_git_write": "false"}, backend_name="codex", ) - assert result["valid"] is True # recipe is structurally valid - assert result.get("dispatch_feasible") is False # but pipeline is DOA - assert "gate_backend_write" in result.get("infeasible_steps", []) + assert result["valid"] is True + assert result.get("dispatch_feasible") is True + assert "gate_backend_write" not in result.get("infeasible_steps", []) def test_claude_implementation_dispatch_feasible() -> None: @@ -327,8 +341,10 @@ def _discover_capability_gate_recipes() -> list[str]: @pytest.mark.parametrize("recipe_name", _CAPABILITY_GATE_RECIPES) def test_codex_capability_gate_recipes(recipe_name: str) -> None: - """Every recipe with a gate_backend_write step must report - dispatch_feasible=False when loaded with codex overrides.""" + """Every recipe with a gate_backend_write step must pass vacuous-gate + detection under codex overrides — either dispatch_feasible=True (gate + is vacuous) or dispatch_feasible=False with gate in infeasible_steps + (gate guards live steps).""" result = load_and_validate( recipe_name, project_dir=_PROJECT_ROOT, @@ -336,8 +352,9 @@ def test_codex_capability_gate_recipes(recipe_name: str) -> None: backend_name="codex", ) assert result.get("valid") is True - assert result.get("dispatch_feasible") is False, ( - f"Recipe '{recipe_name}' with gate_backend_write step did not report " - f"dispatch_feasible=False under codex overrides" - ) - assert "gate_backend_write" in result.get("infeasible_steps", []) + dispatch_feasible = result.get("dispatch_feasible") + infeasible_steps = result.get("infeasible_steps", []) + if dispatch_feasible is True: + assert "gate_backend_write" not in infeasible_steps + else: + assert "gate_backend_write" in infeasible_steps diff --git a/tests/server/test_open_kitchen_deferred_recall.py b/tests/server/test_open_kitchen_deferred_recall.py index 8e0798c6ab..4802ff5b1e 100644 --- a/tests/server/test_open_kitchen_deferred_recall.py +++ b/tests/server/test_open_kitchen_deferred_recall.py @@ -248,6 +248,42 @@ async def test_pre_reveal_then_open_does_not_re_execute_handler(): assert parsed["success"] is True +@pytest.mark.anyio +async def test_deferred_recall_returns_infeasible_when_dispatch_feasible_false(): + """When deferred-recall loads a recipe with dispatch_feasible=False, + open_kitchen must return the infeasible response, not proceed.""" + from autoskillit.server.tools.tools_kitchen import open_kitchen + + mock_ctx = _make_deferred_recall_ctx("test-recipe") + mock_ctx.recipes.load_and_validate.return_value = { + "content": "name: test-recipe\nsteps:\n gate:\n cmd: echo\n", + "valid": True, + "errors": [], + "requires_packs": [], + "requires_features": [], + "content_hash": "abc", + "composite_hash": "def", + "recipe_version": "1.0", + "suggestions": [], + "dispatch_feasible": False, + "infeasible_steps": ["gate_backend_write"], + "post_prune_step_names": ["gate_backend_write"], + } + mock_recipe_info = MagicMock() + mock_recipe_info.path = Path("/fake/.autoskillit/recipes/test-recipe.yaml") + mock_ctx.recipes.find.return_value = mock_recipe_info + mock_recipe_obj = MagicMock() + mock_recipe_obj.steps = {"gate_backend_write": MagicMock()} + mock_ctx.recipes.load.return_value = mock_recipe_obj + + with patch("autoskillit.server._get_ctx", return_value=mock_ctx): + result = await open_kitchen(name="test-recipe", ctx=mock_ctx) + + parsed = json.loads(result) + assert parsed["success"] is False + assert "dispatch_infeasible" in str(parsed).lower() or "gate_backend_write" in str(parsed) + + @pytest.mark.anyio async def test_deferred_recall_strips_content_when_ingredients_only_true(): """Deferred-recall path must respect ingredients_only flag.""" diff --git a/tests/server/test_tools_fleet_dispatch_preflight.py b/tests/server/test_tools_fleet_dispatch_preflight.py index cd5e413aca..6b19cdb2c5 100644 --- a/tests/server/test_tools_fleet_dispatch_preflight.py +++ b/tests/server/test_tools_fleet_dispatch_preflight.py @@ -118,3 +118,119 @@ async def test_execute_dispatch_not_called_on_preflight_failure( parsed = json.loads(result) assert parsed.get("stage") == "dispatch_feasibility_preflight" assert parsed.get("success") is False + + @pytest.mark.anyio + async def test_dispatch_food_truck_blocks_on_dispatch_infeasible( + self, build_ctx_open: Any + ) -> None: + """When load_and_validate returns dispatch_feasible=False, dispatch_food_truck + must NOT call execute_dispatch and must return a dispatch_infeasible response.""" + from autoskillit.core import BackendCapabilities + + tool_ctx = build_ctx_open() + + caps = BackendCapabilities( + applicable_guards=frozenset(), + anthropic_provider_capable=False, + ) + backend = MagicMock() + backend.name = "codex" + backend.capabilities = caps + tool_ctx.backend = backend + + tool_ctx.recipes = MagicMock() + tool_ctx.recipes.load_and_validate.return_value = { + "valid": True, + "dispatch_feasible": False, + "infeasible_steps": ["gate_backend_write"], + "post_prune_step_names": ["gate_backend_write"], + } + recipe_info = MagicMock() + recipe_info.path = Path("/fake/recipe.yaml") + tool_ctx.recipes.find.return_value = recipe_info + + mock_execute = AsyncMock() + with ( + patch("autoskillit.server._state._ctx", tool_ctx), + patch( + "autoskillit.server.tools.tools_fleet_dispatch.execute_dispatch", + mock_execute, + ), + patch( + "autoskillit.server.tools.tools_fleet_dispatch._require_fleet", + lambda _name: None, + ), + ): + from autoskillit.server.tools.tools_fleet_dispatch import dispatch_food_truck + + ctx_mock = AsyncMock() + result = await dispatch_food_truck( + recipe="test-recipe", + task="test task", + ctx=ctx_mock, + ) + + mock_execute.assert_not_called() + parsed = json.loads(result) + assert parsed.get("success") is False + assert "gate_backend_write" in parsed.get( + "user_visible_message", "" + ) or "dispatch_infeasible" in str(parsed) + + @pytest.mark.anyio + async def test_dispatch_food_truck_injects_capability_overrides( + self, build_ctx_open: Any + ) -> None: + """dispatch_food_truck must inject backend capability overrides into + load_and_validate's ingredient_overrides parameter.""" + from autoskillit.core import BackendCapabilities + + tool_ctx = build_ctx_open() + + caps = BackendCapabilities( + applicable_guards=frozenset(), + anthropic_provider_capable=False, + git_metadata_writable=False, + ) + backend = MagicMock() + backend.name = "codex" + backend.capabilities = caps + tool_ctx.backend = backend + + tool_ctx.recipes = MagicMock() + tool_ctx.recipes.load_and_validate.return_value = { + "valid": True, + "dispatch_feasible": True, + "post_prune_step_names": [], + } + recipe_info = MagicMock() + recipe_info.path = Path("/fake/recipe.yaml") + tool_ctx.recipes.find.return_value = recipe_info + + mock_execute = AsyncMock() + with ( + patch("autoskillit.server._state._ctx", tool_ctx), + patch( + "autoskillit.server.tools.tools_fleet_dispatch.execute_dispatch", + mock_execute, + ), + patch( + "autoskillit.server.tools.tools_fleet_dispatch._require_fleet", + lambda _name: None, + ), + ): + from autoskillit.server.tools.tools_fleet_dispatch import dispatch_food_truck + + ctx_mock = AsyncMock() + await dispatch_food_truck( + recipe="test-recipe", + task="test task", + ctx=ctx_mock, + ) + + call_kwargs = tool_ctx.recipes.load_and_validate.call_args.kwargs + ingredient_overrides = call_kwargs.get("ingredient_overrides", {}) + assert ingredient_overrides.get("backend_supports_git_write") == "false", ( + "dispatch_food_truck must inject backend_supports_git_write='false' " + f"for codex backend. Got: {ingredient_overrides}" + ) From 9ce4cb6af8164e5db83de8733ebea6ee1115d5c7 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sat, 27 Jun 2026 13:03:25 -0700 Subject: [PATCH 2/6] fix: correct vacuous-gate detection, import path, test mock, and symbol count - Fix _is_vacuous_gate to skip the gate step itself when scanning for live capability references (gate_backend_write references its own capability ingredient in with_args, which was falsely preventing vacuous detection) - Fix import path violation: use autoskillit.core gateway instead of autoskillit.core.types subpackage directly - Fix deferred-recall test: add AsyncMock for ctx.disable_components to prevent "MagicMock can't be used in await" error - Update symbol count from 93 to 94 for new CAPABILITY_INGREDIENT_TO_SKIP_GUARD Co-Authored-By: Claude Opus 4.6 (1M context) --- src/autoskillit/recipe/_recipe_composition.py | 9 +++++---- tests/arch/test_subpackage_structure.py | 4 ++-- tests/server/test_open_kitchen_deferred_recall.py | 1 + 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/autoskillit/recipe/_recipe_composition.py b/src/autoskillit/recipe/_recipe_composition.py index c2dcce87a0..fc791d8a06 100644 --- a/src/autoskillit/recipe/_recipe_composition.py +++ b/src/autoskillit/recipe/_recipe_composition.py @@ -9,8 +9,7 @@ import regex as re -from autoskillit.core import YAMLError, load_yaml -from autoskillit.core.types import CAPABILITY_INGREDIENT_TO_SKIP_GUARD +from autoskillit.core import CAPABILITY_INGREDIENT_TO_SKIP_GUARD, YAMLError, load_yaml from autoskillit.recipe._contracts_types import INPUT_REF_RE from autoskillit.recipe.io import find_sub_recipe_by_name from autoskillit.recipe.io import load_recipe as _load_recipe_from_path @@ -165,6 +164,7 @@ def _compute_capability_feasibility( continue if _is_vacuous_gate( gate_input_keys, + gate_step_name=step_name, skip_resolutions=skip_resolutions, pre_prune_steps=pre_prune_steps, post_prune_steps=steps, @@ -178,6 +178,7 @@ def _compute_capability_feasibility( def _is_vacuous_gate( gate_input_keys: set[str], *, + gate_step_name: str, skip_resolutions: dict[str, bool | None] | None, pre_prune_steps: dict[str, Any] | None, post_prune_steps: dict[str, Any], @@ -200,8 +201,8 @@ def _is_vacuous_gate( if not pruned_for_capability: return False live_uses_capability = False - for s in post_prune_steps.values(): - if s is None: + for sn, s in post_prune_steps.items(): + if s is None or sn == gate_step_name: continue with_args = getattr(s, "with_args", None) or {} if cap_key in with_args: diff --git a/tests/arch/test_subpackage_structure.py b/tests/arch/test_subpackage_structure.py index cef916fd32..622fde213d 100644 --- a/tests/arch/test_subpackage_structure.py +++ b/tests/arch/test_subpackage_structure.py @@ -60,8 +60,8 @@ def test_type_constants_split_completeness(self): assert len(combined) == len(remaining) + len(env) + len(features) + len(registries), ( "Duplicate symbols across split modules" ) - assert len(combined) == 93, ( - f"Expected 93 symbols total, got {len(combined)} " + assert len(combined) == 94, ( + f"Expected 94 symbols total, got {len(combined)} " f"(remaining={len(remaining)}, env={len(env)}, " f"features={len(features)}, registries={len(registries)})" ) diff --git a/tests/server/test_open_kitchen_deferred_recall.py b/tests/server/test_open_kitchen_deferred_recall.py index 4802ff5b1e..6fe2ebc66d 100644 --- a/tests/server/test_open_kitchen_deferred_recall.py +++ b/tests/server/test_open_kitchen_deferred_recall.py @@ -275,6 +275,7 @@ async def test_deferred_recall_returns_infeasible_when_dispatch_feasible_false() mock_recipe_obj = MagicMock() mock_recipe_obj.steps = {"gate_backend_write": MagicMock()} mock_ctx.recipes.load.return_value = mock_recipe_obj + mock_ctx.disable_components = AsyncMock() with patch("autoskillit.server._get_ctx", return_value=mock_ctx): result = await open_kitchen(name="test-recipe", ctx=mock_ctx) From e13a818da0d112f43a1223ca0ea9cf2163663f72 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sat, 27 Jun 2026 13:34:52 -0700 Subject: [PATCH 3/6] fix(review): merge duplicate vacuous-gate test and add explicit dispatch_feasible assertion Merge test_vacuous_gate_does_not_block_dispatch into test_codex_implementation_dispatch_infeasible (eliminating redundant load_and_validate call) and add explicit `assert dispatch_feasible is False` in the else branch of test_codex_capability_gate_recipes. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../test_bundled_recipes_dispatch_ready.py | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/tests/recipe/test_bundled_recipes_dispatch_ready.py b/tests/recipe/test_bundled_recipes_dispatch_ready.py index e5e07061f7..01140a797e 100644 --- a/tests/recipe/test_bundled_recipes_dispatch_ready.py +++ b/tests/recipe/test_bundled_recipes_dispatch_ready.py @@ -279,19 +279,6 @@ def test_codex_implementation_dispatch_infeasible() -> None: "When all backend_supports_git_write-gated steps are pruned, " "gate_backend_write is vacuous and should NOT block dispatch." ) - - -def test_vacuous_gate_does_not_block_dispatch() -> None: - """When gate_backend_write's downstream operations are all pruned, - the gate is vacuous and dispatch_feasible must be True.""" - result = load_and_validate( - "implementation", - project_dir=_PROJECT_ROOT, - ingredient_overrides={"backend_supports_git_write": "false"}, - backend_name="codex", - ) - assert result["valid"] is True - assert result.get("dispatch_feasible") is True assert "gate_backend_write" not in result.get("infeasible_steps", []) @@ -357,4 +344,7 @@ def test_codex_capability_gate_recipes(recipe_name: str) -> None: if dispatch_feasible is True: assert "gate_backend_write" not in infeasible_steps else: + assert dispatch_feasible is False, ( + f"Expected dispatch_feasible to be explicitly False, got {dispatch_feasible!r}" + ) assert "gate_backend_write" in infeasible_steps From 48977eeb6247dc977a48148c2c5a5015c0e487c8 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sat, 27 Jun 2026 13:35:12 -0700 Subject: [PATCH 4/6] fix(review): add arch test enforcing CAPABILITY_INGREDIENT_TO_SKIP_GUARD keys subset Add test_capability_ingredient_to_skip_guard_keys_subset to ensure every key in CAPABILITY_INGREDIENT_TO_SKIP_GUARD exists in BACKEND_CAPABILITY_INGREDIENTS, preventing silent vacuous-gate detection failures when new capability keys are added. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../arch/test_capability_admission_control.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/arch/test_capability_admission_control.py b/tests/arch/test_capability_admission_control.py index 3086625db6..20a3a9a845 100644 --- a/tests/arch/test_capability_admission_control.py +++ b/tests/arch/test_capability_admission_control.py @@ -198,3 +198,21 @@ def test_capability_gate_callables_have_matching_ingredient() -> None: assert len(CAPABILITY_GATE_CALLABLES) > 0, ( "CAPABILITY_GATE_CALLABLES must declare at least one capability gate callable." ) + + +def test_capability_ingredient_to_skip_guard_keys_subset() -> None: + """CAPABILITY_INGREDIENT_TO_SKIP_GUARD keys must be BACKEND_CAPABILITY_INGREDIENTS subset.""" + from autoskillit.core import ( + BACKEND_CAPABILITY_INGREDIENTS, + CAPABILITY_INGREDIENT_TO_SKIP_GUARD, + ) + + orphaned = set(CAPABILITY_INGREDIENT_TO_SKIP_GUARD) - set(BACKEND_CAPABILITY_INGREDIENTS) + assert not orphaned, ( + f"CAPABILITY_INGREDIENT_TO_SKIP_GUARD keys {orphaned} are not in " + f"BACKEND_CAPABILITY_INGREDIENTS — vacuous-gate detection will silently " + f"malfunction for these keys." + ) + assert len(CAPABILITY_INGREDIENT_TO_SKIP_GUARD) > 0, ( + "CAPABILITY_INGREDIENT_TO_SKIP_GUARD must declare at least one mapping." + ) From ffb8c44dfacc8625acde64fba7e8b97a82efd194 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sat, 27 Jun 2026 13:52:20 -0700 Subject: [PATCH 5/6] fix: update test_codex_open_kitchen_returns_success for vacuous-gate detection The vacuous-gate detection correctly identifies gate_backend_write as vacuous when all backend_supports_git_write-guarded steps are pruned. Update assertions to expect success=True and dispatch_feasible=True. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../test_backend_ingredient_injection.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/server/test_backend_ingredient_injection.py b/tests/server/test_backend_ingredient_injection.py index 0afb2a5be1..d43c7601ff 100644 --- a/tests/server/test_backend_ingredient_injection.py +++ b/tests/server/test_backend_ingredient_injection.py @@ -498,11 +498,9 @@ def test_codex_overrides_remove_guarded_steps_from_content(self, tmp_path: Path) @pytest.mark.anyio async def test_codex_open_kitchen_returns_success(self, tmp_path: Path) -> None: """End-to-end integration: open_kitchen with codex backend on the bundled - implementation recipe must return success=False and kitchen=dispatch_infeasible. - - Capability admission control detects that codex lacks git_metadata_writable, - causing gate_backend_write steps to be infeasible. open_kitchen returns the - dispatch_infeasible envelope instead of opening the kitchen. + implementation recipe must return success=True because vacuous-gate + detection recognises that all backend_supports_git_write-guarded steps + were pruned, making gate_backend_write vacuous. """ from autoskillit.server.tools.tools_kitchen import open_kitchen @@ -556,9 +554,11 @@ def _real_load_wrapper(name, project_dir=None, **kwargs): result = json.loads(result_str) - assert result.get("success") is False, ( - f"open_kitchen must refuse codex backend (dispatch_feasible=False); got: {result}" + assert result.get("success") is True, ( + f"open_kitchen must accept codex backend (vacuous gate); got: {result}" ) - assert result.get("kitchen") == "dispatch_infeasible", ( - f"open_kitchen must report kitchen=dispatch_infeasible; got: {result.get('kitchen')}" + assert result.get("dispatch_feasible") is True, ( + "dispatch_feasible must be True when " + f"gate_backend_write is vacuous; got: " + f"{result.get('dispatch_feasible')}" ) From d9d75e5307b2f07fe8e26c34397b55a5e0ab9858 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sat, 27 Jun 2026 13:55:57 -0700 Subject: [PATCH 6/6] fix: update test_codex_backend_produces_infeasible_recipe for vacuous-gate detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same pattern as the prior commit — the e2e test expected dispatch_feasible=False for codex+implementation, but vacuous-gate detection now correctly returns True. Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/server/test_capability_admission_e2e.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/server/test_capability_admission_e2e.py b/tests/server/test_capability_admission_e2e.py index f1d5acd509..51c26a1238 100644 --- a/tests/server/test_capability_admission_e2e.py +++ b/tests/server/test_capability_admission_e2e.py @@ -29,9 +29,10 @@ def test_capability_gate_callables_includes_gate_backend_write() -> None: assert "gate_backend_write" in CAPABILITY_GATE_CALLABLES -def test_codex_backend_produces_infeasible_recipe() -> None: +def test_codex_backend_produces_feasible_recipe_via_vacuous_gate() -> None: """Codex + implementation recipe chain: backend_supports_git_write=false - produces dispatch_feasible=False with gate_backend_write infeasible.""" + produces dispatch_feasible=True because vacuous-gate detection recognises + all guarded steps were pruned.""" result = load_and_validate( "implementation", project_dir=_PROJECT_ROOT, @@ -39,8 +40,8 @@ def test_codex_backend_produces_infeasible_recipe() -> None: backend_name="codex", ) assert result.get("valid") is True - assert result.get("dispatch_feasible") is False - assert "gate_backend_write" in result.get("infeasible_steps", []) + assert result.get("dispatch_feasible") is True + assert "gate_backend_write" not in result.get("infeasible_steps", []) def test_claude_code_backend_produces_feasible_recipe() -> None: