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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/autoskillit/pipeline/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ class ToolContext:
recipe_content_hash: str = field(default="")
recipe_composite_hash: str = field(default="")
recipe_version: str = field(default="")
gate_infrastructure_ready: bool = field(default=False)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Observation from local review round 0:

gate_infrastructure_ready is server-lifecycle state placed in ToolContext (IL-1). Consider housing in server-layer singleton.

Evidence: gate_infrastructure_ready is exclusively set/read in src/autoskillit/server/ (tools_kitchen.py and _lifespan.py). The established pattern houses all kitchen lifecycle state (kitchen_id, active_recipe_packs, etc.) in ToolContext. Moving to a server-layer singleton would require duplicating session-scoped state management or creating a downward IL violation.

kitchen_id: str = field(default="")
active_recipe_packs: frozenset[str] | None = field(default_factory=lambda: None)
active_recipe_features: frozenset[str] | None = field(default_factory=lambda: None)
Expand Down
1 change: 1 addition & 0 deletions src/autoskillit/server/_lifespan.py
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,7 @@ async def _pre_reveal_kitchen(ctx: Any) -> None:
register_active_kitchen(ctx.kitchen_id, os.getpid(), str(ctx.project_dir))
_write_hook_config()
await _prime_quota_cache()
ctx.gate_infrastructure_ready = True


async def _food_truck_auto_gate_boot(ctx: Any) -> None:
Expand Down
40 changes: 36 additions & 4 deletions src/autoskillit/server/tools/tools_kitchen.py
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,7 @@ async def _open_kitchen_handler() -> str | None:
except Exception:
logger.warning("open_kitchen_registry_failed", exc_info=True)

ctx.gate_infrastructure_ready = True
return None


Expand Down Expand Up @@ -331,6 +332,7 @@ def _close_kitchen_handler() -> None:
ctx.recipe_content_hash = ""
ctx.recipe_composite_hash = ""
ctx.recipe_version = ""
ctx.gate_infrastructure_ready = False
logger.info("close_kitchen", gate_state="closed")
if (log := ctx.github_api_log) is not None:
orphan_usage = log.drain(ctx.kitchen_id)
Expand Down Expand Up @@ -515,15 +517,30 @@ async def open_kitchen(
disabled_subsets = _get_ctx().config.subsets.disabled

_ctx_pre = _get_ctx()
_is_deferred_recall = (
name is not None and _ctx_pre.gate.enabled and _ctx_pre.recipe_name == name
)
_skip_handler = _ctx_pre.gate_infrastructure_ready
tool_ctx = _get_ctx()

if not _is_deferred_recall:
if not _skip_handler:
handler_err = await _open_kitchen_handler()
if handler_err is not None:
return handler_err
else:
_ctx_post = _get_ctx()
if _ctx_post.quota_refresh_task is None:
Comment thread
Trecek marked this conversation as resolved.
try:
_ctx_post.quota_refresh_task = create_background_task(
_quota_refresh_loop(
_ctx_post.config.quota_guard,
provider=resolve_provider(_ctx_post.config.providers.default_provider),
),
label="quota_refresh_loop",
)
except Exception:

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[warning] defense: The except Exception block swallows the failure after logging. If create_background_task or resolve_provider raises, the quota refresh loop silently does not start and no caller-visible signal is surfaced beyond this log line. The deferred-recall path proceeds without quota guard protection.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Valid observation — flagged for design decision. The non-deferred path treats quota-refresh startup failure as fatal (disables gate, returns failure envelope), while the deferred-recall path logs silently. The asymmetry is intentional: by the time deferred-recall runs, the kitchen is already open. Whether to escalate this to a non-fatal warning in the response JSON is a product-level trade-off.

logger.warning(
"open_kitchen_quota_refresh_deferred_start_failed", exc_info=True
)

if not _skip_handler:
_kctx_pre = _get_ctx()
_skip_notify = (
_kctx_pre.backend is not None
Expand All @@ -538,6 +555,7 @@ async def open_kitchen(
logger.warning(
"open_kitchen_failure", stage="enable_components", exc_info=True
)
tool_ctx.gate_infrastructure_ready = False
return _kitchen_failure_envelope(exc, stage="enable_components")

try:
Expand All @@ -550,8 +568,16 @@ async def open_kitchen(
)
except Exception as exc:
logger.warning("open_kitchen_failure", stage="redisable_subsets", exc_info=True)
tool_ctx.gate_infrastructure_ready = False
return _kitchen_failure_envelope(exc, stage="redisable_subsets")

_is_deferred_recall = (
name is not None
and _ctx_pre.gate.enabled
and _ctx_pre.recipe_name == name
and _ctx_pre.recipe_name != ""
)

_forbidden_list = ", ".join(PIPELINE_FORBIDDEN_TOOLS)
_ctx = _get_ctx()
_categories = _build_tool_category_listing(
Expand Down Expand Up @@ -645,6 +671,7 @@ async def open_kitchen(
# Default to False for missing 'valid' so a absent key is treated as invalid
if not result.get("valid", False) or not result.get("content", ""):
tool_ctx.gate.disable()
tool_ctx.gate_infrastructure_ready = False
return _recipe_validation_error_response(name, result)
# Dispatch-feasibility preflight: verify the backend can enforce
# all fix-required hooks for the recipe's run_skill steps.
Expand All @@ -658,6 +685,7 @@ async def open_kitchen(
)
if _preflight_err is not None:
tool_ctx.gate.disable()
tool_ctx.gate_infrastructure_ready = False
await ctx.disable_components(tags={"kitchen"})
return _preflight_err
result["success"] = True
Expand All @@ -680,6 +708,8 @@ async def open_kitchen(
)
if _override_warnings:
result["warnings"] = _override_warnings
if ingredients_only:
result = strip_ingredients_only_keys(result)
return json.dumps(result)
try:
result = tool_ctx.recipes.load_and_validate(
Expand Down Expand Up @@ -749,6 +779,7 @@ async def open_kitchen(

if not result.get("valid", False) or not result.get("content", ""):
tool_ctx.gate.disable()
tool_ctx.gate_infrastructure_ready = False
return _recipe_validation_error_response(name, result)

# Dispatch-feasibility preflight: verify the backend can enforce
Expand All @@ -763,6 +794,7 @@ async def open_kitchen(
)
if _preflight_err is not None:
tool_ctx.gate.disable()
tool_ctx.gate_infrastructure_ready = False
await ctx.disable_components(tags={"kitchen"})
return _preflight_err

Expand Down
6 changes: 4 additions & 2 deletions tests/arch/test_subpackage_isolation.py
Original file line number Diff line number Diff line change
Expand Up @@ -962,14 +962,16 @@ def test_data_directories_are_not_python_packages() -> None:
"co-located with the execution engine that calls them",
),
"tools_kitchen.py": (
1200,
1250,
"REQ-CNST-010-E7: kitchen tool handlers — open_kitchen and lock_ingredients require "
"inline validation helpers (_check_override_keys, _build_ingredient_key_suggestions) "
"for ingredient key validation; splitting would cross import-layer boundaries; "
"backend capability promotion delegated to _promote_capability_keys in _auto_overrides; "
"fail-closed validity gate on both deferred-recall and normal paths adds structural "
"error propagation from LoadRecipeResult; get_recipe validity guard adds 9 lines; "
"dispatch-feasibility preflight wiring on both paths with gate-close on failure",
"dispatch-feasibility preflight wiring on both paths with gate-close on failure; "
"gate_infrastructure_ready guard restructuring adds 22 lines for handler-skip path, "
"quota_refresh_loop deferred start, and all four gate.disable() rollback resets",
),
"tools_execution.py": (
1130,
Expand Down
4 changes: 2 additions & 2 deletions tests/infra/test_schema_version_convention.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,8 +122,8 @@ def _is_yaml_dump(node: ast.expr) -> bool:
("src/autoskillit/server/tools/tools_kitchen.py", 171),
("src/autoskillit/server/tools/tools_kitchen.py", 190),
("src/autoskillit/server/tools/tools_kitchen.py", 224),
("src/autoskillit/server/tools/tools_kitchen.py", 918),
("src/autoskillit/server/tools/tools_kitchen.py", 978),
("src/autoskillit/server/tools/tools_kitchen.py", 950),
("src/autoskillit/server/tools/tools_kitchen.py", 1010),
# tools_pipeline_tracker.py — tracker_data dict
("src/autoskillit/server/tools/tools_pipeline_tracker.py", 166),
# tools_status.py — mcp_data dict
Expand Down
1 change: 1 addition & 0 deletions tests/server/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ def _make_mock_ctx() -> MagicMock:
ctx.project_dir = Path("/fake/project")
ctx.config.subsets.disabled = [] # REQ-VIS-008: no subsets disabled by default
ctx.active_recipe_ingredients = None
ctx.gate_infrastructure_ready = False
return ctx


Expand Down
39 changes: 39 additions & 0 deletions tests/server/test_kitchen_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,42 @@ async def test_close_kitchen_removes_tracker_dir(monkeypatch, tmp_path):
_close_kitchen_handler()

assert not tracker_dir.exists()


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."""
monkeypatch.chdir(tmp_path)

ctx = make_context(
AutomationConfig(),
runner=None,
plugin_source=DirectInstall(plugin_dir=tmp_path),
project_dir=tmp_path,
)
monkeypatch.setattr(_state, "_ctx", ctx)
monkeypatch.setattr(_state, "_startup_ready", None)

with (
patch("autoskillit.server.tools.tools_kitchen._prime_quota_cache", new_callable=AsyncMock),
patch("autoskillit.core.register_active_kitchen"),
patch("autoskillit.core.unregister_active_kitchen"),
):
result1 = await _open_kitchen_handler()
assert result1 is None
assert ctx.gate_infrastructure_ready is True
first_task = ctx.quota_refresh_task

_close_kitchen_handler()
assert ctx.gate_infrastructure_ready is False
await asyncio.sleep(0)
assert first_task.cancelled() or first_task.done()

result2 = await _open_kitchen_handler()
assert result2 is None
assert ctx.gate_infrastructure_ready is True
second_task = ctx.quota_refresh_task
assert second_task is not first_task

_close_kitchen_handler()
await asyncio.sleep(0)
assert second_task.cancelled() or second_task.done()
3 changes: 3 additions & 0 deletions tests/server/test_lifespan_skill_boot.py
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,9 @@ async def test_codex_non_headless_pre_reveal_opens_gate(self, build_ctx, monkeyp
assert ctx.kitchen_id is not None, "kitchen_id should be set by pre-reveal path"
assert ctx.active_recipe_packs == frozenset()
assert ctx.active_recipe_steps == {}
assert ctx.gate_infrastructure_ready is True, (
"gate_infrastructure_ready should be True after pre-reveal"
)

@pytest.mark.anyio
async def test_codex_non_headless_pre_reveal_suppresses_disabled_subset(
Expand Down
Loading
Loading