Skip to content

Commit 8611e8f

Browse files
Trecekclaude
andauthored
dispatch_food_truck per-dispatch backend override (heterogeneous routing R1) (#4182)
## Summary Add an optional `backend` parameter to the `dispatch_food_truck` MCP tool that selects which coding-agent backend executes the dispatched child session. When omitted, behavior is byte-for-byte identical to today (uses `ctx.backend`). When supplied, the per-dispatch backend is used for: capability gating (`food_truck_capable`), command building (`build_food_truck_cmd`), pre-launch validation (`ensure_pre_launch`), recipe admission control (capability overrides, `backend-incompatible-skill` rule, `effective_backend_map`), session-log resolution, and record persistence. The `DispatchRecord` gains a `backend_name` field so downstream consumers (reset, diagnostics, session-log lookup) resolve against the correct backend. The field is added to `_RETRY_IDENTITY_FIELDS` so retried dispatches preserve the backend override. The implementation follows the existing `run_headless_core` call-local override pattern: `ctx.backend` is never mutated; a resolved `CodingAgentBackend` instance is threaded through the dispatch path as a function argument. ## Requirements **REQ-HBR-001:** The `dispatch_food_truck` MCP tool (`server/tools/tools_fleet_dispatch.py`) must accept an optional `backend` parameter whose accepted values are the names registered in `BACKEND_REGISTRY` (`"claude-code"`, `"codex"`). **REQ-HBR-002:** When the `backend` parameter is omitted, dispatch behavior must be byte-for-byte identical to the current `ctx.backend` path — zero behavior change for existing single-backend flows. **REQ-HBR-003:** The child session command must be built via the per-dispatch backend's `build_food_truck_cmd`, with backend-correct env injection (`AUTOSKILLIT_AGENT_BACKEND__BACKEND`, `AUTOSKILLIT_AGENT_BACKEND`, `AUTOSKILLIT_MCP_CLIENT_BACKEND`) so the child process resolves the per-dispatch backend at boot. **REQ-HBR-004:** `ensure_pre_launch()` must be invoked on the per-dispatch backend before spawn (e.g., Codex MCP registration in `config.toml`). **REQ-HBR-005:** The `food_truck_capable` capability gate must be evaluated against the per-dispatch backend, not the kitchen backend. **REQ-HBR-006:** Recipe admission control and capability overrides must be computed against the per-dispatch backend: `_provider_aware_capability_overrides`, the `backend_supports_git_write` ingredient, `_compute_effective_backend_map`, and the `backend-incompatible-skill` validation rule (`server/tools/_auto_overrides.py`, `recipe/rules/rules_backend_compat.py`). **REQ-HBR-007:** Per-dispatch backend selection must compose with existing provider profiles (e.g., a MiniMax implement-step provider override via `ANTHROPIC_BASE_URL`) and with the R0 capability-driven auto-route (#4176) inside the child session — existing provider recipe overrides must behave identically. **REQ-HBR-008:** The `DispatchRecord` must persist which backend the dispatch ran on, so that `reset_dispatch`, diagnostics, and session-log lookup (Channel B for Claude Code vs `CodexSessionLocator` for Codex) resolve against the correct backend. **REQ-HBR-009:** Any `BackendCapabilities` field addition required by this work must be forward-declared with a tracking entry in `_FORWARD_DECLARED` in `test_capability_consumption.py` (frozen-dataclass constraint noted in the forward-obligations doc). Closes #4179 ## Implementation Plan Plan file: `.autoskillit/temp/make-plan/dispatch_food_truck_per_dispatch_backend_override_plan_2026-07-04_152400.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 | 2.2k | 44.9k | 4.1M | 168.5k | 87 | 149.6k | 22m 37s | | review_approach* | opus | 1 | 9.2k | 5.3k | 199.4k | 49.0k | 12 | 61.8k | 6m 1s | | **Total** | | | 11.4k | 50.2k | 4.3M | 168.5k | | 211.4k | 28m 39s | \* *Step used a non-Anthropic provider; caching behavior may differ.* ## Model Usage Breakdown | Model | steps | uncached | output | cache_read | cache_write | time | |-------|-------|----------|--------|------------|-------------|------| | opus[1m] | 1 | 2.2k | 44.9k | 4.1M | 149.6k | 22m 37s | | opus | 1 | 9.2k | 5.3k | 199.4k | 61.8k | 6m 1s | --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 7a03a23 commit 8611e8f

16 files changed

Lines changed: 317 additions & 30 deletions

src/autoskillit/core/types/_type_enums.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,6 +450,7 @@ class FleetErrorCode(StrEnum):
450450
FLEET_RESET_NOT_FOUND = "fleet_reset_not_found"
451451
FLEET_RESET_INVALID_TARGET = "fleet_reset_invalid_target"
452452
FLEET_RESET_STILL_RUNNING = "fleet_reset_still_running"
453+
FLEET_INVALID_BACKEND = "fleet_invalid_backend"
453454

454455

455456
class FeatureLifecycle(StrEnum):

src/autoskillit/execution/headless/__init__.py

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -382,11 +382,28 @@ async def dispatch_food_truck(
382382
backend_override: str | None = None,
383383
on_session_id_resolved: Callable[[str], None] | None = None,
384384
) -> SkillResult:
385-
if self._ctx.backend is not None and not self._ctx.backend.capabilities.food_truck_capable:
385+
dispatch_backend: CodingAgentBackend | None
386+
if backend_override is not None:
387+
from autoskillit.execution.backends import get_backend
388+
389+
dispatch_backend = get_backend(backend_override)
390+
else:
391+
dispatch_backend = self._ctx.backend
392+
393+
if dispatch_backend is not None and not dispatch_backend.capabilities.food_truck_capable:
386394
raise RuntimeError(
387395
f"backend does not support food truck dispatch "
388-
f"(food_truck_capable=False); got {self._ctx.backend.name!r}"
396+
f"(food_truck_capable=False); got {dispatch_backend.name!r}"
389397
)
398+
if backend_override is not None:
399+
assert dispatch_backend is not None
400+
pre_launch_errors = dispatch_backend.ensure_pre_launch()
401+
if pre_launch_errors:
402+
raise RuntimeError(
403+
f"Pre-launch check failed for dispatch backend "
404+
f"{dispatch_backend.name!r}: {'; '.join(pre_launch_errors)}"
405+
)
406+
390407
cfg = self._ctx.config
391408
model_identity = resolve_model_identity(
392409
model, cfg, step_name=step_name, profile_name=profile_name
@@ -412,8 +429,11 @@ async def dispatch_food_truck(
412429
if idle_cfg_val > 0:
413430
merged_extras.setdefault("AUTOSKILLIT_IDLE_OUTPUT_TIMEOUT", str(idle_cfg_val))
414431

415-
assert self._ctx.backend is not None, "ctx.backend must be set before dispatch_food_truck"
416-
backend = self._ctx.backend
432+
if dispatch_backend is None:
433+
raise RuntimeError(
434+
"dispatch_backend must be resolved before dispatch_food_truck execution"
435+
)
436+
backend = dispatch_backend
417437
cmd_spec = backend.build_food_truck_cmd(
418438
orchestrator_prompt=orchestrator_prompt,
419439
plugin_source=self._ctx.plugin_source,
@@ -451,7 +471,7 @@ async def dispatch_food_truck(
451471
)
452472

453473
effective_marker_dir: Path | None = marker_dir or (
454-
_resolve_session_log_dir(cwd, cast(CodingAgentBackend, self._ctx.backend))
474+
_resolve_session_log_dir(cwd, cast(CodingAgentBackend, dispatch_backend))
455475
if cwd
456476
else None
457477
)
@@ -485,4 +505,5 @@ async def dispatch_food_truck(
485505
session_id=session_id,
486506
model_identity=model_identity,
487507
on_session_id_resolved=on_session_id_resolved,
508+
step_backend=dispatch_backend,
488509
)

src/autoskillit/fleet/_api.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
)
3636

3737
if TYPE_CHECKING:
38+
from autoskillit.core import CodingAgentBackend
3839
from autoskillit.fleet.sidecar import IssueSidecarEntry
3940
from autoskillit.fleet.state import DispatchRecord, DispatchStateHandle
4041
from autoskillit.pipeline.context import ToolContext
@@ -190,6 +191,7 @@ async def execute_dispatch(
190191
resume_message: str | None = None,
191192
caller_instructions: str | None = None,
192193
provider_capability_overrides: dict[str, str] | None = None,
194+
dispatch_backend: CodingAgentBackend | None = None,
193195
) -> DispatchResult:
194196
"""Execute a single food truck dispatch.
195197
@@ -257,6 +259,7 @@ def _reject(error_code: FleetErrorCode, message: str, **kwargs: Any) -> Dispatch
257259
resume_message=resume_message,
258260
caller_instructions=caller_instructions,
259261
provider_capability_overrides=provider_capability_overrides,
262+
dispatch_backend=dispatch_backend,
260263
)
261264
except asyncio.CancelledError:
262265
raise
@@ -318,6 +321,7 @@ async def _run_dispatch(
318321
resume_message: str | None = None,
319322
caller_instructions: str | None = None,
320323
provider_capability_overrides: dict[str, str] | None = None,
324+
dispatch_backend: CodingAgentBackend | None = None,
321325
) -> DispatchResult:
322326
"""Inner dispatch body — called after lock acquisition."""
323327
from autoskillit.fleet.state import (
@@ -349,9 +353,11 @@ async def _run_dispatch(
349353
per_dispatch_state_path=None,
350354
)
351355

356+
_effective_backend = dispatch_backend if dispatch_backend is not None else tool_ctx.backend
357+
352358
try:
353359
_capability_overrides = provider_capability_overrides or _build_capability_overrides(
354-
tool_ctx.backend
360+
_effective_backend
355361
)
356362
_merged_ingredients = {**(ingredients or {}), **_capability_overrides}
357363
validation_result = tool_ctx.recipes.load_and_validate(
@@ -360,7 +366,7 @@ async def _run_dispatch(
360366
suppressed=tool_ctx.config.migration.suppressed if tool_ctx.config else None,
361367
ingredient_overrides=_merged_ingredients,
362368
temp_dir=tool_ctx.temp_dir,
363-
backend_name=tool_ctx.backend.name if tool_ctx.backend else None,
369+
backend_name=_effective_backend.name if _effective_backend else None,
364370
)
365371
except ProcessStaleError as exc:
366372
return DispatchResult(
@@ -442,7 +448,7 @@ async def _run_dispatch(
442448
)
443449

444450
_capability_overrides = provider_capability_overrides or _build_capability_overrides(
445-
tool_ctx.backend
451+
_effective_backend
446452
)
447453
effective_ingredients = apply_config_authoritative_overrides(
448454
effective_ingredients,
@@ -573,7 +579,7 @@ def _reject_with_state(error_code: FleetErrorCode, message: str) -> DispatchResu
573579
if quota_result.get("should_sleep"):
574580
await asyncio.sleep(quota_result.get("sleep_seconds", 0))
575581

576-
_locator = tool_ctx.backend.session_locator() if tool_ctx.backend is not None else None
582+
_locator = _effective_backend.session_locator() if _effective_backend is not None else None
577583

578584
if resume_session_id:
579585
_primary_jsonl = (
@@ -758,6 +764,9 @@ def _on_session_id(session_id: str) -> None:
758764
on_spawn=_on_spawn,
759765
sentinel_contract=sentinel_contract,
760766
resume_message=resume_message,
767+
backend_override=dispatch_backend.name
768+
if dispatch_backend is not None
769+
else None,
761770
)
762771

763772
ended_at = time.time()
@@ -957,6 +966,7 @@ def _on_session_id(session_id: str) -> None:
957966
labels_cleaned=_labels_cleaned,
958967
issue_url=_issue_urls_raw,
959968
branch_name=_branch_name,
969+
backend_name=_effective_backend.name if _effective_backend else "",
960970
resume_checkpoint=_checkpoint_to_dict(dispatch_checkpoint),
961971
)
962972

src/autoskillit/fleet/state_types.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"session_chain",
2727
"resume_count",
2828
"issue_url",
29+
"backend_name",
2930
}
3031
)
3132

@@ -130,6 +131,7 @@ class DispatchRecord:
130131
wait_seconds: float | None = None
131132
resets_at: str = ""
132133
resume_count: int = 0
134+
backend_name: str = ""
133135

134136
def to_dict(self) -> dict[str, Any]:
135137
return {
@@ -164,6 +166,7 @@ def to_dict(self) -> dict[str, Any]:
164166
"wait_seconds": self.wait_seconds,
165167
"resets_at": self.resets_at,
166168
"resume_count": self.resume_count,
169+
"backend_name": self.backend_name,
167170
}
168171

169172
@classmethod
@@ -224,6 +227,7 @@ def from_dict(cls, d: dict[str, Any]) -> DispatchRecord:
224227
wait_seconds=d.get("wait_seconds"),
225228
resets_at=d.get("resets_at", ""),
226229
resume_count=d.get("resume_count", 0),
230+
backend_name=d.get("backend_name", ""),
227231
)
228232

229233
@classmethod

src/autoskillit/server/_misc.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,15 @@
2020
get_logger,
2121
)
2222
from autoskillit.execution import (
23-
SCENARIO_STEP_NAME_ENV as SCENARIO_STEP_NAME_ENV,
24-
)
25-
from autoskillit.execution import (
23+
BACKEND_REGISTRY,
2624
SessionState,
2725
clear_session_state,
2826
persist_session_state,
2927
resolve_remote_repo,
3028
)
29+
from autoskillit.execution import (
30+
SCENARIO_STEP_NAME_ENV as SCENARIO_STEP_NAME_ENV,
31+
)
3132
from autoskillit.execution import (
3233
_refresh_quota_cache as _refresh_quota_cache,
3334
)
@@ -69,10 +70,22 @@
6970

7071
if TYPE_CHECKING:
7172
from autoskillit.config import QuotaGuardConfig
72-
from autoskillit.core import SkillResult
73+
from autoskillit.core import CodingAgentBackend, SkillResult
7374

7475
logger = get_logger(__name__)
7576

77+
78+
def resolve_backend_override(name: str) -> CodingAgentBackend:
79+
"""Resolve a backend name to a CodingAgentBackend instance.
80+
81+
Raises ValueError if the name is not in BACKEND_REGISTRY.
82+
"""
83+
if name not in BACKEND_REGISTRY:
84+
valid = ", ".join(sorted(BACKEND_REGISTRY))
85+
raise ValueError(f"Unknown backend {name!r}. Valid names: {valid}")
86+
return get_backend(name)
87+
88+
7689
_HOOK_CONFIG_FILENAME: str = _HOOK_CONFIG_PATH_COMPONENTS[-1]
7790
_HOOK_CONFIG_OVERLAY_FILENAME: str = ".hook_config_overlay.json"
7891
_HOOK_DIR_COMPONENTS: tuple[str, ...] = _HOOK_CONFIG_PATH_COMPONENTS[:-1]

src/autoskillit/server/tools/tools_fleet_dispatch.py

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020

2121
from autoskillit.core import (
2222
CapabilityResolutionDetail,
23+
CodingAgentBackend,
2324
FleetErrorCode,
2425
SessionCheckpoint,
2526
detect_autoskillit_mcp_prefix,
@@ -48,7 +49,7 @@
4849
)
4950
from autoskillit.server import mcp
5051
from autoskillit.server._guards import _require_enabled, _require_fleet
51-
from autoskillit.server._misc import resolve_log_dir
52+
from autoskillit.server._misc import resolve_backend_override, resolve_log_dir
5253
from autoskillit.server._notify import track_response_size
5354
from autoskillit.server.tools._auto_overrides import (
5455
_compute_effective_backend_map,
@@ -175,6 +176,7 @@ async def dispatch_food_truck(
175176
skip_when: str | None = None,
176177
resume_message: str | None = None,
177178
caller_instructions: str | None = None,
179+
backend: str | None = None,
178180
ctx: Context = CurrentContext(),
179181
) -> str:
180182
"""Dispatch a single food truck L2 session for one recipe.
@@ -226,6 +228,16 @@ async def dispatch_food_truck(
226228
if caller_instructions and len(caller_instructions) > _MAX_CALLER_INSTRUCTIONS_LEN:
227229
caller_instructions = caller_instructions[:_MAX_CALLER_INSTRUCTIONS_LEN]
228230

231+
dispatch_backend: CodingAgentBackend | None = None
232+
if backend is not None:
233+
try:
234+
dispatch_backend = resolve_backend_override(backend)
235+
except ValueError as exc:
236+
return fleet_error(
237+
FleetErrorCode.FLEET_INVALID_BACKEND,
238+
str(exc),
239+
)
240+
229241
# Feature guard: config authority check independent of MCP visibility state.
230242
# Fleet sessions open the gate unconditionally at boot; this catch-all ensures
231243
# dispatch_food_truck never executes when features.fleet is disabled in config.
@@ -284,6 +296,7 @@ async def dispatch_food_truck(
284296
SessionCheckpoint.from_dict(resume_checkpoint) if resume_checkpoint else None
285297
)
286298
tool_ctx = _get_ctx()
299+
_override_backend = dispatch_backend if dispatch_backend is not None else tool_ctx.backend
287300
caller_session_id = find_caller_session_id(project_dir=tool_ctx.project_dir)
288301
effective_name = dispatch_name or recipe
289302

@@ -337,7 +350,7 @@ async def dispatch_food_truck(
337350
else None
338351
)
339352
_capability_overrides, _cap_detail = _provider_aware_capability_overrides(
340-
tool_ctx.backend,
353+
_override_backend,
341354
recipe,
342355
tool_ctx.config.providers,
343356
_preflight_raw_steps,
@@ -346,7 +359,7 @@ async def dispatch_food_truck(
346359
_merged_ingredients = {**(ingredients or {}), **_capability_overrides}
347360
_effective_backend_map = _compute_effective_backend_map(
348361
_preflight_raw_steps,
349-
tool_ctx.backend.name if tool_ctx.backend else None,
362+
_override_backend.name if _override_backend else None,
350363
tool_ctx.config.providers,
351364
recipe,
352365
skill_resolver=tool_ctx.skill_resolver,
@@ -357,7 +370,7 @@ async def dispatch_food_truck(
357370
suppressed=tool_ctx.config.migration.suppressed if tool_ctx.config else None,
358371
ingredient_overrides=_merged_ingredients,
359372
temp_dir=tool_ctx.temp_dir,
360-
backend_name=tool_ctx.backend.name if tool_ctx.backend else None,
373+
backend_name=_override_backend.name if _override_backend else None,
361374
effective_backend_map=_effective_backend_map,
362375
)
363376
except Exception:
@@ -411,20 +424,20 @@ async def dispatch_food_truck(
411424
except Exception:
412425
logger.warning("dispatch_food_truck_preflight_recipe_load_failed", exc_info=True)
413426

414-
if tool_ctx.backend is not None and _active_recipe_steps is not None:
427+
if _override_backend is not None and _active_recipe_steps is not None:
415428
_preflight_err = _check_dispatch_feasibility(
416429
post_prune_step_names=_fleet_load_result.get("post_prune_step_names", []),
417430
active_recipe_steps=_active_recipe_steps,
418-
backend=tool_ctx.backend,
431+
backend=_override_backend,
419432
config_providers=tool_ctx.config.providers,
420433
recipe_name=recipe,
421434
)
422435
if _preflight_err is not None:
423436
return _preflight_err
424437

425438
_supports_quota = (
426-
tool_ctx.backend is not None
427-
and tool_ctx.backend.capabilities.anthropic_provider_capable
439+
_override_backend is not None
440+
and _override_backend.capabilities.anthropic_provider_capable
428441
)
429442
try:
430443
with anyio.fail_after(tool_ctx.config.run_skill.mcp_tool_timeout_sec):
@@ -437,8 +450,8 @@ async def dispatch_food_truck(
437450
timeout_sec=timeout_sec,
438451
prompt_builder=_get_food_truck_prompt_builder(
439452
has_unguarded_filesystem_access=(
440-
tool_ctx.backend.capabilities.has_unguarded_filesystem_access
441-
if tool_ctx.backend
453+
_override_backend.capabilities.has_unguarded_filesystem_access
454+
if _override_backend
442455
else False
443456
),
444457
),
@@ -457,6 +470,7 @@ async def dispatch_food_truck(
457470
resume_message=resume_message,
458471
caller_instructions=caller_instructions,
459472
provider_capability_overrides=_capability_overrides,
473+
dispatch_backend=dispatch_backend,
460474
)
461475
except TimeoutError:
462476
logger.error(

tests/_test_filter.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,7 @@ class ImportContext(enum.StrEnum):
203203
"_type_helpers": frozenset({"core", "execution", "fleet", "pipeline", "recipe", "server"}),
204204
"_type_protocols_workspace": frozenset({"core", "pipeline", "recipe", "server", "workspace"}),
205205
"_type_protocols_backend": frozenset(
206-
{"_llm_triage", "cli", "core", "execution", "pipeline", "server", "workspace"}
206+
{"_llm_triage", "cli", "core", "execution", "fleet", "pipeline", "server", "workspace"}
207207
),
208208
"_type_inspector": frozenset({"core", "execution"}),
209209
"_type_invariant_registry": frozenset({"core"}),
@@ -830,6 +830,7 @@ class ImportContext(enum.StrEnum):
830830
"cli",
831831
# file-level: fleet tests that import server tool handlers directly
832832
"fleet/test_api.py",
833+
"fleet/test_dispatch_backend_override.py",
833834
"fleet/test_dispatch_crash_diagnostics.py",
834835
"fleet/test_fleet_e2e.py",
835836
"fleet/test_pack_enforcement.py",

tests/arch/test_execution_source_split.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
"_headless_execute.py",
1717
]
1818
HEADLESS_SIZE_BUDGETS = {
19-
"headless/__init__.py": 490,
19+
"headless/__init__.py": 510,
2020
"headless/_headless_helpers.py": 220,
2121
"headless/_headless_execute.py": 616,
2222
"headless/_headless_recovery.py": 370,

tests/execution/test_headless_dispatch.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -407,7 +407,7 @@ async def test_dispatch_food_truck_raises_for_none_backend(
407407
minimal_ctx.plugin_source = DirectInstall(plugin_dir=tmp_path)
408408
executor = DefaultHeadlessExecutor(minimal_ctx)
409409

410-
with pytest.raises(AssertionError):
410+
with pytest.raises(RuntimeError, match="dispatch_backend must be resolved"):
411411
await executor.dispatch_food_truck(
412412
"some prompt",
413413
str(tmp_path),

0 commit comments

Comments
 (0)