diff --git a/scripts/e2e/README.md b/scripts/e2e/README.md index c4371c613f..bb732de838 100644 --- a/scripts/e2e/README.md +++ b/scripts/e2e/README.md @@ -254,10 +254,7 @@ Recipe `expected_failures` rows identify an individual, verified product defect accepted; process timeouts, malformed results, other failures and cleanup failures remain fatal. An accepted defect is provisional until mandatory branch/PR cleanup succeeds. A clean run that observes a configured row makes it stale and fails with instructions to remove the -row and close the bug. The smoke catalog currently records -[#5250](https://github.com/TalonT-Org/AutoSkillit/issues/5250) for `open_kitchen_failed` -during kitchen initialization, before the recipe executes. This row does not accept -arbitrary failures from the recipe's `failed` stop step. Fleet translates the L3 result's +row and close the bug. Fleet translates the L3 result's `reason` into the failed command envelope's `error` and `user_visible_message` fields; the harness matches that envelope rather than the recipe payload's `failed_step`/`reason`. diff --git a/scripts/e2e/catalog.json b/scripts/e2e/catalog.json index c568fddafb..00bf957ce1 100644 --- a/scripts/e2e/catalog.json +++ b/scripts/e2e/catalog.json @@ -69,15 +69,7 @@ "recipe": "sandbox-smoke", "recipe_fixture": "sandbox-smoke.yaml", "ingredients": {}, - "expected_pull_request_state": "closed", - "expected_failures": [ - { - "severity": "error", - "check": "open_kitchen_failed", - "message": "open_kitchen_failed", - "issue": "https://github.com/TalonT-Org/AutoSkillit/issues/5250" - } - ] + "expected_pull_request_state": "closed" } ] } diff --git a/scripts/e2e/e2e_harness.py b/scripts/e2e/e2e_harness.py index 818b11d819..3a89b895de 100644 --- a/scripts/e2e/e2e_harness.py +++ b/scripts/e2e/e2e_harness.py @@ -1029,13 +1029,15 @@ def run_test( """Run *test* and always record ``out/result.json``; return the failures.""" out.mkdir(parents=True, exist_ok=True) matched_findings: list[dict[str, str]] = [] + result: dict[str, Any] = {"test": test.name} try: failures, matched_findings = _run_test( test, catalog, out=out, home=home, env=env, runner=runner ) except Exception as exc: - failures = [f"harness error: {exc}"] - result = {"test": test.name, "passed": not failures, "failures": failures} + failures = [f"harness error: {type(exc).__name__}: {exc}"] + result["exception"] = traceback.format_exc() + result.update(passed=not failures, failures=failures) if test.kind in ("clean-install", "recipe"): outcome = "failed" if failures else "expected_failure" if matched_findings else "passed" result.update( diff --git a/src/autoskillit/cli/fleet/_fleet_run.py b/src/autoskillit/cli/fleet/_fleet_run.py index fb592b0a5b..96123bdae9 100644 --- a/src/autoskillit/cli/fleet/_fleet_run.py +++ b/src/autoskillit/cli/fleet/_fleet_run.py @@ -1,4 +1,10 @@ -"""Headless one-shot fleet dispatch command.""" +"""Headless one-shot fleet dispatch command. + +A fresh invocation inherits a nonempty ambient campaign id or mints a fresh id. A paired +dispatch resume preserves the campaign id recorded in its prior state. The resolved parent +id is forwarded through dispatch arguments and the child's +AUTOSKILLIT_CAMPAIGN_ID environment value. +""" from __future__ import annotations @@ -15,7 +21,7 @@ if TYPE_CHECKING: from autoskillit.config import AutomationConfig from autoskillit.core import NativeShellCaptureMode - from autoskillit.fleet import DispatchResult + from autoskillit.fleet import CampaignState, DispatchResult logger = get_logger(__name__) @@ -65,7 +71,7 @@ async def _execute_fleet_run( new_managed_launch_id, ) from autoskillit.fleet import _build_food_truck_prompt, execute_dispatch - from autoskillit.server import make_context + from autoskillit.server import establish_kitchen_identity, make_context from autoskillit.workspace import ( SkillProjectionContext, compile_session_skill_catalog, @@ -77,6 +83,19 @@ async def _execute_fleet_run( project_dir=Path.cwd(), plugin_retirement_coordinator=default_plugin_retirement_coordinator(), ) + prior_state: CampaignState | None = None + if resume_session_id is not None and prior_dispatch_id is not None: + from autoskillit.fleet import read_state + + prior_state = read_state(ctx.temp_dir / "dispatches" / f"{prior_dispatch_id}.json") + establish_kitchen_identity( + ctx, + campaign_id=( + prior_state.campaign_id + if prior_state is not None and prior_state.campaign_id + else None + ), + ) if disable_quota_guard: ctx.config.quota_guard.enabled = False @@ -100,17 +119,13 @@ async def _execute_fleet_run( from autoskillit.server.managed_join_prelaunch import acquire_managed_join_evidence managed_join_parent_id = new_managed_launch_id() - if resume_session_id is not None and prior_dispatch_id is not None: - from autoskillit.fleet import read_state - - prior_state = read_state(ctx.temp_dir / "dispatches" / f"{prior_dispatch_id}.json") - if prior_state is not None: - prior_record = next( - (record for record in prior_state.dispatches if record.name == recipe), - None, - ) - if prior_record is not None and prior_record.managed_lineage_ref is not None: - managed_join_parent_id = prior_record.managed_lineage_ref.launch_id + if prior_state is not None: + prior_record = next( + (record for record in prior_state.dispatches if record.name == recipe), + None, + ) + if prior_record is not None and prior_record.managed_lineage_ref is not None: + managed_join_parent_id = prior_record.managed_lineage_ref.launch_id evidence = acquire_managed_join_evidence( backend=effective_backend, configured_model=cfg.model.model_override or cfg.model.default_model, diff --git a/src/autoskillit/core/runtime/kitchen_state.py b/src/autoskillit/core/runtime/kitchen_state.py index a87d750e68..4776cb8d3b 100644 --- a/src/autoskillit/core/runtime/kitchen_state.py +++ b/src/autoskillit/core/runtime/kitchen_state.py @@ -162,11 +162,14 @@ def sweep_stale_markers(ttl_hours: int = 24) -> int: def resolve_kitchen_id() -> str: - """Resolve kitchen identity: inherit campaign ID from environment, or mint fresh. + """Return a stable kitchen identity for this process. - This is the sole mint source used by bootstrap/request transition construction. - Later ``ToolContext.kitchen_id`` assignments consume the stored transition value; - request handling must never call this function after bootstrap identity exists. + It is called only by ``establish_kitchen_identity`` (``autoskillit.server``), + the sole owner of the ``CLOSED -> INFRASTRUCTURE_READY`` edge, when no + explicit resume campaign id was supplied. All other code reads identity + through ``ToolContext.kitchen_id``. + + Empty values are treated as unset per OpenTelemetry's documented convention. """ return os.environ.get("AUTOSKILLIT_CAMPAIGN_ID") or str(uuid4()) diff --git a/src/autoskillit/core/types/protocols/_type_protocols_infra.py b/src/autoskillit/core/types/protocols/_type_protocols_infra.py index cac25cf280..b46768aa26 100644 --- a/src/autoskillit/core/types/protocols/_type_protocols_infra.py +++ b/src/autoskillit/core/types/protocols/_type_protocols_infra.py @@ -185,7 +185,7 @@ def timeout(self) -> float | None: ... @runtime_checkable class KitchenTransitionLock(Protocol): - """Synchronous lock protecting one ToolContext kitchen transition snapshot.""" + """Reentrant synchronous lock protecting one ToolContext kitchen transition snapshot.""" def __enter__(self) -> Any: ... diff --git a/src/autoskillit/pipeline/context.py b/src/autoskillit/pipeline/context.py index f8d6d5540f..ae6397a3c1 100644 --- a/src/autoskillit/pipeline/context.py +++ b/src/autoskillit/pipeline/context.py @@ -114,7 +114,9 @@ class TerminalRecipeResponseCacheEntry: class _KitchenIdentityContext(Protocol): - kitchen_id: str + @property + def kitchen_id(self) -> str: ... + kitchen_process_identity: KitchenProcessIdentity | None project_dir: Path @@ -196,8 +198,8 @@ class ToolContext: context accounting and recovery service. workspace_outcome_ledger: WorkspaceOutcomeLedger — durable shared authority for terminal workspace outcomes across parent and child sessions. - kitchen_id: UUID string assigned when open_kitchen fires; scopes token telemetry - to the current kitchen session lifetime. + kitchen_id: Read-only identity derived from kitchen_open_state; set only through + establish_kitchen_identity and empty while the state is CLOSED. kitchen_open_state: Immutable process-local open-operation lifecycle and effect journal retained for the ToolContext lifetime. Fleet dispatch provenance instead uses request/task-scoped ContextVars; the different owners @@ -293,7 +295,6 @@ class ToolContext: recipe_composite_hash: str = field(default="") recipe_version: str = field(default="") gate_infrastructure_ready: bool = field(default=False) - kitchen_id: str = field(default="") kitchen_process_identity: KitchenProcessIdentity | None = field(default_factory=lambda: None) kitchen_tracker_key: TrackerParticipantKey | None = field(default_factory=lambda: None) tracker_leases: dict[TrackerParticipantKey, ArtifactLease] = field( @@ -408,6 +409,11 @@ def __post_init__(self) -> None: if self.background is None: self.background = DefaultBackgroundSupervisor(audit=self.audit) + @property + def kitchen_id(self) -> str: + """Return the identity derived from the current lifecycle state.""" + return self.kitchen_open_state.kitchen_id + @property def host_client_attestation(self) -> HostClientAttestation | None: """Return the launcher attestation snapshot owned by this context.""" diff --git a/src/autoskillit/server/__init__.py b/src/autoskillit/server/__init__.py index 4358e304f6..4f468b6c21 100644 --- a/src/autoskillit/server/__init__.py +++ b/src/autoskillit/server/__init__.py @@ -38,6 +38,9 @@ ToolContext, gate_error_result, ) +from autoskillit.server.lifecycle._kitchen_identity import ( + establish_kitchen_identity, +) from autoskillit.server.lifecycle._lifespan import ( _autoskillit_lifespan, ) @@ -59,6 +62,7 @@ # Public utilities consumed by CLI and tests "version_info", "make_context", + "establish_kitchen_identity", "resolve_backend_override", # Admission helpers consumed by CLI (re-exported to avoid cross-submodule imports) "_compute_effective_backend_map", diff --git a/src/autoskillit/server/lifecycle/AGENTS.md b/src/autoskillit/server/lifecycle/AGENTS.md index 767e6949c0..230ac4f370 100644 --- a/src/autoskillit/server/lifecycle/AGENTS.md +++ b/src/autoskillit/server/lifecycle/AGENTS.md @@ -17,6 +17,11 @@ boot sequence — the async context manager wired via `lifespan=`, per-session-t auto-gate boots, and one-shot startup checks — lives in the `_lifespan/` sub-package. +`_kitchen_identity` is the sole kitchen-identity mint and activation authority. +This is application lifecycle state: `ToolContext.kitchen_id` is derived from +`kitchen_open_state`, while the remaining context fields stay mutable. It is +separate from FastMCP transport/session identity. + The pre-deletion editable-install scan that halts `perform_merge()` before a worktree is deleted lives at `server/_editable_guard.py`, one level up, not here: it is a merge-time filesystem check with a single call site in diff --git a/src/autoskillit/server/lifecycle/_kitchen_identity.py b/src/autoskillit/server/lifecycle/_kitchen_identity.py new file mode 100644 index 0000000000..82b9e80ddc --- /dev/null +++ b/src/autoskillit/server/lifecycle/_kitchen_identity.py @@ -0,0 +1,33 @@ +"""Sole owner of the kitchen-identity CLOSED -> INFRASTRUCTURE_READY edge.""" + +from autoskillit.core import resolve_kitchen_id +from autoskillit.pipeline import ( + KitchenOpenPhase, + ToolContext, + new_kitchen_open_state, +) + + +def establish_kitchen_identity(ctx: ToolContext, *, campaign_id: str | None = None) -> str: + """Return the lifecycle kitchen id, minting and activating it at most once. + + Callers may already hold the context's reentrant transition lock. + """ + with ctx.kitchen_transition_lock: + state = ctx.kitchen_open_state + if state.phase is KitchenOpenPhase.CLOSED: + from autoskillit.server.recipe._recipe_generation import ( # circular-break + activate_kitchen, + ) + + kitchen_id = campaign_id or resolve_kitchen_id() + if not kitchen_id.strip(): + raise ValueError("kitchen_id must contain non-whitespace characters") + activate_kitchen(kitchen_id) + ctx.kitchen_process_identity = None + state = new_kitchen_open_state( + kitchen_id=kitchen_id, + context_id=state.context_id, + ) + ctx.kitchen_open_state = state + return state.kitchen_id diff --git a/src/autoskillit/server/lifecycle/_lifespan/AGENTS.md b/src/autoskillit/server/lifecycle/_lifespan/AGENTS.md index afb24a1431..3b282da2c5 100644 --- a/src/autoskillit/server/lifecycle/_lifespan/AGENTS.md +++ b/src/autoskillit/server/lifecycle/_lifespan/AGENTS.md @@ -8,7 +8,7 @@ FastMCP lifespan boot sequence — relocated from `server/_lifespan/` `_startup_checks` runs one-shot synchronous startup checks (`run_startup_drift_check`, `run_startup_hook_health_check`, `run_startup_install_state_check`, `run_startup_fix_required_coverage_check`, -`run_startup_join_guard_coverage_check`, `_activate_recipe_kitchen`, +`run_startup_join_guard_coverage_check`, `_finalize_recorder`). `_session_boots` holds the per-session-type async auto-gate boots and the `_LIFESPAN_BOOT_REGISTRY` dispatch table that picks exactly one boot path diff --git a/src/autoskillit/server/lifecycle/_lifespan/__init__.py b/src/autoskillit/server/lifecycle/_lifespan/__init__.py index 71a5de0091..edcda4fbb1 100644 --- a/src/autoskillit/server/lifecycle/_lifespan/__init__.py +++ b/src/autoskillit/server/lifecycle/_lifespan/__init__.py @@ -9,7 +9,6 @@ (``run_startup_drift_check``, ``run_startup_hook_health_check``, ``run_startup_install_state_check``, ``run_startup_fix_required_coverage_check``, ``run_startup_join_guard_coverage_check``, - ``_activate_recipe_kitchen``, ``_finalize_recorder``). - :mod:`_session_boots` — per-session-type async auto-gate boots and the ``_LIFESPAN_BOOT_REGISTRY`` dispatch table. @@ -27,7 +26,6 @@ _collect_disabled_feature_tags, cleanup_readiness_sentinel, register_active_kitchen, - resolve_kitchen_id, write_readiness_sentinel, ) from autoskillit.execution import ( @@ -65,7 +63,6 @@ _skill_auto_gate_boot, ) from autoskillit.server.lifecycle._lifespan._startup_checks import ( - _activate_recipe_kitchen, _finalize_recorder, run_startup_drift_check, run_startup_fix_required_coverage_check, @@ -80,7 +77,6 @@ __all__ = [ "_LIFESPAN_BOOT_REGISTRY", - "_activate_recipe_kitchen", "_autoskillit_lifespan", "_cleanup_stale_loop", "_evidence_reader_auto_gate_boot", diff --git a/src/autoskillit/server/lifecycle/_lifespan/_session_boots.py b/src/autoskillit/server/lifecycle/_lifespan/_session_boots.py index 77bc22d4a0..287b3498f4 100644 --- a/src/autoskillit/server/lifecycle/_lifespan/_session_boots.py +++ b/src/autoskillit/server/lifecycle/_lifespan/_session_boots.py @@ -36,12 +36,10 @@ ) from autoskillit.fleet import sweep_stale_dispatch_labels from autoskillit.pipeline import ( - KitchenOpenPhase, OwnerBoundExplorationContextStore, confirm_kitchen_effect, exploration_auto_provision_eligible, get_kitchen_process_identity, - new_kitchen_open_state, start_kitchen_effect, ) from autoskillit.server._tracker_authority import ( @@ -55,9 +53,7 @@ # call time rather than imported by name into this submodule. from autoskillit.server.lifecycle import _lifespan as _lifespan_pkg from autoskillit.server.lifecycle._guards import _backend_supports_quota -from autoskillit.server.lifecycle._lifespan._startup_checks import ( - _activate_recipe_kitchen, -) +from autoskillit.server.lifecycle._kitchen_identity import establish_kitchen_identity logger = get_logger(__name__) @@ -133,8 +129,9 @@ async def _fleet_auto_gate_boot(ctx: Any) -> None: Called synchronously in _autoskillit_lifespan before yield, ensuring gate is open before any tool call arrives. Fails open: any step failure is logged as a warning and does not abort gate activation. + Identity establishment is not one of the fail-open steps. """ - ctx.kitchen_id = _lifespan_pkg.resolve_kitchen_id() + establish_kitchen_identity(ctx) ctx.active_recipe_packs = frozenset() ctx.active_recipe_features = frozenset() ctx.active_recipe_steps = {} @@ -189,7 +186,6 @@ async def _fleet_auto_gate_boot(ctx: Any) -> None: _retain_kitchen_tracker_authority(ctx) if not register_active_kitchen(get_kitchen_process_identity(ctx)): logger.warning("fleet_auto_gate_boot_registry_refused") - _activate_recipe_kitchen(ctx.kitchen_id) except Exception: logger.warning("fleet_auto_gate_boot_registry_failed", exc_info=True) @@ -238,14 +234,7 @@ async def _pre_reveal_kitchen(ctx: Any) -> None: from autoskillit.server.tools import tools_kitchen as _tk_pre_reveal # circular-break with ctx.kitchen_transition_lock: - state = ctx.kitchen_open_state - if state.phase is KitchenOpenPhase.CLOSED: - state = new_kitchen_open_state( - kitchen_id=_lifespan_pkg.resolve_kitchen_id(), - context_id=state.context_id, - ) - ctx.kitchen_open_state = state - ctx.kitchen_id = state.kitchen_id + establish_kitchen_identity(ctx) ctx.kitchen_open_state = start_kitchen_effect( ctx.kitchen_open_state, "pre_reveal_bootstrap", @@ -280,7 +269,6 @@ async def _pre_reveal_kitchen(ctx: Any) -> None: _retain_kitchen_tracker_authority(ctx) if not register_active_kitchen(get_kitchen_process_identity(ctx)): logger.warning("pre_reveal_kitchen_registry_refused") - _activate_recipe_kitchen(ctx.kitchen_id) except Exception: logger.warning("pre_reveal_kitchen_registry_failed", exc_info=True) _tk_pre_reveal._write_hook_config() @@ -302,6 +290,7 @@ async def _food_truck_auto_gate_boot(ctx: Any) -> None: Runs at lifespan startup when AUTOSKILLIT_HEADLESS=1 and AUTOSKILLIT_FOOD_TRUCK_TOOL_TAGS is set. No-ops for interactive ORCHESTRATOR sessions (open_kitchen handles the gate there). + Identity establishment is not one of the fail-open steps. """ from autoskillit.server._misc import ( # circular-break _prime_quota_cache, @@ -318,7 +307,7 @@ async def _food_truck_auto_gate_boot(ctx: Any) -> None: return _packs = frozenset(p.strip() for p in _raw_tags.split(",") if p.strip()) - ctx.kitchen_id = _lifespan_pkg.resolve_kitchen_id() + establish_kitchen_identity(ctx) ctx.active_recipe_packs = _packs ctx.active_recipe_features = frozenset() ctx.active_recipe_steps = {} @@ -373,7 +362,6 @@ async def _food_truck_auto_gate_boot(ctx: Any) -> None: _retain_kitchen_tracker_authority(ctx) if not register_active_kitchen(get_kitchen_process_identity(ctx)): logger.warning("food_truck_auto_gate_boot_registry_refused") - _activate_recipe_kitchen(ctx.kitchen_id) except Exception: logger.warning("food_truck_auto_gate_boot_registry_failed", exc_info=True) @@ -433,6 +421,7 @@ async def _skill_auto_gate_boot(ctx: Any) -> None: Omits quota-refresh loop, campaign state recovery, and the codex/daemon orphan reap — SKILL sessions are short-lived and don't own long-lived codex/daemon children (see test_boot_step_symmetry.py's carve-out). + Identity establishment is not one of the fail-open steps. """ if not session_shape().headless: @@ -442,7 +431,7 @@ async def _skill_auto_gate_boot(ctx: Any) -> None: if os.environ.get(HEADLESS_AUTO_GATE_ENV_VAR) != "1": return - ctx.kitchen_id = _lifespan_pkg.resolve_kitchen_id() + establish_kitchen_identity(ctx) ctx.active_recipe_packs = frozenset() ctx.active_recipe_features = frozenset() ctx.active_recipe_steps = {} @@ -486,7 +475,6 @@ async def _skill_auto_gate_boot(ctx: Any) -> None: _retain_kitchen_tracker_authority(ctx) if not register_active_kitchen(get_kitchen_process_identity(ctx)): logger.warning("skill_auto_gate_boot_registry_refused") - _activate_recipe_kitchen(ctx.kitchen_id) except Exception: logger.warning("skill_auto_gate_boot_registry_failed", exc_info=True) diff --git a/src/autoskillit/server/lifecycle/_lifespan/_startup_checks.py b/src/autoskillit/server/lifecycle/_lifespan/_startup_checks.py index 249d8714e5..3757f301b8 100644 --- a/src/autoskillit/server/lifecycle/_lifespan/_startup_checks.py +++ b/src/autoskillit/server/lifecycle/_lifespan/_startup_checks.py @@ -78,13 +78,6 @@ def run_startup_drift_check() -> None: logger.exception("startup_drift_check_failed") -def _activate_recipe_kitchen(kitchen_id: str) -> None: - """Publish one kitchen to the recipe-generation lifecycle.""" - from autoskillit.server.recipe._recipe_generation import activate_kitchen # circular-break - - activate_kitchen(kitchen_id) - - def _log_hook_repair_outcome(outcome: PluginHookRepairOutcome, artifact_scope: str) -> None: """Record one cache or projection hook repair result.""" match outcome.status: diff --git a/src/autoskillit/server/recipe/_recipe_generation.py b/src/autoskillit/server/recipe/_recipe_generation.py index fc0f63a088..e25fbb574f 100644 --- a/src/autoskillit/server/recipe/_recipe_generation.py +++ b/src/autoskillit/server/recipe/_recipe_generation.py @@ -33,6 +33,7 @@ "RecipeGenerationCapacityError", "RecipeGenerationConflictError", "RecipeGenerationError", + "RecipeGenerationKitchenNotActiveError", "RecipeGenerationRecord", "RecipeGenerationRetiredError", "RecipeGenerationStore", @@ -61,6 +62,10 @@ class RecipeGenerationRetiredError(RecipeGenerationError): """A write targeted a kitchen whose generation namespace is retired.""" +class RecipeGenerationKitchenNotActiveError(RecipeGenerationError): + """A write targeted an inactive kitchen without retained retirement evidence.""" + + def _freeze_primitive(value: object, *, path: str) -> object: if value is None or isinstance(value, (str, bool, int)): return value @@ -314,12 +319,15 @@ def activate_kitchen(self, kitchen_id: str) -> None: self._retired_kitchens.pop(kitchen_id, None) def _require_active_locked(self, kitchen_id: str) -> None: - if (self._lifecycle_authoritative and kitchen_id not in self._active_kitchens) or ( - not self._lifecycle_authoritative and kitchen_id in self._retired_kitchens - ): + if kitchen_id in self._retired_kitchens: raise RecipeGenerationRetiredError( f"recipe generation kitchen is retired: {kitchen_id}" ) + if self._lifecycle_authoritative and kitchen_id not in self._active_kitchens: + raise RecipeGenerationKitchenNotActiveError( + "recipe generation kitchen is not active in this process: " + f"{kitchen_id} ({len(self._active_kitchens)} active)" + ) def _deindex_artifacts_locked( self, diff --git a/src/autoskillit/server/tools/tools_kitchen/__init__.py b/src/autoskillit/server/tools/tools_kitchen/__init__.py index fa2f0d1da5..dcc8f00e3e 100644 --- a/src/autoskillit/server/tools/tools_kitchen/__init__.py +++ b/src/autoskillit/server/tools/tools_kitchen/__init__.py @@ -32,7 +32,6 @@ from autoskillit.core import ( _collect_disabled_feature_tags, find_latest_session_id, - resolve_kitchen_id, ) from autoskillit.core import get_logger as _get_logger from autoskillit.execution import default_tether_dir, sweep_orphaned_tethers_async @@ -118,7 +117,6 @@ _OPEN_KITCHEN_REQUEST_CTX, _attach_transition_fields, _bind_open_kitchen_transition, - _ensure_kitchen_transition, _open_kitchen_cancellation_response, _open_kitchen_conflict_response, _read_open_kitchen_request_ctx, @@ -176,7 +174,6 @@ def __getattr__(name: str): # pragma: no cover — late-binding shim "_build_hook_diagnostic_warning", "_check_dispatch_feasibility", "_collect_disabled_feature_tags", - "_ensure_kitchen_transition", "_find_session_id_for_reload", "_hook_config_path", "_quota_refresh_loop", @@ -217,7 +214,6 @@ def __getattr__(name: str): # pragma: no cover — late-binding shim "reap_stale_dispatches_async", "release_kitchen_request", "resolve_ingredient_defaults", - "resolve_kitchen_id", "resolve_log_dir", "serve_recipe", "sweep_orphaned_tethers_async", diff --git a/src/autoskillit/server/tools/tools_kitchen/_close_kitchen.py b/src/autoskillit/server/tools/tools_kitchen/_close_kitchen.py index b2f159ed04..32c7958254 100644 --- a/src/autoskillit/server/tools/tools_kitchen/_close_kitchen.py +++ b/src/autoskillit/server/tools/tools_kitchen/_close_kitchen.py @@ -153,7 +153,6 @@ def _close_kitchen_handler() -> None: with ctx.kitchen_transition_lock: context_id = ctx.kitchen_open_state.context_id ctx.kitchen_open_state = closed_kitchen_open_state(context_id=context_id) - ctx.kitchen_id = "" @mcp.tool( diff --git a/src/autoskillit/server/tools/tools_kitchen/_open_kitchen/_gate.py b/src/autoskillit/server/tools/tools_kitchen/_open_kitchen/_gate.py index 9d60090efe..f323ebd848 100644 --- a/src/autoskillit/server/tools/tools_kitchen/_open_kitchen/_gate.py +++ b/src/autoskillit/server/tools/tools_kitchen/_open_kitchen/_gate.py @@ -1,9 +1,7 @@ """Gate-enablement/transition handler extracted for testability. -Cross-submodule helpers are imported directly from their submodules -(``.._open_kitchen_transition``, ``.._open_kitchen_errors``, -``.._recipe_serve``) to avoid a circular-import hazard through the -package facade. +Helpers are imported from their defining modules to avoid a circular import +through the package facade. """ from __future__ import annotations @@ -20,6 +18,7 @@ _retain_kitchen_tracker_authority, ) from autoskillit.server.lifecycle._guards import _backend_supports_quota +from autoskillit.server.lifecycle._kitchen_identity import establish_kitchen_identity from autoskillit.server.tools import tools_kitchen as _tk_pkg from autoskillit.server.tools.tools_kitchen._open_kitchen._tracker_auto_init import ( prune_stale_kitchen_state, @@ -27,10 +26,7 @@ from autoskillit.server.tools.tools_kitchen._open_kitchen_errors import ( _kitchen_failure_envelope, ) -from autoskillit.server.tools.tools_kitchen._open_kitchen_transition import ( - _ensure_kitchen_transition, - _transition_start, -) +from autoskillit.server.tools.tools_kitchen._open_kitchen_transition import _transition_start logger = get_logger(__name__) @@ -142,7 +138,7 @@ async def _open_kitchen_handler(*, preserve_active_recipe: bool = False) -> str from autoskillit.server import _get_ctx # circular-break ctx = _get_ctx() - _ensure_kitchen_transition(ctx) + establish_kitchen_identity(ctx) if _transition_start(ctx, "gate_enablement"): ctx.gate.enable() transition_confirm( @@ -168,9 +164,6 @@ async def _open_kitchen_handler(*, preserve_active_recipe: bool = False) -> str try: _retain_kitchen_tracker_authority(ctx) identity = _register_active_kitchen(ctx) - from autoskillit.server.recipe import _recipe_generation # circular-break - - _recipe_generation.activate_kitchen(identity.kitchen_id) except Exception as exc: transition_degraded(ctx, "registry_update", exc) logger.warning("open_kitchen_registry_failed", exc_info=True) @@ -179,7 +172,7 @@ async def _open_kitchen_handler(*, preserve_active_recipe: bool = False) -> str ctx, "registry_update", receipt="registry:kitchen_registered", - downstream_identity=ctx.kitchen_id, + downstream_identity=identity.kitchen_id, ) if _transition_start(ctx, "tracker_prune"): diff --git a/src/autoskillit/server/tools/tools_kitchen/_open_kitchen_transition.py b/src/autoskillit/server/tools/tools_kitchen/_open_kitchen_transition.py index 287dbe3276..c7029fd663 100644 --- a/src/autoskillit/server/tools/tools_kitchen/_open_kitchen_transition.py +++ b/src/autoskillit/server/tools/tools_kitchen/_open_kitchen_transition.py @@ -34,24 +34,12 @@ # "autoskillit.server.tools.tools_kitchen." (the package facade), so # cross-submodule helpers must be resolved via attribute access on the # package at call time rather than imported by name into this submodule. +from autoskillit.server.lifecycle._kitchen_identity import establish_kitchen_identity from autoskillit.server.tools import tools_kitchen as _tk_pkg _OPEN_KITCHEN_REQUEST_CTX: ContextVar[ToolContext] = ContextVar("open_kitchen_request_context") -def _ensure_kitchen_transition(tool_ctx: ToolContext) -> None: - """Create infrastructure identity once, before request arguments are bound.""" - with tool_ctx.kitchen_transition_lock: - state = tool_ctx.kitchen_open_state - if state.phase is KitchenOpenPhase.CLOSED: - state = new_kitchen_open_state( - kitchen_id=_tk_pkg.resolve_kitchen_id(), - context_id=state.context_id, - ) - tool_ctx.kitchen_open_state = state - tool_ctx.kitchen_id = state.kitchen_id - - def _transition_start(tool_ctx: ToolContext, name: str) -> bool: """Journal STARTED and report whether the effect still needs dispatch.""" with tool_ctx.kitchen_transition_lock: @@ -221,7 +209,7 @@ async def wrapper(*args: Any, **kwargs: Any) -> str: getattr(fn, "__wrapped__", fn), ) return await unshielded(*args, **kwargs) - _ensure_kitchen_transition(tool_ctx) + establish_kitchen_identity(tool_ctx) bound = signature.bind_partial(*args, **kwargs) name = bound.arguments.get("name") overrides = bound.arguments.get("overrides") diff --git a/tests/_ambient_env_surface.py b/tests/_ambient_env_surface.py index 15333add53..947b7371a1 100644 --- a/tests/_ambient_env_surface.py +++ b/tests/_ambient_env_surface.py @@ -961,7 +961,7 @@ class AmbientEnvDisposition: "`profile.api_key_env` is a per-provider-profile instance attribute resolved at runtime" "from config, not a module-level constant this AST scanner can resolve." ), - "server/lifecycle/_lifespan/_session_boots.py:536": ( + "server/lifecycle/_lifespan/_session_boots.py:524": ( "Dict-comprehension key bound by `for name in EVIDENCE_READER_ENV_FORWARD_VARS`; the three" "forwarded names are already captured directly via that collection's own R4 scan." ), diff --git a/tests/arch/test_boot_step_symmetry.py b/tests/arch/test_boot_step_symmetry.py index 438db76a55..b13054ebe2 100644 --- a/tests/arch/test_boot_step_symmetry.py +++ b/tests/arch/test_boot_step_symmetry.py @@ -45,6 +45,11 @@ ] _BOOT_STEP_ORDERING: list[tuple[str, str, tuple[str, ...]]] = [ + ( + "establish_kitchen_identity", + "register_active_kitchen", + ("_fleet_auto_gate_boot", "_food_truck_auto_gate_boot", "_skill_auto_gate_boot"), + ), ( "reap_stale_dispatches_async", "sweep_stale_dispatch_labels", @@ -107,6 +112,20 @@ def _function_checks_false_result(tree: ast.Module, func_name: str, symbol: str) class TestBootStepSymmetry: + def test_identity_ordering_covers_registered_boots(self) -> None: + from autoskillit.server.lifecycle._lifespan import _LIFESPAN_BOOT_REGISTRY + + identity_boots = next( + boot_functions + for before, after, boot_functions in _BOOT_STEP_ORDERING + if (before, after) == ("establish_kitchen_identity", "register_active_kitchen") + ) + registered_boots = { + fn.__name__ for fn in _LIFESPAN_BOOT_REGISTRY.values() if fn is not None + } + + assert set(identity_boots) == registered_boots + @pytest.mark.parametrize("symbol,boot_functions", _REQUIRED_BOOT_STEPS) def test_boot_functions_contain_required_step( self, symbol: str, boot_functions: tuple[str, ...] diff --git a/tests/arch/test_kitchen_id_assignment.py b/tests/arch/test_kitchen_id_assignment.py deleted file mode 100644 index 4e8c001203..0000000000 --- a/tests/arch/test_kitchen_id_assignment.py +++ /dev/null @@ -1,92 +0,0 @@ -"""AST structural guard: kitchen_id assignment is only via resolve_kitchen_id().""" - -from __future__ import annotations - -import ast -from pathlib import Path - -import pytest - -pytestmark = [pytest.mark.layer("arch"), pytest.mark.small] - - -def _is_bootstrap_kitchen_id(value: ast.expr) -> bool: - if not isinstance(value, ast.Call): - return False - func_name = getattr(value.func, "id", None) or getattr(value.func, "attr", None) - return func_name == "resolve_kitchen_id" - - -def _is_stored_kitchen_id(value: ast.expr) -> bool: - return ( - isinstance(value, ast.Attribute) - and value.attr == "kitchen_id" - and isinstance(value.value, ast.Name) - and value.value.id == "state" - ) - - -def _is_empty_kitchen_id_reset(value: ast.expr) -> bool: - return isinstance(value, ast.Constant) and value.value == "" - - -def test_kitchen_id_only_assigned_via_transition_bootstrap(): - """Assignments must mint at bootstrap or consume/reset the stored transition ID. - - This prevents request handling from reminting a kitchen identity after the - infrastructure transition already owns one. - """ - pkg_dirs = ( - Path(__file__).parent.parent.parent - / "src" - / "autoskillit" - / "server" - / "lifecycle" - / "_lifespan", - Path(__file__).parent.parent.parent - / "src" - / "autoskillit" - / "server" - / "tools" - / "tools_kitchen", - ) - - canonical_assign_linenos: set[str] = set() - stored_assign_linenos: set[str] = set() - - for pkg_dir in pkg_dirs: - for py_path in sorted(pkg_dir.rglob("*.py")): - file_path_rel = str(py_path.relative_to(Path(__file__).parent.parent.parent)) - tree = ast.parse(py_path.read_text(), filename=file_path_rel) - - for node in ast.walk(tree): - if not isinstance(node, ast.Assign): - continue - for target in node.targets: - if not (isinstance(target, ast.Attribute) and target.attr == "kitchen_id"): - continue - if _is_bootstrap_kitchen_id(node.value): - canonical_assign_linenos.add(f"{file_path_rel}:{node.lineno}") - continue - if _is_stored_kitchen_id(node.value): - stored_assign_linenos.add(f"{file_path_rel}:{node.lineno}") - continue - if _is_empty_kitchen_id_reset(node.value): - continue - message = ( - f"ctx.kitchen_id assignment uses {ast.unparse(node.value)}, " - "not resolve_kitchen_id()" - if isinstance(node.value, ast.Call) - else "ctx.kitchen_id assignment must mint via resolve_kitchen_id(), " - "consume transition_state.kitchen_id, or reset empty" - ) - pytest.fail(f"{file_path_rel}:{node.lineno}: {message}") - - assert canonical_assign_linenos, ( - "No ctx.kitchen_id = resolve_kitchen_id() assignments found in scanned files — " - "guard is vacuous" - ) - assert stored_assign_linenos, ( - "No ctx.kitchen_id = transition_state.kitchen_id assignments found — " - "request handling is not consuming bootstrap identity" - ) diff --git a/tests/arch/test_kitchen_identity_single_authority.py b/tests/arch/test_kitchen_identity_single_authority.py new file mode 100644 index 0000000000..83f7da4df1 --- /dev/null +++ b/tests/arch/test_kitchen_identity_single_authority.py @@ -0,0 +1,276 @@ +"""Count the production references that define kitchen identity authority. + +Import-layer checks establish which modules may depend on one another; they do +not establish how many mint, activation, or state-construction sites exist. +These exact occurrence counts also reject duplicate references inside an +otherwise allowed scope. +""" + +from __future__ import annotations + +import ast +from collections import Counter, defaultdict +from pathlib import Path + +import pytest + +pytestmark = [pytest.mark.layer("arch"), pytest.mark.small] + +SRC_ROOT = Path(__file__).parents[2] / "src" / "autoskillit" +Location = tuple[str, str] + +_REFERENCE_TARGETS = frozenset({"resolve_kitchen_id"}) +_CALL_TARGETS = frozenset({"new_kitchen_open_state", "activate_kitchen", "KitchenOpenState"}) + + +class _KitchenIdentityOccurrenceCollector(ast.NodeVisitor): + """Count selected references and calls under their enclosing source scope.""" + + def __init__(self, relative_path: str) -> None: + self._relative_path = relative_path + self._scope_stack: list[str] = [] + self.references: defaultdict[str, Counter[Location]] = defaultdict(Counter) + self.calls: defaultdict[str, Counter[Location]] = defaultdict(Counter) + self.replace_kitchen_id_keywords: Counter[Location] = Counter() + + def _location(self) -> Location: + return ( + self._relative_path, + ".".join(self._scope_stack) or "", + ) + + def _record(self, inventory: defaultdict[str, Counter[Location]], symbol: str) -> None: + inventory[symbol][self._location()] += 1 + + def _enter_scope(self, name: str, node: ast.AST) -> None: + self._scope_stack.append(name) + self.generic_visit(node) + self._scope_stack.pop() + + def visit_ClassDef(self, node: ast.ClassDef) -> None: + self._enter_scope(node.name, node) + + def visit_FunctionDef(self, node: ast.FunctionDef) -> None: + self._enter_scope(node.name, node) + + def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: + self._enter_scope(node.name, node) + + def visit_Name(self, node: ast.Name) -> None: + if node.id in _REFERENCE_TARGETS: + self._record(self.references, node.id) + self.generic_visit(node) + + def visit_Attribute(self, node: ast.Attribute) -> None: + if node.attr in _REFERENCE_TARGETS: + self._record(self.references, node.attr) + self.generic_visit(node) + + def visit_ImportFrom(self, node: ast.ImportFrom) -> None: + for alias in node.names: + if alias.name in _REFERENCE_TARGETS or alias.asname in _REFERENCE_TARGETS: + self._record(self.references, "resolve_kitchen_id") + self.generic_visit(node) + + def visit_Call(self, node: ast.Call) -> None: + symbol = _call_name(node.func) + # Its public wrapper delegates to the store method; count callers of + # the API, not that implementation detail as a second authority site. + delegated_activation = ( + symbol == "activate_kitchen" + and self._relative_path == "server/recipe/_recipe_generation.py" + and self._scope_stack == ["activate_kitchen"] + and isinstance(node.func, ast.Attribute) + ) + if symbol in _CALL_TARGETS and not delegated_activation: + self._record(self.calls, symbol) + if symbol == "replace" and any(keyword.arg == "kitchen_id" for keyword in node.keywords): + self.replace_kitchen_id_keywords[self._location()] += 1 + self.generic_visit(node) + + +def _call_name(node: ast.expr) -> str | None: + if isinstance(node, ast.Name): + return node.id + if isinstance(node, ast.Attribute): + return node.attr + return None + + +def _collect_occurrences( + sources: list[tuple[str, str]], +) -> tuple[ + dict[str, Counter[Location]], + dict[str, Counter[Location]], + Counter[Location], +]: + assert sources, "kitchen identity collector scanned no source files" + references: defaultdict[str, Counter[Location]] = defaultdict(Counter) + calls: defaultdict[str, Counter[Location]] = defaultdict(Counter) + replace_kitchen_id_keywords: Counter[Location] = Counter() + + for relative_path, source in sources: + collector = _KitchenIdentityOccurrenceCollector(relative_path) + collector.visit(ast.parse(source, filename=relative_path)) + for symbol, counts in collector.references.items(): + references[symbol].update(counts) + for symbol, counts in collector.calls.items(): + calls[symbol].update(counts) + replace_kitchen_id_keywords.update(collector.replace_kitchen_id_keywords) + + return dict(references), dict(calls), replace_kitchen_id_keywords + + +def _collect_source_tree() -> tuple[ + dict[str, Counter[Location]], + dict[str, Counter[Location]], + Counter[Location], +]: + sources = [ + (path.relative_to(SRC_ROOT).as_posix(), path.read_text(encoding="utf-8")) + for path in sorted(SRC_ROOT.rglob("*.py")) + ] + return _collect_occurrences(sources) + + +def _assert_exact_counter( + actual: Counter[Location], expected: Counter[Location], description: str +) -> None: + assert actual, f"{description} inventory is empty; guard is vacuous" + duplicates = {location: count for location, count in actual.items() if count != 1} + assert not duplicates, f"{description} has duplicate occurrences: {duplicates}" + assert actual == expected, ( + f"{description} occurrences changed.\nExpected: {expected}\nFound: {actual}" + ) + + +def test_kitchen_identity_authority_matches_exact_occurrence_counts() -> None: + references, calls, replace_kitchen_id_keywords = _collect_source_tree() + + _assert_exact_counter( + references.get("resolve_kitchen_id", Counter()), + Counter( + { + ("core/runtime/__init__.py", ""): 1, + ("server/lifecycle/_kitchen_identity.py", ""): 1, + ("server/lifecycle/_kitchen_identity.py", "establish_kitchen_identity"): 1, + } + ), + "resolve_kitchen_id references", + ) + _assert_exact_counter( + calls.get("new_kitchen_open_state", Counter()), + Counter( + { + ("server/lifecycle/_kitchen_identity.py", "establish_kitchen_identity"): 1, + ( + "server/tools/tools_kitchen/_open_kitchen_transition.py", + "_bind_open_kitchen_transition.wrapper", + ): 1, + } + ), + "new_kitchen_open_state calls", + ) + _assert_exact_counter( + calls.get("activate_kitchen", Counter()), + Counter({("server/lifecycle/_kitchen_identity.py", "establish_kitchen_identity"): 1}), + "activate_kitchen calls", + ) + _assert_exact_counter( + calls.get("KitchenOpenState", Counter()), + Counter( + { + ("pipeline/kitchen_transition.py", "closed_kitchen_open_state"): 1, + ("pipeline/kitchen_transition.py", "new_kitchen_open_state"): 1, + } + ), + "KitchenOpenState constructions", + ) + assert not replace_kitchen_id_keywords, ( + f"dataclasses.replace must not change kitchen identity: {replace_kitchen_id_keywords}" + ) + + +def test_committed_reseed_passes_the_existing_kitchen_id_attribute() -> None: + source_path = SRC_ROOT / "server/tools/tools_kitchen/_open_kitchen_transition.py" + tree = ast.parse(source_path.read_text(encoding="utf-8"), filename=str(source_path)) + binder = next( + node + for node in tree.body + if isinstance(node, ast.FunctionDef) and node.name == "_bind_open_kitchen_transition" + ) + wrapper = next( + node + for node in ast.walk(binder) + if isinstance(node, ast.AsyncFunctionDef) and node.name == "wrapper" + ) + reseeds = [ + node + for node in ast.walk(wrapper) + if isinstance(node, ast.Call) and _call_name(node.func) == "new_kitchen_open_state" + ] + assert len(reseeds) == 1, "the committed re-seed call must remain in the wrapper" + kitchen_id_values = [ + keyword.value for keyword in reseeds[0].keywords if keyword.arg == "kitchen_id" + ] + assert len(kitchen_id_values) == 1, "the re-seed must pass one kitchen_id keyword" + assert isinstance(kitchen_id_values[0], ast.Attribute) + assert kitchen_id_values[0].attr == "kitchen_id" + + +_FORGED_SOURCE = """ +from autoskillit.core.runtime import resolve_kitchen_id + + +def establish_kitchen_identity(): + resolve_kitchen_id() + new_kitchen_open_state(kitchen_id="first", context_id="ctx") + new_kitchen_open_state(kitchen_id="second", context_id="ctx") + activate_kitchen("first") + KitchenOpenState(phase=None, kitchen_id="first", operation_id="", context_id="ctx") + replace(state, kitchen_id="forged") + + +def unregistered_boot(): + resolve_kitchen_id() + new_kitchen_open_state(kitchen_id="rogue", context_id="ctx") + activate_kitchen("rogue") + KitchenOpenState(phase=None, kitchen_id="rogue", operation_id="", context_id="ctx") + replace(state, kitchen_id="forged") +""" + + +def test_forged_source_canary_trips_counting_guards() -> None: + references, calls, replace_kitchen_id_keywords = _collect_occurrences( + [("synthetic/boot.py", _FORGED_SOURCE)] + ) + + with pytest.raises(AssertionError, match="duplicate occurrences|occurrences changed"): + _assert_exact_counter( + references["resolve_kitchen_id"], + Counter( + { + ("synthetic/boot.py", ""): 1, + ("synthetic/boot.py", "establish_kitchen_identity"): 1, + } + ), + "synthetic resolve_kitchen_id references", + ) + + # This duplicate is inside the otherwise allowed establishment scope. The + # Counter preserves it, where a set-based collector would collapse it. + assert ( + calls["new_kitchen_open_state"][("synthetic/boot.py", "establish_kitchen_identity")] == 2 + ) + for symbol in ("new_kitchen_open_state", "activate_kitchen", "KitchenOpenState"): + with pytest.raises(AssertionError, match="duplicate occurrences|occurrences changed"): + _assert_exact_counter( + calls[symbol], + Counter({("synthetic/boot.py", "establish_kitchen_identity"): 1}), + f"synthetic {symbol} calls", + ) + + with pytest.raises(AssertionError, match="replace must not change kitchen identity"): + assert not replace_kitchen_id_keywords, ( + f"dataclasses.replace must not change kitchen identity: {replace_kitchen_id_keywords}" + ) diff --git a/tests/cli/test_fleet_run.py b/tests/cli/test_fleet_run.py index 87a91aff7f..4d12ea5724 100644 --- a/tests/cli/test_fleet_run.py +++ b/tests/cli/test_fleet_run.py @@ -95,6 +95,39 @@ def _mock_backend() -> MagicMock: return backend +def _configure_fleet_run_identity_capture(monkeypatch, make_tool_ctx): + from autoskillit.pipeline import ToolContext + from autoskillit.server.recipe import _recipe_generation + + monkeypatch.setattr( + _recipe_generation, + "_RECIPE_GENERATION_STORE", + _recipe_generation.RecipeGenerationStore(), + ) + ctx = make_tool_ctx() + ctx.backend = _mock_backend() + ctx.skill_resolver = None + ctx.recipes = None + monkeypatch.setattr( + "autoskillit.server.make_context", + lambda cfg, **kwargs: ctx, + ) + monkeypatch.setattr( + "autoskillit.config.load_config", + lambda path=None: _make_test_config(fleet=True, fleet_headless_run=True), + ) + + captured: dict[str, object] = {} + + async def fake_execute_dispatch(*, tool_ctx: ToolContext, **kwargs: object) -> DispatchResult: + captured["kitchen_id"] = tool_ctx.kitchen_open_state.kitchen_id + captured["phase"] = tool_ctx.kitchen_open_state.phase + return _mock_success_result() + + monkeypatch.setattr("autoskillit.fleet.execute_dispatch", fake_execute_dispatch) + return ctx, captured + + class TestFleetRunGates: def test_fleet_run_blocks_in_leaf_session( self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture @@ -518,6 +551,84 @@ async def fake_execute(**kwargs: object) -> DispatchResult: assert captured_args["resume_session_id"] == "sess-123" assert captured_args["prior_dispatch_id"] == "disp-456" + @pytest.mark.parametrize( + "ambient_campaign_id", + [None, "ambient-campaign"], + ids=["without-ambient", "with-ambient"], + ) + def test_fleet_run_establishes_kitchen_identity_before_dispatch( + self, monkeypatch: pytest.MonkeyPatch, make_tool_ctx, ambient_campaign_id + ) -> None: + from autoskillit.core import CAMPAIGN_ID_ENV_VAR + from autoskillit.pipeline import KitchenOpenPhase + + if ambient_campaign_id is None: + monkeypatch.delenv(CAMPAIGN_ID_ENV_VAR, raising=False) + else: + monkeypatch.setenv(CAMPAIGN_ID_ENV_VAR, ambient_campaign_id) + + _ctx, captured = _configure_fleet_run_identity_capture(monkeypatch, make_tool_ctx) + + from autoskillit.cli.fleet import fleet_run + + with pytest.raises(SystemExit) as exit_info: + fleet_run("test-recipe", task="test") + + assert exit_info.value.code == 0 + kitchen_id = captured["kitchen_id"] + assert isinstance(kitchen_id, str) + assert kitchen_id + if ambient_campaign_id is not None: + assert kitchen_id == ambient_campaign_id + assert captured["phase"] is KitchenOpenPhase.INFRASTRUCTURE_READY + + def test_fleet_run_resume_preserves_recorded_campaign_id( + self, monkeypatch: pytest.MonkeyPatch, make_tool_ctx + ) -> None: + from autoskillit.core import CAMPAIGN_ID_ENV_VAR + from autoskillit.pipeline import KitchenOpenPhase + + monkeypatch.setenv(CAMPAIGN_ID_ENV_VAR, "ambient-campaign") + ctx, captured = _configure_fleet_run_identity_capture(monkeypatch, make_tool_ctx) + + prior_dispatch_id = "prior-dispatch" + dispatches_dir = ctx.temp_dir / "dispatches" + dispatches_dir.mkdir(parents=True, exist_ok=True) + (dispatches_dir / f"{prior_dispatch_id}.json").write_text( + json.dumps( + { + "schema_version": 9, + "campaign_id": "recorded-campaign", + "campaign_name": "test", + "manifest_path": "/m.yaml", + "started_at": 0.0, + "dispatches": [ + { + "name": "test-recipe", + "status": "failure", + "reason": "fleet_l3_timeout", + "session_chain": ["sess-A", "sess-B"], + "dispatched_session_id": "sess-B", + } + ], + } + ) + ) + + from autoskillit.cli.fleet import fleet_run + + with pytest.raises(SystemExit) as exit_info: + fleet_run( + "test-recipe", + task="test", + resume_session_id="sess-B", + prior_dispatch_id=prior_dispatch_id, + ) + + assert exit_info.value.code == 0 + assert captured["kitchen_id"] == "recorded-campaign" + assert captured["phase"] is KitchenOpenPhase.INFRASTRUCTURE_READY + def test_fleet_run_uses_shared_worker_capacity(self, monkeypatch: pytest.MonkeyPatch) -> None: """ToolContext is constructed via make_context with shared capacity wired.""" monkeypatch.setattr( diff --git a/tests/conftest.py b/tests/conftest.py index 400c42d8ff..4be839ca04 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -868,7 +868,7 @@ def tool_ctx(make_tool_ctx): @pytest.fixture def tool_ctx_kitchen_open(tool_ctx): - """tool_ctx variant with gate explicitly opened. + """tool_ctx variant with established identity and gate explicitly opened. Use when the test requires a tool that calls _require_enabled() and the test is not testing gate-boot behavior itself. This fixture @@ -881,10 +881,24 @@ def tool_ctx_kitchen_open(tool_ctx): from autoskillit.pipeline.gate import DefaultGateState tool_ctx.gate = DefaultGateState(enabled=True) - tool_ctx.kitchen_id = "test-kitchen" + bind_test_kitchen_identity(tool_ctx, "test-kitchen") return tool_ctx +def bind_test_kitchen_identity(ctx, kitchen_id: str) -> None: + """Bind a test kitchen identity without activating the recipe store.""" + from autoskillit.pipeline import closed_kitchen_open_state, new_kitchen_open_state + + with ctx.kitchen_transition_lock: + context_id = ctx.kitchen_open_state.context_id + ctx.kitchen_open_state = ( + new_kitchen_open_state(kitchen_id=kitchen_id, context_id=context_id) + if kitchen_id + else closed_kitchen_open_state(context_id=context_id) + ) + ctx.kitchen_process_identity = None + + def bind_test_skill_resume_contract( tool_ctx, *, diff --git a/tests/fleet/test_dispatch_crash_diagnostics.py b/tests/fleet/test_dispatch_crash_diagnostics.py index 465c1f78f4..1f0446ea86 100644 --- a/tests/fleet/test_dispatch_crash_diagnostics.py +++ b/tests/fleet/test_dispatch_crash_diagnostics.py @@ -7,6 +7,7 @@ import pytest +from tests.conftest import bind_test_kitchen_identity from tests.fleet._helpers import ( _noop_quota_refresher, _setup_dispatch, @@ -243,7 +244,7 @@ async def test_reject_with_state_persists_message_to_both_states( ) _setup_dispatch(tool_ctx, monkeypatch) - tool_ctx.kitchen_id = "cmp-reject" + bind_test_kitchen_identity(tool_ctx, kitchen_id="cmp-reject") from autoskillit.fleet._api import execute_dispatch from autoskillit.server.tools.tools_fleet_dispatch import _write_dispatch_to_campaign_state diff --git a/tests/fleet/test_dispatch_descendant_settlement.py b/tests/fleet/test_dispatch_descendant_settlement.py index 74265132da..2a5959ace8 100644 --- a/tests/fleet/test_dispatch_descendant_settlement.py +++ b/tests/fleet/test_dispatch_descendant_settlement.py @@ -33,6 +33,7 @@ from autoskillit.core import CLAUDE_CODE_CAPABILITIES from autoskillit.execution.process import default_tether_dir +from tests.conftest import bind_test_kitchen_identity from tests.fleet._descendant_worker import ( is_identity_alive, kill_identity_fenced, @@ -358,7 +359,7 @@ def descendant_runtime( tool_ctx.worker_capacity = DefaultManagedWorkerCapacity(max_concurrent=1) recipes = InMemoryRecipeRepository() tool_ctx.recipes = recipes - tool_ctx.kitchen_id = uuid4().hex[:16] + bind_test_kitchen_identity(tool_ctx, kitchen_id=uuid4().hex[:16]) tool_ctx.project_dir = tmp_path tool_ctx.managed_headless_session_lineage_store = DefaultManagedHeadlessSessionLineageStore() diff --git a/tests/fleet/test_dispatch_outcome_codex_backend.py b/tests/fleet/test_dispatch_outcome_codex_backend.py index ec81b815c1..59edaff1db 100644 --- a/tests/fleet/test_dispatch_outcome_codex_backend.py +++ b/tests/fleet/test_dispatch_outcome_codex_backend.py @@ -47,6 +47,7 @@ from autoskillit.fleet.result_parser import parse_l3_result_block from autoskillit.fleet.sidecar import sidecar_path from autoskillit.recipe.schema import Recipe, RecipeInfo, RecipeKind, RecipeSource +from tests.conftest import bind_test_kitchen_identity from tests.fakes import InMemoryRecipeRepository from tests.fleet._helpers import _read_dispatch_record from tests.fleet.test_fleet_e2e_codex import _noop_quota_refresher, _simple_prompt_builder @@ -279,7 +280,7 @@ def _wire_codex_runtime( tool_ctx.worker_capacity = DefaultManagedWorkerCapacity(max_concurrent=1) recipes = InMemoryRecipeRepository() tool_ctx.recipes = recipes - tool_ctx.kitchen_id = uuid4().hex[:16] + bind_test_kitchen_identity(tool_ctx, kitchen_id=uuid4().hex[:16]) tool_ctx.project_dir = tmp_path (tool_ctx.temp_dir / "dispatches").mkdir(parents=True, exist_ok=True) _add_recipe(recipes, _RECIPE_NAME) diff --git a/tests/fleet/test_fleet_e2e.py b/tests/fleet/test_fleet_e2e.py index 3679feb9cd..30e54dc246 100644 --- a/tests/fleet/test_fleet_e2e.py +++ b/tests/fleet/test_fleet_e2e.py @@ -25,6 +25,7 @@ import pytest from autoskillit.core import CLAUDE_CODE_CAPABILITIES +from tests.conftest import bind_test_kitchen_identity from tests.fleet._helpers import assert_food_truck_lease_launch pytestmark = [ @@ -387,7 +388,7 @@ def fleet_runtime( tool_ctx.worker_capacity = DefaultManagedWorkerCapacity(max_concurrent=1) recipes = InMemoryRecipeRepository() tool_ctx.recipes = recipes - tool_ctx.kitchen_id = uuid4().hex[:16] + bind_test_kitchen_identity(tool_ctx, kitchen_id=uuid4().hex[:16]) tool_ctx.project_dir = tmp_path tool_ctx.managed_headless_session_lineage_store = DefaultManagedHeadlessSessionLineageStore() @@ -1129,11 +1130,6 @@ async def test_fleet_auto_gate_boot_reaps_orphan(tmp_path: Path) -> None: patch("autoskillit.fleet._dispatch_reaper.psutil.pid_exists", return_value=False), patch.object(dispatch_reaper, "read_boot_id", return_value=None), patch.object(dispatch_reaper, "kill_process_tree"), - patch.object( - lifespan, - "resolve_kitchen_id", - return_value="kitchen-test", - ), patch.object( lifespan, "discover_campaign_state_files", diff --git a/tests/fleet/test_fleet_e2e_codex.py b/tests/fleet/test_fleet_e2e_codex.py index ac3180ddaf..56ea693542 100644 --- a/tests/fleet/test_fleet_e2e_codex.py +++ b/tests/fleet/test_fleet_e2e_codex.py @@ -25,6 +25,7 @@ from autoskillit.execution.backends import CodexBackend from autoskillit.execution.headless import DefaultHeadlessExecutor from autoskillit.fleet._api import execute_dispatch +from tests.conftest import bind_test_kitchen_identity from tests.fakes import InMemoryRecipeRepository from tests.fleet._helpers import assert_food_truck_lease_launch from tests.fleet.test_fleet_e2e import FleetTestRunner @@ -161,7 +162,7 @@ def codex_runtime( tool_ctx.worker_capacity = DefaultManagedWorkerCapacity(max_concurrent=1) recipes = InMemoryRecipeRepository() tool_ctx.recipes = recipes - tool_ctx.kitchen_id = uuid4().hex[:16] + bind_test_kitchen_identity(tool_ctx, kitchen_id=uuid4().hex[:16]) tool_ctx.project_dir = tmp_path dispatches_dir = tool_ctx.temp_dir / "dispatches" diff --git a/tests/fleet/test_fleet_e2e_codex_dispatch_identity.py b/tests/fleet/test_fleet_e2e_codex_dispatch_identity.py index 9329af791e..b649af3ed4 100644 --- a/tests/fleet/test_fleet_e2e_codex_dispatch_identity.py +++ b/tests/fleet/test_fleet_e2e_codex_dispatch_identity.py @@ -24,7 +24,7 @@ from autoskillit.execution.process import default_tether_dir from autoskillit.pipeline.gate import DefaultGateState from autoskillit.server.tools.tools_fleet_dispatch import dispatch_food_truck -from tests.conftest import production_interpreter_env +from tests.conftest import bind_test_kitchen_identity, production_interpreter_env from tests.fakes import InMemoryRecipeRepository from tests.fleet._codex_mcp_env import ( CODEX_MCP_DEFAULT_ENV_VARS, @@ -369,7 +369,7 @@ def codex_mcp_runtime( tool_ctx.worker_capacity = DefaultManagedWorkerCapacity(max_concurrent=1) recipes = InMemoryRecipeRepository() tool_ctx.recipes = recipes - tool_ctx.kitchen_id = uuid4().hex[:16] + bind_test_kitchen_identity(tool_ctx, kitchen_id=uuid4().hex[:16]) tool_ctx.project_dir = tmp_path dispatches_dir = tool_ctx.temp_dir / "dispatches" diff --git a/tests/infra/test_e2e_harness.py b/tests/infra/test_e2e_harness.py index 1670cc60c2..121a2c9a86 100644 --- a/tests/infra/test_e2e_harness.py +++ b/tests/infra/test_e2e_harness.py @@ -1081,18 +1081,6 @@ def test_real_bare_remote_cleanup_preserves_unrelated_branch_and_expected_failur sleeps: list[float] = [] monkeypatch.setattr(harness, "time", SimpleNamespace(sleep=sleeps.append)) - with subprocess.Popen( - [harness.sys.executable, "-c", "import sys; sys.stdin.read()"], - stdin=subprocess.PIPE, - ) as process: - try: - with pytest.raises(subprocess.TimeoutExpired): - process.wait(timeout=0.05) - finally: - process.kill() - process.wait(timeout=5) - assert len(sleeps) == 0 - status = harness.cleanup_smoke_test(catalog, test.name, runner, out, env) result = json.loads((out / "result.json").read_text(encoding="utf-8")) evidence = json.loads((out / "smoke-cleanup.json").read_text(encoding="utf-8")) @@ -1494,7 +1482,11 @@ def test_unexpected_fleet_error_records_failure_and_still_cleans_up( ) -> None: runner = FakeRunner(_recipe_handler(fleet=RuntimeError("unexpected fleet error"))) failures, result = _run_recipe(tmp_path, runner) - assert failures == ["harness error: unexpected fleet error"] + assert failures == ["harness error: RuntimeError: unexpected fleet error"] + exception = result.pop("exception") + assert "Traceback (most recent call last)" in exception + assert "_run_test" in exception + assert "RuntimeError: unexpected fleet error" in exception assert result == { "test": "impl", "passed": False, @@ -1868,7 +1860,7 @@ def test_cli_names_expected_failure_and_run_test_handles_exceptions( assert "e2e_harness: clean-install expected_failure" in capsys.readouterr().out def crash(*_args, **_kwargs): - raise RuntimeError("runner crashed") + raise RuntimeError("runner crashed") from ValueError(SANDBOX_TOKEN) monkeypatch.setattr(harness, "run_command", crash) assert ( @@ -1885,7 +1877,20 @@ def crash(*_args, **_kwargs): ) == 1 ) - assert "harness error: runner crashed" in capsys.readouterr().err + stderr = capsys.readouterr().err + assert "harness error: RuntimeError: runner crashed" in stderr + assert "Traceback" not in stderr + assert SANDBOX_TOKEN not in stderr + crash_out = tmp_path / "crash-out" + result = json.loads((crash_out / "result.json").read_text(encoding="utf-8")) + assert "RuntimeError: runner crashed" in result["exception"] + assert SANDBOX_TOKEN in result["exception"] + + redacted = tmp_path / "redacted" + harness.redact_tree([crash_out], redacted, [SANDBOX_TOKEN]) + copied_result = (redacted / "crash-out" / "result.json").read_text(encoding="utf-8") + assert SANDBOX_TOKEN not in copied_result + assert "[REDACTED]" in copied_result class TestRedaction: diff --git a/tests/integration/test_headless_kitchen_identity.py b/tests/integration/test_headless_kitchen_identity.py new file mode 100644 index 0000000000..26c55ff2bd --- /dev/null +++ b/tests/integration/test_headless_kitchen_identity.py @@ -0,0 +1,136 @@ +"""Keep a headless orchestrator food truck on one kitchen identity.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import anyio +import pytest +from fastmcp.client import Client +from fastmcp.client.transports import StdioTransport + +from autoskillit.core import ( + CAMPAIGN_ID_ENV_VAR, + DISPATCH_ID_ENV_VAR, + FOOD_TRUCK_TOOL_TAGS_ENV_VAR, + HEADLESS_ENV_VAR, + SESSION_TYPE_ENV_VAR, + SESSION_TYPE_ORCHESTRATOR, + load_yaml, +) +from tests.conftest import production_interpreter_env +from tests.integration.test_codex_mcp_tracker_dispatch_identity import ( + _tool_json, + _write_project_config, +) + +pytestmark = [ + pytest.mark.layer("integration"), + pytest.mark.integration, + pytest.mark.medium, + pytest.mark.anyio, + pytest.mark.skipif(sys.platform != "linux", reason="Linux-only: stdio MCP subprocess"), +] + + +@pytest.mark.parametrize( + "campaign_id", + ["", "campaign-5250"], + ids=["empty-campaign", "campaign-id"], +) +async def test_headless_food_truck_reuses_its_kitchen_identity( + tmp_path: Path, + campaign_id: str, +) -> None: + project_dir = tmp_path / "project" + project_dir.mkdir() + _write_project_config(project_dir) + recipe_dir = project_dir / ".autoskillit" / "recipes" + recipe_dir.mkdir() + (recipe_dir / "kitchen-identity-probe.yaml").write_text( + """\ +name: kitchen-identity-probe +description: Expose the session kitchen identity in rendered recipe content. +recipe_version: "1.0.0" +kitchen_rules: + - Use MCP tools only. +ingredients: + source_dir: + description: Project directory + required: true + kitchen_id: + description: Session kitchen identity + default: "" + hidden: true +steps: + observe_identity: + tool: run_cmd + with: + cmd: echo ${{ inputs.kitchen_id }} + cwd: ${{ inputs.source_dir }} + step_name: observe_identity + on_success: done + on_failure: done + done: + action: stop + message: 'Emit the L3 result sentinel JSON block with success=true: {"success": true}' +""", + encoding="utf-8", + ) + + env = { + **production_interpreter_env(), + HEADLESS_ENV_VAR: "1", + SESSION_TYPE_ENV_VAR: SESSION_TYPE_ORCHESTRATOR, + FOOD_TRUCK_TOOL_TAGS_ENV_VAR: "kitchen-core", + DISPATCH_ID_ENV_VAR: "disp-5250", + CAMPAIGN_ID_ENV_VAR: campaign_id, + "AUTOSKILLIT_PROJECT_DIR": str(project_dir), + } + transport = StdioTransport( + command=sys.executable, + args=["-m", "autoskillit"], + env=env, + cwd=str(project_dir), + keep_alive=False, + ) + + with anyio.fail_after(45): + async with Client(transport) as client: + open_payload = _tool_json( + await client.call_tool( + "open_kitchen", + { + "name": "kitchen-identity-probe", + "overrides": {"source_dir": str(project_dir)}, + }, + ) + ) + assert open_payload["success"] is True, open_payload + assert open_payload["valid"] is True, open_payload + assert open_payload["phase"] == "committed", open_payload + opened_recipe = load_yaml(str(open_payload["content"])) + opened_kitchen_id = opened_recipe["steps"]["observe_identity"]["with"][ + "cmd" + ].removeprefix("echo ") + assert opened_kitchen_id + assert "${{" not in opened_kitchen_id + if campaign_id: + assert opened_kitchen_id == campaign_id + + load_payload = _tool_json( + await client.call_tool( + "load_recipe", + { + "name": "kitchen-identity-probe", + "overrides": {"source_dir": str(project_dir)}, + }, + ) + ) + assert load_payload["success"] is True, load_payload + loaded_recipe = load_yaml(str(load_payload["content"])) + assert ( + loaded_recipe["steps"]["observe_identity"]["with"]["cmd"] + == f"echo {opened_kitchen_id}" + ) diff --git a/tests/integration/test_pipeline_step_completion_flow.py b/tests/integration/test_pipeline_step_completion_flow.py index 592bd7ecf2..485a49bc07 100644 --- a/tests/integration/test_pipeline_step_completion_flow.py +++ b/tests/integration/test_pipeline_step_completion_flow.py @@ -24,6 +24,7 @@ complete_run_skill_result, recover_run_skill_result, ) +from tests.conftest import bind_test_kitchen_identity from tests.server._pipeline_test_helpers import ( _ack_direct_run_skill_result, _write_tracker, @@ -129,7 +130,6 @@ async def test_run_skill_success_marks_step_complete_server_side( ): monkeypatch.delenv("AUTOSKILLIT_DISPATCH_ID", raising=False) _setup_project(tmp_path, tool_ctx_kitchen_open) - tool_ctx_kitchen_open.kitchen_id = "test-kitchen" _write_tracker( tmp_path, "test-kitchen", @@ -153,7 +153,6 @@ async def test_wire_delivery_is_acknowledged_through_completion_handler( ): monkeypatch.delenv("AUTOSKILLIT_DISPATCH_ID", raising=False) _setup_project(tmp_path, tool_ctx_kitchen_open) - tool_ctx_kitchen_open.kitchen_id = "test-kitchen" _write_tracker( tmp_path, "test-kitchen", @@ -208,7 +207,6 @@ async def test_lost_delivery_is_recovered_by_replacement_request_session( ): monkeypatch.delenv("AUTOSKILLIT_DISPATCH_ID", raising=False) _setup_project(tmp_path, tool_ctx_kitchen_open) - tool_ctx_kitchen_open.kitchen_id = "test-kitchen" _write_tracker( tmp_path, "test-kitchen", @@ -279,7 +277,6 @@ async def test_retry_after_post_ack_failure_replays_carrier_and_tracker_once( ): monkeypatch.delenv("AUTOSKILLIT_DISPATCH_ID", raising=False) _setup_project(tmp_path, tool_ctx_kitchen_open) - tool_ctx_kitchen_open.kitchen_id = "test-kitchen" _write_tracker( tmp_path, "test-kitchen", @@ -358,7 +355,6 @@ async def test_failed_receipt_uses_bodyless_recovery_carrier( ): monkeypatch.delenv("AUTOSKILLIT_DISPATCH_ID", raising=False) _setup_project(tmp_path, tool_ctx_kitchen_open) - tool_ctx_kitchen_open.kitchen_id = "test-kitchen" _write_tracker( tmp_path, "test-kitchen", @@ -395,7 +391,6 @@ async def test_acknowledged_replay_rejects_other_bindings_and_unknown_receipts( ): monkeypatch.delenv("AUTOSKILLIT_DISPATCH_ID", raising=False) _setup_project(tmp_path, tool_ctx_kitchen_open) - tool_ctx_kitchen_open.kitchen_id = "test-kitchen" _write_tracker( tmp_path, "test-kitchen", @@ -425,20 +420,20 @@ async def test_acknowledged_replay_rejects_other_bindings_and_unknown_receipts( ctx=SimpleNamespace(session_id="other-session"), # type: ignore[arg-type] ) ) - tool_ctx_kitchen_open.kitchen_id = "other-kitchen" + bind_test_kitchen_identity(tool_ctx_kitchen_open, kitchen_id="other-kitchen") wrong_kitchen = json.loads( await complete_run_skill_result( receipt_id, ctx=SimpleNamespace(session_id="request-session"), # type: ignore[arg-type] ) ) - tool_ctx_kitchen_open.kitchen_id = "test-kitchen" unknown = json.loads( await complete_run_skill_result( "unknown-receipt", ctx=SimpleNamespace(session_id="request-session"), # type: ignore[arg-type] ) ) + bind_test_kitchen_identity(tool_ctx_kitchen_open, kitchen_id="test-kitchen") replay = json.loads( await complete_run_skill_result( receipt_id, @@ -462,7 +457,6 @@ async def test_dependent_step_allowed_after_server_side_marking( ): monkeypatch.delenv("AUTOSKILLIT_DISPATCH_ID", raising=False) _setup_project(tmp_path, tool_ctx_kitchen_open) - tool_ctx_kitchen_open.kitchen_id = "test-kitchen" _write_tracker( tmp_path, "test-kitchen", @@ -496,7 +490,6 @@ async def test_stale_second_tracker_does_not_disable_marking( ): monkeypatch.delenv("AUTOSKILLIT_DISPATCH_ID", raising=False) _setup_project(tmp_path, tool_ctx_kitchen_open) - tool_ctx_kitchen_open.kitchen_id = "test-kitchen" _write_tracker( tmp_path, "test-kitchen", @@ -532,7 +525,6 @@ async def test_retry_suffix_folds_to_canonical_step( ): monkeypatch.delenv("AUTOSKILLIT_DISPATCH_ID", raising=False) _setup_project(tmp_path, tool_ctx_kitchen_open) - tool_ctx_kitchen_open.kitchen_id = "test-kitchen" _write_tracker( tmp_path, "test-kitchen", @@ -558,7 +550,6 @@ async def test_failure_and_needs_retry_do_not_mark_complete( ): monkeypatch.delenv("AUTOSKILLIT_DISPATCH_ID", raising=False) _setup_project(tmp_path, tool_ctx_kitchen_open) - tool_ctx_kitchen_open.kitchen_id = "test-kitchen" _write_tracker( tmp_path, "test-kitchen", @@ -584,7 +575,6 @@ async def test_empty_step_name_does_not_write_tracker( ): monkeypatch.delenv("AUTOSKILLIT_DISPATCH_ID", raising=False) _setup_project(tmp_path, tool_ctx_kitchen_open) - tool_ctx_kitchen_open.kitchen_id = "test-kitchen" tool_ctx_kitchen_open.active_recipe_steps = {} _write_tracker( tmp_path, @@ -613,7 +603,6 @@ async def test_advisory_surfaced_when_dependent_still_unmet( """The 'advisory' key mark_step_complete() populates must reach the caller.""" monkeypatch.delenv("AUTOSKILLIT_DISPATCH_ID", raising=False) _setup_project(tmp_path, tool_ctx_kitchen_open) - tool_ctx_kitchen_open.kitchen_id = "test-kitchen" _write_tracker( tmp_path, "test-kitchen", @@ -645,7 +634,6 @@ async def test_resume_with_step_name_marks_complete_on_success( ): monkeypatch.delenv("AUTOSKILLIT_DISPATCH_ID", raising=False) _setup_project(tmp_path, tool_ctx_kitchen_open) - tool_ctx_kitchen_open.kitchen_id = "test-kitchen" _write_tracker( tmp_path, "test-kitchen", diff --git a/tests/pipeline/test_context.py b/tests/pipeline/test_context.py index 4c02ec05b7..addf0bc291 100644 --- a/tests/pipeline/test_context.py +++ b/tests/pipeline/test_context.py @@ -345,6 +345,20 @@ def test_kitchen_process_identity_is_sampled_once_with_owner_fallback(tmp_path): assert first.project_path == str(tmp_path.resolve()) +def test_tool_context_kitchen_id_is_read_only_and_derived(tmp_path) -> None: + from tests.conftest import bind_test_kitchen_identity + + ctx = _make_ctx(tmp_path) + + assert "kitchen_id" not in {field.name for field in dataclasses.fields(ToolContext)} + assert ctx.kitchen_id == "" + with pytest.raises(AttributeError): + setattr(ctx, "kitchen_id", "forged") + + bind_test_kitchen_identity(ctx, "test-kitchen") + assert ctx.kitchen_id == ctx.kitchen_open_state.kitchen_id == "test-kitchen" + + def test_toolcontext_github_client_annotated_with_protocol(): """github_client annotation must reference GitHubFetcher protocol.""" hints = get_type_hints(ToolContext) diff --git a/tests/server/_helpers.py b/tests/server/_helpers.py index f74e988c47..c2287843ab 100644 --- a/tests/server/_helpers.py +++ b/tests/server/_helpers.py @@ -4,6 +4,7 @@ import json from collections.abc import Mapping +from contextlib import AbstractContextManager from dataclasses import dataclass, field from pathlib import Path from typing import TYPE_CHECKING, Any @@ -31,6 +32,15 @@ from autoskillit.config.settings import AgentBackendConfig from autoskillit.pipeline import ToolContext + +def patched_kitchen_id(kitchen_id: str) -> AbstractContextManager[MagicMock]: + """Patch the lifecycle identity resolver for server tests.""" + return patch( + "autoskillit.server.lifecycle._kitchen_identity.resolve_kitchen_id", + return_value=kitchen_id, + ) + + _HOOK_CONFIG_OVERLAY_RELPATH = (".autoskillit", "temp", ".hook_config_overlay.json") BUNDLED_RECIPE_STEP_BACKEND_PIN_CASES = ( @@ -245,11 +255,7 @@ async def _open_kitchen_patched( with patch.object(tools_kitchen, "_prime_quota_cache", new=AsyncMock()): with patch.object(tools_kitchen, "_write_hook_config"): with patch.object(tools_kitchen, "create_background_task"): - with patch.object( - tools_kitchen, - "resolve_kitchen_id", - return_value="test-kitchen", - ): + with patched_kitchen_id("test-kitchen"): raw_response = await open_kitchen(name=name, overrides=overrides, ctx=fmcp_ctx) envelope = json.loads(raw_response) if counter is not None: diff --git a/tests/server/_session_catalogs.py b/tests/server/_session_catalogs.py index af649525a8..4c00cf39c8 100644 --- a/tests/server/_session_catalogs.py +++ b/tests/server/_session_catalogs.py @@ -131,7 +131,6 @@ async def build_session_catalog( monkeypatch.setattr(_misc, "_prime_quota_cache", AsyncMock()) monkeypatch.setattr(_session_boots, "_retain_kitchen_tracker_authority", MagicMock()) monkeypatch.setattr(_session_boots, "register_active_kitchen", MagicMock(return_value=True)) - monkeypatch.setattr(_session_boots, "_activate_recipe_kitchen", MagicMock()) monkeypatch.setattr(_lifespan, "create_background_task", MagicMock(return_value=MagicMock())) monkeypatch.setattr(_lifespan, "discover_campaign_state_files", MagicMock(return_value=[])) monkeypatch.setattr(_lifespan, "sweep_orphaned_tethers_async", AsyncMock()) diff --git a/tests/server/conftest.py b/tests/server/conftest.py index 728e64f66e..75e36eeebf 100644 --- a/tests/server/conftest.py +++ b/tests/server/conftest.py @@ -16,6 +16,8 @@ import structlog.contextvars import structlog.testing +from tests.conftest import bind_test_kitchen_identity + if TYPE_CHECKING: from autoskillit.pipeline import ToolContext from autoskillit.pipeline.timings import DefaultTimingLog @@ -141,18 +143,12 @@ def _set_mock_kitchen_transition(ctx: MagicMock, *, kitchen_id: str = "") -> Non """Give a mock context the typed transition state guaranteed by make_context().""" from threading import RLock - from autoskillit.pipeline import closed_kitchen_open_state, new_kitchen_open_state + from autoskillit.pipeline import closed_kitchen_open_state - closed_state = closed_kitchen_open_state() ctx.kitchen_transition_lock = RLock() - ctx.kitchen_open_state = ( - new_kitchen_open_state( - kitchen_id=kitchen_id, - context_id=closed_state.context_id, - ) - if kitchen_id - else closed_state - ) + ctx.kitchen_open_state = closed_kitchen_open_state() + bind_test_kitchen_identity(ctx, kitchen_id) + type(ctx).kitchen_id = property(lambda self: self.kitchen_open_state.kitchen_id) def _make_mock_ctx(config=None) -> MagicMock: @@ -181,7 +177,7 @@ def _make_mock_ctx(config=None) -> MagicMock: ctx.project_dir.mkdir(parents=True, exist_ok=True) ctx.skill_resolver = MagicMock(spec=SkillResolver) ctx.skill_resolver.resolve_effective.return_value = None - ctx.kitchen_id = f"test-{uuid4().hex}" + kitchen_id = f"test-{uuid4().hex}" ctx.kitchen_process_identity = None ctx.kitchen_tracker_key = None ctx.tracker_leases = {} @@ -191,7 +187,7 @@ def _make_mock_ctx(config=None) -> MagicMock: ctx.config.subsets.disabled = [] # REQ-VIS-008: no subsets disabled by default ctx.active_recipe_ingredients = None ctx.gate_infrastructure_ready = False - _set_mock_kitchen_transition(ctx) + _set_mock_kitchen_transition(ctx, kitchen_id=kitchen_id) ctx.recipe_execution_lock = RLock() ctx.recipe_initialization_state = NoActiveRecipe() ctx.recipe_execution_factory = make_recipe_execution @@ -449,17 +445,15 @@ def with_args(self) -> dict[str, Any]: @pytest.fixture def tool_ctx_kitchen_open(tool_ctx: ToolContext) -> ToolContext: - """Open the gate while retaining production backend compatibility metadata. + """Bind test identity and open the gate while retaining backend metadata. Mirrors the parent fixture in tests/conftest.py so tests under tests/server/ - that read ``tool_ctx.kitchen_id`` (e.g. tracker-bound tests) get the same - ``"test-kitchen"`` sentinel the parent provides, instead of the silent - ``None`` a gate-only override would yield. + get the same established identity and open gate state. """ assert tool_ctx is not None assert hasattr(tool_ctx, "gate"), "tool_ctx must expose a `gate` attribute" tool_ctx.gate = DefaultGateState(enabled=True) - tool_ctx.kitchen_id = "test-kitchen" + bind_test_kitchen_identity(tool_ctx, "test-kitchen") return tool_ctx diff --git a/tests/server/test_kitchen_identity_lifecycle.py b/tests/server/test_kitchen_identity_lifecycle.py new file mode 100644 index 0000000000..7482157d78 --- /dev/null +++ b/tests/server/test_kitchen_identity_lifecycle.py @@ -0,0 +1,186 @@ +"""Behavioral contracts for the single kitchen-identity lifecycle owner.""" + +from __future__ import annotations + +import json +from unittest.mock import AsyncMock, MagicMock, Mock, patch + +import pytest + +pytestmark = [pytest.mark.layer("server"), pytest.mark.medium] + + +@pytest.mark.anyio +@pytest.mark.parametrize( + ("session_type", "headless", "campaign_id", "pre_reveal", "effect_name"), + [ + pytest.param("orchestrator", "1", None, False, "registry_update", id="food-truck"), + pytest.param( + "orchestrator", + "1", + "campaign-5250", + False, + "registry_update", + id="food-truck-campaign", + ), + pytest.param("fleet", "0", None, False, "registry_update", id="interactive-fleet"), + pytest.param( + "orchestrator", + "0", + None, + True, + "pre_reveal_bootstrap", + id="interactive-pre-reveal", + ), + ], +) +async def test_boot_open_load_share_one_kitchen_identity( + session_type, + headless, + campaign_id, + pre_reveal, + effect_name, + monkeypatch, + tool_ctx, +): + """Each boot path keeps its established identity through recipe registration and pull.""" + from autoskillit.core import ( + CAMPAIGN_ID_ENV_VAR, + DISPATCH_ID_ENV_VAR, + FOOD_TRUCK_TOOL_TAGS_ENV_VAR, + SessionType, + ) + from autoskillit.pipeline import KitchenOpenPhase, get_kitchen_process_identity + from autoskillit.server import _misc, _tracker_authority + from autoskillit.server.lifecycle import _lifespan + from autoskillit.server.lifecycle._lifespan import _LIFESPAN_BOOT_REGISTRY, _session_boots + from autoskillit.server.tools import tools_kitchen + from autoskillit.server.tools.tools_kitchen import open_kitchen + from autoskillit.server.tools.tools_kitchen._open_kitchen import _gate as open_gate + from autoskillit.server.tools.tools_recipe import load_recipe + from tests.server._helpers import _mock_fmcp_ctx + from tests.server.conftest import _READY_RECIPE_OVERRIDES + + monkeypatch.setenv("AUTOSKILLIT_HEADLESS", headless) + monkeypatch.setenv("AUTOSKILLIT_SESSION_TYPE", session_type) + if campaign_id is not None: + monkeypatch.setenv(CAMPAIGN_ID_ENV_VAR, campaign_id) + if session_type == "orchestrator" and headless == "1": + monkeypatch.setenv(FOOD_TRUCK_TOOL_TAGS_ENV_VAR, "kitchen-core") + monkeypatch.setenv(DISPATCH_ID_ENV_VAR, "disp-5250") + if pre_reveal: + from dataclasses import replace + + real_backend = tool_ctx.backend + assert real_backend is not None + backend = MagicMock(wraps=real_backend) + backend.name = real_backend.name + backend.capabilities = replace( + real_backend.capabilities, + supports_tool_list_changed=False, + ) + tool_ctx.backend = backend + + tool_ctx.quota_refresh_task = None + overrides = { + **_READY_RECIPE_OVERRIDES, + "source_dir": str(tool_ctx.project_dir.resolve()), + } + fmcp_ctx = _mock_fmcp_ctx() + no_op_reaper = AsyncMock() + no_op_sweep = AsyncMock() + session_enum = SessionType(session_type) + + with ( + patch.object(_misc, "_prime_quota_cache", new=AsyncMock()), + patch.object(tools_kitchen, "_prime_quota_cache", new=AsyncMock()), + patch.object(tools_kitchen, "_write_hook_config"), + patch.object(_lifespan, "create_background_task", return_value=MagicMock()), + patch.object(tools_kitchen, "create_background_task", return_value=MagicMock()), + patch.object(_lifespan, "discover_campaign_state_files", return_value=[]), + patch.object(tools_kitchen, "discover_campaign_state_files", return_value=[]), + patch.object(_lifespan, "reap_stale_dispatches_async", new=no_op_reaper), + patch.object(tools_kitchen, "reap_stale_dispatches_async", new=no_op_reaper), + patch.object(_lifespan, "sweep_orphaned_tethers_async", new=no_op_sweep), + patch.object(tools_kitchen, "sweep_orphaned_tethers_async", new=no_op_sweep), + patch.object(open_gate, "sweep_stale_markers"), + patch.object(open_gate, "prune_stale_kitchen_state"), + patch.object(_lifespan, "_reap_self_excluded_codex_and_daemon_orphans"), + patch.object(_session_boots, "register_active_kitchen", return_value=True), + patch.object(_tracker_authority, "register_active_kitchen", return_value=True), + ): + await _LIFESPAN_BOOT_REGISTRY[session_enum](tool_ctx) + + opened = json.loads(await open_kitchen(name="research", overrides=overrides, ctx=fmcp_ctx)) + loaded = json.loads(await load_recipe(name="research", overrides=overrides)) + + assert opened["success"] is True, opened + assert tool_ctx.kitchen_open_state.phase is KitchenOpenPhase.COMMITTED + assert loaded["success"] is True, loaded + assert tool_ctx.kitchen_id == tool_ctx.kitchen_open_state.kitchen_id + assert get_kitchen_process_identity(tool_ctx).kitchen_id == tool_ctx.kitchen_id + effect = next(item for item in tool_ctx.kitchen_open_state.effects if item.name == effect_name) + assert effect.downstream_identity == tool_ctx.kitchen_id + if campaign_id is not None: + assert tool_ctx.kitchen_id == campaign_id + + +def test_establish_kitchen_identity_activates_and_resets_owner_cache(tool_ctx): + from autoskillit.pipeline import KitchenOpenPhase, get_kitchen_process_identity + from autoskillit.server.lifecycle._kitchen_identity import establish_kitchen_identity + from autoskillit.server.recipe._recipe_generation import get_recipe_generation_store + from tests.server.test_recipe_generation import _record + + store = get_recipe_generation_store() + store.activate_kitchen("other") + owner_identity = get_kitchen_process_identity(tool_ctx, "owner-before-establishment") + assert owner_identity.kitchen_id == "owner-before-establishment" + + kitchen_id = establish_kitchen_identity(tool_ctx) + + assert kitchen_id + assert tool_ctx.kitchen_open_state.phase is KitchenOpenPhase.INFRASTRUCTURE_READY + assert tool_ctx.kitchen_open_state.kitchen_id == kitchen_id + assert tool_ctx.kitchen_process_identity is None + stored = store.put(_record(kitchen_id=kitchen_id, compile_key=f"compile-{kitchen_id}")) + assert stored.kitchen_id == kitchen_id + + +@pytest.mark.parametrize("source", ["campaign", "resolver"]) +def test_establish_kitchen_identity_rejects_whitespace_before_activation( + tool_ctx, monkeypatch, source +): + from autoskillit.server.lifecycle import _kitchen_identity + from autoskillit.server.recipe import _recipe_generation + + activate = Mock() + monkeypatch.setattr(_recipe_generation, "activate_kitchen", activate) + monkeypatch.setattr(_kitchen_identity, "resolve_kitchen_id", lambda: " \t\n") + state = tool_ctx.kitchen_open_state + cached_identity = tool_ctx.kitchen_process_identity + + with pytest.raises(ValueError, match="non-whitespace"): + _kitchen_identity.establish_kitchen_identity( + tool_ctx, campaign_id=" \t\n" if source == "campaign" else None + ) + + activate.assert_not_called() + assert tool_ctx.kitchen_open_state is state + assert tool_ctx.kitchen_process_identity is cached_identity + + +def test_establish_kitchen_identity_is_idempotent_and_preserves_current_cache( + tool_ctx, monkeypatch +): + from autoskillit.pipeline import get_kitchen_process_identity + from autoskillit.server.lifecycle import _kitchen_identity + + establish = _kitchen_identity.establish_kitchen_identity + kitchen_id = establish(tool_ctx) + cached_identity = get_kitchen_process_identity(tool_ctx) + resolver_spy = Mock(wraps=_kitchen_identity.resolve_kitchen_id) + monkeypatch.setattr(_kitchen_identity, "resolve_kitchen_id", resolver_spy) + + assert establish(tool_ctx) == kitchen_id + resolver_spy.assert_not_called() + assert tool_ctx.kitchen_process_identity is cached_identity diff --git a/tests/server/test_kitchen_lifecycle.py b/tests/server/test_kitchen_lifecycle.py index f32f5b087c..45f5b34288 100644 --- a/tests/server/test_kitchen_lifecycle.py +++ b/tests/server/test_kitchen_lifecycle.py @@ -7,7 +7,6 @@ import psutil import pytest -import autoskillit.core as core from autoskillit.config import AutomationConfig from autoskillit.hooks import _HOOK_CONFIG_PATH_COMPONENTS from autoskillit.server import _tracker_authority @@ -21,6 +20,7 @@ from autoskillit.server.tools.tools_kitchen._open_kitchen._tracker_auto_init import ( prune_stale_kitchen_state, ) +from tests.conftest import bind_test_kitchen_identity from tests.fakes import FakePluginArtifactAuthority from tests.server._helpers import _write_registry @@ -37,6 +37,7 @@ async def test_kitchen_open_close_lifecycle(monkeypatch, tmp_path): plugin_authority=FakePluginArtifactAuthority(tmp_path), project_dir=tmp_path, ) + bind_test_kitchen_identity(ctx, "test-kitchen") monkeypatch.setattr(_state, "_ctx", ctx) monkeypatch.setattr(_state, "_startup_ready", None) @@ -83,6 +84,7 @@ async def test_kitchen_lifecycle_logs_registry_write_refusals(monkeypatch, tmp_p plugin_authority=FakePluginArtifactAuthority(tmp_path), project_dir=tmp_path, ) + bind_test_kitchen_identity(ctx, "test-kitchen") monkeypatch.setattr(_state, "_ctx", ctx) monkeypatch.setattr(_state, "_startup_ready", None) lifecycle_logger = MagicMock() @@ -123,6 +125,7 @@ async def test_open_kitchen_runs_reaper(monkeypatch, tmp_path): plugin_authority=FakePluginArtifactAuthority(tmp_path), project_dir=tmp_path, ) + bind_test_kitchen_identity(ctx, "test-kitchen") monkeypatch.setattr(_state, "_ctx", ctx) monkeypatch.setattr(_state, "_startup_ready", None) @@ -137,7 +140,7 @@ async def test_open_kitchen_runs_reaper(monkeypatch, tmp_path): with ( patch.object(tools_kitchen, "_prime_quota_cache", new_callable=AsyncMock), - patch.object(core, "register_active_kitchen"), + patch.object(_tracker_authority, "register_active_kitchen"), ): result = await _open_kitchen_handler() assert result is None @@ -164,6 +167,7 @@ async def test_close_kitchen_preserves_peer_tracker_and_lease_sidecar(monkeypatc plugin_authority=FakePluginArtifactAuthority(tmp_path), project_dir=tmp_path, ) + bind_test_kitchen_identity(ctx, "test-kitchen") monkeypatch.setattr(_state, "_ctx", ctx) monkeypatch.setattr(_state, "_startup_ready", None) @@ -181,8 +185,8 @@ async def test_close_kitchen_preserves_peer_tracker_and_lease_sidecar(monkeypatc try: with ( patch.object(tools_kitchen, "_prime_quota_cache", new_callable=AsyncMock), - patch.object(core, "register_active_kitchen"), - patch.object(core, "unregister_active_kitchen"), + patch.object(_tracker_authority, "register_active_kitchen"), + patch.object(_tracker_authority, "unregister_active_kitchen"), ): await _open_kitchen_handler() current_target = TrackerAuthorityTarget.for_project( @@ -212,8 +216,21 @@ async def test_close_kitchen_preserves_peer_tracker_and_lease_sidecar(monkeypatc async def test_back_to_back_open_close_open_resets_infrastructure(monkeypatch, tmp_path): - """After close, gate_infrastructure_ready must be False so a re-open runs the handler.""" + """Close resets identity, and a real reopen establishes a new usable identity.""" + import json + + from autoskillit.pipeline import KitchenOpenPhase + from autoskillit.server import _misc + from autoskillit.server.lifecycle import _lifespan + from autoskillit.server.tools.tools_kitchen import open_kitchen + from autoskillit.server.tools.tools_kitchen._open_kitchen import _gate as open_gate + from autoskillit.server.tools.tools_recipe import load_recipe + from tests.server._helpers import _mock_fmcp_ctx + from tests.server.conftest import _READY_RECIPE_OVERRIDES + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("AUTOSKILLIT_HEADLESS", "0") + monkeypatch.setenv("AUTOSKILLIT_SESSION_TYPE", "orchestrator") ctx = make_context( AutomationConfig(), @@ -223,31 +240,60 @@ async def test_back_to_back_open_close_open_resets_infrastructure(monkeypatch, t ) monkeypatch.setattr(_state, "_ctx", ctx) monkeypatch.setattr(_state, "_startup_ready", None) + overrides = { + **_READY_RECIPE_OVERRIDES, + "source_dir": str(ctx.project_dir.resolve()), + } + fmcp_ctx = _mock_fmcp_ctx() with ( patch.object(tools_kitchen, "_prime_quota_cache", new_callable=AsyncMock), - patch.object(core, "register_active_kitchen"), - patch.object(core, "unregister_active_kitchen"), + patch.object(_misc, "_prime_quota_cache", new_callable=AsyncMock), + patch.object(tools_kitchen, "_write_hook_config"), + patch.object(tools_kitchen, "create_background_task", return_value=None), + patch.object(tools_kitchen, "discover_campaign_state_files", return_value=[]), + patch.object(tools_kitchen, "reap_stale_dispatches_async", new_callable=AsyncMock), + patch.object(tools_kitchen, "sweep_orphaned_tethers_async", new_callable=AsyncMock), + patch.object(open_gate, "sweep_stale_markers"), + patch.object(open_gate, "prune_stale_kitchen_state"), + patch.object(_lifespan, "_reap_self_excluded_codex_and_daemon_orphans"), + patch.object(_tracker_authority, "register_active_kitchen", return_value=True), + patch.object(_tracker_authority, "unregister_active_kitchen", return_value=True), ): - result1 = await _open_kitchen_handler() - assert result1 is None + result1 = json.loads( + await open_kitchen(name="research", overrides=overrides, ctx=fmcp_ctx) + ) + assert result1["success"] is True, result1 + assert ctx.kitchen_open_state.phase is KitchenOpenPhase.COMMITTED assert ctx.gate_infrastructure_ready is True - first_task = ctx.quota_refresh_task + first_id = ctx.kitchen_id + first_identity = ctx.kitchen_process_identity + assert first_identity is not None + assert first_identity.kitchen_id == first_id _close_kitchen_handler() assert ctx.gate_infrastructure_ready is False - await asyncio.sleep(0) - assert first_task.cancelled() or first_task.done() + assert ctx.kitchen_open_state.phase is KitchenOpenPhase.CLOSED + assert ctx.kitchen_id == "" + assert ctx.kitchen_process_identity is None - result2 = await _open_kitchen_handler() - assert result2 is None + result2 = json.loads( + await open_kitchen(name="research", overrides=overrides, ctx=fmcp_ctx) + ) + assert result2["success"] is True, result2 + assert ctx.kitchen_open_state.phase is KitchenOpenPhase.COMMITTED assert ctx.gate_infrastructure_ready is True - second_task = ctx.quota_refresh_task - assert second_task is not first_task + second_id = ctx.kitchen_id + assert second_id != first_id + second_identity = ctx.kitchen_process_identity + assert second_identity is not None + assert second_identity.kitchen_id == second_id + + loaded = json.loads(await load_recipe(name="research", overrides=overrides)) + assert loaded["success"] is True, loaded _close_kitchen_handler() await asyncio.sleep(0) - assert second_task.cancelled() or second_task.done() def _write_tracker(tracker_dir, kitchen_id, *, initialized_at=None): @@ -450,13 +496,14 @@ async def test_open_kitchen_sweeps_stale_kitchen_state_markers(monkeypatch, tmp_ plugin_authority=FakePluginArtifactAuthority(tmp_path), project_dir=tmp_path, ) + bind_test_kitchen_identity(ctx, "test-kitchen") monkeypatch.setattr(_state, "_ctx", ctx) monkeypatch.setattr(_state, "_startup_ready", None) with ( patch.object(tools_kitchen, "_prime_quota_cache", new_callable=AsyncMock), - patch.object(core, "register_active_kitchen"), - patch.object(core, "unregister_active_kitchen"), + patch.object(_tracker_authority, "register_active_kitchen"), + patch.object(_tracker_authority, "unregister_active_kitchen"), ): result = await _open_kitchen_handler() assert result is None @@ -504,13 +551,14 @@ async def test_close_kitchen_retains_stable_lock_and_restores_config_baseline( plugin_authority=FakePluginArtifactAuthority(tmp_path), project_dir=tmp_path, ) + bind_test_kitchen_identity(ctx, "test-kitchen") monkeypatch.setattr(_state, "_ctx", ctx) monkeypatch.setattr(_state, "_startup_ready", None) with ( patch.object(tools_kitchen, "_prime_quota_cache", new_callable=AsyncMock), - patch.object(core, "register_active_kitchen"), - patch.object(core, "unregister_active_kitchen"), + patch.object(_tracker_authority, "register_active_kitchen"), + patch.object(_tracker_authority, "unregister_active_kitchen"), ): await _open_kitchen_handler() diff --git a/tests/server/test_load_recipe_authority.py b/tests/server/test_load_recipe_authority.py index bf87c82826..339f27b455 100644 --- a/tests/server/test_load_recipe_authority.py +++ b/tests/server/test_load_recipe_authority.py @@ -15,6 +15,7 @@ from autoskillit.core import SkillResolver from autoskillit.recipe.schema import RecipeIngredient from autoskillit.server.tools.tools_recipe import load_recipe +from tests.server.conftest import _set_mock_kitchen_transition pytestmark = [pytest.mark.layer("server"), pytest.mark.small] @@ -81,7 +82,7 @@ async def test_load_recipe_rejects_authority_override(self, tmp_path, monkeypatc mock_ctx.enable_components = AsyncMock() mock_ctx.recipes = MagicMock() mock_ctx.config.migration.suppressed = [] - mock_ctx.kitchen_id = "test-kitchen" + _set_mock_kitchen_transition(mock_ctx, kitchen_id="test-kitchen") mock_ctx.config.linux_tracing.log_dir = "" from autoskillit.server.tools import tools_recipe @@ -132,7 +133,7 @@ async def test_load_recipe_rejects_invalid_type_override(self, tmp_path, monkeyp mock_ctx.enable_components = AsyncMock() mock_ctx.recipes = MagicMock() mock_ctx.config.migration.suppressed = [] - mock_ctx.kitchen_id = "test-kitchen-type" + _set_mock_kitchen_transition(mock_ctx, kitchen_id="test-kitchen-type") mock_ctx.config.linux_tracing.log_dir = "" typed_recipe = SimpleNamespace( ingredients={"count": RecipeIngredient(description="Count", type="integer")}, @@ -185,7 +186,7 @@ async def test_load_recipe_fails_closed_when_recipe_load_returns_none( mock_ctx.enable_components = AsyncMock() mock_ctx.recipes = MagicMock() mock_ctx.config.migration.suppressed = [] - mock_ctx.kitchen_id = "test-kitchen-fail-closed" + _set_mock_kitchen_transition(mock_ctx, kitchen_id="test-kitchen-fail-closed") mock_ctx.config.linux_tracing.log_dir = "" mock_ctx.recipes.load.return_value = None mock_recipe_info = MagicMock() diff --git a/tests/server/test_lock_ingredients.py b/tests/server/test_lock_ingredients.py index ffe3d57607..f40e4c1665 100644 --- a/tests/server/test_lock_ingredients.py +++ b/tests/server/test_lock_ingredients.py @@ -10,8 +10,8 @@ import autoskillit.server as server import autoskillit.server.tools.tools_kitchen as tools_kitchen from autoskillit.core import FinalizedRecipeStep, RecipeFlowEdge -from tests.server._helpers import _make_finalized_projection -from tests.server.conftest import _make_mock_ctx +from tests.server._helpers import _make_finalized_projection, patched_kitchen_id +from tests.server.conftest import _make_mock_ctx, _set_mock_kitchen_transition pytestmark = [pytest.mark.layer("server"), pytest.mark.small] @@ -460,7 +460,7 @@ async def test_every_authority_key_emits_feedback_on_both_surfaces( mock_ctx.enable_components = AsyncMock() mock_ctx.recipes = MagicMock() mock_ctx.config.migration.suppressed = [] - mock_ctx.kitchen_id = "test-kitchen" + _set_mock_kitchen_transition(mock_ctx, kitchen_id="test-kitchen") mock_ctx.config.linux_tracing.log_dir = "" with patch.object(server, "_get_ctx", return_value=mock_ctx): @@ -471,11 +471,7 @@ async def test_every_authority_key_emits_feedback_on_both_surfaces( new=AsyncMock(), ): with patch.object(tools_kitchen, "_write_hook_config"): - with patch.object( - tools_kitchen, - "resolve_kitchen_id", - return_value="test-kitchen", - ): + with patched_kitchen_id("test-kitchen"): with patch.object( tools_kitchen, "resolve_ingredient_defaults", diff --git a/tests/server/test_mcp_overrides.py b/tests/server/test_mcp_overrides.py index d3f7fa162f..5a18acc7c7 100644 --- a/tests/server/test_mcp_overrides.py +++ b/tests/server/test_mcp_overrides.py @@ -47,7 +47,6 @@ def _make_mock_ctx(recipes: MagicMock, temp_dir: Path) -> MagicMock: mock_ctx.skill_resolver.resolve_effective.return_value = None mock_ctx.project_dir = temp_dir mock_ctx.temp_dir = temp_dir - mock_ctx.kitchen_id = "test-mcp-overrides" mock_ctx.config.migration.suppressed = [] mock_ctx.config.output_budget = OutputBudgetConfig() # linux_tracing.log_dir must be a real str/Path value: resolve_log_dir @@ -60,7 +59,7 @@ def _make_mock_ctx(recipes: MagicMock, temp_dir: Path) -> MagicMock: mock_ctx.recipe_execution_lock = RLock() mock_ctx.recipe_initialization_state = NoActiveRecipe() mock_ctx.recipe_execution_factory = make_recipe_execution - _set_mock_kitchen_transition(mock_ctx) + _set_mock_kitchen_transition(mock_ctx, kitchen_id="test-mcp-overrides") mock_ctx.audit_admission_ledger.create_or_get_installation.return_value = InstallationVersion( "test-installation" ) diff --git a/tests/server/test_open_kitchen_authority_enforcement.py b/tests/server/test_open_kitchen_authority_enforcement.py index d6d79f83d4..1de6ec3741 100644 --- a/tests/server/test_open_kitchen_authority_enforcement.py +++ b/tests/server/test_open_kitchen_authority_enforcement.py @@ -15,7 +15,8 @@ import autoskillit.server.tools.tools_kitchen as _patch_tools_tools_kitchen from autoskillit.config.ingredient_defaults import SERVER_AUTHORITATIVE_INGREDIENTS -from tests.server.conftest import _make_mock_ctx +from tests.server._helpers import patched_kitchen_id +from tests.server.conftest import _make_mock_ctx, _set_mock_kitchen_transition pytestmark = [pytest.mark.layer("server"), pytest.mark.small] @@ -26,7 +27,7 @@ def _patched_env(mock_ctx: MagicMock) -> None: mock_ctx.enable_components = AsyncMock() mock_ctx.recipes = MagicMock() mock_ctx.config.migration.suppressed = [] - mock_ctx.kitchen_id = "test-kitchen-auth" + _set_mock_kitchen_transition(mock_ctx, kitchen_id="test-kitchen-auth") mock_ctx.config.linux_tracing.log_dir = "" @@ -48,11 +49,7 @@ async def test_open_kitchen_rejects_each_server_authoritative_ingredient( new=AsyncMock(), ): with patch.object(_patch_tools_tools_kitchen, "_write_hook_config"): - with patch.object( - _patch_tools_tools_kitchen, - "resolve_kitchen_id", - return_value="test-kitchen-auth", - ): + with patched_kitchen_id("test-kitchen-auth"): with patch.object( _patch_tools_tools_kitchen, "resolve_ingredient_defaults", @@ -88,11 +85,7 @@ async def test_open_kitchen_rejects_base_branch_override(tmp_path, monkeypatch): new=AsyncMock(), ): with patch.object(_patch_tools_tools_kitchen, "_write_hook_config"): - with patch.object( - _patch_tools_tools_kitchen, - "resolve_kitchen_id", - return_value="test-kitchen-auth", - ): + with patched_kitchen_id("test-kitchen-auth"): with patch.object( _patch_tools_tools_kitchen, "resolve_ingredient_defaults", @@ -142,11 +135,7 @@ async def test_open_kitchen_still_accepts_non_authoritative_overrides(tmp_path, new=AsyncMock(), ): with patch.object(_patch_tools_tools_kitchen, "_write_hook_config"): - with patch.object( - _patch_tools_tools_kitchen, - "resolve_kitchen_id", - return_value="test-kitchen-auth", - ): + with patched_kitchen_id("test-kitchen-auth"): with patch.object( _patch_tools_tools_kitchen, "resolve_ingredient_defaults", @@ -199,11 +188,7 @@ async def test_open_kitchen_authority_supersedes_type_validation(tmp_path, monke new=AsyncMock(), ): with patch.object(_patch_tools_tools_kitchen, "_write_hook_config"): - with patch.object( - _patch_tools_tools_kitchen, - "resolve_kitchen_id", - return_value="test-kitchen-auth", - ): + with patched_kitchen_id("test-kitchen-auth"): with patch.object( _patch_tools_tools_kitchen, "resolve_ingredient_defaults", @@ -246,11 +231,7 @@ async def test_open_kitchen_no_snapshot_persisted_on_rejection(tmp_path, monkeyp new=AsyncMock(), ): with patch.object(_patch_tools_tools_kitchen, "_write_hook_config"): - with patch.object( - _patch_tools_tools_kitchen, - "resolve_kitchen_id", - return_value="test-kitchen-auth", - ): + with patched_kitchen_id("test-kitchen-auth"): with patch.object( _patch_tools_tools_kitchen, "resolve_ingredient_defaults", @@ -294,11 +275,7 @@ async def test_open_kitchen_ingredients_only_rejects_authority_override(tmp_path new=AsyncMock(), ): with patch.object(_patch_tools_tools_kitchen, "_write_hook_config"): - with patch.object( - _patch_tools_tools_kitchen, - "resolve_kitchen_id", - return_value="test-kitchen-auth", - ): + with patched_kitchen_id("test-kitchen-auth"): with patch.object( _patch_tools_tools_kitchen, "resolve_ingredient_defaults", diff --git a/tests/server/test_open_kitchen_auto_init_tracker.py b/tests/server/test_open_kitchen_auto_init_tracker.py index 483b474bdd..008052ded1 100644 --- a/tests/server/test_open_kitchen_auto_init_tracker.py +++ b/tests/server/test_open_kitchen_auto_init_tracker.py @@ -97,7 +97,7 @@ def test_auto_init_releases_authority_when_initialization_raises(self, monkeypat ctx = _make_mock_ctx() ctx.project_dir = tmp_path - ctx.kitchen_id = "kitchen-error" + _set_mock_kitchen_transition(ctx, kitchen_id="kitchen-error") _install_active_recipe_projection( ctx, { @@ -126,7 +126,7 @@ def test_auto_init_preserves_corrupt_authority(self, tmp_path): ctx = _make_mock_ctx() ctx.project_dir = tmp_path - ctx.kitchen_id = "kitchen-corrupt" + _set_mock_kitchen_transition(ctx, kitchen_id="kitchen-corrupt") _install_active_recipe_projection( ctx, { @@ -168,7 +168,7 @@ async def test_open_kitchen_auto_inits_tracker(self, tmp_path, track_and_release ctx.gate.enabled = True ctx.gate_infrastructure_ready = True ctx.recipe_name = "" - ctx.kitchen_id = "kitchen-abc" + _set_mock_kitchen_transition(ctx, kitchen_id="kitchen-abc") _set_mock_kitchen_transition(ctx, kitchen_id=ctx.kitchen_id) _configure_open_kitchen_mock(ctx, steps, tmp_path) track_and_release(ctx) @@ -205,7 +205,7 @@ async def test_open_kitchen_auto_init_idempotent(self, tmp_path, track_and_relea ctx1.gate.enabled = True ctx1.gate_infrastructure_ready = True ctx1.recipe_name = "" - ctx1.kitchen_id = "kitchen-abc" + _set_mock_kitchen_transition(ctx1, kitchen_id="kitchen-abc") _set_mock_kitchen_transition(ctx1, kitchen_id=ctx1.kitchen_id) _configure_open_kitchen_mock(ctx1, steps, tmp_path) track_and_release(ctx1) @@ -223,7 +223,7 @@ async def test_open_kitchen_auto_init_idempotent(self, tmp_path, track_and_relea ctx2.gate.enabled = True ctx2.gate_infrastructure_ready = True ctx2.recipe_name = "remediation" - ctx2.kitchen_id = "kitchen-abc" + _set_mock_kitchen_transition(ctx2, kitchen_id="kitchen-abc") _set_mock_kitchen_transition(ctx2, kitchen_id=ctx2.kitchen_id) _configure_open_kitchen_mock(ctx2, steps, tmp_path) track_and_release(ctx2) @@ -257,7 +257,7 @@ async def test_open_kitchen_clears_stale_dependencies_for_existing_tracker( first_ctx = _make_mock_ctx() first_ctx.gate.enabled = True first_ctx.gate_infrastructure_ready = True - first_ctx.kitchen_id = kitchen_id + _set_mock_kitchen_transition(first_ctx, kitchen_id=kitchen_id) _set_mock_kitchen_transition(first_ctx, kitchen_id=kitchen_id) _configure_open_kitchen_mock(first_ctx, initial_steps, tmp_path) track_and_release(first_ctx) @@ -275,7 +275,7 @@ async def test_open_kitchen_clears_stale_dependencies_for_existing_tracker( second_ctx.gate.enabled = True second_ctx.gate_infrastructure_ready = True second_ctx.recipe_name = "remediation" - second_ctx.kitchen_id = kitchen_id + _set_mock_kitchen_transition(second_ctx, kitchen_id=kitchen_id) _set_mock_kitchen_transition(second_ctx, kitchen_id=kitchen_id) _configure_open_kitchen_mock(second_ctx, no_dependency_steps, tmp_path) track_and_release(second_ctx) @@ -309,7 +309,7 @@ async def test_open_kitchen_auto_init_multi_pipeline(self, tmp_path, track_and_r ctx.gate.enabled = True ctx.gate_infrastructure_ready = True ctx.recipe_name = "" - ctx.kitchen_id = "kitchen-multi" + _set_mock_kitchen_transition(ctx, kitchen_id="kitchen-multi") _set_mock_kitchen_transition(ctx, kitchen_id=ctx.kitchen_id) _configure_open_kitchen_mock(ctx, steps, tmp_path) track_and_release(ctx) diff --git a/tests/server/test_open_kitchen_deferred_recall.py b/tests/server/test_open_kitchen_deferred_recall.py index 11b649be09..368b303247 100644 --- a/tests/server/test_open_kitchen_deferred_recall.py +++ b/tests/server/test_open_kitchen_deferred_recall.py @@ -12,12 +12,13 @@ from autoskillit.recipe._binding import bind_recipe from autoskillit.recipe.schema import Recipe, RecipeStep from autoskillit.server.recipe._recipe_execution import get_recipe_execution +from tests.conftest import bind_test_kitchen_identity from tests.server._helpers import ( _configure_admitted_recipe, _make_finalized_projection, _with_finalized_projection, ) -from tests.server.conftest import _make_mock_ctx +from tests.server.conftest import _make_mock_ctx, _set_mock_kitchen_transition pytestmark = [pytest.mark.layer("server"), pytest.mark.small] @@ -26,7 +27,7 @@ def _make_deferred_recall_ctx(name: str) -> MagicMock: ctx = _make_mock_ctx() ctx.gate.enabled = True ctx.recipe_name = name - ctx.kitchen_id = "test-kitchen" + _set_mock_kitchen_transition(ctx, kitchen_id="test-kitchen") ctx.gate_infrastructure_ready = True return ctx @@ -100,7 +101,7 @@ async def test_recipe_open_atomically_installs_compiled_execution( tool_ctx.gate.enable() tool_ctx.gate_infrastructure_ready = True - tool_ctx.kitchen_id = f"test-open-{deferred_recall}" + bind_test_kitchen_identity(tool_ctx, kitchen_id=f"test-open-{deferred_recall}") tool_ctx.recipe_name = "test-recipe" if deferred_recall else "" recipes = MagicMock() tool_ctx.recipes = recipes @@ -341,7 +342,7 @@ def _make_pre_revealed_ctx(name: str) -> MagicMock: ctx = _make_mock_ctx() ctx.gate.enabled = True ctx.recipe_name = "" - ctx.kitchen_id = "test-kitchen" + _set_mock_kitchen_transition(ctx, kitchen_id="test-kitchen") ctx.gate_infrastructure_ready = True ctx.recipes.load_and_validate.return_value = { "content": "name: test-recipe\nsteps:\n build:\n cmd: task build\n", @@ -451,7 +452,7 @@ async def test_double_open_kitchen_no_name_does_not_re_execute_handler(): mock_ctx = _make_mock_ctx() mock_ctx.gate.enabled = True mock_ctx.gate_infrastructure_ready = True - mock_ctx.kitchen_id = "test-kitchen" + _set_mock_kitchen_transition(mock_ctx, kitchen_id="test-kitchen") with ( patch("autoskillit.server._get_ctx", return_value=mock_ctx), diff --git a/tests/server/test_open_kitchen_type_enforcement.py b/tests/server/test_open_kitchen_type_enforcement.py index 9df0a33d95..576556e539 100644 --- a/tests/server/test_open_kitchen_type_enforcement.py +++ b/tests/server/test_open_kitchen_type_enforcement.py @@ -15,7 +15,8 @@ import autoskillit.server.tools.tools_kitchen as _patch_tools_tools_kitchen from autoskillit.recipe.schema import RecipeIngredient -from tests.server.conftest import _make_mock_ctx +from tests.server._helpers import patched_kitchen_id +from tests.server.conftest import _make_mock_ctx, _set_mock_kitchen_transition pytestmark = [pytest.mark.layer("server"), pytest.mark.small] @@ -34,7 +35,7 @@ def _patched_env(mock_ctx: MagicMock) -> None: mock_ctx.enable_components = AsyncMock() mock_ctx.recipes = MagicMock() mock_ctx.config.migration.suppressed = [] - mock_ctx.kitchen_id = "test-kitchen-type" + _set_mock_kitchen_transition(mock_ctx, kitchen_id="test-kitchen-type") mock_ctx.config.linux_tracing.log_dir = "" @@ -79,11 +80,7 @@ async def _call_open_kitchen_with_recipe( new=AsyncMock(), ): with patch.object(_patch_tools_tools_kitchen, "_write_hook_config"): - with patch.object( - _patch_tools_tools_kitchen, - "resolve_kitchen_id", - return_value="test-kitchen-type", - ): + with patched_kitchen_id("test-kitchen-type"): with patch.object( _patch_tools_tools_kitchen, "resolve_ingredient_defaults", @@ -298,11 +295,7 @@ async def test_open_kitchen_ingredients_only_rejects_invalid_type(tmp_path, monk new=AsyncMock(), ): with patch.object(_patch_tools_tools_kitchen, "_write_hook_config"): - with patch.object( - _patch_tools_tools_kitchen, - "resolve_kitchen_id", - return_value="test-kitchen-type", - ): + with patched_kitchen_id("test-kitchen-type"): with patch.object( _patch_tools_tools_kitchen, "resolve_ingredient_defaults", @@ -349,11 +342,7 @@ async def test_open_kitchen_fails_closed_when_recipe_load_returns_none(tmp_path, new=AsyncMock(), ): with patch.object(_patch_tools_tools_kitchen, "_write_hook_config"): - with patch.object( - _patch_tools_tools_kitchen, - "resolve_kitchen_id", - return_value="test-kitchen-fail-closed", - ): + with patched_kitchen_id("test-kitchen-fail-closed"): with patch.object( _patch_tools_tools_kitchen, "resolve_ingredient_defaults", diff --git a/tests/server/test_pipeline_tracker_authority.py b/tests/server/test_pipeline_tracker_authority.py index be5aad6332..1edef97a69 100644 --- a/tests/server/test_pipeline_tracker_authority.py +++ b/tests/server/test_pipeline_tracker_authority.py @@ -6,6 +6,8 @@ import pytest +from tests.conftest import bind_test_kitchen_identity + pytestmark = [pytest.mark.layer("server"), pytest.mark.small] @@ -17,7 +19,7 @@ async def test_kitchen_target_ignores_multiple_ambient_pipelines( from autoskillit.server.tools.tools_execution import _check_pipeline_deps tool_ctx_kitchen_open.project_dir = tmp_path - tool_ctx_kitchen_open.kitchen_id = "kitchen-xyz" + bind_test_kitchen_identity(tool_ctx_kitchen_open, kitchen_id="kitchen-xyz") tracker_dir = tmp_path / ".autoskillit" / "temp" / "pipeline_tracker" tracker_dir.mkdir(parents=True) @@ -60,7 +62,7 @@ def test_read_failure_releases_retained_lease( from autoskillit.server import _tracker_authority tool_ctx_kitchen_open.project_dir = tmp_path - tool_ctx_kitchen_open.kitchen_id = "kitchen-xyz" + bind_test_kitchen_identity(tool_ctx_kitchen_open, kitchen_id="kitchen-xyz") retained = {} def fail_read(_target, lease): @@ -90,7 +92,7 @@ def test_scoped_selection_cannot_release_kitchen_lifetime_lease( ) tool_ctx_kitchen_open.project_dir = tmp_path - tool_ctx_kitchen_open.kitchen_id = "kitchen-xyz" + bind_test_kitchen_identity(tool_ctx_kitchen_open, kitchen_id="kitchen-xyz") kitchen_key, kitchen_lease = _retain_kitchen_tracker_authority(tool_ctx_kitchen_open) _target, _authority, scoped_key, scoped_lease = _select_tracker_authority( @@ -143,7 +145,7 @@ def test_same_participant_keeps_existing_lease(self, tool_ctx_kitchen_open, tmp_ ) tool_ctx_kitchen_open.project_dir = tmp_path - tool_ctx_kitchen_open.kitchen_id = "kitchen-xyz" + bind_test_kitchen_identity(tool_ctx_kitchen_open, kitchen_id="kitchen-xyz") target = TrackerAuthorityTarget.for_project(tmp_path, "AB", expected=True) key, lease = _retain_context_tracker( tool_ctx_kitchen_open, @@ -180,7 +182,7 @@ def test_replacement_read_failure_preserves_current_lease( from autoskillit.server import _tracker_authority tool_ctx_kitchen_open.project_dir = tmp_path - tool_ctx_kitchen_open.kitchen_id = "kitchen-xyz" + bind_test_kitchen_identity(tool_ctx_kitchen_open, kitchen_id="kitchen-xyz") current_target = TrackerAuthorityTarget.for_project(tmp_path, "AB", expected=True) current_key, current_lease = _tracker_authority._retain_context_tracker( tool_ctx_kitchen_open, @@ -218,7 +220,7 @@ def test_kitchen_scoped_fallback_never_scans_ambient_candidates( from autoskillit.server._tracker_authority import select_tracker_target tool_ctx_kitchen_open.project_dir = tmp_path - tool_ctx_kitchen_open.kitchen_id = "kitchen-xyz" + bind_test_kitchen_identity(tool_ctx_kitchen_open, kitchen_id="kitchen-xyz") tracker_dir = tmp_path / ".autoskillit" / "temp" / "pipeline_tracker" tracker_dir.mkdir(parents=True) diff --git a/tests/server/test_recipe_generation.py b/tests/server/test_recipe_generation.py index 8f5074c886..fe246fc823 100644 --- a/tests/server/test_recipe_generation.py +++ b/tests/server/test_recipe_generation.py @@ -351,6 +351,35 @@ def test_retirement_callback_removes_kitchen_and_permanently_rejects_writes( store.put(record) +@pytest.mark.parametrize("write", ("put", "bind_surface")) +def test_authoritative_store_rejects_never_activated_kitchen( + write: str, +) -> None: + from autoskillit.server.recipe._recipe_generation import ( + RecipeGenerationKitchenNotActiveError, + ) + + store = RecipeGenerationStore() + active = _record(kitchen_id="A") + store.activate_kitchen(active.kitchen_id) + store.put(active) + + with pytest.raises(RecipeGenerationKitchenNotActiveError, match="B") as error: + if write == "put": + store.put(_record(kitchen_id="B")) + else: + store.bind_surface( + "B", + active.normalized_compile_key, + "open_kitchen", + _generation("never-active"), + ) + + message = str(error.value) + assert "not active" in message + assert "retired" not in message + + def test_lifecycle_authority_bounds_retirement_history_without_stale_reactivation() -> None: store = RecipeGenerationStore(max_retired_kitchens=1) first = _record(kitchen_id="first-kitchen", compile_key="first") @@ -359,13 +388,20 @@ def test_lifecycle_authority_bounds_retirement_history_without_stale_reactivatio store.activate_kitchen(first.kitchen_id) store.put(first) store.retire_kitchen(first.kitchen_id) + with pytest.raises(RecipeGenerationRetiredError): + store.put(first) store.activate_kitchen(second.kitchen_id) store.put(second) store.retire_kitchen(second.kitchen_id) assert store.retired_kitchen_count == 1 - with pytest.raises(RecipeGenerationRetiredError): + from autoskillit.server.recipe._recipe_generation import ( + RecipeGenerationKitchenNotActiveError, + ) + + with pytest.raises(RecipeGenerationKitchenNotActiveError, match="not active") as error: store.put(first) + assert "retired" not in str(error.value) store.activate_kitchen(first.kitchen_id) assert store.put(first).kitchen_id == first.kitchen_id diff --git a/tests/server/test_recipe_initialization.py b/tests/server/test_recipe_initialization.py index 902e8b016a..38a3d4a93c 100644 --- a/tests/server/test_recipe_initialization.py +++ b/tests/server/test_recipe_initialization.py @@ -47,6 +47,7 @@ replay_terminal_section_response, stage_recipe_initialization, ) +from tests.conftest import bind_test_kitchen_identity pytestmark = [pytest.mark.layer("server"), pytest.mark.small] @@ -56,7 +57,7 @@ def _hash(seed: str) -> str: def _stage(tool_ctx, tmp_path) -> InitializingRecipe: - tool_ctx.kitchen_id = "kitchen" + bind_test_kitchen_identity(tool_ctx, kitchen_id="kitchen") snapshot = build_recipe_execution_snapshot( recipe_name="recipe", content_hash=_hash("content"), diff --git a/tests/server/test_record_pipeline_step.py b/tests/server/test_record_pipeline_step.py index 72fc7c7bd7..c75c558a08 100644 --- a/tests/server/test_record_pipeline_step.py +++ b/tests/server/test_record_pipeline_step.py @@ -17,7 +17,6 @@ class TestRecordPipelineStepInit: def _setup(self, tool_ctx_kitchen_open, monkeypatch, tmp_path): monkeypatch.chdir(tmp_path) tool_ctx_kitchen_open.project_dir = tmp_path - tool_ctx_kitchen_open.kitchen_id = "test-kitchen" tool_ctx_kitchen_open.active_recipe_steps = { "review": {}, "implement": {}, @@ -277,7 +276,6 @@ class TestRecordPipelineStepStatus: def _setup(self, tool_ctx_kitchen_open, monkeypatch, tmp_path): monkeypatch.chdir(tmp_path) tool_ctx_kitchen_open.project_dir = tmp_path - tool_ctx_kitchen_open.kitchen_id = "test-kitchen" self.ctx = tool_ctx_kitchen_open self.tmp_path = tmp_path diff --git a/tests/server/test_record_pipeline_step_complete.py b/tests/server/test_record_pipeline_step_complete.py index 17a5d0738c..91d014343b 100644 --- a/tests/server/test_record_pipeline_step_complete.py +++ b/tests/server/test_record_pipeline_step_complete.py @@ -7,6 +7,7 @@ import pytest from autoskillit.server.tools.tools_pipeline_tracker import record_pipeline_step +from tests.conftest import bind_test_kitchen_identity from tests.server._pipeline_test_helpers import _grant_success_credit, _write_tracker pytestmark = [pytest.mark.layer("server"), pytest.mark.small] @@ -16,7 +17,6 @@ class TestRecordPipelineStepComplete: @pytest.mark.anyio async def test_marks_step_complete(self, tool_ctx_kitchen_open, tmp_path): tool_ctx_kitchen_open.project_dir = tmp_path - tool_ctx_kitchen_open.kitchen_id = "test-kitchen" _write_tracker(tmp_path, "test-kitchen", {"rectify": {"status": "pending"}}, {}) _grant_success_credit(tool_ctx_kitchen_open, tmp_path, "rectify") result = json.loads( @@ -40,7 +40,6 @@ async def test_marks_step_complete(self, tool_ctx_kitchen_open, tmp_path): @pytest.mark.anyio async def test_canonicalizes_suffix(self, tool_ctx_kitchen_open, tmp_path): tool_ctx_kitchen_open.project_dir = tmp_path - tool_ctx_kitchen_open.kitchen_id = "test-kitchen" _write_tracker(tmp_path, "test-kitchen", {"rectify": {"status": "pending"}}, {}) _grant_success_credit(tool_ctx_kitchen_open, tmp_path, "rectify-2") result = json.loads( @@ -54,7 +53,6 @@ async def test_canonicalizes_suffix(self, tool_ctx_kitchen_open, tmp_path): @pytest.mark.anyio async def test_errors_on_unknown_step(self, tool_ctx_kitchen_open, tmp_path): tool_ctx_kitchen_open.project_dir = tmp_path - tool_ctx_kitchen_open.kitchen_id = "test-kitchen" _write_tracker(tmp_path, "test-kitchen", {"rectify": {"status": "pending"}}, {}) _grant_success_credit(tool_ctx_kitchen_open, tmp_path, "nonexistent") result = json.loads( @@ -70,7 +68,6 @@ async def test_denies_completion_without_acknowledged_success( self, tool_ctx_kitchen_open, tmp_path ): tool_ctx_kitchen_open.project_dir = tmp_path - tool_ctx_kitchen_open.kitchen_id = "test-kitchen" _write_tracker(tmp_path, "test-kitchen", {"rectify": {"status": "pending"}}, {}) result = json.loads( @@ -85,7 +82,6 @@ async def test_denies_completion_without_acknowledged_success( @pytest.mark.anyio async def test_errors_on_missing_step_name(self, tool_ctx_kitchen_open, tmp_path): tool_ctx_kitchen_open.project_dir = tmp_path - tool_ctx_kitchen_open.kitchen_id = "test-kitchen" _write_tracker(tmp_path, "test-kitchen", {"rectify": {"status": "pending"}}, {}) result = json.loads( await record_pipeline_step(pipeline_id="test-kitchen", op="complete", step_name="") @@ -99,7 +95,7 @@ async def test_errors_on_unresolvable_pipeline( ): monkeypatch.delenv("AUTOSKILLIT_DISPATCH_ID", raising=False) tool_ctx_kitchen_open.project_dir = tmp_path - tool_ctx_kitchen_open.kitchen_id = "" + bind_test_kitchen_identity(tool_ctx_kitchen_open, kitchen_id="") result = json.loads( await record_pipeline_step(pipeline_id="", op="complete", step_name="rectify") ) @@ -109,7 +105,6 @@ async def test_errors_on_unresolvable_pipeline( @pytest.mark.anyio async def test_malformed_tracker_is_not_retriable(self, tool_ctx_kitchen_open, tmp_path): tool_ctx_kitchen_open.project_dir = tmp_path - tool_ctx_kitchen_open.kitchen_id = "test-kitchen" _write_tracker(tmp_path, "test-kitchen", {"rectify": {"status": "pending"}}, {}) tracker_path = ( tmp_path / ".autoskillit" / "temp" / "pipeline_tracker" / "test-kitchen.json" diff --git a/tests/server/test_run_skill_pipeline_deps.py b/tests/server/test_run_skill_pipeline_deps.py index ad7cc753db..782b820285 100644 --- a/tests/server/test_run_skill_pipeline_deps.py +++ b/tests/server/test_run_skill_pipeline_deps.py @@ -8,6 +8,7 @@ import autoskillit.server.tools.tools_execution as _patch_tools_tools_execution from autoskillit.server.tools.tools_execution import run_skill +from tests.conftest import bind_test_kitchen_identity from tests.server._pipeline_test_helpers import _setup_project as _shared_setup_project from tests.server._pipeline_test_helpers import _write_tracker @@ -174,7 +175,7 @@ class TestPipelineDepsKitchenScopedFallback: @pytest.mark.anyio async def test_run_skill_denies_out_of_order_step(self, tool_ctx_kitchen_open, tmp_path): _setup_project(tmp_path, tool_ctx_kitchen_open) - tool_ctx_kitchen_open.kitchen_id = "kitchen-1" + bind_test_kitchen_identity(tool_ctx_kitchen_open, kitchen_id="kitchen-1") _write_tracker( tmp_path, "kitchen-1", @@ -194,7 +195,7 @@ async def test_run_skill_denies_out_of_order_step(self, tool_ctx_kitchen_open, t @pytest.mark.anyio async def test_run_skill_allows_in_order_step(self, tool_ctx_kitchen_open, tmp_path): _setup_project(tmp_path, tool_ctx_kitchen_open) - tool_ctx_kitchen_open.kitchen_id = "kitchen-1" + bind_test_kitchen_identity(tool_ctx_kitchen_open, kitchen_id="kitchen-1") _write_tracker( tmp_path, "kitchen-1", @@ -224,7 +225,7 @@ async def test_check_pipeline_deps_falls_back_to_kitchen_id( from autoskillit.server.tools.tools_execution import _check_pipeline_deps tool_ctx_kitchen_open.project_dir = tmp_path - tool_ctx_kitchen_open.kitchen_id = "kitchen-2" + bind_test_kitchen_identity(tool_ctx_kitchen_open, kitchen_id="kitchen-2") _write_tracker( tmp_path, "kitchen-2", @@ -288,7 +289,7 @@ async def test_run_skill_denies_empty_step_name_with_active_deps( from types import SimpleNamespace _setup_project(tmp_path, tool_ctx_kitchen_open) - tool_ctx_kitchen_open.kitchen_id = "kitchen-3" + bind_test_kitchen_identity(tool_ctx_kitchen_open, kitchen_id="kitchen-3") tool_ctx_kitchen_open.active_recipe_steps = { "some_other_step": SimpleNamespace(with_args={"skill_command": "/other-skill"}), } @@ -315,7 +316,7 @@ async def test_run_skill_denies_ambiguous_step_name_with_active_deps( from types import SimpleNamespace _setup_project(tmp_path, tool_ctx_kitchen_open) - tool_ctx_kitchen_open.kitchen_id = "kitchen-4" + bind_test_kitchen_identity(tool_ctx_kitchen_open, kitchen_id="kitchen-4") tool_ctx_kitchen_open.active_recipe_steps = { "investigate_a": SimpleNamespace( with_args={"skill_command": "/autoskillit:investigate a"} diff --git a/tests/server/test_session_type_visibility_orchestrator.py b/tests/server/test_session_type_visibility_orchestrator.py index fb3f90d551..344447103f 100644 --- a/tests/server/test_session_type_visibility_orchestrator.py +++ b/tests/server/test_session_type_visibility_orchestrator.py @@ -280,7 +280,6 @@ async def test_non_notification_backend_gets_kitchen_pre_reveal(build_ctx, monke patch.object(_misc, "_prime_quota_cache", new=AsyncMock()), patch.object(_session_boots, "_retain_kitchen_tracker_authority"), patch.object(_session_boots, "register_active_kitchen"), - patch.object(_session_boots, "_activate_recipe_kitchen"), ): await _lifespan._skill_auto_gate_boot(ctx) @@ -319,7 +318,6 @@ async def test_non_notification_backend_plan_review_pre_revealed(build_ctx, monk patch.object(_misc, "_prime_quota_cache", new=AsyncMock()), patch.object(_session_boots, "_retain_kitchen_tracker_authority"), patch.object(_session_boots, "register_active_kitchen"), - patch.object(_session_boots, "_activate_recipe_kitchen"), ): await _lifespan._food_truck_auto_gate_boot(ctx) diff --git a/tests/server/test_tools_clone.py b/tests/server/test_tools_clone.py index 909051de82..1abf0db9b2 100644 --- a/tests/server/test_tools_clone.py +++ b/tests/server/test_tools_clone.py @@ -18,6 +18,7 @@ remove_clone, ) from autoskillit.workspace.clone import _registry as clone_registry +from tests.conftest import bind_test_kitchen_identity from tests.server._pipeline_test_helpers import _seed_acknowledged_receipt from tests.server._recipe_segment_test_helpers import install_prepared_recipe_segment @@ -361,7 +362,7 @@ class TestRegisterCloneStatusTool: @pytest.mark.anyio async def test_register_clone_status_success(self, tool_ctx_kitchen_open, tmp_path): """register_clone_status status='success' writes registry and returns registered=true.""" - tool_ctx_kitchen_open.kitchen_id = "kit-test" + bind_test_kitchen_identity(tool_ctx_kitchen_open, kitchen_id="kit-test") registry_path = str(tmp_path / "registry.json") result = json.loads( await register_clone_status( @@ -376,7 +377,7 @@ async def test_register_clone_status_success(self, tool_ctx_kitchen_open, tmp_pa @pytest.mark.anyio async def test_register_clone_status_error(self, tool_ctx_kitchen_open, tmp_path): """register_clone_status status='error' writes registry and returns registered=true.""" - tool_ctx_kitchen_open.kitchen_id = "kit-test" + bind_test_kitchen_identity(tool_ctx_kitchen_open, kitchen_id="kit-test") registry_path = str(tmp_path / "registry.json") result = json.loads( await register_clone_status( @@ -409,7 +410,7 @@ async def test_register_clone_status_unconfirmed_accepted( self, tool_ctx_kitchen_open, tmp_path ): """status='unconfirmed' is accepted: writes registry entry, returns registered=true.""" - tool_ctx_kitchen_open.kitchen_id = "kit-test" + bind_test_kitchen_identity(tool_ctx_kitchen_open, kitchen_id="kit-test") registry_path = str(tmp_path / "registry.json") result = json.loads( await register_clone_status( @@ -431,7 +432,7 @@ async def test_batch_cleanup_clones_deletes_success_preserves_error( self, tool_ctx_kitchen_open, tmp_path ): """batch_cleanup_clones removes success clones, skips error clones.""" - tool_ctx_kitchen_open.kitchen_id = "kit-test" + bind_test_kitchen_identity(tool_ctx_kitchen_open, kitchen_id="kit-test") registry_path = str(tmp_path / "registry.json") success_path = str(tmp_path / "success_clone") error_path = str(tmp_path / "error_clone") @@ -455,7 +456,7 @@ async def test_batch_cleanup_clones_deletes_success_preserves_error( @pytest.mark.anyio async def test_batch_cleanup_clones_empty_registry(self, tool_ctx_kitchen_open, tmp_path): """batch_cleanup_clones with missing registry returns deleted=[], preserved=[].""" - tool_ctx_kitchen_open.kitchen_id = "kit-test" + bind_test_kitchen_identity(tool_ctx_kitchen_open, kitchen_id="kit-test") registry_path = str(tmp_path / "nonexistent.json") result = json.loads(await batch_cleanup_clones(registry_path=registry_path)) assert result == {"deleted": [], "delete_failures": [], "preserved": []} @@ -465,7 +466,7 @@ async def test_batch_cleanup_clones_nonexistent_path_does_not_raise( self, tool_ctx_kitchen_open, tmp_path ): """batch_cleanup_clones reports failure gracefully when a success clone path is gone.""" - tool_ctx_kitchen_open.kitchen_id = "kit-test" + bind_test_kitchen_identity(tool_ctx_kitchen_open, kitchen_id="kit-test") registry_path = str(tmp_path / "registry.json") missing_path = str(tmp_path / "gone_clone") @@ -498,7 +499,7 @@ async def test_register_clone_status_propagates_kitchen_id_as_owner( self, tool_ctx_kitchen_open, tmp_path ): """T12 — register_clone_status writes entry with owner == kitchen_id.""" - tool_ctx_kitchen_open.kitchen_id = "kit-xyz" + bind_test_kitchen_identity(tool_ctx_kitchen_open, kitchen_id="kit-xyz") reg = str(tmp_path / "registry.json") result = json.loads( await register_clone_status(clone_path="/c", status="success", registry_path=reg) @@ -514,7 +515,7 @@ async def test_register_clone_status_rejects_when_kitchen_id_empty( self, tool_ctx_kitchen_open, tmp_path ): """T13 — register_clone_status returns registered=false when kitchen_id is empty.""" - tool_ctx_kitchen_open.kitchen_id = "" + bind_test_kitchen_identity(tool_ctx_kitchen_open, kitchen_id="") reg = str(tmp_path / "registry.json") result = json.loads( await register_clone_status(clone_path="/c", status="success", registry_path=reg) @@ -536,7 +537,7 @@ async def test_batch_cleanup_clones_default_scopes_to_current_kitchen_id( clone_registry.register_clone("/clone-A", "success", "kit-A", reg) clone_registry.register_clone("/clone-B", "success", "kit-B", reg) - tool_ctx_kitchen_open.kitchen_id = "kit-A" + bind_test_kitchen_identity(tool_ctx_kitchen_open, kitchen_id="kit-A") mock_mgr = MagicMock() mock_mgr.remove_clone.return_value = {"removed": "true"} tool_ctx_kitchen_open.clone_mgr = mock_mgr @@ -556,7 +557,7 @@ async def test_batch_cleanup_clones_all_owners_true_removes_every_success( clone_registry.register_clone("/clone-A", "success", "kit-A", reg) clone_registry.register_clone("/clone-B", "success", "kit-B", reg) - tool_ctx_kitchen_open.kitchen_id = "kit-A" + bind_test_kitchen_identity(tool_ctx_kitchen_open, kitchen_id="kit-A") mock_mgr = MagicMock() mock_mgr.remove_clone.return_value = {"removed": "true"} tool_ctx_kitchen_open.clone_mgr = mock_mgr @@ -572,7 +573,7 @@ async def test_batch_cleanup_clones_empty_kitchen_id_and_all_owners_false_return self, tool_ctx_kitchen_open, tmp_path ): """T16 — empty kitchen_id with default all_owners='false' returns error.""" - tool_ctx_kitchen_open.kitchen_id = "" + bind_test_kitchen_identity(tool_ctx_kitchen_open, kitchen_id="") reg = str(tmp_path / "registry.json") mock_mgr = MagicMock() tool_ctx_kitchen_open.clone_mgr = mock_mgr @@ -592,7 +593,7 @@ async def test_batch_cleanup_clones_empty_kitchen_id_with_all_owners_true_succee reg_path = tmp_path / "registry.json" reg_path.write_text(json.dumps({"clones": [{"path": "/legacy", "status": "success"}]})) - tool_ctx_kitchen_open.kitchen_id = "" + bind_test_kitchen_identity(tool_ctx_kitchen_open, kitchen_id="") mock_mgr = MagicMock() mock_mgr.remove_clone.return_value = {"removed": "true"} tool_ctx_kitchen_open.clone_mgr = mock_mgr @@ -613,7 +614,7 @@ async def test_batch_cleanup_clones_invalid_all_owners_literal_treated_as_false( clone_registry.register_clone("/clone-A", "success", "kit-A", reg) clone_registry.register_clone("/clone-B", "success", "kit-B", reg) - tool_ctx_kitchen_open.kitchen_id = "kit-A" + bind_test_kitchen_identity(tool_ctx_kitchen_open, kitchen_id="kit-A") mock_mgr = MagicMock() mock_mgr.remove_clone.return_value = {"removed": "true"} tool_ctx_kitchen_open.clone_mgr = mock_mgr @@ -636,7 +637,7 @@ async def test_two_kitchens_register_and_cleanup_isolated( clone_registry.register_clone("/clone-2", "success", "kit-2", reg) # Session 1 cleans up via the MCP tool - tool_ctx_kitchen_open.kitchen_id = "kit-1" + bind_test_kitchen_identity(tool_ctx_kitchen_open, kitchen_id="kit-1") mock_mgr = MagicMock() mock_mgr.remove_clone.return_value = {"removed": "true"} tool_ctx_kitchen_open.clone_mgr = mock_mgr @@ -664,7 +665,7 @@ async def test_register_clone_status_prefers_campaign_id_over_kitchen_id( ): """T26 — CAMPAIGN_ID env var takes precedence over tool_ctx_kitchen_open.kitchen_id.""" monkeypatch.setenv("AUTOSKILLIT_CAMPAIGN_ID", "camp-x") - tool_ctx_kitchen_open.kitchen_id = "kit-y" + bind_test_kitchen_identity(tool_ctx_kitchen_open, kitchen_id="kit-y") reg = str(tmp_path / "registry.json") result = json.loads( @@ -681,7 +682,7 @@ async def test_register_clone_status_falls_back_to_kitchen_id_without_campaign_i ): """T27 — Falls back to tool_ctx_kitchen_open.kitchen_id when CAMPAIGN_ID is not set.""" monkeypatch.delenv("AUTOSKILLIT_CAMPAIGN_ID", raising=False) - tool_ctx_kitchen_open.kitchen_id = "kit-fallback" + bind_test_kitchen_identity(tool_ctx_kitchen_open, kitchen_id="kit-fallback") reg = str(tmp_path / "registry.json") result = json.loads( @@ -705,7 +706,7 @@ async def test_batch_cleanup_clones_owner_filter_scopes_to_specified_owner( clone_registry.register_clone("/clone-a", "success", "camp-A", reg) clone_registry.register_clone("/clone-b", "success", "camp-B", reg) - tool_ctx_kitchen_open.kitchen_id = "kit-x" + bind_test_kitchen_identity(tool_ctx_kitchen_open, kitchen_id="kit-x") mock_mgr = MagicMock() mock_mgr.remove_clone.return_value = {"removed": "true"} tool_ctx_kitchen_open.clone_mgr = mock_mgr @@ -725,7 +726,7 @@ async def test_batch_cleanup_clones_owner_filter_empty_falls_back_to_kitchen_id( clone_registry.register_clone("/clone-a", "success", "kit-x", reg) clone_registry.register_clone("/clone-b", "success", "kit-y", reg) - tool_ctx_kitchen_open.kitchen_id = "kit-x" + bind_test_kitchen_identity(tool_ctx_kitchen_open, kitchen_id="kit-x") mock_mgr = MagicMock() mock_mgr.remove_clone.return_value = {"removed": "true"} tool_ctx_kitchen_open.clone_mgr = mock_mgr @@ -744,7 +745,7 @@ async def test_batch_cleanup_clones_owner_filter_with_all_owners_true_ignores_fi clone_registry.register_clone("/clone-a", "success", "camp-A", reg) clone_registry.register_clone("/clone-b", "success", "camp-B", reg) - tool_ctx_kitchen_open.kitchen_id = "kit-x" + bind_test_kitchen_identity(tool_ctx_kitchen_open, kitchen_id="kit-x") mock_mgr = MagicMock() mock_mgr.remove_clone.return_value = {"removed": "true"} tool_ctx_kitchen_open.clone_mgr = mock_mgr diff --git a/tests/server/test_tools_execution_persistent_root.py b/tests/server/test_tools_execution_persistent_root.py index 74a2c50007..9cdbd23c33 100644 --- a/tests/server/test_tools_execution_persistent_root.py +++ b/tests/server/test_tools_execution_persistent_root.py @@ -19,6 +19,7 @@ import pytest import autoskillit.server.tools.tools_execution as _patch_tools_tools_execution +from tests.conftest import bind_test_kitchen_identity pytestmark = [pytest.mark.layer("server"), pytest.mark.small] @@ -54,7 +55,7 @@ async def test_recipe_pin_to_persistent_backend_resolves_root_when_global_backen ) ctx = make_tool_ctx(config) ctx.gate = DefaultGateState(enabled=True) - ctx.kitchen_id = "test-kitchen" + bind_test_kitchen_identity(ctx, kitchen_id="test-kitchen") ctx.recipe_name = "remediation" executor = InMemoryHeadlessExecutor() diff --git a/tests/server/test_tools_kitchen_cache_poison.py b/tests/server/test_tools_kitchen_cache_poison.py index 62e9e4425c..f1abd56e9a 100644 --- a/tests/server/test_tools_kitchen_cache_poison.py +++ b/tests/server/test_tools_kitchen_cache_poison.py @@ -10,7 +10,7 @@ import pytest import autoskillit.server.tools.tools_kitchen as _patch_tools_tools_kitchen -from tests.server._helpers import _resolve_recipe_section +from tests.server._helpers import _resolve_recipe_section, patched_kitchen_id pytestmark = [pytest.mark.layer("server"), pytest.mark.anyio, pytest.mark.medium] @@ -30,11 +30,7 @@ async def test_open_kitchen_ingredients_only_does_not_poison_load_recipe( with patch.object(_patch_tools_tools_kitchen, "_prime_quota_cache", new=AsyncMock()): with patch.object(_patch_tools_tools_kitchen, "_write_hook_config"): with patch.object(_patch_tools_tools_kitchen, "create_background_task"): - with patch.object( - _patch_tools_tools_kitchen, - "resolve_kitchen_id", - return_value="test-kitchen", - ): + with patched_kitchen_id("test-kitchen"): ok_result = json.loads( await open_kitchen( name="implementation", diff --git a/tests/server/test_tools_kitchen_envelope_failure.py b/tests/server/test_tools_kitchen_envelope_failure.py index 0465ecc205..41af2a20f3 100644 --- a/tests/server/test_tools_kitchen_envelope_failure.py +++ b/tests/server/test_tools_kitchen_envelope_failure.py @@ -15,8 +15,9 @@ _configure_admitted_recipe, _make_finalized_projection, _with_finalized_projection, + patched_kitchen_id, ) -from tests.server.conftest import _make_mock_ctx +from tests.server.conftest import _make_mock_ctx, _set_mock_kitchen_transition pytestmark = [pytest.mark.layer("server"), pytest.mark.small] @@ -161,17 +162,13 @@ async def test_open_kitchen_injects_hidden_ingredient_overrides(tmp_path, monkey } _configure_admitted_recipe(mock_ctx, tmp_path / "demo.yaml") mock_ctx.config.migration.suppressed = [] - mock_ctx.kitchen_id = "test-kitchen-abc" + _set_mock_kitchen_transition(mock_ctx, kitchen_id="test-kitchen-abc") with patch("autoskillit.server._get_ctx", return_value=mock_ctx): with patch("autoskillit.server.logger"): with patch.object(_patch_tools_tools_kitchen, "_prime_quota_cache", new=AsyncMock()): with patch.object(_patch_tools_tools_kitchen, "_write_hook_config"): - with patch.object( - _patch_tools_tools_kitchen, - "resolve_kitchen_id", - return_value="test-kitchen-abc", - ): + with patched_kitchen_id("test-kitchen-abc"): with patch.object( _patch_tools_tools_kitchen, "resolve_ingredient_defaults", @@ -213,17 +210,13 @@ async def test_config_layer_keys_match_server_authoritative_ingredients(tmp_path } _configure_admitted_recipe(mock_ctx, tmp_path / "demo.yaml") mock_ctx.config.migration.suppressed = [] - mock_ctx.kitchen_id = "test-kitchen-abc" + _set_mock_kitchen_transition(mock_ctx, kitchen_id="test-kitchen-abc") with patch("autoskillit.server._get_ctx", return_value=mock_ctx): with patch("autoskillit.server.logger"): with patch.object(_patch_tools_tools_kitchen, "_prime_quota_cache", new=AsyncMock()): with patch.object(_patch_tools_tools_kitchen, "_write_hook_config"): - with patch.object( - _patch_tools_tools_kitchen, - "resolve_kitchen_id", - return_value="test-kitchen-abc", - ): + with patched_kitchen_id("test-kitchen-abc"): with patch.object( _patch_tools_tools_kitchen, "resolve_ingredient_defaults", @@ -314,18 +307,14 @@ async def test_open_kitchen_rejects_config_authority_override(tmp_path, monkeypa mock_ctx.recipes = MagicMock() _configure_admitted_recipe(mock_ctx, tmp_path / "demo.yaml") mock_ctx.config.migration.suppressed = [] - mock_ctx.kitchen_id = "test-kitchen-abc" + _set_mock_kitchen_transition(mock_ctx, kitchen_id="test-kitchen-abc") mock_ctx.config.linux_tracing.log_dir = "" with patch("autoskillit.server._get_ctx", return_value=mock_ctx): with patch("autoskillit.server.logger"): with patch.object(_patch_tools_tools_kitchen, "_prime_quota_cache", new=AsyncMock()): with patch.object(_patch_tools_tools_kitchen, "_write_hook_config"): - with patch.object( - _patch_tools_tools_kitchen, - "resolve_kitchen_id", - return_value="test-kitchen-abc", - ): + with patched_kitchen_id("test-kitchen-abc"): with patch.object( _patch_tools_tools_kitchen, "resolve_ingredient_defaults", @@ -364,18 +353,14 @@ async def test_open_kitchen_rejects_authority_override_with_envelope(tmp_path, m mock_ctx.recipes = MagicMock() _configure_admitted_recipe(mock_ctx, tmp_path / "demo.yaml") mock_ctx.config.migration.suppressed = [] - mock_ctx.kitchen_id = "test-kitchen-abc" + _set_mock_kitchen_transition(mock_ctx, kitchen_id="test-kitchen-abc") mock_ctx.config.linux_tracing.log_dir = "" with patch("autoskillit.server._get_ctx", return_value=mock_ctx): with patch("autoskillit.server.logger"): with patch.object(_patch_tools_tools_kitchen, "_prime_quota_cache", new=AsyncMock()): with patch.object(_patch_tools_tools_kitchen, "_write_hook_config"): - with patch.object( - _patch_tools_tools_kitchen, - "resolve_kitchen_id", - return_value="test-kitchen-abc", - ): + with patched_kitchen_id("test-kitchen-abc"): with patch.object( _patch_tools_tools_kitchen, "resolve_ingredient_defaults", diff --git a/tests/server/test_tools_kitchen_envelope_validation.py b/tests/server/test_tools_kitchen_envelope_validation.py index 960a668b6c..9775d94190 100644 --- a/tests/server/test_tools_kitchen_envelope_validation.py +++ b/tests/server/test_tools_kitchen_envelope_validation.py @@ -9,7 +9,8 @@ import pytest import autoskillit.server.tools.tools_kitchen as _patch_tools_tools_kitchen -from tests.server.conftest import _make_mock_ctx +from tests.server._helpers import patched_kitchen_id +from tests.server.conftest import _make_mock_ctx, _set_mock_kitchen_transition pytestmark = [pytest.mark.layer("server"), pytest.mark.small] @@ -181,18 +182,14 @@ async def test_pipeline_health_override_wins_over_config(tmp_path, monkeypatch): mock_recipe_info.path = Path("/fake/recipe.yaml") mock_ctx.recipes.find.return_value = mock_recipe_info mock_ctx.config.migration.suppressed = [] - mock_ctx.kitchen_id = "test-kitchen-abc" + _set_mock_kitchen_transition(mock_ctx, kitchen_id="test-kitchen-abc") mock_ctx.config.linux_tracing.log_dir = "" with patch("autoskillit.server._get_ctx", return_value=mock_ctx): with patch("autoskillit.server.logger"): with patch.object(_patch_tools_tools_kitchen, "_prime_quota_cache", new=AsyncMock()): with patch.object(_patch_tools_tools_kitchen, "_write_hook_config"): - with patch.object( - _patch_tools_tools_kitchen, - "resolve_kitchen_id", - return_value="test-kitchen-abc", - ): + with patched_kitchen_id("test-kitchen-abc"): with patch.object( _patch_tools_tools_kitchen, "resolve_ingredient_defaults", @@ -241,18 +238,14 @@ async def test_pipeline_health_config_default_applied(tmp_path, monkeypatch): mock_recipe_info.path = Path("/fake/recipe.yaml") mock_ctx.recipes.find.return_value = mock_recipe_info mock_ctx.config.migration.suppressed = [] - mock_ctx.kitchen_id = "test-kitchen-abc" + _set_mock_kitchen_transition(mock_ctx, kitchen_id="test-kitchen-abc") mock_ctx.config.linux_tracing.log_dir = "" with patch("autoskillit.server._get_ctx", return_value=mock_ctx): with patch("autoskillit.server.logger"): with patch.object(_patch_tools_tools_kitchen, "_prime_quota_cache", new=AsyncMock()): with patch.object(_patch_tools_tools_kitchen, "_write_hook_config"): - with patch.object( - _patch_tools_tools_kitchen, - "resolve_kitchen_id", - return_value="test-kitchen-abc", - ): + with patched_kitchen_id("test-kitchen-abc"): with patch.object( _patch_tools_tools_kitchen, "resolve_ingredient_defaults", diff --git a/tests/server/test_tools_kitchen_gate.py b/tests/server/test_tools_kitchen_gate.py index db2959f410..eb94024568 100644 --- a/tests/server/test_tools_kitchen_gate.py +++ b/tests/server/test_tools_kitchen_gate.py @@ -9,7 +9,7 @@ import autoskillit.server.tools.tools_kitchen as _patch_tools_tools_kitchen from autoskillit.hooks.formatters._fmt_primitives import _HOOK_CONFIG_PATH_COMPONENTS -from tests.server.conftest import _make_mock_ctx +from tests.server.conftest import _make_mock_ctx, _set_mock_kitchen_transition pytestmark = [pytest.mark.layer("server"), pytest.mark.small] @@ -327,6 +327,7 @@ async def test_open_kitchen_inherits_campaign_id_from_env(tmp_path, monkeypatch) monkeypatch.chdir(tmp_path) monkeypatch.setenv("AUTOSKILLIT_CAMPAIGN_ID", "abc123def456") mock_ctx = _make_mock_ctx() + _set_mock_kitchen_transition(mock_ctx) with patch("autoskillit.server._get_ctx", return_value=mock_ctx): with patch("autoskillit.server.logger"): @@ -345,6 +346,7 @@ async def test_open_kitchen_generates_uuid_without_campaign_env(tmp_path, monkey monkeypatch.chdir(tmp_path) monkeypatch.delenv("AUTOSKILLIT_CAMPAIGN_ID", raising=False) mock_ctx = _make_mock_ctx() + _set_mock_kitchen_transition(mock_ctx) with patch("autoskillit.server._get_ctx", return_value=mock_ctx): with patch("autoskillit.server.logger"): @@ -386,7 +388,7 @@ async def test_close_kitchen_drains_orphaned_github_api_entries(tmp_path, monkey mock_ctx = _make_mock_ctx() mock_ctx.project_dir = tmp_path mock_ctx.github_api_log = log - mock_ctx.kitchen_id = "test-kitchen-123" + _set_mock_kitchen_transition(mock_ctx, kitchen_id="test-kitchen-123") log_dir = tmp_path / "logs" log_dir.mkdir() mock_ctx.config.linux_tracing.log_dir = str(log_dir) diff --git a/tests/server/test_tools_kitchen_gate_hook_config.py b/tests/server/test_tools_kitchen_gate_hook_config.py index 2e82c9a8d0..674927287f 100644 --- a/tests/server/test_tools_kitchen_gate_hook_config.py +++ b/tests/server/test_tools_kitchen_gate_hook_config.py @@ -11,7 +11,7 @@ from autoskillit.config.settings import OutputBudgetConfig, QuotaGuardConfig from autoskillit.hooks.formatters._fmt_primitives import _HOOK_CONFIG_PATH_COMPONENTS from tests.server._helpers import _HOOK_CONFIG_OVERLAY_RELPATH -from tests.server.conftest import _make_mock_ctx +from tests.server.conftest import _make_mock_ctx, _set_mock_kitchen_transition pytestmark = [pytest.mark.layer("server"), pytest.mark.small] @@ -345,7 +345,7 @@ async def test_update_hook_config_with_recipe_includes_recipe_allows_pr_create( mock_ctx = _make_mock_ctx() mock_ctx.project_dir = tmp_path mock_ctx.recipe_name = "merge-prs" - mock_ctx.kitchen_id = "test-kitchen-id" + _set_mock_kitchen_transition(mock_ctx, kitchen_id="test-kitchen-id") mock_ctx.config.quota_guard.cache_max_age = 300 mock_ctx.config.quota_guard.cache_path = "/p/q.json" mock_ctx.config.quota_guard.buffer_seconds = 60 @@ -377,7 +377,7 @@ async def test_update_hook_config_with_recipe_excludes_pr_create_for_non_pr_reci mock_ctx = _make_mock_ctx() mock_ctx.project_dir = tmp_path mock_ctx.recipe_name = "smoke-test" - mock_ctx.kitchen_id = "test-kitchen-id" + _set_mock_kitchen_transition(mock_ctx, kitchen_id="test-kitchen-id") mock_ctx.config.quota_guard.cache_max_age = 300 mock_ctx.config.quota_guard.cache_path = "/p/q.json" mock_ctx.config.quota_guard.buffer_seconds = 60 @@ -517,7 +517,7 @@ def test_write_hook_config_does_not_touch_overlay(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) mock_ctx = _make_mock_ctx() mock_ctx.project_dir = tmp_path - mock_ctx.kitchen_id = "test-kitchen-id" + _set_mock_kitchen_transition(mock_ctx, kitchen_id="test-kitchen-id") mock_ctx.config.quota_guard.cache_max_age = 300 mock_ctx.config.quota_guard.cache_path = "/p/q.json" mock_ctx.config.quota_guard.buffer_seconds = 60 diff --git a/tests/server/test_tools_recipe.py b/tests/server/test_tools_recipe.py index 70f2cec715..bdf536bbe2 100644 --- a/tests/server/test_tools_recipe.py +++ b/tests/server/test_tools_recipe.py @@ -10,6 +10,7 @@ import autoskillit.server.tools.tools_recipe as tools_recipe from autoskillit.server.tools.tools_recipe import list_recipes as list_recipes_tool from autoskillit.server.tools.tools_recipe import validate_recipe +from tests.conftest import bind_test_kitchen_identity from tests.server._helpers import ( _PATCHED_DEFAULTS, _configure_admitted_recipe, @@ -555,7 +556,7 @@ async def test_load_recipe_injects_hidden_ingredient_overrides( "ingredients_table": "--- INGREDIENTS TABLE ---\n task required\n--- END TABLE ---", } _configure_admitted_recipe(tool_ctx_kitchen_open, tmp_path / "demo.yaml") - tool_ctx_kitchen_open.kitchen_id = "test-kitchen-xyz" + bind_test_kitchen_identity(tool_ctx_kitchen_open, kitchen_id="test-kitchen-xyz") with patch.object( tools_recipe, @@ -584,7 +585,7 @@ async def test_load_recipe_rejects_config_authority_override(tool_ctx_kitchen_op tool_ctx_kitchen_open.recipes = MagicMock() _configure_admitted_recipe(tool_ctx_kitchen_open, tmp_path / "demo.yaml") - tool_ctx_kitchen_open.kitchen_id = "test-kitchen-xyz" + bind_test_kitchen_identity(tool_ctx_kitchen_open, kitchen_id="test-kitchen-xyz") with patch.object( tools_recipe, @@ -625,7 +626,7 @@ async def test_load_recipe_with_config_authority_ingredient(tool_ctx_kitchen_ope tool_ctx_kitchen_open.recipes = MagicMock() _configure_admitted_recipe(tool_ctx_kitchen_open, tmp_path / "demo.yaml") - tool_ctx_kitchen_open.kitchen_id = "test-kitchen-xyz" + bind_test_kitchen_identity(tool_ctx_kitchen_open, kitchen_id="test-kitchen-xyz") with patch.object( tools_recipe, diff --git a/tests/server/test_tools_recipe_pull.py b/tests/server/test_tools_recipe_pull.py index a5bef82146..b536679250 100644 --- a/tests/server/test_tools_recipe_pull.py +++ b/tests/server/test_tools_recipe_pull.py @@ -92,6 +92,7 @@ from autoskillit.server.response._response_budget import enforce_response_budget from autoskillit.server.tools._recipe_section_handler import _inject_initialization_counters from autoskillit.server.tools.tools_recipe import get_recipe_section +from tests.conftest import bind_test_kitchen_identity pytestmark = [pytest.mark.layer("server"), pytest.mark.medium] @@ -248,7 +249,7 @@ def test_prepare_generation_rejects_non_finite_compile_values(tool_ctx) -> None: def test_compile_identity_and_artifact_share_one_source_projection(tool_ctx) -> None: - tool_ctx.kitchen_id = "compile-identity" + bind_test_kitchen_identity(tool_ctx, kitchen_id="compile-identity") payload = _payload() payload["custom_generation_input"] = {"value": 3} payload["initialization_id"] = "caller-owned-stale-id" @@ -286,7 +287,7 @@ def _persist_finalized_generation( def test_finalized_response_rejects_stale_kitchen_transition_owner(tool_ctx) -> None: - tool_ctx.kitchen_id = "transition-owner" + bind_test_kitchen_identity(tool_ctx, kitchen_id="transition-owner") tool_ctx.kitchen_open_state = start_kitchen_effect( new_kitchen_open_state( kitchen_id=tool_ctx.kitchen_id, @@ -322,7 +323,7 @@ def test_finalized_response_rejects_stale_kitchen_transition_owner(tool_ctx) -> def test_finalized_response_confirms_owned_kitchen_serving_effect(tool_ctx) -> None: - tool_ctx.kitchen_id = "transition-owner" + bind_test_kitchen_identity(tool_ctx, kitchen_id="transition-owner") tool_ctx.kitchen_open_state = start_kitchen_effect( new_kitchen_open_state( kitchen_id=tool_ctx.kitchen_id, @@ -356,7 +357,7 @@ def test_finalized_response_confirms_owned_kitchen_serving_effect(tool_ctx) -> N def test_finalized_response_marks_changed_kitchen_serving_effect_ambiguous( tool_ctx, ) -> None: - tool_ctx.kitchen_id = "transition-owner" + bind_test_kitchen_identity(tool_ctx, kitchen_id="transition-owner") tool_ctx.kitchen_open_state = start_kitchen_effect( new_kitchen_open_state( kitchen_id=tool_ctx.kitchen_id, @@ -982,7 +983,7 @@ def test_kitchen_retirement_rejects_dot_path_components(tmp_path: Path, kitchen_ def test_codex_without_supported_host_evidence_uses_bounded_envelope(tool_ctx) -> None: tool_ctx.backend = CodexBackend() - tool_ctx.kitchen_id = "codex-envelope" + bind_test_kitchen_identity(tool_ctx, kitchen_id="codex-envelope") payload = _payload( "name: remediation\nsteps:\n first:\n action: stop\n message: " + ("x" * 50_000) @@ -1006,7 +1007,7 @@ def test_codex_without_supported_host_evidence_uses_bounded_envelope(tool_ctx) - def test_token_dense_payload_does_not_use_four_byte_ordinary_estimate(tool_ctx) -> None: tool_ctx.backend = CodexBackend() - tool_ctx.kitchen_id = "codex-token-dense" + bind_test_kitchen_identity(tool_ctx, kitchen_id="codex-token-dense") finalized = _finalize_recipe_delivery( _payload("!" * 20_000), @@ -1028,7 +1029,7 @@ def test_initialization_requirements_use_the_pull_response_bound( ) -> None: response_max_bytes = RECIPE_SECTION_RESPONSE_FLOOR_BYTES + 500 tool_ctx.backend = CodexBackend() - tool_ctx.kitchen_id = "initialization-page-bound" + bind_test_kitchen_identity(tool_ctx, kitchen_id="initialization-page-bound") tool_ctx.config.output_budget = OutputBudgetConfig( response_max_bytes=response_max_bytes, page_max_bytes=None, @@ -1081,7 +1082,9 @@ async def test_initialization_pull_rejections_preserve_progress( expected_error: str, ) -> None: tool_ctx_kitchen_open.backend = CodexBackend() - tool_ctx_kitchen_open.kitchen_id = f"initialization-rejection-{case}" + bind_test_kitchen_identity( + tool_ctx_kitchen_open, kitchen_id=f"initialization-rejection-{case}" + ) tool_ctx_kitchen_open.config.output_budget = OutputBudgetConfig( response_max_bytes=8_000, page_max_bytes=195_000, @@ -1187,7 +1190,7 @@ def test_finalizer_uses_backend_selected_recipe_budget(tool_ctx) -> None: recipe_delivery_budget=selected_budget, ) tool_ctx.backend = backend - tool_ctx.kitchen_id = "selected-budget" + bind_test_kitchen_identity(tool_ctx, kitchen_id="selected-budget") finalized = _finalize_recipe_delivery( _payload(), @@ -1222,7 +1225,7 @@ def test_annotation_aware_inline_for_exempt_surface_within_ceiling( eligible for the annotation-aware branch when attestation is present. """ tool_ctx.backend = ClaudeCodeBackend() - tool_ctx.kitchen_id = "claude-code-exemption" + bind_test_kitchen_identity(tool_ctx, kitchen_id="claude-code-exemption") # The effective unannotated limit rises to the attested gate × headroom # when attestation is valid. The payload must exceed THAT to exercise the @@ -1270,7 +1273,7 @@ def test_annotation_aware_inline_falls_through_without_attestation(tool_ctx) -> the annotation-aware branch must remain ENVELOPE — never trust an unattested per-call claim.""" tool_ctx.backend = ClaudeCodeBackend() - tool_ctx.kitchen_id = "claude-code-exemption-unattested" + bind_test_kitchen_identity(tool_ctx, kitchen_id="claude-code-exemption-unattested") ordinary_limit = ClaudeCodeBackend().capabilities.unnegotiated_tool_result_token_limit ceiling = RESPONSE_BACKSTOP_EXEMPTION_REGISTRY["open_kitchen"].max_utf8_bytes @@ -1296,7 +1299,7 @@ def test_exemption_override_retains_envelope_for_payload_above_ceiling(tool_ctx) exemption. """ tool_ctx.backend = ClaudeCodeBackend() - tool_ctx.kitchen_id = "claude-code-over-ceiling" + bind_test_kitchen_identity(tool_ctx, kitchen_id="claude-code-over-ceiling") # Payload whose ordinary JSON exceeds the 195,000-byte exemption ceiling. oversized_content = "y" * ( @@ -1328,7 +1331,7 @@ def test_exemption_override_requires_char_ceiling_too(tool_ctx) -> None: must not fire for such a payload. """ tool_ctx.backend = ClaudeCodeBackend() - tool_ctx.kitchen_id = "claude-code-char-ceiling" + bind_test_kitchen_identity(tool_ctx, kitchen_id="claude-code-char-ceiling") exemption = RESPONSE_BACKSTOP_EXEMPTION_REGISTRY["open_kitchen"] byte_margin = exemption.max_utf8_bytes * 9 // 10 @@ -1360,7 +1363,7 @@ def test_exemption_override_does_not_apply_to_non_exempt_surface(tool_ctx) -> No with attestation present. """ tool_ctx.backend = ClaudeCodeBackend() - tool_ctx.kitchen_id = "claude-code-get-recipe" + bind_test_kitchen_identity(tool_ctx, kitchen_id="claude-code-get-recipe") ordinary_limit = ClaudeCodeBackend().capabilities.unnegotiated_tool_result_token_limit oversized_content = "z" * (ordinary_limit * 4 + 5_000) @@ -1386,7 +1389,7 @@ def test_annotation_aware_inline_not_available_to_protected_backend(tool_ctx) -> delivery pipeline. """ tool_ctx.backend = CodexBackend() - tool_ctx.kitchen_id = "codex-annotation-aware-rejected" + bind_test_kitchen_identity(tool_ctx, kitchen_id="codex-annotation-aware-rejected") ordinary_limit = CodexBackend().capabilities.unnegotiated_tool_result_token_limit ceiling = RESPONSE_BACKSTOP_EXEMPTION_REGISTRY["open_kitchen"].max_utf8_bytes @@ -1481,7 +1484,7 @@ def test_attested_finalization_commits_only_after_exact_enforcement( tmp_path: Path, tool_ctx ) -> None: tool_ctx.backend = _protected_codex_backend() - tool_ctx.kitchen_id = "codex-attested" + bind_test_kitchen_identity(tool_ctx, kitchen_id="codex-attested") ledger = _ledger(tmp_path) finalized = _finalize_recipe_delivery( _payload("x" * 50_000), @@ -1523,7 +1526,7 @@ def test_failed_attested_receipt_commit_restores_prior_state_without_retirement( monkeypatch: pytest.MonkeyPatch, ) -> None: tool_ctx.backend = _protected_codex_backend() - tool_ctx.kitchen_id = "codex-commit-failure" + bind_test_kitchen_identity(tool_ctx, kitchen_id="codex-commit-failure") ledger = _ledger(tmp_path) finalized = _finalize_recipe_delivery( _payload("x" * 50_000), @@ -1576,7 +1579,7 @@ def test_failed_attested_install_retires_restores_then_aborts_receipt( monkeypatch: pytest.MonkeyPatch, ) -> None: tool_ctx.backend = _protected_codex_backend() - tool_ctx.kitchen_id = "codex-install-failure" + bind_test_kitchen_identity(tool_ctx, kitchen_id="codex-install-failure") ledger = _ledger(tmp_path) finalized = _finalize_recipe_delivery( _payload("x" * 50_000), @@ -1630,7 +1633,7 @@ def _record_abort(receipt_ledger: Any, handle: Any) -> bool: def test_transformed_attested_response_aborts_pending_receipt(tmp_path: Path, tool_ctx) -> None: tool_ctx.backend = _protected_codex_backend() - tool_ctx.kitchen_id = "codex-abort" + bind_test_kitchen_identity(tool_ctx, kitchen_id="codex-abort") ledger = _ledger(tmp_path) finalized = _finalize_recipe_delivery( _payload("x" * 50_000), @@ -1654,7 +1657,7 @@ def test_failed_receipt_abort_is_reported( tmp_path: Path, tool_ctx, monkeypatch: pytest.MonkeyPatch ) -> None: tool_ctx.backend = _protected_codex_backend() - tool_ctx.kitchen_id = "codex-abort-failure" + bind_test_kitchen_identity(tool_ctx, kitchen_id="codex-abort-failure") ledger = _ledger(tmp_path) finalized = _finalize_recipe_delivery( _payload("x" * 50_000), @@ -1684,7 +1687,7 @@ async def test_pull_tool_reads_exact_generation_and_reports_byte_offsets( tool_ctx_kitchen_open, ) -> None: tool_ctx_kitchen_open.backend = CodexBackend() - tool_ctx_kitchen_open.kitchen_id = "pull-kitchen" + bind_test_kitchen_identity(tool_ctx_kitchen_open, kitchen_id="pull-kitchen") tool_ctx_kitchen_open.config.output_budget = OutputBudgetConfig(page_max_bytes=None) expected_content = "héllo\n" * 12_000 generation = persist_recipe_artifact( @@ -1742,7 +1745,7 @@ async def test_pull_tool_rejects_missing_or_wrong_continuation( continuation: str | None, ) -> None: tool_ctx_kitchen_open.backend = CodexBackend() - tool_ctx_kitchen_open.kitchen_id = "pull-continuation-rejection" + bind_test_kitchen_identity(tool_ctx_kitchen_open, kitchen_id="pull-continuation-rejection") tool_ctx_kitchen_open.config.output_budget = OutputBudgetConfig(page_max_bytes=None) generation = persist_recipe_artifact( tool_ctx_kitchen_open.temp_dir, @@ -1801,7 +1804,9 @@ async def test_pull_tool_maps_planner_failures_to_exact_bounded_codes( expected_code: str, ) -> None: tool_ctx_kitchen_open.backend = CodexBackend() - tool_ctx_kitchen_open.kitchen_id = f"planner-failure-{expected_code}" + bind_test_kitchen_identity( + tool_ctx_kitchen_open, kitchen_id=f"planner-failure-{expected_code}" + ) generation = persist_recipe_artifact( tool_ctx_kitchen_open.temp_dir, kitchen_id=tool_ctx_kitchen_open.kitchen_id, @@ -1849,7 +1854,7 @@ async def test_pull_tool_distinguishes_missing_none_and_present_empty_sections( expected_value: object, ) -> None: tool_ctx_kitchen_open.backend = CodexBackend() - tool_ctx_kitchen_open.kitchen_id = f"pull-empty-{section}-{state}" + bind_test_kitchen_identity(tool_ctx_kitchen_open, kitchen_id=f"pull-empty-{section}-{state}") payload = _payload() if state == "missing": payload.pop(section) @@ -1894,7 +1899,7 @@ async def test_initial_schema_failure_is_bounded_and_never_recreates( import autoskillit.server.tools.tools_recipe as tools_recipe tool_ctx_kitchen_open.backend = CodexBackend() - tool_ctx_kitchen_open.kitchen_id = "pull-initial-schema-mismatch" + bind_test_kitchen_identity(tool_ctx_kitchen_open, kitchen_id="pull-initial-schema-mismatch") payload = _payload() payload["warnings"] = ["valid", 1] generation = _write_malformed_generation( @@ -1929,7 +1934,7 @@ async def test_recreation_persistence_schema_failure_precedes_artifact_error( import autoskillit.server.tools._recipe_section_handler as recipe_section_handler tool_ctx_kitchen_open.backend = CodexBackend() - tool_ctx_kitchen_open.kitchen_id = "pull-recreate-schema-write" + bind_test_kitchen_identity(tool_ctx_kitchen_open, kitchen_id="pull-recreate-schema-write") generation, _finalized = _persist_finalized_generation(tool_ctx_kitchen_open) _remove_persisted_namespace( tool_ctx_kitchen_open.temp_dir, @@ -1962,7 +1967,7 @@ async def test_post_recreation_reload_schema_failure_precedes_reload_error( import autoskillit.server.tools._recipe_section_handler as recipe_section_handler tool_ctx_kitchen_open.backend = CodexBackend() - tool_ctx_kitchen_open.kitchen_id = "pull-recreate-schema-reload" + bind_test_kitchen_identity(tool_ctx_kitchen_open, kitchen_id="pull-recreate-schema-reload") generation, _finalized = _persist_finalized_generation(tool_ctx_kitchen_open) monkeypatch.setattr( recipe_section_handler, @@ -2001,7 +2006,7 @@ async def test_post_recreation_reload_artifact_failure_logs_exception_context( import autoskillit.server.tools._recipe_section_handler as recipe_section_handler tool_ctx_kitchen_open.backend = CodexBackend() - tool_ctx_kitchen_open.kitchen_id = "pull-recreate-artifact-reload" + bind_test_kitchen_identity(tool_ctx_kitchen_open, kitchen_id="pull-recreate-artifact-reload") generation, _finalized = _persist_finalized_generation(tool_ctx_kitchen_open) monkeypatch.setattr( recipe_section_handler, @@ -2045,7 +2050,7 @@ async def test_negative_part_is_rejected_before_artifact_load( import autoskillit.server.tools._recipe_section_handler as recipe_section_handler tool_ctx_kitchen_open.backend = CodexBackend() - tool_ctx_kitchen_open.kitchen_id = "kitchen-test" + bind_test_kitchen_identity(tool_ctx_kitchen_open, kitchen_id="kitchen-test") generation = _persist(tool_ctx_kitchen_open.temp_dir) artifact_load = MagicMock(side_effect=AssertionError("negative part reached artifact load")) monkeypatch.setattr(recipe_section_handler, "load_recipe_artifact", artifact_load) @@ -2063,7 +2068,7 @@ async def test_oversized_part_is_rejected_after_page_planning( tool_ctx_kitchen_open, ) -> None: tool_ctx_kitchen_open.backend = CodexBackend() - tool_ctx_kitchen_open.kitchen_id = "kitchen-test" + bind_test_kitchen_identity(tool_ctx_kitchen_open, kitchen_id="kitchen-test") generation = _persist(tool_ctx_kitchen_open.temp_dir) kwargs = generation.pull_identity() kwargs.pop("pull_tool") @@ -2078,7 +2083,7 @@ async def test_request_specific_floor_returns_exact_bounded_failure( tool_ctx_kitchen_open, monkeypatch: pytest.MonkeyPatch ) -> None: tool_ctx_kitchen_open.backend = CodexBackend() - tool_ctx_kitchen_open.kitchen_id = "kitchen-test" + bind_test_kitchen_identity(tool_ctx_kitchen_open, kitchen_id="kitchen-test") monkeypatch.setattr( tool_ctx_kitchen_open, "config", @@ -2121,7 +2126,7 @@ async def test_pull_tool_returns_named_step_and_rejects_unknown_section( tool_ctx_kitchen_open, ) -> None: tool_ctx_kitchen_open.backend = CodexBackend() - tool_ctx_kitchen_open.kitchen_id = "pull-named-step" + bind_test_kitchen_identity(tool_ctx_kitchen_open, kitchen_id="pull-named-step") generation = persist_recipe_artifact( tool_ctx_kitchen_open.temp_dir, kitchen_id=tool_ctx_kitchen_open.kitchen_id, @@ -2146,7 +2151,7 @@ async def test_pull_tool_returns_named_step_and_rejects_unknown_section( async def test_pull_tool_reports_malformed_named_step_yaml(tool_ctx_kitchen_open) -> None: - tool_ctx_kitchen_open.kitchen_id = "pull-malformed-step" + bind_test_kitchen_identity(tool_ctx_kitchen_open, kitchen_id="pull-malformed-step") malformed = _payload("steps: [") generation = persist_recipe_artifact( tool_ctx_kitchen_open.temp_dir, @@ -2168,7 +2173,7 @@ async def test_oversized_named_step_round_trips_through_continuation( tool_ctx_kitchen_open, ) -> None: tool_ctx_kitchen_open.backend = CodexBackend() - tool_ctx_kitchen_open.kitchen_id = "pull-oversized-step" + bind_test_kitchen_identity(tool_ctx_kitchen_open, kitchen_id="pull-oversized-step") tool_ctx_kitchen_open.config.output_budget = OutputBudgetConfig(page_max_bytes=None) content = "steps:\n giant_step:\n note: " + ("X" * 80_000) + "\n" payload = _payload(content) @@ -2214,7 +2219,7 @@ async def test_oversized_named_step_round_trips_through_continuation( async def test_pull_tool_rejects_wrong_generation_identity(tool_ctx_kitchen_open) -> None: - tool_ctx_kitchen_open.kitchen_id = "pull-wrong" + bind_test_kitchen_identity(tool_ctx_kitchen_open, kitchen_id="pull-wrong") generation = persist_recipe_artifact( tool_ctx_kitchen_open.temp_dir, kitchen_id=tool_ctx_kitchen_open.kitchen_id, @@ -2234,7 +2239,7 @@ async def test_pull_tool_rejects_wrong_generation_identity(tool_ctx_kitchen_open async def test_pull_tool_rejects_forged_unbounded_identity_sizes( tool_ctx_kitchen_open, field: str, producer_tool: str ) -> None: - tool_ctx_kitchen_open.kitchen_id = "pull-unbounded-identity" + bind_test_kitchen_identity(tool_ctx_kitchen_open, kitchen_id="pull-unbounded-identity") generation = persist_recipe_artifact( tool_ctx_kitchen_open.temp_dir, kitchen_id=tool_ctx_kitchen_open.kitchen_id, @@ -2256,7 +2261,7 @@ async def test_pull_tool_recreates_missing_exact_generation( ) -> None: import autoskillit.server.tools.tools_recipe as tools_recipe - tool_ctx_kitchen_open.kitchen_id = "pull-recreate" + bind_test_kitchen_identity(tool_ctx_kitchen_open, kitchen_id="pull-recreate") generation, finalized = _persist_finalized_generation(tool_ctx_kitchen_open) _remove_persisted_namespace( tool_ctx_kitchen_open.temp_dir, kitchen_id=tool_ctx_kitchen_open.kitchen_id @@ -2291,7 +2296,7 @@ async def test_recreation_reuses_original_snapshot_without_snapshot_factory( ) -> None: import autoskillit.server.recipe._recipe_execution as recipe_execution - tool_ctx_kitchen_open.kitchen_id = "pull-recreate-stale-snapshot" + bind_test_kitchen_identity(tool_ctx_kitchen_open, kitchen_id="pull-recreate-stale-snapshot") payload = _payload() payload["content_hash"] = "sha256:" + ("a" * 64) payload["composite_hash"] = "sha256:" + ("b" * 64) @@ -2330,7 +2335,7 @@ async def test_recreation_reuses_original_snapshot_without_snapshot_factory( async def test_pull_tool_reports_invalid_missing_generation_recreation( tool_ctx_kitchen_open, ) -> None: - tool_ctx_kitchen_open.kitchen_id = "pull-recreate-invalid" + bind_test_kitchen_identity(tool_ctx_kitchen_open, kitchen_id="pull-recreate-invalid") generation = persist_recipe_artifact( tool_ctx_kitchen_open.temp_dir, kitchen_id=tool_ctx_kitchen_open.kitchen_id, @@ -2352,7 +2357,7 @@ async def test_pull_tool_reports_invalid_missing_generation_recreation( async def test_pull_tool_rejects_changed_recreated_generation( tool_ctx_kitchen_open, ) -> None: - tool_ctx_kitchen_open.kitchen_id = "pull-recreate-changed" + bind_test_kitchen_identity(tool_ctx_kitchen_open, kitchen_id="pull-recreate-changed") generation, _finalized = _persist_finalized_generation(tool_ctx_kitchen_open) _remove_persisted_namespace( tool_ctx_kitchen_open.temp_dir, kitchen_id=tool_ctx_kitchen_open.kitchen_id diff --git a/tests/server/test_tools_status_kitchen.py b/tests/server/test_tools_status_kitchen.py index e159e5ccc4..9b5418d3e0 100644 --- a/tests/server/test_tools_status_kitchen.py +++ b/tests/server/test_tools_status_kitchen.py @@ -25,7 +25,7 @@ get_token_summary, kitchen_status, ) -from tests.conftest import _make_result +from tests.conftest import _make_result, bind_test_kitchen_identity from tests.server._helpers import _make_finalized_projection_from_recipe_steps pytestmark = [pytest.mark.layer("server"), pytest.mark.small] @@ -181,7 +181,7 @@ async def test_kitchen_status_reports_missing_exact_kitchen_tracker( self, tool_ctx_kitchen_open, monkeypatch, tmp_path ): tool_ctx_kitchen_open.project_dir = tmp_path - tool_ctx_kitchen_open.kitchen_id = "kitchen-status" + bind_test_kitchen_identity(tool_ctx_kitchen_open, kitchen_id="kitchen-status") _set_active_recipe_steps(tool_ctx_kitchen_open, _dependency_recipe_steps()) dispatch_path = ( tmp_path / ".autoskillit" / "temp" / "pipeline_tracker" / "dispatch-status.json" @@ -202,7 +202,7 @@ async def test_kitchen_status_treats_tracker_as_optional_without_dependencies( self, tool_ctx_kitchen_open, tmp_path ): tool_ctx_kitchen_open.project_dir = tmp_path - tool_ctx_kitchen_open.kitchen_id = "kitchen-status" + bind_test_kitchen_identity(tool_ctx_kitchen_open, kitchen_id="kitchen-status") _set_active_recipe_steps( tool_ctx_kitchen_open, {"review": RecipeStep(name="review")}, @@ -227,7 +227,7 @@ async def test_kitchen_status_reports_readable_kitchen_tracker_clean( self, tool_ctx_kitchen_open, tmp_path ): tool_ctx_kitchen_open.project_dir = tmp_path - tool_ctx_kitchen_open.kitchen_id = "kitchen-status" + bind_test_kitchen_identity(tool_ctx_kitchen_open, kitchen_id="kitchen-status") _set_active_recipe_steps(tool_ctx_kitchen_open, _dependency_recipe_steps()) tracker_path = ( tmp_path / ".autoskillit" / "temp" / "pipeline_tracker" / "kitchen-status.json"