diff --git a/features/correction_checkpoint_rollback.feature b/features/correction_checkpoint_rollback.feature new file mode 100644 index 000000000..efebfcad0 --- /dev/null +++ b/features/correction_checkpoint_rollback.feature @@ -0,0 +1,111 @@ +@phase2 @correction @checkpoint @rollback +Feature: Correction checkpoint rollback wiring + As an operator + I want corrections to roll back filesystem state via workspace checkpoints + So that reverted decisions leave no stale artifacts on disk + + # ─────────────────────────────────────────────────────────── + # Workspace snapshot creation + # ─────────────────────────────────────────────────────────── + + Scenario: Workspace snapshot created before decision execution + Given ccr a checkpoint service + When ccr I create a workspace snapshot for plan "01ARZ3NDEKTSV4RRFFQ69G5FAV" decision "01DEC1S10N0000000000000000" with sandbox_ref "abc123" + Then ccr the snapshot should be created with type "pre_decision" + And ccr the snapshot should be aligned to decision "01DEC1S10N0000000000000000" + And ccr the snapshot metadata should indicate diff-based storage + + Scenario: Workspace snapshot stores diff manifest in metadata + Given ccr a checkpoint service + When ccr I create a workspace snapshot for plan "01ARZ3NDEKTSV4RRFFQ69G5FAV" decision "01DEC1S10N0000000000000000" with sandbox_ref "abc123" + Then ccr the snapshot metadata extra should contain "diff_based" as true + And ccr the snapshot metadata extra should contain a "diff_hash" + + # ─────────────────────────────────────────────────────────── + # CorrectionService.revert_decisions() wiring + # ─────────────────────────────────────────────────────────── + + Scenario: revert_decisions invokes checkpoint rollback + Given ccr a correction service with checkpoint support + When ccr I call revert_decisions for plan "01PL4N000000000000000000" targeting decision "01DEC0000000000000000000" + Then ccr the correction result should have status "applied" + And ccr the correction result should have reverted decisions + + Scenario: revert_decisions without checkpoint service still succeeds + Given ccr a correction service without checkpoint support + When ccr I call revert_decisions for plan "01PL4N000000000000000000" targeting decision "01DEC0000000000000000000" + Then ccr the correction result should have status "applied" + And ccr the correction result checkpoint_restored should be false + + # ─────────────────────────────────────────────────────────── + # Physical artifact archival + # ─────────────────────────────────────────────────────────── + + Scenario: Artifacts physically moved to archive directory + Given ccr a temporary sandbox directory with artifact files + And ccr a checkpoint service + When ccr I archive artifacts from the sandbox + Then ccr the artifacts should exist in the archive directory + And ccr the artifacts should not exist in their original locations + + Scenario: Archive tolerates missing artifact files + Given ccr a temporary sandbox directory without artifact files + And ccr a checkpoint service + When ccr I archive artifacts from the sandbox + Then ccr no errors should be raised + And ccr the archived count should be 0 + + # ─────────────────────────────────────────────────────────── + # Selective rollback + # ─────────────────────────────────────────────────────────── + + Scenario: Selective rollback to a specific checkpoint + Given ccr a checkpoint service with sandbox for plan "01ARZ3NDEKTSV4RRFFQ69G5FAV" + And ccr checkpoints "cp1" and "cp2" exist for plan "01ARZ3NDEKTSV4RRFFQ69G5FAV" + When ccr I selectively rollback to checkpoint "cp1" + Then ccr the rollback should succeed + + Scenario: Selective rollback is atomic on failure + Given ccr a checkpoint service with sandbox for plan "01ARZ3NDEKTSV4RRFFQ69G5FAV" + When ccr I attempt selective rollback to a nonexistent checkpoint + Then ccr the sandbox should remain at the original HEAD + + # ─────────────────────────────────────────────────────────── + # Diff-based storage + # ─────────────────────────────────────────────────────────── + + Scenario: Diff-based checkpoint stores only changed file paths + Given ccr a checkpoint service with sandbox for plan "01ARZ3NDEKTSV4RRFFQ69G5FAV" + And ccr an initial checkpoint exists for plan "01ARZ3NDEKTSV4RRFFQ69G5FAV" + And ccr a file is modified and committed in the sandbox + When ccr I create a workspace snapshot capturing the diff + Then ccr the snapshot diff_paths should include the modified file + + # ─────────────────────────────────────────────────────────── + # Atomic rollback + # ─────────────────────────────────────────────────────────── + + Scenario: Atomic rollback fully restores on success + Given ccr a checkpoint service with sandbox for plan "01ARZ3NDEKTSV4RRFFQ69G5FAV" + And ccr a checkpoint is created from sandbox HEAD + And ccr a file is modified and committed in the sandbox + When ccr I perform selective rollback to the checkpoint + Then ccr the modified file should be reverted to its original state + + # ─────────────────────────────────────────────────────────── + # DI container wiring + # ─────────────────────────────────────────────────────────── + + Scenario: DI container wires checkpoint_service into CorrectionService + Given ccr a fresh DI container + When ccr I resolve the correction_service from the container + Then ccr the correction_service should have a non-None checkpoint_service + + # ─────────────────────────────────────────────────────────── + # CLI rollback --to-checkpoint + # ─────────────────────────────────────────────────────────── + + Scenario: plan rollback --to-checkpoint option accepted + Given ccr a CLI runner + When ccr I invoke plan rollback with --to-checkpoint option + Then ccr the command should recognize the --to-checkpoint flag diff --git a/features/mocks/tdd_plan_correct_plan_id_fixtures.py b/features/mocks/tdd_plan_correct_plan_id_fixtures.py index 131c8e7dd..56b888526 100644 --- a/features/mocks/tdd_plan_correct_plan_id_fixtures.py +++ b/features/mocks/tdd_plan_correct_plan_id_fixtures.py @@ -95,6 +95,7 @@ def _make_plan(_plan_id: str) -> Plan: def make_mock_container( decisions: list[SimpleNamespace], influence_edges: dict[str, list[str]], + correction_svc: MagicMock | None = None, ) -> MagicMock: """Build a mock DI container returning DecisionService and PlanLifecycleService.""" mock_decision_svc = MagicMock() @@ -110,6 +111,8 @@ def make_mock_container( mock_container = MagicMock() mock_container.decision_service.return_value = mock_decision_svc mock_container.plan_lifecycle_service.return_value = mock_plan_svc + if correction_svc is not None: + mock_container.correction_service.return_value = correction_svc return mock_container diff --git a/features/steps/application_container_coverage_boost_steps.py b/features/steps/application_container_coverage_boost_steps.py index 8d24be053..8c44dfcf7 100644 --- a/features/steps/application_container_coverage_boost_steps.py +++ b/features/steps/application_container_coverage_boost_steps.py @@ -211,7 +211,7 @@ def step_build_session_factory_invalid(context: Any) -> None: # SQLAlchemy lazily connects; the factory itself succeeds, but # calling it to create a session and executing SQL raises. context.boost_session_factory = _build_session_factory( - "sqlite:///nonexistent/path/to/db.sqlite" + "sqlite:////proc/nonexistent/impossible/db.sqlite" ) diff --git a/features/steps/correction_checkpoint_rollback_steps.py b/features/steps/correction_checkpoint_rollback_steps.py new file mode 100644 index 000000000..5d7878a63 --- /dev/null +++ b/features/steps/correction_checkpoint_rollback_steps.py @@ -0,0 +1,434 @@ +"""Step definitions for correction_checkpoint_rollback.feature. + +Exercises the wiring of checkpoint rollback into the correction service +revert flow, including workspace snapshots, physical artifact archival, +selective rollback, diff-based storage, and atomic rollback semantics. +""" + +from __future__ import annotations + +import os +import subprocess +import tempfile +from pathlib import Path + +from behave import given, then, when + +from cleveragents.application.services.checkpoint_service import CheckpointService +from cleveragents.application.services.correction_service import CorrectionService +from cleveragents.core.exceptions import BusinessRuleViolation, ResourceNotFoundError + +# ─── Helpers ─────────────────────────────────────────────────────────── + + +def _init_sandbox(tmpdir: str) -> str: + """Create a bare git repo sandbox in tmpdir and return its path.""" + sandbox = os.path.join(tmpdir, "sandbox") + os.makedirs(sandbox) + subprocess.run(["git", "init"], cwd=sandbox, capture_output=True, check=True) + subprocess.run( + ["git", "config", "user.email", "test@test.com"], + cwd=sandbox, + capture_output=True, + check=True, + ) + subprocess.run( + ["git", "config", "user.name", "Test"], + cwd=sandbox, + capture_output=True, + check=True, + ) + # Initial commit + init_file = os.path.join(sandbox, "README.md") + with open(init_file, "w") as f: + f.write("initial\n") + subprocess.run(["git", "add", "."], cwd=sandbox, capture_output=True, check=True) + subprocess.run( + ["git", "commit", "-m", "init"], + cwd=sandbox, + capture_output=True, + check=True, + ) + return sandbox + + +def _get_head(sandbox: str) -> str: + """Get current HEAD commit hash.""" + result = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=sandbox, + capture_output=True, + text=True, + check=True, + ) + return result.stdout.strip() + + +PLAN_ID = "01ARZ3NDEKTSV4RRFFQ69G5FAV" + + +# ─── Workspace snapshot creation ────────────────────────────────────── + + +@given("ccr a checkpoint service") +def step_ccr_checkpoint_service(context): + context.ccr_svc = CheckpointService() + + +@when( + 'ccr I create a workspace snapshot for plan "{plan_id}" ' + 'decision "{decision_id}" with sandbox_ref "{ref}"' +) +def step_ccr_create_workspace_snapshot(context, plan_id, decision_id, ref): + context.ccr_snapshot = context.ccr_svc.create_workspace_snapshot( + plan_id=plan_id, + sandbox_ref=ref, + decision_id=decision_id, + ) + + +@then('ccr the snapshot should be created with type "{cp_type}"') +def step_ccr_snapshot_type(context, cp_type): + assert context.ccr_snapshot.checkpoint_type == cp_type + + +@then('ccr the snapshot should be aligned to decision "{decision_id}"') +def step_ccr_snapshot_decision(context, decision_id): + assert context.ccr_snapshot.decision_id == decision_id + + +@then("ccr the snapshot metadata should indicate diff-based storage") +def step_ccr_snapshot_diff_based(context): + assert context.ccr_snapshot.metadata.extra.get("diff_based") is True + + +@then('ccr the snapshot metadata extra should contain "diff_based" as true') +def step_ccr_snapshot_diff_based_true(context): + assert context.ccr_snapshot.metadata.extra.get("diff_based") is True + + +@then('ccr the snapshot metadata extra should contain a "diff_hash"') +def step_ccr_snapshot_diff_hash(context): + assert "diff_hash" in context.ccr_snapshot.metadata.extra + assert isinstance(context.ccr_snapshot.metadata.extra["diff_hash"], str) + assert len(context.ccr_snapshot.metadata.extra["diff_hash"]) > 0 + + +# ─── CorrectionService.revert_decisions() wiring ───────────────────── + + +@given("ccr a correction service with checkpoint support") +def step_ccr_correction_with_checkpoint(context): + context.ccr_cp_svc = CheckpointService() + context.ccr_corr_svc = CorrectionService( + checkpoint_service=context.ccr_cp_svc, + ) + + +@given("ccr a correction service without checkpoint support") +def step_ccr_correction_without_checkpoint(context): + context.ccr_corr_svc = CorrectionService() + + +@when( + 'ccr I call revert_decisions for plan "{plan_id}" ' + 'targeting decision "{decision_id}"' +) +def step_ccr_revert_decisions(context, plan_id, decision_id): + context.ccr_result = context.ccr_corr_svc.revert_decisions( + plan_id=plan_id, + target_decision_id=decision_id, + ) + + +@then('ccr the correction result should have status "{status}"') +def step_ccr_result_status(context, status): + assert context.ccr_result.status.value == status + + +@then("ccr the correction result should have reverted decisions") +def step_ccr_result_has_reverted(context): + assert len(context.ccr_result.reverted_decisions) > 0 + + +@then("ccr the correction result checkpoint_restored should be false") +def step_ccr_result_no_checkpoint(context): + assert context.ccr_result.checkpoint_restored is False + + +# ─── Physical artifact archival ─────────────────────────────────────── + + +@given("ccr a temporary sandbox directory with artifact files") +def step_ccr_sandbox_with_artifacts(context): + context.ccr_tmpdir = tempfile.mkdtemp() + sandbox = os.path.join(context.ccr_tmpdir, "sandbox") + os.makedirs(sandbox) + # Create artifact files + context.ccr_artifact_paths = ["output/result.txt", "output/log.txt"] + for rel in context.ccr_artifact_paths: + full = os.path.join(sandbox, rel) + os.makedirs(os.path.dirname(full), exist_ok=True) + with open(full, "w") as f: + f.write(f"artifact: {rel}\n") + context.ccr_sandbox_path = sandbox + + +@given("ccr a temporary sandbox directory without artifact files") +def step_ccr_sandbox_without_artifacts(context): + context.ccr_tmpdir = tempfile.mkdtemp() + sandbox = os.path.join(context.ccr_tmpdir, "sandbox") + os.makedirs(sandbox) + context.ccr_artifact_paths = ["nonexistent/file.txt"] + context.ccr_sandbox_path = sandbox + + +@when("ccr I archive artifacts from the sandbox") +def step_ccr_archive_artifacts(context): + context.ccr_error = None + try: + context.ccr_archived = context.ccr_svc.archive_artifacts( + context.ccr_sandbox_path, + context.ccr_artifact_paths, + ) + except Exception as exc: + context.ccr_error = exc + context.ccr_archived = [] + + +@then("ccr the artifacts should exist in the archive directory") +def step_ccr_artifacts_in_archive(context): + archive_root = ( + Path(context.ccr_sandbox_path) / ".cleveragents" / "archived_artifacts" + ) + for rel in context.ccr_archived: + assert (archive_root / rel).exists(), f"Archived file missing: {rel}" + + +@then("ccr the artifacts should not exist in their original locations") +def step_ccr_artifacts_not_in_original(context): + for rel in context.ccr_archived: + original = Path(context.ccr_sandbox_path) / rel + assert not original.exists(), f"Original file still exists: {rel}" + + +@then("ccr no errors should be raised") +def step_ccr_no_errors(context): + assert context.ccr_error is None + + +@then("ccr the archived count should be {count:d}") +def step_ccr_archived_count(context, count): + assert len(context.ccr_archived) == count + + +# ─── Selective rollback ─────────────────────────────────────────────── + + +@given('ccr a checkpoint service with sandbox for plan "{plan_id}"') +def step_ccr_svc_with_sandbox(context, plan_id): + context.ccr_tmpdir = tempfile.mkdtemp() + context.ccr_sandbox = _init_sandbox(context.ccr_tmpdir) + context.ccr_svc = CheckpointService() + context.ccr_svc.register_sandbox(plan_id, context.ccr_sandbox) + context.ccr_plan_id = plan_id + + +@given('ccr checkpoints "cp1" and "cp2" exist for plan "{plan_id}"') +def step_ccr_two_checkpoints(context, plan_id): + head1 = _get_head(context.ccr_sandbox) + context.ccr_cp1 = context.ccr_svc.create_checkpoint( + plan_id=plan_id, + sandbox_ref=head1, + reason="cp1", + ) + # Make a change and commit for cp2 + fpath = os.path.join(context.ccr_sandbox, "file2.txt") + with open(fpath, "w") as f: + f.write("cp2 content\n") + subprocess.run( + ["git", "add", "."], cwd=context.ccr_sandbox, capture_output=True, check=True + ) + subprocess.run( + ["git", "commit", "-m", "cp2"], + cwd=context.ccr_sandbox, + capture_output=True, + check=True, + ) + head2 = _get_head(context.ccr_sandbox) + context.ccr_cp2 = context.ccr_svc.create_checkpoint( + plan_id=plan_id, + sandbox_ref=head2, + reason="cp2", + ) + + +@when('ccr I selectively rollback to checkpoint "cp1"') +def step_ccr_selective_rollback_cp1(context): + context.ccr_rollback_result = context.ccr_svc.selective_rollback( + context.ccr_plan_id, + context.ccr_cp1.checkpoint_id, + ) + + +@then("ccr the rollback should succeed") +def step_ccr_rollback_success(context): + assert context.ccr_rollback_result is not None + assert context.ccr_rollback_result.restored_files_count >= 0 + + +@when("ccr I attempt selective rollback to a nonexistent checkpoint") +def step_ccr_selective_rollback_nonexistent(context): + context.ccr_original_head = _get_head(context.ccr_sandbox) + context.ccr_rollback_error = None + try: + context.ccr_svc.selective_rollback( + context.ccr_plan_id, + "01NONEXISTENT0000000000000", + ) + except (ResourceNotFoundError, BusinessRuleViolation) as exc: + context.ccr_rollback_error = exc + + +@then("ccr the sandbox should remain at the original HEAD") +def step_ccr_sandbox_at_original_head(context): + assert context.ccr_rollback_error is not None + current_head = _get_head(context.ccr_sandbox) + assert current_head == context.ccr_original_head + + +# ─── Diff-based storage ────────────────────────────────────────────── + + +@given('ccr an initial checkpoint exists for plan "{plan_id}"') +def step_ccr_initial_checkpoint(context, plan_id): + head = _get_head(context.ccr_sandbox) + context.ccr_initial_cp = context.ccr_svc.create_checkpoint( + plan_id=plan_id, + sandbox_ref=head, + reason="initial", + ) + + +@given("ccr a file is modified and committed in the sandbox") +def step_ccr_modify_file(context): + fpath = os.path.join(context.ccr_sandbox, "modified.txt") + with open(fpath, "w") as f: + f.write("modified content\n") + subprocess.run( + ["git", "add", "."], cwd=context.ccr_sandbox, capture_output=True, check=True + ) + subprocess.run( + ["git", "commit", "-m", "modify"], + cwd=context.ccr_sandbox, + capture_output=True, + check=True, + ) + context.ccr_modified_file = "modified.txt" + + +@when("ccr I create a workspace snapshot capturing the diff") +def step_ccr_workspace_snapshot_diff(context): + head = _get_head(context.ccr_sandbox) + context.ccr_diff_snapshot = context.ccr_svc.create_workspace_snapshot( + plan_id=context.ccr_plan_id, + sandbox_ref=head, + decision_id="01DEC0000000000000000000", + ) + + +@then("ccr the snapshot diff_paths should include the modified file") +def step_ccr_diff_paths_contain_modified(context): + diff_paths = context.ccr_diff_snapshot.metadata.extra.get("diff_paths", []) + assert context.ccr_modified_file in diff_paths, ( + f"Expected {context.ccr_modified_file!r} in {diff_paths}" + ) + + +# ─── Atomic rollback ───────────────────────────────────────────────── + + +@given("ccr a checkpoint is created from sandbox HEAD") +def step_ccr_checkpoint_from_head(context): + head = _get_head(context.ccr_sandbox) + context.ccr_atomic_cp = context.ccr_svc.create_checkpoint( + plan_id=context.ccr_plan_id, + sandbox_ref=head, + reason="atomic-test", + ) + # Record original content + readme = os.path.join(context.ccr_sandbox, "README.md") + with open(readme) as f: + context.ccr_original_readme = f.read() + + +@when("ccr I perform selective rollback to the checkpoint") +def step_ccr_selective_rollback_atomic(context): + context.ccr_rollback_result = context.ccr_svc.selective_rollback( + context.ccr_plan_id, + context.ccr_atomic_cp.checkpoint_id, + ) + + +@then("ccr the modified file should be reverted to its original state") +def step_ccr_file_reverted(context): + readme = os.path.join(context.ccr_sandbox, "README.md") + with open(readme) as f: + content = f.read() + assert content == context.ccr_original_readme + + +# ─── DI container wiring ───────────────────────────────────────────── + + +@given("ccr a fresh DI container") +def step_ccr_fresh_container(context): + # Container will be resolved in the when step + pass + + +@when("ccr I resolve the correction_service from the container") +def step_ccr_resolve_correction_service(context): + from cleveragents.application.container import get_container + + container = get_container() + context.ccr_container_correction_svc = container.correction_service() + + +@then("ccr the correction_service should have a non-None checkpoint_service") +def step_ccr_correction_has_checkpoint(context): + svc = context.ccr_container_correction_svc + assert svc._checkpoint_service is not None, ( + "CorrectionService._checkpoint_service should be wired by the DI container" + ) + + +# ─── CLI rollback --to-checkpoint ───────────────────────────────────── + + +@given("ccr a CLI runner") +def step_ccr_cli_runner(context): + from typer.testing import CliRunner + + context.ccr_cli_runner = CliRunner() + + +@when("ccr I invoke plan rollback with --to-checkpoint option") +def step_ccr_cli_rollback_to_checkpoint(context): + from cleveragents.cli.commands.plan import app as plan_app + + result = context.ccr_cli_runner.invoke( + plan_app, + ["rollback", "PLAN-DUMMY", "--to-checkpoint", "CP-DUMMY", "--yes"], + ) + context.ccr_cli_result = result + + +@then("ccr the command should recognize the --to-checkpoint flag") +def step_ccr_cli_to_checkpoint_flag(context): + result = context.ccr_cli_result + # Typer/Click returns exit code 2 with "No such option" for unrecognized flags. + # Any other exit code means the flag was accepted by the CLI parser. + assert "No such option" not in result.output, ( + f"--to-checkpoint was not recognized: {result.output}" + ) diff --git a/features/steps/plan_cli_coverage_r2_steps.py b/features/steps/plan_cli_coverage_r2_steps.py index a31beb2fc..8f0d03979 100644 --- a/features/steps/plan_cli_coverage_r2_steps.py +++ b/features/steps/plan_cli_coverage_r2_steps.py @@ -493,7 +493,7 @@ def _make_rollback_result() -> RollbackResult: def step_mock_rollback_success(context: Context) -> None: mock_container = MagicMock() mock_cp_svc = MagicMock() - mock_cp_svc.rollback_to_checkpoint.return_value = _make_rollback_result() + mock_cp_svc.selective_rollback.return_value = _make_rollback_result() mock_container.checkpoint_service.return_value = mock_cp_svc p = patch(_PATCH_CONTAINER, return_value=mock_container) @@ -507,7 +507,7 @@ def step_mock_rollback_success(context: Context) -> None: def step_mock_rollback_business_rule(context: Context) -> None: mock_container = MagicMock() mock_cp_svc = MagicMock() - mock_cp_svc.rollback_to_checkpoint.side_effect = BusinessRuleViolation( + mock_cp_svc.selective_rollback.side_effect = BusinessRuleViolation( "Plan is already applied" ) mock_container.checkpoint_service.return_value = mock_cp_svc @@ -523,7 +523,7 @@ def step_mock_rollback_business_rule(context: Context) -> None: def step_mock_rollback_not_found(context: Context) -> None: mock_container = MagicMock() mock_cp_svc = MagicMock() - mock_cp_svc.rollback_to_checkpoint.side_effect = ResourceNotFoundError( + mock_cp_svc.selective_rollback.side_effect = ResourceNotFoundError( "Checkpoint not found" ) mock_container.checkpoint_service.return_value = mock_cp_svc @@ -537,9 +537,7 @@ def step_mock_rollback_not_found(context: Context) -> None: def step_mock_rollback_validation(context: Context) -> None: mock_container = MagicMock() mock_cp_svc = MagicMock() - mock_cp_svc.rollback_to_checkpoint.side_effect = ValidationError( - "Invalid checkpoint" - ) + mock_cp_svc.selective_rollback.side_effect = ValidationError("Invalid checkpoint") mock_container.checkpoint_service.return_value = mock_cp_svc p = patch(_PATCH_CONTAINER, return_value=mock_container) @@ -551,7 +549,7 @@ def step_mock_rollback_validation(context: Context) -> None: def step_mock_rollback_clever_error(context: Context) -> None: mock_container = MagicMock() mock_cp_svc = MagicMock() - mock_cp_svc.rollback_to_checkpoint.side_effect = CleverAgentsError( + mock_cp_svc.selective_rollback.side_effect = CleverAgentsError( "unknown rollback failure" ) mock_container.checkpoint_service.return_value = mock_cp_svc diff --git a/features/steps/plan_cli_uncovered_region_coverage_steps.py b/features/steps/plan_cli_uncovered_region_coverage_steps.py index 7bdb49177..628419131 100644 --- a/features/steps/plan_cli_uncovered_region_coverage_steps.py +++ b/features/steps/plan_cli_uncovered_region_coverage_steps.py @@ -48,7 +48,6 @@ _ULID_A = "01ARZ3NDEKTSV4RRFFQ69G5FAA" _PATCH_GET_LIFECYCLE = "cleveragents.cli.commands.plan._get_lifecycle_service" _PATCH_GET_APPLY = "cleveragents.cli.commands.plan._get_apply_service" -_PATCH_CORRECTION_SVC = "cleveragents.cli.commands.plan.CorrectionService" _PATCH_RESOLVE_ACTIVE = "cleveragents.cli.commands.plan._resolve_active_plan_id" _PATCH_CONTAINER = "cleveragents.application.container.get_container" @@ -502,15 +501,9 @@ def _invoke_correct( mock_decision_svc.get_influence_edges.return_value = {} mock_container = MagicMock() mock_container.decision_service.return_value = mock_decision_svc + mock_container.correction_service.return_value = mock_correction - # Patch CorrectionService to return our mock when instantiated - with ( - patch( - "cleveragents.application.services.correction_service.CorrectionService", - return_value=mock_correction, - ), - patch(_PATCH_CONTAINER, return_value=mock_container), - ): + with patch(_PATCH_CONTAINER, return_value=mock_container): context.uncov_result = context.uncov_runner.invoke(plan_app, args) diff --git a/features/steps/plan_correct_tree_wiring_steps.py b/features/steps/plan_correct_tree_wiring_steps.py index 2d218dbee..ba73c0821 100644 --- a/features/steps/plan_correct_tree_wiring_steps.py +++ b/features/steps/plan_correct_tree_wiring_steps.py @@ -27,9 +27,6 @@ from cleveragents.cli.commands.plan import app as plan_app runner = CliRunner() _PATCH_CONTAINER = "cleveragents.application.container.get_container" -_PATCH_CORRECTION_SVC = ( - "cleveragents.application.services.correction_service.CorrectionService" -) # Fixed ULIDs for deterministic assertions _PLAN_ID = str(ULID()) @@ -186,10 +183,11 @@ def _invoke( else: args.append("--yes") - with ( - patch(_PATCH_CORRECTION_SVC, return_value=context.pctw_correction_svc), - patch(_PATCH_CONTAINER, return_value=context.pctw_mock_container), - ): + context.pctw_mock_container.correction_service.return_value = ( + context.pctw_correction_svc + ) + + with patch(_PATCH_CONTAINER, return_value=context.pctw_mock_container): context.pctw_result = runner.invoke(plan_app, args) diff --git a/features/steps/plan_explain_cli_coverage_steps.py b/features/steps/plan_explain_cli_coverage_steps.py index 03cd0649f..79dfca9c6 100644 --- a/features/steps/plan_explain_cli_coverage_steps.py +++ b/features/steps/plan_explain_cli_coverage_steps.py @@ -654,14 +654,9 @@ def _invoke_correct( mock_decision_svc.get_influence_edges.return_value = {} mock_container = MagicMock() mock_container.decision_service.return_value = mock_decision_svc + mock_container.correction_service.return_value = context.pec_correction_svc - with ( - patch( - "cleveragents.application.services.correction_service.CorrectionService", - return_value=context.pec_correction_svc, - ), - patch(_PATCH_CONTAINER, return_value=mock_container), - ): + with patch(_PATCH_CONTAINER, return_value=mock_container): context.pec_result = runner.invoke(plan_app, args, input=input_text) diff --git a/features/steps/tdd_plan_correct_plan_id_steps.py b/features/steps/tdd_plan_correct_plan_id_steps.py index ab6dc7c67..7d586f8f6 100644 --- a/features/steps/tdd_plan_correct_plan_id_steps.py +++ b/features/steps/tdd_plan_correct_plan_id_steps.py @@ -28,7 +28,6 @@ from typer.testing import CliRunner from cleveragents.cli.commands.plan import app as plan_app from features.mocks.tdd_plan_correct_plan_id_fixtures import ( PATCH_CONTAINER, - PATCH_CORRECTION_SVC, PATCH_RESOLVE_PLAN, PLAN_ID, ROOT_DECISION_ID, @@ -96,11 +95,10 @@ def step_tpcpid_invoke_with_plan_id(context: Context) -> None: """ args = build_cli_args(context.tpcpid_plan_id, mode="revert") + context.tpcpid_mock_container.correction_service.return_value = ( + context.tpcpid_correction_svc + ) with ( - patch( - PATCH_CORRECTION_SVC, - return_value=context.tpcpid_correction_svc, - ), patch( PATCH_CONTAINER, return_value=context.tpcpid_mock_container, @@ -126,11 +124,10 @@ def step_tpcpid_invoke_with_plan_id_append(context: Context) -> None: """ args = build_cli_args(context.tpcpid_plan_id, mode="append") + context.tpcpid_mock_container.correction_service.return_value = ( + context.tpcpid_correction_svc + ) with ( - patch( - PATCH_CORRECTION_SVC, - return_value=context.tpcpid_correction_svc, - ), patch( PATCH_CONTAINER, return_value=context.tpcpid_mock_container, diff --git a/features/tdd_correction_checkpoint_wiring.feature b/features/tdd_correction_checkpoint_wiring.feature index 953d80939..b2c46dc05 100644 --- a/features/tdd_correction_checkpoint_wiring.feature +++ b/features/tdd_correction_checkpoint_wiring.feature @@ -1,23 +1,14 @@ -@tdd_expected_fail @tdd_issue @tdd_issue_986 @mock_only +@tdd_issue @tdd_issue_986 @mock_only Feature: TDD Issue #986 — CorrectionService missing checkpoint_service wiring in DI container As a developer I want to verify that CorrectionService receives checkpoint_service from the DI container So that revert-mode corrections can perform checkpoint rollback - Bug #986: The DI container registers CorrectionService with only - event_bus but omits checkpoint_service, despite CheckpointService - being registered in the same container. As a result, revert-mode - corrections silently skip checkpoint rollback because the dependency - is never injected. - - Additionally, the CLI creates an ad-hoc CorrectionService instance - that bypasses the container entirely, which means even if the - container wiring is fixed, the CLI path remains broken. - - These tests assert the expected wiring and will FAIL until the bug - is fixed. The @tdd_expected_fail tag inverts the result so CI - passes. + Bug #986 (fixed in #943): The DI container now registers + CorrectionService with both checkpoint_service and event_bus. + The CLI correct command also uses the container-provided service + instead of creating an ad-hoc instance. Scenario: DI container wires checkpoint_service into CorrectionService Given tccw a fresh DI container with an in-memory database diff --git a/robot/helper_plan_correct_tree_wiring.py b/robot/helper_plan_correct_tree_wiring.py index 2765dba6b..27c572439 100644 --- a/robot/helper_plan_correct_tree_wiring.py +++ b/robot/helper_plan_correct_tree_wiring.py @@ -105,6 +105,8 @@ def _test_dry_run_tree() -> None: analyze_affected=[_ROOT_ID, _CHILD_A, _CHILD_B], ) + container.correction_service.return_value = correction_svc + args = [ "correct", _ROOT_ID, @@ -116,10 +118,7 @@ def _test_dry_run_tree() -> None: _PLAN_ID, "--dry-run", ] - with ( - patch(_PATCH_CORRECTION_SVC, return_value=correction_svc), - patch(_PATCH_CONTAINER, return_value=container), - ): + with patch(_PATCH_CONTAINER, return_value=container): result = runner.invoke(plan_app, args) assert result.exit_code == 0, f"Exit {result.exit_code}: {result.output}" @@ -144,6 +143,8 @@ def _test_execute_tree() -> None: execute_affected=[_ROOT_ID, _CHILD_A, _CHILD_B], ) + container.correction_service.return_value = correction_svc + args = [ "correct", _ROOT_ID, @@ -155,10 +156,7 @@ def _test_execute_tree() -> None: _PLAN_ID, "--yes", ] - with ( - patch(_PATCH_CORRECTION_SVC, return_value=correction_svc), - patch(_PATCH_CONTAINER, return_value=container), - ): + with patch(_PATCH_CONTAINER, return_value=container): result = runner.invoke(plan_app, args) assert result.exit_code == 0, f"Exit {result.exit_code}: {result.output}" @@ -177,6 +175,8 @@ def _test_leaf_empty() -> None: analyze_affected=[_LEAF_ID], ) + container.correction_service.return_value = correction_svc + args = [ "correct", _LEAF_ID, @@ -188,10 +188,7 @@ def _test_leaf_empty() -> None: _PLAN_ID, "--dry-run", ] - with ( - patch(_PATCH_CORRECTION_SVC, return_value=correction_svc), - patch(_PATCH_CONTAINER, return_value=container), - ): + with patch(_PATCH_CONTAINER, return_value=container): result = runner.invoke(plan_app, args) assert result.exit_code == 0, f"Exit {result.exit_code}: {result.output}" diff --git a/robot/helper_tdd_plan_correct_plan_id.py b/robot/helper_tdd_plan_correct_plan_id.py index 12bb686da..5fb03af81 100644 --- a/robot/helper_tdd_plan_correct_plan_id.py +++ b/robot/helper_tdd_plan_correct_plan_id.py @@ -30,7 +30,6 @@ if _FEATURES not in sys.path: from features.mocks.tdd_plan_correct_plan_id_fixtures import ( # noqa: E402 PATCH_CONTAINER, - PATCH_CORRECTION_SVC, PATCH_RESOLVE_PLAN, PLAN_ID, ROOT_DECISION_ID, @@ -63,11 +62,11 @@ def _run_plan_correct(mode: str, sentinel: str) -> None: """ mock_container = make_default_container() correction_svc = make_correction_svc(ROOT_DECISION_ID, mode=mode) + mock_container.correction_service.return_value = correction_svc args = build_cli_args(PLAN_ID, mode=mode) with ( - patch(PATCH_CORRECTION_SVC, return_value=correction_svc), patch(PATCH_CONTAINER, return_value=mock_container), patch(PATCH_RESOLVE_PLAN, return_value=PLAN_ID), ): diff --git a/src/cleveragents/application/container.py b/src/cleveragents/application/container.py index a5c099470..0c494505f 100644 --- a/src/cleveragents/application/container.py +++ b/src/cleveragents/application/container.py @@ -802,6 +802,7 @@ class Container(containers.DeclarativeContainer): # are emitted in production (N9). correction_service = providers.Singleton( CorrectionService, + checkpoint_service=checkpoint_service, event_bus=event_bus, ) diff --git a/src/cleveragents/application/services/checkpoint_service.py b/src/cleveragents/application/services/checkpoint_service.py index 6f3d178d1..4ed25d07c 100644 --- a/src/cleveragents/application/services/checkpoint_service.py +++ b/src/cleveragents/application/services/checkpoint_service.py @@ -19,7 +19,9 @@ tests that do not require the lifecycle service. from __future__ import annotations +import hashlib import logging +import shutil import subprocess from datetime import UTC, datetime from pathlib import Path @@ -322,6 +324,247 @@ class CheckpointService: """ return self._get_checkpoint(checkpoint_id) + # ------------------------------------------------------------------ + # Workspace snapshot (diff-based) + # ------------------------------------------------------------------ + + def create_workspace_snapshot( + self, + plan_id: str, + sandbox_ref: str, + decision_id: str, + *, + reason: str = "pre-decision snapshot", + phase: str = "execute", + ) -> Checkpoint: + """Create a diff-based workspace snapshot before decision execution. + + Captures only files that differ from the previous checkpoint (or + the initial sandbox state when no prior checkpoints exist). The + diff manifest is stored in the checkpoint metadata ``extra`` dict + under the ``"diff_paths"`` key. + + Args: + plan_id: Plan owning the workspace. + sandbox_ref: Git commit hash of the current HEAD. + decision_id: Decision about to execute. + reason: Human-readable snapshot reason. + phase: Plan phase (defaults to ``"execute"``). + + Returns: + The newly created ``Checkpoint`` with diff metadata. + """ + # Compute diff paths against the previous checkpoint + diff_paths = self._compute_diff_snapshot(plan_id, sandbox_ref) + diff_size = sum(len(p.encode()) for p in diff_paths) + + checkpoint = self.create_checkpoint( + plan_id=plan_id, + sandbox_ref=sandbox_ref, + reason=reason, + source_tool="workspace_snapshot", + phase=phase, + decision_id=decision_id, + checkpoint_type="pre_decision", + size_bytes=diff_size or 0, + ) + + # Store diff manifest in metadata extra + checkpoint.metadata.extra["diff_paths"] = diff_paths + checkpoint.metadata.extra["diff_based"] = True + checkpoint.metadata.extra["diff_hash"] = hashlib.sha256( + "|".join(sorted(diff_paths)).encode() + ).hexdigest() + + logger.info( + "checkpoint.workspace_snapshot_created", + extra={ + "checkpoint_id": checkpoint.checkpoint_id, + "plan_id": plan_id, + "decision_id": decision_id, + "diff_paths_count": len(diff_paths), + }, + ) + + return checkpoint + + def selective_rollback( + self, + plan_id: str, + checkpoint_id: str, + ) -> RollbackResult: + """Roll back to a specific checkpoint with atomic semantics. + + This method wraps :meth:`rollback_to_checkpoint` with + all-or-nothing transaction semantics. If the rollback fails + partway through, a best-effort recovery attempt restores the + sandbox to its pre-rollback HEAD. + + Args: + plan_id: Plan owning the checkpoint. + checkpoint_id: Any checkpoint to roll back to (not just the + most recent). + + Returns: + A ``RollbackResult`` on success. + + Raises: + BusinessRuleViolation: If any rollback step fails and + recovery also fails. + ResourceNotFoundError: If checkpoint does not exist. + ValidationError: If checkpoint does not belong to the plan. + """ + # Capture current HEAD for recovery + sandbox_path = self._resolve_sandbox_path(plan_id) + self._validate_sandbox(sandbox_path) + + try: + current_head = self._run_git( + ["rev-parse", "HEAD"], cwd=sandbox_path + ).stdout.strip() + except Exception: + current_head = None + + try: + result = self.rollback_to_checkpoint(plan_id, checkpoint_id) + logger.info( + "checkpoint.selective_rollback_success", + extra={ + "plan_id": plan_id, + "checkpoint_id": checkpoint_id, + "restored_files_count": result.restored_files_count, + }, + ) + return result + except Exception: + # Atomic rollback: attempt recovery to original HEAD + if current_head is not None: + try: + self._git_reset_hard(sandbox_path, current_head) + self._git_clean(sandbox_path) + logger.warning( + "checkpoint.selective_rollback_recovered", + extra={ + "plan_id": plan_id, + "checkpoint_id": checkpoint_id, + "recovered_to": current_head, + }, + ) + except Exception: + logger.error( + "checkpoint.selective_rollback_recovery_failed", + extra={ + "plan_id": plan_id, + "checkpoint_id": checkpoint_id, + }, + exc_info=True, + ) + raise + + def archive_artifacts( + self, + sandbox_path: str, + artifact_paths: list[str], + archive_dir: str | None = None, + ) -> list[str]: + """Physically move artifact files to an archive directory. + + Moves each file from ``sandbox_path / artifact`` to the archive + directory, preserving relative paths. Files that do not exist + are silently skipped. + + Args: + sandbox_path: Filesystem path to the sandbox root. + artifact_paths: Relative paths of artifacts to archive. + archive_dir: Override archive directory. Defaults to + ``sandbox_path / .cleveragents / archived_artifacts``. + + Returns: + List of paths that were successfully archived. + """ + sandbox = Path(sandbox_path) + archive_root = ( + Path(archive_dir) + if archive_dir + else sandbox / ".cleveragents" / "archived_artifacts" + ) + archived: list[str] = [] + + for rel_path in artifact_paths: + source = sandbox / rel_path + if not source.exists(): + continue + dest = archive_root / rel_path + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(source), str(dest)) + archived.append(rel_path) + + if archived: + logger.info( + "checkpoint.artifacts_archived", + extra={ + "sandbox_path": sandbox_path, + "archived_count": len(archived), + "archived_paths": archived, + }, + ) + + return archived + + # ------------------------------------------------------------------ + # Diff computation + # ------------------------------------------------------------------ + + def _compute_diff_snapshot( + self, + plan_id: str, + current_ref: str, + ) -> list[str]: + """Compute changed file paths between the last checkpoint and current ref. + + If no previous checkpoint exists, returns an empty list (the + first snapshot is the baseline). + + Args: + plan_id: Plan to query checkpoints for. + current_ref: Current git ref (commit hash). + + Returns: + List of relative paths that changed since the last checkpoint. + """ + existing = self.list_checkpoints(plan_id) + if not existing: + return [] + + previous_ref = existing[-1].sandbox_ref + if previous_ref == current_ref: + return [] + + # Try to resolve sandbox path for git diff + try: + sandbox_path = self._resolve_sandbox_path(plan_id) + except Exception: + # No sandbox available — return empty diff + return [] + + try: + result = self._run_git( + ["diff", "--name-only", previous_ref, current_ref], + cwd=sandbox_path, + ) + return [line for line in result.stdout.strip().splitlines() if line] + except Exception: + logger.warning( + "checkpoint.diff_computation_failed", + extra={ + "plan_id": plan_id, + "previous_ref": previous_ref, + "current_ref": current_ref, + }, + exc_info=True, + ) + return [] + def delete_checkpoint(self, checkpoint_id: str) -> None: """Delete a single checkpoint. diff --git a/src/cleveragents/application/services/correction_service.py b/src/cleveragents/application/services/correction_service.py index dd137eae1..1aa4f848b 100644 --- a/src/cleveragents/application/services/correction_service.py +++ b/src/cleveragents/application/services/correction_service.py @@ -504,11 +504,17 @@ class CorrectionService: # Revert always re-enters Strategize from the decision point. phase_target = "strategize" + # --- Physical artifact archival --- + archived = self._archive_decision_artifacts( + request.plan_id, + impact.artifacts_to_archive, + ) + result = CorrectionResult( correction_id=correction_id, status=CorrectionStatus.APPLIED, reverted_decisions=impact.affected_decisions, - archived_artifacts=impact.artifacts_to_archive, + archived_artifacts=archived or impact.artifacts_to_archive, checkpoint_restored=checkpoint_restored, actor_state_ref=actor_state_ref, user_intervention_decision_id=user_intervention_id, @@ -669,6 +675,57 @@ class CorrectionService: ) return self.execute_append(correction_id) + # ------------------------------------------------------------------ + # Revert decisions (full rollback + artifact archival) + # ------------------------------------------------------------------ + + def revert_decisions( + self, + plan_id: str, + target_decision_id: str, + decision_tree: dict[str, list[str]] | None = None, + influence_edges: dict[str, list[str]] | None = None, + decisions: dict[str, Decision] | None = None, + guidance: str = "", + ) -> CorrectionResult: + """Revert decisions with checkpoint rollback and physical artifact archival. + + This is the high-level entry point that combines: + + 1. Creating a correction request. + 2. Computing impact analysis. + 3. Invoking checkpoint rollback via ``CheckpointService``. + 4. Physically archiving artifacts from reverted decisions. + 5. Returning a complete ``CorrectionResult``. + + The method is atomic: if checkpoint rollback fails, no artifacts + are archived and the correction is marked as FAILED. + + Args: + plan_id: Plan owning the decision tree. + target_decision_id: Decision to revert from. + decision_tree: Structural tree adjacency list. + influence_edges: Influence DAG adjacency list. + decisions: Mapping of decision_id → ``Decision`` objects. + guidance: Human-supplied correction guidance. + + Returns: + ``CorrectionResult`` with reverted decisions, archived + artifacts, and re-execution metadata. + """ + request = self.request_correction( + plan_id=plan_id, + target_decision_id=target_decision_id, + mode=CorrectionMode.REVERT, + guidance=guidance, + ) + return self.execute_revert( + request.correction_id, + decision_tree=decision_tree, + influence_edges=influence_edges, + decisions=decisions, + ) + # ------------------------------------------------------------------ # Query helpers # ------------------------------------------------------------------ @@ -721,6 +778,40 @@ class CorrectionService: # Internal helpers # ------------------------------------------------------------------ + def _archive_decision_artifacts( + self, + plan_id: str, + artifact_paths: list[str], + ) -> list[str]: + """Physically archive artifacts via the checkpoint service. + + Delegates to ``CheckpointService.archive_artifacts`` when a + checkpoint service and sandbox path are available. Falls back + gracefully when the service is absent or the sandbox cannot be + resolved. + + Args: + plan_id: Plan owning the artifacts. + artifact_paths: Relative paths to archive. + + Returns: + List of successfully archived paths (may be empty). + """ + if self._checkpoint_service is None: + return [] + try: + sandbox_path = self._checkpoint_service._resolve_sandbox_path(plan_id) + return self._checkpoint_service.archive_artifacts( + sandbox_path, artifact_paths + ) + except Exception as exc: + logger.debug( + "correction.artifact_archival_skipped", + plan_id=plan_id, + reason=str(exc), + ) + return [] + # ------------------------------------------------------------------ # Revert re-execution helpers # ------------------------------------------------------------------ diff --git a/src/cleveragents/cli/commands/plan.py b/src/cleveragents/cli/commands/plan.py index dc57d089d..476ab1a83 100644 --- a/src/cleveragents/cli/commands/plan.py +++ b/src/cleveragents/cli/commands/plan.py @@ -2886,9 +2886,6 @@ def correct_decision( agents plan correct --mode revert -g "Use FastAPI instead" DEC-001 agents plan correct --mode append -g "Add caching layer" --dry-run DEC-002 """ - from cleveragents.application.services.correction_service import ( - CorrectionService, - ) from cleveragents.core.exceptions import ResourceNotFoundError as RNF from cleveragents.domain.models.core.correction import CorrectionMode @@ -2954,7 +2951,7 @@ def correct_decision( # Fetch influence DAG edges influence_edges = decision_svc.get_influence_edges(resolved_plan_id) - svc = CorrectionService(event_bus=container.event_bus()) + svc = container.correction_service() # Create the correction request request = svc.request_correction( @@ -3187,9 +3184,16 @@ def rollback_plan( typer.Argument(help="Plan ID to rollback"), ], checkpoint_id: Annotated[ - str, - typer.Argument(help="Checkpoint ID to restore"), - ], + str | None, + typer.Argument(help="Checkpoint ID to restore (positional)"), + ] = None, + to_checkpoint: Annotated[ + str | None, + typer.Option( + "--to-checkpoint", + help="Checkpoint ID to restore (named option)", + ), + ] = None, yes: Annotated[ bool, typer.Option( @@ -3213,8 +3217,12 @@ def rollback_plan( the specified checkpoint. The plan must not be applied, and the sandbox must still exist. + The checkpoint can be specified either as a positional argument or + via the ``--to-checkpoint`` named option. + Examples: agents plan rollback --yes 01ARZ3NDEK... 01BRZ4NFEK... + agents plan rollback --to-checkpoint 01BRZ4NFEK... 01ARZ3NDEK... """ import time @@ -3226,9 +3234,18 @@ def rollback_plan( ResourceNotFoundError as RNF, ) + # Resolve checkpoint from positional arg or --to-checkpoint option + resolved_checkpoint_id = checkpoint_id or to_checkpoint + if not resolved_checkpoint_id: + console.print( + "[red]Error:[/red] checkpoint ID required. " + "Provide as positional argument or via --to-checkpoint." + ) + raise typer.Abort() + if not yes: confirm = typer.confirm( - f"\nRollback plan {plan_id} to checkpoint {checkpoint_id}?" + f"\nRollback plan {plan_id} to checkpoint {resolved_checkpoint_id}?" ) if not confirm: console.print("[yellow]Rollback cancelled.[/yellow]") @@ -3238,7 +3255,7 @@ def rollback_plan( container = get_container() svc = container.checkpoint_service() t0 = time.monotonic() - result = svc.rollback_to_checkpoint(plan_id, checkpoint_id) + result = svc.selective_rollback(plan_id, resolved_checkpoint_id) elapsed = time.monotonic() - t0 # Spec-aligned output envelope (lines 15760-15797) diff --git a/src/cleveragents/domain/models/core/checkpoint.py b/src/cleveragents/domain/models/core/checkpoint.py index 534ebb976..8c2957321 100644 --- a/src/cleveragents/domain/models/core/checkpoint.py +++ b/src/cleveragents/domain/models/core/checkpoint.py @@ -120,7 +120,7 @@ class Checkpoint(BaseModel): @classmethod def validate_checkpoint_type(cls: type[Checkpoint], v: str) -> str: """Ensure checkpoint_type is one of the allowed values.""" - allowed = {"pre_write", "post_step", "manual"} + allowed = {"pre_write", "post_step", "manual", "pre_decision"} if v not in allowed: raise ValueError( f"checkpoint_type must be one of {sorted(allowed)}, got '{v}'" diff --git a/vulture_whitelist.py b/vulture_whitelist.py index 305510613..cef933f03 100644 --- a/vulture_whitelist.py +++ b/vulture_whitelist.py @@ -1197,3 +1197,9 @@ _make_runtime_handler # noqa: B018, F821 # LSP config fields — public API (#835) LspTransport # noqa: B018, F821 + +# Checkpoint rollback wiring (#943) — public API for correction service +create_workspace_snapshot # noqa: B018, F821 +selective_rollback # noqa: B018, F821 +archive_artifacts # noqa: B018, F821 +revert_decisions # noqa: B018, F821