Skip to content

Commit 1112c0f

Browse files
Trecekclaude
andauthored
[FIX] Dispatch Feasibility Immunity — Capability-Aware Admission Control (#4130)
## Summary Three independent subsystems — step pruning (`_prune_skipped_steps`), capability feasibility (`_compute_capability_feasibility`), and per-step backend override (`run_skill` provider rerouting) — operate without cross-communication. The capability feasibility check uses a binary pipeline-level verdict even though step pruning already achieves per-step granularity. A gate step (`gate_backend_write`) that guards already-pruned operations survives pruning and blocks the entire recipe. Additionally, `dispatch_food_truck` silently ignores the `dispatch_feasible` field and omits capability override injection, creating a bypass for the entire admission control system. The arch test falsely claims four surfaces gate on `dispatch_feasible` when only three are tested. The immunity plan addresses these through three coordinated changes: 1. **Vacuous-gate detection** in `_compute_capability_feasibility` — a gate whose guarded operations were all pruned is vacuously infeasible and should not block the pipeline 2. **Fleet dispatch admission parity** — `dispatch_food_truck` must inject capability overrides and check `dispatch_feasible`, closing the bypass; `_run_dispatch` must also check `dispatch_feasible` as defense-in-depth 3. **Arch test truthfulness** — add the missing `dispatch_food_truck` structural assertion and registry-sync updates Closes #4109 ## Implementation Plan Plan file: `/home/talon/projects/autoskillit-runs/remediation-20260627-120516-576225/.autoskillit/temp/rectify/rectify_dispatch_feasibility_immunity_2026-06-27_130000.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 | |------|-------|-------|----------|--------|------------|----------|-------|-------------|------| | plan* | opus[1m] | 1 | 756 | 15.7k | 1.1M | 91.7k | 45 | 96.5k | 9m 27s | | verify* | sonnet | 1 | 1.3k | 10.7k | 685.4k | 55.8k | 41 | 56.0k | 7m 17s | | implement* | MiniMax-M3 | 1 | 86.7k | 16.2k | 4.0M | 0 | 141 | 0 | 5m 34s | | fix* | sonnet | 1 | 182 | 9.9k | 1.3M | 81.3k | 59 | 62.8k | 6m 36s | | audit_impl* | sonnet | 1 | 44 | 10.4k | 165.5k | 44.1k | 14 | 36.7k | 5m 33s | | prepare_pr* | MiniMax-M3 | 1 | 42.8k | 2.4k | 187.4k | 0 | 13 | 0 | 32s | | compose_pr* | MiniMax-M3 | 1 | 36.3k | 1.7k | 177.7k | 0 | 12 | 0 | 35s | | review_pr* | sonnet | 1 | 158 | 41.5k | 1.2M | 94.8k | 46 | 77.2k | 10m 41s | | resolve_review* | sonnet | 1 | 207 | 15.4k | 1.4M | 81.0k | 66 | 63.0k | 9m 50s | | dispatch:708cbd54-0959-49f1-b3f1-6458eb4371a2* | sonnet | 1 | 354 | 15.4k | 4.3M | 111.0k | 127 | 111.3k | 1h 19m | | **Total** | | | 168.8k | 139.3k | 14.7M | 111.0k | | 503.4k | 2h 15m | \* *Step used a non-Anthropic provider; caching behavior may differ.* ## Token Efficiency | Step | LoC Changed | cache_read/LoC | cache_write/LoC | output/LoC | |------|-------------|----------------|-----------------|------------| | plan | 0 | — | — | — | | verify | 0 | — | — | — | | implement | 181 | 22071.2 | 0.0 | 89.6 | | fix | 21 | 63991.2 | 2989.3 | 470.1 | | audit_impl | 0 | — | — | — | | prepare_pr | 0 | — | — | — | | compose_pr | 0 | — | — | — | | review_pr | 0 | — | — | — | | resolve_review | 3 | 481018.0 | 21014.7 | 5130.0 | | dispatch:708cbd54-0959-49f1-b3f1-6458eb4371a2 | 0 | — | — | — | | **Total** | **205** | 71487.3 | 2455.8 | 679.5 | ## Model Usage Breakdown | Model | steps | uncached | output | cache_read | cache_write | time | |-------|-------|----------|--------|------------|-------------|------| | opus[1m] | 1 | 756 | 15.7k | 1.1M | 96.5k | 9m 27s | | sonnet | 6 | 2.3k | 103.3k | 9.2M | 407.0k | 1h 59m | | MiniMax-M3 | 3 | 165.7k | 20.3k | 4.4M | 0 | 6m 42s | --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 3a18305 commit 1112c0f

15 files changed

Lines changed: 425 additions & 34 deletions

src/autoskillit/core/__init__.pyi

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ from .types import AUTOSKILLIT_WRITE_GUARD_TOOL_NAMES as AUTOSKILLIT_WRITE_GUARD
134134
from .types import BACKEND_CAPABILITY_INGREDIENTS as BACKEND_CAPABILITY_INGREDIENTS
135135
from .types import CAMPAIGN_ID_ENV_VAR as CAMPAIGN_ID_ENV_VAR
136136
from .types import CAPABILITY_GATE_CALLABLES as CAPABILITY_GATE_CALLABLES
137+
from .types import CAPABILITY_INGREDIENT_TO_SKIP_GUARD as CAPABILITY_INGREDIENT_TO_SKIP_GUARD
137138
from .types import CAPTURE_VALID_VALUE_TYPES as CAPTURE_VALID_VALUE_TYPES
138139
from .types import CATEGORY_TAGS as CATEGORY_TAGS
139140
from .types import CLAUDE_CODE_CAPABILITIES as CLAUDE_CODE_CAPABILITIES

src/autoskillit/core/types/_type_constants.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"SCOPE_DIRECTION_SOURCE_TYPES",
3535
"WORKTREE_SKILLS",
3636
"BACKEND_CAPABILITY_INGREDIENTS",
37+
"CAPABILITY_INGREDIENT_TO_SKIP_GUARD",
3738
"CAPABILITY_GATE_CALLABLES",
3839
"SkillFamilyDef",
3940
"GITHUB_API_SKILL_FAMILIES",
@@ -97,6 +98,13 @@
9798
}
9899
)
99100

101+
# Maps each BACKEND_CAPABILITY_INGREDIENTS key to its corresponding
102+
# skip_when_false ingredient reference string. Used by capability-feasibility
103+
# to detect vacuous gates — a gate whose guarded steps were all pruned.
104+
CAPABILITY_INGREDIENT_TO_SKIP_GUARD: dict[str, str] = {
105+
"backend_supports_git_write": "inputs.backend_supports_git_write",
106+
}
107+
100108
# Bare (un-dotted) callable names whose run_python steps are capability gates.
101109
# Each entry corresponds to a callable in smoke_utils that reads a
102110
# BACKEND_CAPABILITY_INGREDIENTS key and returns a verdict dict. When a

src/autoskillit/fleet/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
``autoskillit.fleet``, not from sub-modules.
55
"""
66

7+
from ._api import _build_capability_overrides as _build_capability_overrides
78
from ._api import _write_pid as _write_pid
89
from ._api import execute_dispatch
910
from ._capture import CaptureCompletenessError
@@ -101,6 +102,7 @@
101102
)
102103

103104
__all__ = [
105+
"_build_capability_overrides",
104106
"_write_pid",
105107
"cleanup_orphaned_labels",
106108
"discover_campaign_state_files",

src/autoskillit/fleet/_api.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,10 +347,13 @@ async def _run_dispatch(
347347
)
348348

349349
try:
350+
_capability_overrides = _build_capability_overrides(tool_ctx.backend)
351+
_merged_ingredients = {**(ingredients or {}), **_capability_overrides}
350352
validation_result = tool_ctx.recipes.load_and_validate(
351353
recipe,
352354
tool_ctx.project_dir,
353355
suppressed=tool_ctx.config.migration.suppressed if tool_ctx.config else None,
356+
ingredient_overrides=_merged_ingredients,
354357
temp_dir=tool_ctx.temp_dir,
355358
backend_name=tool_ctx.backend.name if tool_ctx.backend else None,
356359
)
@@ -388,6 +391,19 @@ async def _run_dispatch(
388391
per_dispatch_state_path=None,
389392
)
390393

394+
if not validation_result.get("dispatch_feasible", True):
395+
infeasible_steps = validation_result.get("infeasible_steps", [])
396+
return DispatchResult(
397+
DispatchRejected(
398+
error_code=FleetErrorCode.FLEET_RECIPE_INVALID,
399+
message=(
400+
f"Recipe '{recipe}' is dispatch-infeasible: "
401+
f"infeasible_steps={infeasible_steps}"
402+
),
403+
),
404+
per_dispatch_state_path=None,
405+
)
406+
391407
try:
392408
full_recipe = tool_ctx.recipes.load(recipe_obj.path)
393409
except Exception as exc:

src/autoskillit/recipe/_api.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,8 @@ def load_and_validate(
407407
ingredient_overrides or {},
408408
capability_ingredient_keys=BACKEND_CAPABILITY_INGREDIENTS,
409409
capability_gate_callables=CAPABILITY_GATE_CALLABLES,
410+
skip_resolutions=_skip_resolutions,
411+
pre_prune_steps=_pre_prune_steps,
410412
)
411413
t0 = _t("capability_feasibility", t0, name)
412414

src/autoskillit/recipe/_recipe_composition.py

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
import regex as re
1111

12-
from autoskillit.core import YAMLError, load_yaml
12+
from autoskillit.core import CAPABILITY_INGREDIENT_TO_SKIP_GUARD, YAMLError, load_yaml
1313
from autoskillit.recipe._contracts_types import INPUT_REF_RE
1414
from autoskillit.recipe.io import find_sub_recipe_by_name
1515
from autoskillit.recipe.io import load_recipe as _load_recipe_from_path
@@ -112,6 +112,8 @@ def _compute_capability_feasibility(
112112
*,
113113
capability_ingredient_keys: frozenset[str],
114114
capability_gate_callables: frozenset[str],
115+
skip_resolutions: dict[str, bool | None] | None = None,
116+
pre_prune_steps: dict[str, Any] | None = None,
115117
) -> tuple[bool, list[str]]:
116118
"""Detect dead-on-arrival pipelines caused by capability-gated run_python steps.
117119
@@ -124,6 +126,10 @@ def _compute_capability_feasibility(
124126
3. The step's on_result / on_failure routes the falsy case to a terminal
125127
failure target ("escalate") rather than recovery.
126128
129+
A gate is vacuous (and removed from infeasible) when all steps gated by
130+
the same capability ingredient were pruned by skip_when_false and no
131+
surviving step references that capability.
132+
127133
Returns (is_feasible, infeasible_step_names).
128134
"""
129135
if not post_prune_recipe or not getattr(post_prune_recipe, "steps", None):
@@ -154,12 +160,59 @@ def _compute_capability_feasibility(
154160
continue
155161
on_failure = getattr(step, "on_failure", None) or "escalate"
156162
on_exhausted = getattr(step, "on_exhausted", None) or "escalate"
157-
if on_failure == "escalate" or on_exhausted == "escalate":
158-
infeasible.append(step_name)
163+
if on_failure != "escalate" and on_exhausted != "escalate":
164+
continue
165+
if _is_vacuous_gate(
166+
gate_input_keys,
167+
gate_step_name=step_name,
168+
skip_resolutions=skip_resolutions,
169+
pre_prune_steps=pre_prune_steps,
170+
post_prune_steps=steps,
171+
):
172+
continue
173+
infeasible.append(step_name)
159174

160175
return (not infeasible), infeasible
161176

162177

178+
def _is_vacuous_gate(
179+
gate_input_keys: set[str],
180+
*,
181+
gate_step_name: str,
182+
skip_resolutions: dict[str, bool | None] | None,
183+
pre_prune_steps: dict[str, Any] | None,
184+
post_prune_steps: dict[str, Any],
185+
) -> bool:
186+
"""Return True if the gate is vacuous — its guarded operations were all pruned."""
187+
if skip_resolutions is None or pre_prune_steps is None:
188+
return False
189+
for cap_key in gate_input_keys:
190+
guard_ref = CAPABILITY_INGREDIENT_TO_SKIP_GUARD.get(cap_key)
191+
if guard_ref is None:
192+
return False
193+
pruned_for_capability = False
194+
for pre_step_name, pre_step in pre_prune_steps.items():
195+
skip_guard = getattr(pre_step, "skip_when_false", None)
196+
if skip_guard != guard_ref:
197+
continue
198+
if skip_resolutions.get(pre_step_name) is False:
199+
pruned_for_capability = True
200+
break
201+
if not pruned_for_capability:
202+
return False
203+
live_uses_capability = False
204+
for sn, s in post_prune_steps.items():
205+
if s is None or sn == gate_step_name:
206+
continue
207+
with_args = getattr(s, "with_args", None) or {}
208+
if cap_key in with_args:
209+
live_uses_capability = True
210+
break
211+
if live_uses_capability:
212+
return False
213+
return True
214+
215+
163216
def _drop_sub_recipe_step(recipe: Any, step_name: str) -> Any:
164217
"""Return a new Recipe with the named sub_recipe placeholder step removed."""
165218
new_steps = {k: v for k, v in recipe.steps.items() if k != step_name}

src/autoskillit/server/tools/tools_fleet_dispatch.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
DispatchRejected,
3333
DispatchResult,
3434
DispatchStatus,
35+
_build_capability_overrides,
3536
_build_food_truck_prompt,
3637
evaluate_skip_when,
3738
execute_dispatch,
@@ -321,17 +322,32 @@ async def dispatch_food_truck(
321322
_fleet_load_result: dict[str, Any] = {}
322323
if tool_ctx.recipes is not None:
323324
try:
325+
_capability_overrides = _build_capability_overrides(tool_ctx.backend)
326+
_merged_ingredients = {**(ingredients or {}), **_capability_overrides}
324327
_fleet_load_result = tool_ctx.recipes.load_and_validate(
325328
recipe,
326329
tool_ctx.project_dir,
327330
suppressed=tool_ctx.config.migration.suppressed if tool_ctx.config else None,
328-
ingredient_overrides=ingredients,
331+
ingredient_overrides=_merged_ingredients,
329332
temp_dir=tool_ctx.temp_dir,
330333
backend_name=tool_ctx.backend.name if tool_ctx.backend else None,
331334
)
332335
except Exception:
333336
logger.warning("dispatch_food_truck_preflight_load_failed", exc_info=True)
334337

338+
if not _fleet_load_result.get("dispatch_feasible", True):
339+
_infeasible_steps = _fleet_load_result.get("infeasible_steps", [])
340+
_infeasible_msg = (
341+
f"Recipe '{recipe}' is dispatch-infeasible: capability gate(s) "
342+
f"blocked at preflight. Infeasible steps: {_infeasible_steps}"
343+
)
344+
logger.warning(
345+
"dispatch_food_truck_capability_infeasible",
346+
recipe=recipe,
347+
infeasible_steps=_infeasible_steps,
348+
)
349+
return fleet_error(FleetErrorCode.FLEET_RECIPE_INVALID, _infeasible_msg)
350+
335351
_active_recipe_steps: dict[str, Any] | None = None
336352
if _fleet_load_result and tool_ctx.recipes is not None:
337353
try:

tests/arch/test_capability_admission_control.py

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
"""Architectural structural tests for capability admission control.
22
33
Verifies that:
4-
- _compute_capability_feasibility is called inside load_and_validate
4+
- _compute_capability_feasibility is called inside load_and_validate with skip_resolutions
55
- All four content-serving surfaces gate on dispatch_feasible
6+
(open_kitchen, get_recipe, load_recipe, dispatch_food_truck)
67
- CAPABILITY_GATE_CALLABLES entries have a corresponding BACKEND_CAPABILITY_INGREDIENTS input
78
- Every run_python step using a CAPABILITY_GATE_CALLABLES callable reads a capability ingredient
9+
- dispatch_food_truck injects _build_capability_overrides for backend capability signals
810
"""
911

1012
from __future__ import annotations
@@ -104,6 +106,73 @@ def test_load_recipe_gates_on_dispatch_feasible() -> None:
104106
pytest.fail("load_recipe function not found in tools_recipe.py")
105107

106108

109+
def test_dispatch_food_truck_gates_on_dispatch_feasible() -> None:
110+
"""dispatch_food_truck must check dispatch_feasible before executing."""
111+
fleet_path = SRC_ROOT / "server" / "tools" / "tools_fleet_dispatch.py"
112+
src = fleet_path.read_text(encoding="utf-8")
113+
tree = ast.parse(src)
114+
for node in ast.walk(tree):
115+
if isinstance(node, ast.AsyncFunctionDef) and node.name == "dispatch_food_truck":
116+
found = False
117+
for child in ast.walk(node):
118+
if isinstance(child, ast.Constant) and child.value == "dispatch_feasible":
119+
found = True
120+
break
121+
assert found, (
122+
"dispatch_food_truck must reference 'dispatch_feasible' to gate "
123+
"capability-DOA fleet dispatches before subprocess launch."
124+
)
125+
return
126+
pytest.fail("dispatch_food_truck function not found in tools_fleet_dispatch.py")
127+
128+
129+
def test_compute_capability_feasibility_receives_skip_resolutions() -> None:
130+
"""load_and_validate must pass skip_resolutions to _compute_capability_feasibility
131+
so the vacuous-gate detection has access to pruning context."""
132+
api_path = SRC_ROOT / "recipe" / "_api.py"
133+
tree = ast.parse(api_path.read_text(encoding="utf-8"))
134+
for node in ast.walk(tree):
135+
if (
136+
isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
137+
and node.name == "load_and_validate"
138+
):
139+
for child in ast.walk(node):
140+
if (
141+
isinstance(child, ast.Call)
142+
and isinstance(child.func, ast.Name)
143+
and child.func.id == "_compute_capability_feasibility"
144+
):
145+
kw_names = {kw.arg for kw in child.keywords if kw.arg is not None}
146+
assert "skip_resolutions" in kw_names, (
147+
"load_and_validate must pass skip_resolutions kwarg "
148+
"to _compute_capability_feasibility for vacuous-gate detection."
149+
)
150+
return
151+
pytest.fail("_compute_capability_feasibility call not found in load_and_validate")
152+
pytest.fail("load_and_validate function not found in _api.py")
153+
154+
155+
def test_dispatch_food_truck_injects_capability_overrides() -> None:
156+
"""dispatch_food_truck must reference _build_capability_overrides to inject
157+
backend capability signals into the load_and_validate call."""
158+
fleet_path = SRC_ROOT / "server" / "tools" / "tools_fleet_dispatch.py"
159+
src = fleet_path.read_text(encoding="utf-8")
160+
tree = ast.parse(src)
161+
for node in ast.walk(tree):
162+
if isinstance(node, ast.AsyncFunctionDef) and node.name == "dispatch_food_truck":
163+
found = False
164+
for child in ast.walk(node):
165+
if isinstance(child, ast.Name) and child.id == "_build_capability_overrides":
166+
found = True
167+
break
168+
assert found, (
169+
"dispatch_food_truck must reference _build_capability_overrides "
170+
"to inject backend capability signals into load_and_validate."
171+
)
172+
return
173+
pytest.fail("dispatch_food_truck function not found in tools_fleet_dispatch.py")
174+
175+
107176
def test_capability_gate_callables_have_matching_ingredient() -> None:
108177
"""Every CAPABILITY_GATE_CALLABLES callable must map to a BACKEND_CAPABILITY_INGREDIENTS input.
109178
@@ -129,3 +198,21 @@ def test_capability_gate_callables_have_matching_ingredient() -> None:
129198
assert len(CAPABILITY_GATE_CALLABLES) > 0, (
130199
"CAPABILITY_GATE_CALLABLES must declare at least one capability gate callable."
131200
)
201+
202+
203+
def test_capability_ingredient_to_skip_guard_keys_subset() -> None:
204+
"""CAPABILITY_INGREDIENT_TO_SKIP_GUARD keys must be BACKEND_CAPABILITY_INGREDIENTS subset."""
205+
from autoskillit.core import (
206+
BACKEND_CAPABILITY_INGREDIENTS,
207+
CAPABILITY_INGREDIENT_TO_SKIP_GUARD,
208+
)
209+
210+
orphaned = set(CAPABILITY_INGREDIENT_TO_SKIP_GUARD) - set(BACKEND_CAPABILITY_INGREDIENTS)
211+
assert not orphaned, (
212+
f"CAPABILITY_INGREDIENT_TO_SKIP_GUARD keys {orphaned} are not in "
213+
f"BACKEND_CAPABILITY_INGREDIENTS — vacuous-gate detection will silently "
214+
f"malfunction for these keys."
215+
)
216+
assert len(CAPABILITY_INGREDIENT_TO_SKIP_GUARD) > 0, (
217+
"CAPABILITY_INGREDIENT_TO_SKIP_GUARD must declare at least one mapping."
218+
)

tests/arch/test_subpackage_structure.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,8 @@ def test_type_constants_split_completeness(self):
6060
assert len(combined) == len(remaining) + len(env) + len(features) + len(registries), (
6161
"Duplicate symbols across split modules"
6262
)
63-
assert len(combined) == 93, (
64-
f"Expected 93 symbols total, got {len(combined)} "
63+
assert len(combined) == 94, (
64+
f"Expected 94 symbols total, got {len(combined)} "
6565
f"(remaining={len(remaining)}, env={len(env)}, "
6666
f"features={len(features)}, registries={len(registries)})"
6767
)

tests/fleet/test_dispatch_ingredient_validation.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
from __future__ import annotations
44

5+
from unittest.mock import AsyncMock, MagicMock
6+
57
import pytest
68

79
from tests.fleet._helpers import (
@@ -548,3 +550,46 @@ def test_capability_overrides_dict_covers_registry_keys():
548550
assert not missing, (
549551
f"Capability registry keys missing from dispatch capability_overrides: {missing}"
550552
)
553+
554+
555+
@pytest.mark.anyio
556+
async def test_run_dispatch_rejects_dispatch_infeasible(tool_ctx):
557+
"""When _run_dispatch's inner load_and_validate returns dispatch_feasible=False,
558+
it must return DispatchRejected without proceeding to subprocess launch."""
559+
from unittest.mock import patch
560+
561+
tool_ctx.recipes = MagicMock()
562+
tool_ctx.recipes.load_and_validate.return_value = {
563+
"valid": True,
564+
"dispatch_feasible": False,
565+
"infeasible_steps": ["gate_backend_write"],
566+
}
567+
tool_ctx.recipes.find.return_value = MagicMock()
568+
tool_ctx.recipes.load.return_value = MagicMock()
569+
570+
with patch(
571+
"autoskillit.fleet._api.execute_dispatch",
572+
new_callable=AsyncMock,
573+
) as mock_exec:
574+
from autoskillit.fleet._api import _run_dispatch
575+
576+
result = await _run_dispatch(
577+
tool_ctx=tool_ctx,
578+
recipe="test-recipe",
579+
task="test task",
580+
ingredients={},
581+
dispatch_name=None,
582+
timeout_sec=None,
583+
prompt_builder=lambda **kw: "prompt",
584+
quota_checker=_no_sleep_quota_checker,
585+
quota_refresher=_noop_quota_refresher,
586+
)
587+
588+
mock_exec.assert_not_called()
589+
from autoskillit.fleet.state_types import DispatchRejected
590+
591+
assert isinstance(result.outcome, DispatchRejected)
592+
assert (
593+
"gate_backend_write" in result.outcome.message
594+
or "dispatch" in result.outcome.message.lower()
595+
)

0 commit comments

Comments
 (0)