From e3b9b1ba0b06777f01b26962d5e74a9fd019dbb5 Mon Sep 17 00:00:00 2001 From: Trecek Date: Wed, 14 Oct 2026 10:42:08 -0700 Subject: [PATCH 01/46] fix: deliver complete recipe credentials and actionable attestation recovery --- docs/decisions/0004-recipe-redelivery.md | 6 +- src/autoskillit/cli/prompts/_prompts.py | 15 +- .../core/types/constants/_type_constants.py | 4 +- .../server/recipe/_recipe_initialization.py | 33 +-- .../tools_execution/_run_skill_admission.py | 48 +++- src/autoskillit/skills/sous-chef/SKILL.md | 14 +- tests/_test_filter.py | 1 + tests/cli/test_sous_chef_content.py | 6 +- ...t_recipe_execution_receipt_completeness.py | 227 ++++++++++++++++++ tests/server/test_recipe_initialization.py | 47 ---- 10 files changed, 306 insertions(+), 95 deletions(-) create mode 100644 tests/server/test_recipe_execution_receipt_completeness.py diff --git a/docs/decisions/0004-recipe-redelivery.md b/docs/decisions/0004-recipe-redelivery.md index fc502a9f85..4fcc7455b4 100644 --- a/docs/decisions/0004-recipe-redelivery.md +++ b/docs/decisions/0004-recipe-redelivery.md @@ -127,8 +127,10 @@ Four invariants are non-negotiable: for envelope responses, `get_recipe_section`). - Terminal-page delivery enforces the four invariants above while preserving the separate completion call as a compatibility fallback for non-receipt responses. -- Segmented recipes recover from the latest `recipe_segment`; the startup credential is - intentionally scoped to delivered bodies rather than the full future recipe. +- Segmented recipes recover from the latest `recipe_segment`; the startup carrier's + credential is scoped to the first segment's bodies by the carrier byte cap, while the + completion receipt (invariant 4) always carries the complete credential and is + re-requestable while the recipe is READY. - The delivery-reachability suite verifies `load_recipe` / `open_kitchen` / `get_recipe_section` re-delivery restores pipeline execution capability, including complete-envelope and bounded-envelope per-step pulls. diff --git a/src/autoskillit/cli/prompts/_prompts.py b/src/autoskillit/cli/prompts/_prompts.py index 903cb72061..6f557b2811 100644 --- a/src/autoskillit/cli/prompts/_prompts.py +++ b/src/autoskillit/cli/prompts/_prompts.py @@ -178,12 +178,19 @@ def render(self) -> str: "recipe_delivery.payload_metadata when the body is attested); the same " "forwarding rule applies. If any response contains recipe_segment, " "consume it as mandatory authority: verify and install ordered bodies " - "for startup/success carriers, use the carrier-scoped invocation " - "digests, and reconstruct every recovery pull_closure entry through " + "for startup/success carriers and reconstruct every recovery " + "pull_closure entry through " "the unchanged recipe_pull. The overview is only a table of contents. " "If subtype=recipe_segment_post_effect_delivery_failure, the operation " - "already ran; do not repeat it. Do not assume startup contains " - "full-horizon invocation_template_digests. For structured child inputs, " + "already ran; do not repeat it. " + "The complete_recipe_initialization(initialization_id) receipt carries every " + "named step's recipe_execution credential. Use the delivered completion receipt " + "when one is already present, including a terminal-page embedded receipt; " + "otherwise call complete_recipe_initialization(initialization_id) before the " + "first run_skill. The receipt can be re-requested at any time while the recipe " + "is READY, and a digest rejection names that exact call. Absence of a top-level " + "recipe_execution block alone does not identify segmented delivery. " + "For structured child inputs, " "select " "recipe_execution.skill_input_shapes[step_name], initialize skill_inputs " "with exactly its ordered keys, and replace available values in place. " diff --git a/src/autoskillit/core/types/constants/_type_constants.py b/src/autoskillit/core/types/constants/_type_constants.py index a5be8b4e4b..0da0630a71 100644 --- a/src/autoskillit/core/types/constants/_type_constants.py +++ b/src/autoskillit/core/types/constants/_type_constants.py @@ -398,8 +398,8 @@ def _render_stop_step_evidence_doctrine(indent: str = "") -> str: RECIPE_EXECUTION_ATTESTATION_MISSING_MESSAGE: str = ( "an active recipe requires recipe_execution_id and invocation_template_digest; " "take both from the recipe_execution block of the complete_recipe_initialization " - "receipt (bounded delivery) or of the open_kitchen response (inline delivery), " - "using invocation_template_digests[step_name] for this step; structured calls must " + "receipt, which carries every named step's credential, using " + "invocation_template_digests[step_name] for this step; structured calls must " "initialize skill_inputs from skill_input_shapes[step_name] ordered keys, replace " "available values in place, copy only advertised absence_values by key presence " 'so "", 0, and False remain verbatim, and never delete or invent a key' diff --git a/src/autoskillit/server/recipe/_recipe_initialization.py b/src/autoskillit/server/recipe/_recipe_initialization.py index 4bd0365fab..53c1ca7b29 100644 --- a/src/autoskillit/server/recipe/_recipe_initialization.py +++ b/src/autoskillit/server/recipe/_recipe_initialization.py @@ -538,29 +538,6 @@ def _render_completion_receipt( ) -def _public_completion_credential( - credential: RecipeExecutionCredential, - projection: FinalizedRecipeProjection, -) -> RecipeExecutionCredential: - if not projection.delivery_segments: - return credential - initial_steps = frozenset(projection.delivery_segments[0].ordered_step_names) - return RecipeExecutionCredential( - execution_id=credential.execution_id, - snapshot_digest=credential.snapshot_digest, - invocation_template_digests={ - step_name: digest - for step_name, digest in credential.invocation_template_digests.items() - if step_name in initial_steps - }, - skill_input_shapes={ - step_name: shape - for step_name, shape in credential.skill_input_shapes.items() - if step_name in initial_steps - }, - ) - - def build_embedded_completion_response( *, initialization_id: str, @@ -602,10 +579,7 @@ def build_completion_response( recipe_name=state.recipe_name, artifact_generation=state.artifact_generation, flow_generation=state.flow_generation, - credential=_public_completion_credential( - build_recipe_execution_credential(state.installed_execution.snapshot), - state.finalized_projection, - ), + credential=build_recipe_execution_credential(state.installed_execution.snapshot), ) return rendered if ( @@ -627,10 +601,7 @@ def build_completion_response( recipe_name=state.recipe_name, artifact_generation=state.artifact_generation, flow_generation=state.flow_generation, - credential=_public_completion_credential( - build_recipe_execution_credential(state.staged_snapshot), - state.finalized_projection, - ), + credential=build_recipe_execution_credential(state.staged_snapshot), ) return FinalizedRecipeInitializationResponse( rendered=rendered, diff --git a/src/autoskillit/server/tools/tools_execution/_run_skill_admission.py b/src/autoskillit/server/tools/tools_execution/_run_skill_admission.py index a3fa9054fe..c523d4ad2f 100644 --- a/src/autoskillit/server/tools/tools_execution/_run_skill_admission.py +++ b/src/autoskillit/server/tools/tools_execution/_run_skill_admission.py @@ -31,6 +31,7 @@ normalize_declared_truth, resolve_temp_dir, ) +from autoskillit.pipeline import ReadyRecipe from autoskillit.server._audit_authority_materializer import ( derive_initial_lifecycle_ids, load_current_prior_authority, @@ -61,13 +62,53 @@ from autoskillit.server.tools.tools_execution._state import _RunSkillDispatchState -def _recipe_execution_deny(code: str, message: str) -> str: +_CREDENTIAL_REJECTION_CODES = frozenset( + {"recipe_execution_id_mismatch", "invocation_template_digest_mismatch"} +) + + +def _recipe_execution_deny( + code: str, + message: str, + *, + recovery: str | None = None, +) -> str: return json.dumps( deny_envelope( f"RECIPE EXECUTION REJECTED [{code}]: {message}", stage="preflight:recipe_execution", retriable=False, + recovery=recovery, + ) + ) + + +def _credential_recovery(state: _RunSkillDispatchState) -> str | None: + with state.tool_ctx.recipe_execution_lock: + initialization = state.tool_ctx.recipe_initialization_state + if not isinstance(initialization, ReadyRecipe): + return None + step_name = state.step_name or "" + return ( + 'call complete_recipe_initialization(initialization_id="' + f'{initialization.initialization_id}"); its recipe_execution block carries ' + "every named step's credential: send " + "recipe_execution_id=recipe_execution.execution_id and " + "invocation_template_digest=recipe_execution.invocation_template_digests[" + f'"{step_name}"]' ) + + +def _credential_rejection( + state: _RunSkillDispatchState, + code: str, + message: str, +) -> str: + step_context = f"step {state.step_name!r}: " if state.step_name else "" + return _recipe_execution_deny( + code, + f"{step_context}{message}", + recovery=_credential_recovery(state), ) @@ -489,7 +530,8 @@ def _admit_dynamic_recipe_invocation(state: _RunSkillDispatchState) -> str | Non def _admit_attested_recipe_invocation(state: _RunSkillDispatchState) -> str | None: assert state._installed_execution is not None if not state.recipe_execution_id or not state.invocation_template_digest: - return _recipe_execution_deny( + return _credential_rejection( + state, "recipe_execution_attestation_missing", RECIPE_EXECUTION_ATTESTATION_MISSING_MESSAGE, ) @@ -538,6 +580,8 @@ def _admit_attested_recipe_invocation(state: _RunSkillDispatchState) -> str | No actual_mcp_kwargs=_actual_mcp_kwargs, ) except RecipeExecutionAdmissionError as exc: + if exc.code in _CREDENTIAL_REJECTION_CODES: + return _credential_rejection(state, exc.code, str(exc)) return _recipe_execution_deny(exc.code, str(exc)) if state.tool_ctx.config.safety.require_dry_walkthrough: bound_plan_path = dict(state._bound_recipe_inputs).get("plan_path") diff --git a/src/autoskillit/skills/sous-chef/SKILL.md b/src/autoskillit/skills/sous-chef/SKILL.md index 912b47446b..c4efc23573 100644 --- a/src/autoskillit/skills/sous-chef/SKILL.md +++ b/src/autoskillit/skills/sous-chef/SKILL.md @@ -1035,9 +1035,9 @@ ingredient interpolation) before serving content to you. When a response contains `recipe_segment`, treat that carrier as mandatory recipe authority. For `kind: startup` or `kind: success`, install every canonical body in -`bodies` in the advertised order, verify each `body_sha256`, and use the segment-scoped -`recipe_execution.invocation_template_digests` for the newly delivered named steps. The -segment overview is a table of contents, not permission to invent or read future bodies. +`bodies` in the advertised order and verify each `body_sha256`; a carrier repeats the +`recipe_execution` digests of the bodies it delivers. The segment overview is a table of +contents, not permission to invent or read future bodies. For `kind: recovery`, use its ordered `pull_closure` and `pull_requests` with the carrier's unchanged `recipe_pull`; reconstruct every named body before continuing. A `recipe_segment_post_effect_delivery_failure` @@ -1080,8 +1080,12 @@ response (top level, or under `recipe_delivery.payload_metadata` when the body i the same forwarding rule applies. Omitting these parameters or passing values that were not delivered in the response will cause `recipe_execution_attestation_missing` or `recipe_execution_inactive` denial. -Segmented delivery narrows this lookup to the latest delivered carrier. Do not assume the -startup credential contains invocation digests for the full recipe horizon. +The complete_recipe_initialization(initialization_id) receipt carries every named step's `recipe_execution` +credential. Use the delivered completion receipt when one is already present, including a terminal-page +embedded receipt; otherwise call complete_recipe_initialization(initialization_id) before the first +`run_skill`. The receipt can be re-requested at any time while the recipe is READY, and a digest rejection +names that exact call. Absence of a top-level `recipe_execution` block alone does not identify segmented +delivery. For structured child inputs, select `recipe_execution.skill_input_shapes[step_name]` and initialize `skill_inputs` with exactly its ordered keys; replace available values in place; diff --git a/tests/_test_filter.py b/tests/_test_filter.py index 5e6142f2ce..92e01b4593 100644 --- a/tests/_test_filter.py +++ b/tests/_test_filter.py @@ -1336,6 +1336,7 @@ class SourceMapRejection(enum.StrEnum): "server/test_bundled_dispatch_write_scope_admission.py", "server/test_serve_idempotence.py", "server/test_attestation_delivery_reachability.py", + "server/test_recipe_execution_receipt_completeness.py", "server/test_tools_status_kitchen.py", "server/test_open_kitchen_deferred_recall.py", "server/test_factory_context_construction.py", diff --git a/tests/cli/test_sous_chef_content.py b/tests/cli/test_sous_chef_content.py index 6a42a14bb2..697bb6a470 100644 --- a/tests/cli/test_sous_chef_content.py +++ b/tests/cli/test_sous_chef_content.py @@ -75,14 +75,16 @@ def test_sous_chef_requires_progressive_segment_consumption() -> None: for required in ( "recipe_segment", "body_sha256", - "segment-scoped", + "every named step", + "re-requested", "pull_closure", "recipe_segment_post_effect_delivery_failure", "operation already ran", "do not repeat it", - "full recipe horizon", ): assert required in content + assert "segment-scoped" not in content + assert "full recipe horizon" not in content def test_sous_chef_preserves_attested_skill_input_shape_and_falsey_defaults() -> None: diff --git a/tests/server/test_recipe_execution_receipt_completeness.py b/tests/server/test_recipe_execution_receipt_completeness.py new file mode 100644 index 0000000000..fb90027026 --- /dev/null +++ b/tests/server/test_recipe_execution_receipt_completeness.py @@ -0,0 +1,227 @@ +"""Completion receipts are the re-requestable source for attested step digests.""" + +from __future__ import annotations + +import json +import re +from pathlib import Path + +import pytest + +from autoskillit.core import FinalizedRecipeProjection, build_recipe_execution_credential +from autoskillit.server.recipe._recipe_execution import get_recipe_execution + +pytestmark = [pytest.mark.layer("server"), pytest.mark.anyio, pytest.mark.medium] + +_PROJECT_ROOT = Path(__file__).resolve().parents[2] +_IMPLEMENTATION_OVERRIDES = { + "issue_url": "https://github.com/TalonT-Org/AutoSkillit/issues/5256", + "task": "test task", +} + + +def _recipe_fixture_parameters() -> list[tuple[str, str, dict[str, str]]]: + from autoskillit.recipe import load_and_validate + from tests.contracts.test_delivery_bound_fitness import _delivery_recipe_names + from tests.server.conftest import _READY_RECIPE_OVERRIDES + + parameters = [] + for recipe_name in _delivery_recipe_names(): + loaded = load_and_validate( + recipe_name, + project_dir=_PROJECT_ROOT, + include_finalized_projection=True, + ) + projection = loaded["_finalized_projection"] + assert isinstance(projection, FinalizedRecipeProjection) + if not projection.delivery_segments and recipe_name != "research": + continue + attested_step = next( + step_name + for step_name, invocation in projection.binding_projection.invocations.items() + if invocation.tool_name == "run_skill" and invocation.attested + ) + overrides = ( + _READY_RECIPE_OVERRIDES if recipe_name == "research" else _IMPLEMENTATION_OVERRIDES + ) + parameters.append((recipe_name, attested_step, overrides)) + return parameters + + +_RECIPE_FIXTURE_PARAMETERS = [ + pytest.param(parameters, id=parameters[0]) for parameters in _recipe_fixture_parameters() +] + + +def _skill_command(tool_ctx, step_name: str) -> str: + installed = get_recipe_execution(tool_ctx) + assert installed is not None + template = installed.snapshot.templates[step_name] + command = next( + value.effective_value + for value in template.invocation.mcp_kwargs + if value.name == "skill_command" + ) + assert isinstance(command, str) + return command + + +async def _run_attested_call( + ready, + git_checkout: Path, + step_name: str, + execution_id: str, + digest: str, +) -> dict[str, object]: + from autoskillit.server.tools.tools_execution import run_skill + + return json.loads( + await run_skill( + skill_command=_skill_command(ready.tool_ctx, step_name), + cwd=str(git_checkout), + step_name=step_name, + recipe_execution_id=execution_id, + invocation_template_digest=digest, + ) + ) + + +def _assert_digest_recovery( + response: dict[str, object], step_name: str, initialization_id: str, code: str +) -> str: + serialized = response["error"] + assert isinstance(serialized, str) + assert "Recovery:" in serialized + assert f"[{code}]" in serialized + assert f"step '{step_name}'" in serialized + assert f'complete_recipe_initialization(initialization_id="{initialization_id}")' in serialized + assert f'invocation_template_digests["{step_name}"]' in serialized + match = re.search( + r'complete_recipe_initialization\(initialization_id="([0-9a-f]+)"\)', serialized + ) + assert match is not None, response + return match.group(1) + + +@pytest.mark.parametrize("tool_ctx_ready_recipe", _RECIPE_FIXTURE_PARAMETERS, indirect=True) +async def test_completion_receipt_carries_every_attested_step_credential( + tool_ctx_ready_recipe, +) -> None: + ready = tool_ctx_ready_recipe + installed = get_recipe_execution(ready.tool_ctx) + assert installed is not None + + credential = ready.receipt["recipe_execution"] + assert credential["invocation_template_digests"] == dict(installed.snapshot.template_digests) + expected_shapes = build_recipe_execution_credential(installed.snapshot).skill_input_shapes + assert set(credential["skill_input_shapes"]) == set(expected_shapes) + + +@pytest.mark.parametrize("tool_ctx_ready_recipe", _RECIPE_FIXTURE_PARAMETERS, indirect=True) +async def test_missing_digest_rejection_names_step_and_call( + tool_ctx_ready_recipe, git_checkout: Path +) -> None: + ready = tool_ctx_ready_recipe + installed = get_recipe_execution(ready.tool_ctx) + assert installed is not None + + for step_name in installed.snapshot.template_digests: + response = await _run_attested_call( + ready, git_checkout, step_name, ready.credential["execution_id"], "" + ) + _assert_digest_recovery( + response, + step_name, + ready.receipt["initialization_id"], + "recipe_execution_attestation_missing", + ) + + +@pytest.mark.parametrize("tool_ctx_ready_recipe", _RECIPE_FIXTURE_PARAMETERS, indirect=True) +async def test_wrong_digest_rejection_names_step_and_call( + tool_ctx_ready_recipe, git_checkout: Path +) -> None: + ready = tool_ctx_ready_recipe + installed = get_recipe_execution(ready.tool_ctx) + assert installed is not None + wrong_digest = "sha256:" + "0" * 64 + + for step_name in installed.snapshot.template_digests: + response = await _run_attested_call( + ready, + git_checkout, + step_name, + ready.credential["execution_id"], + wrong_digest, + ) + _assert_digest_recovery( + response, + step_name, + ready.receipt["initialization_id"], + "invocation_template_digest_mismatch", + ) + + +@pytest.mark.parametrize("tool_ctx_ready_recipe", _RECIPE_FIXTURE_PARAMETERS, indirect=True) +async def test_following_the_recovery_yields_an_admitted_digest( + tool_ctx_ready_recipe, git_checkout: Path +) -> None: + from autoskillit.server.tools.tools_recipe import complete_recipe_initialization + + ready = tool_ctx_ready_recipe + installed = get_recipe_execution(ready.tool_ctx) + assert installed is not None + + for step_name in installed.snapshot.template_digests: + missing = await _run_attested_call( + ready, git_checkout, step_name, ready.credential["execution_id"], "" + ) + recovery_id = _assert_digest_recovery( + missing, + step_name, + ready.receipt["initialization_id"], + "recipe_execution_attestation_missing", + ) + receipt = json.loads(await complete_recipe_initialization(initialization_id=recovery_id)) + credential = receipt["recipe_execution"] + admitted = await _run_attested_call( + ready, + git_checkout, + step_name, + credential["execution_id"], + credential["invocation_template_digests"][step_name], + ) + serialized = json.dumps(admitted) + for code in ( + "recipe_execution_attestation_missing", + "recipe_execution_id_mismatch", + "recipe_execution_step_mismatch", + "invocation_template_digest_mismatch", + ): + assert code not in serialized + + +@pytest.mark.parametrize("tool_ctx_ready_recipe", _RECIPE_FIXTURE_PARAMETERS, indirect=True) +async def test_wrong_execution_id_rejection_names_the_call( + tool_ctx_ready_recipe, git_checkout: Path +) -> None: + ready = tool_ctx_ready_recipe + installed = get_recipe_execution(ready.tool_ctx) + assert installed is not None + actual_id = ready.credential["execution_id"] + wrong_id = "0" * 32 if actual_id != "0" * 32 else "1" * 32 + + for step_name in installed.snapshot.template_digests: + response = await _run_attested_call( + ready, + git_checkout, + step_name, + wrong_id, + "sha256:" + "0" * 64, + ) + _assert_digest_recovery( + response, + step_name, + ready.receipt["initialization_id"], + "recipe_execution_id_mismatch", + ) diff --git a/tests/server/test_recipe_initialization.py b/tests/server/test_recipe_initialization.py index 38a3d4a93c..123ea29a81 100644 --- a/tests/server/test_recipe_initialization.py +++ b/tests/server/test_recipe_initialization.py @@ -15,12 +15,9 @@ RECIPE_ARTIFACT_SCHEMA_VERSION, RECIPE_FLOW_SCHEMA_VERSION, FinalizedRecipeProjection, - FinalizedRecipeSegment, FinalizedRecipeStep, RecipeArtifactGeneration, RecipeBindingProjection, - RecipeExecutionCredential, - RecipeFlowEdge, RecipeFlowGeneration, ) from autoskillit.pipeline import ( @@ -175,50 +172,6 @@ def test_completion_is_server_owned_and_commits_ready_only_after_enforcement( assert parsed_initial["recipe_execution"] == parsed_replay["recipe_execution"] -def test_segmented_completion_credential_is_scoped_to_initial_bodies() -> None: - projection = FinalizedRecipeProjection( - binding_projection=RecipeBindingProjection({}), - ordered_step_names=("initial", "future"), - entrypoint="initial", - ordered_flow_edges=( - RecipeFlowEdge( - source="initial", - edge_type="success", - target="future", - condition=None, - result_field=None, - ), - ), - ordered_steps=( - FinalizedRecipeStep(name="initial"), - FinalizedRecipeStep(name="future"), - ), - ingredient_names=frozenset(), - delivery_segments=( - FinalizedRecipeSegment(name="S0", ordered_step_names=("initial",)), - FinalizedRecipeSegment(name="S1", ordered_step_names=("future",)), - ), - ) - credential = RecipeExecutionCredential( - execution_id="execution", - snapshot_digest=_hash("snapshot"), - invocation_template_digests={"initial": _hash("initial"), "future": _hash("future")}, - skill_input_shapes={ - "initial": {"keys": ["task"], "absence_values": {}}, - "future": {"keys": ["review_path"], "absence_values": {"review_path": ""}}, - }, - ) - - public_credential = recipe_initialization._public_completion_credential(credential, projection) - - assert public_credential.execution_id == credential.execution_id - assert public_credential.snapshot_digest == credential.snapshot_digest - assert public_credential.invocation_template_digests == {"initial": _hash("initial")} - assert public_credential.skill_input_shapes == { - "initial": {"keys": ["task"], "absence_values": {}} - } - - def test_install_rejects_initializing_recipe_without_completion_receipt( minimal_ctx, tmp_path, From df35a2fe408d30f878f31d6b390216d6707f11bb Mon Sep 17 00:00:00 2001 From: Trecek Date: Wed, 14 Oct 2026 11:27:16 -0700 Subject: [PATCH 02/46] fix: attach recipe checkpoint carriers from registry outcomes Centralize attachment and declared outcome readers, preserve response outputs with pull-only success carriers, and remove handler wiring. Recovery-only claim and publish success responses now omit carriers; update the measured delivery totals accordingly. --- docs/decisions/0004-recipe-redelivery.md | 3 +- src/autoskillit/core/__init__.pyi | 1 + .../core/_tool_registry_builders.py | 34 ++ .../core/types/recipe/_type_recipe_binding.py | 42 +++ src/autoskillit/server/_notify.py | 52 ++- src/autoskillit/server/recipe/AGENTS.md | 3 +- .../server/recipe/_recipe_segment_delivery.py | 207 ++++++++++- .../server/tools/_commit_outcome.py | 12 +- src/autoskillit/server/tools/tools_ci.py | 59 +-- .../server/tools/tools_ci_merge_queue.py | 87 +---- .../server/tools/tools_ci_watch.py | 65 +--- src/autoskillit/server/tools/tools_clone.py | 111 ++---- .../server/tools/tools_execution/__init__.py | 4 - .../server/tools/tools_execution/_run_cmd.py | 32 +- .../tools/tools_execution/_run_python.py | 72 +--- src/autoskillit/server/tools/tools_git.py | 101 +---- .../server/tools/tools_issue_composite.py | 28 +- .../server/tools/tools_issue_labels.py | 29 +- .../server/tools/tools_kitchen/__init__.py | 2 - .../tools/tools_pipeline_tracker/__init__.py | 5 - .../tools/tools_pipeline_tracker/_handlers.py | 13 +- .../server/tools/tools_plan_set.py | 8 +- src/autoskillit/server/tools/tools_pr_ops.py | 3 + .../server/tools/tools_workspace.py | 79 ++-- tests/arch/test_git_ref_qualification.py | 8 +- ...st_recipe_checkpoint_delivery_authority.py | 51 +++ .../core/test_grab_bag_split_completeness.py | 1 + tests/core/test_tool_def_delivery_outcome.py | 113 ++++++ tests/infra/test_schema_version_convention.py | 2 +- .../test_pipeline_step_completion_flow.py | 8 +- tests/server/_helpers.py | 2 + tests/server/_recipe_segment_test_helpers.py | 124 ++++++- ...test_complete_run_skill_result_delivery.py | 80 ++++ .../server/test_delivery_e2e_verification.py | 11 +- ...st_implementation_plan_bind_verify_walk.py | 202 ++++++++++ .../test_recipe_checkpoint_conformance.py | 349 ++++++++++++++++++ tests/server/test_recipe_segment_delivery.py | 165 ++++++++- tests/server/test_tools_bootstrap.py | 42 ++- tests/server/test_tools_ci_enqueue.py | 29 +- tests/server/test_tools_ci_merge_state.py | 31 +- tests/server/test_tools_ci_wait_for_ci.py | 48 ++- .../test_tools_ci_wait_for_merge_queue.py | 30 +- tests/server/test_tools_clone.py | 41 +- tests/server/test_tools_execution_facade.py | 1 - tests/server/test_tools_git.py | 29 +- tests/server/test_tools_git_branch.py | 33 +- tests/server/test_tools_issue_release.py | 12 +- tests/server/test_tools_plan_set.py | 70 +++- tests/server/test_tools_pr_ops.py | 51 ++- tests/server/test_tools_run_cmd.py | 22 +- tests/server/test_tools_run_python.py | 31 +- tests/server/test_tools_test_check.py | 5 +- tests/server/test_tools_workspace.py | 29 +- 53 files changed, 2002 insertions(+), 670 deletions(-) create mode 100644 tests/arch/test_recipe_checkpoint_delivery_authority.py create mode 100644 tests/core/test_tool_def_delivery_outcome.py create mode 100644 tests/server/test_complete_run_skill_result_delivery.py create mode 100644 tests/server/test_implementation_plan_bind_verify_walk.py create mode 100644 tests/server/test_recipe_checkpoint_conformance.py diff --git a/docs/decisions/0004-recipe-redelivery.md b/docs/decisions/0004-recipe-redelivery.md index 4fcc7455b4..6a29095df0 100644 --- a/docs/decisions/0004-recipe-redelivery.md +++ b/docs/decisions/0004-recipe-redelivery.md @@ -60,7 +60,8 @@ snapshot, while the public `open_kitchen` response contains only the segment ove initial bodies. Each mapped checkpoint tool verifies READY against that exact durable generation before its effect and then returns `recipe_segment` with either authenticated future bodies or an ordered manual `recipe_pull` closure. No raw YAML, LRU lookup, runtime -skip carrier, or separately persisted projection participates in recovery. +skip carrier, or separately persisted projection participates in recovery. Carriers are +attached by the shared response wrapper from each tool's registry outcome declaration. Acknowledged `run_skill` receipts are replayable only for the same kitchen, request session, and receipt identity. Their tracker effect remains one-shot and cached, so a diff --git a/src/autoskillit/core/__init__.pyi b/src/autoskillit/core/__init__.pyi index 0f79e5b1f2..2fc711925d 100644 --- a/src/autoskillit/core/__init__.pyi +++ b/src/autoskillit/core/__init__.pyi @@ -1205,6 +1205,7 @@ from .types import RecipeDeliveryBudgetError as RecipeDeliveryBudgetError from .types import RecipeDeliveryDecision as RecipeDeliveryDecision from .types import RecipeDeliveryEvidenceDef as RecipeDeliveryEvidenceDef from .types import RecipeDeliveryMode as RecipeDeliveryMode +from .types import RecipeDeliveryOutcomeDef as RecipeDeliveryOutcomeDef from .types import RecipeDeliveryRequest as RecipeDeliveryRequest from .types import RecipeDeliverySurfaceDef as RecipeDeliverySurfaceDef from .types import RecipeExecutionCredential as RecipeExecutionCredential diff --git a/src/autoskillit/core/_tool_registry_builders.py b/src/autoskillit/core/_tool_registry_builders.py index 883a3cdd35..51cb3cd3f9 100644 --- a/src/autoskillit/core/_tool_registry_builders.py +++ b/src/autoskillit/core/_tool_registry_builders.py @@ -6,6 +6,7 @@ from types import MappingProxyType from .types.recipe._type_recipe_binding import ( + RecipeDeliveryOutcomeDef, ToolDef, ToolInitializationOperation, ToolParamDef, @@ -139,6 +140,38 @@ def _initialization_operation(name: str) -> ToolInitializationOperation: ) +_RECIPE_DELIVERY_OUTCOMES: Mapping[str, RecipeDeliveryOutcomeDef] = MappingProxyType( + { + "check_repo_merge_state": RecipeDeliveryOutcomeDef(), + "enqueue_pr": RecipeDeliveryOutcomeDef(success_field="success"), + "wait_for_merge_queue": RecipeDeliveryOutcomeDef(success_field="success"), + "wait_for_ci": RecipeDeliveryOutcomeDef( + success_field="conclusion", + success_value="success", + ), + "push_to_remote": RecipeDeliveryOutcomeDef(success_field="success"), + "bootstrap_clone": RecipeDeliveryOutcomeDef(), + "run_python": RecipeDeliveryOutcomeDef(success_field="success"), + "complete_run_skill_result": RecipeDeliveryOutcomeDef( + success_field="run_skill_success", + step_name_field="step_name", + ), + "merge_worktree": RecipeDeliveryOutcomeDef(success_field="merge_succeeded"), + "check_pr_mergeable": RecipeDeliveryOutcomeDef(), + "test_check": RecipeDeliveryOutcomeDef(success_field="passed"), + "bind_plan_set": RecipeDeliveryOutcomeDef(success_field="success"), + "run_cmd": RecipeDeliveryOutcomeDef(success_field="success"), + "create_and_publish_branch": RecipeDeliveryOutcomeDef(), + "claim_and_resolve_issue": RecipeDeliveryOutcomeDef(success_field="claimed"), + "release_issue": RecipeDeliveryOutcomeDef(success_field="success"), + "verify_review_receipt": RecipeDeliveryOutcomeDef( + success_field="reviews_posted", + success_value="true", + ), + } +) + + def _tool( name: str, params: tuple[str, ...] = (), @@ -171,6 +204,7 @@ def _tool( initialization_operation=_initialization_operation(name), automatic_recipe_delivery=automatic_recipe_delivery, recovery_recipe_delivery=recovery_recipe_delivery, + recipe_delivery_outcome=_RECIPE_DELIVERY_OUTCOMES.get(name), ) diff --git a/src/autoskillit/core/types/recipe/_type_recipe_binding.py b/src/autoskillit/core/types/recipe/_type_recipe_binding.py index 438b2029c2..74f2cd9ebb 100644 --- a/src/autoskillit/core/types/recipe/_type_recipe_binding.py +++ b/src/autoskillit/core/types/recipe/_type_recipe_binding.py @@ -35,6 +35,7 @@ "RecipeFlowEdge", "RUNTIME_ADMISSION_BY_ROLE", "RuntimeAdmission", + "RecipeDeliveryOutcomeDef", "ToolDef", "ToolInitializationOperation", "ToolParamDef", @@ -161,6 +162,23 @@ class ToolParamDef: role: ToolParamRole = field(kw_only=True) +@dataclass(frozen=True, slots=True) +class RecipeDeliveryOutcomeDef: + """How the response wrapper reads a delivery tool's outcome from its response.""" + + success_field: str | None = None + success_value: bool | str = True + step_name_field: str | None = None + + def succeeded(self, payload: Mapping[str, object]) -> bool: + if self.success_field is None: + return "error" not in payload and payload.get("success") is not False + actual = payload.get(self.success_field) + if isinstance(self.success_value, bool): + return actual is self.success_value + return actual == self.success_value + + @dataclass(frozen=True, slots=True) class ToolDef: """Static definition of one registered MCP tool.""" @@ -172,6 +190,7 @@ class ToolDef: automatic_recipe_delivery: bool = False # Failure edges may carry a recovery segment; this can be enabled independently. recovery_recipe_delivery: bool = False + recipe_delivery_outcome: RecipeDeliveryOutcomeDef | None = None def __post_init__(self) -> None: if not isinstance(self.initialization_operation, ToolInitializationOperation): @@ -195,6 +214,29 @@ def __post_init__(self) -> None: ) if structured and self.name != "run_skill": raise ValueError("Only run_skill may declare structured skill_inputs") + self._validate_recipe_delivery_outcome() + + def _validate_recipe_delivery_outcome(self) -> None: + delivery_enabled = self.automatic_recipe_delivery or self.recovery_recipe_delivery + if not delivery_enabled: + if self.recipe_delivery_outcome is not None: + raise ValueError( + f"ToolDef {self.name!r} declares a recipe delivery outcome " + "without a delivery flag" + ) + return + if self.recipe_delivery_outcome is None: + raise ValueError( + f"ToolDef {self.name!r} enables recipe delivery without an outcome declaration" + ) + if ( + self.recipe_delivery_outcome.step_name_field is None + and "step_name" not in self.param_set + ): + raise ValueError( + f"ToolDef {self.name!r} uses argument-sourced recipe delivery " + "without a step_name parameter" + ) @property def param_names(self) -> tuple[str, ...]: diff --git a/src/autoskillit/server/_notify.py b/src/autoskillit/server/_notify.py index 3515ef0970..ff7f3cb2a1 100644 --- a/src/autoskillit/server/_notify.py +++ b/src/autoskillit/server/_notify.py @@ -39,6 +39,10 @@ complete_section_response, ) from autoskillit.server.recipe._recipe_section_pagination import resolve_recipe_section_bound_bytes +from autoskillit.server.recipe._recipe_segment_delivery import ( + attach_checkpoint, + prepare_checkpoint, +) from autoskillit.server.response._response_budget import ( bounded_response_budget_failure, enforce_response_budget, @@ -64,6 +68,31 @@ def _get_ctx_or_none() -> ToolContext | None: return _ctx_none_fn() +async def _invoke_handler( + fn: Callable[..., Awaitable[Any]], + args: tuple[Any, ...], + kwargs: dict[str, Any], + tool_name: str, +) -> Any: + try: + return await fn(*args, **kwargs) + except Exception as exc: + result = json.dumps( + { + "success": False, + "error": f"{type(exc).__name__}: {exc}", + "exit_code": -1, + "subtype": "tool_exception", + "user_visible_message": ( + f"An internal error occurred in {tool_name}: " + f"{type(exc).__name__}. Run 'autoskillit doctor' or reinstall." + ), + } + ) + logger.exception("track_response_size_handler_failed", tool_name=tool_name) + return result + + def _resolve_backend_capabilities(ctx: ToolContext | None) -> BackendCapabilities | None: """Extract the resolved backend's capabilities from ``ctx``, if any.""" backend = getattr(ctx, "backend", None) if ctx is not None else None @@ -126,22 +155,12 @@ async def get_token_summary(...) -> str: def decorator(fn: Callable[..., Awaitable[Any]]) -> Callable[..., Awaitable[Any]]: @functools.wraps(fn) async def wrapper(*args: Any, **kwargs: Any) -> Any: - try: - result = await fn(*args, **kwargs) - except Exception as exc: - result = json.dumps( - { - "success": False, - "error": f"{type(exc).__name__}: {exc}", - "exit_code": -1, - "subtype": "tool_exception", - "user_visible_message": ( - f"An internal error occurred in {tool_name}: " - f"{type(exc).__name__}. Run 'autoskillit doctor' or reinstall." - ), - } - ) - logger.exception("track_response_size_handler_failed", tool_name=tool_name) + ctx = _get_ctx_or_none() + prepared, early = prepare_checkpoint(ctx, fn, args, kwargs) + result = ( + early if early is not None else await _invoke_handler(fn, args, kwargs, tool_name) + ) + result = attach_checkpoint(ctx, fn, result, prepared) finalized = ( result if isinstance( @@ -159,7 +178,6 @@ async def wrapper(*args: Any, **kwargs: Any) -> Any: response_value = finalized.rendered else: response_value = result - ctx = _get_ctx_or_none() try: response_str = ( response_value diff --git a/src/autoskillit/server/recipe/AGENTS.md b/src/autoskillit/server/recipe/AGENTS.md index b82ee7330c..3074d053f3 100644 --- a/src/autoskillit/server/recipe/AGENTS.md +++ b/src/autoskillit/server/recipe/AGENTS.md @@ -9,7 +9,8 @@ Compiled-recipe generation and its persisted artifacts are owned by `_recipe_generation` and `_recipe_artifact` respectively. Transactional finalization lives in the `_recipe_delivery/` sub-package, with its own decision-support helpers in `_recipe_delivery_helpers` and its segmented -startup/checkpoint carriers in `_recipe_segment_delivery`. Server-owned +startup/checkpoint carriers in `_recipe_segment_delivery`, which also owns checkpoint +attachment through `track_response_size`. Server-owned execution and initialization commits are split across `_recipe_execution` and `_recipe_initialization`. Section pagination is split between the page-fitting engine (`_recipe_section_pagination`, `_recipe_section_planning`) diff --git a/src/autoskillit/server/recipe/_recipe_segment_delivery.py b/src/autoskillit/server/recipe/_recipe_segment_delivery.py index 0dd4f797a3..7e7777a143 100644 --- a/src/autoskillit/server/recipe/_recipe_segment_delivery.py +++ b/src/autoskillit/server/recipe/_recipe_segment_delivery.py @@ -3,7 +3,9 @@ from __future__ import annotations import hashlib +import inspect import json +from collections.abc import Callable from dataclasses import dataclass from typing import TYPE_CHECKING, Any @@ -11,9 +13,12 @@ RECIPE_EXECUTION_CREDENTIAL_WIRE_KEY, FinalizedRecipeProjection, RecipeArtifactGeneration, + RecipeDeliveryOutcomeDef, RecipeExecutionSnapshot, RecipeFlowEdge, + ToolDef, build_recipe_execution_credential, + get_logger, get_tool_def, load_yaml, ) @@ -25,10 +30,13 @@ extract_recipe_step_bodies, load_recipe_artifact, ) +from autoskillit.server.tools._types import deny_envelope if TYPE_CHECKING: from autoskillit.pipeline import ToolContext +logger = get_logger(__name__) + RECIPE_SEGMENT_MAX_BYTES = 10_000 _SEGMENTED_STARTUP_SURFACES = frozenset({"open_kitchen", "open_kitchen_deferred_recall"}) _SEGMENTED_STARTUP_EXCLUDED_FIELDS = frozenset( @@ -63,7 +71,8 @@ class PreparedRecipeSegmentDelivery: """Pre-effect carriers for one mapped checkpoint source.""" step_name: str - success_carrier: dict[str, Any] + success_carrier: dict[str, Any] | None + success_pull_carrier: dict[str, Any] | None recovery_carrier: dict[str, Any] @@ -399,6 +408,7 @@ def _checkpoint_carrier( success: bool, target_index: int | None = None, recovery_target: str | None = None, + pull_only: bool = False, ) -> dict[str, Any]: projection = ready.finalized_projection target_indices = ( @@ -408,9 +418,13 @@ def _checkpoint_carrier( ) generation = ready.artifact_generation if success: - body_indices = [ - index for index in target_indices if index < len(projection.delivery_segments) - 1 - ] + body_indices = ( + [] + if pull_only + else [ + index for index in target_indices if index < len(projection.delivery_segments) - 1 + ] + ) while True: target_segments = tuple(projection.delivery_segments[index] for index in body_indices) target_steps = tuple( @@ -483,6 +497,77 @@ def _checkpoint_carrier( ) +def _recovery_only_success_carrier( + projection: FinalizedRecipeProjection, + step_name: str, + recovery_carrier: dict[str, Any], +) -> dict[str, Any] | None: + """Reuse recovery delivery only when a recovery-only tool has a success crossing.""" + tool_name = _delivery_tool_name(projection, step_name) + definition = get_tool_def(tool_name) if tool_name is not None else None + if definition is None or definition.automatic_recipe_delivery: + return None + segment_indices = _segment_index(projection) + source_index = segment_indices.get(step_name) + if source_index is None: + return None + crosses = any( + edge.source == step_name + and edge.edge_type == "success" + and segment_indices.get(edge.target, -1) > source_index + for edge in projection.ordered_flow_edges + ) + return recovery_carrier if crosses else None + + +def _prepare_checkpoint_carriers( + persisted: dict[str, Any], + *, + ready: ReadyRecipe, + step_name: str, + tool_name: str, + definition: ToolDef | None, +) -> tuple[dict[str, Any] | None, dict[str, Any] | None, dict[str, Any]]: + projection = ready.finalized_projection + recovery_carrier = _checkpoint_carrier( + persisted, + ready=ready, + step_name=step_name, + tool_name=tool_name, + success=False, + ) + success_carrier: dict[str, Any] | None + success_pull_carrier: dict[str, Any] | None + if definition is not None and definition.automatic_recipe_delivery: + success_carrier = _checkpoint_carrier( + persisted, + ready=ready, + step_name=step_name, + tool_name=tool_name, + success=True, + ) + success_pull_carrier = _checkpoint_carrier( + persisted, + ready=ready, + step_name=step_name, + tool_name=tool_name, + success=True, + pull_only=True, + ) + else: + success_carrier = _recovery_only_success_carrier( + projection, + step_name, + recovery_carrier, + ) + success_pull_carrier = success_carrier + if success_carrier is not None: + build_post_effect_segment_failure(success_carrier, tool_name=tool_name) + if recovery_carrier is not success_carrier: + build_post_effect_segment_failure(recovery_carrier, tool_name=tool_name) + return success_carrier, success_pull_carrier, recovery_carrier + + def prepare_recipe_segment_delivery( tool_ctx: ToolContext, step_name: str | None, @@ -528,25 +613,18 @@ def prepare_recipe_segment_delivery( raise RecipeSegmentDeliveryError("READY compile identity differs from artifact") validate_segment_delivery_projection(state.finalized_projection) tool_name = _delivery_tool_name(state.finalized_projection, step_name) or "unknown" - success_carrier = _checkpoint_carrier( - persisted, - ready=state, - step_name=step_name, - tool_name=tool_name, - success=True, - ) - recovery_carrier = _checkpoint_carrier( + definition = get_tool_def(tool_name) + success_carrier, success_pull_carrier, recovery_carrier = _prepare_checkpoint_carriers( persisted, ready=state, step_name=step_name, tool_name=tool_name, - success=False, + definition=definition, ) - build_post_effect_segment_failure(success_carrier, tool_name=tool_name) - build_post_effect_segment_failure(recovery_carrier, tool_name=tool_name) return PreparedRecipeSegmentDelivery( step_name=step_name, success_carrier=success_carrier, + success_pull_carrier=success_pull_carrier, recovery_carrier=recovery_carrier, ) @@ -560,7 +638,104 @@ def attach_recipe_segment( """Return a fresh result mapping with the selected pre-rendered carrier.""" if prepared is None: return result + carrier = prepared.success_carrier if success else prepared.recovery_carrier + if carrier is None: + return result + if ( + success + and len(_serialized_bytes({**result, "recipe_segment": carrier})) + >= RECIPE_SEGMENT_MAX_BYTES + ): + carrier = prepared.success_pull_carrier + if carrier is None: + return result return { **result, - "recipe_segment": (prepared.success_carrier if success else prepared.recovery_carrier), + "recipe_segment": carrier, } + + +def prepare_checkpoint( + tool_ctx: ToolContext | None, + fn: Callable[..., Any], + args: tuple[Any, ...], + kwargs: dict[str, Any], +) -> tuple[PreparedRecipeSegmentDelivery | None, str | None]: + """Prepare registry-declared delivery before an argument-sourced tool effect.""" + outcome = _recipe_delivery_outcome(fn) + if outcome is None or tool_ctx is None: + return None, None + if outcome.step_name_field is not None: + return None, None + try: + step_name = inspect.signature(fn).bind_partial(*args, **kwargs).arguments.get("step_name") + except TypeError: + return None, None + if not step_name: + return None, None + try: + return prepare_recipe_segment_delivery(tool_ctx, step_name), None + except Exception as exc: + logger.warning("recipe_segment_pre_effect_preparation_failed", exc_info=True) + return None, json.dumps({"success": False, "error": f"{type(exc).__name__}: {exc}"}) + + +def _recipe_delivery_outcome( + fn: Callable[..., Any], +) -> RecipeDeliveryOutcomeDef | None: + definition = get_tool_def(fn.__name__) + if definition is None or not ( + definition.automatic_recipe_delivery or definition.recovery_recipe_delivery + ): + return None + return definition.recipe_delivery_outcome + + +def _prepare_result_sourced_checkpoint( + tool_ctx: ToolContext | None, + fn: Callable[..., Any], + step_name: str, +) -> tuple[PreparedRecipeSegmentDelivery | None, str | None]: + if tool_ctx is None: + return None, None + try: + return prepare_recipe_segment_delivery(tool_ctx, step_name), None + except Exception as exc: + logger.warning("recipe_segment_post_effect_preparation_failed", exc_info=True) + response = deny_envelope( + f"{fn.__name__}: recipe segment delivery failed: {type(exc).__name__}: {exc}", + stage="recipe_segment_delivery", + retriable=True, + ) + return None, json.dumps(response, ensure_ascii=False, separators=(",", ":")) + + +def attach_checkpoint( + tool_ctx: ToolContext | None, + fn: Callable[..., Any], + result: Any, + prepared: PreparedRecipeSegmentDelivery | None, +) -> Any: + """Attach the registry-selected carrier to a delivery tool's JSON response.""" + outcome = _recipe_delivery_outcome(fn) + if outcome is None or not isinstance(result, str): + return result + try: + payload = json.loads(result) + except json.JSONDecodeError: + return result + if not isinstance(payload, dict): + return result + if outcome.step_name_field is not None: + step_name = payload.get(outcome.step_name_field) + if not step_name: + return result + prepared, early = _prepare_result_sourced_checkpoint(tool_ctx, fn, step_name) + if early is not None: + return early + if prepared is None: + return result + attached = attach_recipe_segment(payload, prepared, success=outcome.succeeded(payload)) + if attached is payload: + return result + return json.dumps(attached, ensure_ascii=False, separators=(",", ":")) diff --git a/src/autoskillit/server/tools/_commit_outcome.py b/src/autoskillit/server/tools/_commit_outcome.py index 36c673332b..110b838d85 100644 --- a/src/autoskillit/server/tools/_commit_outcome.py +++ b/src/autoskillit/server/tools/_commit_outcome.py @@ -12,10 +12,6 @@ WorkspaceOutcomeRecord, get_logger, ) -from autoskillit.server.recipe._recipe_segment_delivery import ( - PreparedRecipeSegmentDelivery, - attach_recipe_segment, -) if TYPE_CHECKING: from autoskillit.pipeline import ToolContext @@ -31,15 +27,13 @@ def _finish_commit_response( failure_class: CommitFailureClass | None, tool_ctx: ToolContext, workspace: str, - prepared_segment: PreparedRecipeSegmentDelivery | None, ) -> str: - """Attach a recipe segment and persist the authoritative commit outcome.""" + """Persist the authoritative commit outcome and render its response.""" envelope = dict(response) if failure_class is not None: envelope["failure_class"] = failure_class.value commit_sha = envelope.get("commit_sha") succeeded = envelope.get("success") is True - wire_envelope = attach_recipe_segment(envelope, prepared_segment, success=succeeded) try: tool_ctx.workspace_outcome_ledger.record( WorkspaceOutcomeRecord( @@ -60,5 +54,5 @@ def _finish_commit_response( } if isinstance(commit_sha, str) and commit_sha: ledger_failure["commit_sha"] = commit_sha - return json.dumps(attach_recipe_segment(ledger_failure, prepared_segment, success=False)) - return json.dumps(wire_envelope) + return json.dumps(ledger_failure) + return json.dumps(envelope) diff --git a/src/autoskillit/server/tools/tools_ci.py b/src/autoskillit/server/tools/tools_ci.py index 79e8a8893f..f4520b43ac 100644 --- a/src/autoskillit/server/tools/tools_ci.py +++ b/src/autoskillit/server/tools/tools_ci.py @@ -17,11 +17,6 @@ from autoskillit.server._subprocess import _run_subprocess from autoskillit.server.lifecycle._guards import _require_enabled from autoskillit.server.lifecycle._session_scope import SCOPE_ANY, session_scoped -from autoskillit.server.recipe._recipe_segment_delivery import ( - PreparedRecipeSegmentDelivery, - attach_recipe_segment, - prepare_recipe_segment_delivery, -) from autoskillit.server.tools._cancellation_shield import _cancellation_shield logger = get_logger(__name__) @@ -143,7 +138,7 @@ async def check_repo_merge_state( branch: Target branch name to check merge queue state for. cwd: Working directory for git remote resolution. remote_url: Full GitHub remote URL; parsed to owner/repo if provided. - step_name: Step name for timing telemetry. + step_name: Exact recipe step key; selects recipe-segment delivery and timing. base_branch: PR target branch; enables pull_request trigger detection. When empty, pull_request triggers are not evaluated (conservative). @@ -161,30 +156,24 @@ async def check_repo_merge_state( if (gate := _require_enabled()) is not None: return gate try: - prepared_segment: PreparedRecipeSegmentDelivery | None = None from autoskillit.server import ( # circular-break _get_ctx, ) # circular-break: server-internal circular dependency tool_ctx = _get_ctx() - prepared_segment = prepare_recipe_segment_delivery(tool_ctx, step_name) _start = time.monotonic() try: resolved_repo = await resolve_repo_from_remote(cwd, hint=remote_url or None) if not resolved_repo or "/" not in resolved_repo: return json.dumps( - attach_recipe_segment( - { - "error": f"Could not resolve owner/repo from cwd={cwd!r}", - "queue_available": False, - "merge_group_trigger": False, - "auto_merge_available": False, - "ci_event": None, - "ci_applicable": False, - }, - prepared_segment, - success=False, - ) + { + "error": f"Could not resolve owner/repo from cwd={cwd!r}", + "queue_available": False, + "merge_group_trigger": False, + "auto_merge_available": False, + "ci_event": None, + "ci_applicable": False, + } ) owner, repo = resolved_repo.split("/", 1) resolved_token = ( @@ -199,13 +188,7 @@ async def check_repo_merge_state( token=resolved_token, base_branch=base_branch or None, ) - return json.dumps( - attach_recipe_segment( - state, - prepared_segment, - success="error" not in state, - ) - ) + return json.dumps(state) except Exception as exc: logger.error("autoskillit.check_repo_merge_state failed", exc_info=True) envelope: dict[str, object] = { @@ -218,23 +201,19 @@ async def check_repo_merge_state( } if isinstance(exc, httpx.HTTPStatusError): envelope["http_status"] = exc.response.status_code - return json.dumps(attach_recipe_segment(envelope, prepared_segment, success=False)) + return json.dumps(envelope) finally: if step_name: tool_ctx.timing_log.record(step_name, time.monotonic() - _start) except Exception as exc: logger.error("check_repo_merge_state unhandled exception", exc_info=True) return json.dumps( - attach_recipe_segment( - { - "error": f"{type(exc).__name__}: {exc}", - "queue_available": False, - "merge_group_trigger": False, - "auto_merge_available": False, - "ci_event": None, - "ci_applicable": False, - }, - prepared_segment, - success=False, - ) + { + "error": f"{type(exc).__name__}: {exc}", + "queue_available": False, + "merge_group_trigger": False, + "auto_merge_available": False, + "ci_event": None, + "ci_applicable": False, + } ) diff --git a/src/autoskillit/server/tools/tools_ci_merge_queue.py b/src/autoskillit/server/tools/tools_ci_merge_queue.py index 05622735dc..2616cc927f 100644 --- a/src/autoskillit/server/tools/tools_ci_merge_queue.py +++ b/src/autoskillit/server/tools/tools_ci_merge_queue.py @@ -15,11 +15,6 @@ from autoskillit.server._notify import _notify, track_response_size from autoskillit.server.lifecycle._guards import _require_enabled from autoskillit.server.lifecycle._session_scope import SCOPE_ANY, session_scoped -from autoskillit.server.recipe._recipe_segment_delivery import ( - PreparedRecipeSegmentDelivery, - attach_recipe_segment, - prepare_recipe_segment_delivery, -) from autoskillit.server.tools._cancellation_shield import _cancellation_shield logger = get_logger(__name__) @@ -124,7 +119,7 @@ async def enqueue_pr( auto_merge_available: Whether the repository allows auto-merge. repo: Optional "owner/name" string. Inferred from git remote if empty. remote_url: Full GitHub remote URL. Parsed to owner/repo before inference. - step_name: Optional YAML step key for wall-clock timing accumulation. + step_name: Exact recipe step key; selects recipe-segment delivery and timing. Returns: JSON: {"success": bool, "pr_number": int, "enrollment_method": str} on success, @@ -135,7 +130,6 @@ async def enqueue_pr( if (gate := _require_enabled()) is not None: return gate try: - prepared_segment: PreparedRecipeSegmentDelivery | None = None with structlog.contextvars.bound_contextvars( tool="enqueue_pr", pr_number=pr_number, target_branch=target_branch ): @@ -152,18 +146,13 @@ async def enqueue_pr( ) # circular-break: server-internal circular dependency tool_ctx = _get_ctx() - prepared_segment = prepare_recipe_segment_delivery(tool_ctx, step_name) if tool_ctx.merge_queue_watcher is None: return json.dumps( - attach_recipe_segment( - { - "success": False, - "error": "merge_queue_watcher not configured (missing GITHUB_TOKEN?)", - }, - prepared_segment, - success=False, - ) + { + "success": False, + "error": "merge_queue_watcher not configured (missing GITHUB_TOKEN?)", + } ) resolved_repo = await resolve_repo_from_remote(cwd, hint=remote_url or repo or None) @@ -177,34 +166,16 @@ async def enqueue_pr( cwd=cwd, auto_merge_available=auto_merge_available, ) - return json.dumps( - attach_recipe_segment( - result, - prepared_segment, - success=result.get("success") is True, - ) - ) + return json.dumps(result) except Exception as exc: logger.error("enqueue_pr watcher error", exc_info=True) - return json.dumps( - attach_recipe_segment( - {"success": False, "error": f"{type(exc).__name__}: {exc}"}, - prepared_segment, - success=False, - ) - ) + return json.dumps({"success": False, "error": f"{type(exc).__name__}: {exc}"}) finally: if step_name: tool_ctx.timing_log.record(step_name, time.monotonic() - _start) except Exception as exc: logger.error("enqueue_pr unhandled exception", exc_info=True) - return json.dumps( - attach_recipe_segment( - {"success": False, "error": f"{type(exc).__name__}: {exc}"}, - prepared_segment, - success=False, - ) - ) + return json.dumps({"success": False, "error": f"{type(exc).__name__}: {exc}"}) @mcp.tool(tags={"autoskillit", "kitchen", "ci"}, annotations={"readOnlyHint": True}) @@ -253,7 +224,7 @@ async def wait_for_merge_queue( confirmation window) before returning pr_state="timeout" (default 5). auto_merge_available: Whether the repository allows auto-merge. When False, stall recovery uses enqueuePullRequest instead of toggle (default True). - step_name: Optional YAML step key for wall-clock timing accumulation. + step_name: Exact recipe step key; selects recipe-segment delivery and timing. Returns: JSON: { @@ -281,7 +252,6 @@ async def wait_for_merge_queue( if (gate := _require_enabled()) is not None: return gate try: - prepared_segment: PreparedRecipeSegmentDelivery | None = None with structlog.contextvars.bound_contextvars( tool="wait_for_merge_queue", pr_number=pr_number, target_branch=target_branch ): @@ -298,21 +268,16 @@ async def wait_for_merge_queue( ) # circular-break: server-internal circular dependency tool_ctx = _get_ctx() - prepared_segment = prepare_recipe_segment_delivery(tool_ctx, step_name) if tool_ctx.merge_queue_watcher is None: if step_name: tool_ctx.timing_log.record(step_name, 0.0) return json.dumps( - attach_recipe_segment( - { - "success": False, - "pr_state": "error", - "reason": "merge_queue_watcher not configured (missing GITHUB_TOKEN?)", - }, - prepared_segment, - success=False, - ) + { + "success": False, + "pr_state": "error", + "reason": "merge_queue_watcher not configured (missing GITHUB_TOKEN?)", + } ) resolved_repo = await resolve_repo_from_remote(cwd, hint=remote_url or repo or None) @@ -335,31 +300,13 @@ async def wait_for_merge_queue( merge_group_drop_backoff=merge_group_drop_backoff, progress_callback=lambda msg: _notify(ctx, "info", msg, __name__), ) - return json.dumps( - attach_recipe_segment( - result, - prepared_segment, - success=result.get("success") is True, - ) - ) + return json.dumps(result) except Exception as exc: logger.error("wait_for_merge_queue ci_watcher error", exc_info=True) - return json.dumps( - attach_recipe_segment( - {"success": False, "error": f"{type(exc).__name__}: {exc}"}, - prepared_segment, - success=False, - ) - ) + return json.dumps({"success": False, "error": f"{type(exc).__name__}: {exc}"}) finally: if step_name: tool_ctx.timing_log.record(step_name, time.monotonic() - _start) except Exception as exc: logger.error("wait_for_merge_queue unhandled exception", exc_info=True) - return json.dumps( - attach_recipe_segment( - {"success": False, "error": f"{type(exc).__name__}: {exc}"}, - prepared_segment, - success=False, - ) - ) + return json.dumps({"success": False, "error": f"{type(exc).__name__}: {exc}"}) diff --git a/src/autoskillit/server/tools/tools_ci_watch.py b/src/autoskillit/server/tools/tools_ci_watch.py index c7348b8f18..d73cab4b14 100644 --- a/src/autoskillit/server/tools/tools_ci_watch.py +++ b/src/autoskillit/server/tools/tools_ci_watch.py @@ -18,11 +18,6 @@ from autoskillit.server._subprocess import _run_subprocess from autoskillit.server.lifecycle._guards import _require_enabled from autoskillit.server.lifecycle._session_scope import SCOPE_ANY, session_scoped -from autoskillit.server.recipe._recipe_segment_delivery import ( - PreparedRecipeSegmentDelivery, - attach_recipe_segment, - prepare_recipe_segment_delivery, -) from autoskillit.server.tools._cancellation_shield import _cancellation_shield logger = get_logger(__name__) @@ -113,7 +108,7 @@ async def wait_for_ci( If omitted, falls back to the project-level ci.event config. timeout_seconds: Maximum time to wait (default 300s). cwd: Working directory for git operations. - step_name: Optional YAML step key for wall-clock timing accumulation. + step_name: Exact recipe step key; selects recipe-segment delivery and timing. auto_trigger: When True and ci_watcher returns "no_runs", performs an active self-healing sequence: checks PR mergeability, creates an empty commit, and force-pushes the branch to re-trigger webhook delivery, @@ -131,7 +126,6 @@ async def wait_for_ci( if (gate := _require_enabled()) is not None: return gate try: - prepared_segment: PreparedRecipeSegmentDelivery | None = None _start = time.monotonic() _timing_ctx = None event = _coerce_none_string(event, tool="wait_for_ci") @@ -153,21 +147,16 @@ async def wait_for_ci( tool_ctx = _get_ctx() _timing_ctx = tool_ctx - prepared_segment = prepare_recipe_segment_delivery(tool_ctx, step_name) if tool_ctx.ci_watcher is None: if step_name: tool_ctx.timing_log.record(step_name, time.monotonic() - _start) return json.dumps( - attach_recipe_segment( - { - "run_id": None, - "conclusion": "error", - "failed_jobs": [], - "error": "CI watcher not configured", - }, - prepared_segment, - success=False, - ) + { + "run_id": None, + "conclusion": "error", + "failed_jobs": [], + "error": "CI watcher not configured", + } ) head_sha = await _infer_ci_head_sha(head_sha, branch=branch, cwd=cwd) @@ -229,26 +218,16 @@ async def wait_for_ci( extra={"run_id": result.get("run_id")}, ) - return json.dumps( - attach_recipe_segment( - result, - prepared_segment, - success=conclusion == "success", - ) - ) + return json.dumps(result) except Exception as exc: logger.error("wait_for_ci failed", exc_info=True) return json.dumps( - attach_recipe_segment( - { - "run_id": None, - "conclusion": "error", - "failed_jobs": [], - "error": f"{type(exc).__name__}: {exc}", - }, - prepared_segment, - success=False, - ) + { + "run_id": None, + "conclusion": "error", + "failed_jobs": [], + "error": f"{type(exc).__name__}: {exc}", + } ) finally: if step_name: @@ -258,16 +237,12 @@ async def wait_for_ci( if step_name and _timing_ctx is not None: _timing_ctx.timing_log.record(step_name, time.monotonic() - _start) return json.dumps( - attach_recipe_segment( - { - "run_id": None, - "conclusion": "error", - "failed_jobs": [], - "error": f"{type(exc).__name__}: {exc}", - }, - prepared_segment, - success=False, - ) + { + "run_id": None, + "conclusion": "error", + "failed_jobs": [], + "error": f"{type(exc).__name__}: {exc}", + } ) diff --git a/src/autoskillit/server/tools/tools_clone.py b/src/autoskillit/server/tools/tools_clone.py index 3ef1d053d9..4beba38d39 100644 --- a/src/autoskillit/server/tools/tools_clone.py +++ b/src/autoskillit/server/tools/tools_clone.py @@ -23,11 +23,6 @@ from autoskillit.server._notify import _notify, track_response_size from autoskillit.server.lifecycle._guards import _require_enabled, _require_no_infrastructure_fault from autoskillit.server.lifecycle._session_scope import SCOPE_ANY, session_scoped -from autoskillit.server.recipe._recipe_segment_delivery import ( - PreparedRecipeSegmentDelivery, - attach_recipe_segment, - prepare_recipe_segment_delivery, -) from autoskillit.server.tools._cancellation_shield import _cancellation_shield logger = get_logger(__name__) @@ -224,7 +219,7 @@ async def push_to_remote( source_dir via git remote get-url (read-only). source_dir is never modified — clone isolation is preserved. - Returns {"success": "true", "stderr": str} on success. + Returns {"success": bool, "stderr": str} on success. Returns {"error": str, "stderr": str, "error_type": str} on failure (triggers on_failure routing). @@ -233,14 +228,13 @@ async def push_to_remote( branch: Branch name to push (e.g. "develop"). source_dir: Source repo path (read-only URL lookup when remote_url is empty). remote_url: Pre-resolved upstream remote URL. When provided, source_dir is skipped. - step_name: Optional YAML step key for wall-clock timing accumulation. + step_name: Exact recipe step key; selects recipe-segment delivery and timing. Never raises. """ if (gate := _require_enabled()) is not None: return gate try: - prepared_segment: PreparedRecipeSegmentDelivery | None = None with structlog.contextvars.bound_contextvars(tool="push_to_remote", clone_path=clone_path): logger.info( "push_to_remote", @@ -267,16 +261,9 @@ async def push_to_remote( ) # circular-break: server-internal circular dependency tool_ctx = _get_ctx() - prepared_segment = prepare_recipe_segment_delivery(tool_ctx, step_name) clone_mgr = tool_ctx.clone_mgr if clone_mgr is None: - return json.dumps( - attach_recipe_segment( - {"error": "Clone manager not configured", "stderr": ""}, - prepared_segment, - success=False, - ) - ) + return json.dumps({"error": "Clone manager not configured", "stderr": ""}) _start = time.monotonic() try: @@ -303,36 +290,22 @@ async def push_to_remote( }, ) return json.dumps( - attach_recipe_segment( - { - "success": False, - "error": "push failed", - "stderr": result.get("stderr", ""), - "error_type": result.get("error_type", ""), - }, - prepared_segment, - success=False, - ) + { + "success": False, + "error": "push failed", + "stderr": result.get("stderr", ""), + "error_type": result.get("error_type", ""), + } ) - return json.dumps( - attach_recipe_segment( - result, - prepared_segment, - success=bool(result.get("success")), - ) - ) + return json.dumps(result) finally: if step_name: tool_ctx.timing_log.record(step_name, time.monotonic() - _start) except Exception as exc: logger.error("push_to_remote unhandled exception", exc_info=True) return json.dumps( - attach_recipe_segment( - {"success": False, "error": f"{type(exc).__name__}: {exc}", "stderr": ""}, - prepared_segment, - success=False, - ) + {"success": False, "error": f"{type(exc).__name__}: {exc}", "stderr": ""} ) @@ -575,14 +548,13 @@ async def bootstrap_clone( strategy: On uncommitted changes: "" = return warning, "proceed" = clone remote committed state, "clone_local" = copytree. remote_url: Override remote URL for clone's origin. - step_name: Optional YAML step key for wall-clock timing accumulation. + step_name: Exact recipe step key; selects recipe-segment delivery and timing. Never raises. """ if (gate := _require_enabled()) is not None: return gate try: - prepared_segment: PreparedRecipeSegmentDelivery | None = None with structlog.contextvars.bound_contextvars( tool="bootstrap_clone", source_dir=source_dir, base_branch=base_branch ): @@ -611,15 +583,8 @@ async def bootstrap_clone( ) # circular-break: server-internal circular dependency tool_ctx = _get_ctx() - prepared_segment = prepare_recipe_segment_delivery(tool_ctx, step_name) if tool_ctx.clone_mgr is None: - return json.dumps( - attach_recipe_segment( - {"error": "Clone manager not configured"}, - prepared_segment, - success=False, - ) - ) + return json.dumps({"error": "Clone manager not configured"}) _total_start = time.monotonic() @@ -641,22 +606,14 @@ async def bootstrap_clone( "autoskillit.bootstrap_clone", extra={"reason": str(exc)}, ) - return json.dumps( - attach_recipe_segment({"error": str(exc)}, prepared_segment, success=False) - ) + return json.dumps({"error": str(exc)}) finally: clone_ms = int((time.monotonic() - _clone_start) * 1000) gate_error = _require_clone_success(clone_result, source_dir) if gate_error is not None: gate_payload = json.loads(gate_error) - return json.dumps( - attach_recipe_segment( - gate_payload, - prepared_segment, - success=False, - ) - ) + return json.dumps(gate_payload) success = cast("CloneSuccessResult", clone_result) clone_path: str = success["clone_path"] @@ -697,13 +654,7 @@ async def bootstrap_clone( clone_path=clone_path, exc_info=True, ) - return json.dumps( - attach_recipe_segment( - {"error": base_resolution.failure_reason}, - prepared_segment, - success=False, - ) - ) + return json.dumps({"error": base_resolution.failure_reason}) base_sha = base_resolution.resolved.sha @@ -711,27 +662,17 @@ async def bootstrap_clone( tool_ctx.timing_log.record(step_name, time.monotonic() - _total_start) return json.dumps( - attach_recipe_segment( - { - "work_dir": clone_path, - "remote_url": resolved_remote_url, - "base_sha": base_sha, - "merge_target": base_branch, - "timings": { - "clone_ms": clone_ms, - "rev_parse_ms": rev_parse_ms, - }, + { + "work_dir": clone_path, + "remote_url": resolved_remote_url, + "base_sha": base_sha, + "merge_target": base_branch, + "timings": { + "clone_ms": clone_ms, + "rev_parse_ms": rev_parse_ms, }, - prepared_segment, - success=True, - ) + } ) except Exception as exc: logger.error("bootstrap_clone unhandled exception", exc_info=True) - return json.dumps( - attach_recipe_segment( - {"error": f"{type(exc).__name__}: {exc}"}, - prepared_segment, - success=False, - ) - ) + return json.dumps({"error": f"{type(exc).__name__}: {exc}"}) diff --git a/src/autoskillit/server/tools/tools_execution/__init__.py b/src/autoskillit/server/tools/tools_execution/__init__.py index e783f1af36..8d283ef574 100644 --- a/src/autoskillit/server/tools/tools_execution/__init__.py +++ b/src/autoskillit/server/tools/tools_execution/__init__.py @@ -58,9 +58,6 @@ from autoskillit.server.recipe._recipe_execution import ( complete_audit_finalization_effects as _complete_audit_finalization_effects, ) -from autoskillit.server.recipe._recipe_segment_delivery import ( - prepare_recipe_segment_delivery, -) from autoskillit.server.tools._backend_compat import ( _check_backend_compat, ) @@ -182,7 +179,6 @@ "is_feature_enabled", "logger", "mark_step_skipped", - "prepare_recipe_segment_delivery", "progress_heartbeat", "read_fixed_batch_result", "read_overlay", diff --git a/src/autoskillit/server/tools/tools_execution/_run_cmd.py b/src/autoskillit/server/tools/tools_execution/_run_cmd.py index 46c214c624..1124078cae 100644 --- a/src/autoskillit/server/tools/tools_execution/_run_cmd.py +++ b/src/autoskillit/server/tools/tools_execution/_run_cmd.py @@ -27,7 +27,6 @@ SCOPE_ORCHESTRATOR_OR_HIGHER, session_scoped, ) -from autoskillit.server.recipe._recipe_segment_delivery import attach_recipe_segment from autoskillit.server.tools import tools_execution as _te_pkg from autoskillit.server.tools._cancellation_shield import _cancellation_shield from autoskillit.server.tools._execution_helpers import ( @@ -42,7 +41,6 @@ if TYPE_CHECKING: from autoskillit.pipeline import ToolContext - from autoskillit.server.recipe._recipe_segment_delivery import PreparedRecipeSegmentDelivery logger = get_logger(__name__) @@ -140,7 +138,7 @@ async def run_cmd( cmd: The full command to run (e.g. "make build"). cwd: Working directory for the command. timeout: Max seconds before killing the process (default 600). - step_name: Optional YAML step key for wall-clock timing accumulation. + step_name: Exact recipe step key; selects recipe-segment delivery and timing. Never raises. """ @@ -153,12 +151,10 @@ async def run_cmd( ) is not None: return gate try: - prepared_segment: PreparedRecipeSegmentDelivery | None = None with structlog.contextvars.bound_contextvars(tool="run_cmd", cwd=cwd): from autoskillit.server import _get_ctx # circular-break tool_ctx = _get_ctx() - prepared_segment = _te_pkg.prepare_recipe_segment_delivery(tool_ctx, step_name) configured_commands = tuple(tool_ctx.config.test_check.effective_commands) if contains_test_gate_command(cmd, configured_commands) and not ( _trusted_smoke_gate_provenance(tool_ctx, step_name) @@ -201,13 +197,7 @@ async def run_cmd( stderr="", capture_error=str(exc), ) - return json.dumps( - attach_recipe_segment( - result, - prepared_segment, - success=False, - ) - ) + return json.dumps(result) if not result.get("success"): await _te_pkg._notify( @@ -219,21 +209,17 @@ async def run_cmd( ) if result.get("success") is True: return json.dumps(result) - return json.dumps(attach_recipe_segment(result, prepared_segment, success=False)) + return json.dumps(result) finally: if step_name: tool_ctx.timing_log.record(step_name, time.monotonic() - _start) except Exception as exc: logger.error("run_cmd unhandled exception", exc_info=True) return json.dumps( - attach_recipe_segment( - { - "success": False, - "exit_code": -1, - "stdout": "", - "stderr": f"{type(exc).__name__}: {exc}", - }, - prepared_segment, - success=False, - ) + { + "success": False, + "exit_code": -1, + "stdout": "", + "stderr": f"{type(exc).__name__}: {exc}", + } ) diff --git a/src/autoskillit/server/tools/tools_execution/_run_python.py b/src/autoskillit/server/tools/tools_execution/_run_python.py index 4e3b408b3e..0a391146d7 100644 --- a/src/autoskillit/server/tools/tools_execution/_run_python.py +++ b/src/autoskillit/server/tools/tools_execution/_run_python.py @@ -4,7 +4,6 @@ import json from pathlib import Path -from typing import TYPE_CHECKING import structlog from fastmcp import Context @@ -21,7 +20,6 @@ SCOPE_ORCHESTRATOR_OR_HIGHER, session_scoped, ) -from autoskillit.server.recipe._recipe_segment_delivery import attach_recipe_segment from autoskillit.server.tools import tools_execution as _te_pkg from autoskillit.server.tools._cancellation_shield import _cancellation_shield from autoskillit.server.tools._execution_helpers import ( @@ -31,39 +29,9 @@ validate_path_arg_anchoring, ) -if TYPE_CHECKING: - from autoskillit.pipeline import ToolContext - from autoskillit.server.recipe._recipe_segment_delivery import PreparedRecipeSegmentDelivery - logger = get_logger(__name__) -def _finalize_run_python_response( - tool_ctx: ToolContext, - result: dict[str, object], - *, - work_dir: str, - prepared_segment: PreparedRecipeSegmentDelivery | None, -) -> str: - """Shape an execution result and attach its prepared recipe segment.""" - rendered = _te_pkg.shape_execution_response( - tool_ctx, - result, - tool_name="run_python", - work_dir=work_dir, - ) - shaped = json.loads(rendered) - if not isinstance(shaped, dict): - raise TypeError("run_python response must be a JSON object") - return json.dumps( - attach_recipe_segment( - shaped, - prepared_segment, - success=shaped.get("success") is True, - ) - ) - - @mcp.tool(tags={"autoskillit", "kitchen", "kitchen-core"}, annotations={"readOnlyHint": True}) @session_scoped(SCOPE_ORCHESTRATOR_OR_HIGHER) @_cancellation_shield(result_type="run_python") @@ -92,7 +60,7 @@ async def run_python( timeout: Max seconds before aborting the call (default 30). work_dir: When set, relative path-like args (output_dir, etc.) are anchored to this directory before the callable is invoked. - step_name: Optional YAML step key for progressive recipe delivery. + step_name: Exact recipe step key; selects recipe-segment delivery and timing. Never raises. """ @@ -101,11 +69,9 @@ async def run_python( if (gate := _check_recipe_read_prohibition(callable_name=callable)) is not None: return gate try: - prepared_segment: PreparedRecipeSegmentDelivery | None = None from autoskillit.server import _get_ctx # circular-break tool_ctx = _get_ctx() - prepared_segment = _te_pkg.prepare_recipe_segment_delivery(tool_ctx, step_name) with structlog.contextvars.bound_contextvars(tool="run_python"): logger.info("run_python", callable=callable, timeout=timeout) await _te_pkg._notify( @@ -117,13 +83,7 @@ async def run_python( ) anchor_err = validate_path_arg_anchoring(args, work_dir) if anchor_err: - return json.dumps( - attach_recipe_segment( - {"success": False, "error": anchor_err}, - prepared_segment, - success=False, - ) - ) + return json.dumps({"success": False, "error": anchor_err}) promoted = maybe_promote_work_dir(args, work_dir) if promoted != work_dir: logger.warning( @@ -134,14 +94,10 @@ async def run_python( work_dir = promoted if work_dir and not Path(work_dir).is_absolute(): return json.dumps( - attach_recipe_segment( - { - "success": False, - "error": f"run_python: work_dir must be absolute, got {work_dir!r}", - }, - prepared_segment, - success=False, - ) + { + "success": False, + "error": f"run_python: work_dir must be absolute, got {work_dir!r}", + } ) resolved_args = args if work_dir: @@ -160,18 +116,16 @@ async def run_python( "autoskillit.run_python", extra={"callable": callable}, ) - return _finalize_run_python_response( + rendered = _te_pkg.shape_execution_response( tool_ctx, result, + tool_name="run_python", work_dir=work_dir, - prepared_segment=prepared_segment, ) + shaped = json.loads(rendered) + if not isinstance(shaped, dict): + raise TypeError("run_python response must be a JSON object") + return json.dumps(shaped) except Exception as exc: logger.error("run_python unhandled exception", exc_info=True) - return json.dumps( - attach_recipe_segment( - {"success": False, "error": f"{type(exc).__name__}: {exc}"}, - prepared_segment, - success=False, - ) - ) + return json.dumps({"success": False, "error": f"{type(exc).__name__}: {exc}"}) diff --git a/src/autoskillit/server/tools/tools_git.py b/src/autoskillit/server/tools/tools_git.py index 28815e54e8..fade8a2808 100644 --- a/src/autoskillit/server/tools/tools_git.py +++ b/src/autoskillit/server/tools/tools_git.py @@ -18,11 +18,6 @@ from autoskillit.server._subprocess import _run_subprocess from autoskillit.server.lifecycle._guards import _require_enabled from autoskillit.server.lifecycle._session_scope import SCOPE_ANY, session_scoped -from autoskillit.server.recipe._recipe_segment_delivery import ( - PreparedRecipeSegmentDelivery, - attach_recipe_segment, - prepare_recipe_segment_delivery, -) from autoskillit.server.tools._cancellation_shield import _cancellation_shield _BRANCH_DATE_FORMAT = "%Y%m%d" @@ -41,15 +36,6 @@ def _compute_branch_name(issue_slug: str, run_name: str, issue_number: str) -> s logger = get_logger(__name__) -def _render_segment_result( - result: dict[str, Any], - prepared: PreparedRecipeSegmentDelivery | None, - *, - success: bool, -) -> str: - return json.dumps(attach_recipe_segment(result, prepared, success=success)) - - @mcp.tool(tags={"autoskillit", "kitchen", "kitchen-core"}, annotations={"readOnlyHint": True}) @session_scoped(SCOPE_ANY) @_cancellation_shield() @@ -70,14 +56,13 @@ async def merge_worktree( Args: worktree_path: Absolute path to the git worktree. base_branch: Branch to merge into (e.g. "develop"). - step_name: Optional YAML step key for wall-clock timing accumulation. + step_name: Exact recipe step key; selects recipe-segment delivery and timing. Never raises. """ if (gate := _require_enabled()) is not None: return gate try: - prepared_segment: PreparedRecipeSegmentDelivery | None = None with structlog.contextvars.bound_contextvars(tool="merge_worktree", cwd=worktree_path): logger.info("merge_worktree", path=worktree_path, base=base_branch) await _notify( @@ -93,7 +78,6 @@ async def merge_worktree( from autoskillit.server.git import perform_merge # circular-break tool_ctx = _get_ctx() - prepared_segment = prepare_recipe_segment_delivery(tool_ctx, step_name) runner = tool_ctx.runner assert runner is not None, "No subprocess runner configured" _start = time.monotonic() @@ -117,21 +101,13 @@ async def merge_worktree( extra={"reason": result["error"]}, ) - return _render_segment_result( - result, - prepared_segment, - success=result.get("merge_succeeded") is True, - ) + return json.dumps(result) finally: if step_name: tool_ctx.timing_log.record(step_name, time.monotonic() - _start) except Exception as exc: logger.error("merge_worktree unhandled exception", exc_info=True) - return _render_segment_result( - {"success": False, "error": f"{type(exc).__name__}: {exc}"}, - prepared_segment, - success=False, - ) + return json.dumps({"success": False, "error": f"{type(exc).__name__}: {exc}"}) @mcp.tool(tags={"autoskillit", "kitchen", "kitchen-core"}, annotations={"readOnlyHint": True}) @@ -412,14 +388,13 @@ async def check_pr_mergeable( pr_number: GitHub pull request number. cwd: Working directory for gh commands. repo: Repository as owner/repo. Passed as -R flag when provided. - step_name: Optional YAML step key for progressive recipe delivery. + step_name: Exact recipe step key; selects recipe-segment delivery and timing. Never raises. """ if (gate := _require_enabled()) is not None: return gate try: - prepared_segment: PreparedRecipeSegmentDelivery | None = None with structlog.contextvars.bound_contextvars(tool="check_pr_mergeable", cwd=cwd): logger.info("check_pr_mergeable", pr_number=pr_number, repo=repo) await _notify( @@ -430,15 +405,9 @@ async def check_pr_mergeable( extra={"repo": repo}, ) - from autoskillit.server import _get_ctx # circular-break - - prepared_segment = prepare_recipe_segment_delivery(_get_ctx(), step_name) - if not cwd or not os.path.isdir(cwd): - return _render_segment_result( - {"success": False, "error": f"cwd is not a valid directory: {cwd!r}"}, - prepared_segment, - success=False, + return json.dumps( + {"success": False, "error": f"cwd is not a valid directory: {cwd!r}"} ) cmd = ["gh", "pr", "view", str(pr_number), "--json", "mergeable,mergeStateStatus"] @@ -447,37 +416,25 @@ async def check_pr_mergeable( rc, stdout, stderr = await _run_subprocess(cmd, cwd=cwd, timeout=30) if rc != 0: - return _render_segment_result( - {"success": False, "error": stderr.strip() or "gh command failed"}, - prepared_segment, - success=False, + return json.dumps( + {"success": False, "error": stderr.strip() or "gh command failed"} ) try: data = json.loads(stdout) except json.JSONDecodeError: - return _render_segment_result( - {"success": False, "error": "Failed to parse gh output"}, - prepared_segment, - success=False, - ) + return json.dumps({"success": False, "error": "Failed to parse gh output"}) - return _render_segment_result( + return json.dumps( { "mergeable": data.get("mergeable") == "MERGEABLE", "merge_state_status": data.get("mergeStateStatus", ""), "mergeable_status": data.get("mergeable", ""), - }, - prepared_segment, - success=True, + } ) except Exception as exc: logger.error("check_pr_mergeable unhandled exception", exc_info=True) - return _render_segment_result( - {"success": False, "error": f"{type(exc).__name__}: {exc}"}, - prepared_segment, - success=False, - ) + return json.dumps({"success": False, "error": f"{type(exc).__name__}: {exc}"}) async def _resolve_and_create_branch( @@ -571,14 +528,13 @@ async def create_and_publish_branch( issue_number: GitHub issue number as a string. Empty string → date-based branch. work_dir: Absolute path to the clone directory. remote_url: Pre-resolved upstream remote URL for push isolation. - step_name: Optional YAML step key for wall-clock timing accumulation. + step_name: Exact recipe step key; selects recipe-segment delivery and timing. Never raises. """ if (gate := _require_enabled()) is not None: return gate try: - prepared_segment: PreparedRecipeSegmentDelivery | None = None with structlog.contextvars.bound_contextvars( tool="create_and_publish_branch", work_dir=work_dir, @@ -601,21 +557,12 @@ async def create_and_publish_branch( from autoskillit.server import _get_ctx # circular-break tool_ctx = _get_ctx() - prepared_segment = prepare_recipe_segment_delivery(tool_ctx, step_name) clone_mgr = tool_ctx.clone_mgr if clone_mgr is None: - return _render_segment_result( - {"error": "Clone manager not configured"}, - prepared_segment, - success=False, - ) + return json.dumps({"error": "Clone manager not configured"}) if not work_dir or not os.path.isdir(work_dir): - return _render_segment_result( - {"error": f"work_dir is not a valid directory: {work_dir!r}"}, - prepared_segment, - success=False, - ) + return json.dumps({"error": f"work_dir is not a valid directory: {work_dir!r}"}) _total_start = time.monotonic() @@ -631,7 +578,7 @@ async def create_and_publish_branch( branch_create_ms = int((time.monotonic() - _branch_start) * 1000) if "error" in branch_result: - return _render_segment_result( + return json.dumps( { "error": branch_result["error"], "merge_target": base_name, @@ -640,9 +587,7 @@ async def create_and_publish_branch( "branch_create_ms": branch_create_ms, "push_ms": 0, }, - }, - prepared_segment, - success=False, + } ) branch_name: str = branch_result["branch_name"] @@ -671,15 +616,13 @@ async def create_and_publish_branch( tool_ctx.timing_log.record(step_name, time.monotonic() - _total_start) if not push_result.get("success"): - return _render_segment_result( + return json.dumps( { "error": push_result.get("stderr", "push failed"), "error_type": push_result.get("error_type", ""), "merge_target": branch_name, "timings": timings, - }, - prepared_segment, - success=False, + } ) return json.dumps( @@ -691,8 +634,4 @@ async def create_and_publish_branch( ) except Exception as exc: logger.error("create_and_publish_branch unhandled exception", exc_info=True) - return _render_segment_result( - {"error": f"{type(exc).__name__}: {exc}"}, - prepared_segment, - success=False, - ) + return json.dumps({"error": f"{type(exc).__name__}: {exc}"}) diff --git a/src/autoskillit/server/tools/tools_issue_composite.py b/src/autoskillit/server/tools/tools_issue_composite.py index 8fdda6005b..26e75716b4 100644 --- a/src/autoskillit/server/tools/tools_issue_composite.py +++ b/src/autoskillit/server/tools/tools_issue_composite.py @@ -16,11 +16,6 @@ from autoskillit.server._notify import track_response_size from autoskillit.server.lifecycle._guards import _require_enabled from autoskillit.server.lifecycle._session_scope import SCOPE_ANY, session_scoped -from autoskillit.server.recipe._recipe_segment_delivery import ( - PreparedRecipeSegmentDelivery, - attach_recipe_segment, - prepare_recipe_segment_delivery, -) from autoskillit.server.tools._cancellation_shield import _cancellation_shield from autoskillit.server.tools._claim_helpers import ( _claim_fetched_issue, @@ -58,19 +53,13 @@ async def claim_and_resolve_issue( issue_url: Full GitHub issue URL or shorthand (owner/repo#42). label: Label name to apply. Defaults to github.in_progress_label from config. allow_reentry: When True and label already present, returns claimed=True. - step_name: Exact YAML step key used for recovery segment delivery. + step_name: Exact recipe step key; selects recipe-segment delivery and timing. Never raises. """ if (gate := _require_enabled()) is not None: return gate try: - prepared_segment: PreparedRecipeSegmentDelivery | None = None - - def _render(result: dict[str, Any]) -> str: - success = result.get("success") is True and result.get("claimed") is True - return json.dumps(attach_recipe_segment(result, prepared_segment, success=success)) - with structlog.contextvars.bound_contextvars( tool="claim_and_resolve_issue", issue_url=issue_url ): @@ -81,9 +70,8 @@ def _render(result: dict[str, Any]) -> str: ) # circular-break: server-internal circular dependency tool_ctx = _get_ctx() - prepared_segment = prepare_recipe_segment_delivery(tool_ctx, step_name) if tool_ctx.github_client is None: - return _render( + return json.dumps( {"success": False, "error": "GitHub token required for label management"} ) @@ -92,17 +80,17 @@ def _render(result: dict[str, Any]) -> str: try: owner, repo, issue_number = _parse_issue_ref(issue_url) except ValueError as exc: - return _render({"success": False, "error": str(exc)}) + return json.dumps({"success": False, "error": str(exc)}) if err := tool_ctx.config.github.check_label_allowed(effective_label): - return _render({"success": False, "error": err}) + return json.dumps({"success": False, "error": err}) _fetch_title_start = time.monotonic() title_result = await tool_ctx.github_client.fetch_title(issue_url) fetch_title_ms = int((time.monotonic() - _fetch_title_start) * 1000) if not title_result.get("success"): - return _render( + return json.dumps( {"success": False, "error": title_result.get("error", "fetch_title failed")} ) @@ -117,7 +105,7 @@ def _render(result: dict[str, Any]) -> str: ) if not fetch_result.get("success"): claim_ms = int((time.monotonic() - _claim_start) * 1000) - return _render( + return json.dumps( { "success": False, "error": fetch_result.get("error", "fetch_issue failed"), @@ -147,7 +135,7 @@ def _render(result: dict[str, Any]) -> str: claim_result["issue_title"] = issue_title claim_result["issue_slug"] = issue_slug claim_result["timings"] = {"fetch_title_ms": fetch_title_ms, "claim_ms": claim_ms} - return _render(dict(claim_result)) + return json.dumps(dict(claim_result)) except Exception as exc: logger.error("claim_and_resolve_issue unhandled exception", exc_info=True) - return _render({"success": False, "error": f"{type(exc).__name__}: {exc}"}) + return json.dumps({"success": False, "error": f"{type(exc).__name__}: {exc}"}) diff --git a/src/autoskillit/server/tools/tools_issue_labels.py b/src/autoskillit/server/tools/tools_issue_labels.py index 10726fb916..0dc6545c76 100644 --- a/src/autoskillit/server/tools/tools_issue_labels.py +++ b/src/autoskillit/server/tools/tools_issue_labels.py @@ -15,11 +15,6 @@ from autoskillit.server._notify import track_response_size from autoskillit.server.lifecycle._guards import _require_enabled, _require_no_infrastructure_fault from autoskillit.server.lifecycle._session_scope import SCOPE_ANY, session_scoped -from autoskillit.server.recipe._recipe_segment_delivery import ( - PreparedRecipeSegmentDelivery, - attach_recipe_segment, - prepare_recipe_segment_delivery, -) from autoskillit.server.tools._cancellation_shield import _cancellation_shield from autoskillit.server.tools._claim_helpers import ( _claim_fetched_issue, @@ -217,7 +212,7 @@ async def release_issue( target_branch: Branch the PR was merged into. When non-default, applies staged label. staged_label: Label name for staged state. Defaults to github.staged_label from config. fail_label: Label name for failure state. When provided, swaps in-progress for this label. - step_name: Exact YAML step key used for recovery segment delivery. + step_name: Exact recipe step key; selects recipe-segment delivery and timing. infrastructure_fault_override_reason: Required to proceed when the most recently completed step ended in an infrastructure fault. Absent by default so refusal is the default path, not an opt-in. @@ -233,11 +228,6 @@ async def release_issue( ) is not None: return fault_gate try: - prepared_segment: PreparedRecipeSegmentDelivery | None = None - - def _render(result: dict[str, Any]) -> str: - return json.dumps(attach_recipe_segment(result, prepared_segment, success=False)) - with structlog.contextvars.bound_contextvars(tool="release_issue", issue_url=issue_url): logger.info("release_issue", issue_url=issue_url) @@ -246,9 +236,8 @@ def _render(result: dict[str, Any]) -> str: ) # circular-break: server-internal circular dependency tool_ctx = _get_ctx() - prepared_segment = prepare_recipe_segment_delivery(tool_ctx, step_name) if tool_ctx.github_client is None: - return _render( + return json.dumps( {"success": False, "error": "GitHub token required for label management"} ) @@ -257,7 +246,7 @@ def _render(result: dict[str, Any]) -> str: try: owner, repo, issue_number = _parse_issue_ref(issue_url) except ValueError as exc: - return _render({"success": False, "error": str(exc)}) + return json.dumps({"success": False, "error": str(exc)}) # Determine if staging is needed promotion_target = tool_ctx.config.branching.promotion_target @@ -290,7 +279,7 @@ def _render(result: dict[str, Any]) -> str: swap_error_prefix="Failed to apply staged label", ) if release_error is not None: - return _render(release_error) + return json.dumps(release_error) staged = True elif fail_label is not None: release_error = await _apply_release_label( @@ -311,9 +300,9 @@ def _render(result: dict[str, Any]) -> str: swap_error_prefix="Failed to apply fail label", ) if release_error is not None: - return _render(release_error) + return json.dumps(release_error) - return _render( + return json.dumps( { "success": True, "issue_number": issue_number, @@ -335,7 +324,7 @@ def _render(result: dict[str, Any]) -> str: add_labels=[], ) if not swap_result.get("success"): - return _render( + return json.dumps( { "success": False, "issue_number": issue_number, @@ -346,7 +335,7 @@ def _render(result: dict[str, Any]) -> str: if close_issue == "true": await tool_ctx.github_client.close_issue(owner, repo, issue_number) - return _render( + return json.dumps( { "success": True, "issue_number": issue_number, @@ -357,4 +346,4 @@ def _render(result: dict[str, Any]) -> str: ) except Exception as exc: logger.error("release_issue unhandled exception", exc_info=True) - return _render({"success": False, "error": f"{type(exc).__name__}: {exc}"}) + return json.dumps({"success": False, "error": f"{type(exc).__name__}: {exc}"}) diff --git a/src/autoskillit/server/tools/tools_kitchen/__init__.py b/src/autoskillit/server/tools/tools_kitchen/__init__.py index dcc8f00e3e..a4561c453c 100644 --- a/src/autoskillit/server/tools/tools_kitchen/__init__.py +++ b/src/autoskillit/server/tools/tools_kitchen/__init__.py @@ -57,7 +57,6 @@ ) from autoskillit.server.recipe._recipe_delivery import finalize_recipe_delivery from autoskillit.server.recipe._recipe_execution import clear_recipe_execution -from autoskillit.server.recipe._recipe_segment_delivery import prepare_recipe_segment_delivery from autoskillit.server.tools._overlay_state import locked_overlay, update_overlay from autoskillit.server.tools._preflight import _check_dispatch_feasibility from autoskillit.server.tools._serve_helpers import project_orchestrator_guidance, serve_recipe @@ -209,7 +208,6 @@ def __getattr__(name: str): # pragma: no cover — late-binding shim "locked_overlay", "logger", "mcp", - "prepare_recipe_segment_delivery", "project_orchestrator_guidance", "reap_stale_dispatches_async", "release_kitchen_request", diff --git a/src/autoskillit/server/tools/tools_pipeline_tracker/__init__.py b/src/autoskillit/server/tools/tools_pipeline_tracker/__init__.py index 2e02d76f85..f2c5aa9926 100644 --- a/src/autoskillit/server/tools/tools_pipeline_tracker/__init__.py +++ b/src/autoskillit/server/tools/tools_pipeline_tracker/__init__.py @@ -2,11 +2,6 @@ from __future__ import annotations -# Re-exports for tests that patch symbols via the package facade. -from autoskillit.server.recipe._recipe_segment_delivery import ( - prepare_recipe_segment_delivery, # noqa: F401 -) - # Side-effect imports: register @mcp.tool() decorators on the FastMCP server. from autoskillit.server.tools.tools_pipeline_tracker import ( # noqa: F401 _handlers, diff --git a/src/autoskillit/server/tools/tools_pipeline_tracker/_handlers.py b/src/autoskillit/server/tools/tools_pipeline_tracker/_handlers.py index b8cf7eb9e2..47163881b3 100644 --- a/src/autoskillit/server/tools/tools_pipeline_tracker/_handlers.py +++ b/src/autoskillit/server/tools/tools_pipeline_tracker/_handlers.py @@ -37,7 +37,6 @@ SCOPE_ORCHESTRATOR_EXACT, session_scoped, ) -from autoskillit.server.recipe._recipe_segment_delivery import attach_recipe_segment from autoskillit.server.response._run_skill_completion import _request_session_identity from autoskillit.server.tools import ( tools_pipeline_tracker, @@ -676,23 +675,20 @@ async def complete_run_skill_result( request_session_id=request_session_id, ), ) - prepared_segment = tools_pipeline_tracker.prepare_recipe_segment_delivery( - tool_ctx, receipt.step_name - ) - tracker_result = authority.apply_acknowledged_tracker_outcome( receipt.receipt_id, kitchen_id=tool_ctx.kitchen_id, request_session_id=request_session_id, effect=lambda: _apply_receipt_tracker_outcome(tool_ctx, authority, receipt), ) - response = attach_recipe_segment( + return json.dumps( { "success": True, "receipt_id": receipt.receipt_id, "run_skill_success": receipt.success, "classification": receipt.classification, "session_id": receipt.child_session_id, + "step_name": receipt.step_name, "tracker": tracker_result, "tracker_repairable": bool( receipt.success @@ -700,11 +696,6 @@ async def complete_run_skill_result( and not tracker_result.get("success") ), }, - prepared_segment, - success=receipt.success, - ) - return json.dumps( - response, ensure_ascii=False, separators=(",", ":"), ) diff --git a/src/autoskillit/server/tools/tools_plan_set.py b/src/autoskillit/server/tools/tools_plan_set.py index b1046530cd..ada93a95ab 100644 --- a/src/autoskillit/server/tools/tools_plan_set.py +++ b/src/autoskillit/server/tools/tools_plan_set.py @@ -48,7 +48,13 @@ async def bind_plan_set( step_name: str = "", ctx: Context = CurrentContext(), ) -> str: - """Bind plan parts to one verified authority artifact. Never raises.""" + """Bind plan parts to one verified authority artifact. + + Args: + step_name: Exact recipe step key; selects recipe-segment delivery and timing. + + Never raises. + """ try: from autoskillit.server import _get_ctx # circular-break: tool registration owns the app from autoskillit.server.recipe._recipe_execution import ( # circular-break diff --git a/src/autoskillit/server/tools/tools_pr_ops.py b/src/autoskillit/server/tools/tools_pr_ops.py index 25df0ebf68..bad59fb714 100644 --- a/src/autoskillit/server/tools/tools_pr_ops.py +++ b/src/autoskillit/server/tools/tools_pr_ops.py @@ -256,6 +256,9 @@ async def verify_review_receipt( ) -> str: """Verify a review effect against the server-owned publication ledger. + Args: + step_name: Exact recipe step key; selects recipe-segment delivery and timing. + Never raises. """ diff --git a/src/autoskillit/server/tools/tools_workspace.py b/src/autoskillit/server/tools/tools_workspace.py index ce7e81925c..f93bd3601a 100644 --- a/src/autoskillit/server/tools/tools_workspace.py +++ b/src/autoskillit/server/tools/tools_workspace.py @@ -32,11 +32,6 @@ from autoskillit.server.git import detect_self_reverts, validate_commit_paths from autoskillit.server.lifecycle._guards import _require_enabled from autoskillit.server.lifecycle._session_scope import SCOPE_ANY, session_scoped -from autoskillit.server.recipe._recipe_segment_delivery import ( - PreparedRecipeSegmentDelivery, - attach_recipe_segment, - prepare_recipe_segment_delivery, -) from autoskillit.server.tools._cancellation_shield import _cancellation_shield from autoskillit.server.tools._commit_outcome import _finish_commit_response from autoskillit.server.tools._pre_commit_failure import ( @@ -221,13 +216,12 @@ async def test_check( Args: worktree_path: Path to the git worktree to run tests in. - step_name: Optional YAML step key for wall-clock timing accumulation. + step_name: Exact recipe step key; selects recipe-segment delivery and timing. Has no effect on test filtering — filter behavior is controlled by config.test_check.filter_mode. Never raises. """ - prepared_segment: PreparedRecipeSegmentDelivery | None = None try: resolved = os.path.realpath(worktree_path) with structlog.contextvars.bound_contextvars(tool="test_check", cwd=resolved): @@ -243,14 +237,8 @@ async def test_check( from autoskillit.server import _get_ctx # circular-break tool_ctx = _get_ctx() - prepared_segment = prepare_recipe_segment_delivery(tool_ctx, step_name) - def _finish(response: dict[str, object], *, success: bool) -> str: - wire_response = attach_recipe_segment( - response, - prepared_segment, - success=success, - ) + def _finish(response: dict[str, object]) -> str: try: tool_ctx.workspace_outcome_ledger.record( WorkspaceOutcomeRecord( @@ -266,23 +254,16 @@ def _finish(response: dict[str, object], *, success: bool) -> str: ) except (OSError, ValueError, RuntimeError) as exc: logger.error("test_check outcome recording failed", exc_info=True) - wire_response = attach_recipe_segment( - { - "passed": False, - "error": ( - f"workspace outcome recording failed: {type(exc).__name__}: {exc}" - ), - }, - prepared_segment, - success=False, - ) - return json.dumps(wire_response) + response = { + "passed": False, + "error": ( + f"workspace outcome recording failed: {type(exc).__name__}: {exc}" + ), + } + return json.dumps(response) if tool_ctx.tester is None: - return _finish( - {"passed": False, "error": "Test runner not configured"}, - success=False, - ) + return _finish({"passed": False, "error": "Test runner not configured"}) if not os.path.isdir(resolved): logger.warning("test_check path does not exist", path=resolved) @@ -291,8 +272,7 @@ def _finish(response: dict[str, object], *, success: bool) -> str: "passed": False, "error": f"Worktree path does not exist: {resolved}", "infrastructure_missing": True, - }, - success=False, + } ) infra_issue = tool_ctx.tester.check_infrastructure(Path(resolved)) if infra_issue is not None: @@ -302,8 +282,7 @@ def _finish(response: dict[str, object], *, success: bool) -> str: "passed": False, "error": f"Test infrastructure not found: {infra_issue}", "infrastructure_missing": True, - }, - success=False, + } ) _start = time.monotonic() @@ -333,27 +312,18 @@ def _finish(response: dict[str, object], *, success: bool) -> str: timed_out=timed_out, effective_passed=effective_passed, ) - return _finish(response, success=effective_passed) + return _finish(response) except WorktreeGateContention as exc: - return _finish({"passed": False, "error": str(exc)}, success=False) + return _finish({"passed": False, "error": str(exc)}) except Exception as exc: logger.error("test_check unhandled exception", exc_info=True) - return _finish( - {"passed": False, "error": f"{type(exc).__name__}: {exc}"}, - success=False, - ) + return _finish({"passed": False, "error": f"{type(exc).__name__}: {exc}"}) finally: if step_name: tool_ctx.timing_log.record(step_name, time.monotonic() - _start) except Exception as exc: logger.error("test_check unhandled exception", exc_info=True) - return json.dumps( - attach_recipe_segment( - {"passed": False, "error": f"{type(exc).__name__}: {exc}"}, - prepared_segment, - success=False, - ) - ) + return json.dumps({"passed": False, "error": f"{type(exc).__name__}: {exc}"}) @mcp.tool( @@ -386,7 +356,6 @@ async def commit_files( Never raises. """ - prepared_segment: PreparedRecipeSegmentDelivery | None = None try: resolved = os.path.realpath(cwd) with structlog.contextvars.bound_contextvars(tool="commit_files", cwd=resolved): @@ -395,7 +364,6 @@ async def commit_files( from autoskillit.server import _get_ctx # circular-break tool_ctx = _get_ctx() - prepared_segment = prepare_recipe_segment_delivery(tool_ctx, step_name) def _finish( response: dict[str, object], @@ -407,7 +375,6 @@ def _finish( failure_class=failure_class, tool_ctx=tool_ctx, workspace=resolved, - prepared_segment=prepared_segment, ) if not cwd or not os.path.isdir(resolved): @@ -462,15 +429,11 @@ def _finish( except Exception as exc: logger.error("commit_files unhandled exception", exc_info=True) return json.dumps( - attach_recipe_segment( - { - "success": False, - "error": f"{type(exc).__name__}: {exc}", - "failure_class": CommitFailureClass.UNHANDLED.value, - }, - prepared_segment, - success=False, - ) + { + "success": False, + "error": f"{type(exc).__name__}: {exc}", + "failure_class": CommitFailureClass.UNHANDLED.value, + } ) diff --git a/tests/arch/test_git_ref_qualification.py b/tests/arch/test_git_ref_qualification.py index 832065b95c..3e2bede1cb 100644 --- a/tests/arch/test_git_ref_qualification.py +++ b/tests/arch/test_git_ref_qualification.py @@ -163,9 +163,9 @@ ("server/git.py", "perform_merge", 565, "merge-base", "BARE"), ("server/git.py", "perform_merge", 617, "merge", "BARE"), ("core/install/install_detect.py", "_git", 64, "", "UNRESOLVABLE"), - ("server/tools/tools_ci_watch.py", "_auto_trigger_ci", 455, "push", "BARE"), - ("server/tools/tools_git.py", "classify_fix", 201, "fetch", "BARE"), - ("server/tools/tools_git.py", "classify_fix", 220, "diff", "BARE"), + ("server/tools/tools_ci_watch.py", "_auto_trigger_ci", 430, "push", "BARE"), + ("server/tools/tools_git.py", "classify_fix", 177, "fetch", "BARE"), + ("server/tools/tools_git.py", "classify_fix", 196, "diff", "BARE"), ( "server/tools/_pre_commit_transaction.py", "_run_pre_commit_transaction", @@ -178,7 +178,7 @@ ( "server/tools/tools_workspace.py", "_run_commit_transaction", - 68, + 63, "", "UNRESOLVABLE", ), diff --git a/tests/arch/test_recipe_checkpoint_delivery_authority.py b/tests/arch/test_recipe_checkpoint_delivery_authority.py new file mode 100644 index 0000000000..c06ecb882e --- /dev/null +++ b/tests/arch/test_recipe_checkpoint_delivery_authority.py @@ -0,0 +1,51 @@ +"""Keep recipe checkpoint carrier preparation and attachment in one module.""" + +from __future__ import annotations + +import ast + +import pytest + +from tests.arch._helpers import SRC_ROOT + +pytestmark = [pytest.mark.layer("arch"), pytest.mark.small] + +_DELIVERY_AUTHORITY_MODULE = SRC_ROOT / "server" / "recipe" / "_recipe_segment_delivery.py" +_DELIVERY_NAMES = frozenset({"prepare_recipe_segment_delivery", "attach_recipe_segment"}) + + +def _referenced_delivery_names(node: ast.AST) -> set[str]: + names: set[str] = set() + if isinstance(node, ast.Name): + names.add(node.id) + elif isinstance(node, ast.Attribute): + names.add(node.attr) + elif isinstance(node, ast.ImportFrom | ast.Import): + for alias in node.names: + names.add(alias.name.rsplit(".", maxsplit=1)[-1]) + if alias.asname is not None: + names.add(alias.asname) + return names + + +def _scan_for_alternate_delivery_authorities() -> list[str]: + violations: list[str] = [] + for source_path in sorted(SRC_ROOT.rglob("*.py")): + if source_path == _DELIVERY_AUTHORITY_MODULE: + continue + source = source_path.read_text(encoding="utf-8") + tree = ast.parse(source, filename=str(source_path)) + relative_path = source_path.relative_to(SRC_ROOT) + for node in ast.walk(tree): + matched = sorted(_referenced_delivery_names(node) & _DELIVERY_NAMES) + violations.extend(f"{relative_path}:{node.lineno} {name}" for name in matched) + return violations + + +def test_only_the_delivery_module_prepares_or_attaches_carriers() -> None: + violations = _scan_for_alternate_delivery_authorities() + + assert not violations, ( + "Recipe checkpoint carrier preparation and attachment must stay in " + "server/recipe/_recipe_segment_delivery.py:\n" + "\n".join(violations) + ) diff --git a/tests/core/test_grab_bag_split_completeness.py b/tests/core/test_grab_bag_split_completeness.py index 2d964db1a9..e0e13395c4 100644 --- a/tests/core/test_grab_bag_split_completeness.py +++ b/tests/core/test_grab_bag_split_completeness.py @@ -550,6 +550,7 @@ def test_no_unintended_new_test_files_under_tests_core() -> None: "test_skill_discovery_route_def.py", "test_skill_semantic_plan.py", "test_stamp_constants.py", + "test_tool_def_delivery_outcome.py", "test_tool_sequence_analysis.py", "test_type_helpers.py", "test_type_protocol_shards.py", diff --git a/tests/core/test_tool_def_delivery_outcome.py b/tests/core/test_tool_def_delivery_outcome.py new file mode 100644 index 0000000000..9848471ab4 --- /dev/null +++ b/tests/core/test_tool_def_delivery_outcome.py @@ -0,0 +1,113 @@ +"""Registry contracts for recipe checkpoint delivery outcomes.""" + +from __future__ import annotations + +import pytest + +from autoskillit.core import ( + RecipeDeliveryOutcomeDef, + ToolDef, + ToolInitializationOperation, + ToolParamDef, + ToolParamRole, +) +from autoskillit.core._tool_registry_builders import _RECIPE_DELIVERY_OUTCOMES +from autoskillit.core.tool_registry import TOOL_REGISTRY + +pytestmark = [pytest.mark.layer("core"), pytest.mark.small] + + +def _tool_def( + *, + automatic: bool = False, + recovery: bool = False, + outcome: RecipeDeliveryOutcomeDef | None = None, + params: tuple[str, ...] = (), +) -> ToolDef: + return ToolDef( + name="test_tool", + params=tuple(ToolParamDef(name, role=ToolParamRole.PROTOCOL) for name in params), + initialization_operation=ToolInitializationOperation.INSPECTION, + automatic_recipe_delivery=automatic, + recovery_recipe_delivery=recovery, + recipe_delivery_outcome=outcome, + ) + + +@pytest.mark.parametrize( + ("automatic", "recovery", "outcome", "params"), + [ + (True, False, None, ("step_name",)), + (False, False, RecipeDeliveryOutcomeDef(), ("step_name",)), + (True, False, RecipeDeliveryOutcomeDef(), ()), + ], + ids=("flag-needs-outcome", "outcome-needs-flag", "argument-step-needs-parameter"), +) +def test_tool_def_delivery_invariants( + automatic: bool, + recovery: bool, + outcome: RecipeDeliveryOutcomeDef | None, + params: tuple[str, ...], +) -> None: + with pytest.raises(ValueError): + _tool_def( + automatic=automatic, + recovery=recovery, + outcome=outcome, + params=params, + ) + + +@pytest.mark.parametrize( + ("outcome", "payload", "expected"), + [ + ( + RecipeDeliveryOutcomeDef(success_field="success", success_value=True), + {"success": True}, + True, + ), + ( + RecipeDeliveryOutcomeDef(success_field="success", success_value=True), + {"success": 1}, + False, + ), + ( + RecipeDeliveryOutcomeDef(success_field="reviews_posted", success_value="true"), + {"reviews_posted": "true"}, + True, + ), + ( + RecipeDeliveryOutcomeDef(success_field="reviews_posted", success_value="true"), + {"reviews_posted": True}, + False, + ), + (RecipeDeliveryOutcomeDef(), {}, True), + (RecipeDeliveryOutcomeDef(), {"success": True}, True), + (RecipeDeliveryOutcomeDef(), {"success": 1}, True), + (RecipeDeliveryOutcomeDef(), {"error": "failed"}, False), + (RecipeDeliveryOutcomeDef(), {"success": True, "error": "failed"}, False), + (RecipeDeliveryOutcomeDef(), {"success": False}, False), + ], + ids=( + "bool-identity-success", + "bool-does-not-equal-integer", + "string-equality-success", + "string-does-not-equal-bool", + "implicit-success-empty-payload", + "implicit-success-true-payload", + "implicit-success-non-false-value", + "implicit-failure-error-key", + "implicit-failure-error-key-with-true-success", + "implicit-failure-false-success", + ), +) +def test_outcome_reader_semantics( + outcome: RecipeDeliveryOutcomeDef, + payload: dict[str, object], + expected: bool, +) -> None: + assert outcome.succeeded(payload) is expected + + +def test_every_outcome_key_names_a_registered_tool() -> None: + assert set(_RECIPE_DELIVERY_OUTCOMES) <= set(TOOL_REGISTRY) diff --git a/tests/infra/test_schema_version_convention.py b/tests/infra/test_schema_version_convention.py index 4339591e8f..8ef2bc59b3 100644 --- a/tests/infra/test_schema_version_convention.py +++ b/tests/infra/test_schema_version_convention.py @@ -149,7 +149,7 @@ def _is_yaml_dump(node: ast.expr) -> bool: ("src/autoskillit/smoke_utils/_review_design.py", 248), ("src/autoskillit/smoke_utils/_review_design.py", 350), # tools_pr_ops.py — GitHubReviewReceipt.to_dict() already carries schema_version - ("src/autoskillit/server/tools/tools_pr_ops.py", 504), + ("src/autoskillit/server/tools/tools_pr_ops.py", 507), # smoke_utils/_git.py — partitions, merge queue data # Line 110 is a list-payload write site (dual membership: also in list_sites # in test_allowlist_includes_list_payloads_as_documented). The AST scanner catches diff --git a/tests/integration/test_pipeline_step_completion_flow.py b/tests/integration/test_pipeline_step_completion_flow.py index 485a49bc07..7c2b124230 100644 --- a/tests/integration/test_pipeline_step_completion_flow.py +++ b/tests/integration/test_pipeline_step_completion_flow.py @@ -16,6 +16,7 @@ from autoskillit.core.types import RetryReason from autoskillit.core.types.results._type_results import SkillResult from autoskillit.server import _notify +from autoskillit.server.recipe import _recipe_segment_delivery as delivery_module from autoskillit.server.recipe._recipe_segment_delivery import PreparedRecipeSegmentDelivery from autoskillit.server.response._run_skill_completion import RunSkillCompletionMiddleware from autoskillit.server.tools import tools_pipeline_tracker @@ -119,6 +120,7 @@ def _prepared_delivery() -> PreparedRecipeSegmentDelivery: return PreparedRecipeSegmentDelivery( step_name="rectify", success_carrier=_SUCCESS_CARRIER, + success_pull_carrier=_SUCCESS_CARRIER, recovery_carrier=_RECOVERY_CARRIER, ) @@ -321,7 +323,7 @@ def enforce_response_budget(*args, **kwargs): raise RuntimeError("response enforcement failed") return original_enforce_response_budget(*args, **kwargs) - monkeypatch.setattr(tools_pipeline_tracker, "prepare_recipe_segment_delivery", prepare) + monkeypatch.setattr(delivery_module, "prepare_recipe_segment_delivery", prepare) monkeypatch.setattr(_notify, "enforce_response_budget", enforce_response_budget) request_context = SimpleNamespace(session_id="request-session") @@ -367,7 +369,7 @@ async def test_failed_receipt_uses_bodyless_recovery_carrier( _FAIL_RESULT, ) monkeypatch.setattr( - tools_pipeline_tracker, + delivery_module, "prepare_recipe_segment_delivery", lambda *_args, **_kwargs: _prepared_delivery(), ) @@ -403,7 +405,7 @@ async def test_acknowledged_replay_rejects_other_bindings_and_unknown_receipts( _SUCCESS_RESULT, ) monkeypatch.setattr( - tools_pipeline_tracker, + delivery_module, "prepare_recipe_segment_delivery", lambda *_args, **_kwargs: _prepared_delivery(), ) diff --git a/tests/server/_helpers.py b/tests/server/_helpers.py index c2287843ab..dff2371741 100644 --- a/tests/server/_helpers.py +++ b/tests/server/_helpers.py @@ -405,6 +405,8 @@ def _ready_recipe_segment_step( prepared = prepare_recipe_segment_delivery(tool_ctx, source_step) assert prepared is not None for carrier in (prepared.success_carrier, prepared.recovery_carrier): + if carrier is None: + continue for body in carrier.get("bodies", ()): if body.get("step") != step_name: continue diff --git a/tests/server/_recipe_segment_test_helpers.py b/tests/server/_recipe_segment_test_helpers.py index f2ee364340..06a645b52c 100644 --- a/tests/server/_recipe_segment_test_helpers.py +++ b/tests/server/_recipe_segment_test_helpers.py @@ -1,27 +1,58 @@ -"""Shared stubs for handler-local recipe-segment branch tests.""" +"""Shared checkpoint stubs and checks for real handler responses.""" from __future__ import annotations -from types import ModuleType +import json +from pathlib import Path +from typing import Any, cast import pytest -from autoskillit.server.recipe._recipe_segment_delivery import PreparedRecipeSegmentDelivery +from autoskillit.core import get_tool_def +from autoskillit.pipeline import ReadyRecipe +from autoskillit.server.recipe import _recipe_segment_delivery as delivery_module +from autoskillit.server.recipe._recipe_segment_delivery import ( + RECIPE_SEGMENT_MAX_BYTES, + PreparedRecipeSegmentDelivery, + _delivery_tool_name, + _serialized_bytes, + _target_segment_indices, +) +from autoskillit.server.recipe._recipe_segment_delivery import ( + prepare_recipe_segment_delivery as _prepare_real_recipe_segment, +) +from autoskillit.server.response._response_budget import ( + RESPONSE_SPILL_METADATA_KEY, + enforce_response_budget, +) + +_DEFAULT_CARRIER = object() def install_prepared_recipe_segment( monkeypatch: pytest.MonkeyPatch, - module: ModuleType, *, step_name: str, + success_carrier: object = _DEFAULT_CARRIER, + success_pull_carrier: object = _DEFAULT_CARRIER, ) -> PreparedRecipeSegmentDelivery: - prepared = PreparedRecipeSegmentDelivery( - step_name=step_name, - success_carrier={ + success = ( + { "kind": "success", "source_step": step_name, "bodies": [{"step": "next", "body": "next:\n action: stop\n"}], - }, + } + if success_carrier is _DEFAULT_CARRIER + else cast(dict[str, Any] | None, success_carrier) + ) + prepared = PreparedRecipeSegmentDelivery( + step_name=step_name, + success_carrier=success, + success_pull_carrier=( + success + if success_pull_carrier is _DEFAULT_CARRIER + else cast(dict[str, Any] | None, success_pull_carrier) + ), recovery_carrier={ "kind": "recovery", "source_step": step_name, @@ -39,7 +70,7 @@ def _prepare(_tool_ctx, actual_step_name): assert actual_step_name == step_name return prepared - monkeypatch.setattr(module, "prepare_recipe_segment_delivery", _prepare) + monkeypatch.setattr(delivery_module, "prepare_recipe_segment_delivery", _prepare) return prepared @@ -58,3 +89,78 @@ def assert_recovery_recipe_segment(result: dict[str, object], *, step_name: str) }, } assert "bodies" not in segment + + +def assert_delivery_outcome(tool_name: str, result: dict[str, Any], *, expected: bool) -> None: + definition = get_tool_def(tool_name) + assert definition is not None + outcome = definition.recipe_delivery_outcome + assert outcome is not None + assert outcome.succeeded(result) is expected, result + + +def _real_success_delivery(ready, tool_name: str) -> PreparedRecipeSegmentDelivery: + state = ready.tool_ctx.recipe_initialization_state + assert isinstance(state, ReadyRecipe) + sources = dict.fromkeys( + source + for segment in state.finalized_projection.delivery_segments + for source in segment.checkpoint_sources + ) + candidates = [] + for source in sources: + if _delivery_tool_name(state.finalized_projection, source) != tool_name: + continue + prepared = _prepare_real_recipe_segment(ready.tool_ctx, source) + assert prepared is not None + candidates.append(prepared) + assert candidates, f"{tool_name} has no mapped checkpoint in the READY recipe" + return max( + candidates, + key=lambda candidate: ( + bool((candidate.success_carrier or {}).get("bodies")), + bool((candidate.success_carrier or {}).get("pull_closures")), + ), + ) + + +async def assert_real_checkpoint_response_budget( + ready, tool_name: str, result: dict[str, Any], artifact_dir: Path +) -> None: + prepared = _real_success_delivery(ready, tool_name) + assert prepared.success_carrier is not None + assert prepared.success_pull_carrier is not None + if not prepared.success_carrier.get("bodies") and not prepared.success_carrier.get( + "pull_closures" + ): + state = ready.tool_ctx.recipe_initialization_state + assert isinstance(state, ReadyRecipe) + assert not _target_segment_indices( + state.finalized_projection, prepared.step_name, success=True + ) + return + base = {key: value for key, value in result.items() if key != "recipe_segment"} + expanded = {**base, "checkpoint_auxiliary": ""} + inline_size = len(_serialized_bytes({**expanded, "recipe_segment": prepared.success_carrier})) + assert inline_size < RECIPE_SEGMENT_MAX_BYTES + expanded["checkpoint_auxiliary"] = "x" * (RECIPE_SEGMENT_MAX_BYTES - inline_size) + attached = delivery_module.attach_recipe_segment(expanded, prepared, success=True) + assert attached["recipe_segment"] == prepared.success_pull_carrier + shaped = enforce_response_budget( + attached, + tool_name=tool_name, + artifact_dir=artifact_dir, + config=ready.tool_ctx.config.output_budget, + ) + payload = json.loads(shaped) if isinstance(shaped, str) else shaped + assert payload.get("subtype") != "recipe_segment_post_effect_delivery_failure" + assert_delivery_outcome(tool_name, payload, expected=True) + assert payload["recipe_segment"] == prepared.success_pull_carrier + assert prepared.success_pull_carrier["source_step"] == prepared.step_name + assert prepared.success_pull_carrier["recipe_pull"] == prepared.success_carrier["recipe_pull"] + assert prepared.success_pull_carrier["pull_closures"] + for key, value in base.items(): + assert payload[key] == value, key + metadata = payload.get(RESPONSE_SPILL_METADATA_KEY) + if metadata is not None: + assert json.loads(Path(metadata["artifact_path"]).read_text()) == expanded diff --git a/tests/server/test_complete_run_skill_result_delivery.py b/tests/server/test_complete_run_skill_result_delivery.py new file mode 100644 index 0000000000..415dc21db3 --- /dev/null +++ b/tests/server/test_complete_run_skill_result_delivery.py @@ -0,0 +1,80 @@ +"""The completion handler returns a step identity for automatic segment delivery.""" + +from __future__ import annotations + +import json +from types import SimpleNamespace + +import pytest + +pytestmark = [pytest.mark.layer("server"), pytest.mark.anyio, pytest.mark.medium] + +_IMPLEMENTATION_OVERRIDES = { + "issue_url": "https://github.com/TalonT-Org/AutoSkillit/issues/5256", + "task": "test task", +} + + +def _publish_receipt(tool_ctx, *, success: bool) -> str: + authority = tool_ctx.run_skill_completion + assert authority is not None + invocation_id = authority.begin( + kitchen_id=tool_ctx.kitchen_id, + request_session_id="request-session", + tracker_order_id="", + tracker_path="", + tracker_kitchen_id="", + tracker_incarnation_id="", + step_name="plan", + ) + receipt = authority.draft( + invocation_id, + classification="success" if success else "timeout", + success=success, + result_digest="sha256:completion-result", + ) + return authority.publish(receipt.receipt_id).receipt_id + + +@pytest.mark.parametrize( + "tool_ctx_ready_recipe", + [pytest.param(("implementation", "plan", _IMPLEMENTATION_OVERRIDES), id="implementation")], + indirect=True, +) +@pytest.mark.parametrize( + ("run_skill_success", "expected_segment"), + [(True, "success"), (False, "recovery")], + ids=("success", "failure"), +) +async def test_completion_receipt_delivery_carries_plan_step_name( + tool_ctx_ready_recipe, + tmp_path, + run_skill_success: bool, + expected_segment: str, +) -> None: + from autoskillit.server.tools.tools_pipeline_tracker import complete_run_skill_result + from tests.server._recipe_segment_test_helpers import ( + assert_delivery_outcome, + assert_real_checkpoint_response_budget, + ) + + receipt_id = _publish_receipt(tool_ctx_ready_recipe.tool_ctx, success=run_skill_success) + response = json.loads( + await complete_run_skill_result( + receipt_id, + ctx=SimpleNamespace(session_id="request-session"), + ) + ) + + assert response["success"] is True + assert response["step_name"] == "plan" + assert response["run_skill_success"] is run_skill_success + assert_delivery_outcome("complete_run_skill_result", response, expected=run_skill_success) + assert response["recipe_segment"]["kind"] == expected_segment + if run_skill_success: + await assert_real_checkpoint_response_budget( + tool_ctx_ready_recipe, + "complete_run_skill_result", + response, + tmp_path, + ) diff --git a/tests/server/test_delivery_e2e_verification.py b/tests/server/test_delivery_e2e_verification.py index 0211538777..629df7d821 100644 --- a/tests/server/test_delivery_e2e_verification.py +++ b/tests/server/test_delivery_e2e_verification.py @@ -65,6 +65,9 @@ async def _record_implementation_bounded_path( delivery_shape="SEGMENTED_CHECKPOINT", segment_or_section=step_name, ) + if step_name in {"claim_and_resolve", "create_and_publish"}: + assert "recipe_segment" not in shaped + continue segment = shaped["recipe_segment"] assert isinstance(segment, dict) assert segment["kind"] == "success" @@ -251,9 +254,9 @@ async def test_implementation_bounded_path_counts_automatic_and_advertised_deliv # Pin normalized delivery costs to detect drift in the bundled recipe. # These measured totals are not production-safe thresholds. assert totals == { - "raw_chars": 18_800, - "utf8_bytes": 18_802, - "client_serialized_chars": 20_871, - "estimated_tokens": 4_698, + "raw_chars": 16_831, + "utf8_bytes": 16_833, + "client_serialized_chars": 18_754, + "estimated_tokens": 4_206, "responses": 5, } diff --git a/tests/server/test_implementation_plan_bind_verify_walk.py b/tests/server/test_implementation_plan_bind_verify_walk.py new file mode 100644 index 0000000000..d7d51b3118 --- /dev/null +++ b/tests/server/test_implementation_plan_bind_verify_walk.py @@ -0,0 +1,202 @@ +"""The implementation plan path carries attestation through bind_plan_set to verify.""" + +from __future__ import annotations + +import json +import re +from pathlib import Path +from typing import Any + +import pytest + +from tests.server._pipeline_test_helpers import _ack_direct_run_skill_result + +pytestmark = [pytest.mark.layer("server"), pytest.mark.anyio, pytest.mark.medium] + +_IMPLEMENTATION_OVERRIDES = { + "issue_url": "https://github.com/TalonT-Org/AutoSkillit/issues/5256", + "task": "test task", +} +_REVIEW_APPROACH_OVERRIDES = {**_IMPLEMENTATION_OVERRIDES, "review_approach": "true"} +_INPUT_REFERENCE = re.compile(r"\$\{\{\s*(inputs|context)\.([A-Za-z_][A-Za-z0-9_]*)\s*\}\}") +_INPUT_DEFAULTS = {"adversarial_review_level": "auto"} + + +def _plan_output_token(result_text: str, name: str) -> str: + match = re.search(rf"(?m)^{re.escape(name)}\s*=\s*(.+?)\s*$", result_text) + assert match is not None, result_text + return match.group(1) + + +def _skill_inputs( + step_name: str, + step_body: dict[str, Any], + credential: dict[str, Any], + ingredient_values: dict[str, str], + context_values: dict[str, str], +) -> dict[str, str | int | bool]: + shape = credential["skill_input_shapes"][step_name] + declared = step_body["with"]["skill_inputs"] + absence_values = shape["absence_values"] + values: dict[str, str | int | bool] = {} + for key in shape["keys"]: + value = declared[key] + if not isinstance(value, str): + values[key] = value + continue + match = _INPUT_REFERENCE.fullmatch(value) + if match is None: + values[key] = value + continue + source, name = match.groups() + if source == "inputs": + values[key] = ( + ingredient_values[name] if name in ingredient_values else _INPUT_DEFAULTS[name] + ) + else: + values[key] = context_values[name] if name in context_values else absence_values[key] + return values + + +async def _run_step( + ready, + git_checkout: Path, + step_name: str, + step_body: dict[str, Any], + credential: dict[str, Any], + ingredient_values: dict[str, str], + context_values: dict[str, str], +) -> dict[str, Any]: + from autoskillit.server.tools.tools_execution import run_skill + + with_args = step_body["with"] + return json.loads( + await run_skill( + skill_command=with_args["skill_command"], + cwd=str(git_checkout), + step_name=step_name, + output_dir=with_args.get("output_dir", ""), + recipe_execution_id=credential["execution_id"], + invocation_template_digest=credential["invocation_template_digests"][step_name], + skill_inputs=_skill_inputs( + step_name, + step_body, + credential, + ingredient_values, + context_values, + ), + ) + ) + + +def _carrier_delivers_step(carrier: dict[str, Any], step_name: str) -> bool: + if any(body.get("step") == step_name for body in carrier.get("bodies", [])): + return True + return any( + step_name in closure.get("steps", []) for closure in carrier.get("pull_closures", []) + ) + + +@pytest.mark.parametrize( + ("tool_ctx_ready_recipe", "review_approach"), + [ + pytest.param(("implementation", "plan", _IMPLEMENTATION_OVERRIDES), False, id="default"), + pytest.param( + ("implementation", "plan", _REVIEW_APPROACH_OVERRIDES), True, id="review-approach" + ), + ], + indirect=["tool_ctx_ready_recipe"], +) +async def test_plan_bind_verify_walk_uses_only_delivered_responses( + tool_ctx_ready_recipe, + git_checkout: Path, + monkeypatch: pytest.MonkeyPatch, + review_approach: bool, +) -> None: + from autoskillit.core.io import load_yaml + from tests.fakes import InMemoryHeadlessExecutor + from tests.server._helpers import _skill_ok + from tests.server._recipe_segment_test_helpers import assert_delivery_outcome + from tests.server.test_tools_plan_set import _parts + + ready = tool_ctx_ready_recipe + credential = ready.receipt["recipe_execution"] + _, part_a, part_b = _parts(git_checkout, ready.tool_ctx, monkeypatch) + plan_text = f"verdict = plan\nplan_path = {part_a}\nplan_parts = {part_a},{part_b}" + executor = InMemoryHeadlessExecutor(default_result=_skill_ok(plan_text)) + monkeypatch.setattr(ready.tool_ctx, "executor", executor) + + plan_response = await _run_step( + ready, + git_checkout, + "plan", + ready.step_body, + credential, + _IMPLEMENTATION_OVERRIDES, + {}, + ) + assert plan_response["success"] is True + plan_result = plan_response["result"] + assert isinstance(plan_result, str) + plan_path = _plan_output_token(plan_result, "plan_path") + plan_parts = _plan_output_token(plan_result, "plan_parts") + assert _plan_output_token(plan_result, "verdict") == "plan" + _ack_direct_run_skill_result(ready.tool_ctx, plan_response) + + from autoskillit.server.tools.tools_plan_set import bind_plan_set + + bind_response = json.loads( + await bind_plan_set( + step_name="bind_plan_set", + plan_parts=plan_parts, + cwd=str(git_checkout), + issue_url=_IMPLEMENTATION_OVERRIDES["issue_url"], + ) + ) + assert bind_response["success"] is True, bind_response + assert_delivery_outcome("bind_plan_set", bind_response, expected=True) + assert bind_response["plan_set_state"] == "sealed" + assert bind_response["plan_set_parts"] == "\n".join(plan_parts.split(",")) + authority_path = Path(bind_response["plan_set_authority_path"]) + assert authority_path.is_absolute() and authority_path.is_file() + authority_digest = bind_response["plan_set_authority_digest"] + assert isinstance(authority_digest, str) + assert re.fullmatch(r"sha256:[0-9a-f]{64}", authority_digest) + carrier = bind_response["recipe_segment"] + assert carrier["kind"] == "success" + assert _carrier_delivers_step(carrier, "verify") + + if review_approach: + return + + verify_record = next(body for body in carrier["bodies"] if body["step"] == "verify") + verify_body = load_yaml(verify_record["body"])["verify"] + assert ( + verify_record["invocation_template_digest"] + == credential["invocation_template_digests"]["verify"] + ) + verify_context = { + **plan_response, + "plan_path": plan_path, + **bind_response, + } + verify_response = await _run_step( + ready, + git_checkout, + "verify", + verify_body, + credential, + _IMPLEMENTATION_OVERRIDES, + {key: value for key, value in verify_context.items() if isinstance(value, str)}, + ) + serialized = json.dumps(verify_response) + for code in ( + "recipe_execution_attestation_missing", + "recipe_execution_id_mismatch", + "recipe_execution_step_mismatch", + "invocation_template_digest_mismatch", + ): + assert code not in serialized + expected_command = verify_body["with"]["skill_command"].replace("/autoskillit:", "/", 1) + assert executor.calls[-1].skill_command.splitlines()[0] == expected_command + assert executor.calls[-1].step_name == "verify" diff --git a/tests/server/test_recipe_checkpoint_conformance.py b/tests/server/test_recipe_checkpoint_conformance.py new file mode 100644 index 0000000000..585dd7fbfd --- /dev/null +++ b/tests/server/test_recipe_checkpoint_conformance.py @@ -0,0 +1,349 @@ +"""Conformance tests for registry-driven checkpoint carrier attachment.""" + +from __future__ import annotations + +import json +from collections.abc import Callable +from typing import Any + +import pytest + +import autoskillit.server.recipe._recipe_segment_delivery as delivery_module +from autoskillit.core.tool_registry import TOOL_REGISTRY +from autoskillit.pipeline.gate import gate_error_result +from autoskillit.server._notify import track_response_size +from autoskillit.server.tools._types import deny_envelope + +pytestmark = [pytest.mark.layer("server"), pytest.mark.medium] + +_SUCCESS_CARRIER: dict[str, Any] = {"kind": "success", "marker": "S"} +_PULL_SUCCESS_CARRIER: dict[str, Any] = {"kind": "success", "marker": "P"} +_RECOVERY_CARRIER: dict[str, Any] = {"kind": "recovery", "marker": "R"} +_STEP_NAME = "checkpoint" + + +def _reader_shape_representatives() -> tuple[str, ...]: + representatives: dict[tuple[bool, bool, type, bool], str] = {} + for name in sorted(TOOL_REGISTRY): + tool_def = TOOL_REGISTRY[name] + if not (tool_def.automatic_recipe_delivery or tool_def.recovery_recipe_delivery): + continue + outcome = tool_def.recipe_delivery_outcome + assert outcome is not None + shape = ( + tool_def.automatic_recipe_delivery, + outcome.success_field is None, + type(outcome.success_value), + outcome.step_name_field is None, + ) + representatives.setdefault(shape, name) + return tuple(representatives.values()) + + +_READER_SHAPE_TOOLS = _reader_shape_representatives() + + +def _has_result_sourced_step_name(tool_name: str) -> bool: + outcome = TOOL_REGISTRY[tool_name].recipe_delivery_outcome + assert outcome is not None + return outcome.step_name_field is not None + + +_ARGUMENT_SOURCED_READER_TOOLS = tuple( + name for name in _READER_SHAPE_TOOLS if not _has_result_sourced_step_name(name) +) +_RESULT_SOURCED_READER_TOOLS = tuple( + name for name in _READER_SHAPE_TOOLS if _has_result_sourced_step_name(name) +) + + +def _payload( + tool_name: str, *, success: bool, include_step_name: bool = True +) -> dict[str, object]: + outcome = TOOL_REGISTRY[tool_name].recipe_delivery_outcome + assert outcome is not None + if outcome.success_field is None: + payload: dict[str, object] = {"success": success} + if not success: + payload["error"] = "failed" + else: + value = outcome.success_value + if success: + payload = {outcome.success_field: value} + else: + payload = {outcome.success_field: (False if isinstance(value, bool) else "failed")} + if outcome.step_name_field is not None and include_step_name: + payload[outcome.step_name_field] = _STEP_NAME + return payload + + +def _install_preparer( + monkeypatch: pytest.MonkeyPatch, + *, + success_carrier: dict[str, Any] | None, + failure: Exception | None = None, +) -> tuple[delivery_module.PreparedRecipeSegmentDelivery, list[str]]: + prepared = delivery_module.PreparedRecipeSegmentDelivery( + step_name=_STEP_NAME, + success_carrier=success_carrier, + success_pull_carrier=(_PULL_SUCCESS_CARRIER if success_carrier is not None else None), + recovery_carrier=_RECOVERY_CARRIER, + ) + calls: list[str] = [] + + def prepare(_tool_ctx, step_name: str): + calls.append(step_name) + if failure is not None: + raise failure + return prepared + + monkeypatch.setattr( + delivery_module, + "prepare_recipe_segment_delivery", + prepare, + ) + return prepared, calls + + +def _wrapped( + tool_name: str, + response: str, + *, + raised: Exception | None = None, + entered: list[bool] | None = None, +) -> Callable[..., Any]: + async def handler(step_name: str = "", **_kwargs: object) -> str: + if entered is not None: + entered.append(True) + if raised is not None: + raise raised + return response + + handler.__name__ = tool_name + return track_response_size(tool_name)(handler) + + +def _success_carrier_for(tool_name: str) -> dict[str, Any] | None: + return _SUCCESS_CARRIER if TOOL_REGISTRY[tool_name].automatic_recipe_delivery else None + + +@pytest.mark.parametrize("tool_name", _READER_SHAPE_TOOLS, ids=_READER_SHAPE_TOOLS) +@pytest.mark.anyio +async def test_success_payload_gets_declared_carrier( + tool_name: str, + tool_ctx, + monkeypatch: pytest.MonkeyPatch, +) -> None: + success_carrier = _success_carrier_for(tool_name) + prepared, calls = _install_preparer( + monkeypatch, + success_carrier=success_carrier, + ) + payload = _payload(tool_name, success=True) + outcome = TOOL_REGISTRY[tool_name].recipe_delivery_outcome + assert outcome is not None and outcome.succeeded(payload) + + argument_step = "argument-step" if outcome.step_name_field is not None else _STEP_NAME + result = await _wrapped(tool_name, json.dumps(payload))(step_name=argument_step) + parsed = json.loads(result) + + if prepared.success_carrier is None: + assert "recipe_segment" not in parsed + else: + assert parsed["recipe_segment"] == prepared.success_carrier + expected_step = ( + payload[outcome.step_name_field] if outcome.step_name_field is not None else argument_step + ) + assert calls == [expected_step] + + +@pytest.mark.parametrize("tool_name", _READER_SHAPE_TOOLS, ids=_READER_SHAPE_TOOLS) +@pytest.mark.anyio +async def test_failure_payload_gets_recovery_carrier( + tool_name: str, + tool_ctx, + monkeypatch: pytest.MonkeyPatch, +) -> None: + _prepared, calls = _install_preparer( + monkeypatch, + success_carrier=_success_carrier_for(tool_name), + ) + payload = _payload(tool_name, success=False, include_step_name=False) + outcome = TOOL_REGISTRY[tool_name].recipe_delivery_outcome + assert outcome is not None and not outcome.succeeded(payload) + + result = await _wrapped(tool_name, json.dumps(payload))(step_name=_STEP_NAME) + parsed = json.loads(result) + + if outcome.step_name_field is None: + assert parsed["recipe_segment"] == _RECOVERY_CARRIER + assert calls == [_STEP_NAME] + else: + assert "recipe_segment" not in parsed + assert calls == [] + + +@pytest.mark.parametrize("tool_name", _READER_SHAPE_TOOLS, ids=_READER_SHAPE_TOOLS) +@pytest.mark.anyio +async def test_handler_exception_gets_recovery_carrier( + tool_name: str, + tool_ctx, + monkeypatch: pytest.MonkeyPatch, +) -> None: + outcome = TOOL_REGISTRY[tool_name].recipe_delivery_outcome + assert outcome is not None + _prepared, calls = _install_preparer( + monkeypatch, + success_carrier=_success_carrier_for(tool_name), + ) + result = await _wrapped(tool_name, "", raised=RuntimeError("boom"))(step_name=_STEP_NAME) + parsed = json.loads(result) + + assert parsed["subtype"] == "tool_exception" + assert parsed["error"] == "RuntimeError: boom" + if outcome.step_name_field is None: + assert parsed["recipe_segment"] == _RECOVERY_CARRIER + assert calls == [_STEP_NAME] + else: + assert "recipe_segment" not in parsed + assert calls == [] + + +@pytest.mark.parametrize("tool_name", _READER_SHAPE_TOOLS, ids=_READER_SHAPE_TOOLS) +@pytest.mark.anyio +async def test_deny_and_gate_errors_get_recovery_carrier( + tool_name: str, + tool_ctx, + monkeypatch: pytest.MonkeyPatch, +) -> None: + outcome = TOOL_REGISTRY[tool_name].recipe_delivery_outcome + assert outcome is not None + _prepared, calls = _install_preparer( + monkeypatch, + success_carrier=_success_carrier_for(tool_name), + ) + responses = ( + json.dumps(deny_envelope("denied", stage="test", retriable=False)), + gate_error_result(), + ) + + for response in responses: + parsed = json.loads(await _wrapped(tool_name, response)(step_name=_STEP_NAME)) + if outcome.step_name_field is None: + assert parsed["recipe_segment"] == _RECOVERY_CARRIER + else: + assert "recipe_segment" not in parsed + + assert calls == ([] if outcome.step_name_field is not None else [_STEP_NAME] * 2) + + +@pytest.mark.parametrize("tool_name", _READER_SHAPE_TOOLS, ids=_READER_SHAPE_TOOLS) +@pytest.mark.anyio +async def test_missing_step_name_is_byte_identical_passthrough( + tool_name: str, + tool_ctx, + monkeypatch: pytest.MonkeyPatch, +) -> None: + _prepared, calls = _install_preparer( + monkeypatch, + success_carrier=_success_carrier_for(tool_name), + ) + response = json.dumps({"success": True, "value": "unchanged"}) + result = await _wrapped(tool_name, response)() + + assert result == response + assert calls == [] + + +@pytest.mark.parametrize( + "tool_name", + _ARGUMENT_SOURCED_READER_TOOLS, + ids=_ARGUMENT_SOURCED_READER_TOOLS, +) +@pytest.mark.anyio +async def test_pre_effect_prepare_failure_prevents_handler( + tool_name: str, + tool_ctx, + monkeypatch: pytest.MonkeyPatch, +) -> None: + entered: list[bool] = [] + _prepared, calls = _install_preparer( + monkeypatch, + success_carrier=_success_carrier_for(tool_name), + failure=delivery_module.RecipeSegmentDeliveryError("unavailable"), + ) + result = await _wrapped( + tool_name, + json.dumps({"success": True}), + entered=entered, + )(step_name=_STEP_NAME) + + assert not entered + assert calls == [_STEP_NAME] + assert json.loads(result) == { + "success": False, + "error": "RecipeSegmentDeliveryError: unavailable", + } + + +@pytest.mark.parametrize( + "tool_name", + _RESULT_SOURCED_READER_TOOLS, + ids=_RESULT_SOURCED_READER_TOOLS, +) +@pytest.mark.anyio +async def test_result_sourced_prepare_failure_is_retriable_after_handler( + tool_name: str, + tool_ctx, + monkeypatch: pytest.MonkeyPatch, +) -> None: + entered: list[bool] = [] + _prepared, calls = _install_preparer( + monkeypatch, + success_carrier=_SUCCESS_CARRIER, + failure=delivery_module.RecipeSegmentDeliveryError("unavailable"), + ) + payload = _payload(tool_name, success=True) + result = await _wrapped( + tool_name, + json.dumps(payload), + entered=entered, + )(step_name="argument-step") + parsed = json.loads(result) + + assert entered + assert calls == [_STEP_NAME] + assert parsed["success"] is False + assert parsed["stage"] == "recipe_segment_delivery" + assert parsed["retriable"] is True + assert "recipe_segment" not in parsed + assert "do_not_repeat" not in parsed + + +@pytest.mark.anyio +async def test_unflagged_tools_never_prepare_or_attach( + tool_ctx, + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls: list[str] = [] + + def prepare(_tool_ctx, step_name: str): + calls.append(step_name) + return None + + monkeypatch.setattr( + delivery_module, + "prepare_recipe_segment_delivery", + prepare, + ) + for tool_name in sorted(TOOL_REGISTRY): + tool_def = TOOL_REGISTRY[tool_name] + if tool_def.automatic_recipe_delivery or tool_def.recovery_recipe_delivery: + continue + result = await _wrapped( + tool_name, + json.dumps({"success": False}), + )(step_name=_STEP_NAME) + assert "recipe_segment" not in json.loads(result), tool_name + + assert calls == [] diff --git a/tests/server/test_recipe_segment_delivery.py b/tests/server/test_recipe_segment_delivery.py index e4460b4e4e..ee1559e477 100644 --- a/tests/server/test_recipe_segment_delivery.py +++ b/tests/server/test_recipe_segment_delivery.py @@ -2,7 +2,9 @@ from __future__ import annotations +import json from dataclasses import replace +from pathlib import Path import pytest @@ -25,13 +27,35 @@ ) from autoskillit.server.recipe._recipe_generation import RecipeGenerationStore from autoskillit.server.recipe._recipe_segment_delivery import ( + RECIPE_SEGMENT_MAX_BYTES, RecipeSegmentDeliveryError, + attach_recipe_segment, prepare_recipe_segment_delivery, uses_segmented_startup, ) +from autoskillit.server.response._response_budget import ( + RESPONSE_SPILL_METADATA_KEY, + enforce_response_budget, +) pytestmark = [pytest.mark.layer("server"), pytest.mark.medium] +_IMPLEMENTATION_OVERRIDES = { + "issue_url": "https://github.com/TalonT-Org/AutoSkillit/issues/5256", + "task": "test task", +} +_IMPLEMENTATION_READY_PARAMETERS = [ + pytest.param(("implementation", "plan", _IMPLEMENTATION_OVERRIDES), id="default"), + pytest.param( + ( + "implementation", + "plan", + {**_IMPLEMENTATION_OVERRIDES, "review_approach": "true"}, + ), + id="review-approach", + ), +] + def _projection(*, segmented: bool) -> FinalizedRecipeProjection: return FinalizedRecipeProjection( @@ -169,6 +193,7 @@ async def test_checkpoint_delivery_reads_ready_exact_durable_artifact( ) assert prepared is not None + assert prepared.success_carrier is not None assert prepared.success_carrier["source_step"] == "scope" assert [body["step"] for body in prepared.success_carrier["bodies"]] == ["select_directions"] credential = prepared.success_carrier[RECIPE_EXECUTION_CREDENTIAL_WIRE_KEY] @@ -198,10 +223,13 @@ async def test_checkpoint_delivery_sizes_failure_with_actual_tool_name( else invocation.tool_name ) seen_tool_names: list[str] = [] + validated_carriers: list[dict[str, object]] = [] + build_failure = segment_delivery_module.build_post_effect_segment_failure def record_tool_name(carrier, *, tool_name: str): seen_tool_names.append(tool_name) - return carrier + validated_carriers.append(carrier) + return build_failure(carrier, tool_name=tool_name) monkeypatch.setattr( segment_delivery_module, @@ -209,9 +237,22 @@ def record_tool_name(carrier, *, tool_name: str): record_tool_name, ) - prepare_recipe_segment_delivery(tool_ctx_ready_recipe.tool_ctx, "scope") + prepared = prepare_recipe_segment_delivery(tool_ctx_ready_recipe.tool_ctx, "scope") - assert seen_tool_names == [expected_tool_name, expected_tool_name, expected_tool_name] + assert prepared is not None + assert prepared.success_carrier is not None + assert prepared.success_pull_carrier is not None + assert prepared.recovery_carrier is not None + assert seen_tool_names + assert all(tool_name == expected_tool_name for tool_name in seen_tool_names) + assert all( + carrier in validated_carriers + for carrier in ( + prepared.success_carrier, + prepared.success_pull_carrier, + prepared.recovery_carrier, + ) + ) @pytest.mark.anyio @@ -332,4 +373,122 @@ async def test_checkpoint_delivery_survives_ready_generation_lru_eviction( prepared = prepare_recipe_segment_delivery(tool_ctx_ready_recipe.tool_ctx, "scope") assert prepared is not None + assert prepared.success_carrier is not None assert prepared.success_carrier["recipe_pull"] == state.artifact_generation.pull_identity() + + +@pytest.mark.parametrize("tool_ctx_ready_recipe", _IMPLEMENTATION_READY_PARAMETERS, indirect=True) +@pytest.mark.anyio +async def test_recovery_only_success_carrier_follows_success_crossings( + tool_ctx_ready_recipe, +) -> None: + tool_ctx = tool_ctx_ready_recipe.tool_ctx + + close_issue = prepare_recipe_segment_delivery(tool_ctx, "close_issue_no_changes") + assert close_issue is not None + assert close_issue.success_carrier is not None + assert close_issue.success_carrier["kind"] == "recovery" + assert "register_clone_no_changes" in close_issue.success_carrier["target_steps"] + + claim_issue = prepare_recipe_segment_delivery(tool_ctx, "claim_and_resolve") + assert claim_issue is not None + assert claim_issue.success_carrier is None + + +@pytest.mark.parametrize("tool_ctx_ready_recipe", _IMPLEMENTATION_READY_PARAMETERS, indirect=True) +@pytest.mark.anyio +async def test_success_carrier_degrades_to_pull_only_when_response_would_not_fit( + tool_ctx_ready_recipe, + tmp_path: Path, +) -> None: + tool_ctx = tool_ctx_ready_recipe.tool_ctx + prepared = prepare_recipe_segment_delivery(tool_ctx, "bind_plan_set") + assert prepared is not None + assert prepared.success_carrier is not None + assert prepared.success_pull_carrier is not None + + base = { + "success": True, + "plan_set_authority_path": "/tmp/plan-set-authority.json", + "base_marker": "retained", + "auxiliary": "", + } + inline_size = len( + segment_delivery_module._serialized_bytes( + {**base, "recipe_segment": prepared.success_carrier} + ) + ) + assert inline_size < RECIPE_SEGMENT_MAX_BYTES + base["auxiliary"] = "x" * (RECIPE_SEGMENT_MAX_BYTES - inline_size) + assert ( + len( + segment_delivery_module._serialized_bytes( + {**base, "recipe_segment": prepared.success_carrier} + ) + ) + >= RECIPE_SEGMENT_MAX_BYTES + ) + + attached = attach_recipe_segment(base, prepared, success=True) + assert attached["recipe_segment"] == prepared.success_pull_carrier + shaped = enforce_response_budget( + attached, + tool_name="bind_plan_set", + artifact_dir=tmp_path, + config=tool_ctx.config.output_budget, + ) + payload = json.loads(shaped) if isinstance(shaped, str) else shaped + + assert payload["success"] is True + assert payload["recipe_segment"] == prepared.success_pull_carrier + assert all(payload[key] == value for key, value in base.items()) + + +@pytest.mark.parametrize("tool_ctx_ready_recipe", _IMPLEMENTATION_READY_PARAMETERS, indirect=True) +@pytest.mark.anyio +async def test_pull_only_success_survives_oversized_bind_response_spill( + tool_ctx_ready_recipe, + tmp_path: Path, +) -> None: + tool_ctx = tool_ctx_ready_recipe.tool_ctx + state = tool_ctx.recipe_initialization_state + assert isinstance(state, ReadyRecipe) + prepared = prepare_recipe_segment_delivery(tool_ctx, "bind_plan_set") + assert prepared is not None + assert prepared.success_pull_carrier is not None + base = { + "success": True, + "plan_set_state": "sealed", + "plan_set_authority_path": "/tmp/plan-set-authority.json", + "plan_set_authority_digest": "sha256:" + ("a" * 64), + "auxiliary": "spill-sentinel:" + ("x" * 20_000), + } + + attached = attach_recipe_segment(base, prepared, success=True) + assert attached["recipe_segment"] == prepared.success_pull_carrier + shaped = enforce_response_budget( + attached, + tool_name="bind_plan_set", + artifact_dir=tmp_path, + config=tool_ctx.config.output_budget, + ) + payload = json.loads(shaped) if isinstance(shaped, str) else shaped + + assert payload.get("subtype") != "recipe_segment_post_effect_delivery_failure" + assert "do_not_repeat" not in payload + assert payload["success"] is True + assert payload["plan_set_authority_path"] == base["plan_set_authority_path"] + assert payload["recipe_segment"] == prepared.success_pull_carrier + assert payload["recipe_segment"]["source_step"] == "bind_plan_set" + assert payload["recipe_segment"]["recipe_pull"] == state.artifact_generation.pull_identity() + pull_requests = [ + request + for closure in payload["recipe_segment"]["pull_closures"] + for request in closure["pull_requests"] + ] + assert {"section": "verify", "part": 0} in pull_requests + assert RESPONSE_SPILL_METADATA_KEY in payload + + metadata = payload[RESPONSE_SPILL_METADATA_KEY] + spilled_base = json.loads(Path(metadata["artifact_path"]).read_text(encoding="utf-8")) + assert spilled_base == base diff --git a/tests/server/test_tools_bootstrap.py b/tests/server/test_tools_bootstrap.py index b892f05fc8..978725fed6 100644 --- a/tests/server/test_tools_bootstrap.py +++ b/tests/server/test_tools_bootstrap.py @@ -13,14 +13,14 @@ import pytest import autoskillit.server.tools.tools_clone as tools_clone -import autoskillit.server.tools.tools_git as tools_git -import autoskillit.server.tools.tools_issue_composite as tools_issue_composite from autoskillit.server.tools.tools_clone import bootstrap_clone from autoskillit.server.tools.tools_git import create_and_publish_branch from autoskillit.server.tools.tools_issue_composite import claim_and_resolve_issue from autoskillit.workspace.clone import DefaultCloneManager from tests.conftest import _make_result from tests.server._recipe_segment_test_helpers import ( + assert_delivery_outcome, + assert_real_checkpoint_response_budget, assert_recovery_recipe_segment, install_prepared_recipe_segment, ) @@ -28,6 +28,11 @@ pytestmark = [pytest.mark.layer("server"), pytest.mark.medium] +_IMPLEMENTATION_OVERRIDES = { + "issue_url": "https://github.com/TalonT-Org/AutoSkillit/issues/5256", + "task": "test task", +} + def _git_stdout(repo_path: Path, *args: str) -> str: return subprocess.run( @@ -65,11 +70,18 @@ def record_remove_clone( class TestBootstrapClone: @pytest.mark.anyio + @pytest.mark.parametrize( + "tool_ctx_ready_recipe", + [pytest.param(("implementation", "plan", _IMPLEMENTATION_OVERRIDES), id="implementation")], + indirect=True, + ) async def test_bootstrap_clone_success( self, tool_ctx_kitchen_open, + tool_ctx_ready_recipe, monkeypatch: pytest.MonkeyPatch, remote_only_base_clone: tuple[Path, str], + tmp_path, ): """bootstrap_clone returns work_dir, remote_url, base_sha, merge_target.""" clone_path, upstream_url = remote_only_base_clone @@ -78,7 +90,7 @@ async def test_bootstrap_clone_success( clone_path, "rev-parse", "refs/remotes/origin/develop^{commit}" ) assert isinstance(tool_ctx_kitchen_open.clone_mgr, DefaultCloneManager) - install_prepared_recipe_segment(monkeypatch, tools_clone, step_name="bootstrap") + install_prepared_recipe_segment(monkeypatch, step_name="bootstrap") result = json.loads( await bootstrap_clone( source_dir=str(clone_path), @@ -92,6 +104,10 @@ async def test_bootstrap_clone_success( assert result["base_sha"] == expected_base_sha assert result["merge_target"] == "develop" assert result["recipe_segment"]["kind"] == "success" + assert_delivery_outcome("bootstrap_clone", result, expected=True) + await assert_real_checkpoint_response_budget( + tool_ctx_ready_recipe, "bootstrap_clone", result, tmp_path + ) @pytest.mark.anyio async def test_bootstrap_clone_gate_closed(self, tool_ctx): @@ -110,7 +126,7 @@ async def test_bootstrap_clone_clone_failure( mock_mgr = MagicMock() mock_mgr.clone_repo.side_effect = RuntimeError("disk full") tool_ctx_kitchen_open.clone_mgr = mock_mgr - install_prepared_recipe_segment(monkeypatch, tools_clone, step_name="bootstrap") + install_prepared_recipe_segment(monkeypatch, step_name="bootstrap") result = json.loads( await bootstrap_clone( source_dir="/src", @@ -122,6 +138,7 @@ async def test_bootstrap_clone_clone_failure( assert "error" in result assert "disk full" in result["error"] assert result["recipe_segment"]["kind"] == "recovery" + assert_delivery_outcome("bootstrap_clone", result, expected=False) @pytest.mark.anyio async def test_bootstrap_clone_unresolvable_base_preserves_error_contract( @@ -394,8 +411,8 @@ async def test_claim_and_resolve_issue_claimed( tool_ctx_kitchen_open.github_client.swap_labels = AsyncMock(return_value={"success": True}) install_prepared_recipe_segment( monkeypatch, - tools_issue_composite, step_name="claim", + success_carrier=None, ) result = json.loads( await claim_and_resolve_issue(issue_url="owner/repo#42", step_name="claim") @@ -404,11 +421,8 @@ async def test_claim_and_resolve_issue_claimed( assert result["issue_number"] == 42 assert result["issue_title"] == "Fix bug" assert result["issue_slug"] == "fix-bug" - assert result["recipe_segment"] == { - "kind": "success", - "source_step": "claim", - "bodies": [{"step": "next", "body": "next:\n action: stop\n"}], - } + assert "recipe_segment" not in result + assert_delivery_outcome("claim_and_resolve_issue", result, expected=True) @pytest.mark.anyio async def test_claim_and_resolve_issue_already_claimed( @@ -430,7 +444,6 @@ async def test_claim_and_resolve_issue_already_claimed( ) install_prepared_recipe_segment( monkeypatch, - tools_issue_composite, step_name="claim", ) result = json.loads( @@ -441,6 +454,7 @@ async def test_claim_and_resolve_issue_already_claimed( assert result["issue_title"] == "Fix bug" assert result["issue_slug"] == "fix-bug" assert_recovery_recipe_segment(result, step_name="claim") + assert_delivery_outcome("claim_and_resolve_issue", result, expected=False) @pytest.mark.anyio async def test_claim_and_resolve_issue_reentry(self, tool_ctx_kitchen_open): @@ -706,7 +720,7 @@ async def test_create_and_publish_branch_success( mock_mgr = MagicMock() mock_mgr.push_to_remote.return_value = {"success": True, "stderr": ""} tool_ctx_kitchen_open.clone_mgr = mock_mgr - install_prepared_recipe_segment(monkeypatch, tools_git, step_name="publish") + install_prepared_recipe_segment(monkeypatch, step_name="publish", success_carrier=None) result = json.loads( await create_and_publish_branch( issue_slug="fix-bug", @@ -719,6 +733,7 @@ async def test_create_and_publish_branch_success( ) assert result["merge_target"] == "fix-bug/42" assert "recipe_segment" not in result + assert_delivery_outcome("create_and_publish_branch", result, expected=True) @pytest.mark.anyio async def test_create_and_publish_branch_collision(self, tool_ctx_kitchen_open, tmp_path): @@ -778,7 +793,7 @@ async def test_create_and_publish_branch_push_failure( "error_type": "", } tool_ctx_kitchen_open.clone_mgr = mock_mgr - install_prepared_recipe_segment(monkeypatch, tools_git, step_name="publish") + install_prepared_recipe_segment(monkeypatch, step_name="publish") result = json.loads( await create_and_publish_branch( issue_slug="fix-bug", @@ -791,6 +806,7 @@ async def test_create_and_publish_branch_push_failure( ) assert "error" in result assert_recovery_recipe_segment(result, step_name="publish") + assert_delivery_outcome("create_and_publish_branch", result, expected=False) @pytest.mark.anyio async def test_create_and_publish_branch_gate_closed(self, tool_ctx): diff --git a/tests/server/test_tools_ci_enqueue.py b/tests/server/test_tools_ci_enqueue.py index 3ce845a939..813a8acaad 100644 --- a/tests/server/test_tools_ci_enqueue.py +++ b/tests/server/test_tools_ci_enqueue.py @@ -9,14 +9,30 @@ import autoskillit.server.tools.tools_ci_merge_queue as tools_ci_merge_queue from tests.fakes import InMemoryMergeQueueWatcher -from tests.server._recipe_segment_test_helpers import install_prepared_recipe_segment +from tests.server._recipe_segment_test_helpers import ( + assert_delivery_outcome, + assert_real_checkpoint_response_budget, + install_prepared_recipe_segment, +) -pytestmark = [pytest.mark.layer("server"), pytest.mark.small] +pytestmark = [pytest.mark.layer("server"), pytest.mark.medium] + +_IMPLEMENTATION_OVERRIDES = { + "issue_url": "https://github.com/TalonT-Org/AutoSkillit/issues/5256", + "task": "test task", +} @pytest.mark.anyio +@pytest.mark.parametrize( + "tool_ctx_ready_recipe", + [pytest.param(("implementation", "plan", _IMPLEMENTATION_OVERRIDES), id="implementation")], + indirect=True, +) async def test_enqueue_pr_delegates_to_watcher_enqueue( tool_ctx_kitchen_open, + tool_ctx_ready_recipe, + tmp_path, monkeypatch: pytest.MonkeyPatch, ): """enqueue_pr tool passes auto_merge_available to watcher.enqueue().""" @@ -28,7 +44,7 @@ async def test_enqueue_pr_delegates_to_watcher_enqueue( }, ) tool_ctx_kitchen_open.merge_queue_watcher = watcher - install_prepared_recipe_segment(monkeypatch, tools_ci_merge_queue, step_name="enqueue") + install_prepared_recipe_segment(monkeypatch, step_name="enqueue") with patch.object( tools_ci_merge_queue, @@ -49,6 +65,10 @@ async def test_enqueue_pr_delegates_to_watcher_enqueue( assert result["success"] is True assert result["enrollment_method"] == "direct_enqueue" assert result["recipe_segment"]["kind"] == "success" + assert_delivery_outcome("enqueue_pr", result, expected=True) + await assert_real_checkpoint_response_budget( + tool_ctx_ready_recipe, "enqueue_pr", result, tmp_path + ) assert len(watcher.enqueue_calls) == 1 call = watcher.enqueue_calls[0] assert call["auto_merge_available"] is False @@ -61,7 +81,7 @@ async def test_enqueue_pr_returns_structured_error_when_watcher_none( ): """enqueue_pr returns {"success": false} when merge_queue_watcher is None.""" tool_ctx_kitchen_open.merge_queue_watcher = None - install_prepared_recipe_segment(monkeypatch, tools_ci_merge_queue, step_name="enqueue") + install_prepared_recipe_segment(monkeypatch, step_name="enqueue") from autoskillit.server.tools.tools_ci_merge_queue import enqueue_pr @@ -76,3 +96,4 @@ async def test_enqueue_pr_returns_structured_error_when_watcher_none( assert result["success"] is False assert "error" in result assert result["recipe_segment"]["kind"] == "recovery" + assert_delivery_outcome("enqueue_pr", result, expected=False) diff --git a/tests/server/test_tools_ci_merge_state.py b/tests/server/test_tools_ci_merge_state.py index bff718b0c2..6ab7e46d26 100644 --- a/tests/server/test_tools_ci_merge_state.py +++ b/tests/server/test_tools_ci_merge_state.py @@ -10,9 +10,18 @@ import autoskillit.server.tools.tools_ci as tools_ci from autoskillit.server.tools.tools_ci import check_repo_merge_state -from tests.server._recipe_segment_test_helpers import install_prepared_recipe_segment +from tests.server._recipe_segment_test_helpers import ( + assert_delivery_outcome, + assert_real_checkpoint_response_budget, + install_prepared_recipe_segment, +) -pytestmark = [pytest.mark.layer("server"), pytest.mark.small] +pytestmark = [pytest.mark.layer("server"), pytest.mark.medium] + +_IMPLEMENTATION_OVERRIDES = { + "issue_url": "https://github.com/TalonT-Org/AutoSkillit/issues/5256", + "task": "test task", +} # --------------------------------------------------------------------------- @@ -21,7 +30,14 @@ @pytest.mark.anyio -async def test_check_repo_merge_state_uses_token_factory(tool_ctx_kitchen_open, monkeypatch): +@pytest.mark.parametrize( + "tool_ctx_ready_recipe", + [pytest.param(("implementation", "plan", _IMPLEMENTATION_OVERRIDES), id="implementation")], + indirect=True, +) +async def test_check_repo_merge_state_uses_token_factory( + tool_ctx_kitchen_open, tool_ctx_ready_recipe, monkeypatch, tmp_path +): """check_repo_merge_state calls token_factory() when set, not config.github.token.""" resolved_calls = [] @@ -50,12 +66,16 @@ async def fake_fetch(owner, repo, branch, token, base_branch=None): "resolve_repo_from_remote", AsyncMock(return_value="owner/repo"), ) - install_prepared_recipe_segment(monkeypatch, tools_ci, step_name="merge_state") + install_prepared_recipe_segment(monkeypatch, step_name="merge_state") result = json.loads(await check_repo_merge_state(branch="main", step_name="merge_state")) assert captured_tokens == ["factory-token"] assert resolved_calls == [1] assert result["recipe_segment"]["kind"] == "success" + assert_delivery_outcome("check_repo_merge_state", result, expected=True) + await assert_real_checkpoint_response_budget( + tool_ctx_ready_recipe, "check_repo_merge_state", result, tmp_path + ) @pytest.mark.anyio @@ -108,8 +128,9 @@ async def fake_fetch(owner, repo, branch, token, base_branch=None): "resolve_repo_from_remote", AsyncMock(return_value="owner/repo"), ) - install_prepared_recipe_segment(monkeypatch, tools_ci, step_name="merge_state") + install_prepared_recipe_segment(monkeypatch, step_name="merge_state") result = json.loads(await check_repo_merge_state(branch="main", step_name="merge_state")) assert result["http_status"] == 403 assert result["recipe_segment"]["kind"] == "recovery" + assert_delivery_outcome("check_repo_merge_state", result, expected=False) diff --git a/tests/server/test_tools_ci_wait_for_ci.py b/tests/server/test_tools_ci_wait_for_ci.py index faa2ded741..be48d8bc77 100644 --- a/tests/server/test_tools_ci_wait_for_ci.py +++ b/tests/server/test_tools_ci_wait_for_ci.py @@ -9,14 +9,23 @@ import pytest -import autoskillit.server.tools.tools_ci_watch as tools_ci_watch from autoskillit.core import SubprocessResult, TerminationReason from autoskillit.server.tools.tools_ci_watch import wait_for_ci from tests.fakes import InMemoryCIWatcher -from tests.server._recipe_segment_test_helpers import install_prepared_recipe_segment +from tests.server._recipe_segment_test_helpers import ( + assert_delivery_outcome, + assert_real_checkpoint_response_budget, + assert_recovery_recipe_segment, + install_prepared_recipe_segment, +) from tests.server.conftest import assert_no_timing, assert_step_timed -pytestmark = [pytest.mark.layer("server"), pytest.mark.small] +pytestmark = [pytest.mark.layer("server"), pytest.mark.medium] + +_IMPLEMENTATION_OVERRIDES = { + "issue_url": "https://github.com/TalonT-Org/AutoSkillit/issues/5256", + "task": "test task", +} # --------------------------------------------------------------------------- @@ -25,8 +34,15 @@ @pytest.mark.anyio +@pytest.mark.parametrize( + "tool_ctx_ready_recipe", + [pytest.param(("implementation", "plan", _IMPLEMENTATION_OVERRIDES), id="implementation")], + indirect=True, +) async def test_wait_for_ci_success_response( tool_ctx_kitchen_open, + tool_ctx_ready_recipe, + tmp_path, monkeypatch: pytest.MonkeyPatch, ): watcher = InMemoryCIWatcher( @@ -42,7 +58,7 @@ async def test_wait_for_ci_success_response( pid=0, ) ) - install_prepared_recipe_segment(monkeypatch, tools_ci_watch, step_name="ci") + install_prepared_recipe_segment(monkeypatch, step_name="ci") result = json.loads(await wait_for_ci("main", cwd="/some/repo", step_name="ci")) @@ -50,6 +66,10 @@ async def test_wait_for_ci_success_response( assert result["conclusion"] == "success" assert result["failed_jobs"] == [] assert result["recipe_segment"]["kind"] == "success" + assert_delivery_outcome("wait_for_ci", result, expected=True) + await assert_real_checkpoint_response_budget( + tool_ctx_ready_recipe, "wait_for_ci", result, tmp_path + ) @pytest.mark.anyio @@ -74,13 +94,31 @@ async def test_wait_for_ci_failure_response( pid=0, ) ) - install_prepared_recipe_segment(monkeypatch, tools_ci_watch, step_name="ci") + install_prepared_recipe_segment(monkeypatch, step_name="ci") result = json.loads(await wait_for_ci("main", cwd="/some/repo", step_name="ci")) assert result["conclusion"] == "failure" assert sorted(result["failed_jobs"]) == ["lint", "test"] assert result["recipe_segment"]["kind"] == "recovery" + assert_delivery_outcome("wait_for_ci", result, expected=False) + + +@pytest.mark.anyio +async def test_wait_for_ci_invalid_event_returns_recovery_carrier( + tool_ctx_kitchen_open, + monkeypatch: pytest.MonkeyPatch, +): + install_prepared_recipe_segment(monkeypatch, step_name="ci") + + result = json.loads( + await wait_for_ci("main", event="invalid-event", cwd="/some/repo", step_name="ci") + ) + + assert result["conclusion"] == "error" + assert "Invalid event" in result["error"] + assert_recovery_recipe_segment(result, step_name="ci") + assert_delivery_outcome("wait_for_ci", result, expected=False) # --------------------------------------------------------------------------- diff --git a/tests/server/test_tools_ci_wait_for_merge_queue.py b/tests/server/test_tools_ci_wait_for_merge_queue.py index c836a944ae..8cd5931545 100644 --- a/tests/server/test_tools_ci_wait_for_merge_queue.py +++ b/tests/server/test_tools_ci_wait_for_merge_queue.py @@ -9,26 +9,41 @@ import pytest -import autoskillit.server.tools.tools_ci_merge_queue as tools_ci_merge_queue from autoskillit.core import PRState from autoskillit.server.tools.tools_ci_merge_queue import wait_for_merge_queue from tests.fakes import InMemoryMergeQueueWatcher -from tests.server._recipe_segment_test_helpers import install_prepared_recipe_segment +from tests.server._recipe_segment_test_helpers import ( + assert_delivery_outcome, + assert_real_checkpoint_response_budget, + install_prepared_recipe_segment, +) from tests.server.conftest import assert_no_timing, assert_step_timed -pytestmark = [pytest.mark.layer("server"), pytest.mark.small] +pytestmark = [pytest.mark.layer("server"), pytest.mark.medium] + +_IMPLEMENTATION_OVERRIDES = { + "issue_url": "https://github.com/TalonT-Org/AutoSkillit/issues/5256", + "task": "test task", +} @pytest.mark.anyio +@pytest.mark.parametrize( + "tool_ctx_ready_recipe", + [pytest.param(("implementation", "plan", _IMPLEMENTATION_OVERRIDES), id="implementation")], + indirect=True, +) async def test_delegates_to_merge_queue_watcher( tool_ctx_kitchen_open, + tool_ctx_ready_recipe, + tmp_path, monkeypatch: pytest.MonkeyPatch, ): watcher = InMemoryMergeQueueWatcher( wait_result={"success": True, "pr_state": "merged", "reason": "PR merged"} ) tool_ctx_kitchen_open.merge_queue_watcher = watcher - install_prepared_recipe_segment(monkeypatch, tools_ci_merge_queue, step_name="queue") + install_prepared_recipe_segment(monkeypatch, step_name="queue") with patch( "autoskillit.execution.github_ops.remote_resolver.asyncio.create_subprocess_exec", @@ -55,6 +70,10 @@ async def test_delegates_to_merge_queue_watcher( assert watcher.wait_calls[-1]["pr_number"] == 42 assert watcher.wait_calls[-1]["target_branch"] == "develop" assert result["recipe_segment"]["kind"] == "success" + assert_delivery_outcome("wait_for_merge_queue", result, expected=True) + await assert_real_checkpoint_response_budget( + tool_ctx_ready_recipe, "wait_for_merge_queue", result, tmp_path + ) @pytest.mark.anyio @@ -108,7 +127,7 @@ async def test_watcher_none_returns_error( monkeypatch: pytest.MonkeyPatch, ): tool_ctx_kitchen_open.merge_queue_watcher = None - install_prepared_recipe_segment(monkeypatch, tools_ci_merge_queue, step_name="queue") + install_prepared_recipe_segment(monkeypatch, step_name="queue") result = json.loads( await wait_for_merge_queue( pr_number=42, @@ -121,6 +140,7 @@ async def test_watcher_none_returns_error( assert "pr_state" in result assert result["pr_state"] == "error" assert result["recipe_segment"]["kind"] == "recovery" + assert_delivery_outcome("wait_for_merge_queue", result, expected=False) # --------------------------------------------------------------------------- diff --git a/tests/server/test_tools_clone.py b/tests/server/test_tools_clone.py index 1abf0db9b2..f1943b5f55 100644 --- a/tests/server/test_tools_clone.py +++ b/tests/server/test_tools_clone.py @@ -8,7 +8,6 @@ import pytest -import autoskillit.server.tools.tools_clone as tools_clone from autoskillit.server.recipe._recipe_segment_delivery import RecipeSegmentDeliveryError from autoskillit.server.tools.tools_clone import ( batch_cleanup_clones, @@ -20,9 +19,18 @@ 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 +from tests.server._recipe_segment_test_helpers import ( + assert_delivery_outcome, + assert_real_checkpoint_response_budget, + install_prepared_recipe_segment, +) + +pytestmark = [pytest.mark.layer("server"), pytest.mark.medium] -pytestmark = [pytest.mark.layer("server"), pytest.mark.small] +_IMPLEMENTATION_OVERRIDES = { + "issue_url": "https://github.com/TalonT-Org/AutoSkillit/issues/5256", + "task": "test task", +} class TestCloneRepoTool: @@ -181,15 +189,22 @@ async def test_returns_gate_error_when_disabled(self, tool_ctx): assert result["subtype"] == "gate_error" @pytest.mark.anyio + @pytest.mark.parametrize( + "tool_ctx_ready_recipe", + [pytest.param(("implementation", "plan", _IMPLEMENTATION_OVERRIDES), id="implementation")], + indirect=True, + ) async def test_delegates_to_workspace_clone_on_success( self, tool_ctx_kitchen_open, + tool_ctx_ready_recipe, + tmp_path, monkeypatch: pytest.MonkeyPatch, ): mock_mgr = MagicMock() - mock_mgr.push_to_remote.return_value = {"success": "true", "stderr": ""} + mock_mgr.push_to_remote.return_value = {"success": True, "stderr": ""} tool_ctx_kitchen_open.clone_mgr = mock_mgr - install_prepared_recipe_segment(monkeypatch, tools_clone, step_name="push") + install_prepared_recipe_segment(monkeypatch, step_name="push") result = json.loads( await push_to_remote( clone_path="/clone", @@ -198,9 +213,13 @@ async def test_delegates_to_workspace_clone_on_success( step_name="push", ) ) - assert result["success"] == "true" + assert result["success"] is True assert "error" not in result assert result["recipe_segment"]["kind"] == "success" + assert_delivery_outcome("push_to_remote", result, expected=True) + await assert_real_checkpoint_response_budget( + tool_ctx_ready_recipe, "push_to_remote", result, tmp_path + ) @pytest.mark.anyio async def test_returns_error_key_when_push_fails( @@ -211,7 +230,7 @@ async def test_returns_error_key_when_push_fails( mock_mgr = MagicMock() mock_mgr.push_to_remote.return_value = {"success": False, "stderr": "remote rejected"} tool_ctx_kitchen_open.clone_mgr = mock_mgr - install_prepared_recipe_segment(monkeypatch, tools_clone, step_name="push") + install_prepared_recipe_segment(monkeypatch, step_name="push") result = json.loads( await push_to_remote( clone_path="/clone", @@ -223,6 +242,7 @@ async def test_returns_error_key_when_push_fails( assert "error" in result assert "remote rejected" in result["stderr"] assert result["recipe_segment"]["kind"] == "recovery" + assert_delivery_outcome("push_to_remote", result, expected=False) @pytest.mark.anyio async def test_segment_identity_denial_prevents_push_effect( @@ -236,7 +256,10 @@ async def test_segment_identity_denial_prevents_push_effect( def _reject(*_args, **_kwargs): raise RecipeSegmentDeliveryError("READY compile identity differs from artifact") - monkeypatch.setattr(tools_clone, "prepare_recipe_segment_delivery", _reject) + monkeypatch.setattr( + "autoskillit.server.recipe._recipe_segment_delivery.prepare_recipe_segment_delivery", + _reject, + ) result = json.loads( await push_to_remote( @@ -250,8 +273,8 @@ def _reject(*_args, **_kwargs): assert result == { "success": False, "error": "RecipeSegmentDeliveryError: READY compile identity differs from artifact", - "stderr": "", } + assert_delivery_outcome("push_to_remote", result, expected=False) mock_mgr.push_to_remote.assert_not_called() @pytest.mark.anyio diff --git a/tests/server/test_tools_execution_facade.py b/tests/server/test_tools_execution_facade.py index 4c573c3eba..914c970cc2 100644 --- a/tests/server/test_tools_execution_facade.py +++ b/tests/server/test_tools_execution_facade.py @@ -86,7 +86,6 @@ "_explorer_launch_identity", "find_caller_session_id", "logger", - "prepare_recipe_segment_delivery", "shutil", ) diff --git a/tests/server/test_tools_git.py b/tests/server/test_tools_git.py index 1eeb324976..3739b1d0a3 100644 --- a/tests/server/test_tools_git.py +++ b/tests/server/test_tools_git.py @@ -9,13 +9,21 @@ import pytest import autoskillit.server.git as server_git -import autoskillit.server.tools.tools_git as tools_git from autoskillit.core.types import MergeFailedStep, MergeState from autoskillit.server.tools.tools_git import merge_worktree from tests.conftest import _make_result -from tests.server._recipe_segment_test_helpers import install_prepared_recipe_segment +from tests.server._recipe_segment_test_helpers import ( + assert_delivery_outcome, + assert_real_checkpoint_response_budget, + install_prepared_recipe_segment, +) -pytestmark = [pytest.mark.layer("server"), pytest.mark.small] +pytestmark = [pytest.mark.layer("server"), pytest.mark.medium] + +_IMPLEMENTATION_OVERRIDES = { + "issue_url": "https://github.com/TalonT-Org/AutoSkillit/issues/5256", + "task": "test task", +} class TestMergeWorktree: @@ -48,18 +56,25 @@ async def test_merge_worktree_blocks_on_failing_tests( tool_ctx_kitchen_open.runner.push( _make_result(1, "FAIL\n= 3 failed, 97 passed =", "") ) # test-check - install_prepared_recipe_segment(monkeypatch, tools_git, step_name="merge") + install_prepared_recipe_segment(monkeypatch, step_name="merge") result = json.loads(await merge_worktree(str(wt), "dev", step_name="merge")) assert "error" in result assert result["failed_step"] == MergeFailedStep.TEST_GATE assert result["state"] == MergeState.WORKTREE_INTACT assert "test_summary" not in result assert result["recipe_segment"]["kind"] == "recovery" + assert_delivery_outcome("merge_worktree", result, expected=False) @pytest.mark.anyio + @pytest.mark.parametrize( + "tool_ctx_ready_recipe", + [pytest.param(("implementation", "plan", _IMPLEMENTATION_OVERRIDES), id="implementation")], + indirect=True, + ) async def test_merge_worktree_merges_on_green( self, tool_ctx_kitchen_open, + tool_ctx_ready_recipe, tmp_path, monkeypatch: pytest.MonkeyPatch, ): @@ -103,7 +118,7 @@ async def test_merge_worktree_merges_on_green( tool_ctx_kitchen_open.runner.push(_make_result(0, "", "")) # git merge tool_ctx_kitchen_open.runner.push(_make_result(0, "", "")) # worktree remove tool_ctx_kitchen_open.runner.push(_make_result(0, "", "")) # branch -D - install_prepared_recipe_segment(monkeypatch, tools_git, step_name="merge") + install_prepared_recipe_segment(monkeypatch, step_name="merge") with patch.object(server_git, "resolve_main_worktree", return_value=Path("/repo")): result = json.loads(await merge_worktree(str(wt), "dev", step_name="merge")) assert result["merge_succeeded"] is True @@ -115,6 +130,10 @@ async def test_merge_worktree_merges_on_green( assert "test_stderr" not in result assert "raw_output_artifact_path" not in result assert result["recipe_segment"]["kind"] == "success" + assert_delivery_outcome("merge_worktree", result, expected=True) + await assert_real_checkpoint_response_budget( + tool_ctx_ready_recipe, "merge_worktree", result, tmp_path + ) assert not (wt / ".autoskillit" / "temp" / "merge_worktree").exists() # Verify merge command cwd is the main_repo (/repo) merge_call = next( diff --git a/tests/server/test_tools_git_branch.py b/tests/server/test_tools_git_branch.py index d347134716..e7b15536f5 100644 --- a/tests/server/test_tools_git_branch.py +++ b/tests/server/test_tools_git_branch.py @@ -6,12 +6,20 @@ import pytest -import autoskillit.server.tools.tools_git as tools_git from autoskillit.server.tools.tools_git import check_pr_mergeable, create_unique_branch from tests.conftest import _make_result -from tests.server._recipe_segment_test_helpers import install_prepared_recipe_segment +from tests.server._recipe_segment_test_helpers import ( + assert_delivery_outcome, + assert_real_checkpoint_response_budget, + install_prepared_recipe_segment, +) -pytestmark = [pytest.mark.layer("server"), pytest.mark.small] +pytestmark = [pytest.mark.layer("server"), pytest.mark.medium] + +_IMPLEMENTATION_OVERRIDES = { + "issue_url": "https://github.com/TalonT-Org/AutoSkillit/issues/5256", + "task": "test task", +} class TestCreateUniqueBranch: @@ -128,9 +136,16 @@ async def test_reports_detached_head_as_base_ref(self, tool_ctx_kitchen_open): class TestCheckPrMergeable: @pytest.mark.anyio + @pytest.mark.parametrize( + "tool_ctx_ready_recipe", + [pytest.param(("implementation", "plan", _IMPLEMENTATION_OVERRIDES), id="implementation")], + indirect=True, + ) async def test_mergeable_pr( self, tool_ctx_kitchen_open, + tool_ctx_ready_recipe, + tmp_path, monkeypatch: pytest.MonkeyPatch, ): tool_ctx_kitchen_open.runner.push( @@ -138,12 +153,16 @@ async def test_mergeable_pr( 0, json.dumps({"mergeable": "MERGEABLE", "mergeStateStatus": "CLEAN"}), "" ) ) - install_prepared_recipe_segment(monkeypatch, tools_git, step_name="mergeable") + install_prepared_recipe_segment(monkeypatch, step_name="mergeable") result = json.loads(await check_pr_mergeable(42, ".", step_name="mergeable")) assert result["mergeable"] is True assert result["merge_state_status"] == "CLEAN" assert result["mergeable_status"] == "MERGEABLE" assert result["recipe_segment"]["kind"] == "success" + assert_delivery_outcome("check_pr_mergeable", result, expected=True) + await assert_real_checkpoint_response_budget( + tool_ctx_ready_recipe, "check_pr_mergeable", result, tmp_path + ) @pytest.mark.anyio async def test_conflicting_pr_is_not_mergeable( @@ -156,12 +175,13 @@ async def test_conflicting_pr_is_not_mergeable( 0, json.dumps({"mergeable": "CONFLICTING", "mergeStateStatus": "DIRTY"}), "" ) ) - install_prepared_recipe_segment(monkeypatch, tools_git, step_name="mergeable") + install_prepared_recipe_segment(monkeypatch, step_name="mergeable") result = json.loads(await check_pr_mergeable(42, ".", step_name="mergeable")) assert result["mergeable"] is False assert result["merge_state_status"] == "DIRTY" assert result["mergeable_status"] == "CONFLICTING" assert result["recipe_segment"]["kind"] == "success" + assert_delivery_outcome("check_pr_mergeable", result, expected=True) @pytest.mark.anyio async def test_unknown_mergeable_status_returned_raw(self, tool_ctx_kitchen_open): @@ -181,10 +201,11 @@ async def test_gh_command_failure_returns_error( monkeypatch: pytest.MonkeyPatch, ): tool_ctx_kitchen_open.runner.push(_make_result(1, "", "pr not found")) - install_prepared_recipe_segment(monkeypatch, tools_git, step_name="mergeable") + install_prepared_recipe_segment(monkeypatch, step_name="mergeable") result = json.loads(await check_pr_mergeable(99, ".", step_name="mergeable")) assert result["success"] is False assert result["recipe_segment"]["kind"] == "recovery" + assert_delivery_outcome("check_pr_mergeable", result, expected=False) @pytest.mark.anyio async def test_gate_closed_returns_gate_error(self, tool_ctx): diff --git a/tests/server/test_tools_issue_release.py b/tests/server/test_tools_issue_release.py index af8b5c5f66..5cbee9c4c0 100644 --- a/tests/server/test_tools_issue_release.py +++ b/tests/server/test_tools_issue_release.py @@ -7,9 +7,9 @@ import pytest -import autoskillit.server.tools.tools_issue_labels as tools_issue_labels from autoskillit.server.tools.tools_issue_labels import release_issue from tests.server._recipe_segment_test_helpers import ( + assert_delivery_outcome, assert_recovery_recipe_segment, install_prepared_recipe_segment, ) @@ -30,7 +30,7 @@ async def test_release_issue_returns_error_without_github_client( monkeypatch: pytest.MonkeyPatch, ): tool_ctx_kitchen_open.github_client = None - install_prepared_recipe_segment(monkeypatch, tools_issue_labels, step_name="release") + install_prepared_recipe_segment(monkeypatch, step_name="release") result = json.loads( await release_issue( "https://github.com/owner/repo/issues/42", @@ -40,9 +40,10 @@ async def test_release_issue_returns_error_without_github_client( assert result["success"] is False assert "error" in result assert_recovery_recipe_segment(result, step_name="release") + assert_delivery_outcome("release_issue", result, expected=False) @pytest.mark.anyio - async def test_release_issue_success_returns_recovery_segment( + async def test_release_issue_success_has_no_success_carrier( self, tool_ctx_kitchen_open, monkeypatch: pytest.MonkeyPatch, @@ -50,7 +51,7 @@ async def test_release_issue_success_returns_recovery_segment( mock_client = AsyncMock() mock_client.remove_label.return_value = {"success": True} tool_ctx_kitchen_open.github_client = mock_client - install_prepared_recipe_segment(monkeypatch, tools_issue_labels, step_name="release") + install_prepared_recipe_segment(monkeypatch, step_name="release", success_carrier=None) result = json.loads( await release_issue( "https://github.com/owner/repo/issues/42", @@ -59,7 +60,8 @@ async def test_release_issue_success_returns_recovery_segment( ) assert result["success"] is True assert result["issue_number"] == 42 - assert_recovery_recipe_segment(result, step_name="release") + assert "recipe_segment" not in result + assert_delivery_outcome("release_issue", result, expected=True) # P5F4-T2 @pytest.mark.anyio diff --git a/tests/server/test_tools_plan_set.py b/tests/server/test_tools_plan_set.py index 89365d8929..17994b7ebf 100644 --- a/tests/server/test_tools_plan_set.py +++ b/tests/server/test_tools_plan_set.py @@ -16,25 +16,41 @@ pytestmark = [pytest.mark.layer("server"), pytest.mark.anyio, pytest.mark.medium] _FIXTURE = Path(__file__).resolve().parents[1] / "fixtures" / "plan_set" / "split_r1_r2" +_MISSING_R2_FIXTURE = _FIXTURE.parent / "missing_r2" class _GitHub: - def __init__(self, *, success: bool = True) -> None: + def __init__(self, *, success: bool = True, fixture_dir: Path = _FIXTURE) -> None: self.success = success + self.fixture_dir = fixture_dir async def fetch_issue(self, *_args: object, **_kwargs: object) -> dict[str, object]: if not self.success: return {"success": False, "error": "offline"} - return {"success": True, "issue_number": 1, "body": (_FIXTURE / "issue.md").read_text()} - - -def _parts(tmp_path: Path, tool_ctx, monkeypatch: pytest.MonkeyPatch) -> tuple[Path, Path, Path]: + return { + "success": True, + "issue_number": 1, + "body": (self.fixture_dir / "issue.md").read_text(), + } + + +def _parts( + tmp_path: Path, + tool_ctx, + monkeypatch: pytest.MonkeyPatch, + *, + fixture_dir: Path = _FIXTURE, +) -> tuple[Path, Path, Path]: root = resolve_temp_dir(tmp_path, tool_ctx.config.workspace.temp_dir) root.mkdir(parents=True, exist_ok=True) parts = (root / "part_a.md", root / "part_b.md") for part in parts: - part.write_text((_FIXTURE / part.name).read_text(), encoding="utf-8") - monkeypatch.setattr(tool_ctx, "plan_set_materializer", DefaultPlanSetMaterializer(_GitHub())) + part.write_text((fixture_dir / part.name).read_text(), encoding="utf-8") + monkeypatch.setattr( + tool_ctx, + "plan_set_materializer", + DefaultPlanSetMaterializer(_GitHub(fixture_dir=fixture_dir)), + ) return root, *parts @@ -157,3 +173,43 @@ async def test_wrapper_classifies_inputs_and_failures( assert result["success"] is True authority = json.loads(Path(result["plan_set_authority_path"]).read_text()) assert authority["inventory_mode"] == "no_issue" + + +_IMPLEMENTATION_OVERRIDES = { + "issue_url": "https://github.com/TalonT-Org/AutoSkillit/issues/5256", + "task": "test task", +} + + +@pytest.mark.parametrize( + "tool_ctx_ready_recipe", + [pytest.param(("implementation", "plan", _IMPLEMENTATION_OVERRIDES), id="implementation")], + indirect=True, +) +async def test_bind_plan_set_coverage_failure_delivers_recovery( + tool_ctx_ready_recipe, + git_checkout: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from tests.server._recipe_segment_test_helpers import assert_delivery_outcome + + _, first, second = _parts( + git_checkout, + tool_ctx_ready_recipe.tool_ctx, + monkeypatch, + fixture_dir=_MISSING_R2_FIXTURE, + ) + result = json.loads( + await bind_plan_set( + step_name="bind_plan_set", + plan_parts=f"{first}\n{second}", + cwd=str(git_checkout), + issue_url=_IMPLEMENTATION_OVERRIDES["issue_url"], + ) + ) + + assert result["reason"] == PlanSetRejectReason.COVERAGE_FAILED.value + assert_delivery_outcome("bind_plan_set", result, expected=False) + segment = result["recipe_segment"] + assert segment["kind"] == "recovery" + assert "release_issue_failure" in segment["target_steps"] diff --git a/tests/server/test_tools_pr_ops.py b/tests/server/test_tools_pr_ops.py index 1f8f7eb2e6..014f80fc70 100644 --- a/tests/server/test_tools_pr_ops.py +++ b/tests/server/test_tools_pr_ops.py @@ -30,7 +30,7 @@ verify_review_receipt, ) -pytestmark = [pytest.mark.layer("server"), pytest.mark.small] +pytestmark = [pytest.mark.layer("server"), pytest.mark.medium] class _FakeReviewPoster: @@ -194,7 +194,7 @@ def _write_receipt(cwd: Path, payload: dict[str, object]) -> Path: return path -async def _verify_receipt(cwd: Path, receipt_path: Path, **overrides: object) -> dict[str, str]: +async def _verify_receipt(cwd: Path, receipt_path: Path, **overrides: object) -> dict[str, object]: arguments: dict[str, object] = { "cwd": str(cwd.resolve()), "receipt_path": str(receipt_path), @@ -322,6 +322,53 @@ async def test_local_mode_gate_requires_explicit_local_marker( assert result == {"reviews_posted": expected} +_IMPLEMENTATION_OVERRIDES = { + "issue_url": "https://github.com/TalonT-Org/AutoSkillit/issues/5256", + "task": "test task", +} + + +@pytest.mark.anyio +@pytest.mark.parametrize( + "tool_ctx_ready_recipe", + [pytest.param(("implementation", "plan", _IMPLEMENTATION_OVERRIDES), id="implementation")], + indirect=True, +) +@pytest.mark.parametrize( + ("post_state", "expected", "expected_segment"), + [("", "false", "recovery"), ("LOCAL", "true", None)], + ids=("unposted", "posted"), +) +async def test_verify_review_receipt_delivers_ready_checkpoint( + tool_ctx_ready_recipe, + tmp_path: Path, + post_state: str, + expected: str, + expected_segment: str | None, +) -> None: + from tests.server._recipe_segment_test_helpers import assert_delivery_outcome + + result = await _verify_receipt( + tmp_path, + tmp_path / "missing.json", + mode="local", + post_state=post_state, + step_name="check_review_posted", + ) + + assert result["reviews_posted"] == expected + assert_delivery_outcome("verify_review_receipt", result, expected=post_state == "LOCAL") + if expected_segment is None: + assert "recipe_segment" not in result + else: + segment = result["recipe_segment"] + assert isinstance(segment, dict) + assert segment["kind"] == expected_segment + target_steps = segment["target_steps"] + assert isinstance(target_steps, list) + assert "release_issue_failure" in target_steps + + @pytest.mark.anyio async def test_verify_review_receipt_gate_closed(tool_ctx, tmp_path: Path) -> None: tool_ctx.gate = DefaultGateState(enabled=False) diff --git a/tests/server/test_tools_run_cmd.py b/tests/server/test_tools_run_cmd.py index 64f36d05bc..0742bbcf45 100644 --- a/tests/server/test_tools_run_cmd.py +++ b/tests/server/test_tools_run_cmd.py @@ -9,11 +9,11 @@ import pytest import structlog.testing -import autoskillit.server.tools.tools_execution as tools_execution from autoskillit.server._subprocess import _run_subprocess from autoskillit.server.tools.tools_execution import run_cmd, run_python from tests.conftest import _make_result, _make_timeout_result from tests.server._recipe_segment_test_helpers import ( + assert_delivery_outcome, assert_recovery_recipe_segment, install_prepared_recipe_segment, ) @@ -55,18 +55,36 @@ async def test_run_cmd_attaches_recovery_carrier_only_on_failure( returncode: int, expected_success: bool, ) -> None: - install_prepared_recipe_segment(monkeypatch, tools_execution, step_name="command") + install_prepared_recipe_segment(monkeypatch, step_name="command", success_carrier=None) tool_ctx_kitchen_open.runner.push(_make_result(returncode, "stdout", "stderr")) result = json.loads(await run_cmd(cmd="command", cwd="/tmp", step_name="command")) assert result["success"] is expected_success assert result["exit_code"] == returncode + assert_delivery_outcome("run_cmd", result, expected=expected_success) if expected_success: assert "recipe_segment" not in result else: assert_recovery_recipe_segment(result, step_name="command") + @pytest.mark.anyio + async def test_test_gate_refusal_returns_recovery_carrier( + self, + tool_ctx_kitchen_open, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + install_prepared_recipe_segment(monkeypatch, step_name="command", success_carrier=None) + + result = json.loads(await run_cmd(cmd="uv run pytest -q", cwd="/tmp", step_name="command")) + + assert result["success"] is False + assert result["subtype"] == "gate_error" + assert "test_check" in json.dumps(result) + assert_recovery_recipe_segment(result, step_name="command") + assert_delivery_outcome("run_cmd", result, expected=False) + assert tool_ctx_kitchen_open.runner.call_args_list == [] + @pytest.mark.anyio async def test_custom_timeout(self, tool_ctx_kitchen_open): tool_ctx_kitchen_open.runner.push(_make_result(0, "", "")) diff --git a/tests/server/test_tools_run_python.py b/tests/server/test_tools_run_python.py index dd94b80bb2..b4005a8d32 100644 --- a/tests/server/test_tools_run_python.py +++ b/tests/server/test_tools_run_python.py @@ -11,9 +11,18 @@ import autoskillit.server.tools.tools_execution as tools_execution from autoskillit.server.tools.tools_execution import run_python -from tests.server._recipe_segment_test_helpers import install_prepared_recipe_segment +from tests.server._recipe_segment_test_helpers import ( + assert_delivery_outcome, + assert_real_checkpoint_response_budget, + install_prepared_recipe_segment, +) -pytestmark = [pytest.mark.layer("server"), pytest.mark.small] +pytestmark = [pytest.mark.layer("server"), pytest.mark.medium] + +_IMPLEMENTATION_OVERRIDES = { + "issue_url": "https://github.com/TalonT-Org/AutoSkillit/issues/5256", + "task": "test task", +} class TestRunPythonObservability: @@ -51,17 +60,24 @@ async def test_run_python_returns_failure_result_on_bad_module(self, tool_ctx, m ("nonexistent.module.func", {}, "recovery"), ], ) + @pytest.mark.parametrize( + "tool_ctx_ready_recipe", + [pytest.param(("implementation", "plan", _IMPLEMENTATION_OVERRIDES), id="implementation")], + indirect=True, + ) @pytest.mark.anyio async def test_run_python_selects_carrier_from_result_success( self, tool_ctx_kitchen_open, + tool_ctx_ready_recipe, + tmp_path, mock_ctx, monkeypatch: pytest.MonkeyPatch, callable_name: str, args: dict[str, object], expected_kind: str, ) -> None: - install_prepared_recipe_segment(monkeypatch, tools_execution, step_name="python") + install_prepared_recipe_segment(monkeypatch, step_name="python") result = json.loads( await run_python( @@ -74,6 +90,12 @@ async def test_run_python_selects_carrier_from_result_success( assert result["success"] is (expected_kind == "success") assert result["recipe_segment"]["kind"] == expected_kind + succeeded = expected_kind == "success" + assert_delivery_outcome("run_python", result, expected=succeeded) + if succeeded: + await assert_real_checkpoint_response_budget( + tool_ctx_ready_recipe, "run_python", result, tmp_path + ) @pytest.mark.anyio async def test_run_python_selects_carrier_from_shaped_failure( @@ -82,7 +104,7 @@ async def test_run_python_selects_carrier_from_shaped_failure( mock_ctx, monkeypatch: pytest.MonkeyPatch, ) -> None: - install_prepared_recipe_segment(monkeypatch, tools_execution, step_name="python") + install_prepared_recipe_segment(monkeypatch, step_name="python") monkeypatch.setattr( tools_execution, "shape_execution_response", @@ -102,6 +124,7 @@ async def test_run_python_selects_carrier_from_shaped_failure( assert result["success"] is False assert result["recipe_segment"]["kind"] == "recovery" + assert_delivery_outcome("run_python", result, expected=False) class TestRunPythonHeadlessGate: diff --git a/tests/server/test_tools_test_check.py b/tests/server/test_tools_test_check.py index 18167b6081..6d30ec9243 100644 --- a/tests/server/test_tools_test_check.py +++ b/tests/server/test_tools_test_check.py @@ -14,6 +14,7 @@ from tests.conftest import _make_result from tests.server._outcome_ledger_fakes import _FailingLedger, _RecordingLedger from tests.server._recipe_segment_test_helpers import ( + assert_delivery_outcome, assert_recovery_recipe_segment, install_prepared_recipe_segment, ) @@ -38,6 +39,7 @@ async def test_wire_passed_value_records_one_realpath_utc_outcome(tool_ctx, tmp_ result = json.loads(await test_check(str(worktree / "."))) assert result["passed"] is True + assert_delivery_outcome("test_check", result, expected=True) assert len(ledger.records) == 1 record = ledger.records[0] assert record.workspace == os.path.realpath(worktree) @@ -72,6 +74,7 @@ async def test_accounting_uses_wire_passed_instead_of_internal_effective_value( result = json.loads(await test_check(str(worktree))) assert result["passed"] is False + assert_delivery_outcome("test_check", result, expected=False) assert len(ledger.records) == 1 assert ledger.records[0].succeeded is False @@ -102,7 +105,6 @@ async def test_ledger_failure_returns_recovery_envelope_without_recursive_retry( tool_ctx.runner.push(_make_result(0, "= 1 passed =\n", "")) install_prepared_recipe_segment( monkeypatch, - tools_workspace, step_name="gate", ) @@ -111,6 +113,7 @@ async def test_ledger_failure_returns_recovery_envelope_without_recursive_retry( assert result["passed"] is False assert "outcome recording failed" in result["error"] assert_recovery_recipe_segment(result, step_name="gate") + assert_delivery_outcome("test_check", result, expected=False) assert ledger.calls == 1 assert len(tool_ctx.runner.call_args_list) == 1 assert any(entry["step_name"] == "gate" for entry in tool_ctx.timing_log.get_report()) diff --git a/tests/server/test_tools_workspace.py b/tests/server/test_tools_workspace.py index d00c4c9dbc..1ed5a03b11 100644 --- a/tests/server/test_tools_workspace.py +++ b/tests/server/test_tools_workspace.py @@ -25,11 +25,18 @@ from autoskillit.workspace import CleanupResult from tests.conftest import _make_result from tests.server._recipe_segment_test_helpers import ( + assert_delivery_outcome, + assert_real_checkpoint_response_budget, assert_recovery_recipe_segment, install_prepared_recipe_segment, ) -pytestmark = [pytest.mark.layer("server"), pytest.mark.small] +pytestmark = [pytest.mark.layer("server"), pytest.mark.medium] + +_IMPLEMENTATION_OVERRIDES = { + "issue_url": "https://github.com/TalonT-Org/AutoSkillit/issues/5256", + "task": "test task", +} test_check.__test__ = False # type: ignore[attr-defined] @@ -206,7 +213,7 @@ async def test_outer_timeout_normalizes_a_contradictory_runner_result( tool_ctx.config.output_budget.inline_max_chars = 20 tool_ctx.config.output_budget.head_chars = 10 tool_ctx.config.output_budget.tail_chars = 10 - install_prepared_recipe_segment(monkeypatch, tools_workspace, step_name="gate") + install_prepared_recipe_segment(monkeypatch, step_name="gate") async def timed_out_runner(_cwd: Path) -> TestResult: return TestResult( @@ -243,6 +250,7 @@ async def capture_notification( {"worktree": self.wt}, ) in notifications assert_recovery_recipe_segment(result, step_name="gate") + assert_delivery_outcome("test_check", result, expected=False) @pytest.mark.parametrize( ("returncode", "output", "expected_kind"), @@ -251,22 +259,35 @@ async def capture_notification( (1, "= 1 failed, 9 passed =\n", "recovery"), ], ) + @pytest.mark.parametrize( + "tool_ctx_ready_recipe", + [pytest.param(("implementation", "plan", _IMPLEMENTATION_OVERRIDES), id="implementation")], + indirect=True, + ) @pytest.mark.anyio async def test_test_check_selects_carrier_only_from_passed_true( self, - tool_ctx, + tool_ctx_ready_recipe, + tmp_path, monkeypatch: pytest.MonkeyPatch, returncode: int, output: str, expected_kind: str, ) -> None: - install_prepared_recipe_segment(monkeypatch, tools_workspace, step_name="gate") + tool_ctx = tool_ctx_ready_recipe.tool_ctx + install_prepared_recipe_segment(monkeypatch, step_name="gate") tool_ctx.runner.push(_make_result(returncode, output, "")) result = json.loads(await test_check(worktree_path=self.wt, step_name="gate")) assert result["passed"] is (expected_kind == "success") assert result["recipe_segment"]["kind"] == expected_kind + succeeded = expected_kind == "success" + assert_delivery_outcome("test_check", result, expected=succeeded) + if succeeded: + await assert_real_checkpoint_response_budget( + tool_ctx_ready_recipe, "test_check", result, tmp_path + ) @pytest.mark.anyio async def test_cross_validates_exit_code_against_output(self, tool_ctx): From a18501f4a0f38312137dc1a31d48e14f84f45d72 Mon Sep 17 00:00:00 2001 From: Trecek Date: Wed, 14 Oct 2026 14:10:54 -0700 Subject: [PATCH 03/46] fix(review): centralize successful checkpoint outcome assertions --- tests/server/_recipe_segment_test_helpers.py | 1 + tests/server/test_complete_run_skill_result_delivery.py | 3 ++- tests/server/test_tools_bootstrap.py | 1 - tests/server/test_tools_ci_enqueue.py | 1 - tests/server/test_tools_ci_merge_state.py | 1 - tests/server/test_tools_ci_wait_for_ci.py | 1 - tests/server/test_tools_ci_wait_for_merge_queue.py | 1 - tests/server/test_tools_clone.py | 1 - tests/server/test_tools_git.py | 1 - tests/server/test_tools_git_branch.py | 1 - tests/server/test_tools_run_python.py | 3 ++- tests/server/test_tools_workspace.py | 3 ++- 12 files changed, 7 insertions(+), 11 deletions(-) diff --git a/tests/server/_recipe_segment_test_helpers.py b/tests/server/_recipe_segment_test_helpers.py index 06a645b52c..03626e9dac 100644 --- a/tests/server/_recipe_segment_test_helpers.py +++ b/tests/server/_recipe_segment_test_helpers.py @@ -127,6 +127,7 @@ def _real_success_delivery(ready, tool_name: str) -> PreparedRecipeSegmentDelive async def assert_real_checkpoint_response_budget( ready, tool_name: str, result: dict[str, Any], artifact_dir: Path ) -> None: + assert_delivery_outcome(tool_name, result, expected=True) prepared = _real_success_delivery(ready, tool_name) assert prepared.success_carrier is not None assert prepared.success_pull_carrier is not None diff --git a/tests/server/test_complete_run_skill_result_delivery.py b/tests/server/test_complete_run_skill_result_delivery.py index 415dc21db3..f0637a89c4 100644 --- a/tests/server/test_complete_run_skill_result_delivery.py +++ b/tests/server/test_complete_run_skill_result_delivery.py @@ -69,7 +69,8 @@ async def test_completion_receipt_delivery_carries_plan_step_name( assert response["success"] is True assert response["step_name"] == "plan" assert response["run_skill_success"] is run_skill_success - assert_delivery_outcome("complete_run_skill_result", response, expected=run_skill_success) + if not run_skill_success: + assert_delivery_outcome("complete_run_skill_result", response, expected=False) assert response["recipe_segment"]["kind"] == expected_segment if run_skill_success: await assert_real_checkpoint_response_budget( diff --git a/tests/server/test_tools_bootstrap.py b/tests/server/test_tools_bootstrap.py index 978725fed6..c40534a8c4 100644 --- a/tests/server/test_tools_bootstrap.py +++ b/tests/server/test_tools_bootstrap.py @@ -104,7 +104,6 @@ async def test_bootstrap_clone_success( assert result["base_sha"] == expected_base_sha assert result["merge_target"] == "develop" assert result["recipe_segment"]["kind"] == "success" - assert_delivery_outcome("bootstrap_clone", result, expected=True) await assert_real_checkpoint_response_budget( tool_ctx_ready_recipe, "bootstrap_clone", result, tmp_path ) diff --git a/tests/server/test_tools_ci_enqueue.py b/tests/server/test_tools_ci_enqueue.py index 813a8acaad..2287c06775 100644 --- a/tests/server/test_tools_ci_enqueue.py +++ b/tests/server/test_tools_ci_enqueue.py @@ -65,7 +65,6 @@ async def test_enqueue_pr_delegates_to_watcher_enqueue( assert result["success"] is True assert result["enrollment_method"] == "direct_enqueue" assert result["recipe_segment"]["kind"] == "success" - assert_delivery_outcome("enqueue_pr", result, expected=True) await assert_real_checkpoint_response_budget( tool_ctx_ready_recipe, "enqueue_pr", result, tmp_path ) diff --git a/tests/server/test_tools_ci_merge_state.py b/tests/server/test_tools_ci_merge_state.py index 6ab7e46d26..0fb7bfcebd 100644 --- a/tests/server/test_tools_ci_merge_state.py +++ b/tests/server/test_tools_ci_merge_state.py @@ -72,7 +72,6 @@ async def fake_fetch(owner, repo, branch, token, base_branch=None): assert captured_tokens == ["factory-token"] assert resolved_calls == [1] assert result["recipe_segment"]["kind"] == "success" - assert_delivery_outcome("check_repo_merge_state", result, expected=True) await assert_real_checkpoint_response_budget( tool_ctx_ready_recipe, "check_repo_merge_state", result, tmp_path ) diff --git a/tests/server/test_tools_ci_wait_for_ci.py b/tests/server/test_tools_ci_wait_for_ci.py index be48d8bc77..8ca1cd01e6 100644 --- a/tests/server/test_tools_ci_wait_for_ci.py +++ b/tests/server/test_tools_ci_wait_for_ci.py @@ -66,7 +66,6 @@ async def test_wait_for_ci_success_response( assert result["conclusion"] == "success" assert result["failed_jobs"] == [] assert result["recipe_segment"]["kind"] == "success" - assert_delivery_outcome("wait_for_ci", result, expected=True) await assert_real_checkpoint_response_budget( tool_ctx_ready_recipe, "wait_for_ci", result, tmp_path ) diff --git a/tests/server/test_tools_ci_wait_for_merge_queue.py b/tests/server/test_tools_ci_wait_for_merge_queue.py index 8cd5931545..dd43d721ee 100644 --- a/tests/server/test_tools_ci_wait_for_merge_queue.py +++ b/tests/server/test_tools_ci_wait_for_merge_queue.py @@ -70,7 +70,6 @@ async def test_delegates_to_merge_queue_watcher( assert watcher.wait_calls[-1]["pr_number"] == 42 assert watcher.wait_calls[-1]["target_branch"] == "develop" assert result["recipe_segment"]["kind"] == "success" - assert_delivery_outcome("wait_for_merge_queue", result, expected=True) await assert_real_checkpoint_response_budget( tool_ctx_ready_recipe, "wait_for_merge_queue", result, tmp_path ) diff --git a/tests/server/test_tools_clone.py b/tests/server/test_tools_clone.py index f1943b5f55..a125ea58a7 100644 --- a/tests/server/test_tools_clone.py +++ b/tests/server/test_tools_clone.py @@ -216,7 +216,6 @@ async def test_delegates_to_workspace_clone_on_success( assert result["success"] is True assert "error" not in result assert result["recipe_segment"]["kind"] == "success" - assert_delivery_outcome("push_to_remote", result, expected=True) await assert_real_checkpoint_response_budget( tool_ctx_ready_recipe, "push_to_remote", result, tmp_path ) diff --git a/tests/server/test_tools_git.py b/tests/server/test_tools_git.py index 3739b1d0a3..8d77f2f31a 100644 --- a/tests/server/test_tools_git.py +++ b/tests/server/test_tools_git.py @@ -130,7 +130,6 @@ async def test_merge_worktree_merges_on_green( assert "test_stderr" not in result assert "raw_output_artifact_path" not in result assert result["recipe_segment"]["kind"] == "success" - assert_delivery_outcome("merge_worktree", result, expected=True) await assert_real_checkpoint_response_budget( tool_ctx_ready_recipe, "merge_worktree", result, tmp_path ) diff --git a/tests/server/test_tools_git_branch.py b/tests/server/test_tools_git_branch.py index e7b15536f5..1614c5abce 100644 --- a/tests/server/test_tools_git_branch.py +++ b/tests/server/test_tools_git_branch.py @@ -159,7 +159,6 @@ async def test_mergeable_pr( assert result["merge_state_status"] == "CLEAN" assert result["mergeable_status"] == "MERGEABLE" assert result["recipe_segment"]["kind"] == "success" - assert_delivery_outcome("check_pr_mergeable", result, expected=True) await assert_real_checkpoint_response_budget( tool_ctx_ready_recipe, "check_pr_mergeable", result, tmp_path ) diff --git a/tests/server/test_tools_run_python.py b/tests/server/test_tools_run_python.py index b4005a8d32..791caf7c52 100644 --- a/tests/server/test_tools_run_python.py +++ b/tests/server/test_tools_run_python.py @@ -91,7 +91,8 @@ async def test_run_python_selects_carrier_from_result_success( assert result["success"] is (expected_kind == "success") assert result["recipe_segment"]["kind"] == expected_kind succeeded = expected_kind == "success" - assert_delivery_outcome("run_python", result, expected=succeeded) + if not succeeded: + assert_delivery_outcome("run_python", result, expected=False) if succeeded: await assert_real_checkpoint_response_budget( tool_ctx_ready_recipe, "run_python", result, tmp_path diff --git a/tests/server/test_tools_workspace.py b/tests/server/test_tools_workspace.py index 1ed5a03b11..872bd628bd 100644 --- a/tests/server/test_tools_workspace.py +++ b/tests/server/test_tools_workspace.py @@ -283,7 +283,8 @@ async def test_test_check_selects_carrier_only_from_passed_true( assert result["passed"] is (expected_kind == "success") assert result["recipe_segment"]["kind"] == expected_kind succeeded = expected_kind == "success" - assert_delivery_outcome("test_check", result, expected=succeeded) + if not succeeded: + assert_delivery_outcome("test_check", result, expected=False) if succeeded: await assert_real_checkpoint_response_budget( tool_ctx_ready_recipe, "test_check", result, tmp_path From 6fc6bdc9e2aa503d234dd1329ff18b117209520d Mon Sep 17 00:00:00 2001 From: Trecek Date: Wed, 14 Oct 2026 14:12:28 -0700 Subject: [PATCH 04/46] fix(review): share READY recipe overrides and implementation parameters --- tests/server/_recipe_fixture_parameters.py | 12 ++++++++++++ ...attested_dispatch_write_scope_admission.py | 13 ++++++------- ...test_complete_run_skill_result_delivery.py | 11 +++++------ ...st_implementation_plan_bind_verify_walk.py | 19 +++++++++---------- ...t_recipe_execution_receipt_completeness.py | 9 ++++----- tests/server/test_recipe_segment_delivery.py | 12 ++++++------ tests/server/test_tools_bootstrap.py | 10 ++++------ tests/server/test_tools_ci_enqueue.py | 10 ++++------ tests/server/test_tools_ci_merge_state.py | 10 ++++------ tests/server/test_tools_ci_wait_for_ci.py | 10 ++++------ .../test_tools_ci_wait_for_merge_queue.py | 10 ++++------ tests/server/test_tools_clone.py | 10 ++++------ tests/server/test_tools_git.py | 10 ++++------ tests/server/test_tools_git_branch.py | 10 ++++------ tests/server/test_tools_plan_set.py | 14 ++++++-------- tests/server/test_tools_pr_ops.py | 11 ++++------- tests/server/test_tools_run_python.py | 10 ++++------ tests/server/test_tools_workspace.py | 9 ++++----- 18 files changed, 92 insertions(+), 108 deletions(-) create mode 100644 tests/server/_recipe_fixture_parameters.py diff --git a/tests/server/_recipe_fixture_parameters.py b/tests/server/_recipe_fixture_parameters.py new file mode 100644 index 0000000000..4f7b7d2a60 --- /dev/null +++ b/tests/server/_recipe_fixture_parameters.py @@ -0,0 +1,12 @@ +"""Shared READY-recipe inputs and parametrization for server tests.""" + +import pytest + +IMPLEMENTATION_OVERRIDES = { + "issue_url": "https://github.com/TalonT-Org/AutoSkillit/issues/5256", + "task": "test task", +} +REVIEW_APPROACH_OVERRIDES = {**IMPLEMENTATION_OVERRIDES, "review_approach": "true"} +IMPLEMENTATION_READY_PARAMETERS = [ + pytest.param(("implementation", "plan", IMPLEMENTATION_OVERRIDES), id="implementation"), +] diff --git a/tests/server/test_attested_dispatch_write_scope_admission.py b/tests/server/test_attested_dispatch_write_scope_admission.py index 847851f9bb..6c74a0b3f3 100644 --- a/tests/server/test_attested_dispatch_write_scope_admission.py +++ b/tests/server/test_attested_dispatch_write_scope_admission.py @@ -7,19 +7,18 @@ import pytest -pytestmark = [pytest.mark.layer("server"), pytest.mark.anyio, pytest.mark.medium] +from tests.server._recipe_fixture_parameters import ( + IMPLEMENTATION_OVERRIDES, +) -_IMPLEMENTATION_OVERRIDES = { - "issue_url": "https://github.com/TalonT-Org/AutoSkillit/issues/5256", - "task": "test task", -} +pytestmark = [pytest.mark.layer("server"), pytest.mark.anyio, pytest.mark.medium] @pytest.mark.parametrize( ("tool_ctx_ready_recipe", "skill"), [ - (("implementation", "plan", _IMPLEMENTATION_OVERRIDES), "make-plan"), - (("implementation", "prepare_pr", _IMPLEMENTATION_OVERRIDES), "prepare-pr"), + (("implementation", "plan", IMPLEMENTATION_OVERRIDES), "make-plan"), + (("implementation", "prepare_pr", IMPLEMENTATION_OVERRIDES), "prepare-pr"), ], indirect=["tool_ctx_ready_recipe"], ) diff --git a/tests/server/test_complete_run_skill_result_delivery.py b/tests/server/test_complete_run_skill_result_delivery.py index f0637a89c4..9c5eb61a91 100644 --- a/tests/server/test_complete_run_skill_result_delivery.py +++ b/tests/server/test_complete_run_skill_result_delivery.py @@ -7,12 +7,11 @@ import pytest -pytestmark = [pytest.mark.layer("server"), pytest.mark.anyio, pytest.mark.medium] +from tests.server._recipe_fixture_parameters import ( + IMPLEMENTATION_READY_PARAMETERS, +) -_IMPLEMENTATION_OVERRIDES = { - "issue_url": "https://github.com/TalonT-Org/AutoSkillit/issues/5256", - "task": "test task", -} +pytestmark = [pytest.mark.layer("server"), pytest.mark.anyio, pytest.mark.medium] def _publish_receipt(tool_ctx, *, success: bool) -> str: @@ -38,7 +37,7 @@ def _publish_receipt(tool_ctx, *, success: bool) -> str: @pytest.mark.parametrize( "tool_ctx_ready_recipe", - [pytest.param(("implementation", "plan", _IMPLEMENTATION_OVERRIDES), id="implementation")], + IMPLEMENTATION_READY_PARAMETERS, indirect=True, ) @pytest.mark.parametrize( diff --git a/tests/server/test_implementation_plan_bind_verify_walk.py b/tests/server/test_implementation_plan_bind_verify_walk.py index d7d51b3118..3d760d53f8 100644 --- a/tests/server/test_implementation_plan_bind_verify_walk.py +++ b/tests/server/test_implementation_plan_bind_verify_walk.py @@ -10,14 +10,13 @@ import pytest from tests.server._pipeline_test_helpers import _ack_direct_run_skill_result +from tests.server._recipe_fixture_parameters import ( + IMPLEMENTATION_OVERRIDES, + REVIEW_APPROACH_OVERRIDES, +) pytestmark = [pytest.mark.layer("server"), pytest.mark.anyio, pytest.mark.medium] -_IMPLEMENTATION_OVERRIDES = { - "issue_url": "https://github.com/TalonT-Org/AutoSkillit/issues/5256", - "task": "test task", -} -_REVIEW_APPROACH_OVERRIDES = {**_IMPLEMENTATION_OVERRIDES, "review_approach": "true"} _INPUT_REFERENCE = re.compile(r"\$\{\{\s*(inputs|context)\.([A-Za-z_][A-Za-z0-9_]*)\s*\}\}") _INPUT_DEFAULTS = {"adversarial_review_level": "auto"} @@ -100,9 +99,9 @@ def _carrier_delivers_step(carrier: dict[str, Any], step_name: str) -> bool: @pytest.mark.parametrize( ("tool_ctx_ready_recipe", "review_approach"), [ - pytest.param(("implementation", "plan", _IMPLEMENTATION_OVERRIDES), False, id="default"), + pytest.param(("implementation", "plan", IMPLEMENTATION_OVERRIDES), False, id="default"), pytest.param( - ("implementation", "plan", _REVIEW_APPROACH_OVERRIDES), True, id="review-approach" + ("implementation", "plan", REVIEW_APPROACH_OVERRIDES), True, id="review-approach" ), ], indirect=["tool_ctx_ready_recipe"], @@ -132,7 +131,7 @@ async def test_plan_bind_verify_walk_uses_only_delivered_responses( "plan", ready.step_body, credential, - _IMPLEMENTATION_OVERRIDES, + IMPLEMENTATION_OVERRIDES, {}, ) assert plan_response["success"] is True @@ -150,7 +149,7 @@ async def test_plan_bind_verify_walk_uses_only_delivered_responses( step_name="bind_plan_set", plan_parts=plan_parts, cwd=str(git_checkout), - issue_url=_IMPLEMENTATION_OVERRIDES["issue_url"], + issue_url=IMPLEMENTATION_OVERRIDES["issue_url"], ) ) assert bind_response["success"] is True, bind_response @@ -186,7 +185,7 @@ async def test_plan_bind_verify_walk_uses_only_delivered_responses( "verify", verify_body, credential, - _IMPLEMENTATION_OVERRIDES, + IMPLEMENTATION_OVERRIDES, {key: value for key, value in verify_context.items() if isinstance(value, str)}, ) serialized = json.dumps(verify_response) diff --git a/tests/server/test_recipe_execution_receipt_completeness.py b/tests/server/test_recipe_execution_receipt_completeness.py index fb90027026..e2280d1ff3 100644 --- a/tests/server/test_recipe_execution_receipt_completeness.py +++ b/tests/server/test_recipe_execution_receipt_completeness.py @@ -10,14 +10,13 @@ from autoskillit.core import FinalizedRecipeProjection, build_recipe_execution_credential from autoskillit.server.recipe._recipe_execution import get_recipe_execution +from tests.server._recipe_fixture_parameters import ( + IMPLEMENTATION_OVERRIDES, +) pytestmark = [pytest.mark.layer("server"), pytest.mark.anyio, pytest.mark.medium] _PROJECT_ROOT = Path(__file__).resolve().parents[2] -_IMPLEMENTATION_OVERRIDES = { - "issue_url": "https://github.com/TalonT-Org/AutoSkillit/issues/5256", - "task": "test task", -} def _recipe_fixture_parameters() -> list[tuple[str, str, dict[str, str]]]: @@ -42,7 +41,7 @@ def _recipe_fixture_parameters() -> list[tuple[str, str, dict[str, str]]]: if invocation.tool_name == "run_skill" and invocation.attested ) overrides = ( - _READY_RECIPE_OVERRIDES if recipe_name == "research" else _IMPLEMENTATION_OVERRIDES + _READY_RECIPE_OVERRIDES if recipe_name == "research" else IMPLEMENTATION_OVERRIDES ) parameters.append((recipe_name, attested_step, overrides)) return parameters diff --git a/tests/server/test_recipe_segment_delivery.py b/tests/server/test_recipe_segment_delivery.py index ee1559e477..469605f878 100644 --- a/tests/server/test_recipe_segment_delivery.py +++ b/tests/server/test_recipe_segment_delivery.py @@ -37,20 +37,20 @@ RESPONSE_SPILL_METADATA_KEY, enforce_response_budget, ) +from tests.server._recipe_fixture_parameters import ( + IMPLEMENTATION_OVERRIDES, + REVIEW_APPROACH_OVERRIDES, +) pytestmark = [pytest.mark.layer("server"), pytest.mark.medium] -_IMPLEMENTATION_OVERRIDES = { - "issue_url": "https://github.com/TalonT-Org/AutoSkillit/issues/5256", - "task": "test task", -} _IMPLEMENTATION_READY_PARAMETERS = [ - pytest.param(("implementation", "plan", _IMPLEMENTATION_OVERRIDES), id="default"), + pytest.param(("implementation", "plan", IMPLEMENTATION_OVERRIDES), id="default"), pytest.param( ( "implementation", "plan", - {**_IMPLEMENTATION_OVERRIDES, "review_approach": "true"}, + REVIEW_APPROACH_OVERRIDES, ), id="review-approach", ), diff --git a/tests/server/test_tools_bootstrap.py b/tests/server/test_tools_bootstrap.py index c40534a8c4..99c0ea6041 100644 --- a/tests/server/test_tools_bootstrap.py +++ b/tests/server/test_tools_bootstrap.py @@ -18,6 +18,9 @@ from autoskillit.server.tools.tools_issue_composite import claim_and_resolve_issue from autoskillit.workspace.clone import DefaultCloneManager from tests.conftest import _make_result +from tests.server._recipe_fixture_parameters import ( + IMPLEMENTATION_READY_PARAMETERS, +) from tests.server._recipe_segment_test_helpers import ( assert_delivery_outcome, assert_real_checkpoint_response_budget, @@ -28,11 +31,6 @@ pytestmark = [pytest.mark.layer("server"), pytest.mark.medium] -_IMPLEMENTATION_OVERRIDES = { - "issue_url": "https://github.com/TalonT-Org/AutoSkillit/issues/5256", - "task": "test task", -} - def _git_stdout(repo_path: Path, *args: str) -> str: return subprocess.run( @@ -72,7 +70,7 @@ class TestBootstrapClone: @pytest.mark.anyio @pytest.mark.parametrize( "tool_ctx_ready_recipe", - [pytest.param(("implementation", "plan", _IMPLEMENTATION_OVERRIDES), id="implementation")], + IMPLEMENTATION_READY_PARAMETERS, indirect=True, ) async def test_bootstrap_clone_success( diff --git a/tests/server/test_tools_ci_enqueue.py b/tests/server/test_tools_ci_enqueue.py index 2287c06775..62681fa897 100644 --- a/tests/server/test_tools_ci_enqueue.py +++ b/tests/server/test_tools_ci_enqueue.py @@ -9,6 +9,9 @@ import autoskillit.server.tools.tools_ci_merge_queue as tools_ci_merge_queue from tests.fakes import InMemoryMergeQueueWatcher +from tests.server._recipe_fixture_parameters import ( + IMPLEMENTATION_READY_PARAMETERS, +) from tests.server._recipe_segment_test_helpers import ( assert_delivery_outcome, assert_real_checkpoint_response_budget, @@ -17,16 +20,11 @@ pytestmark = [pytest.mark.layer("server"), pytest.mark.medium] -_IMPLEMENTATION_OVERRIDES = { - "issue_url": "https://github.com/TalonT-Org/AutoSkillit/issues/5256", - "task": "test task", -} - @pytest.mark.anyio @pytest.mark.parametrize( "tool_ctx_ready_recipe", - [pytest.param(("implementation", "plan", _IMPLEMENTATION_OVERRIDES), id="implementation")], + IMPLEMENTATION_READY_PARAMETERS, indirect=True, ) async def test_enqueue_pr_delegates_to_watcher_enqueue( diff --git a/tests/server/test_tools_ci_merge_state.py b/tests/server/test_tools_ci_merge_state.py index 0fb7bfcebd..2aa834d87e 100644 --- a/tests/server/test_tools_ci_merge_state.py +++ b/tests/server/test_tools_ci_merge_state.py @@ -10,6 +10,9 @@ import autoskillit.server.tools.tools_ci as tools_ci from autoskillit.server.tools.tools_ci import check_repo_merge_state +from tests.server._recipe_fixture_parameters import ( + IMPLEMENTATION_READY_PARAMETERS, +) from tests.server._recipe_segment_test_helpers import ( assert_delivery_outcome, assert_real_checkpoint_response_budget, @@ -18,11 +21,6 @@ pytestmark = [pytest.mark.layer("server"), pytest.mark.medium] -_IMPLEMENTATION_OVERRIDES = { - "issue_url": "https://github.com/TalonT-Org/AutoSkillit/issues/5256", - "task": "test task", -} - # --------------------------------------------------------------------------- # check_repo_merge_state: token_factory and http_status @@ -32,7 +30,7 @@ @pytest.mark.anyio @pytest.mark.parametrize( "tool_ctx_ready_recipe", - [pytest.param(("implementation", "plan", _IMPLEMENTATION_OVERRIDES), id="implementation")], + IMPLEMENTATION_READY_PARAMETERS, indirect=True, ) async def test_check_repo_merge_state_uses_token_factory( diff --git a/tests/server/test_tools_ci_wait_for_ci.py b/tests/server/test_tools_ci_wait_for_ci.py index 8ca1cd01e6..ab55c25d8a 100644 --- a/tests/server/test_tools_ci_wait_for_ci.py +++ b/tests/server/test_tools_ci_wait_for_ci.py @@ -12,6 +12,9 @@ from autoskillit.core import SubprocessResult, TerminationReason from autoskillit.server.tools.tools_ci_watch import wait_for_ci from tests.fakes import InMemoryCIWatcher +from tests.server._recipe_fixture_parameters import ( + IMPLEMENTATION_READY_PARAMETERS, +) from tests.server._recipe_segment_test_helpers import ( assert_delivery_outcome, assert_real_checkpoint_response_budget, @@ -22,11 +25,6 @@ pytestmark = [pytest.mark.layer("server"), pytest.mark.medium] -_IMPLEMENTATION_OVERRIDES = { - "issue_url": "https://github.com/TalonT-Org/AutoSkillit/issues/5256", - "task": "test task", -} - # --------------------------------------------------------------------------- # wait_for_ci success / failure responses @@ -36,7 +34,7 @@ @pytest.mark.anyio @pytest.mark.parametrize( "tool_ctx_ready_recipe", - [pytest.param(("implementation", "plan", _IMPLEMENTATION_OVERRIDES), id="implementation")], + IMPLEMENTATION_READY_PARAMETERS, indirect=True, ) async def test_wait_for_ci_success_response( diff --git a/tests/server/test_tools_ci_wait_for_merge_queue.py b/tests/server/test_tools_ci_wait_for_merge_queue.py index dd43d721ee..d6a86351cd 100644 --- a/tests/server/test_tools_ci_wait_for_merge_queue.py +++ b/tests/server/test_tools_ci_wait_for_merge_queue.py @@ -12,6 +12,9 @@ from autoskillit.core import PRState from autoskillit.server.tools.tools_ci_merge_queue import wait_for_merge_queue from tests.fakes import InMemoryMergeQueueWatcher +from tests.server._recipe_fixture_parameters import ( + IMPLEMENTATION_READY_PARAMETERS, +) from tests.server._recipe_segment_test_helpers import ( assert_delivery_outcome, assert_real_checkpoint_response_budget, @@ -21,16 +24,11 @@ pytestmark = [pytest.mark.layer("server"), pytest.mark.medium] -_IMPLEMENTATION_OVERRIDES = { - "issue_url": "https://github.com/TalonT-Org/AutoSkillit/issues/5256", - "task": "test task", -} - @pytest.mark.anyio @pytest.mark.parametrize( "tool_ctx_ready_recipe", - [pytest.param(("implementation", "plan", _IMPLEMENTATION_OVERRIDES), id="implementation")], + IMPLEMENTATION_READY_PARAMETERS, indirect=True, ) async def test_delegates_to_merge_queue_watcher( diff --git a/tests/server/test_tools_clone.py b/tests/server/test_tools_clone.py index a125ea58a7..f96c22f435 100644 --- a/tests/server/test_tools_clone.py +++ b/tests/server/test_tools_clone.py @@ -19,6 +19,9 @@ 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_fixture_parameters import ( + IMPLEMENTATION_READY_PARAMETERS, +) from tests.server._recipe_segment_test_helpers import ( assert_delivery_outcome, assert_real_checkpoint_response_budget, @@ -27,11 +30,6 @@ pytestmark = [pytest.mark.layer("server"), pytest.mark.medium] -_IMPLEMENTATION_OVERRIDES = { - "issue_url": "https://github.com/TalonT-Org/AutoSkillit/issues/5256", - "task": "test task", -} - class TestCloneRepoTool: @pytest.mark.anyio @@ -191,7 +189,7 @@ async def test_returns_gate_error_when_disabled(self, tool_ctx): @pytest.mark.anyio @pytest.mark.parametrize( "tool_ctx_ready_recipe", - [pytest.param(("implementation", "plan", _IMPLEMENTATION_OVERRIDES), id="implementation")], + IMPLEMENTATION_READY_PARAMETERS, indirect=True, ) async def test_delegates_to_workspace_clone_on_success( diff --git a/tests/server/test_tools_git.py b/tests/server/test_tools_git.py index 8d77f2f31a..1801b9e8df 100644 --- a/tests/server/test_tools_git.py +++ b/tests/server/test_tools_git.py @@ -12,6 +12,9 @@ from autoskillit.core.types import MergeFailedStep, MergeState from autoskillit.server.tools.tools_git import merge_worktree from tests.conftest import _make_result +from tests.server._recipe_fixture_parameters import ( + IMPLEMENTATION_READY_PARAMETERS, +) from tests.server._recipe_segment_test_helpers import ( assert_delivery_outcome, assert_real_checkpoint_response_budget, @@ -20,11 +23,6 @@ pytestmark = [pytest.mark.layer("server"), pytest.mark.medium] -_IMPLEMENTATION_OVERRIDES = { - "issue_url": "https://github.com/TalonT-Org/AutoSkillit/issues/5256", - "task": "test task", -} - class TestMergeWorktree: """merge_worktree enforces test gate, rebases, and merges.""" @@ -68,7 +66,7 @@ async def test_merge_worktree_blocks_on_failing_tests( @pytest.mark.anyio @pytest.mark.parametrize( "tool_ctx_ready_recipe", - [pytest.param(("implementation", "plan", _IMPLEMENTATION_OVERRIDES), id="implementation")], + IMPLEMENTATION_READY_PARAMETERS, indirect=True, ) async def test_merge_worktree_merges_on_green( diff --git a/tests/server/test_tools_git_branch.py b/tests/server/test_tools_git_branch.py index 1614c5abce..9a0d6662bf 100644 --- a/tests/server/test_tools_git_branch.py +++ b/tests/server/test_tools_git_branch.py @@ -8,6 +8,9 @@ from autoskillit.server.tools.tools_git import check_pr_mergeable, create_unique_branch from tests.conftest import _make_result +from tests.server._recipe_fixture_parameters import ( + IMPLEMENTATION_READY_PARAMETERS, +) from tests.server._recipe_segment_test_helpers import ( assert_delivery_outcome, assert_real_checkpoint_response_budget, @@ -16,11 +19,6 @@ pytestmark = [pytest.mark.layer("server"), pytest.mark.medium] -_IMPLEMENTATION_OVERRIDES = { - "issue_url": "https://github.com/TalonT-Org/AutoSkillit/issues/5256", - "task": "test task", -} - class TestCreateUniqueBranch: @pytest.mark.anyio @@ -138,7 +136,7 @@ class TestCheckPrMergeable: @pytest.mark.anyio @pytest.mark.parametrize( "tool_ctx_ready_recipe", - [pytest.param(("implementation", "plan", _IMPLEMENTATION_OVERRIDES), id="implementation")], + IMPLEMENTATION_READY_PARAMETERS, indirect=True, ) async def test_mergeable_pr( diff --git a/tests/server/test_tools_plan_set.py b/tests/server/test_tools_plan_set.py index 17994b7ebf..bb384d77e5 100644 --- a/tests/server/test_tools_plan_set.py +++ b/tests/server/test_tools_plan_set.py @@ -12,6 +12,10 @@ from autoskillit.core import PlanSetRejectReason, resolve_temp_dir from autoskillit.server._plan_set_materializer import DefaultPlanSetMaterializer from autoskillit.server.tools.tools_plan_set import bind_plan_set +from tests.server._recipe_fixture_parameters import ( + IMPLEMENTATION_OVERRIDES, + IMPLEMENTATION_READY_PARAMETERS, +) pytestmark = [pytest.mark.layer("server"), pytest.mark.anyio, pytest.mark.medium] @@ -175,15 +179,9 @@ async def test_wrapper_classifies_inputs_and_failures( assert authority["inventory_mode"] == "no_issue" -_IMPLEMENTATION_OVERRIDES = { - "issue_url": "https://github.com/TalonT-Org/AutoSkillit/issues/5256", - "task": "test task", -} - - @pytest.mark.parametrize( "tool_ctx_ready_recipe", - [pytest.param(("implementation", "plan", _IMPLEMENTATION_OVERRIDES), id="implementation")], + IMPLEMENTATION_READY_PARAMETERS, indirect=True, ) async def test_bind_plan_set_coverage_failure_delivers_recovery( @@ -204,7 +202,7 @@ async def test_bind_plan_set_coverage_failure_delivers_recovery( step_name="bind_plan_set", plan_parts=f"{first}\n{second}", cwd=str(git_checkout), - issue_url=_IMPLEMENTATION_OVERRIDES["issue_url"], + issue_url=IMPLEMENTATION_OVERRIDES["issue_url"], ) ) diff --git a/tests/server/test_tools_pr_ops.py b/tests/server/test_tools_pr_ops.py index 014f80fc70..2cf18826f3 100644 --- a/tests/server/test_tools_pr_ops.py +++ b/tests/server/test_tools_pr_ops.py @@ -29,6 +29,9 @@ post_pr_review, verify_review_receipt, ) +from tests.server._recipe_fixture_parameters import ( + IMPLEMENTATION_READY_PARAMETERS, +) pytestmark = [pytest.mark.layer("server"), pytest.mark.medium] @@ -322,16 +325,10 @@ async def test_local_mode_gate_requires_explicit_local_marker( assert result == {"reviews_posted": expected} -_IMPLEMENTATION_OVERRIDES = { - "issue_url": "https://github.com/TalonT-Org/AutoSkillit/issues/5256", - "task": "test task", -} - - @pytest.mark.anyio @pytest.mark.parametrize( "tool_ctx_ready_recipe", - [pytest.param(("implementation", "plan", _IMPLEMENTATION_OVERRIDES), id="implementation")], + IMPLEMENTATION_READY_PARAMETERS, indirect=True, ) @pytest.mark.parametrize( diff --git a/tests/server/test_tools_run_python.py b/tests/server/test_tools_run_python.py index 791caf7c52..1945aa3afc 100644 --- a/tests/server/test_tools_run_python.py +++ b/tests/server/test_tools_run_python.py @@ -11,6 +11,9 @@ import autoskillit.server.tools.tools_execution as tools_execution from autoskillit.server.tools.tools_execution import run_python +from tests.server._recipe_fixture_parameters import ( + IMPLEMENTATION_READY_PARAMETERS, +) from tests.server._recipe_segment_test_helpers import ( assert_delivery_outcome, assert_real_checkpoint_response_budget, @@ -19,11 +22,6 @@ pytestmark = [pytest.mark.layer("server"), pytest.mark.medium] -_IMPLEMENTATION_OVERRIDES = { - "issue_url": "https://github.com/TalonT-Org/AutoSkillit/issues/5256", - "task": "test task", -} - class TestRunPythonObservability: """run_python binds structlog contextvars and calls ctx.info/ctx.error.""" @@ -62,7 +60,7 @@ async def test_run_python_returns_failure_result_on_bad_module(self, tool_ctx, m ) @pytest.mark.parametrize( "tool_ctx_ready_recipe", - [pytest.param(("implementation", "plan", _IMPLEMENTATION_OVERRIDES), id="implementation")], + IMPLEMENTATION_READY_PARAMETERS, indirect=True, ) @pytest.mark.anyio diff --git a/tests/server/test_tools_workspace.py b/tests/server/test_tools_workspace.py index 872bd628bd..dfc9180fba 100644 --- a/tests/server/test_tools_workspace.py +++ b/tests/server/test_tools_workspace.py @@ -24,6 +24,9 @@ from autoskillit.server.tools.tools_workspace import reset_test_dir, reset_workspace, test_check from autoskillit.workspace import CleanupResult from tests.conftest import _make_result +from tests.server._recipe_fixture_parameters import ( + IMPLEMENTATION_READY_PARAMETERS, +) from tests.server._recipe_segment_test_helpers import ( assert_delivery_outcome, assert_real_checkpoint_response_budget, @@ -33,10 +36,6 @@ pytestmark = [pytest.mark.layer("server"), pytest.mark.medium] -_IMPLEMENTATION_OVERRIDES = { - "issue_url": "https://github.com/TalonT-Org/AutoSkillit/issues/5256", - "task": "test task", -} test_check.__test__ = False # type: ignore[attr-defined] @@ -261,7 +260,7 @@ async def capture_notification( ) @pytest.mark.parametrize( "tool_ctx_ready_recipe", - [pytest.param(("implementation", "plan", _IMPLEMENTATION_OVERRIDES), id="implementation")], + IMPLEMENTATION_READY_PARAMETERS, indirect=True, ) @pytest.mark.anyio From f4fc73aa6352fb0e8b64adda3591873207cedf32 Mon Sep 17 00:00:00 2001 From: Trecek Date: Wed, 14 Oct 2026 14:13:09 -0700 Subject: [PATCH 05/46] fix(review): require validation of all three checkpoint carriers --- tests/server/test_recipe_segment_delivery.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/server/test_recipe_segment_delivery.py b/tests/server/test_recipe_segment_delivery.py index 469605f878..8987f67ab0 100644 --- a/tests/server/test_recipe_segment_delivery.py +++ b/tests/server/test_recipe_segment_delivery.py @@ -243,8 +243,7 @@ def record_tool_name(carrier, *, tool_name: str): assert prepared.success_carrier is not None assert prepared.success_pull_carrier is not None assert prepared.recovery_carrier is not None - assert seen_tool_names - assert all(tool_name == expected_tool_name for tool_name in seen_tool_names) + assert seen_tool_names == [expected_tool_name] * 3 assert all( carrier in validated_carriers for carrier in ( From de628d5aa23a7b39dacf5e6970efe012528a08b6 Mon Sep 17 00:00:00 2001 From: Trecek Date: Wed, 14 Oct 2026 14:14:25 -0700 Subject: [PATCH 06/46] fix(review): identify unresolved skill inputs in plan-walk failures --- .../test_implementation_plan_bind_verify_walk.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/tests/server/test_implementation_plan_bind_verify_walk.py b/tests/server/test_implementation_plan_bind_verify_walk.py index 3d760d53f8..93d034bf68 100644 --- a/tests/server/test_implementation_plan_bind_verify_walk.py +++ b/tests/server/test_implementation_plan_bind_verify_walk.py @@ -49,9 +49,15 @@ def _skill_inputs( continue source, name = match.groups() if source == "inputs": - values[key] = ( - ingredient_values[name] if name in ingredient_values else _INPUT_DEFAULTS[name] - ) + try: + values[key] = ( + ingredient_values[name] if name in ingredient_values else _INPUT_DEFAULTS[name] + ) + except KeyError as exc: + raise KeyError( + f"Step {step_name!r}, skill input {key!r}: " + f"no value or default for inputs.{name}" + ) from exc else: values[key] = context_values[name] if name in context_values else absence_values[key] return values From dc3c94bad2a4ad741e1e4543ba9ccb3e167d26e8 Mon Sep 17 00:00:00 2001 From: Trecek Date: Wed, 14 Oct 2026 14:16:01 -0700 Subject: [PATCH 07/46] fix(review): share research fixture inputs outside conftest --- tests/server/_recipe_fixture_parameters.py | 7 +++++++ tests/server/conftest.py | 9 ++------- tests/server/test_kitchen_identity_lifecycle.py | 4 ++-- tests/server/test_kitchen_lifecycle.py | 4 ++-- .../server/test_recipe_execution_receipt_completeness.py | 6 ++---- 5 files changed, 15 insertions(+), 15 deletions(-) diff --git a/tests/server/_recipe_fixture_parameters.py b/tests/server/_recipe_fixture_parameters.py index 4f7b7d2a60..8ab0d84ef8 100644 --- a/tests/server/_recipe_fixture_parameters.py +++ b/tests/server/_recipe_fixture_parameters.py @@ -10,3 +10,10 @@ IMPLEMENTATION_READY_PARAMETERS = [ pytest.param(("implementation", "plan", IMPLEMENTATION_OVERRIDES), id="implementation"), ] + +RESEARCH_OVERRIDES = { + "issue_url": "https://github.com/TalonT-Org/AutoSkillit/issues/4411", + "source_dir": ".", + "task": "test task", + "task_description": "test task", +} diff --git a/tests/server/conftest.py b/tests/server/conftest.py index be47a01559..3b195c99d5 100644 --- a/tests/server/conftest.py +++ b/tests/server/conftest.py @@ -17,6 +17,7 @@ import structlog.testing from tests.conftest import bind_test_kitchen_identity +from tests.server._recipe_fixture_parameters import RESEARCH_OVERRIDES if TYPE_CHECKING: from autoskillit.pipeline import ToolContext @@ -397,18 +398,12 @@ def _forbidden(*_args, **_kwargs): _READY_RECIPE_NAME = "research" _READY_RECIPE_ATTESTED_STEP = "scope" -_READY_RECIPE_OVERRIDES = { - "issue_url": "https://github.com/TalonT-Org/AutoSkillit/issues/4411", - "source_dir": ".", - "task": "test task", - "task_description": "test task", -} # Default-preserving: every existing tool_ctx_ready_recipe consumer requests # it without parametrization and must keep getting exactly this triple. _READY_RECIPE_DEFAULT_PARAM = ( _READY_RECIPE_NAME, _READY_RECIPE_ATTESTED_STEP, - _READY_RECIPE_OVERRIDES, + RESEARCH_OVERRIDES, ) diff --git a/tests/server/test_kitchen_identity_lifecycle.py b/tests/server/test_kitchen_identity_lifecycle.py index 38105b255a..3a6b6c1d80 100644 --- a/tests/server/test_kitchen_identity_lifecycle.py +++ b/tests/server/test_kitchen_identity_lifecycle.py @@ -59,7 +59,7 @@ async def test_boot_open_load_share_one_kitchen_identity( 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 + from tests.server._recipe_fixture_parameters import RESEARCH_OVERRIDES monkeypatch.setenv("AUTOSKILLIT_HEADLESS", headless) monkeypatch.setenv("AUTOSKILLIT_SESSION_TYPE", session_type) @@ -83,7 +83,7 @@ async def test_boot_open_load_share_one_kitchen_identity( tool_ctx.quota_refresh_task = None overrides = { - **_READY_RECIPE_OVERRIDES, + **RESEARCH_OVERRIDES, "source_dir": str(tool_ctx.project_dir.resolve()), } fmcp_ctx = _mock_fmcp_ctx() diff --git a/tests/server/test_kitchen_lifecycle.py b/tests/server/test_kitchen_lifecycle.py index 58d0d48f90..5e0b93b209 100644 --- a/tests/server/test_kitchen_lifecycle.py +++ b/tests/server/test_kitchen_lifecycle.py @@ -234,7 +234,7 @@ def real_kitchen_lifecycle(monkeypatch, tmp_path, campaign_id): from autoskillit.server.recipe._recipe_section_pagination import PagePlanCache from autoskillit.server.tools.tools_kitchen._open_kitchen import _gate as open_gate from tests.server._helpers import _mock_fmcp_ctx - from tests.server.conftest import _READY_RECIPE_OVERRIDES + from tests.server._recipe_fixture_parameters import RESEARCH_OVERRIDES monkeypatch.chdir(tmp_path) monkeypatch.setenv("AUTOSKILLIT_HEADLESS", "0") @@ -254,7 +254,7 @@ def make_context_for_lifecycle(): monkeypatch.setattr(_state, "_ctx", ctx) monkeypatch.setattr(_state, "_startup_ready", None) overrides = { - **_READY_RECIPE_OVERRIDES, + **RESEARCH_OVERRIDES, "source_dir": str(ctx.project_dir.resolve()), } fmcp_ctx = _mock_fmcp_ctx() diff --git a/tests/server/test_recipe_execution_receipt_completeness.py b/tests/server/test_recipe_execution_receipt_completeness.py index e2280d1ff3..090c2fae9b 100644 --- a/tests/server/test_recipe_execution_receipt_completeness.py +++ b/tests/server/test_recipe_execution_receipt_completeness.py @@ -22,7 +22,7 @@ def _recipe_fixture_parameters() -> list[tuple[str, str, dict[str, str]]]: from autoskillit.recipe import load_and_validate from tests.contracts.test_delivery_bound_fitness import _delivery_recipe_names - from tests.server.conftest import _READY_RECIPE_OVERRIDES + from tests.server._recipe_fixture_parameters import RESEARCH_OVERRIDES parameters = [] for recipe_name in _delivery_recipe_names(): @@ -40,9 +40,7 @@ def _recipe_fixture_parameters() -> list[tuple[str, str, dict[str, str]]]: for step_name, invocation in projection.binding_projection.invocations.items() if invocation.tool_name == "run_skill" and invocation.attested ) - overrides = ( - _READY_RECIPE_OVERRIDES if recipe_name == "research" else IMPLEMENTATION_OVERRIDES - ) + overrides = RESEARCH_OVERRIDES if recipe_name == "research" else IMPLEMENTATION_OVERRIDES parameters.append((recipe_name, attested_step, overrides)) return parameters From 3133ee0230e00b1852a5ef60dec551d644b4a188 Mon Sep 17 00:00:00 2001 From: Trecek Date: Wed, 14 Oct 2026 14:17:18 -0700 Subject: [PATCH 08/46] fix(review): expose tracked delivery recipes through shared test support --- tests/_tracked_recipes.py | 10 ++++++++++ tests/contracts/test_completion_receipt_fitness.py | 6 ++++-- tests/contracts/test_delivery_bound_fitness.py | 13 ++----------- .../test_recipe_execution_receipt_completeness.py | 4 ++-- 4 files changed, 18 insertions(+), 15 deletions(-) diff --git a/tests/_tracked_recipes.py b/tests/_tracked_recipes.py index d6ac818dde..98aeb356c7 100644 --- a/tests/_tracked_recipes.py +++ b/tests/_tracked_recipes.py @@ -27,6 +27,7 @@ from autoskillit.core import RecipeSource from autoskillit.core.paths import pkg_root from autoskillit.core.types import LoadResult +from autoskillit.recipe import NON_INTERACTIVE_KINDS from autoskillit.recipe.io import ( RECIPE_SCAN_DIRS, collect_recipes_from_candidates, @@ -170,6 +171,15 @@ def tracked_recipe_names( ) +def delivery_recipe_names(project_root: Path) -> list[str]: + """Select tracked recipes that can be delivered to an interactive orchestrator.""" + return sorted( + recipe.name + for recipe in tracked_recipe_load_result(project_root).items + if recipe.kind not in NON_INTERACTIVE_KINDS and not recipe.dispatch_only + ) + + class RecipeStrayAnalysis(NamedTuple): errors: tuple[str, ...] report_paths: tuple[Path, ...] diff --git a/tests/contracts/test_completion_receipt_fitness.py b/tests/contracts/test_completion_receipt_fitness.py index 8d44619cc2..4486289493 100644 --- a/tests/contracts/test_completion_receipt_fitness.py +++ b/tests/contracts/test_completion_receipt_fitness.py @@ -29,9 +29,9 @@ recipe_initialization_receipt, ) from tests._helpers import kitchen_identity_fields +from tests._tracked_recipes import delivery_recipe_names from tests.contracts.test_delivery_bound_fitness import ( _backend_capabilities, - _delivery_recipe_names, _full_open_kitchen_generation, _generic_backstop_bound_bytes, ) @@ -145,7 +145,9 @@ def test_implementation_shape_supplies_unavailable_context( assert dict(bound) == assembled -@pytest.mark.parametrize("recipe_name", _delivery_recipe_names(), ids=lambda n: n) +@pytest.mark.parametrize( + "recipe_name", delivery_recipe_names(Path(__file__).resolve().parents[2]), ids=lambda n: n +) def test_completion_receipt_fits_every_delivery_bound( recipe_name: str, tmp_path: Path, diff --git a/tests/contracts/test_delivery_bound_fitness.py b/tests/contracts/test_delivery_bound_fitness.py index 3b5ec93520..56cea81fbf 100644 --- a/tests/contracts/test_delivery_bound_fitness.py +++ b/tests/contracts/test_delivery_bound_fitness.py @@ -35,7 +35,6 @@ ) from autoskillit.execution.backends import BACKEND_REGISTRY, CODEX_HISTORY_RETENTION_TOKEN_LIMIT from autoskillit.recipe import ( - NON_INTERACTIVE_KINDS, load_and_validate, ) from autoskillit.server.recipe._recipe_delivery import ( @@ -51,7 +50,7 @@ build_open_kitchen_recipe_payload, ) from tests._helpers import kitchen_identity_fields -from tests._tracked_recipes import tracked_recipe_load_result, tracked_recipe_names +from tests._tracked_recipes import delivery_recipe_names, tracked_recipe_names from tests.contracts._delivery_constants import ( CALIBRATED_PAGES_PER_SECTION, MAX_ENVELOPE_MANIFEST_BYTES, @@ -68,14 +67,6 @@ def _recipe_names() -> list[str]: return sorted(tracked_recipe_names(_PROJECT_ROOT)) -def _delivery_recipe_names() -> list[str]: - return sorted( - recipe.name - for recipe in tracked_recipe_load_result(_PROJECT_ROOT).items - if recipe.kind not in NON_INTERACTIVE_KINDS and not recipe.dispatch_only - ) - - def _backend_capabilities(): return {name: cls().capabilities for name, cls in BACKEND_REGISTRY.items()} @@ -141,7 +132,7 @@ def _full_open_kitchen_generation( return build_open_kitchen_recipe_payload(dict(result), version="0.0.0"), projection -@pytest.mark.parametrize("recipe_name", _delivery_recipe_names(), ids=lambda n: n) +@pytest.mark.parametrize("recipe_name", delivery_recipe_names(_PROJECT_ROOT), ids=lambda n: n) @pytest.mark.parametrize("backend_name", sorted(_backend_capabilities().keys()), ids=lambda n: n) def test_bundled_recipe_open_kitchen_envelope_fits_per_backend( recipe_name: str, diff --git a/tests/server/test_recipe_execution_receipt_completeness.py b/tests/server/test_recipe_execution_receipt_completeness.py index 090c2fae9b..963ed0fa63 100644 --- a/tests/server/test_recipe_execution_receipt_completeness.py +++ b/tests/server/test_recipe_execution_receipt_completeness.py @@ -21,11 +21,11 @@ def _recipe_fixture_parameters() -> list[tuple[str, str, dict[str, str]]]: from autoskillit.recipe import load_and_validate - from tests.contracts.test_delivery_bound_fitness import _delivery_recipe_names + from tests._tracked_recipes import delivery_recipe_names from tests.server._recipe_fixture_parameters import RESEARCH_OVERRIDES parameters = [] - for recipe_name in _delivery_recipe_names(): + for recipe_name in delivery_recipe_names(_PROJECT_ROOT): loaded = load_and_validate( recipe_name, project_dir=_PROJECT_ROOT, From 347aac3ad7dbab76fdc7d446128c3fd13b0baa78 Mon Sep 17 00:00:00 2001 From: Trecek Date: Wed, 14 Oct 2026 14:17:51 -0700 Subject: [PATCH 09/46] fix(review): verify recovery completion before reading its credential --- tests/server/test_recipe_execution_receipt_completeness.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/server/test_recipe_execution_receipt_completeness.py b/tests/server/test_recipe_execution_receipt_completeness.py index 963ed0fa63..419afce419 100644 --- a/tests/server/test_recipe_execution_receipt_completeness.py +++ b/tests/server/test_recipe_execution_receipt_completeness.py @@ -180,6 +180,7 @@ async def test_following_the_recovery_yields_an_admitted_digest( "recipe_execution_attestation_missing", ) receipt = json.loads(await complete_recipe_initialization(initialization_id=recovery_id)) + assert receipt["success"] is True, receipt credential = receipt["recipe_execution"] admitted = await _run_attested_call( ready, From 0c901c1a1f681d9218d2124cac5091040ff23b64 Mon Sep 17 00:00:00 2001 From: Trecek Date: Wed, 14 Oct 2026 14:18:23 -0700 Subject: [PATCH 10/46] fix(review): compare complete delivered skill-input shape values --- tests/server/test_recipe_execution_receipt_completeness.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/server/test_recipe_execution_receipt_completeness.py b/tests/server/test_recipe_execution_receipt_completeness.py index 419afce419..fa28da13b0 100644 --- a/tests/server/test_recipe_execution_receipt_completeness.py +++ b/tests/server/test_recipe_execution_receipt_completeness.py @@ -110,8 +110,10 @@ async def test_completion_receipt_carries_every_attested_step_credential( credential = ready.receipt["recipe_execution"] assert credential["invocation_template_digests"] == dict(installed.snapshot.template_digests) - expected_shapes = build_recipe_execution_credential(installed.snapshot).skill_input_shapes - assert set(credential["skill_input_shapes"]) == set(expected_shapes) + expected_shapes = build_recipe_execution_credential(installed.snapshot).as_wire_block()[ + "skill_input_shapes" + ] + assert credential["skill_input_shapes"] == expected_shapes @pytest.mark.parametrize("tool_ctx_ready_recipe", _RECIPE_FIXTURE_PARAMETERS, indirect=True) From e5eda7f414a1ec91cc3d97777a1001fc4f149e46 Mon Sep 17 00:00:00 2001 From: Trecek Date: Wed, 14 Oct 2026 14:20:49 -0700 Subject: [PATCH 11/46] fix(review): cover every tracked recipe with attested invocations --- ...t_recipe_execution_receipt_completeness.py | 26 ++++++++++++++----- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/tests/server/test_recipe_execution_receipt_completeness.py b/tests/server/test_recipe_execution_receipt_completeness.py index fa28da13b0..e96795c685 100644 --- a/tests/server/test_recipe_execution_receipt_completeness.py +++ b/tests/server/test_recipe_execution_receipt_completeness.py @@ -12,17 +12,26 @@ from autoskillit.server.recipe._recipe_execution import get_recipe_execution from tests.server._recipe_fixture_parameters import ( IMPLEMENTATION_OVERRIDES, + RESEARCH_OVERRIDES, ) pytestmark = [pytest.mark.layer("server"), pytest.mark.anyio, pytest.mark.medium] _PROJECT_ROOT = Path(__file__).resolve().parents[2] +_RECIPE_INPUT_OVERRIDES = { + "full-audit": {"workspace": ".", "branch": "develop"}, + "implementation": IMPLEMENTATION_OVERRIDES, + "implementation-groups": {"source_doc": "test source document", "source_dir": "."}, + "merge-prs": {"source_dir": "."}, + "planner": {"task": "test task", "source_dir": "."}, + "remediation": IMPLEMENTATION_OVERRIDES, + "research": RESEARCH_OVERRIDES, +} def _recipe_fixture_parameters() -> list[tuple[str, str, dict[str, str]]]: from autoskillit.recipe import load_and_validate from tests._tracked_recipes import delivery_recipe_names - from tests.server._recipe_fixture_parameters import RESEARCH_OVERRIDES parameters = [] for recipe_name in delivery_recipe_names(_PROJECT_ROOT): @@ -33,14 +42,17 @@ def _recipe_fixture_parameters() -> list[tuple[str, str, dict[str, str]]]: ) projection = loaded["_finalized_projection"] assert isinstance(projection, FinalizedRecipeProjection) - if not projection.delivery_segments and recipe_name != "research": - continue attested_step = next( - step_name - for step_name, invocation in projection.binding_projection.invocations.items() - if invocation.tool_name == "run_skill" and invocation.attested + ( + step_name + for step_name, invocation in projection.binding_projection.invocations.items() + if invocation.tool_name == "run_skill" and invocation.attested + ), + None, ) - overrides = RESEARCH_OVERRIDES if recipe_name == "research" else IMPLEMENTATION_OVERRIDES + if attested_step is None: + continue + overrides = _RECIPE_INPUT_OVERRIDES[recipe_name] parameters.append((recipe_name, attested_step, overrides)) return parameters From d7f10319722c82caf9d2b49ac9e18e08eaddaa43 Mon Sep 17 00:00:00 2001 From: Trecek Date: Wed, 14 Oct 2026 14:21:45 -0700 Subject: [PATCH 12/46] docs(review): document completion response step identity --- .../server/tools/tools_pipeline_tracker/_handlers.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/autoskillit/server/tools/tools_pipeline_tracker/_handlers.py b/src/autoskillit/server/tools/tools_pipeline_tracker/_handlers.py index 47163881b3..f20203539b 100644 --- a/src/autoskillit/server/tools/tools_pipeline_tracker/_handlers.py +++ b/src/autoskillit/server/tools/tools_pipeline_tracker/_handlers.py @@ -656,7 +656,11 @@ async def complete_run_skill_result( receipt_id: str, ctx: Context = CurrentContext(), ) -> str: - """Acknowledge one exactly delivered ``run_skill`` result. Never raises.""" + """Acknowledge one exactly delivered ``run_skill`` result. Never raises. + + Successful acknowledgments include ``step_name`` from the completion receipt, + which identifies the step used to attach its success or recovery recipe segment. + """ if (gate := _require_enabled()) is not None: return gate try: From b7d382f9562cf29dcb5d1781aa7ef6ad82f5b68f Mon Sep 17 00:00:00 2001 From: Trecek Date: Wed, 14 Oct 2026 14:43:12 -0700 Subject: [PATCH 13/46] fix(review): assert the complete checkpoint carrier validation sequence --- tests/server/test_recipe_segment_delivery.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/tests/server/test_recipe_segment_delivery.py b/tests/server/test_recipe_segment_delivery.py index 8987f67ab0..3b3b8fb7e0 100644 --- a/tests/server/test_recipe_segment_delivery.py +++ b/tests/server/test_recipe_segment_delivery.py @@ -243,15 +243,13 @@ def record_tool_name(carrier, *, tool_name: str): assert prepared.success_carrier is not None assert prepared.success_pull_carrier is not None assert prepared.recovery_carrier is not None - assert seen_tool_names == [expected_tool_name] * 3 - assert all( - carrier in validated_carriers - for carrier in ( - prepared.success_carrier, - prepared.success_pull_carrier, - prepared.recovery_carrier, - ) - ) + assert seen_tool_names == [expected_tool_name] * 4 + assert validated_carriers == [ + prepared.success_carrier, + prepared.success_pull_carrier, + prepared.success_carrier, + prepared.recovery_carrier, + ] @pytest.mark.anyio From d0bae4ba3d0529cda2c90ebe884a3512fd221729 Mon Sep 17 00:00:00 2001 From: Trecek Date: Wed, 14 Oct 2026 14:44:56 -0700 Subject: [PATCH 14/46] fix(review): enable dependency packs in recipe credential test contexts --- .../test_recipe_execution_receipt_completeness.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/tests/server/test_recipe_execution_receipt_completeness.py b/tests/server/test_recipe_execution_receipt_completeness.py index e96795c685..e0f4707fc6 100644 --- a/tests/server/test_recipe_execution_receipt_completeness.py +++ b/tests/server/test_recipe_execution_receipt_completeness.py @@ -8,7 +8,11 @@ import pytest -from autoskillit.core import FinalizedRecipeProjection, build_recipe_execution_credential +from autoskillit.core import ( + PACK_REGISTRY, + FinalizedRecipeProjection, + build_recipe_execution_credential, +) from autoskillit.server.recipe._recipe_execution import get_recipe_execution from tests.server._recipe_fixture_parameters import ( IMPLEMENTATION_OVERRIDES, @@ -62,6 +66,12 @@ def _recipe_fixture_parameters() -> list[tuple[str, str, dict[str, str]]]: ] +@pytest.fixture(autouse=True) +def _enable_attested_skill_dependency_packs(tool_ctx, monkeypatch: pytest.MonkeyPatch) -> None: + """Keep dependency visibility from short-circuiting the credential admission tests.""" + monkeypatch.setattr(tool_ctx.config.packs, "enabled", list(PACK_REGISTRY)) + + def _skill_command(tool_ctx, step_name: str) -> str: installed = get_recipe_execution(tool_ctx) assert installed is not None From 27ed20f31067d36a17ef05d88fa581a047a1eee6 Mon Sep 17 00:00:00 2001 From: Trecek Date: Wed, 14 Oct 2026 17:46:02 -0700 Subject: [PATCH 15/46] fix(review): require successful verification in the delivered plan walk --- tests/server/test_implementation_plan_bind_verify_walk.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/server/test_implementation_plan_bind_verify_walk.py b/tests/server/test_implementation_plan_bind_verify_walk.py index 93d034bf68..8ca29ae197 100644 --- a/tests/server/test_implementation_plan_bind_verify_walk.py +++ b/tests/server/test_implementation_plan_bind_verify_walk.py @@ -194,6 +194,7 @@ async def test_plan_bind_verify_walk_uses_only_delivered_responses( IMPLEMENTATION_OVERRIDES, {key: value for key, value in verify_context.items() if isinstance(value, str)}, ) + assert verify_response["success"] is True, verify_response serialized = json.dumps(verify_response) for code in ( "recipe_execution_attestation_missing", From 26671db509f3905b09007978aa3f824037a72e3d Mon Sep 17 00:00:00 2001 From: Trecek Date: Wed, 14 Oct 2026 17:46:32 -0700 Subject: [PATCH 16/46] fix(review): exercise verification after review-approach plan binding --- tests/server/test_implementation_plan_bind_verify_walk.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/server/test_implementation_plan_bind_verify_walk.py b/tests/server/test_implementation_plan_bind_verify_walk.py index 8ca29ae197..820cd14976 100644 --- a/tests/server/test_implementation_plan_bind_verify_walk.py +++ b/tests/server/test_implementation_plan_bind_verify_walk.py @@ -171,9 +171,6 @@ async def test_plan_bind_verify_walk_uses_only_delivered_responses( assert carrier["kind"] == "success" assert _carrier_delivers_step(carrier, "verify") - if review_approach: - return - verify_record = next(body for body in carrier["bodies"] if body["step"] == "verify") verify_body = load_yaml(verify_record["body"])["verify"] assert ( From 1c03fb537f4ef52479c9592e5191a96eb4e89edf Mon Sep 17 00:00:00 2001 From: Trecek Date: Wed, 14 Oct 2026 17:48:04 -0700 Subject: [PATCH 17/46] refactor(review): share plan-set fixtures without importing a test module --- tests/server/_plan_set_test_helpers.py | 45 ++++++++++++++ ...st_implementation_plan_bind_verify_walk.py | 4 +- tests/server/test_tools_plan_set.py | 59 +++++-------------- 3 files changed, 62 insertions(+), 46 deletions(-) create mode 100644 tests/server/_plan_set_test_helpers.py diff --git a/tests/server/_plan_set_test_helpers.py b/tests/server/_plan_set_test_helpers.py new file mode 100644 index 0000000000..66ddc6b587 --- /dev/null +++ b/tests/server/_plan_set_test_helpers.py @@ -0,0 +1,45 @@ +"""Shared plan-set materialization fixtures for server tests.""" + +from pathlib import Path + +import pytest + +from autoskillit.core import resolve_temp_dir +from autoskillit.server._plan_set_materializer import DefaultPlanSetMaterializer + +PLAN_SET_FIXTURE = Path(__file__).resolve().parents[1] / "fixtures" / "plan_set" / "split_r1_r2" + + +class PlanSetGitHub: + def __init__(self, *, success: bool = True, fixture_dir: Path = PLAN_SET_FIXTURE) -> None: + self.success = success + self.fixture_dir = fixture_dir + + async def fetch_issue(self, *_args: object, **_kwargs: object) -> dict[str, object]: + if not self.success: + return {"success": False, "error": "offline"} + return { + "success": True, + "issue_number": 1, + "body": (self.fixture_dir / "issue.md").read_text(), + } + + +def write_plan_set_parts( + tmp_path: Path, + tool_ctx, + monkeypatch: pytest.MonkeyPatch, + *, + fixture_dir: Path = PLAN_SET_FIXTURE, +) -> tuple[Path, Path, Path]: + root = resolve_temp_dir(tmp_path, tool_ctx.config.workspace.temp_dir) + root.mkdir(parents=True, exist_ok=True) + parts = (root / "part_a.md", root / "part_b.md") + for part in parts: + part.write_text((fixture_dir / part.name).read_text(), encoding="utf-8") + monkeypatch.setattr( + tool_ctx, + "plan_set_materializer", + DefaultPlanSetMaterializer(PlanSetGitHub(fixture_dir=fixture_dir)), + ) + return root, *parts diff --git a/tests/server/test_implementation_plan_bind_verify_walk.py b/tests/server/test_implementation_plan_bind_verify_walk.py index 820cd14976..62adfca4e6 100644 --- a/tests/server/test_implementation_plan_bind_verify_walk.py +++ b/tests/server/test_implementation_plan_bind_verify_walk.py @@ -121,12 +121,12 @@ async def test_plan_bind_verify_walk_uses_only_delivered_responses( from autoskillit.core.io import load_yaml from tests.fakes import InMemoryHeadlessExecutor from tests.server._helpers import _skill_ok + from tests.server._plan_set_test_helpers import write_plan_set_parts from tests.server._recipe_segment_test_helpers import assert_delivery_outcome - from tests.server.test_tools_plan_set import _parts ready = tool_ctx_ready_recipe credential = ready.receipt["recipe_execution"] - _, part_a, part_b = _parts(git_checkout, ready.tool_ctx, monkeypatch) + _, part_a, part_b = write_plan_set_parts(git_checkout, ready.tool_ctx, monkeypatch) plan_text = f"verdict = plan\nplan_path = {part_a}\nplan_parts = {part_a},{part_b}" executor = InMemoryHeadlessExecutor(default_result=_skill_ok(plan_text)) monkeypatch.setattr(ready.tool_ctx, "executor", executor) diff --git a/tests/server/test_tools_plan_set.py b/tests/server/test_tools_plan_set.py index bb384d77e5..c358ee1a37 100644 --- a/tests/server/test_tools_plan_set.py +++ b/tests/server/test_tools_plan_set.py @@ -9,9 +9,14 @@ import pytest import autoskillit.server._plan_set_materializer as materializer_module -from autoskillit.core import PlanSetRejectReason, resolve_temp_dir +from autoskillit.core import PlanSetRejectReason from autoskillit.server._plan_set_materializer import DefaultPlanSetMaterializer from autoskillit.server.tools.tools_plan_set import bind_plan_set +from tests.server._plan_set_test_helpers import ( + PLAN_SET_FIXTURE, + PlanSetGitHub, + write_plan_set_parts, +) from tests.server._recipe_fixture_parameters import ( IMPLEMENTATION_OVERRIDES, IMPLEMENTATION_READY_PARAMETERS, @@ -19,49 +24,13 @@ pytestmark = [pytest.mark.layer("server"), pytest.mark.anyio, pytest.mark.medium] -_FIXTURE = Path(__file__).resolve().parents[1] / "fixtures" / "plan_set" / "split_r1_r2" -_MISSING_R2_FIXTURE = _FIXTURE.parent / "missing_r2" - - -class _GitHub: - def __init__(self, *, success: bool = True, fixture_dir: Path = _FIXTURE) -> None: - self.success = success - self.fixture_dir = fixture_dir - - async def fetch_issue(self, *_args: object, **_kwargs: object) -> dict[str, object]: - if not self.success: - return {"success": False, "error": "offline"} - return { - "success": True, - "issue_number": 1, - "body": (self.fixture_dir / "issue.md").read_text(), - } - - -def _parts( - tmp_path: Path, - tool_ctx, - monkeypatch: pytest.MonkeyPatch, - *, - fixture_dir: Path = _FIXTURE, -) -> tuple[Path, Path, Path]: - root = resolve_temp_dir(tmp_path, tool_ctx.config.workspace.temp_dir) - root.mkdir(parents=True, exist_ok=True) - parts = (root / "part_a.md", root / "part_b.md") - for part in parts: - part.write_text((fixture_dir / part.name).read_text(), encoding="utf-8") - monkeypatch.setattr( - tool_ctx, - "plan_set_materializer", - DefaultPlanSetMaterializer(_GitHub(fixture_dir=fixture_dir)), - ) - return root, *parts +_MISSING_R2_FIXTURE = PLAN_SET_FIXTURE.parent / "missing_r2" async def test_headless_bind_writes_snapshot_and_tracks_response( tmp_path: Path, tool_ctx, monkeypatch: pytest.MonkeyPatch ) -> None: - root, first, second = _parts(tmp_path, tool_ctx, monkeypatch) + root, first, second = write_plan_set_parts(tmp_path, tool_ctx, monkeypatch) assert not tool_ctx.gate.enabled encoded = await bind_plan_set( plan_parts=f"{first},{second}\n", @@ -97,7 +66,7 @@ async def test_headless_bind_writes_snapshot_and_tracks_response( async def test_open_bind_and_mixed_parent_list( tmp_path: Path, tool_ctx, monkeypatch: pytest.MonkeyPatch ) -> None: - _, first, second = _parts(tmp_path, tool_ctx, monkeypatch) + _, first, second = write_plan_set_parts(tmp_path, tool_ctx, monkeypatch) opened = json.loads( await bind_plan_set(plan_parts=str(first), cwd=str(tmp_path), seal="false") ) @@ -117,7 +86,7 @@ async def test_open_bind_and_mixed_parent_list( async def test_identical_bind_reconciles_existing_artifacts( tmp_path: Path, tool_ctx, monkeypatch: pytest.MonkeyPatch ) -> None: - _, first, second = _parts(tmp_path, tool_ctx, monkeypatch) + _, first, second = write_plan_set_parts(tmp_path, tool_ctx, monkeypatch) class FrozenDatetime: @staticmethod @@ -142,7 +111,7 @@ def now(_zone): async def test_wrapper_classifies_inputs_and_failures( tmp_path: Path, tool_ctx, monkeypatch: pytest.MonkeyPatch, case: str ) -> None: - root, first, _ = _parts(tmp_path, tool_ctx, monkeypatch) + root, first, _ = write_plan_set_parts(tmp_path, tool_ctx, monkeypatch) if case == "empty": result = json.loads(await bind_plan_set(plan_parts=" ,\n", cwd=str(tmp_path))) assert result["reason"] == PlanSetRejectReason.NO_PARTS.value @@ -153,7 +122,9 @@ async def test_wrapper_classifies_inputs_and_failures( assert result["reason"] == PlanSetRejectReason.CONTAINMENT.value elif case == "fetch_failure": monkeypatch.setattr( - tool_ctx, "plan_set_materializer", DefaultPlanSetMaterializer(_GitHub(success=False)) + tool_ctx, + "plan_set_materializer", + DefaultPlanSetMaterializer(PlanSetGitHub(success=False)), ) result = json.loads( await bind_plan_set(plan_parts=str(first), cwd=str(tmp_path), issue_url="owner/repo#1") @@ -191,7 +162,7 @@ async def test_bind_plan_set_coverage_failure_delivers_recovery( ) -> None: from tests.server._recipe_segment_test_helpers import assert_delivery_outcome - _, first, second = _parts( + _, first, second = write_plan_set_parts( git_checkout, tool_ctx_ready_recipe.tool_ctx, monkeypatch, From d199795c7e9e685bd4e2a255f3dbe4da1ef6ba31 Mon Sep 17 00:00:00 2001 From: Trecek Date: Wed, 14 Oct 2026 17:48:45 -0700 Subject: [PATCH 18/46] refactor(review): centralize implementation review-approach test cases --- tests/server/_recipe_fixture_parameters.py | 4 +++ ...st_implementation_plan_bind_verify_walk.py | 12 ++------- tests/server/test_recipe_segment_delivery.py | 27 +++++++------------ 3 files changed, 16 insertions(+), 27 deletions(-) diff --git a/tests/server/_recipe_fixture_parameters.py b/tests/server/_recipe_fixture_parameters.py index 8ab0d84ef8..72900e5848 100644 --- a/tests/server/_recipe_fixture_parameters.py +++ b/tests/server/_recipe_fixture_parameters.py @@ -10,6 +10,10 @@ IMPLEMENTATION_READY_PARAMETERS = [ pytest.param(("implementation", "plan", IMPLEMENTATION_OVERRIDES), id="implementation"), ] +IMPLEMENTATION_REVIEW_READY_PARAMETERS = [ + pytest.param(("implementation", "plan", IMPLEMENTATION_OVERRIDES), id="default"), + pytest.param(("implementation", "plan", REVIEW_APPROACH_OVERRIDES), id="review-approach"), +] RESEARCH_OVERRIDES = { "issue_url": "https://github.com/TalonT-Org/AutoSkillit/issues/4411", diff --git a/tests/server/test_implementation_plan_bind_verify_walk.py b/tests/server/test_implementation_plan_bind_verify_walk.py index 62adfca4e6..fd41099059 100644 --- a/tests/server/test_implementation_plan_bind_verify_walk.py +++ b/tests/server/test_implementation_plan_bind_verify_walk.py @@ -12,7 +12,7 @@ from tests.server._pipeline_test_helpers import _ack_direct_run_skill_result from tests.server._recipe_fixture_parameters import ( IMPLEMENTATION_OVERRIDES, - REVIEW_APPROACH_OVERRIDES, + IMPLEMENTATION_REVIEW_READY_PARAMETERS, ) pytestmark = [pytest.mark.layer("server"), pytest.mark.anyio, pytest.mark.medium] @@ -103,20 +103,12 @@ def _carrier_delivers_step(carrier: dict[str, Any], step_name: str) -> bool: @pytest.mark.parametrize( - ("tool_ctx_ready_recipe", "review_approach"), - [ - pytest.param(("implementation", "plan", IMPLEMENTATION_OVERRIDES), False, id="default"), - pytest.param( - ("implementation", "plan", REVIEW_APPROACH_OVERRIDES), True, id="review-approach" - ), - ], - indirect=["tool_ctx_ready_recipe"], + "tool_ctx_ready_recipe", IMPLEMENTATION_REVIEW_READY_PARAMETERS, indirect=True ) async def test_plan_bind_verify_walk_uses_only_delivered_responses( tool_ctx_ready_recipe, git_checkout: Path, monkeypatch: pytest.MonkeyPatch, - review_approach: bool, ) -> None: from autoskillit.core.io import load_yaml from tests.fakes import InMemoryHeadlessExecutor diff --git a/tests/server/test_recipe_segment_delivery.py b/tests/server/test_recipe_segment_delivery.py index 3b3b8fb7e0..74a98194d9 100644 --- a/tests/server/test_recipe_segment_delivery.py +++ b/tests/server/test_recipe_segment_delivery.py @@ -38,24 +38,11 @@ enforce_response_budget, ) from tests.server._recipe_fixture_parameters import ( - IMPLEMENTATION_OVERRIDES, - REVIEW_APPROACH_OVERRIDES, + IMPLEMENTATION_REVIEW_READY_PARAMETERS, ) pytestmark = [pytest.mark.layer("server"), pytest.mark.medium] -_IMPLEMENTATION_READY_PARAMETERS = [ - pytest.param(("implementation", "plan", IMPLEMENTATION_OVERRIDES), id="default"), - pytest.param( - ( - "implementation", - "plan", - REVIEW_APPROACH_OVERRIDES, - ), - id="review-approach", - ), -] - def _projection(*, segmented: bool) -> FinalizedRecipeProjection: return FinalizedRecipeProjection( @@ -374,7 +361,9 @@ async def test_checkpoint_delivery_survives_ready_generation_lru_eviction( assert prepared.success_carrier["recipe_pull"] == state.artifact_generation.pull_identity() -@pytest.mark.parametrize("tool_ctx_ready_recipe", _IMPLEMENTATION_READY_PARAMETERS, indirect=True) +@pytest.mark.parametrize( + "tool_ctx_ready_recipe", IMPLEMENTATION_REVIEW_READY_PARAMETERS, indirect=True +) @pytest.mark.anyio async def test_recovery_only_success_carrier_follows_success_crossings( tool_ctx_ready_recipe, @@ -392,7 +381,9 @@ async def test_recovery_only_success_carrier_follows_success_crossings( assert claim_issue.success_carrier is None -@pytest.mark.parametrize("tool_ctx_ready_recipe", _IMPLEMENTATION_READY_PARAMETERS, indirect=True) +@pytest.mark.parametrize( + "tool_ctx_ready_recipe", IMPLEMENTATION_REVIEW_READY_PARAMETERS, indirect=True +) @pytest.mark.anyio async def test_success_carrier_degrades_to_pull_only_when_response_would_not_fit( tool_ctx_ready_recipe, @@ -441,7 +432,9 @@ async def test_success_carrier_degrades_to_pull_only_when_response_would_not_fit assert all(payload[key] == value for key, value in base.items()) -@pytest.mark.parametrize("tool_ctx_ready_recipe", _IMPLEMENTATION_READY_PARAMETERS, indirect=True) +@pytest.mark.parametrize( + "tool_ctx_ready_recipe", IMPLEMENTATION_REVIEW_READY_PARAMETERS, indirect=True +) @pytest.mark.anyio async def test_pull_only_success_survives_oversized_bind_response_spill( tool_ctx_ready_recipe, From 47f31c520b63631e14cc88594a632a4b57981124 Mon Sep 17 00:00:00 2001 From: Trecek Date: Wed, 14 Oct 2026 17:49:20 -0700 Subject: [PATCH 19/46] test(review): generate a guaranteed different execution identifier --- tests/server/test_recipe_execution_receipt_completeness.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/server/test_recipe_execution_receipt_completeness.py b/tests/server/test_recipe_execution_receipt_completeness.py index e0f4707fc6..c636c9040d 100644 --- a/tests/server/test_recipe_execution_receipt_completeness.py +++ b/tests/server/test_recipe_execution_receipt_completeness.py @@ -231,7 +231,7 @@ async def test_wrong_execution_id_rejection_names_the_call( installed = get_recipe_execution(ready.tool_ctx) assert installed is not None actual_id = ready.credential["execution_id"] - wrong_id = "0" * 32 if actual_id != "0" * 32 else "1" * 32 + wrong_id = f"{int(actual_id[0], 16) ^ 1:x}" + actual_id[1:] for step_name in installed.snapshot.template_digests: response = await _run_attested_call( From 3a48d5f2ddd2370a95c3dffcc5566ee1b27a5371 Mon Sep 17 00:00:00 2001 From: Trecek Date: Wed, 14 Oct 2026 17:49:51 -0700 Subject: [PATCH 20/46] test(review): require a successful recovered attested invocation --- tests/server/test_recipe_execution_receipt_completeness.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/server/test_recipe_execution_receipt_completeness.py b/tests/server/test_recipe_execution_receipt_completeness.py index c636c9040d..279e7b96c9 100644 --- a/tests/server/test_recipe_execution_receipt_completeness.py +++ b/tests/server/test_recipe_execution_receipt_completeness.py @@ -213,6 +213,7 @@ async def test_following_the_recovery_yields_an_admitted_digest( credential["execution_id"], credential["invocation_template_digests"][step_name], ) + assert admitted["success"] is True, admitted serialized = json.dumps(admitted) for code in ( "recipe_execution_attestation_missing", From e25ed4b50603a69fe79b45dac080d36a4448bd97 Mon Sep 17 00:00:00 2001 From: Trecek Date: Wed, 14 Oct 2026 17:50:32 -0700 Subject: [PATCH 21/46] test(review): require failure envelopes for attestation rejection --- tests/server/test_recipe_execution_receipt_completeness.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/server/test_recipe_execution_receipt_completeness.py b/tests/server/test_recipe_execution_receipt_completeness.py index 279e7b96c9..99e2406798 100644 --- a/tests/server/test_recipe_execution_receipt_completeness.py +++ b/tests/server/test_recipe_execution_receipt_completeness.py @@ -108,6 +108,7 @@ async def _run_attested_call( def _assert_digest_recovery( response: dict[str, object], step_name: str, initialization_id: str, code: str ) -> str: + assert response["success"] is False, response serialized = response["error"] assert isinstance(serialized, str) assert "Recovery:" in serialized From 33d7144d2de6fc0f8389bb9e1e29ef255935c630 Mon Sep 17 00:00:00 2001 From: Trecek Date: Wed, 14 Oct 2026 17:51:15 -0700 Subject: [PATCH 22/46] test(review): cover successful responses from tools without recipe delivery --- tests/server/test_recipe_checkpoint_conformance.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/tests/server/test_recipe_checkpoint_conformance.py b/tests/server/test_recipe_checkpoint_conformance.py index 585dd7fbfd..d9dd72965b 100644 --- a/tests/server/test_recipe_checkpoint_conformance.py +++ b/tests/server/test_recipe_checkpoint_conformance.py @@ -340,10 +340,9 @@ def prepare(_tool_ctx, step_name: str): tool_def = TOOL_REGISTRY[tool_name] if tool_def.automatic_recipe_delivery or tool_def.recovery_recipe_delivery: continue - result = await _wrapped( - tool_name, - json.dumps({"success": False}), - )(step_name=_STEP_NAME) - assert "recipe_segment" not in json.loads(result), tool_name + for success in (True, False): + payload = {"success": success} + result = await _wrapped(tool_name, json.dumps(payload))(step_name=_STEP_NAME) + assert json.loads(result) == payload, (tool_name, success) assert calls == [] From 5750a4bc17c018dd7ea71b5c31531ff1b0751cd3 Mon Sep 17 00:00:00 2001 From: Trecek Date: Wed, 14 Oct 2026 17:51:40 -0700 Subject: [PATCH 23/46] test(review): verify success payload survives omitted checkpoint delivery --- tests/server/test_recipe_checkpoint_conformance.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/server/test_recipe_checkpoint_conformance.py b/tests/server/test_recipe_checkpoint_conformance.py index d9dd72965b..01d4062e74 100644 --- a/tests/server/test_recipe_checkpoint_conformance.py +++ b/tests/server/test_recipe_checkpoint_conformance.py @@ -148,7 +148,7 @@ async def test_success_payload_gets_declared_carrier( parsed = json.loads(result) if prepared.success_carrier is None: - assert "recipe_segment" not in parsed + assert parsed == payload else: assert parsed["recipe_segment"] == prepared.success_carrier expected_step = ( From 70966316bb49ba8b7dc7764cb377c030e38e15df Mon Sep 17 00:00:00 2001 From: Trecek Date: Wed, 14 Oct 2026 17:52:14 -0700 Subject: [PATCH 24/46] test(review): cover distinct registered checkpoint outcome values --- tests/server/test_recipe_checkpoint_conformance.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/server/test_recipe_checkpoint_conformance.py b/tests/server/test_recipe_checkpoint_conformance.py index 01d4062e74..f5cbdebbaf 100644 --- a/tests/server/test_recipe_checkpoint_conformance.py +++ b/tests/server/test_recipe_checkpoint_conformance.py @@ -23,7 +23,7 @@ def _reader_shape_representatives() -> tuple[str, ...]: - representatives: dict[tuple[bool, bool, type, bool], str] = {} + representatives: dict[tuple[object, ...], str] = {} for name in sorted(TOOL_REGISTRY): tool_def = TOOL_REGISTRY[name] if not (tool_def.automatic_recipe_delivery or tool_def.recovery_recipe_delivery): @@ -32,9 +32,10 @@ def _reader_shape_representatives() -> tuple[str, ...]: assert outcome is not None shape = ( tool_def.automatic_recipe_delivery, - outcome.success_field is None, + outcome.success_field, type(outcome.success_value), - outcome.step_name_field is None, + outcome.success_value, + outcome.step_name_field, ) representatives.setdefault(shape, name) return tuple(representatives.values()) From 0648444e23c4ed22de8d99b8722d2d8a7f59a59c Mon Sep 17 00:00:00 2001 From: Trecek Date: Wed, 14 Oct 2026 17:52:40 -0700 Subject: [PATCH 25/46] test(review): apply the checkpoint conformance async marker once --- tests/server/test_recipe_checkpoint_conformance.py | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/tests/server/test_recipe_checkpoint_conformance.py b/tests/server/test_recipe_checkpoint_conformance.py index f5cbdebbaf..a1989b843f 100644 --- a/tests/server/test_recipe_checkpoint_conformance.py +++ b/tests/server/test_recipe_checkpoint_conformance.py @@ -14,7 +14,7 @@ from autoskillit.server._notify import track_response_size from autoskillit.server.tools._types import deny_envelope -pytestmark = [pytest.mark.layer("server"), pytest.mark.medium] +pytestmark = [pytest.mark.layer("server"), pytest.mark.anyio, pytest.mark.medium] _SUCCESS_CARRIER: dict[str, Any] = {"kind": "success", "marker": "S"} _PULL_SUCCESS_CARRIER: dict[str, Any] = {"kind": "success", "marker": "P"} @@ -129,7 +129,6 @@ def _success_carrier_for(tool_name: str) -> dict[str, Any] | None: @pytest.mark.parametrize("tool_name", _READER_SHAPE_TOOLS, ids=_READER_SHAPE_TOOLS) -@pytest.mark.anyio async def test_success_payload_gets_declared_carrier( tool_name: str, tool_ctx, @@ -159,7 +158,6 @@ async def test_success_payload_gets_declared_carrier( @pytest.mark.parametrize("tool_name", _READER_SHAPE_TOOLS, ids=_READER_SHAPE_TOOLS) -@pytest.mark.anyio async def test_failure_payload_gets_recovery_carrier( tool_name: str, tool_ctx, @@ -185,7 +183,6 @@ async def test_failure_payload_gets_recovery_carrier( @pytest.mark.parametrize("tool_name", _READER_SHAPE_TOOLS, ids=_READER_SHAPE_TOOLS) -@pytest.mark.anyio async def test_handler_exception_gets_recovery_carrier( tool_name: str, tool_ctx, @@ -211,7 +208,6 @@ async def test_handler_exception_gets_recovery_carrier( @pytest.mark.parametrize("tool_name", _READER_SHAPE_TOOLS, ids=_READER_SHAPE_TOOLS) -@pytest.mark.anyio async def test_deny_and_gate_errors_get_recovery_carrier( tool_name: str, tool_ctx, @@ -239,7 +235,6 @@ async def test_deny_and_gate_errors_get_recovery_carrier( @pytest.mark.parametrize("tool_name", _READER_SHAPE_TOOLS, ids=_READER_SHAPE_TOOLS) -@pytest.mark.anyio async def test_missing_step_name_is_byte_identical_passthrough( tool_name: str, tool_ctx, @@ -261,7 +256,6 @@ async def test_missing_step_name_is_byte_identical_passthrough( _ARGUMENT_SOURCED_READER_TOOLS, ids=_ARGUMENT_SOURCED_READER_TOOLS, ) -@pytest.mark.anyio async def test_pre_effect_prepare_failure_prevents_handler( tool_name: str, tool_ctx, @@ -292,7 +286,6 @@ async def test_pre_effect_prepare_failure_prevents_handler( _RESULT_SOURCED_READER_TOOLS, ids=_RESULT_SOURCED_READER_TOOLS, ) -@pytest.mark.anyio async def test_result_sourced_prepare_failure_is_retriable_after_handler( tool_name: str, tool_ctx, @@ -321,7 +314,6 @@ async def test_result_sourced_prepare_failure_is_retriable_after_handler( assert "do_not_repeat" not in parsed -@pytest.mark.anyio async def test_unflagged_tools_never_prepare_or_attach( tool_ctx, monkeypatch: pytest.MonkeyPatch, From c1a28f7b800452dbc690de3466a950ce2991279b Mon Sep 17 00:00:00 2001 From: Trecek Date: Wed, 14 Oct 2026 17:53:25 -0700 Subject: [PATCH 26/46] test(review): validate completion recovery routes and pull identity --- tests/server/test_complete_run_skill_result_delivery.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/server/test_complete_run_skill_result_delivery.py b/tests/server/test_complete_run_skill_result_delivery.py index 9c5eb61a91..c3dfb6b2ed 100644 --- a/tests/server/test_complete_run_skill_result_delivery.py +++ b/tests/server/test_complete_run_skill_result_delivery.py @@ -71,6 +71,13 @@ async def test_completion_receipt_delivery_carries_plan_step_name( if not run_skill_success: assert_delivery_outcome("complete_run_skill_result", response, expected=False) assert response["recipe_segment"]["kind"] == expected_segment + if not run_skill_success: + carrier = response["recipe_segment"] + assert carrier["source_step"] == "plan" + assert "release_issue_failure" in carrier["target_steps"] + assert "release_issue_failure" in carrier["pull_closure"] + assert {"section": "release_issue_failure", "part": 0} in carrier["pull_requests"] + assert carrier["recipe_pull"] == tool_ctx_ready_recipe.receipt["recipe_pull"] if run_skill_success: await assert_real_checkpoint_response_budget( tool_ctx_ready_recipe, From 791ccaf3a003f3e1d492664c466a94cc73abf4c6 Mon Sep 17 00:00:00 2001 From: Trecek Date: Wed, 14 Oct 2026 17:54:02 -0700 Subject: [PATCH 27/46] test(review): validate recovery-only claim checkpoint carriers --- tests/server/test_recipe_segment_delivery.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/server/test_recipe_segment_delivery.py b/tests/server/test_recipe_segment_delivery.py index 74a98194d9..35d7c11afc 100644 --- a/tests/server/test_recipe_segment_delivery.py +++ b/tests/server/test_recipe_segment_delivery.py @@ -379,6 +379,13 @@ async def test_recovery_only_success_carrier_follows_success_crossings( claim_issue = prepare_recipe_segment_delivery(tool_ctx, "claim_and_resolve") assert claim_issue is not None assert claim_issue.success_carrier is None + assert claim_issue.success_pull_carrier is None + recovery = claim_issue.recovery_carrier + assert recovery["kind"] == "recovery" + assert recovery["source_step"] == "claim_and_resolve" + assert "register_clone_failure" in recovery["target_steps"] + assert "register_clone_failure" in recovery["pull_closure"] + assert {"section": "register_clone_failure", "part": 0} in recovery["pull_requests"] @pytest.mark.parametrize( From a27fdb2ab118599ccccccbc3310706b1ad79b032 Mon Sep 17 00:00:00 2001 From: Trecek Date: Wed, 14 Oct 2026 17:54:51 -0700 Subject: [PATCH 28/46] test(review): verify review receipt recovery retrieval metadata --- tests/server/test_tools_pr_ops.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/server/test_tools_pr_ops.py b/tests/server/test_tools_pr_ops.py index 2cf18826f3..ee73c07675 100644 --- a/tests/server/test_tools_pr_ops.py +++ b/tests/server/test_tools_pr_ops.py @@ -364,6 +364,10 @@ async def test_verify_review_receipt_delivers_ready_checkpoint( target_steps = segment["target_steps"] assert isinstance(target_steps, list) assert "release_issue_failure" in target_steps + assert segment["source_step"] == "check_review_posted" + assert "release_issue_failure" in segment["pull_closure"] + assert {"section": "release_issue_failure", "part": 0} in segment["pull_requests"] + assert segment["recipe_pull"] == tool_ctx_ready_recipe.receipt["recipe_pull"] @pytest.mark.anyio From 33c9ca7818cdd35832a13b56a6b0c2ee643656ca Mon Sep 17 00:00:00 2001 From: Trecek Date: Wed, 14 Oct 2026 17:55:41 -0700 Subject: [PATCH 29/46] test(review): use one checkpoint delivery import in completion tests --- tests/integration/test_pipeline_step_completion_flow.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/integration/test_pipeline_step_completion_flow.py b/tests/integration/test_pipeline_step_completion_flow.py index 7c2b124230..633a12cbb9 100644 --- a/tests/integration/test_pipeline_step_completion_flow.py +++ b/tests/integration/test_pipeline_step_completion_flow.py @@ -17,7 +17,6 @@ from autoskillit.core.types.results._type_results import SkillResult from autoskillit.server import _notify from autoskillit.server.recipe import _recipe_segment_delivery as delivery_module -from autoskillit.server.recipe._recipe_segment_delivery import PreparedRecipeSegmentDelivery from autoskillit.server.response._run_skill_completion import RunSkillCompletionMiddleware from autoskillit.server.tools import tools_pipeline_tracker from autoskillit.server.tools.tools_execution import run_skill @@ -116,8 +115,8 @@ async def call_next(_context) -> ToolResult: return json.loads(delivered.content[0].text)["receipt_id"] -def _prepared_delivery() -> PreparedRecipeSegmentDelivery: - return PreparedRecipeSegmentDelivery( +def _prepared_delivery() -> delivery_module.PreparedRecipeSegmentDelivery: + return delivery_module.PreparedRecipeSegmentDelivery( step_name="rectify", success_carrier=_SUCCESS_CARRIER, success_pull_carrier=_SUCCESS_CARRIER, From 339c6159d5cacef60aec766d54b2956b38855083 Mon Sep 17 00:00:00 2001 From: Trecek Date: Wed, 14 Oct 2026 18:01:14 -0700 Subject: [PATCH 30/46] refactor(review): share merge-state failure payload construction --- src/autoskillit/server/tools/tools_ci.py | 53 ++++++++++-------------- 1 file changed, 22 insertions(+), 31 deletions(-) diff --git a/src/autoskillit/server/tools/tools_ci.py b/src/autoskillit/server/tools/tools_ci.py index f4520b43ac..51556f644c 100644 --- a/src/autoskillit/server/tools/tools_ci.py +++ b/src/autoskillit/server/tools/tools_ci.py @@ -22,6 +22,20 @@ logger = get_logger(__name__) +def _merge_state_error(error: str, *, http_status: int | None = None) -> str: + envelope: dict[str, object] = { + "error": error, + "queue_available": False, + "merge_group_trigger": False, + "auto_merge_available": False, + "ci_event": None, + "ci_applicable": False, + } + if http_status is not None: + envelope["http_status"] = http_status + return json.dumps(envelope) + + def _validate_commit_status_inputs(sha: str, context: str, description: str) -> str | None: """Return the first commit-status input error, if any.""" if not sha: @@ -165,16 +179,7 @@ async def check_repo_merge_state( try: resolved_repo = await resolve_repo_from_remote(cwd, hint=remote_url or None) if not resolved_repo or "/" not in resolved_repo: - return json.dumps( - { - "error": f"Could not resolve owner/repo from cwd={cwd!r}", - "queue_available": False, - "merge_group_trigger": False, - "auto_merge_available": False, - "ci_event": None, - "ci_applicable": False, - } - ) + return _merge_state_error(f"Could not resolve owner/repo from cwd={cwd!r}") owner, repo = resolved_repo.split("/", 1) resolved_token = ( tool_ctx.token_factory() @@ -191,29 +196,15 @@ async def check_repo_merge_state( return json.dumps(state) except Exception as exc: logger.error("autoskillit.check_repo_merge_state failed", exc_info=True) - envelope: dict[str, object] = { - "error": f"{type(exc).__name__}: {exc}", - "queue_available": False, - "merge_group_trigger": False, - "auto_merge_available": False, - "ci_event": None, - "ci_applicable": False, - } - if isinstance(exc, httpx.HTTPStatusError): - envelope["http_status"] = exc.response.status_code - return json.dumps(envelope) + return _merge_state_error( + f"{type(exc).__name__}: {exc}", + http_status=( + exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None + ), + ) finally: if step_name: tool_ctx.timing_log.record(step_name, time.monotonic() - _start) except Exception as exc: logger.error("check_repo_merge_state unhandled exception", exc_info=True) - return json.dumps( - { - "error": f"{type(exc).__name__}: {exc}", - "queue_available": False, - "merge_group_trigger": False, - "auto_merge_available": False, - "ci_event": None, - "ci_applicable": False, - } - ) + return _merge_state_error(f"{type(exc).__name__}: {exc}") From 007ebe7d5b23e8554a14efcbe949ceb0ce5f3461 Mon Sep 17 00:00:00 2001 From: Trecek Date: Wed, 14 Oct 2026 18:03:14 -0700 Subject: [PATCH 31/46] refactor(review): share CI watcher failure payload construction --- .../server/tools/tools_ci_watch.py | 40 +++++-------------- 1 file changed, 9 insertions(+), 31 deletions(-) diff --git a/src/autoskillit/server/tools/tools_ci_watch.py b/src/autoskillit/server/tools/tools_ci_watch.py index d73cab4b14..9412204d95 100644 --- a/src/autoskillit/server/tools/tools_ci_watch.py +++ b/src/autoskillit/server/tools/tools_ci_watch.py @@ -34,6 +34,10 @@ def _coerce_none_string(value: str | None, *, tool: str) -> str | None: return value +def _wait_for_ci_error(error: str) -> str: + return json.dumps({"run_id": None, "conclusion": "error", "failed_jobs": [], "error": error}) + + async def _infer_ci_head_sha(head_sha: str | None, *, branch: str, cwd: str) -> str | None: """Infer the current HEAD SHA and warn if cwd is on another branch.""" if head_sha is not None or not cwd: @@ -130,13 +134,8 @@ async def wait_for_ci( _timing_ctx = None event = _coerce_none_string(event, tool="wait_for_ci") if event is not None and event not in KNOWN_CI_EVENTS: - return json.dumps( - { - "run_id": None, - "conclusion": "error", - "failed_jobs": [], - "error": f"Invalid event {event!r}. Valid events: {sorted(KNOWN_CI_EVENTS)}", - } + return _wait_for_ci_error( + f"Invalid event {event!r}. Valid events: {sorted(KNOWN_CI_EVENTS)}" ) with structlog.contextvars.bound_contextvars(tool="wait_for_ci"): logger.info("wait_for_ci", branch=branch, repo=repo or "(infer)") @@ -150,14 +149,7 @@ async def wait_for_ci( if tool_ctx.ci_watcher is None: if step_name: tool_ctx.timing_log.record(step_name, time.monotonic() - _start) - return json.dumps( - { - "run_id": None, - "conclusion": "error", - "failed_jobs": [], - "error": "CI watcher not configured", - } - ) + return _wait_for_ci_error("CI watcher not configured") head_sha = await _infer_ci_head_sha(head_sha, branch=branch, cwd=cwd) @@ -221,14 +213,7 @@ async def wait_for_ci( return json.dumps(result) except Exception as exc: logger.error("wait_for_ci failed", exc_info=True) - return json.dumps( - { - "run_id": None, - "conclusion": "error", - "failed_jobs": [], - "error": f"{type(exc).__name__}: {exc}", - } - ) + return _wait_for_ci_error(f"{type(exc).__name__}: {exc}") finally: if step_name: tool_ctx.timing_log.record(step_name, time.monotonic() - _start) @@ -236,14 +221,7 @@ async def wait_for_ci( logger.error("wait_for_ci unhandled exception", exc_info=True) if step_name and _timing_ctx is not None: _timing_ctx.timing_log.record(step_name, time.monotonic() - _start) - return json.dumps( - { - "run_id": None, - "conclusion": "error", - "failed_jobs": [], - "error": f"{type(exc).__name__}: {exc}", - } - ) + return _wait_for_ci_error(f"{type(exc).__name__}: {exc}") @mcp.tool(tags={"autoskillit", "kitchen", "ci"}, annotations={"readOnlyHint": True}) From 753718cb54c04f933566ede9d5a42d36f58d5f7a Mon Sep 17 00:00:00 2001 From: Trecek Date: Wed, 14 Oct 2026 18:28:46 -0700 Subject: [PATCH 32/46] fix(review): assert recovered credential admission at the binding boundary --- ...t_recipe_execution_receipt_completeness.py | 39 +++++++++++-------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/tests/server/test_recipe_execution_receipt_completeness.py b/tests/server/test_recipe_execution_receipt_completeness.py index 99e2406798..171abce547 100644 --- a/tests/server/test_recipe_execution_receipt_completeness.py +++ b/tests/server/test_recipe_execution_receipt_completeness.py @@ -13,7 +13,10 @@ FinalizedRecipeProjection, build_recipe_execution_credential, ) -from autoskillit.server.recipe._recipe_execution import get_recipe_execution +from autoskillit.server.recipe._recipe_execution import ( + bind_attested_runtime_invocation, + get_recipe_execution, +) from tests.server._recipe_fixture_parameters import ( IMPLEMENTATION_OVERRIDES, RESEARCH_OVERRIDES, @@ -207,22 +210,26 @@ async def test_following_the_recovery_yields_an_admitted_digest( receipt = json.loads(await complete_recipe_initialization(initialization_id=recovery_id)) assert receipt["success"] is True, receipt credential = receipt["recipe_execution"] - admitted = await _run_attested_call( - ready, - git_checkout, - step_name, - credential["execution_id"], - credential["invocation_template_digests"][step_name], + expected_template = installed.snapshot.templates[step_name] + invocation = expected_template.invocation + skill_inputs = { + value.name: value.effective_value + for value in invocation.skill_inputs + if value.is_present + } + bound_inputs, admitted_template = bind_attested_runtime_invocation( + installed, + execution_id=credential["execution_id"], + step_name=step_name, + template_digest=credential["invocation_template_digests"][step_name], + skill_command=_skill_command(ready.tool_ctx, step_name), + skill_inputs=skill_inputs, + actual_mcp_kwargs={ + value.name: value.effective_value for value in invocation.mcp_kwargs + }, ) - assert admitted["success"] is True, admitted - serialized = json.dumps(admitted) - for code in ( - "recipe_execution_attestation_missing", - "recipe_execution_id_mismatch", - "recipe_execution_step_mismatch", - "invocation_template_digest_mismatch", - ): - assert code not in serialized + assert admitted_template is expected_template + assert dict(bound_inputs) == skill_inputs @pytest.mark.parametrize("tool_ctx_ready_recipe", _RECIPE_FIXTURE_PARAMETERS, indirect=True) From 0acbd08f312919b2350663139bccfdd0b8c7e098 Mon Sep 17 00:00:00 2001 From: Trecek Date: Wed, 14 Oct 2026 18:29:50 -0700 Subject: [PATCH 33/46] fix(review): retrieve pulled verification bodies in the plan walk --- ...st_implementation_plan_bind_verify_walk.py | 33 +++++++++++++++---- 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/tests/server/test_implementation_plan_bind_verify_walk.py b/tests/server/test_implementation_plan_bind_verify_walk.py index fd41099059..105a4db766 100644 --- a/tests/server/test_implementation_plan_bind_verify_walk.py +++ b/tests/server/test_implementation_plan_bind_verify_walk.py @@ -163,12 +163,33 @@ async def test_plan_bind_verify_walk_uses_only_delivered_responses( assert carrier["kind"] == "success" assert _carrier_delivers_step(carrier, "verify") - verify_record = next(body for body in carrier["bodies"] if body["step"] == "verify") - verify_body = load_yaml(verify_record["body"])["verify"] - assert ( - verify_record["invocation_template_digest"] - == credential["invocation_template_digests"]["verify"] - ) + verify_record = next((body for body in carrier["bodies"] if body["step"] == "verify"), None) + if verify_record is not None: + verify_yaml = verify_record["body"] + assert ( + verify_record["invocation_template_digest"] + == credential["invocation_template_digests"]["verify"] + ) + else: + from autoskillit.server.tools.tools_recipe import get_recipe_section + + pull = {key: value for key, value in carrier["recipe_pull"].items() if key != "pull_tool"} + chunks = [] + page_args = {"part": 0} + while True: + page = json.loads(await get_recipe_section(section="verify", **pull, **page_args)) + assert page["success"] is True, page + assert page["payload_sha256"] == pull["payload_sha256"] + chunks.append(page["content"]) + if not page["has_more"]: + break + page_args = { + "part": page["next_part"], + "page_plan_sha256": page["page_plan_sha256"], + "continuation": page["continuation"], + } + verify_yaml = "".join(chunks) + verify_body = load_yaml(verify_yaml)["verify"] verify_context = { **plan_response, "plan_path": plan_path, From 1e03c84f32515d593a701dd166dd0942f9f45218 Mon Sep 17 00:00:00 2001 From: Trecek Date: Wed, 14 Oct 2026 18:33:46 -0700 Subject: [PATCH 34/46] fix(review): bind typed runtime values with recovered protocol credentials --- ...t_recipe_execution_receipt_completeness.py | 27 ++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/tests/server/test_recipe_execution_receipt_completeness.py b/tests/server/test_recipe_execution_receipt_completeness.py index 171abce547..d3bca0d190 100644 --- a/tests/server/test_recipe_execution_receipt_completeness.py +++ b/tests/server/test_recipe_execution_receipt_completeness.py @@ -13,6 +13,7 @@ FinalizedRecipeProjection, build_recipe_execution_credential, ) +from autoskillit.recipe import get_skill_contract, load_bundled_manifest from autoskillit.server.recipe._recipe_execution import ( bind_attested_runtime_invocation, get_recipe_execution, @@ -212,11 +213,22 @@ async def test_following_the_recovery_yields_an_admitted_digest( credential = receipt["recipe_execution"] expected_template = installed.snapshot.templates[step_name] invocation = expected_template.invocation - skill_inputs = { - value.name: value.effective_value - for value in invocation.skill_inputs - if value.is_present - } + contract = get_skill_contract(invocation.skill_name, load_bundled_manifest()) + assert contract is not None + input_defs = {input_def.name: input_def for input_def in contract.inputs} + skill_inputs = {} + for value in invocation.skill_inputs: + if not value.is_present: + continue + actual = value.effective_value + if not input_defs[value.name].accepts(actual): + assert value.context_dependencies or value.input_dependencies + actual = next( + sample + for sample in ("fixture", 1, False) + if input_defs[value.name].accepts(sample) + ) + skill_inputs[value.name] = actual bound_inputs, admitted_template = bind_attested_runtime_invocation( installed, execution_id=credential["execution_id"], @@ -225,7 +237,10 @@ async def test_following_the_recovery_yields_an_admitted_digest( skill_command=_skill_command(ready.tool_ctx, step_name), skill_inputs=skill_inputs, actual_mcp_kwargs={ - value.name: value.effective_value for value in invocation.mcp_kwargs + **{value.name: value.effective_value for value in invocation.mcp_kwargs}, + "step_name": step_name, + "recipe_execution_id": credential["execution_id"], + "invocation_template_digest": credential["invocation_template_digests"][step_name], }, ) assert admitted_template is expected_template From 882a3128cecd6c8eb49a0675d5458dcff7c42227 Mon Sep 17 00:00:00 2001 From: Trecek Date: Wed, 14 Oct 2026 18:34:40 -0700 Subject: [PATCH 35/46] fix(review): permit advertised recipe pulls within the response-only walk --- ...st_implementation_plan_bind_verify_walk.py | 28 +++++++++++-------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/tests/server/test_implementation_plan_bind_verify_walk.py b/tests/server/test_implementation_plan_bind_verify_walk.py index 105a4db766..216b0a3b79 100644 --- a/tests/server/test_implementation_plan_bind_verify_walk.py +++ b/tests/server/test_implementation_plan_bind_verify_walk.py @@ -171,23 +171,27 @@ async def test_plan_bind_verify_walk_uses_only_delivered_responses( == credential["invocation_template_digests"]["verify"] ) else: + from autoskillit.server.recipe._recipe_artifact import load_recipe_artifact + from autoskillit.server.tools import _recipe_section_handler as section_handler from autoskillit.server.tools.tools_recipe import get_recipe_section pull = {key: value for key, value in carrier["recipe_pull"].items() if key != "pull_tool"} chunks = [] page_args = {"part": 0} - while True: - page = json.loads(await get_recipe_section(section="verify", **pull, **page_args)) - assert page["success"] is True, page - assert page["payload_sha256"] == pull["payload_sha256"] - chunks.append(page["content"]) - if not page["has_more"]: - break - page_args = { - "part": page["next_part"], - "page_plan_sha256": page["page_plan_sha256"], - "continuation": page["continuation"], - } + with monkeypatch.context() as pull_reads: + pull_reads.setattr(section_handler, "load_recipe_artifact", load_recipe_artifact) + while True: + page = json.loads(await get_recipe_section(section="verify", **pull, **page_args)) + assert page["success"] is True, page + assert page["payload_sha256"] == pull["payload_sha256"] + chunks.append(page["content"]) + if not page["has_more"]: + break + page_args = { + "part": page["next_part"], + "page_plan_sha256": page["page_plan_sha256"], + "continuation": page["continuation"], + } verify_yaml = "".join(chunks) verify_body = load_yaml(verify_yaml)["verify"] verify_context = { From 75a3b356fc6396a92a4858fdd108792b0c7d78b0 Mon Sep 17 00:00:00 2001 From: Trecek Date: Wed, 14 Oct 2026 18:37:26 -0700 Subject: [PATCH 36/46] fix(review): match research attestation step names to recipe keys --- src/autoskillit/recipes/research.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/autoskillit/recipes/research.yaml b/src/autoskillit/recipes/research.yaml index 4920ddd21c..af097f32da 100644 --- a/src/autoskillit/recipes/research.yaml +++ b/src/autoskillit/recipes/research.yaml @@ -492,7 +492,7 @@ steps: skill_inputs: source_doc: ${{ context.experiment_plan }} cwd: ${{ context.worktree_path }} - step_name: decompose + step_name: decompose_phases output_dir: '{{AUTOSKILLIT_TEMP}}/make-groups' capture: group_files: ${{ result.group_files }} @@ -930,7 +930,7 @@ steps: worktree_path: ${{ context.worktree_path }} adjust: true cwd: ${{ context.worktree_path }} - step_name: adjust + step_name: adjust_experiment output_dir: . capture: experiment_results: ${{ result.results_path }} @@ -1060,7 +1060,7 @@ steps: classification_timestamp: ${{ context.classification_timestamp }} group_manifest: ${{ context.group_manifest }} cwd: ${{ context.worktree_path }} - step_name: generate_report + step_name: generate_report_inconclusive output_dir: '{{AUTOSKILLIT_TEMP}}/generate-report' capture: report_path: ${{ result.report_path }} @@ -1089,7 +1089,7 @@ steps: base_branch: ${{ inputs.base_branch }} worktree_path: ${{ context.worktree_path }} cwd: ${{ inputs.source_dir }} - step_name: fix + step_name: fix_tests output_dir: . capture: verdict: ${{ result.verdict }} From 5e4b0fe02f97bd644038c2af91d82af3cd5a9bf5 Mon Sep 17 00:00:00 2001 From: Trecek Date: Wed, 14 Oct 2026 18:39:00 -0700 Subject: [PATCH 37/46] build(review): compile corrected research attestation step names --- src/autoskillit/recipes/research.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/autoskillit/recipes/research.json b/src/autoskillit/recipes/research.json index ae4cc24d7e..22c0d5f70c 100644 --- a/src/autoskillit/recipes/research.json +++ b/src/autoskillit/recipes/research.json @@ -527,7 +527,7 @@ "source_doc": "${{ context.experiment_plan }}" }, "cwd": "${{ context.worktree_path }}", - "step_name": "decompose", + "step_name": "decompose_phases", "output_dir": "{{AUTOSKILLIT_TEMP}}/make-groups" }, "capture": { @@ -1084,7 +1084,7 @@ "adjust": true }, "cwd": "${{ context.worktree_path }}", - "step_name": "adjust", + "step_name": "adjust_experiment", "output_dir": "." }, "capture": { @@ -1244,7 +1244,7 @@ "group_manifest": "${{ context.group_manifest }}" }, "cwd": "${{ context.worktree_path }}", - "step_name": "generate_report", + "step_name": "generate_report_inconclusive", "output_dir": "{{AUTOSKILLIT_TEMP}}/generate-report" }, "capture": { @@ -1276,7 +1276,7 @@ "worktree_path": "${{ context.worktree_path }}" }, "cwd": "${{ inputs.source_dir }}", - "step_name": "fix", + "step_name": "fix_tests", "output_dir": "." }, "capture": { From e81f935f2ad717ecfea81e352ea5889a9280d97e Mon Sep 17 00:00:00 2001 From: Trecek Date: Wed, 14 Oct 2026 18:52:28 -0700 Subject: [PATCH 38/46] test(review): track the relocated CI auto-trigger Git ref site --- tests/arch/test_git_ref_qualification.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/arch/test_git_ref_qualification.py b/tests/arch/test_git_ref_qualification.py index 3e2bede1cb..0eb5fb6d89 100644 --- a/tests/arch/test_git_ref_qualification.py +++ b/tests/arch/test_git_ref_qualification.py @@ -163,7 +163,7 @@ ("server/git.py", "perform_merge", 565, "merge-base", "BARE"), ("server/git.py", "perform_merge", 617, "merge", "BARE"), ("core/install/install_detect.py", "_git", 64, "", "UNRESOLVABLE"), - ("server/tools/tools_ci_watch.py", "_auto_trigger_ci", 430, "push", "BARE"), + ("server/tools/tools_ci_watch.py", "_auto_trigger_ci", 408, "push", "BARE"), ("server/tools/tools_git.py", "classify_fix", 177, "fetch", "BARE"), ("server/tools/tools_git.py", "classify_fix", 196, "diff", "BARE"), ( From 2a4f9a534eeb6b9853e0515d8ec856e946f0a331 Mon Sep 17 00:00:00 2001 From: Trecek Date: Wed, 14 Oct 2026 18:53:01 -0700 Subject: [PATCH 39/46] docs(review): refresh research diagram identity after attestation fixes --- src/autoskillit/recipes/diagrams/research.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/autoskillit/recipes/diagrams/research.md b/src/autoskillit/recipes/diagrams/research.md index 9463481686..3d3602b97e 100644 --- a/src/autoskillit/recipes/diagrams/research.md +++ b/src/autoskillit/recipes/diagrams/research.md @@ -1,4 +1,4 @@ - + ## research From 7ff8a1876899017794902a11b0f620da2941df37 Mon Sep 17 00:00:00 2001 From: Trecek Date: Wed, 14 Oct 2026 21:54:31 -0700 Subject: [PATCH 40/46] fix(review): standardize pre-effect checkpoint failure envelopes --- src/autoskillit/server/recipe/_recipe_segment_delivery.py | 7 ++++++- tests/server/test_recipe_checkpoint_conformance.py | 8 +++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/autoskillit/server/recipe/_recipe_segment_delivery.py b/src/autoskillit/server/recipe/_recipe_segment_delivery.py index 7e7777a143..b185d90f82 100644 --- a/src/autoskillit/server/recipe/_recipe_segment_delivery.py +++ b/src/autoskillit/server/recipe/_recipe_segment_delivery.py @@ -677,7 +677,12 @@ def prepare_checkpoint( return prepare_recipe_segment_delivery(tool_ctx, step_name), None except Exception as exc: logger.warning("recipe_segment_pre_effect_preparation_failed", exc_info=True) - return None, json.dumps({"success": False, "error": f"{type(exc).__name__}: {exc}"}) + response = deny_envelope( + f"{fn.__name__}: recipe segment preparation failed: {type(exc).__name__}: {exc}", + stage="recipe_segment_delivery", + retriable=True, + ) + return None, json.dumps(response, ensure_ascii=False, separators=(",", ":")) def _recipe_delivery_outcome( diff --git a/tests/server/test_recipe_checkpoint_conformance.py b/tests/server/test_recipe_checkpoint_conformance.py index a1989b843f..779495eb5e 100644 --- a/tests/server/test_recipe_checkpoint_conformance.py +++ b/tests/server/test_recipe_checkpoint_conformance.py @@ -277,7 +277,13 @@ async def test_pre_effect_prepare_failure_prevents_handler( assert calls == [_STEP_NAME] assert json.loads(result) == { "success": False, - "error": "RecipeSegmentDeliveryError: unavailable", + "is_error": True, + "error": ( + f"{tool_name}: recipe segment preparation failed: " + "RecipeSegmentDeliveryError: unavailable" + ), + "stage": "recipe_segment_delivery", + "retriable": True, } From e9291dae699498e248820a200e823d7f66e11cce Mon Sep 17 00:00:00 2001 From: Trecek Date: Wed, 14 Oct 2026 21:56:03 -0700 Subject: [PATCH 41/46] refactor(review): clarify pull-only checkpoint carrier naming --- .../server/recipe/_recipe_segment_delivery.py | 23 +++++++++++-------- .../test_pipeline_step_completion_flow.py | 2 +- tests/server/_recipe_segment_test_helpers.py | 23 +++++++++++-------- .../test_recipe_checkpoint_conformance.py | 2 +- tests/server/test_recipe_segment_delivery.py | 18 +++++++-------- 5 files changed, 38 insertions(+), 30 deletions(-) diff --git a/src/autoskillit/server/recipe/_recipe_segment_delivery.py b/src/autoskillit/server/recipe/_recipe_segment_delivery.py index b185d90f82..c0835d9f63 100644 --- a/src/autoskillit/server/recipe/_recipe_segment_delivery.py +++ b/src/autoskillit/server/recipe/_recipe_segment_delivery.py @@ -68,11 +68,16 @@ class RecipeSegmentDeliveryError(RuntimeError): @dataclass(frozen=True, slots=True) class PreparedRecipeSegmentDelivery: - """Pre-effect carriers for one mapped checkpoint source.""" + """Pre-effect carriers for one mapped checkpoint source. + + The primary success carrier includes bodies when automatic delivery is enabled; + the pull-only variant omits bodies to fit larger tool responses. Recovery-only + tools can share a recovery carrier between both success slots. + """ step_name: str success_carrier: dict[str, Any] | None - success_pull_carrier: dict[str, Any] | None + success_pull_only_carrier: dict[str, Any] | None recovery_carrier: dict[str, Any] @@ -537,7 +542,7 @@ def _prepare_checkpoint_carriers( success=False, ) success_carrier: dict[str, Any] | None - success_pull_carrier: dict[str, Any] | None + success_pull_only_carrier: dict[str, Any] | None if definition is not None and definition.automatic_recipe_delivery: success_carrier = _checkpoint_carrier( persisted, @@ -546,7 +551,7 @@ def _prepare_checkpoint_carriers( tool_name=tool_name, success=True, ) - success_pull_carrier = _checkpoint_carrier( + success_pull_only_carrier = _checkpoint_carrier( persisted, ready=ready, step_name=step_name, @@ -560,12 +565,12 @@ def _prepare_checkpoint_carriers( step_name, recovery_carrier, ) - success_pull_carrier = success_carrier + success_pull_only_carrier = success_carrier if success_carrier is not None: build_post_effect_segment_failure(success_carrier, tool_name=tool_name) if recovery_carrier is not success_carrier: build_post_effect_segment_failure(recovery_carrier, tool_name=tool_name) - return success_carrier, success_pull_carrier, recovery_carrier + return success_carrier, success_pull_only_carrier, recovery_carrier def prepare_recipe_segment_delivery( @@ -614,7 +619,7 @@ def prepare_recipe_segment_delivery( validate_segment_delivery_projection(state.finalized_projection) tool_name = _delivery_tool_name(state.finalized_projection, step_name) or "unknown" definition = get_tool_def(tool_name) - success_carrier, success_pull_carrier, recovery_carrier = _prepare_checkpoint_carriers( + success_carrier, success_pull_only_carrier, recovery_carrier = _prepare_checkpoint_carriers( persisted, ready=state, step_name=step_name, @@ -624,7 +629,7 @@ def prepare_recipe_segment_delivery( return PreparedRecipeSegmentDelivery( step_name=step_name, success_carrier=success_carrier, - success_pull_carrier=success_pull_carrier, + success_pull_only_carrier=success_pull_only_carrier, recovery_carrier=recovery_carrier, ) @@ -646,7 +651,7 @@ def attach_recipe_segment( and len(_serialized_bytes({**result, "recipe_segment": carrier})) >= RECIPE_SEGMENT_MAX_BYTES ): - carrier = prepared.success_pull_carrier + carrier = prepared.success_pull_only_carrier if carrier is None: return result return { diff --git a/tests/integration/test_pipeline_step_completion_flow.py b/tests/integration/test_pipeline_step_completion_flow.py index 633a12cbb9..59271f8fa7 100644 --- a/tests/integration/test_pipeline_step_completion_flow.py +++ b/tests/integration/test_pipeline_step_completion_flow.py @@ -119,7 +119,7 @@ def _prepared_delivery() -> delivery_module.PreparedRecipeSegmentDelivery: return delivery_module.PreparedRecipeSegmentDelivery( step_name="rectify", success_carrier=_SUCCESS_CARRIER, - success_pull_carrier=_SUCCESS_CARRIER, + success_pull_only_carrier=_SUCCESS_CARRIER, recovery_carrier=_RECOVERY_CARRIER, ) diff --git a/tests/server/_recipe_segment_test_helpers.py b/tests/server/_recipe_segment_test_helpers.py index 03626e9dac..8b853c8fcb 100644 --- a/tests/server/_recipe_segment_test_helpers.py +++ b/tests/server/_recipe_segment_test_helpers.py @@ -34,7 +34,7 @@ def install_prepared_recipe_segment( *, step_name: str, success_carrier: object = _DEFAULT_CARRIER, - success_pull_carrier: object = _DEFAULT_CARRIER, + success_pull_only_carrier: object = _DEFAULT_CARRIER, ) -> PreparedRecipeSegmentDelivery: success = ( { @@ -48,10 +48,10 @@ def install_prepared_recipe_segment( prepared = PreparedRecipeSegmentDelivery( step_name=step_name, success_carrier=success, - success_pull_carrier=( + success_pull_only_carrier=( success - if success_pull_carrier is _DEFAULT_CARRIER - else cast(dict[str, Any] | None, success_pull_carrier) + if success_pull_only_carrier is _DEFAULT_CARRIER + else cast(dict[str, Any] | None, success_pull_only_carrier) ), recovery_carrier={ "kind": "recovery", @@ -130,7 +130,7 @@ async def assert_real_checkpoint_response_budget( assert_delivery_outcome(tool_name, result, expected=True) prepared = _real_success_delivery(ready, tool_name) assert prepared.success_carrier is not None - assert prepared.success_pull_carrier is not None + assert prepared.success_pull_only_carrier is not None if not prepared.success_carrier.get("bodies") and not prepared.success_carrier.get( "pull_closures" ): @@ -146,7 +146,7 @@ async def assert_real_checkpoint_response_budget( assert inline_size < RECIPE_SEGMENT_MAX_BYTES expanded["checkpoint_auxiliary"] = "x" * (RECIPE_SEGMENT_MAX_BYTES - inline_size) attached = delivery_module.attach_recipe_segment(expanded, prepared, success=True) - assert attached["recipe_segment"] == prepared.success_pull_carrier + assert attached["recipe_segment"] == prepared.success_pull_only_carrier shaped = enforce_response_budget( attached, tool_name=tool_name, @@ -156,10 +156,13 @@ async def assert_real_checkpoint_response_budget( payload = json.loads(shaped) if isinstance(shaped, str) else shaped assert payload.get("subtype") != "recipe_segment_post_effect_delivery_failure" assert_delivery_outcome(tool_name, payload, expected=True) - assert payload["recipe_segment"] == prepared.success_pull_carrier - assert prepared.success_pull_carrier["source_step"] == prepared.step_name - assert prepared.success_pull_carrier["recipe_pull"] == prepared.success_carrier["recipe_pull"] - assert prepared.success_pull_carrier["pull_closures"] + assert payload["recipe_segment"] == prepared.success_pull_only_carrier + assert prepared.success_pull_only_carrier["source_step"] == prepared.step_name + assert ( + prepared.success_pull_only_carrier["recipe_pull"] + == prepared.success_carrier["recipe_pull"] + ) + assert prepared.success_pull_only_carrier["pull_closures"] for key, value in base.items(): assert payload[key] == value, key metadata = payload.get(RESPONSE_SPILL_METADATA_KEY) diff --git a/tests/server/test_recipe_checkpoint_conformance.py b/tests/server/test_recipe_checkpoint_conformance.py index 779495eb5e..5fcedf05e0 100644 --- a/tests/server/test_recipe_checkpoint_conformance.py +++ b/tests/server/test_recipe_checkpoint_conformance.py @@ -87,7 +87,7 @@ def _install_preparer( prepared = delivery_module.PreparedRecipeSegmentDelivery( step_name=_STEP_NAME, success_carrier=success_carrier, - success_pull_carrier=(_PULL_SUCCESS_CARRIER if success_carrier is not None else None), + success_pull_only_carrier=(_PULL_SUCCESS_CARRIER if success_carrier is not None else None), recovery_carrier=_RECOVERY_CARRIER, ) calls: list[str] = [] diff --git a/tests/server/test_recipe_segment_delivery.py b/tests/server/test_recipe_segment_delivery.py index 35d7c11afc..7535b503aa 100644 --- a/tests/server/test_recipe_segment_delivery.py +++ b/tests/server/test_recipe_segment_delivery.py @@ -228,12 +228,12 @@ def record_tool_name(carrier, *, tool_name: str): assert prepared is not None assert prepared.success_carrier is not None - assert prepared.success_pull_carrier is not None + assert prepared.success_pull_only_carrier is not None assert prepared.recovery_carrier is not None assert seen_tool_names == [expected_tool_name] * 4 assert validated_carriers == [ prepared.success_carrier, - prepared.success_pull_carrier, + prepared.success_pull_only_carrier, prepared.success_carrier, prepared.recovery_carrier, ] @@ -379,7 +379,7 @@ async def test_recovery_only_success_carrier_follows_success_crossings( claim_issue = prepare_recipe_segment_delivery(tool_ctx, "claim_and_resolve") assert claim_issue is not None assert claim_issue.success_carrier is None - assert claim_issue.success_pull_carrier is None + assert claim_issue.success_pull_only_carrier is None recovery = claim_issue.recovery_carrier assert recovery["kind"] == "recovery" assert recovery["source_step"] == "claim_and_resolve" @@ -400,7 +400,7 @@ async def test_success_carrier_degrades_to_pull_only_when_response_would_not_fit prepared = prepare_recipe_segment_delivery(tool_ctx, "bind_plan_set") assert prepared is not None assert prepared.success_carrier is not None - assert prepared.success_pull_carrier is not None + assert prepared.success_pull_only_carrier is not None base = { "success": True, @@ -425,7 +425,7 @@ async def test_success_carrier_degrades_to_pull_only_when_response_would_not_fit ) attached = attach_recipe_segment(base, prepared, success=True) - assert attached["recipe_segment"] == prepared.success_pull_carrier + assert attached["recipe_segment"] == prepared.success_pull_only_carrier shaped = enforce_response_budget( attached, tool_name="bind_plan_set", @@ -435,7 +435,7 @@ async def test_success_carrier_degrades_to_pull_only_when_response_would_not_fit payload = json.loads(shaped) if isinstance(shaped, str) else shaped assert payload["success"] is True - assert payload["recipe_segment"] == prepared.success_pull_carrier + assert payload["recipe_segment"] == prepared.success_pull_only_carrier assert all(payload[key] == value for key, value in base.items()) @@ -452,7 +452,7 @@ async def test_pull_only_success_survives_oversized_bind_response_spill( assert isinstance(state, ReadyRecipe) prepared = prepare_recipe_segment_delivery(tool_ctx, "bind_plan_set") assert prepared is not None - assert prepared.success_pull_carrier is not None + assert prepared.success_pull_only_carrier is not None base = { "success": True, "plan_set_state": "sealed", @@ -462,7 +462,7 @@ async def test_pull_only_success_survives_oversized_bind_response_spill( } attached = attach_recipe_segment(base, prepared, success=True) - assert attached["recipe_segment"] == prepared.success_pull_carrier + assert attached["recipe_segment"] == prepared.success_pull_only_carrier shaped = enforce_response_budget( attached, tool_name="bind_plan_set", @@ -475,7 +475,7 @@ async def test_pull_only_success_survives_oversized_bind_response_spill( assert "do_not_repeat" not in payload assert payload["success"] is True assert payload["plan_set_authority_path"] == base["plan_set_authority_path"] - assert payload["recipe_segment"] == prepared.success_pull_carrier + assert payload["recipe_segment"] == prepared.success_pull_only_carrier assert payload["recipe_segment"]["source_step"] == "bind_plan_set" assert payload["recipe_segment"]["recipe_pull"] == state.artifact_generation.pull_identity() pull_requests = [ From bb8217a0dbda8a00dab94c2f3427e8311e02a623 Mon Sep 17 00:00:00 2001 From: Trecek Date: Wed, 14 Oct 2026 21:57:17 -0700 Subject: [PATCH 42/46] refactor(review): remove duplicate run command response branch --- src/autoskillit/server/tools/tools_execution/_run_cmd.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/autoskillit/server/tools/tools_execution/_run_cmd.py b/src/autoskillit/server/tools/tools_execution/_run_cmd.py index 1124078cae..04c11bcdf6 100644 --- a/src/autoskillit/server/tools/tools_execution/_run_cmd.py +++ b/src/autoskillit/server/tools/tools_execution/_run_cmd.py @@ -207,8 +207,6 @@ async def run_cmd( "autoskillit.run_cmd", extra={"exit_code": returncode}, ) - if result.get("success") is True: - return json.dumps(result) return json.dumps(result) finally: if step_name: From 17022e46b64fd0c7576bde08da1c17ff1a9ddb81 Mon Sep 17 00:00:00 2001 From: Trecek Date: Wed, 14 Oct 2026 21:58:29 -0700 Subject: [PATCH 43/46] refactor(review): centralize research ready fixture inputs --- tests/server/_recipe_fixture_parameters.py | 2 ++ tests/server/conftest.py | 23 +++++----------------- 2 files changed, 7 insertions(+), 18 deletions(-) diff --git a/tests/server/_recipe_fixture_parameters.py b/tests/server/_recipe_fixture_parameters.py index 72900e5848..f677d00247 100644 --- a/tests/server/_recipe_fixture_parameters.py +++ b/tests/server/_recipe_fixture_parameters.py @@ -21,3 +21,5 @@ "task": "test task", "task_description": "test task", } + +RESEARCH_READY_DEFAULT = ("research", "scope", RESEARCH_OVERRIDES) diff --git a/tests/server/conftest.py b/tests/server/conftest.py index 3b195c99d5..682ba0773b 100644 --- a/tests/server/conftest.py +++ b/tests/server/conftest.py @@ -17,7 +17,7 @@ import structlog.testing from tests.conftest import bind_test_kitchen_identity -from tests.server._recipe_fixture_parameters import RESEARCH_OVERRIDES +from tests.server._recipe_fixture_parameters import RESEARCH_READY_DEFAULT if TYPE_CHECKING: from autoskillit.pipeline import ToolContext @@ -396,17 +396,6 @@ def _forbidden(*_args, **_kwargs): return lambda: monkeypatch.setattr(recipe_section_handler, "load_recipe_artifact", _forbidden) -_READY_RECIPE_NAME = "research" -_READY_RECIPE_ATTESTED_STEP = "scope" -# Default-preserving: every existing tool_ctx_ready_recipe consumer requests -# it without parametrization and must keep getting exactly this triple. -_READY_RECIPE_DEFAULT_PARAM = ( - _READY_RECIPE_NAME, - _READY_RECIPE_ATTESTED_STEP, - RESEARCH_OVERRIDES, -) - - @dataclass(frozen=True) class ReadyRecipeContext: """A real attested ToolContext plus the delivered credential and step body. @@ -422,7 +411,7 @@ class ReadyRecipeContext: tool_ctx: ToolContext receipt: dict[str, Any] step_body: dict[str, Any] - step_name: str = _READY_RECIPE_ATTESTED_STEP + step_name: str = RESEARCH_READY_DEFAULT[1] @property def credential(self) -> dict[str, Any]: @@ -492,10 +481,8 @@ async def tool_ctx_ready_recipe( Indirectly parametrizable over ``(recipe_name, attested_step, ingredient_overrides)`` via ``@pytest.mark.parametrize("tool_ctx_ready_recipe", [(...)], indirect=True)``. - Requesting the fixture without parametrization (every pre-existing consumer) - defaults to today's values — ``_READY_RECIPE_DEFAULT_PARAM`` — so - ``test_attestation_delivery_reachability.py`` and ``test_recipe_segment_delivery.py`` - are source-compatible. + Without parametrization, the fixture uses ``RESEARCH_READY_DEFAULT`` from + the shared recipe inputs module. """ from autoskillit.server.tools.tools_recipe import complete_recipe_initialization from tests.server._helpers import ( @@ -505,7 +492,7 @@ async def tool_ctx_ready_recipe( ) recipe_name, attested_step, ingredient_overrides = getattr( - request, "param", _READY_RECIPE_DEFAULT_PARAM + request, "param", RESEARCH_READY_DEFAULT ) monkeypatch.chdir(tmp_path) From 1e985dff7a91a3661711cfed8744049102316138 Mon Sep 17 00:00:00 2001 From: Trecek Date: Wed, 14 Oct 2026 21:59:09 -0700 Subject: [PATCH 44/46] test(review): type the completion receipt helper context --- tests/server/test_complete_run_skill_result_delivery.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/server/test_complete_run_skill_result_delivery.py b/tests/server/test_complete_run_skill_result_delivery.py index c3dfb6b2ed..9344b60598 100644 --- a/tests/server/test_complete_run_skill_result_delivery.py +++ b/tests/server/test_complete_run_skill_result_delivery.py @@ -7,6 +7,7 @@ import pytest +from autoskillit.pipeline import ToolContext from tests.server._recipe_fixture_parameters import ( IMPLEMENTATION_READY_PARAMETERS, ) @@ -14,7 +15,7 @@ pytestmark = [pytest.mark.layer("server"), pytest.mark.anyio, pytest.mark.medium] -def _publish_receipt(tool_ctx, *, success: bool) -> str: +def _publish_receipt(tool_ctx: ToolContext, *, success: bool) -> str: authority = tool_ctx.run_skill_completion assert authority is not None invocation_id = authority.begin( From 946e68365c2444d007b2f21f0e55c51bd25c90a5 Mon Sep 17 00:00:00 2001 From: Trecek Date: Wed, 14 Oct 2026 21:59:44 -0700 Subject: [PATCH 45/46] test(review): share completion receipt request session identity --- tests/server/test_complete_run_skill_result_delivery.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/server/test_complete_run_skill_result_delivery.py b/tests/server/test_complete_run_skill_result_delivery.py index 9344b60598..fb9c632e2b 100644 --- a/tests/server/test_complete_run_skill_result_delivery.py +++ b/tests/server/test_complete_run_skill_result_delivery.py @@ -14,13 +14,15 @@ pytestmark = [pytest.mark.layer("server"), pytest.mark.anyio, pytest.mark.medium] +_REQUEST_SESSION_ID = "request-session" + def _publish_receipt(tool_ctx: ToolContext, *, success: bool) -> str: authority = tool_ctx.run_skill_completion assert authority is not None invocation_id = authority.begin( kitchen_id=tool_ctx.kitchen_id, - request_session_id="request-session", + request_session_id=_REQUEST_SESSION_ID, tracker_order_id="", tracker_path="", tracker_kitchen_id="", @@ -62,7 +64,7 @@ async def test_completion_receipt_delivery_carries_plan_step_name( response = json.loads( await complete_run_skill_result( receipt_id, - ctx=SimpleNamespace(session_id="request-session"), + ctx=SimpleNamespace(session_id=_REQUEST_SESSION_ID), ) ) From 5d0c66af42b3cb0bfc2130562e9a15f90543a73d Mon Sep 17 00:00:00 2001 From: Trecek Date: Wed, 14 Oct 2026 22:12:07 -0700 Subject: [PATCH 46/46] test(review): assert canonical clone checkpoint denial envelope --- tests/server/test_tools_clone.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/server/test_tools_clone.py b/tests/server/test_tools_clone.py index f96c22f435..be098549e8 100644 --- a/tests/server/test_tools_clone.py +++ b/tests/server/test_tools_clone.py @@ -269,7 +269,13 @@ def _reject(*_args, **_kwargs): assert result == { "success": False, - "error": "RecipeSegmentDeliveryError: READY compile identity differs from artifact", + "is_error": True, + "error": ( + "push_to_remote: recipe segment preparation failed: " + "RecipeSegmentDeliveryError: READY compile identity differs from artifact" + ), + "stage": "recipe_segment_delivery", + "retriable": True, } assert_delivery_outcome("push_to_remote", result, expected=False) mock_mgr.push_to_remote.assert_not_called()