Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 3 additions & 5 deletions .autoskillit/recipes/eval/agent-eval.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,6 @@ steps:
output_dir: "${{ inputs.output_dir }}"
capture:
eval_run_dir: "${{ result.eval_run_dir }}"
canary_count: "${{ result.canary_count }}"
variant_count: "${{ result.variant_count }}"
manifest_index_path: "${{ result.manifest_index_path }}"
on_success: run_canaries
on_failure: escalate
note: >-
Expand All @@ -100,7 +97,7 @@ steps:
Instead, follow these instructions using MCP tool calls directly:


1. Read the manifest index: run_cmd cat {context.manifest_index_path}
1. Read the manifest index: run_cmd cat {context.eval_run_dir}/manifest_index.json
Parse the JSON to get canary_ids, variant_ids, and directory paths.


Expand Down Expand Up @@ -175,4 +172,5 @@ steps:
(${{ context.passed_runs }}/${{ context.total_runs }}).
Vacuous passes: ${{ context.vacuous_passes }}.
Effective pass rate: ${{ context.effective_pass_rate }}.
Scorecard: ${{ context.scorecard_path }}
Scorecard: ${{ context.scorecard_path }}.
Emit the L3 sentinel with success status.
8 changes: 3 additions & 5 deletions .autoskillit/recipes/eval/skill-eval.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,6 @@ steps:
output_dir: "${{ inputs.output_dir }}"
capture:
eval_run_dir: "${{ result.eval_run_dir }}"
canary_count: "${{ result.canary_count }}"
variant_count: "${{ result.variant_count }}"
manifest_index_path: "${{ result.manifest_index_path }}"
on_success: run_canaries
on_failure: escalate
note: >-
Expand All @@ -86,7 +83,7 @@ steps:
Instead, follow these instructions using MCP tool calls directly:


1. Read the manifest index: run_cmd cat {context.manifest_index_path}
1. Read the manifest index: run_cmd cat {context.eval_run_dir}/manifest_index.json
Parse the JSON to get canary_ids, variant_ids, and directory paths.


Expand Down Expand Up @@ -161,4 +158,5 @@ steps:
(${{ context.passed_runs }}/${{ context.total_runs }}).
Vacuous passes: ${{ context.vacuous_passes }}.
Effective pass rate: ${{ context.effective_pass_rate }}.
Scorecard: ${{ context.scorecard_path }}
Scorecard: ${{ context.scorecard_path }}.
Emit the L3 sentinel with success status.
4 changes: 4 additions & 0 deletions src/autoskillit/fleet/_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -391,9 +391,13 @@ async def _run_dispatch(
error_findings = [
s for s in validation_result.get("suggestions", []) if s.get("severity") == "error"
]
total_errors = len(structural_errors) + len(error_findings)
error_parts = structural_errors[:3] + [
f"[{f['rule']}] {f['message']}" for f in error_findings[:3]
]
shown = len(error_parts)
if total_errors > shown:
error_parts.append(f"+{total_errors - shown} more errors")
return DispatchResult(
DispatchRejected(
error_code=FleetErrorCode.FLEET_RECIPE_INVALID,
Expand Down
36 changes: 36 additions & 0 deletions src/autoskillit/recipe/_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -462,11 +462,47 @@ def load_and_validate(
t0 = _t("semantic_rules", t0, name)

if _skip_resolutions and any(v is False for v in _skip_resolutions.values()):
# Note: the capture-inversion-detection rule is intentionally NOT
# filtered here. Its strict forward-path dominance check (R1) in
# _check_capture_inversion subsumes what the filter would mask for
# this rule, so the filter would be a no-op for it by construction
# (the pre-prune baseline is structurally blind for that rule due
# to bypass edges emptying the BFS fact intersection). The filter
# remains useful for other rules like dead-output where pruning
# genuinely introduces new findings.
#
# Also: _skip_resolutions values are tristate — True (kept),
# False (pruned), None (deferred when defer_unresolved=True).
# The `is False` identity comparison correctly excludes None.
semantic_findings = filter_pruning_false_positives(
semantic_findings, _pre_prune_findings
)
semantic_suggestions = findings_to_dicts(semantic_findings)

# Defense-in-depth diagnostic: surface any post-prune finding from
# a graph-aware rule that did NOT appear in the pre-prune baseline.
# If a future rule has a gap similar to the original capture-inversion
# bug, this surfaces it during development rather than masking it
# silently via the filter. Logged at debug level (no production impact).
_graph_aware_rules = {"capture-inversion-detection", "dead-output"}
_pre_prune_keys = {(f.rule, f.step_name) for f in _pre_prune_findings}
for _f in semantic_findings:
if (
_f.rule in _graph_aware_rules
and (
_f.rule,
_f.step_name,
)
not in _pre_prune_keys
):
logger.debug(
"pruning_filter_new_finding",
recipe=name,
rule=_f.rule,
step=_f.step_name,
message=_f.message,
)

_suppressed = suppressed or []
if name in _suppressed:
semantic_suggestions = filter_version_rule(semantic_suggestions)
Expand Down
5 changes: 5 additions & 0 deletions src/autoskillit/recipe/_api_listing.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,11 @@ def validate_from_path(
report = ctx.dataflow
semantic_findings = run_semantic_rules(ctx)
if _skip_resolutions and any(v is False for v in _skip_resolutions.values()):
# Note: the capture-inversion-detection rule is intentionally NOT
# filtered here. Its strict forward-path dominance check (R1) in
# _check_capture_inversion subsumes what the filter would mask for
# this rule. The filter remains useful for other rules like
# dead-output where pruning genuinely introduces new findings.
semantic_findings = filter_pruning_false_positives(semantic_findings, _pre_prune_findings)

quality = build_quality_dict(report)
Expand Down
19 changes: 19 additions & 0 deletions src/autoskillit/recipe/rules/rules_reachability.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
_bfs_with_facts,
bfs_reachable,
)
from autoskillit.recipe._analysis_bfs import bfs_reachable_without_barrier
from autoskillit.recipe.registry import RuleFinding, make_finding, semantic_rule

# Regex to find ${{ context.X }} references anywhere in a string value.
Expand Down Expand Up @@ -129,6 +130,24 @@ def _check_capture_inversion(ctx: ValidationContext) -> list[RuleFinding]:
if not producers:
continue # no producer anywhere — not an inversion, different bug

# Strict forward-path dominance: exonerate if any producer strictly
# dominates step_name on success paths — every success-path from entry
# to step_name crosses the producer, so var is always captured before
# step_name runs. Using backward reachability (_ancestors) here would
# accept "on some path" producers that don't dominate fork-join readers,
# producing false negatives in real inversions. bfs_reachable_without_barrier
# builds its own success-path graph from ctx.recipe (post-prune) and
# returns all steps reachable from entry without crossing the barrier.
# If step_name is NOT in this set, the producer strictly dominates it.
exonerated = False
for _producer in producers:
_reachable_without = bfs_reachable_without_barrier(ctx.recipe, entry, _producer)
if step_name not in _reachable_without:
exonerated = True
break
if exonerated:
continue

findings.append(
make_finding(
rule_name="capture-inversion-detection",
Expand Down
9 changes: 7 additions & 2 deletions src/autoskillit/server/tools/tools_kitchen.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,12 +116,17 @@ def _recipe_validation_error_response(name: str, result: dict[str, Any]) -> str:
_structural_errs: list[str] = result.get("errors", [])
if _structural_errs:
_error_parts = _structural_errs[:3]
if len(_structural_errs) > 3:
_error_parts.append(f"+{len(_structural_errs) - 3} more errors")
else:
_error_parts = [
_all_errors = [
f"[{s.get('rule', 'unknown-rule')}] {s.get('message', '')}"
for s in result.get("suggestions", [])
if isinstance(s, dict) and s.get("severity") == "error"
][:3]
]
_error_parts = _all_errors[:3]
if len(_all_errors) > 3:
_error_parts.append(f"+{len(_all_errors) - 3} more errors")
_error_detail = "; ".join(_error_parts) if _error_parts else "unknown structural error"
_label = "structural validation" if _structural_errs else "validation"
return json.dumps(
Expand Down
7 changes: 0 additions & 7 deletions tests/arch/test_rule_severity_consistency.py
Original file line number Diff line number Diff line change
Expand Up @@ -270,13 +270,6 @@ def _collect_error_severity_rules() -> set[str]:
return error_rules


@pytest.mark.xfail(
strict=True,
reason=(
"2 ERROR-severity rules still in allowlist — fix agent-eval and skill-eval "
"before removing xfail"
),
)
def test_error_severity_rules_have_no_dispatch_ready_exemptions() -> None:
"""Every ERROR-severity rule must have zero entries in the dispatch-ready allowlist.

Expand Down
1 change: 1 addition & 0 deletions tests/fleet/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ Fleet campaign dispatch, state persistence, and sidecar tests.
| `test_findings_rpc.py` | Tests for autoskillit.fleet._findings_rpc (T15–T21) |
| `test_fleet.py` | Tests for fleet package |
| `test_fleet_e2e.py` | Fleet Group O: end-to-end test suite for fleet dispatch loop |
| `test_fleet_error_count_surfacing.py` | Tests for fleet-path +N more errors indicator in dispatch validation (R4) |
| `test_fleet_e2e_codex.py` | Fleet Group O-codex: end-to-end codex-backend dispatch via shim |
| `test_fleet_rename_integrity.py` | Fleet rename integrity guard |
| `test_fleet_semaphore.py` | Unit tests for FleetSemaphore (FleetLock semaphore implementation) |
Expand Down
90 changes: 90 additions & 0 deletions tests/fleet/test_fleet_error_count_surfacing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
"""Tests for fleet-path error count surfacing — the +N more indicator in dispatch validation.

R4: when fleet dispatch validation produces more errors than the display cap,
the DispatchRejected message must include a '+N more errors' indicator.
"""

from __future__ import annotations

from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from tests.fleet._helpers import _no_sleep_quota_checker, _noop_quota_refresher

pytestmark = [pytest.mark.layer("fleet"), pytest.mark.small, pytest.mark.feature("fleet")]


def _make_suggestions(n: int) -> list[dict[str, str]]:
return [
{"rule": f"rule-{i}", "severity": "error", "message": f"error number {i}"}
for i in range(n)
]


@pytest.mark.anyio
async def test_fleet_dispatch_surfaces_plus_n_more_for_combined_overflow(tool_ctx):
"""5 structural + 5 semantic errors → shown=6, overflow='+4 more errors'."""
tool_ctx.recipes = MagicMock()
tool_ctx.recipes.find.return_value = MagicMock()
tool_ctx.recipes.load.return_value = MagicMock()
tool_ctx.recipes.load_and_validate.return_value = {
"valid": False,
"errors": [f"structural error {i}" for i in range(5)],
"suggestions": _make_suggestions(5),
}

with patch("autoskillit.fleet._api.execute_dispatch", new_callable=AsyncMock):
from autoskillit.fleet._api import _run_dispatch
from autoskillit.fleet.state_types import DispatchRejected

result = await _run_dispatch(
tool_ctx=tool_ctx,
recipe="test-recipe",
task="test task",
ingredients={},
dispatch_name=None,
timeout_sec=None,
prompt_builder=lambda **kw: "prompt",
quota_checker=_no_sleep_quota_checker,
quota_refresher=_noop_quota_refresher,
)

assert isinstance(result.outcome, DispatchRejected)
assert "+4 more errors" in result.outcome.message, (
f"Expected '+4 more errors' for 5+5 combined, got: {result.outcome.message!r}"
)


@pytest.mark.anyio
async def test_fleet_dispatch_no_indicator_at_exactly_six(tool_ctx):
"""3 structural + 3 semantic errors → shown=6, no overflow indicator."""
tool_ctx.recipes = MagicMock()
tool_ctx.recipes.find.return_value = MagicMock()
tool_ctx.recipes.load.return_value = MagicMock()
tool_ctx.recipes.load_and_validate.return_value = {
"valid": False,
"errors": [f"structural error {i}" for i in range(3)],
"suggestions": _make_suggestions(3),
}

with patch("autoskillit.fleet._api.execute_dispatch", new_callable=AsyncMock):
from autoskillit.fleet._api import _run_dispatch
from autoskillit.fleet.state_types import DispatchRejected

result = await _run_dispatch(
tool_ctx=tool_ctx,
recipe="test-recipe",
task="test task",
ingredients={},
dispatch_name=None,
timeout_sec=None,
prompt_builder=lambda **kw: "prompt",
quota_checker=_no_sleep_quota_checker,
quota_refresher=_noop_quota_refresher,
)

assert isinstance(result.outcome, DispatchRejected)
assert "more errors" not in result.outcome.message, (
f"Did not expect overflow indicator for exactly 6 shown, got: {result.outcome.message!r}"
)
10 changes: 5 additions & 5 deletions tests/infra/test_schema_version_convention.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,11 +119,11 @@ def _is_yaml_dump(node: ast.expr) -> bool:
# _lifespan.py — hooks.json self-heal on startup drift (co-owned with Claude plugin system)
("src/autoskillit/server/_lifespan.py", 90),
# tools_kitchen.py — hook config, quota guard, git_ops_policy, ingredient locks overlay
("src/autoskillit/server/tools/tools_kitchen.py", 228),
("src/autoskillit/server/tools/tools_kitchen.py", 247),
("src/autoskillit/server/tools/tools_kitchen.py", 281),
("src/autoskillit/server/tools/tools_kitchen.py", 1119),
("src/autoskillit/server/tools/tools_kitchen.py", 1179),
("src/autoskillit/server/tools/tools_kitchen.py", 233),
("src/autoskillit/server/tools/tools_kitchen.py", 252),
("src/autoskillit/server/tools/tools_kitchen.py", 286),
("src/autoskillit/server/tools/tools_kitchen.py", 1124),
("src/autoskillit/server/tools/tools_kitchen.py", 1184),
# tools_pipeline_tracker.py — tracker_data dict
("src/autoskillit/server/tools/tools_pipeline_tracker.py", 166),
# tools_status.py — mcp_data dict
Expand Down
1 change: 1 addition & 0 deletions tests/recipe/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ Recipe I/O, validation, semantic rules, schema, and bundled recipe tests.
| `test_bundled_recipes_general.py` | General structural tests for all bundled recipes |
| `test_bundled_recipes_dispatch_ready.py` | Universal dispatch-readiness gate for all bundled recipes and contract cards |
| `test_bundled_recipes_no_inversions.py` | Guard: no inversion step order violations in bundled recipes |
| `test_bundled_recipes_all_truthy.py` | Regression guard: every bundled recipe validates cleanly under all-truthy and default ingredient configs (R1, R3) |
| `test_bundled_recipes_pipeline_structure.py` | Pipeline structure tests for bundled recipes |
| `test_bundled_recipes_research.py` | Tests for research bundled recipe structure |
| `test_bundled_recipes_research_design.py` | Tests for research-design bundled recipe structure |
Expand Down
Loading
Loading