Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
ddad196
fix: scope recipe authority to each kitchen activation
Trecek Oct 14, 2026
33692e6
test: guard kitchen activation authority and expose persistence failures
Trecek Oct 14, 2026
8001d80
fix(review): reject blank kitchen activation identities
Trecek Oct 14, 2026
52d7c5c
fix(review): preserve artifact retirement errors when notification fails
Trecek Oct 14, 2026
d502f8b
refactor(review): hoist acyclic kitchen lifecycle imports
Trecek Oct 14, 2026
8fd065e
fix(review): reject invalid activation IDs at artifact retirement entry
Trecek Oct 14, 2026
39c1546
fix(review): validate activation IDs before artifact load
Trecek Oct 14, 2026
2a940f1
fix(review): validate activation IDs before artifact persistence
Trecek Oct 14, 2026
9666cba
refactor(review): keep artifact activation validation at public bound…
Trecek Oct 14, 2026
159fd77
refactor(review): centralize distinct test kitchen identity fields
Trecek Oct 14, 2026
9733770
fix(review): persist section-test artifacts under the context activation
Trecek Oct 14, 2026
90ea45c
refactor(review): name recipe authority APIs by activation
Trecek Oct 14, 2026
f90472c
fix: push to the resolved remote URL from worktrees
Trecek Oct 14, 2026
4390eb8
fix: preserve push tracking only for matching remote destinations
Trecek Oct 14, 2026
f2137a9
fix: retain Git ref inventory coverage for push option terminators
Trecek Oct 14, 2026
63e69b0
fix: make smoke PR body writes provable to the ref guard
Trecek Oct 14, 2026
461f847
fix: grant the smoke worker its owned worktree output scope
Trecek Oct 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions docs/glossary.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,20 @@ Synonym for the kitchen-tagged MCP tools. Two words, no hyphen.

### kitchen_id

The unique identifier assigned to a kitchen instance (typically a session
UUID). Recorded in pipeline telemetry.
The campaign lineage identifier: the supplied resume campaign ID, otherwise
`AUTOSKILLIT_CAMPAIGN_ID`, otherwise a UUID minted when the kitchen opens.
It stays stable across close and reopen within a campaign and is shared by
the campaign's food trucks. Captures, child environment, hooks, tracker,
session logs, plan-set binding, and response envelopes use this identifier.
Per-activation recipe state uses [kitchen_activation_id](#kitchen_activation_id).

### kitchen_activation_id

The identity minted fresh on each `CLOSED -> INFRASTRUCTURE_READY` transition,
never reused, and carried unchanged when an already-open kitchen receives new
arguments. It keys recipe artifacts and their tombstones, the recipe generation
store, and the page-plan cache. [kitchen_id](#kitchen_id) retains the campaign
lineage across these activations.

### L0

Expand Down
7 changes: 4 additions & 3 deletions scripts/e2e/recipes/sandbox-smoke.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ steps:
`smoke: add sandbox canary`. Do not run a test command or call tools
that dispatch work to another worker.
cwd: "${{ context.worktree_path }}"
output_dir: "${{ context.worktree_path }}"
step_name: implement
on_success: test
on_failure: failed
Expand Down Expand Up @@ -120,14 +121,14 @@ steps:
tool: run_cmd
with:
cmd: >-
body_file="{{AUTOSKILLIT_TEMP}}/sandbox-smoke-pr-body.md" &&
mkdir -p "{{AUTOSKILLIT_TEMP}}" &&
printf '%s\n' 'Headless sandbox smoke canary.' > "$body_file" &&
printf '%s\n' 'Headless sandbox smoke canary.' >
"{{AUTOSKILLIT_TEMP}}/sandbox-smoke-pr-body.md" &&
gh pr create --repo "${{ inputs.repository }}"
--head "${{ context.branch_name }}"
--base "${{ inputs.base_branch }}"
--title 'Automated smoke test (DO NOT MERGE)'
--body-file "$body_file"
--body-file "{{AUTOSKILLIT_TEMP}}/sandbox-smoke-pr-body.md"
cwd: "${{ context.worktree_path }}"
step_name: create_pull_request
on_success: close_pull_request
Expand Down
4 changes: 4 additions & 0 deletions src/autoskillit/pipeline/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
from autoskillit.pipeline.kitchen_transition import (
KITCHEN_EFFECT_RECIPE_SERVING,
KITCHEN_EFFECT_RESPONSE_ENFORCEMENT,
KitchenActivationId,
KitchenEffectPhase,
KitchenEffectRecord,
KitchenIntentConflict,
Expand All @@ -81,6 +82,7 @@
new_kitchen_open_state,
prepare_kitchen_response,
release_kitchen_request,
require_kitchen_activation_id,
start_kitchen_effect,
transition_abort,
transition_ambiguous,
Expand Down Expand Up @@ -174,6 +176,7 @@
# kitchen transition
"KITCHEN_EFFECT_RECIPE_SERVING",
"KITCHEN_EFFECT_RESPONSE_ENFORCEMENT",
"KitchenActivationId",
"KitchenEffectPhase",
"KitchenEffectRecord",
"KitchenIntentConflict",
Expand All @@ -197,6 +200,7 @@
"mark_kitchen_effect_ambiguous",
"mark_kitchen_effect_degraded",
"new_kitchen_open_state",
"require_kitchen_activation_id",
"prepare_kitchen_response",
"release_kitchen_request",
"start_kitchen_effect",
Expand Down
15 changes: 13 additions & 2 deletions src/autoskillit/pipeline/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@
)
from autoskillit.pipeline.background import DefaultBackgroundSupervisor
from autoskillit.pipeline.kitchen_transition import (
KitchenActivationId,
KitchenOpenState,
closed_kitchen_open_state,
)
Expand Down Expand Up @@ -198,8 +199,10 @@ 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: Read-only identity derived from kitchen_open_state; set only through
establish_kitchen_identity and empty while the state is CLOSED.
kitchen_id: Read-only campaign-lineage identity derived from kitchen_open_state;
set only through establish_kitchen_identity and empty while CLOSED.
kitchen_activation_id: Read-only per-activation identity derived from kitchen_open_state;
raises 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
Expand Down Expand Up @@ -414,6 +417,14 @@ def kitchen_id(self) -> str:
"""Return the identity derived from the current lifecycle state."""
return self.kitchen_open_state.kitchen_id

@property
def kitchen_activation_id(self) -> KitchenActivationId:
"""Return the live activation identity; raise when none is live."""
activation_id = self.kitchen_open_state.activation_id
if activation_id is None:
raise RuntimeError("no kitchen activation is live")
return activation_id

@property
def host_client_attestation(self) -> HostClientAttestation | None:
"""Return the launcher attestation snapshot owned by this context."""
Expand Down
25 changes: 25 additions & 0 deletions src/autoskillit/pipeline/kitchen_transition.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"KITCHEN_EFFECT_RESPONSE_ENFORCEMENT",
"KitchenEffectPhase",
"KitchenEffectRecord",
"KitchenActivationId",
"KitchenIntentConflict",
"KitchenOpenPhase",
"KitchenOpenState",
Expand All @@ -35,6 +36,7 @@
"mark_kitchen_effect_ambiguous",
"mark_kitchen_effect_degraded",
"new_kitchen_open_state",
"require_kitchen_activation_id",
"prepare_kitchen_response",
"release_kitchen_request",
"start_kitchen_effect",
Expand All @@ -48,6 +50,24 @@
KITCHEN_EFFECT_RESPONSE_ENFORCEMENT = "response_enforcement"


@dataclass(frozen=True, slots=True)
class KitchenActivationId:
"""Identity of one CLOSED -> INFRASTRUCTURE_READY activation. Never reused."""

value: str

def __post_init__(self) -> None:
if not isinstance(self.value, str) or not self.value.strip():
raise ValueError("KitchenActivationId.value must contain non-whitespace characters")


def require_kitchen_activation_id(value: object) -> KitchenActivationId:
"""Return ``value`` when it is an activation identity; reject anything else."""
if not isinstance(value, KitchenActivationId):
raise TypeError("a KitchenActivationId is required")
return value


class KitchenOpenPhase(StrEnum):
"""Authoritative lifecycle phases for one process-local open operation."""

Expand Down Expand Up @@ -107,6 +127,7 @@ class KitchenOpenState:

phase: KitchenOpenPhase
kitchen_id: str
activation_id: KitchenActivationId | None
operation_id: str
context_id: str
intent_fingerprint: str | None = None
Expand Down Expand Up @@ -156,6 +177,7 @@ def closed_kitchen_open_state(*, context_id: str | None = None) -> KitchenOpenSt
return KitchenOpenState(
phase=KitchenOpenPhase.CLOSED,
kitchen_id="",
activation_id=None,
operation_id="",
context_id=(context_id if isinstance(context_id, str) and context_id else str(uuid4())),
)
Expand All @@ -164,15 +186,18 @@ def closed_kitchen_open_state(*, context_id: str | None = None) -> KitchenOpenSt
def new_kitchen_open_state(
*,
kitchen_id: str,
activation_id: KitchenActivationId,
context_id: str,
operation_id: str | None = None,
) -> KitchenOpenState:
"""Mint the infrastructure transition before request arguments are bound."""
if not kitchen_id:
raise ValueError("kitchen_id must be non-empty")
activation_id = require_kitchen_activation_id(activation_id)
return KitchenOpenState(
phase=KitchenOpenPhase.INFRASTRUCTURE_READY,
kitchen_id=kitchen_id,
activation_id=activation_id,
operation_id=operation_id or str(uuid4()),
context_id=context_id,
)
Expand Down
10 changes: 6 additions & 4 deletions src/autoskillit/server/lifecycle/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,12 @@ 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.
`_kitchen_identity` is the sole kitchen-identity mint, activation, and retirement
authority. `ToolContext.kitchen_id` (campaign lineage) and
`ToolContext.kitchen_activation_id` (per activation) are both derived from
`kitchen_open_state`. Per-activation recipe state keys on the activation ID,
never on the kitchen ID. This application lifecycle state 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
Expand Down
51 changes: 44 additions & 7 deletions src/autoskillit/server/lifecycle/_kitchen_identity.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
"""Sole owner of the kitchen-identity CLOSED -> INFRASTRUCTURE_READY edge."""
"""Sole owner of the kitchen-identity lifecycle edges and activation retirement."""

from autoskillit.core import resolve_kitchen_id
from pathlib import Path
from uuid import uuid4

from autoskillit.core import get_logger, resolve_kitchen_id
from autoskillit.pipeline import (
KitchenActivationId,
KitchenOpenPhase,
ToolContext,
closed_kitchen_open_state,
new_kitchen_open_state,
)
from autoskillit.server.recipe import _recipe_artifact, _recipe_generation
from autoskillit.server.recipe.section import _lifecycle

logger = get_logger(__name__)


def establish_kitchen_identity(ctx: ToolContext, *, campaign_id: str | None = None) -> str:
Expand All @@ -16,18 +25,46 @@ def establish_kitchen_identity(ctx: ToolContext, *, campaign_id: str | None = No
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)
activation_id = KitchenActivationId(uuid4().hex)
_recipe_generation.activate_activation(activation_id)
ctx.kitchen_process_identity = None
state = new_kitchen_open_state(
kitchen_id=kitchen_id,
activation_id=activation_id,
context_id=state.context_id,
)
ctx.kitchen_open_state = state
return state.kitchen_id


def retire_kitchen_identity(ctx: ToolContext) -> None:
"""Return the lifecycle to CLOSED, then permanently retire its activation."""
with ctx.kitchen_transition_lock:
state = ctx.kitchen_open_state
ctx.kitchen_open_state = closed_kitchen_open_state(context_id=state.context_id)
activation_id = state.activation_id
if not isinstance(activation_id, KitchenActivationId):
return
if isinstance(ctx.temp_dir, Path):
try:
if _recipe_artifact.retire_recipe_artifacts(ctx.temp_dir, activation_id=activation_id):
return
except Exception:
try:
_lifecycle.notify_activation_retired(activation_id)
except Exception:
logger.warning(
"kitchen_activation_retirement_notification_failed",
activation_id=activation_id.value,
exc_info=True,
)
raise
logger.warning(
"kitchen_activation_artifact_retirement_failed",
kitchen_id=state.kitchen_id,
activation_id=activation_id.value,
)
_lifecycle.notify_activation_retired(activation_id)
Loading
Loading