Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
f3b0276
test: add failing serve-idempotence tests (part A step 1)
Trecek Jul 8, 2026
8b25de5
fix: add session_serve_overrides snapshot to close serve-context asym…
Trecek Jul 8, 2026
3e67bd1
fix: resolve arch guard failures from session_serve_overrides additio…
Trecek Jul 8, 2026
e698cb7
feat: serve-surface symmetry β€” _serve_helpers.py + SERVE_SURFACES reg…
Trecek Jul 8, 2026
53e689c
fix: move SERVE_SURFACES to alphabetical position in __all__
Trecek Jul 8, 2026
d2720fa
fix: guard ended_at >= started_at in fleet dispatch to prevent clock-…
Trecek Jul 8, 2026
e9e2c5d
fix(review): remove double-injection of backend_overrides into sessio…
Trecek Jul 8, 2026
70e2b56
fix(review): add issue reference TODO for tools_fleet_dispatch.py Par…
Trecek Jul 8, 2026
1f5a656
fix(review): move module-level sync assert to test function; use hypo…
Trecek Jul 8, 2026
cf13845
fix(review): update schema version convention allowlist for shifted l…
Trecek Jul 8, 2026
e91e350
fix(review): replace assert with RuntimeError in serve_recipe for -O …
Trecek Jul 8, 2026
77f58a7
fix(review): simplify redundant ternary in open_kitchen overrides sna…
Trecek Jul 8, 2026
e620e02
fix(review): move TODO(#4208-B) into docstring, remove duplicate inli…
Trecek Jul 8, 2026
b7c532d
fix(review): improve test quality in test_serve_idempotence
Trecek Jul 8, 2026
6eb5cd0
fix(server): reset session_serve_overrides in _reset_mcp_tags to prev…
Trecek Jul 8, 2026
8465abc
fix(tests): reset server context between server tests
Trecek Jul 8, 2026
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
5 changes: 5 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -413,3 +413,8 @@ forbidden_modules = [

[tool.uv.sources]
api-simulator = { git = "https://github.com/TalonT-Org/api-simulator", rev = "8f711f958d4d93a8e53a3c3b51279fb420870f3a", subdirectory = "crates/api-simulator-py" }

[dependency-groups]
dev = [
"hypothesis>=6.156.2",
]
2 changes: 2 additions & 0 deletions src/autoskillit/core/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ from .types import REVIEW_APPROACH_MARKER as REVIEW_APPROACH_MARKER
from .types import ROUTING_AUTHORITY_CLAUSE as ROUTING_AUTHORITY_CLAUSE
from .types import RUN_PYTHON_SENTINEL_KEYS as RUN_PYTHON_SENTINEL_KEYS
from .types import SCOPE_DIRECTION_SOURCE_TYPES as SCOPE_DIRECTION_SOURCE_TYPES
from .types import SERVE_SURFACES as SERVE_SURFACES
from .types import SESSION_TYPE_ENV_VAR as SESSION_TYPE_ENV_VAR
from .types import SESSION_TYPE_FLEET as SESSION_TYPE_FLEET
from .types import SESSION_TYPE_ORCHESTRATOR as SESSION_TYPE_ORCHESTRATOR
Expand Down Expand Up @@ -324,6 +325,7 @@ from .types import RestartScope as RestartScope
from .types import ResultParser as ResultParser
from .types import ResumeSpec as ResumeSpec
from .types import RetryReason as RetryReason
from .types import ServeOverridesSnapshot as ServeOverridesSnapshot
from .types import SessionCheckpoint as SessionCheckpoint
from .types import SessionEvent as SessionEvent
from .types import SessionLocator as SessionLocator
Expand Down
10 changes: 10 additions & 0 deletions src/autoskillit/core/types/_type_constants_registries.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"CORE_PACKS",
"TOOL_SUBSET_TAGS",
"ALL_VISIBILITY_TAGS",
"SERVE_SURFACES",
"SkillCapabilityDef",
"SKILL_CAPABILITY_REGISTRY",
]
Expand Down Expand Up @@ -155,6 +156,15 @@

UNGATED_TOOLS: frozenset[str] = FREE_RANGE_TOOLS

SERVE_SURFACES: frozenset[str] = frozenset(
{
"open_kitchen", # S1 β€” initial serve, sets session snapshot
"open_kitchen_deferred_recall", # S2 β€” deferred-recall re-serve
"load_recipe", # S3 β€” re-serve tool
"get_recipe", # S4 β€” MCP resource handler
}
)


class PackDef(NamedTuple):
"""Definition of a named skill pack with default visibility state."""
Expand Down
25 changes: 23 additions & 2 deletions src/autoskillit/core/types/_type_protocols_recipe.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from __future__ import annotations

from collections.abc import Awaitable, Callable, Sequence
from collections.abc import Awaitable, Callable, Iterator, KeysView, Sequence
from pathlib import Path
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable

Expand All @@ -13,7 +13,28 @@

from ._type_results import LoadResult

__all__ = ["RecipeRepository", "MigrationService", "DatabaseReader", "ReadOnlyResolver"]
__all__ = [
"RecipeRepository",
"MigrationService",
"DatabaseReader",
"ReadOnlyResolver",
"ServeOverridesSnapshot",
Comment thread
Trecek marked this conversation as resolved.
]


@runtime_checkable
class ServeOverridesSnapshot(Protocol):
"""Caller-supplied ingredient override values captured at open_kitchen time.

Always stored as a plain dict[str, str]. Protocol type enables the
test_toolcontext_optional_fields_all_have_protocol_annotations arch contract
while remaining compatible with dict[str, str] at all call sites.
"""

def __getitem__(self, __key: str) -> str: ...
def keys(self) -> KeysView[str]: ...
def __iter__(self) -> Iterator[str]: ...
def __len__(self) -> int: ...


@runtime_checkable
Expand Down
1 change: 1 addition & 0 deletions src/autoskillit/execution/backends/_claude_prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ def _extract_write_artifacts(tool_uses: list[dict[str, Any]]) -> list[str]:
"MAX_MCP_OUTPUT_TOKENS",
"AUTOSKILLIT_SESSION_DEADLINE",
"CLAUDE_CODE_SUBAGENT_MODEL",
"CODEX_HOME",
"SCENARIO_STEP_NAME",
}
)
Expand Down
2 changes: 1 addition & 1 deletion src/autoskillit/fleet/_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -777,7 +777,7 @@ def _on_session_id(session_id: str) -> None:
else None,
)

ended_at = time.time()
ended_at = max(time.time(), started_at + 1e-6)
Comment thread
Trecek marked this conversation as resolved.
_dispatch_completed_normally = True
except asyncio.CancelledError:
if _dispatched_pid:
Expand Down
3 changes: 3 additions & 0 deletions src/autoskillit/pipeline/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
QuotaRefreshTask,
ReadOnlyResolver,
RecipeRepository,
ServeOverridesSnapshot,
SessionSkillManager,
SkillResolver,
SubprocessRunner,
Expand Down Expand Up @@ -174,6 +175,8 @@ class ToolContext:
active_recipe_features: frozenset[str] | None = field(default_factory=lambda: None)
active_recipe_steps: dict[str, Any] | None = field(default_factory=lambda: None)
active_recipe_ingredients: frozenset[str] | None = field(default_factory=lambda: None)
session_serve_overrides: ServeOverridesSnapshot | None = field(default_factory=lambda: None)
session_serve_defer_unresolved: bool = field(default=False)
quota_refresh_task: QuotaRefreshTask | None = field(default=None)
token_factory: TokenFactory | None = field(default=None)
fleet_lock: FleetLock | None = field(default=None)
Expand Down
1 change: 1 addition & 0 deletions src/autoskillit/server/_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,7 @@ def make_context(
skill_resolver=provider.resolver,
ephemeral_root=ephemeral_root,
quota_refresh_task=None,
session_serve_overrides=None,
fleet_lock=(
fleet_lock
if fleet_lock is not None
Expand Down
1 change: 1 addition & 0 deletions src/autoskillit/server/tools/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ MCP `@mcp.tool()` handlers registered on import (20 tool modules).
| `_claim_helpers.py` | `ClaimDecision`, `_try_claim_with_liveness`, `_get_campaign_state_paths` β€” shared claiming logic for `claim_issue` and `claim_and_resolve_issue` |
| `_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) |
| `_preflight.py` | `_check_dispatch_feasibility`, `_get_fix_required_hook_matchers`, `filter_steps_by_post_prune` β€” shared preflight for `open_kitchen` and `dispatch_food_truck` |
| `_serve_helpers.py` | Shared serve-pipeline helpers: `_build_serve_override_stack()`, `_resolve_serve_defer_unresolved()`, `serve_recipe()` β€” the only legal call site for `load_and_validate` in `server/tools/` |
| `tools_execution.py` | `run_cmd`, `run_python`, `run_skill` |
| `tools_fleet_dispatch.py` | `dispatch_food_truck`, `record_gate_dispatch` |
| `tools_fleet_reset.py` | `reset_dispatch` (full dispatch artifact cleanup) |
Expand Down
114 changes: 114 additions & 0 deletions src/autoskillit/server/tools/_serve_helpers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
"""Unified serve-pipeline helpers.

The only legal call site for load_and_validate in server/tools/.
All four serve surfaces (open_kitchen normal, open_kitchen deferred-recall,
load_recipe, get_recipe) must call serve_recipe() instead of calling
ctx.recipes.load_and_validate() directly. This structural invariant is
enforced by tests/arch/test_serve_surface_registry.py.
"""

from __future__ import annotations

from pathlib import Path
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
from autoskillit.pipeline.context import ToolContext


def _build_serve_override_stack(
ctx: ToolContext,
caller_overrides: dict[str, str] | None,
*,
config_default: dict[str, str],
session_overrides: dict[str, str],
config_layer: dict[str, str],
backend_overrides: dict[str, str] | None = None,
) -> dict[str, str]:
"""Single source of truth for serve override stack construction.

Priority (highest wins): config_layer > backend_overrides > caller_overrides
> snapshot_baseline > session_overrides > config_default
"""
snapshot_baseline: dict[str, str] = {}
if ctx.session_serve_overrides is not None:
snapshot_baseline = dict(ctx.session_serve_overrides)
return {
**config_default,
**session_overrides,
**snapshot_baseline,
**(caller_overrides or {}),
**(backend_overrides or {}),
**config_layer,
}


def _resolve_serve_defer_unresolved(
ctx: ToolContext,
caller_overrides: dict[str, str] | None,
) -> bool:
"""Resolve defer_unresolved from session snapshot or compute fresh."""
if ctx.session_serve_overrides is not None:
return ctx.session_serve_defer_unresolved
return not bool(caller_overrides)


def reset_session_serve_overrides(ctx: ToolContext) -> None:
"""Clear the session serve-overrides snapshot on ctx.

Called by the test _reset_mcp_tags autouse fixture to prevent snapshot
state from leaking between tests on the same xdist worker. In production,
_close_kitchen_handler performs the equivalent reset.
"""
ctx.session_serve_overrides = None
ctx.session_serve_defer_unresolved = False


def serve_recipe(
ctx: ToolContext,
name: str,
caller_overrides: dict[str, str] | None,
*,
config_default: dict[str, str],
session_overrides: dict[str, str],
config_layer: dict[str, str],
backend_overrides: dict[str, str] | None = None,
ingredients_only: bool = False,
resolved_defaults: dict[str, str] | None = None,
effective_backend_map: dict[str, str] | None = None,
suppressed: list[str] | None = None,
backend_name: str | None = None,
temp_dir: Path | str | None = None,
temp_dir_relpath: str | None = None,
) -> dict[str, Any]:
"""Unified recipe serve path. Only legal caller of load_and_validate in server/tools/."""
ingredient_overrides = _build_serve_override_stack(
ctx,
caller_overrides,
config_default=config_default,
session_overrides=session_overrides,
config_layer=config_layer,
backend_overrides=backend_overrides,
)
defer_unresolved = _resolve_serve_defer_unresolved(ctx, caller_overrides)
kwargs: dict[str, Any] = {
"ingredient_overrides": ingredient_overrides,
"defer_unresolved": defer_unresolved,
}
if resolved_defaults is not None:
kwargs["resolved_defaults"] = resolved_defaults
if effective_backend_map is not None:
kwargs["effective_backend_map"] = effective_backend_map
if suppressed is not None:
kwargs["suppressed"] = suppressed
if backend_name is not None:
kwargs["backend_name"] = backend_name
if ingredients_only:
Comment thread
Trecek marked this conversation as resolved.
kwargs["ingredients_only"] = ingredients_only
Comment thread
Trecek marked this conversation as resolved.
if temp_dir is not None:
kwargs["temp_dir"] = temp_dir
if temp_dir_relpath is not None:
kwargs["temp_dir_relpath"] = temp_dir_relpath
if ctx.recipes is None:
raise RuntimeError("serve_recipe() called with ctx.recipes=None")
return ctx.recipes.load_and_validate(name, ctx.project_dir, **kwargs)
67 changes: 42 additions & 25 deletions src/autoskillit/server/tools/tools_kitchen.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
_check_dispatch_feasibility,
filter_steps_by_post_prune,
)
from autoskillit.server.tools._serve_helpers import serve_recipe
from autoskillit.server.tools._types import _validate_result

logger = get_logger(__name__)
Expand Down Expand Up @@ -402,6 +403,8 @@ def _close_kitchen_handler() -> None:
ctx.active_recipe_features = None
ctx.active_recipe_steps = None
ctx.active_recipe_ingredients = None
ctx.session_serve_overrides = None
ctx.session_serve_defer_unresolved = False
ctx.recipe_name = ""
ctx.recipe_content_hash = ""
ctx.recipe_composite_hash = ""
Expand Down Expand Up @@ -483,6 +486,10 @@ def get_recipe(name: str) -> str:

_defaults = resolve_ingredient_defaults(ctx.project_dir)
_config_layer = build_config_authoritative_layer(_defaults)
_session_overrides: dict[str, str] = {
"kitchen_id": ctx.kitchen_id,
"diagnostics_log_dir": str(resolve_log_dir(ctx.config.linux_tracing.log_dir)),
}
try:
_raw_recipe = ctx.recipes.load(match.path)
_backend_overrides, _cap_detail = _provider_aware_capability_overrides(
Expand All @@ -500,17 +507,17 @@ def get_recipe(name: str) -> str:
skill_resolver=ctx.skill_resolver,
)
_config_default = build_config_default_layer(_defaults)
result = ctx.recipes.load_and_validate(
result = serve_recipe(
ctx,
name,
ctx.project_dir,
caller_overrides=None,
config_default=_config_default,
session_overrides=_session_overrides,
config_layer=_config_layer,
backend_overrides=_backend_overrides,
resolved_defaults=_defaults,
ingredient_overrides={
**_config_default,
**_backend_overrides,
**_config_layer,
},
backend_name=ctx.backend.name if ctx.backend else None,
effective_backend_map=_effective_backend_map,
backend_name=ctx.backend.name if ctx.backend else None,
)
except ProcessStaleError:
logger.warning("get_recipe_failure", recipe=name, stage="process_stale", exc_info=True)
Expand Down Expand Up @@ -731,12 +738,6 @@ async def open_kitchen(
_config_layer = build_config_authoritative_layer(_defaults)
_config_default = build_config_default_layer(_defaults)
_promote_capability_keys(_config_layer, _session_overrides)
_merged_overrides = {
**_config_default,
**_session_overrides,
**(overrides or {}),
**_config_layer,
}
_effective_backend_map = _compute_effective_backend_map(
_raw_recipe.steps if _raw_recipe is not None else None,
tool_ctx.backend.name if tool_ctx.backend else None,
Expand All @@ -756,16 +757,17 @@ async def open_kitchen(
)
}
)
_user_overrides_present = overrides is not None and len(overrides) > 0
if _is_deferred_recall:
try:
result = tool_ctx.recipes.load_and_validate(
result = serve_recipe(
tool_ctx,
name,
tool_ctx.project_dir,
suppressed=suppressed,
caller_overrides=overrides,
config_default=_config_default,
session_overrides=_session_overrides,
config_layer=_config_layer,
resolved_defaults=_defaults,
ingredient_overrides=_merged_overrides,
defer_unresolved=False,
suppressed=suppressed,
backend_name=tool_ctx.backend.name if tool_ctx.backend else None,
effective_backend_map=_effective_backend_map,
)
Expand Down Expand Up @@ -859,15 +861,24 @@ async def open_kitchen(
result["warnings"] = _override_warnings
if ingredients_only:
result = strip_ingredients_only_keys(result)
# When caller provides explicit overrides, update the snapshot so
# subsequent load_recipe/get_recipe calls see the new overrides.
# When overrides=None (replay previous context), leave the existing
# snapshot intact β€” the caller's intent is continuity, not reset.
if overrides is not None:
tool_ctx.session_serve_overrides = dict(overrides)
tool_ctx.session_serve_defer_unresolved = not bool(overrides)
return json.dumps(result)
try:
result = tool_ctx.recipes.load_and_validate(
result = serve_recipe(
tool_ctx,
name,
tool_ctx.project_dir,
suppressed=suppressed,
caller_overrides=overrides,
config_default=_config_default,
session_overrides=_session_overrides,
config_layer=_config_layer,
resolved_defaults=_defaults,
ingredient_overrides=_merged_overrides,
defer_unresolved=not _user_overrides_present,
suppressed=suppressed,
backend_name=tool_ctx.backend.name if tool_ctx.backend else None,
effective_backend_map=_effective_backend_map,
)
Expand Down Expand Up @@ -957,6 +968,12 @@ async def open_kitchen(
await ctx.disable_components(tags={"kitchen"})
return _preflight_err

# Snapshot the caller-supplied values ONLY β€” NOT _merged_overrides.
# Storing _merged_overrides would inject stale kitchen_id/diagnostics_log_dir
# into subsequent load_recipe merges, silently overwriting fresh infra values.
tool_ctx.session_serve_overrides = dict(overrides) if overrides else {}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[warning] bugs: If a caller explicitly passes kitchen_id or diagnostics_log_dir as ingredient overrides to open_kitchen, those keys are captured in session_serve_overrides (L974). On subsequent get_recipe calls, _build_serve_override_stack places snapshot_baseline above session_overrides in priority, so the stale user-supplied value wins over the freshly-computed ctx.kitchen_id / resolve_log_dir(...). The comment at L971-973 warns against storing _merged_overrides for exactly this reason, but raw user overrides containing infra key names have the same effect. Consider stripping known infra keys (kitchen_id, diagnostics_log_dir) from the captured snapshot, or document that users must not pass these keys as overrides.

tool_ctx.session_serve_defer_unresolved = not bool(overrides)

result["success"] = True
result["kitchen"] = "open"
result["version"] = __version__
Expand Down
Loading
Loading