Skip to content

Commit a262d58

Browse files
Trecekclaude
andauthored
Headless One-Shot Recipe Dispatch CLI (#4191)
## Summary Adds the `autoskillit fleet run <recipe>` CLI command for non-interactive, one-shot recipe dispatch. The command is gated behind a new `fleet_headless_run` experimental feature flag and reuses the existing dispatch machinery (`execute_dispatch` / `_run_dispatch`) — dispatch record persistence, sidecar, liveness heartbeat, automatic label cleanup, `FleetSemaphore` concurrency control, and quota-guard behavior — so CLI and kitchen-initiated dispatches share the same `max_concurrent_dispatches` budget. Results are emitted as a JSON envelope on stdout with distinct exit codes for SUCCESS / FAILURE / REFUSED / RESUMABLE / timeout outcomes, and resume is supported via `--resume-session-id` / `--prior-dispatch-id`. The CLAUDECODE guard is intentionally relaxed for this headless path while the skill/leaf session-type guard is retained to prevent recursive dispatch. <details> <summary>Individual Group Plans</summary> ### Group 1: Part A — Feature flag registration, CLI skeleton, runtime gates Part A registers the `fleet_headless_run` feature flag in `FEATURE_REGISTRY` and adds the `fleet_run` CLI command skeleton to `fleet_app` with all argument definitions (REQ-HFD-001/004/008). The command applies runtime feature gates (REQ-HFD-009/010) that produce JSON error envelopes on stdout for machine-readable refusal, enforces the session-type guard with intentionally relaxed `CLAUDECODE` (REQ-HFD-006), and exits with a stub error when gates pass (dispatch implementation deferred to Part B). The canary test `test_fleet_run_command_not_registered` is deleted in the same commit as command registration. Feature registry integrity tests and CLI gate tests are added. Part B will cover the dispatch implementation — `ToolContext` construction via `make_context`, `asyncio.run(execute_dispatch(...))` call, JSON result envelope output, exit code mapping, resume support, `FleetSemaphore` acquisition, quota guard behavior, and backend resolution (separate task). ### Group 2: Part B — Dispatch body, exit codes, quota guard Part B implements the dispatch body of the `fleet_run` CLI command registered in Part A. It replaces the `FLEET_NOT_IMPLEMENTED` stub with a working dispatch pipeline: `ToolContext` construction via `make_context` (REQ-HFD-005), `asyncio.run(execute_dispatch(...))` reusing the existing fleet dispatch machinery (REQ-HFD-002), JSON result envelope output on stdout (REQ-HFD-003), exit code mapping with distinct code for RESUMABLE outcomes (REQ-HFD-003), resume support via `--resume-session-id` and `--prior-dispatch-id` (REQ-HFD-004), `FleetSemaphore` concurrency control (REQ-HFD-007), quota guard with `--disable-quota-guard` opt-out (REQ-HFD-008), and `--backend` resolution via `resolve_backend_override` (REQ-HFD-001). Part A covered feature flag registration, CLI command skeleton, argument parsing, runtime gates, and canary test deletion (separate task, already implemented). </details> ## Requirements **REQ-HFD-001:** A new CLI command (suggested: `autoskillit fleet run <recipe>`) must execute a single recipe dispatch non-interactively, accepting a positional recipe name, a `--task` string, repeatable `--ingredient key=value` overrides, an optional `--backend` (names from `BACKEND_REGISTRY`, with the same `resolve_backend_override` semantics and per-dispatch admission control as #4179/#4182), and an optional `--timeout-sec`. **REQ-HFD-002:** The command must reuse the existing dispatch machinery (`execute_dispatch` / `_run_dispatch` in `fleet/_api.py`) — dispatch record persistence, sidecar, liveness heartbeat, and automatic label cleanup on failure — with no parallel implementation of any of it. **REQ-HFD-003:** The command must print the dispatch result envelope as JSON on stdout (`dispatch_id`, `dispatched_session_id`, `dispatch_status`, `l3_payload`, `token_usage`) and exit 0 on SUCCESS and nonzero on FAILURE/REFUSED/timeout, with a distinct exit code or JSON field identifying RESUMABLE outcomes. **REQ-HFD-004:** The command must support resume via `--resume-session-id` and `--prior-dispatch-id`, mirroring `dispatch_food_truck`'s resume discipline for RESUMABLE outcomes. **REQ-HFD-005:** The command must run entirely in the invoking process tree on the package version installed at invocation time, with zero dependency on any long-lived kitchen MCP server or open kitchen gate. **REQ-HFD-006:** The command must not require a TTY and must be invocable from inside a Claude Code session: the `CLAUDECODE` guard is relaxed for this headless command (that guard protects interactive terminal UX, which does not apply), while the skill/leaf session-type guard (`AUTOSKILLIT_SESSION_TYPE` in `("skill", "leaf")`) is retained to prevent recursive dispatch from leaf sessions. **REQ-HFD-007:** The command must respect FleetConfig concurrency by acquiring the same `FleetSemaphore` gate as kitchen-initiated dispatches, so CLI and kitchen dispatches share `max_concurrent_dispatches`. **REQ-HFD-008:** Quota-guard behavior must be defined for the headless path: either the guard is honored with a clear JSON refusal envelope, or an explicit `--disable-quota-guard` flag provides the same semantics as the MCP tool. **REQ-HFD-009:** The command must be gated behind a new dedicated feature flag registered in `FEATURE_REGISTRY` (`core/types/_type_constants_features.py:42`) — suggested name `fleet_headless_run` — declared with `lifecycle=FeatureLifecycle.EXPERIMENTAL` (`core/types/_type_enums.py:456`), `default_enabled=False`, `depends_on=frozenset({"fleet"})`, `tool_tags=frozenset()` and `skill_categories=frozenset()` (this feature adds a CLI command, not MCP tools or skills), `import_package=None`, `tier=1` (matching the fleet/planner/providers entries), and `since_version` set to the shipping release. **REQ-HFD-010:** The CLI entry point must gate at runtime following the `_require_fleet` precedent (`cli/fleet/__init__.py:40`): check `is_feature_enabled("fleet_headless_run", cfg.features, experimental_enabled=cfg.experimental_enabled)` AND retain the existing `_require_fleet(cfg)` check — both checks are required because `is_feature_enabled` (`core/feature_flags.py:16`) does not enforce `depends_on` at resolution time. On refusal, exit nonzero with an enablement hint (blanket: `features.experimental_enabled: true` in config; per-feature: `features.fleet_headless_run: true` or `AUTOSKILLIT_FEATURES__FLEET_HEADLESS_RUN=true`); in the headless path the refusal must also be a machine-readable JSON envelope on stdout. **REQ-HFD-011:** Feature resolution must follow the existing experimental contract with no new mechanism: the blanket switch is the config key `features.experimental_enabled` (popped from the features dict at `config/settings.py:404` into `AutomationConfig.experimental_enabled`, `settings.py:388`, defaulting to `is_dev_install()` when unset, `settings.py:407`); the new entry must surface automatically in `autoskillit features list` / `features status` (`cli/_features.py`); registry integrity assertions must be added following the `tests/fleet/test_fleet_rename_integrity.py` precedent (assert the entry's lifecycle, depends_on, and tag fields). Closes #4189 ## Implementation Plan Plan files: - `/home/talon/projects/autoskillit-runs/impl-20260705-214515-338429/.autoskillit/temp/make-plan/fleet_headless_run_plan_2026-07-05_214800_part_a.md` - `/home/talon/projects/autoskillit-runs/impl-20260705-214515-338429/.autoskillit/temp/make-plan/fleet_headless_run_plan_2026-07-05_214800_part_b.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] | 2 | 10.9k | 84.3k | 5.5M | 159.8k | 124 | 312.0k | 47m 27s | | review_approach* | sonnet | 2 | 19.1k | 14.0k | 432.3k | 55.1k | 34 | 123.1k | 13m 35s | | verify* | fable | 3 | 56.1k | 121.7k | 6.4M | 142.5k | 115 | 362.3k | 34m 43s | | implement* | MiniMax-M3 | 3 | 266.7k | 37.4k | 6.9M | 70.7k | 279 | 0 | 17m 17s | | fix* | sonnet | 3 | 940 | 83.0k | 8.9M | 114.9k | 294 | 258.7k | 43m 48s | | audit_impl* | sonnet | 1 | 92 | 23.4k | 547.2k | 77.8k | 32 | 78.6k | 9m 6s | | prepare_pr* | MiniMax-M3 | 1 | 56.6k | 6.3k | 305.7k | 0 | 17 | 0 | 3m 19s | | compose_pr* | MiniMax-M3 | 1 | 40.2k | 3.8k | 225.0k | 0 | 14 | 0 | 5m 48s | | **Total** | | | 450.6k | 373.9k | 29.2M | 159.8k | | 1.1M | 2h 55m | \* *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 | — | — | — | | review_approach | 0 | — | — | — | | verify | 0 | — | — | — | | implement | 960 | 7151.3 | 0.0 | 38.9 | | fix | 580 | 15303.1 | 446.0 | 143.1 | | audit_impl | 0 | — | — | — | | prepare_pr | 0 | — | — | — | | compose_pr | 0 | — | — | — | | **Total** | **1540** | 18957.0 | 736.9 | 242.8 | ## Model Usage Breakdown | Model | steps | uncached | output | cache_read | cache_write | time | |-------|-------|----------|--------|------------|-------------|------| | opus[1m] | 1 | 10.9k | 84.3k | 5.5M | 312.0k | 47m 27s | | sonnet | 3 | 20.1k | 120.4k | 9.9M | 460.4k | 1h 6m | | fable | 1 | 56.1k | 121.7k | 6.4M | 362.3k | 34m 43s | | MiniMax-M3 | 3 | 363.5k | 47.4k | 7.4M | 0 | 26m 25s | --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 48152a9 commit a262d58

11 files changed

Lines changed: 745 additions & 3 deletions

File tree

src/autoskillit/cli/fleet/AGENTS.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@ Fleet campaign CLI subcommands for multi-issue dispatch orchestration.
66

77
| File | Purpose |
88
|------|---------|
9-
| `__init__.py` | Main module: `fleet_app` Cyclopts sub-app with `campaign`, `dispatch`, `list`, `status` commands |
9+
| `__init__.py` | Main module: `fleet_app` Cyclopts sub-app with `campaign`, `dispatch`, `list`, `run`, `status` commands |
1010
| `_fleet_display.py` | Status display: `_render_status_display()`, `_watch_loop()`, `_STATUS_COLUMNS`, `render_fleet_error()` |
1111
| `_fleet_lifecycle.py` | Thin wrapper delegating reap to `fleet._dispatch_reaper`; `_pick_resume_campaign()` |
1212
| `_fleet_preview.py` | Pre-launch dispatch preview: `_build_dispatch_recipe_table()`, `_print_dispatch_preview()` |
13+
| `_fleet_run.py` | `fleet_run` command body + `_fleet_run_error` JSON envelope helper (extracted from `__init__.py`) |
1314
| `_fleet_session.py` | `_launch_fleet_session()` — builds Claude interactive session for fleet campaigns |

src/autoskillit/cli/fleet/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
_FLEET_DISPATCH_GREETINGS,
3232
_print_dispatch_preview,
3333
)
34+
from autoskillit.cli.fleet._fleet_run import fleet_run
3435
from autoskillit.cli.fleet._fleet_session import _launch_fleet_session
3536
from autoskillit.core import TerminalColumn, get_logger, is_feature_enabled
3637

@@ -408,3 +409,6 @@ def fleet_status(
408409
return
409410

410411
print(_render_terminal_table(columns, rows_list))
412+
413+
414+
fleet_run = fleet_app.command(name="run")(fleet_run)
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
"""Headless one-shot fleet dispatch command."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
import os
7+
from pathlib import Path
8+
from typing import TYPE_CHECKING, Annotated, NoReturn
9+
10+
from cyclopts import Parameter
11+
12+
from autoskillit.core import get_logger, is_feature_enabled
13+
14+
if TYPE_CHECKING:
15+
from autoskillit.config import AutomationConfig
16+
from autoskillit.core import CodingAgentBackend
17+
from autoskillit.fleet import DispatchResult
18+
19+
logger = get_logger(__name__)
20+
21+
22+
def _fleet_run_error(error: str, message: str, exit_code: int = 1) -> NoReturn:
23+
envelope = {"success": False, "error": error, "user_visible_message": message}
24+
print(json.dumps(envelope))
25+
raise SystemExit(exit_code)
26+
27+
28+
async def _execute_fleet_run(
29+
cfg: AutomationConfig,
30+
recipe: str,
31+
task: str,
32+
ingredients: dict[str, str] | None,
33+
timeout_sec: int | None,
34+
dispatch_backend: CodingAgentBackend | None,
35+
resume_session_id: str | None,
36+
prior_dispatch_id: str | None,
37+
disable_quota_guard: bool,
38+
) -> DispatchResult:
39+
import functools
40+
41+
from autoskillit.core import detect_autoskillit_mcp_prefix
42+
from autoskillit.fleet import _build_food_truck_prompt, execute_dispatch
43+
from autoskillit.server import make_context
44+
45+
ctx = make_context(cfg, project_dir=Path.cwd())
46+
47+
effective_backend = dispatch_backend or ctx.backend
48+
has_ufa = (
49+
effective_backend.capabilities.has_unguarded_filesystem_access
50+
if effective_backend
51+
else False
52+
)
53+
prompt_builder = functools.partial(
54+
_build_food_truck_prompt,
55+
mcp_prefix=detect_autoskillit_mcp_prefix(),
56+
has_unguarded_filesystem_access=has_ufa,
57+
)
58+
59+
if disable_quota_guard:
60+
61+
async def quota_checker(_cfg: object) -> dict[str, object]:
62+
return {"should_sleep": False}
63+
else:
64+
from autoskillit.execution import check_and_sleep_if_needed
65+
66+
_supports_quota = (
67+
effective_backend.capabilities.anthropic_provider_capable
68+
if effective_backend
69+
else True
70+
)
71+
72+
async def quota_checker(_cfg: object) -> dict[str, object]:
73+
return await check_and_sleep_if_needed(
74+
_cfg, provider="anthropic" if _supports_quota else ""
75+
)
76+
77+
async def quota_refresher(_cfg: object) -> None:
78+
pass
79+
80+
return await execute_dispatch(
81+
tool_ctx=ctx,
82+
recipe=recipe,
83+
task=task,
84+
ingredients=ingredients,
85+
dispatch_name=None,
86+
timeout_sec=timeout_sec,
87+
prompt_builder=prompt_builder,
88+
quota_checker=quota_checker,
89+
quota_refresher=quota_refresher,
90+
cache_invalidator=None,
91+
resume_session_id=resume_session_id,
92+
prior_dispatch_id=prior_dispatch_id,
93+
dispatch_backend=dispatch_backend,
94+
)
95+
96+
97+
def fleet_run(
98+
recipe: str,
99+
*,
100+
task: Annotated[str, Parameter(name=["--task", "-t"])] = "",
101+
ingredient: Annotated[tuple[str, ...], Parameter(name=["--ingredient", "-i"])] = (),
102+
backend: Annotated[str | None, Parameter(name=["--backend"])] = None,
103+
timeout_sec: Annotated[int | None, Parameter(name=["--timeout-sec"])] = None,
104+
resume_session_id: Annotated[str | None, Parameter(name=["--resume-session-id"])] = None,
105+
prior_dispatch_id: Annotated[str | None, Parameter(name=["--prior-dispatch-id"])] = None,
106+
disable_quota_guard: Annotated[bool, Parameter(name=["--disable-quota-guard"])] = False,
107+
) -> None:
108+
"""One-shot headless recipe dispatch (experimental).
109+
110+
Dispatches a single recipe run non-interactively. Prints the dispatch
111+
result envelope as JSON on stdout. Exit 0 on SUCCESS, nonzero otherwise.
112+
"""
113+
# --- Session-type guard (retained for headless — prevents recursive dispatch) ---
114+
if os.environ.get("AUTOSKILLIT_SESSION_TYPE") in ("skill", "leaf"):
115+
_fleet_run_error(
116+
"FLEET_SESSION_TYPE_BLOCKED",
117+
"'fleet run' cannot run inside a skill or leaf session.",
118+
)
119+
# NOTE: CLAUDECODE guard intentionally NOT applied — REQ-HFD-006
120+
121+
# --- Config + feature gates ---
122+
from autoskillit.config import load_config
123+
124+
try:
125+
cfg = load_config(Path.cwd())
126+
except Exception as exc:
127+
logger.error("fleet run: failed to load config", exc_info=True)
128+
_fleet_run_error("FLEET_CONFIG_ERROR", f"Failed to load config: {exc}")
129+
130+
# Fleet base feature check (equivalent to _require_fleet but with JSON output)
131+
if not is_feature_enabled(
132+
"fleet", cfg.features, experimental_enabled=cfg.experimental_enabled
133+
):
134+
_fleet_run_error(
135+
"FLEET_FEATURE_DISABLED",
136+
"The 'fleet' feature is not enabled.\n"
137+
"Enable with: features.experimental_enabled: true in your config\n"
138+
"Or set: AUTOSKILLIT_FEATURES__FLEET=true",
139+
)
140+
141+
# Headless run feature check
142+
if not is_feature_enabled(
143+
"fleet_headless_run",
144+
cfg.features,
145+
experimental_enabled=cfg.experimental_enabled,
146+
):
147+
_fleet_run_error(
148+
"FLEET_FEATURE_DISABLED",
149+
"The 'fleet_headless_run' feature is not enabled.\n"
150+
"Enable with: features.experimental_enabled: true\n"
151+
"Or: features.fleet_headless_run: true\n"
152+
"Or: AUTOSKILLIT_FEATURES__FLEET_HEADLESS_RUN=true",
153+
)
154+
155+
# --- Parse ingredients ---
156+
ingredients: dict[str, str] | None = None
157+
if ingredient:
158+
ingredients = {}
159+
for item in ingredient:
160+
if "=" not in item:
161+
_fleet_run_error(
162+
"FLEET_INVALID_ARGUMENT",
163+
f"Ingredient must be key=value, got: {item!r}",
164+
)
165+
k, v = item.split("=", 1)
166+
ingredients[k] = v
167+
168+
# --- Resolve backend ---
169+
dispatch_backend = None
170+
if backend is not None:
171+
from autoskillit.server import resolve_backend_override
172+
173+
try:
174+
dispatch_backend = resolve_backend_override(backend)
175+
except ValueError as exc:
176+
_fleet_run_error("FLEET_INVALID_BACKEND", str(exc))
177+
178+
# --- Run dispatch ---
179+
import asyncio
180+
181+
from autoskillit.fleet import DispatchRejected, DispatchStatus
182+
183+
try:
184+
result = asyncio.run(
185+
_execute_fleet_run(
186+
cfg=cfg,
187+
recipe=recipe,
188+
task=task,
189+
ingredients=ingredients,
190+
timeout_sec=timeout_sec,
191+
dispatch_backend=dispatch_backend,
192+
resume_session_id=resume_session_id,
193+
prior_dispatch_id=prior_dispatch_id,
194+
disable_quota_guard=disable_quota_guard,
195+
)
196+
)
197+
except KeyboardInterrupt as exc:
198+
logger.error("fleet run: dispatch interrupted: %s", exc)
199+
_fleet_run_error(
200+
"FLEET_DISPATCH_INTERRUPTED", "Dispatch interrupted by signal.", exit_code=1
201+
)
202+
except Exception as exc:
203+
logger.error("fleet run: dispatch crashed", exc_info=True)
204+
_fleet_run_error("FLEET_L3_STARTUP_OR_CRASH", str(exc))
205+
206+
# --- Output result envelope ---
207+
print(result.outcome.to_envelope())
208+
209+
# --- Exit code (DispatchRejected has no .success/.dispatch_status — check it first) ---
210+
if isinstance(result.outcome, DispatchRejected):
211+
raise SystemExit(3)
212+
if result.outcome.success:
213+
raise SystemExit(0)
214+
if result.outcome.dispatch_status == DispatchStatus.RESUMABLE:
215+
raise SystemExit(2)
216+
raise SystemExit(1)

src/autoskillit/core/types/_type_constants_features.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,17 @@ class FeatureDef:
6060
default_enabled=False,
6161
since_version="0.9.119",
6262
),
63+
"fleet_headless_run": FeatureDef(
64+
lifecycle=FeatureLifecycle.EXPERIMENTAL,
65+
description="Headless one-shot recipe dispatch via CLI (fleet run)",
66+
tool_tags=frozenset(),
67+
skill_categories=frozenset(),
68+
import_package=None,
69+
tier=1,
70+
default_enabled=False,
71+
depends_on=frozenset({"fleet"}),
72+
since_version="0.10.845",
73+
),
6374
"planner": FeatureDef(
6475
lifecycle=FeatureLifecycle.EXPERIMENTAL,
6576
description=(

src/autoskillit/server/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
# Public utilities consumed by CLI and tests
5757
"version_info",
5858
"make_context",
59+
"resolve_backend_override",
5960
# Wire-format compatibility middleware
6061
"ClaudeCodeCompatMiddleware",
6162
# Session-type visibility dispatcher (callable by tests)
@@ -77,6 +78,7 @@
7778
_notify,
7879
)
7980
from autoskillit.server._factory import make_context # noqa: E402, F401
81+
from autoskillit.server._misc import resolve_backend_override # noqa: E402, F401
8082
from autoskillit.server._session_type import _apply_session_type_visibility # noqa: E402, F401
8183
from autoskillit.server.tools import ( # noqa: E402, F401
8284
tools_agents as _tools_agents,

tests/arch/_rules.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ class RuleDescriptor:
6363
"_fleet_display.py",
6464
"_fleet_lifecycle.py",
6565
"_fleet_preview.py",
66+
"_fleet_run.py",
6667
"_fleet_session.py",
6768
"_session_picker.py",
6869
"_fleet.py",

tests/arch/test_feature_markers.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
_TESTS_ROOT / "cli" / "test_fleet_campaign.py",
3232
_TESTS_ROOT / "cli" / "test_fleet_status.py",
3333
_TESTS_ROOT / "cli" / "test_fleet_list.py",
34+
_TESTS_ROOT / "cli" / "test_fleet_run.py",
3435
_TESTS_ROOT / "server" / "test_tools_dispatch.py",
3536
_TESTS_ROOT / "server" / "test_tools_dispatch_validation.py",
3637
_TESTS_ROOT / "server" / "test_tools_dispatch_params.py",

tests/arch/test_feature_registry.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,32 @@ def test_build_features_dict_franchise_raises_config_schema_error():
345345
AutomationConfig._build_features_dict({"franchise": True})
346346

347347

348+
# ── fleet_headless_run feature registry tests ─────────────────────────────────
349+
350+
351+
def test_fleet_headless_run_in_feature_registry():
352+
"""fleet_headless_run is registered with correct FeatureDef field values."""
353+
from autoskillit.core.types._type_constants_features import FEATURE_REGISTRY
354+
from autoskillit.core.types._type_enums import FeatureLifecycle
355+
356+
entry = FEATURE_REGISTRY["fleet_headless_run"]
357+
assert entry.lifecycle == FeatureLifecycle.EXPERIMENTAL
358+
assert entry.tier == 1
359+
assert entry.default_enabled is False
360+
assert entry.tool_tags == frozenset()
361+
assert entry.skill_categories == frozenset()
362+
assert entry.depends_on == frozenset({"fleet"})
363+
assert entry.import_package is None
364+
365+
366+
def test_fleet_headless_run_promoted_by_experimental_blanket():
367+
"""fleet_headless_run has no requires_backend_alignment, experimental blanket promotes it."""
368+
from autoskillit.core.feature_flags import is_feature_enabled
369+
370+
assert is_feature_enabled("fleet_headless_run", {}, experimental_enabled=True) is True
371+
assert is_feature_enabled("fleet_headless_run", {}, experimental_enabled=False) is False
372+
373+
348374
# ── Providers feature registry tests ─────────────────────────────────────────
349375

350376

tests/cli/AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ CLI command, subcommand, and interactive workflow tests.
4343
| `test_fleet_campaign_preview.py` | Tests: fleet_campaign shows preview + confirmation before launch |
4444
| `test_fleet_dispatch.py` | Tests: fleet CLI dispatch command |
4545
| `test_fleet_list.py` | Tests: fleet CLI list command |
46+
| `test_fleet_run.py` | Tests: fleet CLI run command gates — session-type guard, feature gates, CLAUDECODE relaxation |
4647
| `test_fleet_session.py` | Tests: _launch_fleet_session forwards ingredients_table to prompt builder |
4748
| `test_fleet_split.py` | Structural guard for fleet test split |
4849
| `test_fleet_status.py` | Tests: fleet CLI status command |

tests/cli/test_fleet_list.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ def test_fleet_campaign_command_registered(self) -> None:
9797

9898
assert "campaign" in _subcommand_names(fleet_app)
9999

100-
def test_fleet_run_command_not_registered(self) -> None:
100+
def test_fleet_run_command_registered(self) -> None:
101101
from autoskillit.cli.fleet import fleet_app
102102

103-
assert "run" not in _subcommand_names(fleet_app)
103+
assert "run" in _subcommand_names(fleet_app)

0 commit comments

Comments
 (0)