Skip to content

Commit a72cb01

Browse files
Trecekclaude
andauthored
[FIX] Dispatch-Feasibility Preflight — Claim-Before-Validate Immunity (#4087)
## Summary Add a dispatch-feasibility preflight in the server layer that fires at `open_kitchen` and `dispatch_food_truck` time — validating that the backend can dispatch the recipe's `run_skill` steps before any irreversible side effects (clone, branch push, issue labeling) occur. On preflight failure, the gate is closed, structurally denying all downstream side-effect tools via `_require_enabled()`. This fixes the "claim-before-validate" anti-pattern identified in production run `impl-20260611-163753-197057` where the backend-compat check fired only inside `run_skill`, after claims and branch pushes had already happened. <details> <summary>Individual Group Plans</summary> ### Plan 1: Rectify — Dispatch-Feasibility Preflight — Claim-Before-Validate Immunity The backend-compatibility gate (`_check_backend_compat`) fires only inside `run_skill` — after irreversible external side effects (clone, branch push, issue labeling) have already occurred. The incompatibility is statically knowable at `open_kitchen` time from constants (`HOOK_REGISTRY`, `backend.capabilities`). The architectural weakness is that `open_kitchen` enables the gate and reveals tools before validating that the backend can dispatch the recipe's steps, and no existing test models the temporal ordering between gate-enable, side-effect tools, and the compat check. The immunity plan addresses three layers: 1. **LoadRecipeResult extension** — expose post-prune step names from the recipe layer so the server can inspect them without duplicating parse logic. 2. **Server-layer preflight** — a `_check_dispatch_feasibility` function shared by `open_kitchen` and `dispatch_food_truck`, evaluated against post-prune steps and the real backend, with gate-close on failure. 3. **Temporal-ordering test infrastructure** — tests that assert gate state after validation failure, test with the real `HOOK_REGISTRY`, and verify that side-effect tools are structurally denied after preflight failure. ### Plan 2: Implementation — dispatch_food_truck Preflight Fix and Missing Tests Fix the `active_recipe_steps={}` bug in `dispatch_food_truck` that renders the dispatch-feasibility preflight a no-op, and add four missing tests: gate-closure after validation failure (1a), full `open_kitchen` integration preflight (1b), real `HOOK_REGISTRY` test (1e), and behavioral fleet dispatch preflight (1g). The root cause is that `dispatch_food_truck` calls `_check_dispatch_feasibility` with an empty dict for `active_recipe_steps`, unlike `open_kitchen` which properly loads the `Recipe` object via `recipes.find()` + `recipes.load()` and filters with `filter_steps_by_post_prune()`. The fix mirrors the `open_kitchen` pattern. </details> Closes #4083 ## Implementation Plan Plan files: - `/home/talon/projects/autoskillit-runs/remediation-20260611-215136-833713/.autoskillit/temp/rectify/rectify_dispatch_feasibility_preflight_2026-06-11_215136.md` - `/home/talon/projects/autoskillit-runs/remediation-20260611-215136-833713/.autoskillit/temp/make-plan/remediation_dispatch_feasibility_preflight_plan_2026-06-11_230600.md` 🤖 Generated with [Claude Code](https://claude.com/claude-code) via AutoSkillit <!-- autoskillit:pipeline-signature steps=prepare_pr,run_arch_lenses,compose_pr,annotate_pr_diff,review_pr --> ## Token Usage Summary | Step | Model | count | uncached | output | cache_read | peak_ctx | turns | cache_write | time | |------|-------|-------|----------|--------|------------|----------|-------|-------------|------| | resolve_review* | sonnet | 10 | 3.0k | 136.6k | 15.9M | 109.9k | 608 | 628.2k | 1h 6m | | dispatch:44d3705c-3f62-459b-af5b-9678f2bbcb64* | sonnet | 1 | 114 | 3.6k | 1.2M | 94.6k | 28 | 94.9k | 23m 9s | | plan* | opus[1m] | 10 | 9.8k | 210.3k | 14.0M | 136.8k | 432 | 818.6k | 2h 19m | | verify* | sonnet | 10 | 4.1k | 122.6k | 4.7M | 74.1k | 255 | 427.3k | 1h 2m | | implement* | MiniMax-M3 | 10 | 24.0M | 134.7k | 0 | 0 | 722 | 0 | 1h 11m | | audit_impl* | sonnet | 9 | 6.1k | 71.6k | 2.0M | 49.5k | 141 | 266.9k | 43m 44s | | prepare_pr* | MiniMax-M3 | 8 | 1.9M | 18.0k | 0 | 0 | 115 | 0 | 7m 59s | | compose_pr* | MiniMax-M3 | 8 | 1.6M | 13.2k | 0 | 0 | 103 | 0 | 6m 21s | | review_pr* | sonnet | 14 | 2.1k | 347.6k | 13.6M | 101.1k | 618 | 770.8k | 1h 26m | | dispatch:0145c757-e69c-4878-929a-ba4acb07e3d1* | sonnet | 1 | 322 | 13.7k | 3.0M | 86.3k | 84 | 62.0k | 42m 39s | | fix* | sonnet | 4 | 640 | 28.9k | 4.4M | 98.2k | 210 | 202.0k | 21m 12s | | dispatch:341f411a-3d76-4fcf-88a3-15a64c6d7bd4* | sonnet | 1 | 354 | 10.0k | 3.4M | 86.4k | 89 | 62.0k | 1h 18m | | dispatch:7e27c189-e656-4ec2-b528-b128157c59a8* | sonnet | 1 | 490 | 17.2k | 5.0M | 98.3k | 122 | 73.9k | 1h 26m | | dispatch:36a96fbc-6c25-4316-a068-66f069a326ac* | sonnet | 1 | 386 | 14.1k | 3.8M | 89.9k | 102 | 65.5k | 1h 14m | | dispatch:540912dc-04a8-4bda-b6dd-1ba4a533b68f* | sonnet | 1 | 418 | 12.6k | 4.1M | 89.9k | 106 | 65.5k | 1h 27m | | dispatch:726982d8-8d44-4345-91ee-7c36d41bf8c0* | sonnet | 1 | 330 | 11.6k | 3.1M | 84.9k | 82 | 60.5k | 1h 10m | | dispatch:c51e35aa-e1dc-42fd-91a6-8cadbf463f94* | sonnet | 1 | 418 | 12.6k | 4.1M | 90.7k | 107 | 66.3k | 1h 36m | | dispatch:136d06d2-7313-45ac-b80d-f17a96a36af8* | sonnet | 1 | 322 | 10.0k | 3.0M | 83.9k | 80 | 59.5k | 1h 8m | | **Total** | | | 27.5M | 1.2M | 85.2M | 136.8k | | 3.7M | 18h 54m | \* *Step used a non-Anthropic provider; caching behavior may differ.* ## Token Efficiency | Step | LoC Changed | cache_read/LoC | cache_write/LoC | output/LoC | |------|-------------|----------------|-----------------|------------| | resolve_review | 100 | 159103.9 | 6282.2 | 1366.2 | | dispatch:44d3705c-3f62-459b-af5b-9678f2bbcb64 | 0 | — | — | — | | plan | 0 | — | — | — | | verify | 0 | — | — | — | | implement | 1691 | 0.0 | 0.0 | 79.6 | | audit_impl | 0 | — | — | — | | prepare_pr | 0 | — | — | — | | compose_pr | 0 | — | — | — | | review_pr | 0 | — | — | — | | dispatch:0145c757-e69c-4878-929a-ba4acb07e3d1 | 360 | 8363.5 | 172.2 | 38.2 | | fix | 20 | 219002.4 | 10100.2 | 1446.6 | | dispatch:341f411a-3d76-4fcf-88a3-15a64c6d7bd4 | 446 | 7548.5 | 139.0 | 22.5 | | dispatch:7e27c189-e656-4ec2-b528-b128157c59a8 | 235 | 21444.0 | 314.5 | 73.2 | | dispatch:36a96fbc-6c25-4316-a068-66f069a326ac | 0 | — | — | — | | dispatch:540912dc-04a8-4bda-b6dd-1ba4a533b68f | 0 | — | — | — | | dispatch:726982d8-8d44-4345-91ee-7c36d41bf8c0 | 0 | — | — | — | | dispatch:c51e35aa-e1dc-42fd-91a6-8cadbf463f94 | 844 | 4875.8 | 78.5 | 14.9 | | dispatch:136d06d2-7313-45ac-b80d-f17a96a36af8 | 0 | — | — | — | | **Total** | **3696** | 23057.4 | 1007.5 | 321.7 | ## Model Usage Breakdown | Model | steps | uncached | output | cache_read | cache_write | time | |-------|-------|----------|--------|------------|-------------|------| | sonnet | 14 | 19.1k | 812.8k | 71.2M | 2.9M | 15h 9m | | opus[1m] | 1 | 9.8k | 210.3k | 14.0M | 818.6k | 2h 19m | | MiniMax-M3 | 3 | 27.5M | 165.9k | 0 | 0 | 1h 25m | --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 93b7360 commit a72cb01

18 files changed

Lines changed: 867 additions & 44 deletions

src/autoskillit/hooks/formatters/_fmt_recipe.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
"recipe_version", # internal identity metadata
4444
"stop_step_semantics", # delivered via open_kitchen response Channel B; not redisplayed
4545
"deferred_guards", # internal deferral metadata; not displayed to agent
46+
"post_prune_step_names", # internal preflight field; not displayed to agent
4647
}
4748
)
4849

@@ -183,6 +184,7 @@ def _fmt_load_recipe(data: LoadRecipeResult, pipeline: bool) -> str:
183184
"stop_step_semantics", # delivered via open_kitchen Channel B
184185
"hook_warning", # edge-case diagnostic; not rendered in standard path
185186
"deferred_guards", # internal deferral metadata; not displayed to agent
187+
"post_prune_step_names", # internal preflight field; not displayed to agent
186188
}
187189
)
188190

src/autoskillit/recipe/_api.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -580,6 +580,8 @@ def load_and_validate(
580580
)
581581
if _deferred_guard_list:
582582
result["deferred_guards"] = _deferred_guard_list
583+
if active_recipe is not None:
584+
result["post_prune_step_names"] = list(active_recipe.steps.keys())
583585

584586
# Write to cache (only when recipe was found and fully processed)
585587
if match is not None:

src/autoskillit/recipe/_recipe_ingredients.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ class LoadRecipeResult(TypedDict, total=False):
126126
composite_hash: str
127127
recipe_version: str | None
128128
deferred_guards: list[DeferredGuard]
129+
post_prune_step_names: list[str]
129130

130131

131132
class OpenKitchenResult(TypedDict, total=False):
@@ -134,7 +135,7 @@ class OpenKitchenResult(TypedDict, total=False):
134135
Extends LoadRecipeResult with three post-return keys injected by the handler.
135136
"""
136137

137-
# Inherited from LoadRecipeResult (15 keys)
138+
# Inherited from LoadRecipeResult (17 keys)
138139
content: str
139140
errors: list[str]
140141
diagram: str | None
@@ -151,6 +152,7 @@ class OpenKitchenResult(TypedDict, total=False):
151152
composite_hash: str
152153
recipe_version: str | None
153154
deferred_guards: list[DeferredGuard]
155+
post_prune_step_names: list[str]
154156
# Post-return keys injected by open_kitchen handler (4 keys)
155157
success: bool
156158
kitchen: str

src/autoskillit/server/tools/AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ MCP `@mcp.tool()` handlers registered on import (20 tool modules).
1919
| `tools_clone.py` | `clone_repo`, `remove_clone`, `push_to_remote`, `register_clone_status`, `batch_cleanup_clones`, `bootstrap_clone` |
2020
| `_claim_helpers.py` | `ClaimDecision`, `_try_claim_with_liveness`, `_get_campaign_state_paths` — shared claiming logic for `claim_issue` and `claim_and_resolve_issue` |
2121
| `_execution_helpers.py` | `_import_and_call`, `_coerce_scalar`, `maybe_promote_work_dir`, `validate_path_arg_anchoring`, `resolve_relative_path_args` — execution helpers for `run_python` (no MCP tools) |
22+
| `_preflight.py` | `_check_dispatch_feasibility`, `_get_fix_required_hook_matchers`, `filter_steps_by_post_prune` — shared preflight for `open_kitchen` and `dispatch_food_truck` |
2223
| `tools_execution.py` | `run_cmd`, `run_python`, `run_skill` |
2324
| `tools_fleet_dispatch.py` | `dispatch_food_truck`, `record_gate_dispatch` |
2425
| `tools_fleet_reset.py` | `reset_dispatch` (full dispatch artifact cleanup) |
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
"""Dispatch-feasibility preflight — shared by open_kitchen and dispatch_food_truck."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
from pathlib import Path
7+
from typing import Any
8+
9+
from autoskillit.hook_registry import HOOK_REGISTRY
10+
11+
12+
def _get_fix_required_hook_matchers(applicable_guards: frozenset[str]) -> list[str]:
13+
"""Return matchers of fix-required hooks not enforced by the given guard set."""
14+
return [
15+
h.matcher
16+
for h in HOOK_REGISTRY
17+
if h.codex_status == "fix-required"
18+
and (
19+
not h.scripts
20+
or not frozenset(Path(s).stem for s in h.scripts).issubset(applicable_guards)
21+
)
22+
]
23+
24+
25+
def filter_steps_by_post_prune(
26+
raw_steps: dict[str, Any],
27+
post_prune_step_names: list[str],
28+
) -> dict[str, Any]:
29+
"""Return only the steps whose names survived skip_when_false pruning."""
30+
keep = set(post_prune_step_names)
31+
return {k: v for k, v in raw_steps.items() if k in keep}
32+
33+
34+
def _check_dispatch_feasibility(
35+
post_prune_step_names: list[str],
36+
active_recipe_steps: dict[str, Any],
37+
backend: Any | None,
38+
config_providers: Any,
39+
recipe_name: str = "",
40+
) -> str | None:
41+
"""Fail-closed dispatch-feasibility preflight.
42+
43+
Evaluated at open_kitchen time (and dispatch_food_truck time) to detect
44+
recipe/backend combinations where HOOK_REGISTRY has fix-required entries
45+
that the current backend cannot enforce. Returns a JSON error envelope
46+
string on failure, None on pass.
47+
"""
48+
if backend is None:
49+
return None
50+
if not post_prune_step_names:
51+
return None
52+
53+
run_skill_step_names: list[str] = []
54+
for step_name in post_prune_step_names:
55+
step = active_recipe_steps.get(step_name)
56+
if step is not None and getattr(step, "tool", None) == "run_skill":
57+
run_skill_step_names.append(step_name)
58+
59+
if not run_skill_step_names:
60+
return None
61+
62+
from autoskillit.server._guards import _resolve_provider_profile # circular-break
63+
64+
feasible_step_names: list[str] = []
65+
for step_name in run_skill_step_names:
66+
step = active_recipe_steps.get(step_name)
67+
step_provider = getattr(step, "provider", "") or ""
68+
_profile_name, provider_extras = _resolve_provider_profile(
69+
step_name,
70+
recipe_name,
71+
config_providers,
72+
step_provider=step_provider,
73+
)
74+
if (
75+
provider_extras
76+
and "ANTHROPIC_BASE_URL" in provider_extras
77+
and not backend.capabilities.anthropic_provider_capable
78+
):
79+
continue
80+
feasible_step_names.append(step_name)
81+
82+
if not feasible_step_names:
83+
return None
84+
85+
fix_required_matchers = _get_fix_required_hook_matchers(
86+
backend.capabilities.applicable_guards,
87+
)
88+
if not fix_required_matchers:
89+
return None
90+
91+
return json.dumps(
92+
{
93+
"success": False,
94+
"kitchen": "preflight_failed",
95+
"user_visible_message": (
96+
f"Cannot dispatch recipe: backend {backend.name!r} cannot enforce "
97+
f"HOOK_REGISTRY fix-required entries "
98+
f"[{', '.join(fix_required_matchers)}]. "
99+
f"Add a per-step provider override that sets ANTHROPIC_BASE_URL "
100+
f"to reroute dispatch to claude-code, which has full hook enforcement."
101+
),
102+
"error": (
103+
f"Dispatch infeasible on backend {backend.name!r}: "
104+
f"fix-required hooks {fix_required_matchers} are not enforceable."
105+
),
106+
"stage": "dispatch_feasibility_preflight",
107+
"unfixable_matchers": fix_required_matchers,
108+
"backend": backend.name,
109+
"escape_hatch": (
110+
"Per-step provider: override with ANTHROPIC_BASE_URL set to "
111+
"reroute dispatch to claude-code (which enforces all fix-required hooks)."
112+
),
113+
}
114+
)

src/autoskillit/server/tools/tools_execution.py

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,6 @@
3737
from autoskillit.core import current_order_id as _current_order_id
3838
from autoskillit.core import current_step_name as _current_step_name
3939
from autoskillit.core import resolve_skill_temp_dir as _resolve_skill_temp_dir
40-
from autoskillit.hook_registry import HOOK_REGISTRY
4140
from autoskillit.pipeline import canonical_step_name as _canonical_step_name
4241
from autoskillit.server import mcp
4342
from autoskillit.server._guards import (
@@ -65,6 +64,7 @@
6564
resolve_relative_path_args,
6665
validate_path_arg_anchoring,
6766
)
67+
from autoskillit.server.tools._preflight import _get_fix_required_hook_matchers
6868

6969
logger = get_logger(__name__)
7070

@@ -87,19 +87,6 @@ def _is_backend_incompatible(skill_info: object, effective_backend: str) -> bool
8787
return bool(reqs and effective_backend not in reqs)
8888

8989

90-
def _get_fix_required_hook_matchers(applicable_guards: frozenset[str]) -> list[str]:
91-
"""Return matchers of fix-required hooks not enforced by the given guard set."""
92-
return [
93-
h.matcher
94-
for h in HOOK_REGISTRY
95-
if h.codex_status == "fix-required"
96-
and (
97-
not h.scripts
98-
or not frozenset(Path(s).stem for s in h.scripts).issubset(applicable_guards)
99-
)
100-
]
101-
102-
10390
def _check_backend_compat(
10491
skill_command: str,
10592
resolved_command: str,

src/autoskillit/server/tools/tools_fleet_dispatch.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@
4848
from autoskillit.server._misc import resolve_log_dir
4949
from autoskillit.server._notify import track_response_size
5050
from autoskillit.server.tools._cancellation_shield import _cancellation_shield
51+
from autoskillit.server.tools._preflight import (
52+
_check_dispatch_feasibility,
53+
filter_steps_by_post_prune,
54+
)
5155

5256
logger = get_logger(__name__)
5357

@@ -312,6 +316,47 @@ async def dispatch_food_truck(
312316
"Dispatch skipped: skip_when condition evaluated to true",
313317
)
314318

319+
# Dispatch-feasibility preflight: verify the backend can enforce
320+
# all fix-required hooks for the recipe's run_skill steps before
321+
# spawning a subprocess.
322+
_fleet_load_result: dict[str, Any] = {}
323+
if tool_ctx.recipes is not None:
324+
try:
325+
_fleet_load_result = tool_ctx.recipes.load_and_validate(
326+
recipe,
327+
tool_ctx.project_dir,
328+
suppressed=tool_ctx.config.migration.suppressed if tool_ctx.config else None,
329+
ingredient_overrides=ingredients,
330+
temp_dir=tool_ctx.temp_dir,
331+
backend_name=tool_ctx.backend.name if tool_ctx.backend else None,
332+
)
333+
except Exception:
334+
logger.warning("dispatch_food_truck_preflight_load_failed", exc_info=True)
335+
336+
_active_recipe_steps: dict[str, Any] | None = None
337+
if _fleet_load_result and tool_ctx.recipes is not None:
338+
try:
339+
_recipe_info = tool_ctx.recipes.find(recipe, tool_ctx.project_dir)
340+
if _recipe_info is not None:
341+
_recipe_obj = tool_ctx.recipes.load(_recipe_info.path)
342+
_active_recipe_steps = filter_steps_by_post_prune(
343+
_recipe_obj.steps,
344+
_fleet_load_result.get("post_prune_step_names", []),
345+
)
346+
except Exception:
347+
logger.warning("dispatch_food_truck_preflight_recipe_load_failed", exc_info=True)
348+
349+
if tool_ctx.backend is not None and _active_recipe_steps is not None:
350+
_preflight_err = _check_dispatch_feasibility(
351+
post_prune_step_names=_fleet_load_result.get("post_prune_step_names", []),
352+
active_recipe_steps=_active_recipe_steps,
353+
backend=tool_ctx.backend,
354+
config_providers=tool_ctx.config.providers,
355+
recipe_name=recipe,
356+
)
357+
if _preflight_err is not None:
358+
return _preflight_err
359+
315360
result = await execute_dispatch(
316361
tool_ctx=tool_ctx,
317362
recipe=recipe,

src/autoskillit/server/tools/tools_kitchen.py

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,10 @@
6161
_promote_capability_keys,
6262
)
6363
from autoskillit.server.tools._cancellation_shield import _cancellation_shield
64+
from autoskillit.server.tools._preflight import (
65+
_check_dispatch_feasibility,
66+
filter_steps_by_post_prune,
67+
)
6468

6569
logger = get_logger(__name__)
6670

@@ -625,7 +629,9 @@ async def open_kitchen(
625629
try:
626630
recipe_obj = tool_ctx.recipes.load(recipe_info.path)
627631
_deferred_recipe_obj = recipe_obj
628-
tool_ctx.active_recipe_steps = dict(recipe_obj.steps)
632+
tool_ctx.active_recipe_steps = filter_steps_by_post_prune(
633+
recipe_obj.steps, result.get("post_prune_step_names", [])
634+
)
629635
tool_ctx.active_recipe_ingredients = frozenset(
630636
recipe_obj.ingredients.keys()
631637
)
@@ -638,7 +644,22 @@ async def open_kitchen(
638644
tool_ctx.active_recipe_ingredients = None
639645
# Default to False for missing 'valid' so a absent key is treated as invalid
640646
if not result.get("valid", False) or not result.get("content", ""):
647+
tool_ctx.gate.disable()
641648
return _recipe_validation_error_response(name, result)
649+
# Dispatch-feasibility preflight: verify the backend can enforce
650+
# all fix-required hooks for the recipe's run_skill steps.
651+
if tool_ctx.active_recipe_steps is not None:
652+
_preflight_err = _check_dispatch_feasibility(
653+
post_prune_step_names=result.get("post_prune_step_names", []),
654+
active_recipe_steps=tool_ctx.active_recipe_steps,
655+
backend=tool_ctx.backend,
656+
config_providers=tool_ctx.config.providers,
657+
recipe_name=name,
658+
)
659+
if _preflight_err is not None:
660+
tool_ctx.gate.disable()
661+
await ctx.disable_components(tags={"kitchen"})
662+
return _preflight_err
642663
result["success"] = True
643664
result["kitchen"] = "open"
644665
result["version"] = __version__
@@ -708,7 +729,9 @@ async def open_kitchen(
708729
try:
709730
recipe_obj = tool_ctx.recipes.load(recipe_info.path)
710731
_normal_recipe_obj = recipe_obj
711-
tool_ctx.active_recipe_steps = dict(recipe_obj.steps)
732+
tool_ctx.active_recipe_steps = filter_steps_by_post_prune(
733+
recipe_obj.steps, result.get("post_prune_step_names", [])
734+
)
712735
tool_ctx.active_recipe_ingredients = frozenset(recipe_obj.ingredients.keys())
713736
except Exception:
714737
logger.warning("open_kitchen_recipe_steps_cache_failed", exc_info=True)
@@ -725,8 +748,24 @@ async def open_kitchen(
725748
return _kitchen_failure_envelope(exc, stage="apply_triage_gate")
726749

727750
if not result.get("valid", False) or not result.get("content", ""):
751+
tool_ctx.gate.disable()
728752
return _recipe_validation_error_response(name, result)
729753

754+
# Dispatch-feasibility preflight: verify the backend can enforce
755+
# all fix-required hooks for the recipe's run_skill steps.
756+
if tool_ctx.active_recipe_steps is not None:
757+
_preflight_err = _check_dispatch_feasibility(
758+
post_prune_step_names=result.get("post_prune_step_names", []),
759+
active_recipe_steps=tool_ctx.active_recipe_steps,
760+
backend=tool_ctx.backend,
761+
config_providers=tool_ctx.config.providers,
762+
recipe_name=name,
763+
)
764+
if _preflight_err is not None:
765+
tool_ctx.gate.disable()
766+
await ctx.disable_components(tags={"kitchen"})
767+
return _preflight_err
768+
730769
result["success"] = True
731770
result["kitchen"] = "open"
732771
result["version"] = __version__

tests/_test_filter.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -858,8 +858,10 @@ class ImportContext(enum.StrEnum):
858858
"hooks",
859859
"cli",
860860
"execution",
861-
# server/ narrowed to 3 files
861+
# server/ narrowed to 4 files
862862
"server/test_tools_kitchen_envelope.py",
863+
"server/test_tools_kitchen_preflight.py",
864+
"server/test_tools_fleet_dispatch_preflight.py",
863865
"server/test_lifespan.py",
864866
"server/test_run_skill_backend_compat.py",
865867
# infra/ narrowed to 7 files

tests/arch/test_subpackage_isolation.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -876,7 +876,7 @@ def test_no_subpackage_exceeds_10_files() -> None:
876876
"pipeline": 12,
877877
"fleet": 22, # REQ-CNST-003-E9: _dispatch_reaper.py; +_sidecar_synthesis.py; +_reset.py
878878
"recipe/rules": 51, # +commit_guard_regression_route +rules_model +rules_gitignored_deliverable # noqa: E501
879-
"server/tools": 26, # _auto_overrides.py + _cancellation_shield.py + tools_fleet_reset.py
879+
"server/tools": 27, # +_preflight.py (dispatch-feasibility preflight)
880880
"hooks/guards": 32, # +fleet_claim_guard, +reset_resume_gate, +recipe_read_guard
881881
}
882882
violations: list[str] = []
@@ -962,13 +962,14 @@ def test_data_directories_are_not_python_packages() -> None:
962962
"co-located with the execution engine that calls them",
963963
),
964964
"tools_kitchen.py": (
965-
1160,
965+
1200,
966966
"REQ-CNST-010-E7: kitchen tool handlers — open_kitchen and lock_ingredients require "
967967
"inline validation helpers (_check_override_keys, _build_ingredient_key_suggestions) "
968968
"for ingredient key validation; splitting would cross import-layer boundaries; "
969969
"backend capability promotion delegated to _promote_capability_keys in _auto_overrides; "
970970
"fail-closed validity gate on both deferred-recall and normal paths adds structural "
971-
"error propagation from LoadRecipeResult; get_recipe validity guard adds 9 lines",
971+
"error propagation from LoadRecipeResult; get_recipe validity guard adds 9 lines; "
972+
"dispatch-feasibility preflight wiring on both paths with gate-close on failure",
972973
),
973974
"tools_execution.py": (
974975
1130,

0 commit comments

Comments
 (0)