redeploy 0.2.53__tar.gz → 0.2.55__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {redeploy-0.2.53 → redeploy-0.2.55}/PKG-INFO +6 -6
- {redeploy-0.2.53 → redeploy-0.2.55}/README.md +5 -5
- {redeploy-0.2.53 → redeploy-0.2.55}/pyproject.toml +1 -1
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/__init__.py +1 -1
- redeploy-0.2.55/redeploy/heal/__init__.py +37 -0
- redeploy-0.2.55/redeploy/heal/decider.py +85 -0
- redeploy-0.2.55/redeploy/heal/hint_provider.py +203 -0
- redeploy-0.2.55/redeploy/heal/log_writer.py +51 -0
- redeploy-0.2.55/redeploy/heal/loop_detector.py +61 -0
- redeploy-0.2.55/redeploy/heal/runner.py +224 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/heal.py +95 -64
- redeploy-0.2.55/redeploy/tests/test_heal.py +83 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy.egg-info/PKG-INFO +6 -6
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy.egg-info/SOURCES.txt +8 -0
- redeploy-0.2.55/tests/test_heal_decider.py +63 -0
- redeploy-0.2.55/tests/test_heal_loop_detector.py +47 -0
- redeploy-0.2.53/redeploy/tests/test_heal.py +0 -26
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/apply/__init__.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/apply/exceptions.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/apply/executor.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/apply/handlers.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/apply/progress.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/apply/rollback.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/apply/state.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/apply/state_apply.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/apply/utils/__init__.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/apply/utils/run_container_build.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/audit.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/blueprint/__init__.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/blueprint/extractor.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/blueprint/generators/__init__.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/blueprint/generators/docker_compose.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/blueprint/generators/migration.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/blueprint/sources/__init__.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/blueprint/sources/compose.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/blueprint/sources/hardware.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/blueprint/sources/infra.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/blueprint/sources/migration.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/cli/__init__.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/cli/commands/__init__.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/cli/commands/audit.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/cli/commands/blueprint.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/cli/commands/bump_fix.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/cli/commands/detect.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/cli/commands/device_map.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/cli/commands/device_map_renderers.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/cli/commands/devices.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/cli/commands/diagnose.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/cli/commands/diff.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/cli/commands/exec_.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/cli/commands/export.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/cli/commands/hardware.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/cli/commands/import_.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/cli/commands/init.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/cli/commands/inspect.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/cli/commands/mcp_cmd.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/cli/commands/patterns.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/cli/commands/plan_apply.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/cli/commands/plugin.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/cli/commands/probe.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/cli/commands/prompt_cmd.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/cli/commands/push.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/cli/commands/state.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/cli/commands/status.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/cli/commands/target.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/cli/commands/version/__init__.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/cli/commands/version/commands.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/cli/commands/version/helpers.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/cli/commands/version/monorepo.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/cli/commands/version/release.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/cli/commands/version/scanner.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/cli/commands/workflow.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/cli/core.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/cli/display.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/cli/query.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/cli.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/config_apply/__init__.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/config_apply/applier.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/config_apply/loader.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/data_sync.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/detect/__init__.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/detect/builtin/__init__.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/detect/detector.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/detect/hardware.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/detect/hardware_rules.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/detect/probes.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/detect/remote.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/detect/templates.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/detect/workflow.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/discovery.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/dsl/__init__.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/dsl/loader.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/dsl/parser.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/dsl_python/__init__.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/dsl_python/context.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/dsl_python/decorators.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/dsl_python/docker_steps.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/dsl_python/exceptions.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/dsl_python/runner.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/dsl_python/steps.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/fleet.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/hardware/__init__.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/hardware/config_txt.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/hardware/data/__init__.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/hardware/data/hyperpixel.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/hardware/data/official.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/hardware/data/waveshare.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/hardware/fixes.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/hardware/kiosk/__init__.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/hardware/kiosk/autostart.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/hardware/kiosk/browsers.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/hardware/kiosk/compositors.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/hardware/kiosk/output_profiles.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/hardware/panels.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/hardware/raspi_config.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/iac/__init__.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/iac/base.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/iac/config_hints.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/iac/docker_compose.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/iac/parsers/__init__.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/iac/parsers/compose.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/iac/registry.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/integrations/__init__.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/integrations/op3_bridge.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/markpact/__init__.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/markpact/compiler.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/markpact/models.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/markpact/parser.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/mcp_server.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/models.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/observe.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/parse.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/patterns.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/plan/__init__.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/plan/planner.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/plugins/__init__.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/plugins/builtin/__init__.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/plugins/builtin/browser_reload.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/plugins/builtin/hardware_diagnostic.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/plugins/builtin/notify.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/plugins/builtin/process_control.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/plugins/builtin/systemd_reload.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/schema.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/spec_loader.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/ssh.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/steps/__init__.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/steps/builtins.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/steps/kiosk.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/tests/__init__.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/tests/test_audit.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/tests/test_cli.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/tests/test_data_sync.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/tests/test_discovery.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/tests/test_dsl.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/tests/test_examples.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/tests/test_executor.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/tests/test_fleet.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/tests/test_git_integration.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/tests/test_hooks.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/tests/test_iac.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/tests/test_manifest.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/tests/test_markpact_compiler.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/tests/test_markpact_parser.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/tests/test_models.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/tests/test_observe.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/tests/test_parse.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/tests/test_patterns.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/tests/test_planner_edge.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/tests/test_plugins.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/tests/test_probes.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/tests/test_public_api.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/tests/test_resume.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/tests/test_spec_loader.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/tests/test_ssh.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/tests/test_templates.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/tests/test_verify.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/tests/test_version.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/tests/test_version_changelog.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/tests/test_version_cli.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/tests/test_version_manifest.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/tests/test_version_sources.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/tests/test_workflow.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/verify.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/version/__init__.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/version/bump.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/version/changelog.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/version/commits.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/version/diff.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/version/git_integration.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/version/git_transaction.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/version/manifest.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/version/sources/__init__.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/version/sources/base.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/version/sources/json_.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/version/sources/plain.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/version/sources/regex.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/version/sources/toml_.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/version/sources/yaml_.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/version/transaction.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/version.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy.egg-info/dependency_links.txt +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy.egg-info/entry_points.txt +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy.egg-info/requires.txt +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/redeploy.egg-info/top_level.txt +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/setup.cfg +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/tests/test_blueprint_sources.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/tests/test_blueprint_sources_compose.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/tests/test_cli_query.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/tests/test_example_argocd_flux_plugin.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/tests/test_example_gitops_ci_plugin.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/tests/test_example_helm_kustomize_plugin.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/tests/test_hardware_config.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/tests/test_hardware_kiosk.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/tests/test_hardware_rules.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/tests/test_iac.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/tests/test_iac_config_hints.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/tests/test_iac_plugin_loading.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/tests/test_import_plugin_template.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/tests/test_parse.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/tests/test_prompt_llm.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/tests/test_redeploy.py +0 -0
- {redeploy-0.2.53 → redeploy-0.2.55}/tests/test_steps_kiosk.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: redeploy
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.55
|
|
4
4
|
Summary: Infrastructure migration toolkit: detect → plan → apply
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -32,17 +32,17 @@ Requires-Dist: mcp>=1.0; extra == "mcp"
|
|
|
32
32
|
|
|
33
33
|
## AI Cost Tracking
|
|
34
34
|
|
|
35
|
-
     
|
|
36
|
+
  
|
|
37
37
|
|
|
38
|
-
- 🤖 **LLM usage:** $7.5000 (
|
|
39
|
-
- 👤 **Human dev:** ~$
|
|
38
|
+
- 🤖 **LLM usage:** $7.5000 (75 commits)
|
|
39
|
+
- 👤 **Human dev:** ~$2853 (28.5h @ $100/h, 30min dedup)
|
|
40
40
|
|
|
41
41
|
Generated on 2026-04-21 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
|
|
42
42
|
|
|
43
43
|
---
|
|
44
44
|
|
|
45
|
-
    
|
|
46
46
|
|
|
47
47
|
Infrastructure migration and device deploy toolkit — VPS, Raspberry Pi kiosk, Podman Quadlet, k3s.
|
|
48
48
|
|
|
@@ -3,17 +3,17 @@
|
|
|
3
3
|
|
|
4
4
|
## AI Cost Tracking
|
|
5
5
|
|
|
6
|
-
     
|
|
7
|
+
  
|
|
8
8
|
|
|
9
|
-
- 🤖 **LLM usage:** $7.5000 (
|
|
10
|
-
- 👤 **Human dev:** ~$
|
|
9
|
+
- 🤖 **LLM usage:** $7.5000 (75 commits)
|
|
10
|
+
- 👤 **Human dev:** ~$2853 (28.5h @ $100/h, 30min dedup)
|
|
11
11
|
|
|
12
12
|
Generated on 2026-04-21 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
|
|
13
13
|
|
|
14
14
|
---
|
|
15
15
|
|
|
16
|
-
    
|
|
17
17
|
|
|
18
18
|
Infrastructure migration and device deploy toolkit — VPS, Raspberry Pi kiosk, Podman Quadlet, k3s.
|
|
19
19
|
|
|
@@ -7,7 +7,7 @@ Public API (stable from 0.2.0, semver guaranteed)::
|
|
|
7
7
|
|
|
8
8
|
Everything not listed in ``__all__`` is internal and may change without notice.
|
|
9
9
|
"""
|
|
10
|
-
__version__ = "0.2.
|
|
10
|
+
__version__ = "0.2.55"
|
|
11
11
|
|
|
12
12
|
# ── Core models ───────────────────────────────────────────────────────────────
|
|
13
13
|
from .models import ( # noqa: F401
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""redeploy.heal — Self-healing runner with LiteLLM auto-repair.
|
|
2
|
+
|
|
3
|
+
This package has been refactored from the monolithic ``redeploy.heal`` module.
|
|
4
|
+
For backward compatibility all public names remain importable from
|
|
5
|
+
``redeploy.heal``.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from .loop_detector import HealAbort, HealLoopDetector
|
|
10
|
+
from .log_writer import write_repair_log
|
|
11
|
+
from .hint_provider import (
|
|
12
|
+
DIAG_COMMANDS,
|
|
13
|
+
KNOWN_CONSTRAINTS,
|
|
14
|
+
collect_diagnostics,
|
|
15
|
+
ask_llm,
|
|
16
|
+
apply_fix_to_spec,
|
|
17
|
+
parse_failed_step,
|
|
18
|
+
)
|
|
19
|
+
from .decider import Action, Decision, decide_after_failure, format_decision_message
|
|
20
|
+
from .runner import HealRunner
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"HealAbort",
|
|
24
|
+
"HealLoopDetector",
|
|
25
|
+
"write_repair_log",
|
|
26
|
+
"DIAG_COMMANDS",
|
|
27
|
+
"KNOWN_CONSTRAINTS",
|
|
28
|
+
"collect_diagnostics",
|
|
29
|
+
"ask_llm",
|
|
30
|
+
"apply_fix_to_spec",
|
|
31
|
+
"parse_failed_step",
|
|
32
|
+
"Action",
|
|
33
|
+
"Decision",
|
|
34
|
+
"decide_after_failure",
|
|
35
|
+
"format_decision_message",
|
|
36
|
+
"HealRunner",
|
|
37
|
+
]
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Heal decision policy — retry / skip / abort logic."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Action(Enum):
|
|
12
|
+
RETRY = "retry"
|
|
13
|
+
SKIP = "skip"
|
|
14
|
+
ABORT = "abort"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class Decision:
|
|
19
|
+
action: Action
|
|
20
|
+
reason: str = ""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def decide_after_failure(
|
|
24
|
+
*,
|
|
25
|
+
attempt: int,
|
|
26
|
+
max_retries: int,
|
|
27
|
+
failed_step: Optional[str],
|
|
28
|
+
loop_detected: bool,
|
|
29
|
+
llm_error: bool,
|
|
30
|
+
spec_patched: bool,
|
|
31
|
+
) -> Decision:
|
|
32
|
+
"""Return the next action for the heal loop.
|
|
33
|
+
|
|
34
|
+
Parameters
|
|
35
|
+
----------
|
|
36
|
+
attempt:
|
|
37
|
+
1-based attempt counter inside the heal loop.
|
|
38
|
+
max_retries:
|
|
39
|
+
Maximum retries configured by the user.
|
|
40
|
+
failed_step:
|
|
41
|
+
Step identifier, or *None* if the parser could not identify it.
|
|
42
|
+
loop_detected:
|
|
43
|
+
*True* when :class:`HealLoopDetector` flagged a repeating hint.
|
|
44
|
+
llm_error:
|
|
45
|
+
*True* when the LLM call itself failed (not a fixable step failure).
|
|
46
|
+
spec_patched:
|
|
47
|
+
*True* when the LLM produced a patch and it was applied.
|
|
48
|
+
|
|
49
|
+
Returns
|
|
50
|
+
-------
|
|
51
|
+
Decision
|
|
52
|
+
"""
|
|
53
|
+
if loop_detected:
|
|
54
|
+
return Decision(
|
|
55
|
+
Action.ABORT,
|
|
56
|
+
f"Loop detected: same hint for {failed_step!r} ≥ 3 times in a row",
|
|
57
|
+
)
|
|
58
|
+
if failed_step is None:
|
|
59
|
+
return Decision(Action.ABORT, "Cannot identify failed step — giving up")
|
|
60
|
+
if llm_error:
|
|
61
|
+
return Decision(
|
|
62
|
+
Action.ABORT if attempt >= max_retries else Action.RETRY,
|
|
63
|
+
"LLM error — retrying may help if transient",
|
|
64
|
+
)
|
|
65
|
+
if attempt >= max_retries:
|
|
66
|
+
return Decision(
|
|
67
|
+
Action.ABORT,
|
|
68
|
+
f"Max retries ({max_retries}) exhausted for {failed_step!r}",
|
|
69
|
+
)
|
|
70
|
+
if spec_patched:
|
|
71
|
+
return Decision(Action.RETRY, "Spec patched — re-running migration")
|
|
72
|
+
return Decision(
|
|
73
|
+
Action.SKIP,
|
|
74
|
+
"LLM did not produce an applicable patch — skipping step",
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def format_decision_message(decision: Decision, step_id: str) -> str:
|
|
79
|
+
"""Human-readable log / console message for a decision."""
|
|
80
|
+
emoji = {
|
|
81
|
+
Action.RETRY: "🔄",
|
|
82
|
+
Action.SKIP: "⏭️",
|
|
83
|
+
Action.ABORT: "🛑",
|
|
84
|
+
}
|
|
85
|
+
return f"{emoji[decision.action]} {decision.action.value.upper()}: {decision.reason}"
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""LLM hint generation and spec patching utilities."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import textwrap
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
# ---------------------------------------------------------------------------
|
|
11
|
+
# Diagnostics catalogue -- targeted SSH commands per failed step ID
|
|
12
|
+
# ---------------------------------------------------------------------------
|
|
13
|
+
DIAG_COMMANDS: dict[str, list[str]] = {
|
|
14
|
+
"restart-chromium": [
|
|
15
|
+
"pgrep -fa chromium | head -5 || echo NO_CHROMIUM",
|
|
16
|
+
"ls /run/user/1000/wayland-* 2>/dev/null || echo NO_WAYLAND",
|
|
17
|
+
"systemctl --user status kiosk-chromium.service --no-pager -n 10 2>&1 || true",
|
|
18
|
+
"cat ~/c2004/logs/kiosk.log 2>/dev/null | tail -20 || echo NO_LOG",
|
|
19
|
+
],
|
|
20
|
+
"assert-screen-kiosk-url": [
|
|
21
|
+
"pgrep -fa chromium | head -3 || echo NO_CHROMIUM",
|
|
22
|
+
"cat ~/c2004/scripts/kiosk-launch.sh 2>/dev/null || echo NO_SCRIPT",
|
|
23
|
+
"cat ~/c2004/logs/kiosk.log 2>/dev/null | tail -30 || echo NO_LOG",
|
|
24
|
+
],
|
|
25
|
+
"assert-screen-backend-healthy": [
|
|
26
|
+
"systemctl --user status c2004-backend.service --no-pager -n 20",
|
|
27
|
+
"journalctl --user -u c2004-backend.service --no-pager -n 20 2>/dev/null",
|
|
28
|
+
"curl -sv http://localhost:8000/api/v3/health 2>&1 | tail -10",
|
|
29
|
+
],
|
|
30
|
+
"e2e-svg-icons": [
|
|
31
|
+
"podman exec c2004-frontend ls /usr/share/nginx/html/icons/ 2>/dev/null | head -10",
|
|
32
|
+
"curl -sI http://localhost:8100/icons/sprite.svg | head -8",
|
|
33
|
+
"podman exec c2004-frontend cat /etc/nginx/conf.d/default.conf 2>/dev/null | head -30",
|
|
34
|
+
],
|
|
35
|
+
"e2e-api-endpoints": [
|
|
36
|
+
"systemctl --user status c2004-backend.service --no-pager -n 5",
|
|
37
|
+
"curl -sv http://localhost:8000/api/v3/health 2>&1 | tail -15",
|
|
38
|
+
"journalctl --user -u c2004-backend.service --no-pager -n 15 2>/dev/null",
|
|
39
|
+
],
|
|
40
|
+
"e2e-kiosk-page-load": [
|
|
41
|
+
"curl -sf http://localhost:8100/ 2>/dev/null | head -20",
|
|
42
|
+
"podman exec c2004-frontend ls /usr/share/nginx/html/ | head -10",
|
|
43
|
+
],
|
|
44
|
+
"_default": [
|
|
45
|
+
"systemctl --user list-units --type=service 2>/dev/null | grep -v '@' | tail -15",
|
|
46
|
+
"podman ps -a --format 'table {{.Names}}\\t{{.Status}}' 2>/dev/null",
|
|
47
|
+
"ss -tlnp 2>/dev/null | grep -E '8000|8100|8202' || echo PORTS_FREE",
|
|
48
|
+
"journalctl --user --no-pager -n 10 2>/dev/null",
|
|
49
|
+
],
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
# Known constraints injected into every LLM prompt
|
|
53
|
+
KNOWN_CONSTRAINTS = """
|
|
54
|
+
Known constraints for this deployment target (Raspberry Pi 5, Podman Quadlet, labwc Wayland):
|
|
55
|
+
- pkill/killall chromium via SSH = exit 255 (kills Wayland session and drops SSH)
|
|
56
|
+
- Chromium requires: WAYLAND_DISPLAY=wayland-0 XDG_RUNTIME_DIR=/run/user/1000
|
|
57
|
+
- Use systemd-run --user --collect to start GUI apps without blocking SSH
|
|
58
|
+
- I2C device 0x45 on bus 11 is owned by kernel waveshare driver -- use || true
|
|
59
|
+
- Redirecting /dev/null in heredoc via SSH causes exit 255
|
|
60
|
+
- SQLite DBs at: /data/main/identification.db, /data/menu/menu.db, /data/scenario.db
|
|
61
|
+
- alembic binary may not exist in container -- use python3 -c with sqlite3 module
|
|
62
|
+
- Quadlet unit files go to ~/.config/containers/systemd/
|
|
63
|
+
- Services: c2004-backend, c2004-frontend, c2004-firmware, c2004-reverse-proxy
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _ssh(host: str, command: str) -> tuple[int, str]:
|
|
68
|
+
import subprocess
|
|
69
|
+
result = subprocess.run(
|
|
70
|
+
["ssh", "-o", "ConnectTimeout=10", "-o", "BatchMode=yes", host, command],
|
|
71
|
+
capture_output=True, text=True, timeout=30,
|
|
72
|
+
)
|
|
73
|
+
return result.returncode, ((result.stdout or "") + (result.stderr or "")).strip()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def collect_diagnostics(host: str, failed_step: str) -> str:
|
|
77
|
+
"""Run targeted SSH diagnostics for a failed step, return combined output."""
|
|
78
|
+
cmds = DIAG_COMMANDS.get(failed_step, DIAG_COMMANDS["_default"])
|
|
79
|
+
parts = []
|
|
80
|
+
for cmd in cmds:
|
|
81
|
+
rc, out = _ssh(host, cmd)
|
|
82
|
+
parts.append(f"$ {cmd}\n{out}")
|
|
83
|
+
return "\n\n".join(parts)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def ask_llm(
|
|
87
|
+
failed_step: str,
|
|
88
|
+
step_output: str,
|
|
89
|
+
diag: str,
|
|
90
|
+
spec_text: str,
|
|
91
|
+
fix_hint: str = "",
|
|
92
|
+
) -> str:
|
|
93
|
+
"""Ask LiteLLM to propose a fixed YAML block for the failed step."""
|
|
94
|
+
try:
|
|
95
|
+
import litellm
|
|
96
|
+
except ImportError:
|
|
97
|
+
return ""
|
|
98
|
+
|
|
99
|
+
model = os.getenv("LLM_MODEL", "openrouter/qwen/qwen3-coder-next")
|
|
100
|
+
api_key = os.getenv("OPENROUTER_API_KEY") or os.getenv("OPENAI_API_KEY")
|
|
101
|
+
api_base = "https://openrouter.ai/api/v1" if "openrouter" in model else None
|
|
102
|
+
|
|
103
|
+
hint_section = f"\n## User-reported issue:\n{fix_hint}\n" if fix_hint else ""
|
|
104
|
+
|
|
105
|
+
prompt = textwrap.dedent(f"""
|
|
106
|
+
You are an expert in Raspberry Pi 5 deployments with Podman Quadlet, labwc Wayland compositor and Chromium kiosk.
|
|
107
|
+
|
|
108
|
+
## Failed step: `{failed_step}`
|
|
109
|
+
{hint_section}
|
|
110
|
+
### Step output (error):
|
|
111
|
+
```
|
|
112
|
+
{step_output[:2000]}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### SSH diagnostics collected after failure:
|
|
116
|
+
```
|
|
117
|
+
{diag[:3000]}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### Current spec file (markpact YAML steps):
|
|
121
|
+
```yaml
|
|
122
|
+
{spec_text[:5000]}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
{KNOWN_CONSTRAINTS}
|
|
126
|
+
|
|
127
|
+
## Task:
|
|
128
|
+
Fix ONLY the step `{failed_step}`. Return ONLY the corrected YAML block:
|
|
129
|
+
|
|
130
|
+
```yaml
|
|
131
|
+
- id: {failed_step}
|
|
132
|
+
action: ssh_cmd
|
|
133
|
+
description: "..."
|
|
134
|
+
command: |
|
|
135
|
+
<corrected command>
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Nothing else. No explanation outside the code block.
|
|
139
|
+
""").strip()
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
kwargs: dict = dict(
|
|
143
|
+
model=model,
|
|
144
|
+
messages=[{"role": "user", "content": prompt}],
|
|
145
|
+
temperature=0.1,
|
|
146
|
+
max_tokens=1500,
|
|
147
|
+
)
|
|
148
|
+
if api_key:
|
|
149
|
+
kwargs["api_key"] = api_key
|
|
150
|
+
if api_base:
|
|
151
|
+
kwargs["api_base"] = api_base
|
|
152
|
+
|
|
153
|
+
resp = litellm.completion(**kwargs)
|
|
154
|
+
return resp.choices[0].message.content.strip()
|
|
155
|
+
except Exception as e:
|
|
156
|
+
return f"# LLM error: {e}"
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def apply_fix_to_spec(spec_path: Path, failed_step: str, llm_response: str) -> bool:
|
|
160
|
+
"""Extract YAML block from LLM response and patch it into the spec file."""
|
|
161
|
+
m = re.search(r"```ya?ml\s*(.*?)```", llm_response, re.DOTALL)
|
|
162
|
+
if m:
|
|
163
|
+
new_block = m.group(1).strip()
|
|
164
|
+
else:
|
|
165
|
+
m = re.search(
|
|
166
|
+
rf"(- id: {re.escape(failed_step)}.+?)(?=\n - id:|\Z)",
|
|
167
|
+
llm_response, re.DOTALL
|
|
168
|
+
)
|
|
169
|
+
if not m:
|
|
170
|
+
return False
|
|
171
|
+
new_block = m.group(1).strip()
|
|
172
|
+
|
|
173
|
+
text = spec_path.read_text()
|
|
174
|
+
pattern = rf"( - id: {re.escape(failed_step)}\n(?:(?! - id:).)*)"
|
|
175
|
+
match = re.search(pattern, text, re.DOTALL)
|
|
176
|
+
if not match:
|
|
177
|
+
return False
|
|
178
|
+
|
|
179
|
+
indented = "\n".join(" " + line if line else "" for line in new_block.splitlines())
|
|
180
|
+
spec_path.write_text(text[: match.start()] + indented + "\n" + text[match.end():])
|
|
181
|
+
return True
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def parse_failed_step(executor_summary: str, executor=None) -> tuple[Optional[str], str]:
|
|
185
|
+
"""Extract (step_id, step_output) from executor state or summary string."""
|
|
186
|
+
if executor is not None:
|
|
187
|
+
state = getattr(executor, "state", None)
|
|
188
|
+
if state and state.failed_step_id:
|
|
189
|
+
results = getattr(executor, "_results", {})
|
|
190
|
+
step_out = results.get(state.failed_step_id, {})
|
|
191
|
+
if isinstance(step_out, dict):
|
|
192
|
+
out = step_out.get("output") or step_out.get("error") or ""
|
|
193
|
+
else:
|
|
194
|
+
out = str(step_out)
|
|
195
|
+
return state.failed_step_id, out
|
|
196
|
+
|
|
197
|
+
m = re.search(
|
|
198
|
+
r"Step failed: \[([^\]]+)\].*?exit=\d+:?\s*(.*?)(?=\n\d{{2}}:\d{{2}}|\Z)",
|
|
199
|
+
executor_summary, re.DOTALL
|
|
200
|
+
)
|
|
201
|
+
if m:
|
|
202
|
+
return m.group(1), m.group(2).strip()
|
|
203
|
+
return None, ""
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""REPAIR_LOG.md writing utilities."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
_LOG_HEADER = "# Repair Log\n\nAuto-repairs by ``redeploy run --heal``.\n\n"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def write_repair_log(
|
|
12
|
+
spec_path: Path,
|
|
13
|
+
version: str,
|
|
14
|
+
repairs: list[dict],
|
|
15
|
+
) -> None:
|
|
16
|
+
"""Append an entry to *REPAIR_LOG.md* adjacent to the spec file.
|
|
17
|
+
|
|
18
|
+
Parameters
|
|
19
|
+
----------
|
|
20
|
+
spec_path:
|
|
21
|
+
Path to the spec that was healed.
|
|
22
|
+
version:
|
|
23
|
+
Current project version (written into the log heading).
|
|
24
|
+
repairs:
|
|
25
|
+
List of repair dicts produced by :class:`HealRunner`.
|
|
26
|
+
"""
|
|
27
|
+
log_path = spec_path.parent / "REPAIR_LOG.md"
|
|
28
|
+
now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M")
|
|
29
|
+
lines = [f"## {version} -- {now}\n"]
|
|
30
|
+
if repairs:
|
|
31
|
+
lines.append("### LLM repairs\n")
|
|
32
|
+
for r in repairs:
|
|
33
|
+
lines.append(f"- **`{r['step']}`**: {r.get('summary', 'fixed')}\n")
|
|
34
|
+
if r.get("diag_hint"):
|
|
35
|
+
lines.append(f" - hint: `{r['diag_hint'][:120]}`\n")
|
|
36
|
+
else:
|
|
37
|
+
lines.append("Deployment completed without repairs.\n")
|
|
38
|
+
lines.append("\n")
|
|
39
|
+
entry = "".join(lines)
|
|
40
|
+
|
|
41
|
+
if log_path.exists():
|
|
42
|
+
existing = log_path.read_text()
|
|
43
|
+
if "# " in existing:
|
|
44
|
+
idx = existing.index("\n", existing.index("# ")) + 1
|
|
45
|
+
content = existing[:idx] + "\n" + entry + existing[idx:]
|
|
46
|
+
else:
|
|
47
|
+
content = entry + existing
|
|
48
|
+
else:
|
|
49
|
+
content = _LOG_HEADER + entry
|
|
50
|
+
|
|
51
|
+
log_path.write_text(content)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Heal loop detection — prevents non-converging auto-repair cycles."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from collections import defaultdict
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class HealAbort(Exception):
|
|
9
|
+
"""Raised when a heal loop is detected and retries must stop."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, step_id: str, hint: str, repeats: int) -> None:
|
|
12
|
+
self.step_id = step_id
|
|
13
|
+
self.hint = hint
|
|
14
|
+
self.repeats = repeats
|
|
15
|
+
super().__init__(
|
|
16
|
+
f"Heal loop detected for {step_id!r}: "
|
|
17
|
+
f"same hint repeated {repeats}x"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class HealLoopDetector:
|
|
23
|
+
"""Detect repeated non-converging heal hints for a given step.
|
|
24
|
+
|
|
25
|
+
Parameters
|
|
26
|
+
----------
|
|
27
|
+
max_identical_hints:
|
|
28
|
+
Number of consecutive identical hints that trigger a loop abort.
|
|
29
|
+
Default is 3 because after two identical retries the third
|
|
30
|
+
almost never brings new information.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
max_identical_hints: int = 3
|
|
34
|
+
_history: dict[str, list[str]] = field(default_factory=lambda: defaultdict(list))
|
|
35
|
+
|
|
36
|
+
def observe(self, step_id: str, hint: str) -> bool:
|
|
37
|
+
"""Return *True* when the latest hints indicate a heal loop.
|
|
38
|
+
|
|
39
|
+
A loop is defined as ``max_identical_hints`` identical non-empty
|
|
40
|
+
hints in a row for the same failed step.
|
|
41
|
+
"""
|
|
42
|
+
normalized = (hint or "").strip()
|
|
43
|
+
if not normalized:
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
history = self._history[step_id]
|
|
47
|
+
history.append(normalized)
|
|
48
|
+
|
|
49
|
+
if len(history) < self.max_identical_hints:
|
|
50
|
+
return False
|
|
51
|
+
|
|
52
|
+
recent = history[-self.max_identical_hints :]
|
|
53
|
+
return all(item == recent[0] for item in recent)
|
|
54
|
+
|
|
55
|
+
def reset(self, step_id: str) -> None:
|
|
56
|
+
"""Clear history for *step_id* (e.g. after a successful retry)."""
|
|
57
|
+
self._history.pop(step_id, None)
|
|
58
|
+
|
|
59
|
+
def reset_all(self) -> None:
|
|
60
|
+
"""Clear all history."""
|
|
61
|
+
self._history.clear()
|