alysis-code 0.13.0__py3-none-any.whl
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.
- alysis_code/__init__.py +3 -0
- alysis_code/__main__.py +4 -0
- alysis_code/_build_info.py +14 -0
- alysis_code/account_login.py +468 -0
- alysis_code/agent/README.md +35 -0
- alysis_code/agent/__init__.py +11 -0
- alysis_code/agent/acceptance_contract.py +2217 -0
- alysis_code/agent/blast_radius.py +1403 -0
- alysis_code/agent/cache_keepalive.py +227 -0
- alysis_code/agent/completion_certificate.py +366 -0
- alysis_code/agent/completion_gate.py +306 -0
- alysis_code/agent/empty_response_stall.py +403 -0
- alysis_code/agent/errors.py +28 -0
- alysis_code/agent/llm_calls.py +475 -0
- alysis_code/agent/mutation_classification.py +227 -0
- alysis_code/agent/prompt_context.py +2508 -0
- alysis_code/agent/read_ledger.py +253 -0
- alysis_code/agent/regression_baseline.py +642 -0
- alysis_code/agent/reproduction_first.py +610 -0
- alysis_code/agent/sensitive_output.py +629 -0
- alysis_code/agent/session.py +3218 -0
- alysis_code/agent/steering.py +191 -0
- alysis_code/agent/subagent_execution.py +5177 -0
- alysis_code/agent/subagent_workspace.py +666 -0
- alysis_code/agent/tools_assembly.py +4728 -0
- alysis_code/agent/turn/__init__.py +101 -0
- alysis_code/agent/turn/core.py +8483 -0
- alysis_code/agent/turn/events.py +113 -0
- alysis_code/agent/turn/exploration.py +590 -0
- alysis_code/agent/turn/interventions.py +65 -0
- alysis_code/agent/turn/read_cache.py +420 -0
- alysis_code/agent/turn/snapshot.py +179 -0
- alysis_code/agent/turn_contract.py +661 -0
- alysis_code/agent/turn_path.py +129 -0
- alysis_code/agent/verification.py +2885 -0
- alysis_code/agent/verification_commands.py +512 -0
- alysis_code/agent/verification_evidence.py +738 -0
- alysis_code/agent_loop.py +693 -0
- alysis_code/agent_runtimes/__init__.py +51 -0
- alysis_code/agent_runtimes/base.py +114 -0
- alysis_code/agent_runtimes/builtins.py +129 -0
- alysis_code/agent_runtimes/codex_cli.py +664 -0
- alysis_code/agent_runtimes/host.py +263 -0
- alysis_code/agent_runtimes/registry.py +64 -0
- alysis_code/agent_runtimes/service.py +150 -0
- alysis_code/agentbox_client.py +416 -0
- alysis_code/agentbox_integration.py +310 -0
- alysis_code/alysis_cloud.py +152 -0
- alysis_code/approval_scope.py +276 -0
- alysis_code/assets/README.md +33 -0
- alysis_code/assets/__init__.py +126 -0
- alysis_code/assets/asset_read_core.py +281 -0
- alysis_code/assets/budget_allocator.py +456 -0
- alysis_code/assets/comprehender.py +759 -0
- alysis_code/assets/index.py +654 -0
- alysis_code/assets/ingestion.py +275 -0
- alysis_code/assets/legacy_migration.py +413 -0
- alysis_code/assets/models.py +263 -0
- alysis_code/assets/ocr.py +239 -0
- alysis_code/assets/owl/ascii/f-000.txt +13 -0
- alysis_code/assets/owl/ascii/f-001.txt +13 -0
- alysis_code/assets/owl/ascii/f-002.txt +13 -0
- alysis_code/assets/owl/ascii/f-003.txt +13 -0
- alysis_code/assets/owl/ascii/f-004.txt +13 -0
- alysis_code/assets/owl/ascii/f-005.txt +13 -0
- alysis_code/assets/owl/ascii/f-006.txt +13 -0
- alysis_code/assets/owl/ascii/f-007.txt +13 -0
- alysis_code/assets/owl/ascii/f-008.txt +13 -0
- alysis_code/assets/owl/ascii/f-009.txt +13 -0
- alysis_code/assets/owl/ascii/f-010.txt +13 -0
- alysis_code/assets/owl/ascii/f-011.txt +13 -0
- alysis_code/assets/owl/ascii/f-012.txt +13 -0
- alysis_code/assets/owl/ascii/f-013.txt +13 -0
- alysis_code/assets/owl/ascii/f-014.txt +13 -0
- alysis_code/assets/owl/ascii/f-015.txt +13 -0
- alysis_code/assets/owl/ascii/f-016.txt +13 -0
- alysis_code/assets/owl/ascii/f-017.txt +13 -0
- alysis_code/assets/owl/ascii/f-018.txt +13 -0
- alysis_code/assets/owl/ascii/f-019.txt +13 -0
- alysis_code/assets/owl/ascii/f-020.txt +13 -0
- alysis_code/assets/owl/index.html +98 -0
- alysis_code/assets/owl/show-owl.sh +761 -0
- alysis_code/assets/paths.py +49 -0
- alysis_code/assets/plan_binding.py +326 -0
- alysis_code/assets/planner_context.py +466 -0
- alysis_code/assets/planner_tools.py +184 -0
- alysis_code/assets/prompts.py +101 -0
- alysis_code/assets/replanner_context.py +239 -0
- alysis_code/assets/surface.py +521 -0
- alysis_code/assets/untrusted_content.py +48 -0
- alysis_code/assets/usage_logger.py +94 -0
- alysis_code/assets/worker_mirror.py +428 -0
- alysis_code/assets/worker_section.py +303 -0
- alysis_code/assets/worker_tools.py +468 -0
- alysis_code/atomic_io.py +83 -0
- alysis_code/auth_diagnostics.py +272 -0
- alysis_code/background_runner.py +366 -0
- alysis_code/branding.py +270 -0
- alysis_code/budget_policy.py +390 -0
- alysis_code/build_identity.py +465 -0
- alysis_code/builtin_hooks/__init__.py +7 -0
- alysis_code/builtin_hooks/notify_done_windows.py +65 -0
- alysis_code/bwrap_etc.py +76 -0
- alysis_code/cancellation.py +41 -0
- alysis_code/capabilities.py +137 -0
- alysis_code/chatgpt_codex_static_provider.py +133 -0
- alysis_code/cli.py +51 -0
- alysis_code/cli_impl/__init__.py +1 -0
- alysis_code/cli_impl/assets_cli.py +537 -0
- alysis_code/cli_impl/assets_modal.py +412 -0
- alysis_code/cli_impl/chat/__init__.py +156 -0
- alysis_code/cli_impl/chat/commands.py +2616 -0
- alysis_code/cli_impl/chat/loop.py +4508 -0
- alysis_code/cli_impl/chat/mid_turn_policy.py +125 -0
- alysis_code/cli_impl/chat/rendering.py +444 -0
- alysis_code/cli_impl/chat/state.py +124 -0
- alysis_code/cli_impl/chat_resume.py +830 -0
- alysis_code/cli_impl/chat_slash_completer.py +258 -0
- alysis_code/cli_impl/commands/__init__.py +11 -0
- alysis_code/cli_impl/commands/_shared.py +89 -0
- alysis_code/cli_impl/commands/auth.py +623 -0
- alysis_code/cli_impl/commands/chat_resume_helpers.py +1531 -0
- alysis_code/cli_impl/commands/chat_state.py +158 -0
- alysis_code/cli_impl/commands/chat_status.py +1248 -0
- alysis_code/cli_impl/commands/chat_terminal.py +942 -0
- alysis_code/cli_impl/commands/chat_tui_panels.py +1018 -0
- alysis_code/cli_impl/commands/cli_common.py +1223 -0
- alysis_code/cli_impl/commands/cli_surface.py +77 -0
- alysis_code/cli_impl/commands/config.py +131 -0
- alysis_code/cli_impl/commands/conventions.py +85 -0
- alysis_code/cli_impl/commands/execution_helpers.py +350 -0
- alysis_code/cli_impl/commands/extensions.py +401 -0
- alysis_code/cli_impl/commands/forge.py +1282 -0
- alysis_code/cli_impl/commands/forge_asset_view.py +121 -0
- alysis_code/cli_impl/commands/forge_helpers.py +1215 -0
- alysis_code/cli_impl/commands/hooks.py +737 -0
- alysis_code/cli_impl/commands/ide_bridge.py +31 -0
- alysis_code/cli_impl/commands/mcp.py +700 -0
- alysis_code/cli_impl/commands/profile.py +453 -0
- alysis_code/cli_impl/commands/prompt_helpers.py +307 -0
- alysis_code/cli_impl/commands/report.py +88 -0
- alysis_code/cli_impl/commands/root.py +1118 -0
- alysis_code/cli_impl/commands/sandbox.py +184 -0
- alysis_code/cli_impl/commands/server.py +54 -0
- alysis_code/cli_impl/commands/sessions.py +252 -0
- alysis_code/cli_impl/commands/skills.py +404 -0
- alysis_code/cli_impl/commands/startup.py +946 -0
- alysis_code/cli_impl/commands/tools.py +335 -0
- alysis_code/cli_impl/commands/update.py +364 -0
- alysis_code/cli_impl/commands/welcome.py +972 -0
- alysis_code/cli_impl/config_menu.py +3882 -0
- alysis_code/cli_impl/forge.py +4509 -0
- alysis_code/cli_impl/forge_recovery.py +485 -0
- alysis_code/cli_impl/setup_wizard.py +2409 -0
- alysis_code/cli_impl/tui/__init__.py +58 -0
- alysis_code/cli_impl/tui/app.py +4551 -0
- alysis_code/cli_impl/tui/config.py +32 -0
- alysis_code/cli_impl/tui/config_flow.py +2754 -0
- alysis_code/cli_impl/tui/config_overlay.py +566 -0
- alysis_code/cli_impl/tui/content.py +78 -0
- alysis_code/cli_impl/tui/footer.py +218 -0
- alysis_code/cli_impl/tui/forge_status.py +136 -0
- alysis_code/cli_impl/tui/markdown.py +244 -0
- alysis_code/cli_impl/tui/owl.py +109 -0
- alysis_code/cli_impl/tui/plan_meta.py +477 -0
- alysis_code/cli_impl/tui/setup_app.py +519 -0
- alysis_code/cli_impl/tui/setup_flow.py +1622 -0
- alysis_code/cli_impl/tui/state.py +101 -0
- alysis_code/cli_impl/tui/subagent_identity.py +66 -0
- alysis_code/cli_impl/tui/subagent_panel.py +186 -0
- alysis_code/cli_impl/tui/surface.py +796 -0
- alysis_code/cli_impl/tui/transcript.py +514 -0
- alysis_code/cli_impl/tui/update_prompt.py +79 -0
- alysis_code/cli_impl/tui/workspace_guard.py +384 -0
- alysis_code/clipboard.py +172 -0
- alysis_code/code_review.py +1211 -0
- alysis_code/compaction/__init__.py +28 -0
- alysis_code/compaction/conversation_compactor.py +2932 -0
- alysis_code/compaction/importance.py +177 -0
- alysis_code/compaction/settings.py +297 -0
- alysis_code/compaction/tool_output_offload.py +447 -0
- alysis_code/config.py +3509 -0
- alysis_code/conflict_auto_resolver.py +895 -0
- alysis_code/context/__init__.py +1 -0
- alysis_code/context/tool_schema_budgeter.py +220 -0
- alysis_code/crash_diagnostics.py +282 -0
- alysis_code/custom_tools/README.md +34 -0
- alysis_code/custom_tools/__init__.py +43 -0
- alysis_code/custom_tools/discovery.py +903 -0
- alysis_code/custom_tools/runtime.py +1516 -0
- alysis_code/custom_tools/session.py +227 -0
- alysis_code/custom_tools/trust.py +232 -0
- alysis_code/diff_paths.py +113 -0
- alysis_code/direction_change.py +293 -0
- alysis_code/dispatch_timing.py +306 -0
- alysis_code/durable_service_manager.py +1236 -0
- alysis_code/edit_discipline.py +659 -0
- alysis_code/error_text.py +73 -0
- alysis_code/execution_budget.py +411 -0
- alysis_code/execution_context.py +915 -0
- alysis_code/execution_deadline.py +1065 -0
- alysis_code/execution_shared.py +1904 -0
- alysis_code/extensions/README.md +30 -0
- alysis_code/extensions/__init__.py +93 -0
- alysis_code/extensions/activation.py +138 -0
- alysis_code/extensions/install.py +1436 -0
- alysis_code/extensions/manifest.py +487 -0
- alysis_code/extensions/models.py +74 -0
- alysis_code/extensions/paths.py +56 -0
- alysis_code/extensions/registry.json +4 -0
- alysis_code/extensions/registry.py +52 -0
- alysis_code/extensions/state.py +83 -0
- alysis_code/extensions/workspace_trust.py +101 -0
- alysis_code/failed_task_evidence.py +369 -0
- alysis_code/failure_category.py +315 -0
- alysis_code/feedback_report.py +1647 -0
- alysis_code/file_classification.py +485 -0
- alysis_code/forge.py +2064 -0
- alysis_code/forge_completion.py +362 -0
- alysis_code/forge_events.py +475 -0
- alysis_code/frontmatter_utils.py +95 -0
- alysis_code/git_evidence.py +1181 -0
- alysis_code/git_ops.py +560 -0
- alysis_code/git_safe.py +62 -0
- alysis_code/git_worktrees.py +190 -0
- alysis_code/hooks/README.md +33 -0
- alysis_code/hooks/__init__.py +67 -0
- alysis_code/hooks/audit.py +171 -0
- alysis_code/hooks/config.py +225 -0
- alysis_code/hooks/dispatcher.py +1110 -0
- alysis_code/hooks/models.py +447 -0
- alysis_code/hooks/trust.py +202 -0
- alysis_code/host_actions.py +543 -0
- alysis_code/host_browser.py +103 -0
- alysis_code/ide/__init__.py +5 -0
- alysis_code/ide/activity_events.py +399 -0
- alysis_code/ide/approvals.py +337 -0
- alysis_code/ide/artifacts.py +153 -0
- alysis_code/ide/browser_egress_proxy.py +1076 -0
- alysis_code/ide/cdp_websocket_transport.py +1192 -0
- alysis_code/ide/change_ledger.py +1721 -0
- alysis_code/ide/context_blocks.py +979 -0
- alysis_code/ide/event_stream.py +531 -0
- alysis_code/ide/forge_protocol.py +3112 -0
- alysis_code/ide/forge_request_ledger.py +737 -0
- alysis_code/ide/health.py +965 -0
- alysis_code/ide/managed_browser.py +2251 -0
- alysis_code/ide/management_protocol.py +3414 -0
- alysis_code/ide/mcp_oauth_coordinator.py +744 -0
- alysis_code/ide/mcp_oauth_lifecycle.py +1504 -0
- alysis_code/ide/prompt_queue.py +1070 -0
- alysis_code/ide/protocol.py +191 -0
- alysis_code/ide/resumable_swarm.py +1543 -0
- alysis_code/ide/session_search.py +295 -0
- alysis_code/ide/stdio_bridge.py +9935 -0
- alysis_code/ide/structured_state.py +1579 -0
- alysis_code/ide/swarm_protocol.py +816 -0
- alysis_code/integration_gate.py +506 -0
- alysis_code/interactive_input_guard.py +39 -0
- alysis_code/interactive_plan_mode.py +26 -0
- alysis_code/internal_artifacts.py +179 -0
- alysis_code/knowledge_base.py +1409 -0
- alysis_code/knowledge_capture.py +1190 -0
- alysis_code/knowledge_librarian.py +605 -0
- alysis_code/language_policy.py +34 -0
- alysis_code/litellm_static_provider.py +535 -0
- alysis_code/llm/__init__.py +1 -0
- alysis_code/llm/anthropic_messages.py +2288 -0
- alysis_code/llm/base.py +71 -0
- alysis_code/llm/cache_capabilities.py +985 -0
- alysis_code/llm/cache_control_blocks.py +244 -0
- alysis_code/llm/cache_policy.py +388 -0
- alysis_code/llm/factory.py +373 -0
- alysis_code/llm/gemini_generate_content.py +2652 -0
- alysis_code/llm/gemini_interactions.py +739 -0
- alysis_code/llm/metadata.py +450 -0
- alysis_code/llm/openai_compat.py +2947 -0
- alysis_code/llm/openai_responses.py +2604 -0
- alysis_code/llm/protocols.py +609 -0
- alysis_code/llm/provider_limits.py +525 -0
- alysis_code/llm/request_plan.py +389 -0
- alysis_code/llm/request_shape.py +238 -0
- alysis_code/llm/streaming.py +108 -0
- alysis_code/llm/temperature_compat.py +78 -0
- alysis_code/llm/types.py +195 -0
- alysis_code/llm/usage_normalization.py +222 -0
- alysis_code/llm_error_display.py +315 -0
- alysis_code/logging_redaction.py +326 -0
- alysis_code/managed_host_deadline.py +196 -0
- alysis_code/mcp/README.md +33 -0
- alysis_code/mcp/__init__.py +24 -0
- alysis_code/mcp/client.py +1137 -0
- alysis_code/mcp/config.py +597 -0
- alysis_code/mcp/errors.py +113 -0
- alysis_code/mcp/forge_scope.py +154 -0
- alysis_code/mcp/jsonrpc.py +214 -0
- alysis_code/mcp/manager.py +2308 -0
- alysis_code/mcp/models.py +666 -0
- alysis_code/mcp/oauth.py +972 -0
- alysis_code/mcp/oauth_runtime.py +310 -0
- alysis_code/mcp/oauth_store.py +276 -0
- alysis_code/mcp/prompts.py +329 -0
- alysis_code/mcp/resources.py +295 -0
- alysis_code/mcp/roots.py +106 -0
- alysis_code/mcp/server_requests.py +75 -0
- alysis_code/mcp/token_store.py +859 -0
- alysis_code/mcp/transport_http.py +1338 -0
- alysis_code/mcp/transport_stdio.py +1267 -0
- alysis_code/mcp/untrusted_content.py +119 -0
- alysis_code/merge_conflict_reviewer.py +729 -0
- alysis_code/model_catalog/__init__.py +1 -0
- alysis_code/model_catalog/chatgpt_codex_subscription_snapshot.json +186 -0
- alysis_code/model_catalog/litellm_model_prices_snapshot.json +44715 -0
- alysis_code/model_catalog/litellm_model_prices_snapshot.meta.json +17 -0
- alysis_code/model_metadata_policy.py +223 -0
- alysis_code/model_metadata_utils.py +103 -0
- alysis_code/model_registry.py +1420 -0
- alysis_code/model_router.py +147 -0
- alysis_code/permission_policy.py +1016 -0
- alysis_code/personas.py +451 -0
- alysis_code/pipeline_facts.py +233 -0
- alysis_code/plan_assistant.py +4763 -0
- alysis_code/plan_mode.py +393 -0
- alysis_code/plan_reconciliation.py +1228 -0
- alysis_code/plan_repair.py +652 -0
- alysis_code/plan_validation.py +1099 -0
- alysis_code/planning_constraints.py +904 -0
- alysis_code/policy.py +95 -0
- alysis_code/preview_server.py +457 -0
- alysis_code/process_reaping.py +566 -0
- alysis_code/profile_presets.py +1834 -0
- alysis_code/profiles.py +666 -0
- alysis_code/provider_auth/__init__.py +29 -0
- alysis_code/provider_auth/base.py +99 -0
- alysis_code/provider_auth/openai_codex.py +951 -0
- alysis_code/provider_auth/registry.py +76 -0
- alysis_code/provider_auth/store.py +125 -0
- alysis_code/provider_diagnostics.py +1209 -0
- alysis_code/provider_model_catalog.py +685 -0
- alysis_code/provider_telemetry.py +1699 -0
- alysis_code/provider_url.py +75 -0
- alysis_code/reasoning_contracts.py +911 -0
- alysis_code/remote_sync.py +350 -0
- alysis_code/replanning.py +1195 -0
- alysis_code/repo_scan.py +1152 -0
- alysis_code/request_estimation.py +296 -0
- alysis_code/review_gate.py +617 -0
- alysis_code/run_lock.py +1141 -0
- alysis_code/run_outcome.py +58 -0
- alysis_code/run_provenance.py +774 -0
- alysis_code/run_state.py +445 -0
- alysis_code/runtime_artifacts.py +116 -0
- alysis_code/runtime_context_features.py +78 -0
- alysis_code/runtime_kind.py +52 -0
- alysis_code/safety/__init__.py +11 -0
- alysis_code/safety/mcp_sanitize.py +29 -0
- alysis_code/safety/safe_http.py +297 -0
- alysis_code/safety/subagent_report.py +184 -0
- alysis_code/sandbox_doctor.py +682 -0
- alysis_code/sandbox_runner.py +1025 -0
- alysis_code/sandbox_settings.py +423 -0
- alysis_code/serialized_paths.py +355 -0
- alysis_code/server/__init__.py +3 -0
- alysis_code/server/app.py +367 -0
- alysis_code/server/auth.py +34 -0
- alysis_code/server/job_config.py +30 -0
- alysis_code/server/settings.py +215 -0
- alysis_code/server/store.py +193 -0
- alysis_code/server/worker_runner.py +657 -0
- alysis_code/service_persistence.py +355 -0
- alysis_code/session_artifacts.py +108 -0
- alysis_code/session_metrics.py +331 -0
- alysis_code/session_store.py +624 -0
- alysis_code/skills/README.md +34 -0
- alysis_code/skills/__init__.py +104 -0
- alysis_code/skills/conventions.py +84 -0
- alysis_code/skills/discovery.py +176 -0
- alysis_code/skills/eval_models.py +232 -0
- alysis_code/skills/eval_runner.py +372 -0
- alysis_code/skills/evals.py +1344 -0
- alysis_code/skills/install.py +293 -0
- alysis_code/skills/loader.py +118 -0
- alysis_code/skills/matching.py +103 -0
- alysis_code/skills/models.py +71 -0
- alysis_code/skills/paths.py +56 -0
- alysis_code/skills/prompting.py +500 -0
- alysis_code/skills/scaffold.py +142 -0
- alysis_code/skills/state.py +441 -0
- alysis_code/skills/transactions.py +125 -0
- alysis_code/skills/validation.py +304 -0
- alysis_code/step_budget.py +238 -0
- alysis_code/subagent_labels.py +49 -0
- alysis_code/subagents.py +1072 -0
- alysis_code/surface/__init__.py +80 -0
- alysis_code/surface/base.py +305 -0
- alysis_code/surface/console.py +387 -0
- alysis_code/surface/events.py +372 -0
- alysis_code/surface/hidden_surface.py +529 -0
- alysis_code/surface/noop_surface.py +219 -0
- alysis_code/surface/rich_surface.py +1555 -0
- alysis_code/surface/styles.py +67 -0
- alysis_code/surface/theme.py +455 -0
- alysis_code/surface/types.py +100 -0
- alysis_code/swarm_backend.py +926 -0
- alysis_code/swarm_orchestrator.py +4020 -0
- alysis_code/swarm_scheduler.py +441 -0
- alysis_code/swarm_trace.py +429 -0
- alysis_code/swarm_worker.py +2119 -0
- alysis_code/swarm_write_guard.py +348 -0
- alysis_code/task_dependencies.py +170 -0
- alysis_code/task_readiness.py +992 -0
- alysis_code/task_scope.py +2148 -0
- alysis_code/terminal_manager.py +762 -0
- alysis_code/terminal_ownership.py +460 -0
- alysis_code/text_normalization.py +30 -0
- alysis_code/token_budget.py +97 -0
- alysis_code/tools/README.md +34 -0
- alysis_code/tools/__init__.py +1 -0
- alysis_code/tools/artifacts.py +127 -0
- alysis_code/tools/availability.py +188 -0
- alysis_code/tools/fs.py +1456 -0
- alysis_code/tools/git.py +461 -0
- alysis_code/tools/history.py +229 -0
- alysis_code/tools/http_timeout.py +78 -0
- alysis_code/tools/image_generation.py +552 -0
- alysis_code/tools/registry.py +2936 -0
- alysis_code/tools/repo_map.py +476 -0
- alysis_code/tools/search.py +563 -0
- alysis_code/tools/shell.py +135 -0
- alysis_code/tools/symbols.py +1350 -0
- alysis_code/tools/test_discovery.py +643 -0
- alysis_code/tools/web.py +482 -0
- alysis_code/tools/web_search.py +2012 -0
- alysis_code/tools/web_search_dashscope.py +557 -0
- alysis_code/tools/web_search_ddgs.py +221 -0
- alysis_code/tools/web_search_provider_adapters.py +1429 -0
- alysis_code/tools/web_search_tavily.py +194 -0
- alysis_code/updates.py +933 -0
- alysis_code/usage_tracker.py +1990 -0
- alysis_code/verification_command_analysis.py +1004 -0
- alysis_code/verification_contract.py +574 -0
- alysis_code/verification_failure_summary.py +273 -0
- alysis_code/verification_repair.py +385 -0
- alysis_code/verify_gate.py +3129 -0
- alysis_code/web_research.py +1872 -0
- alysis_code/web_search_adapters.py +66 -0
- alysis_code/web_search_policy.py +27 -0
- alysis_code/workspace_binding.py +389 -0
- alysis_code/workspace_binding_ui.py +408 -0
- alysis_code/workspace_context.py +273 -0
- alysis_code/workspace_isolation.py +138 -0
- alysis_code/workspace_provisioning.py +455 -0
- alysis_code-0.13.0.dist-info/METADATA +507 -0
- alysis_code-0.13.0.dist-info/RECORD +458 -0
- alysis_code-0.13.0.dist-info/WHEEL +4 -0
- alysis_code-0.13.0.dist-info/entry_points.txt +3 -0
- alysis_code-0.13.0.dist-info/licenses/LICENSE +176 -0
- alysis_code-0.13.0.dist-info/licenses/NOTICE +4 -0
alysis_code/branding.py
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""Product identity, and the compatibility shims that keep pre-rebrand installs working.
|
|
2
|
+
|
|
3
|
+
The CLI shipped as "Sylliptor" (package ``sylliptor-agent-cli``, command
|
|
4
|
+
``sylliptor``, env prefix ``SYLLIPTOR_``) before being renamed to Alysis Code.
|
|
5
|
+
Everything a user could have written down — env vars in a CI config, a
|
|
6
|
+
``.sylliptor/`` directory committed to their repo, a keyring entry holding MCP
|
|
7
|
+
OAuth tokens — still resolves, with a one-time deprecation notice. See
|
|
8
|
+
``docs/migration-alysis-code.md`` for the removal timeline.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
import os
|
|
15
|
+
import shutil
|
|
16
|
+
import threading
|
|
17
|
+
import warnings
|
|
18
|
+
from collections.abc import Mapping
|
|
19
|
+
from importlib import metadata as importlib_metadata
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from urllib.parse import urlparse
|
|
22
|
+
|
|
23
|
+
from platformdirs import user_config_dir, user_data_dir
|
|
24
|
+
|
|
25
|
+
LOGGER = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
PRODUCT_NAME = "Alysis Code"
|
|
28
|
+
CANONICAL_APP_NAME = "alysis"
|
|
29
|
+
CANONICAL_SERVER_APP_NAME = "alysis-code-server"
|
|
30
|
+
PYTHON_PACKAGE_NAME = "alysis-code"
|
|
31
|
+
PROJECT_SOURCE_URL = "https://github.com/AlysisAi/alysis-code"
|
|
32
|
+
SANDBOX_IMAGE_REPOSITORY = "alysis-sandbox"
|
|
33
|
+
ENV_PREFIX = "ALYSIS_"
|
|
34
|
+
PROJECT_DIR_NAME = ".alysis"
|
|
35
|
+
PLUGIN_MANIFEST_NAME = "alysis-plugin.toml"
|
|
36
|
+
|
|
37
|
+
# --- pre-rebrand identities -------------------------------------------------
|
|
38
|
+
LEGACY_PRODUCT_NAME = "Sylliptor"
|
|
39
|
+
LEGACY_APP_NAME = "sylliptor"
|
|
40
|
+
LEGACY_SERVER_APP_NAME = "sylliptor-agent-cli-server"
|
|
41
|
+
LEGACY_PYTHON_PACKAGE_NAME = "sylliptor-agent-cli"
|
|
42
|
+
LEGACY_ENV_PREFIX = "SYLLIPTOR_"
|
|
43
|
+
LEGACY_PROJECT_DIR_NAME = ".sylliptor"
|
|
44
|
+
LEGACY_PLUGIN_MANIFEST_NAME = "sylliptor-plugin.toml"
|
|
45
|
+
|
|
46
|
+
# Written into a migrated legacy directory so a second run does not re-copy, and
|
|
47
|
+
# so a human poking around understands why the directory is now inert.
|
|
48
|
+
MIGRATION_MARKER_NAME = ".migrated-to-alysis"
|
|
49
|
+
|
|
50
|
+
_notice_lock = threading.Lock()
|
|
51
|
+
_legacy_env_seen: dict[str, str] = {}
|
|
52
|
+
_migrated_paths: dict[str, str] = {}
|
|
53
|
+
_legacy_paths_in_use: dict[str, str] = {}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def legacy_env_name(name: str) -> str | None:
|
|
57
|
+
"""Return the pre-rebrand spelling of an ``ALYSIS_*`` variable, if any."""
|
|
58
|
+
if not name.startswith(ENV_PREFIX):
|
|
59
|
+
return None
|
|
60
|
+
return LEGACY_ENV_PREFIX + name[len(ENV_PREFIX) :]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _record_legacy_env(legacy: str, current: str) -> None:
|
|
64
|
+
with _notice_lock:
|
|
65
|
+
if legacy in _legacy_env_seen:
|
|
66
|
+
return
|
|
67
|
+
_legacy_env_seen[legacy] = current
|
|
68
|
+
message = (
|
|
69
|
+
f"{legacy} is deprecated and will be removed in a future release; rename it to {current}."
|
|
70
|
+
)
|
|
71
|
+
LOGGER.warning("legacy_env_var legacy=%s current=%s", legacy, current)
|
|
72
|
+
warnings.warn(message, DeprecationWarning, stacklevel=3)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def env_get(name: str, default: str | None = None) -> str | None:
|
|
76
|
+
"""Read an environment variable, falling back to its pre-rebrand name.
|
|
77
|
+
|
|
78
|
+
The new name always wins when both are set, so a user midway through the
|
|
79
|
+
migration gets the value they most recently wrote.
|
|
80
|
+
"""
|
|
81
|
+
return env_get_from(os.environ, name, default)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def env_get_from(
|
|
85
|
+
environ: Mapping[str, str],
|
|
86
|
+
name: str,
|
|
87
|
+
default: str | None = None,
|
|
88
|
+
) -> str | None:
|
|
89
|
+
"""Read ``name`` from a mapping with the same rename fallback as :func:`env_get`.
|
|
90
|
+
|
|
91
|
+
Runtime settings often accept an injected environment mapping for tests or
|
|
92
|
+
subprocess construction. Keeping the prefix-derived fallback here prevents
|
|
93
|
+
those paths from drifting from process-environment behavior.
|
|
94
|
+
"""
|
|
95
|
+
value = environ.get(name)
|
|
96
|
+
if value is not None:
|
|
97
|
+
return value
|
|
98
|
+
|
|
99
|
+
legacy = legacy_env_name(name)
|
|
100
|
+
if legacy is not None:
|
|
101
|
+
legacy_value = environ.get(legacy)
|
|
102
|
+
if legacy_value is not None:
|
|
103
|
+
_record_legacy_env(legacy, name)
|
|
104
|
+
return legacy_value
|
|
105
|
+
|
|
106
|
+
return default
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def with_legacy_env_aliases(env: Mapping[str, str]) -> dict[str, str]:
|
|
110
|
+
"""Duplicate ``ALYSIS_*`` entries under their legacy names.
|
|
111
|
+
|
|
112
|
+
Used when building the environment for code we do not control — user-authored
|
|
113
|
+
hooks and custom tools, which may still read ``SYLLIPTOR_TOOL_NAME`` and
|
|
114
|
+
friends. Existing legacy keys in ``env`` are left alone.
|
|
115
|
+
"""
|
|
116
|
+
merged = dict(env)
|
|
117
|
+
for key, value in env.items():
|
|
118
|
+
legacy = legacy_env_name(key)
|
|
119
|
+
if legacy is not None and legacy not in merged:
|
|
120
|
+
merged[legacy] = value
|
|
121
|
+
return merged
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def consume_legacy_notices() -> list[str]:
|
|
125
|
+
"""Drain accumulated deprecation notices for the CLI to surface once."""
|
|
126
|
+
with _notice_lock:
|
|
127
|
+
env_items = sorted(_legacy_env_seen.items())
|
|
128
|
+
migrated = sorted(_migrated_paths.items())
|
|
129
|
+
in_use = sorted(_legacy_paths_in_use.items())
|
|
130
|
+
_legacy_env_seen.clear()
|
|
131
|
+
_migrated_paths.clear()
|
|
132
|
+
_legacy_paths_in_use.clear()
|
|
133
|
+
|
|
134
|
+
notices = [
|
|
135
|
+
f"Environment variable {legacy} is deprecated — use {current}."
|
|
136
|
+
for legacy, current in env_items
|
|
137
|
+
]
|
|
138
|
+
notices.extend(f"Copied {legacy} to {current}." for legacy, current in migrated)
|
|
139
|
+
notices.extend(
|
|
140
|
+
f"Using legacy directory {legacy}; rename it to {current} when convenient."
|
|
141
|
+
for legacy, current in in_use
|
|
142
|
+
)
|
|
143
|
+
return notices
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _github_owner_from_url(url: str) -> str | None:
|
|
147
|
+
value = url.strip()
|
|
148
|
+
if value.startswith("git@github.com:"):
|
|
149
|
+
path = value.removeprefix("git@github.com:")
|
|
150
|
+
parts = [part for part in path.removesuffix(".git").split("/") if part]
|
|
151
|
+
return parts[0] if len(parts) >= 2 else None
|
|
152
|
+
|
|
153
|
+
parsed = urlparse(value)
|
|
154
|
+
if parsed.hostname != "github.com":
|
|
155
|
+
return None
|
|
156
|
+
parts = [part for part in parsed.path.removesuffix(".git").strip("/").split("/") if part]
|
|
157
|
+
return parts[0] if len(parts) >= 2 else None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _packaging_source_urls() -> tuple[str, ...]:
|
|
161
|
+
urls: list[str] = [PROJECT_SOURCE_URL]
|
|
162
|
+
try:
|
|
163
|
+
package_metadata = importlib_metadata.metadata(PYTHON_PACKAGE_NAME)
|
|
164
|
+
except importlib_metadata.PackageNotFoundError:
|
|
165
|
+
return tuple(urls)
|
|
166
|
+
|
|
167
|
+
homepage = package_metadata.get("Home-page")
|
|
168
|
+
if homepage:
|
|
169
|
+
urls.append(homepage)
|
|
170
|
+
for project_url in package_metadata.get_all("Project-URL") or ():
|
|
171
|
+
label, _, value = project_url.partition(",")
|
|
172
|
+
if label.strip().lower() in {"source", "repository", "homepage"} and value.strip():
|
|
173
|
+
urls.append(value.strip())
|
|
174
|
+
return tuple(dict.fromkeys(urls))
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def resolve_ghcr_owner() -> str | None:
|
|
178
|
+
for source_url in _packaging_source_urls():
|
|
179
|
+
owner = _github_owner_from_url(source_url)
|
|
180
|
+
if owner:
|
|
181
|
+
return owner
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def default_sandbox_docker_image(variant: str = "dev") -> str:
|
|
186
|
+
tag = variant.strip() or "dev"
|
|
187
|
+
owner = resolve_ghcr_owner()
|
|
188
|
+
if owner:
|
|
189
|
+
return f"ghcr.io/{owner.lower()}/{SANDBOX_IMAGE_REPOSITORY}:{tag}"
|
|
190
|
+
return f"{SANDBOX_IMAGE_REPOSITORY}:{tag}"
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _migrate_legacy_dir(current: Path, legacy: Path) -> Path:
|
|
194
|
+
"""Seed ``current`` from a pre-rebrand ``legacy`` directory, once.
|
|
195
|
+
|
|
196
|
+
The legacy tree is *copied*, not moved: an older install sharing the machine
|
|
197
|
+
keeps working, and a failed copy never destroys the only copy of a user's
|
|
198
|
+
credentials. A marker file in the legacy directory makes this idempotent.
|
|
199
|
+
"""
|
|
200
|
+
if current.exists() or not legacy.is_dir():
|
|
201
|
+
return current
|
|
202
|
+
if (legacy / MIGRATION_MARKER_NAME).exists():
|
|
203
|
+
return current
|
|
204
|
+
|
|
205
|
+
try:
|
|
206
|
+
shutil.copytree(legacy, current, dirs_exist_ok=False)
|
|
207
|
+
except (OSError, shutil.Error) as exc:
|
|
208
|
+
# A failed migration must not be fatal: fall through to a fresh
|
|
209
|
+
# directory rather than blocking startup entirely.
|
|
210
|
+
LOGGER.warning(
|
|
211
|
+
"legacy_dir_migration_failed legacy=%s current=%s error=%s", legacy, current, exc
|
|
212
|
+
)
|
|
213
|
+
return current
|
|
214
|
+
|
|
215
|
+
try:
|
|
216
|
+
(legacy / MIGRATION_MARKER_NAME).write_text(
|
|
217
|
+
f"Copied to {current} during the Sylliptor -> Alysis Code rename.\n"
|
|
218
|
+
"This directory is no longer read. Delete it once you are confident "
|
|
219
|
+
"the migration went through.\n",
|
|
220
|
+
encoding="utf-8",
|
|
221
|
+
)
|
|
222
|
+
except OSError:
|
|
223
|
+
# Marker is an optimisation; a missing one only costs a redundant check.
|
|
224
|
+
pass
|
|
225
|
+
|
|
226
|
+
with _notice_lock:
|
|
227
|
+
_migrated_paths.setdefault(str(legacy), str(current))
|
|
228
|
+
LOGGER.info("legacy_dir_migrated legacy=%s current=%s", legacy, current)
|
|
229
|
+
return current
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def canonical_user_config_dir() -> Path:
|
|
233
|
+
current = Path(user_config_dir(CANONICAL_APP_NAME, CANONICAL_APP_NAME))
|
|
234
|
+
legacy = Path(user_config_dir(LEGACY_APP_NAME, LEGACY_APP_NAME))
|
|
235
|
+
return _migrate_legacy_dir(current, legacy)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def canonical_user_data_dir() -> Path:
|
|
239
|
+
current = Path(user_data_dir(CANONICAL_APP_NAME, CANONICAL_APP_NAME))
|
|
240
|
+
legacy = Path(user_data_dir(LEGACY_APP_NAME, LEGACY_APP_NAME))
|
|
241
|
+
return _migrate_legacy_dir(current, legacy)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def canonical_server_data_dir() -> Path:
|
|
245
|
+
current = Path(user_data_dir(CANONICAL_SERVER_APP_NAME, CANONICAL_APP_NAME))
|
|
246
|
+
legacy = Path(user_data_dir(LEGACY_SERVER_APP_NAME, LEGACY_APP_NAME))
|
|
247
|
+
return _migrate_legacy_dir(current, legacy)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def resolve_project_dir(root: Path) -> Path:
|
|
251
|
+
"""Return the per-repo agent directory, preferring ``.alysis``.
|
|
252
|
+
|
|
253
|
+
A ``.sylliptor`` directory is honoured in place when it is the only one
|
|
254
|
+
present: it is typically committed to the user's repository, so silently
|
|
255
|
+
renaming it would show up as an unexplained diff in their working tree.
|
|
256
|
+
"""
|
|
257
|
+
current = root / PROJECT_DIR_NAME
|
|
258
|
+
if current.exists():
|
|
259
|
+
return current
|
|
260
|
+
legacy = root / LEGACY_PROJECT_DIR_NAME
|
|
261
|
+
if legacy.is_dir():
|
|
262
|
+
with _notice_lock:
|
|
263
|
+
_legacy_paths_in_use.setdefault(str(legacy), str(current))
|
|
264
|
+
return legacy
|
|
265
|
+
return current
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def plugin_manifest_candidates() -> tuple[str, ...]:
|
|
269
|
+
"""Manifest filenames to probe, newest spelling first."""
|
|
270
|
+
return (PLUGIN_MANIFEST_NAME, LEGACY_PLUGIN_MANIFEST_NAME)
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
"""Run-budget policy: sizing, stop-reason, watchdog, and progress checkpoint.
|
|
2
|
+
|
|
3
|
+
This module is deliberately dependency-light -- standard library only, and no
|
|
4
|
+
imports from the rest of the package. Two reasons:
|
|
5
|
+
|
|
6
|
+
* it is loaded by ``agent/turn/core.py`` and ``agent/session.py`` on the hot
|
|
7
|
+
path, and must not drag anything in;
|
|
8
|
+
* it holds the decision logic that has to be unit-testable in a bare
|
|
9
|
+
interpreter, without executing ``alysis_code/__init__`` (which pulls
|
|
10
|
+
in httpx/pydantic/rich). ``tests/test_budget_policy.py`` loads this file
|
|
11
|
+
directly by path.
|
|
12
|
+
|
|
13
|
+
Environment lookups stay local rather than importing ``branding`` for the same
|
|
14
|
+
reason. The prefix-derived ``SYLLIPTOR_*`` fallback is repeated in the small
|
|
15
|
+
lookup helper so standalone and package execution keep the same behavior.
|
|
16
|
+
|
|
17
|
+
Why a watchdog exists at all
|
|
18
|
+
----------------------------
|
|
19
|
+
Every pre-existing budget check in the engine is a *start gate*: it is
|
|
20
|
+
evaluated before an operation begins (``ExecutionDeadline.start_decision``,
|
|
21
|
+
``deadline_timeout_or_raise``). Nothing re-checks the clock while an operation
|
|
22
|
+
is in flight, so any blocking call that does not bound itself by the deadline
|
|
23
|
+
can outlive the budget indefinitely -- which is exactly what was observed in
|
|
24
|
+
production, where runs recorded ``exhausted: true`` and then kept going for
|
|
25
|
+
hours. :class:`BudgetWatchdog` is the stop gate: a daemon timer armed once at
|
|
26
|
+
run start that trips a cancellation event at ``budget + grace``, regardless of
|
|
27
|
+
what the main thread is blocked on.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import math
|
|
33
|
+
import os
|
|
34
|
+
import threading
|
|
35
|
+
from collections.abc import Callable, Mapping
|
|
36
|
+
from typing import Any
|
|
37
|
+
|
|
38
|
+
# Wall-clock budget applied to a non-interactive run that did not configure
|
|
39
|
+
# one. Unchanged from the value this replaced, so default behaviour is
|
|
40
|
+
# identical; it is now overridable per-run instead of being a literal.
|
|
41
|
+
DEFAULT_RUN_BUDGET_SECONDS = 3600.0
|
|
42
|
+
|
|
43
|
+
# How long past the deadline an in-flight operation is given to unwind
|
|
44
|
+
# cooperatively before the watchdog cancels it.
|
|
45
|
+
DEFAULT_BUDGET_GRACE_SECONDS = 60.0
|
|
46
|
+
|
|
47
|
+
# Fraction of the budget at which a run with nothing to show for itself is
|
|
48
|
+
# told so.
|
|
49
|
+
DEFAULT_BUDGET_CHECKPOINT_FRACTION = 0.33
|
|
50
|
+
|
|
51
|
+
RUN_BUDGET_SECONDS_ENV = "ALYSIS_RUN_BUDGET_SECONDS"
|
|
52
|
+
BUDGET_GRACE_SECONDS_ENV = "ALYSIS_BUDGET_GRACE_SECONDS"
|
|
53
|
+
BUDGET_CHECKPOINT_FRACTION_ENV = "ALYSIS_BUDGET_CHECKPOINT_FRACTION"
|
|
54
|
+
|
|
55
|
+
# Machine-readable marker for "the run stopped because it ran out of budget".
|
|
56
|
+
# Written to the session log's final event and to the run_finished
|
|
57
|
+
# crash-diagnostic event so a harness can tell a budget stop from a crash
|
|
58
|
+
# without parsing prose.
|
|
59
|
+
STOP_REASON_RUN_BUDGET_EXHAUSTED = "run_budget_exhausted"
|
|
60
|
+
|
|
61
|
+
PROGRESS_CHECKPOINT_FAILED_EVENT = "progress_checkpoint_failed"
|
|
62
|
+
|
|
63
|
+
# A budget stop is a normal outcome, not a failure: the agent did what it could
|
|
64
|
+
# in the time it was given and reported honestly. Exiting non-zero made every
|
|
65
|
+
# harness record it as NonZeroAgentExitCodeError.
|
|
66
|
+
BUDGET_STOP_EXIT_CODE = 0
|
|
67
|
+
DEFAULT_FAILURE_EXIT_CODE = 1
|
|
68
|
+
|
|
69
|
+
# The single model-visible string introduced by this module. Kept as one
|
|
70
|
+
# constant so it is greppable and cannot drift.
|
|
71
|
+
BUDGET_CHECKPOINT_NOTICE = (
|
|
72
|
+
"Budget checkpoint: no material progress recorded yet. "
|
|
73
|
+
"Reassess approach or report the concrete blocker."
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# Terminal reasons that are *not* failures. Everything else exits non-zero.
|
|
77
|
+
GRACEFUL_STOP_REASONS = frozenset(
|
|
78
|
+
{
|
|
79
|
+
"",
|
|
80
|
+
"completed",
|
|
81
|
+
"session_close",
|
|
82
|
+
STOP_REASON_RUN_BUDGET_EXHAUSTED,
|
|
83
|
+
}
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class RunBudgetCancelled(Exception):
|
|
88
|
+
"""Fallback cancellation error used when no error class is injected.
|
|
89
|
+
|
|
90
|
+
Production wires :class:`BudgetCancellationToken` with
|
|
91
|
+
``cancellation.CooperativeCancellationError`` so that every existing
|
|
92
|
+
``except CooperativeCancellationError`` clause keeps working. This class
|
|
93
|
+
exists so the module stays importable, and testable, on its own.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
def __init__(self, reason: str = STOP_REASON_RUN_BUDGET_EXHAUSTED) -> None:
|
|
97
|
+
super().__init__(reason)
|
|
98
|
+
self.reason = str(reason or STOP_REASON_RUN_BUDGET_EXHAUSTED)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _env_value(name: str, environ: Mapping[str, str] | None = None) -> str | None:
|
|
102
|
+
source: Mapping[str, str] = os.environ if environ is None else environ
|
|
103
|
+
raw = source.get(name)
|
|
104
|
+
if raw is None and name.startswith("ALYSIS_"):
|
|
105
|
+
raw = source.get("SYLLIPTOR_" + name.removeprefix("ALYSIS_"))
|
|
106
|
+
if raw is None:
|
|
107
|
+
return None
|
|
108
|
+
cleaned = str(raw).strip()
|
|
109
|
+
return cleaned or None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _positive_float(raw: str | None, fallback: float) -> float:
|
|
113
|
+
"""Parse a strictly positive finite float, falling back on anything else.
|
|
114
|
+
|
|
115
|
+
A misconfigured budget must never be able to abort a run or, worse, make
|
|
116
|
+
the budget effectively infinite. Every bad value -- unparseable, negative,
|
|
117
|
+
zero, NaN, inf -- resolves to the documented default.
|
|
118
|
+
"""
|
|
119
|
+
if raw is None:
|
|
120
|
+
return fallback
|
|
121
|
+
try:
|
|
122
|
+
value = float(raw)
|
|
123
|
+
except (TypeError, ValueError):
|
|
124
|
+
return fallback
|
|
125
|
+
if not math.isfinite(value) or value <= 0.0:
|
|
126
|
+
return fallback
|
|
127
|
+
return value
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _non_negative_float(raw: str | None, fallback: float) -> float:
|
|
131
|
+
"""Like :func:`_positive_float` but admits ``0`` (a zero-length window)."""
|
|
132
|
+
if raw is None:
|
|
133
|
+
return fallback
|
|
134
|
+
try:
|
|
135
|
+
value = float(raw)
|
|
136
|
+
except (TypeError, ValueError):
|
|
137
|
+
return fallback
|
|
138
|
+
if not math.isfinite(value) or value < 0.0:
|
|
139
|
+
return fallback
|
|
140
|
+
return value
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def resolve_run_budget_seconds(environ: Mapping[str, str] | None = None) -> float:
|
|
144
|
+
"""Default wall-clock budget for a non-interactive run, in seconds.
|
|
145
|
+
|
|
146
|
+
``ALYSIS_RUN_BUDGET_SECONDS`` overrides
|
|
147
|
+
:data:`DEFAULT_RUN_BUDGET_SECONDS`. This sets only the *default* rung of
|
|
148
|
+
the existing precedence ladder (``--deadline-seconds`` >
|
|
149
|
+
``ALYSIS_RUN_DEADLINE_SECONDS`` > ``run_deadline_seconds`` config >
|
|
150
|
+
this), so nothing that already configured a budget changes behaviour.
|
|
151
|
+
"""
|
|
152
|
+
return _positive_float(
|
|
153
|
+
_env_value(RUN_BUDGET_SECONDS_ENV, environ),
|
|
154
|
+
DEFAULT_RUN_BUDGET_SECONDS,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def resolve_budget_grace_seconds(environ: Mapping[str, str] | None = None) -> float:
|
|
159
|
+
"""Grace window between the deadline passing and forced cancellation.
|
|
160
|
+
|
|
161
|
+
``ALYSIS_BUDGET_GRACE_SECONDS``; default
|
|
162
|
+
:data:`DEFAULT_BUDGET_GRACE_SECONDS`. Zero is legal and means "cancel the
|
|
163
|
+
moment the budget is gone".
|
|
164
|
+
"""
|
|
165
|
+
return _non_negative_float(
|
|
166
|
+
_env_value(BUDGET_GRACE_SECONDS_ENV, environ),
|
|
167
|
+
DEFAULT_BUDGET_GRACE_SECONDS,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def resolve_checkpoint_fraction(environ: Mapping[str, str] | None = None) -> float:
|
|
172
|
+
"""Budget fraction at which the no-progress checkpoint is evaluated.
|
|
173
|
+
|
|
174
|
+
``ALYSIS_BUDGET_CHECKPOINT_FRACTION``; default
|
|
175
|
+
:data:`DEFAULT_BUDGET_CHECKPOINT_FRACTION`. Values outside ``(0, 1]`` fall
|
|
176
|
+
back, so the checkpoint can never be armed at "already past the end".
|
|
177
|
+
"""
|
|
178
|
+
value = _positive_float(
|
|
179
|
+
_env_value(BUDGET_CHECKPOINT_FRACTION_ENV, environ),
|
|
180
|
+
DEFAULT_BUDGET_CHECKPOINT_FRACTION,
|
|
181
|
+
)
|
|
182
|
+
if value > 1.0:
|
|
183
|
+
return DEFAULT_BUDGET_CHECKPOINT_FRACTION
|
|
184
|
+
return value
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def exit_code_for_stop(
|
|
188
|
+
stop_reason: str | None,
|
|
189
|
+
*,
|
|
190
|
+
failure_exit_code: int = DEFAULT_FAILURE_EXIT_CODE,
|
|
191
|
+
) -> int:
|
|
192
|
+
"""Process exit code for a run terminating with ``stop_reason``.
|
|
193
|
+
|
|
194
|
+
The whole point of this table: a run-budget stop is a *normal* outcome and
|
|
195
|
+
must exit 0, while genuine errors keep their non-zero code. Callers pass
|
|
196
|
+
their own ``failure_exit_code`` so infrastructure failures can still use
|
|
197
|
+
``INFRASTRUCTURE_FAILURE_EXIT_CODE``.
|
|
198
|
+
"""
|
|
199
|
+
reason = str(stop_reason or "").strip().lower()
|
|
200
|
+
if reason in GRACEFUL_STOP_REASONS:
|
|
201
|
+
return BUDGET_STOP_EXIT_CODE
|
|
202
|
+
return int(failure_exit_code)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def is_budget_stop(stop_reason: str | None) -> bool:
|
|
206
|
+
return str(stop_reason or "").strip().lower() == STOP_REASON_RUN_BUDGET_EXHAUSTED
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def is_budget_cancellation(error: BaseException | None) -> bool:
|
|
210
|
+
"""True when ``error`` is a cancellation raised by the budget watchdog.
|
|
211
|
+
|
|
212
|
+
Distinguishes a watchdog stop from a user-requested cancellation, which
|
|
213
|
+
share an exception type. Matching on the ``reason`` attribute is what
|
|
214
|
+
``CooperativeCancellationError`` already exposes.
|
|
215
|
+
"""
|
|
216
|
+
if error is None:
|
|
217
|
+
return False
|
|
218
|
+
return is_budget_stop(getattr(error, "reason", None))
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class BudgetCancellationToken:
|
|
222
|
+
"""Cancellation token whose cancellation means "the run budget is gone".
|
|
223
|
+
|
|
224
|
+
Duck-types ``cancellation.EventCancellationToken``: ``is_cancelled`` plus
|
|
225
|
+
``throw_if_cancelled``. The error class is injected rather than imported so
|
|
226
|
+
this module keeps no package dependencies; production passes
|
|
227
|
+
``CooperativeCancellationError`` so every existing handler still catches it.
|
|
228
|
+
"""
|
|
229
|
+
|
|
230
|
+
def __init__(
|
|
231
|
+
self,
|
|
232
|
+
event: threading.Event,
|
|
233
|
+
*,
|
|
234
|
+
error_class: type[BaseException] | None = None,
|
|
235
|
+
reason: str = STOP_REASON_RUN_BUDGET_EXHAUSTED,
|
|
236
|
+
) -> None:
|
|
237
|
+
self._event = event
|
|
238
|
+
self._error_class: type[BaseException] = error_class or RunBudgetCancelled
|
|
239
|
+
self.reason = str(reason or STOP_REASON_RUN_BUDGET_EXHAUSTED)
|
|
240
|
+
|
|
241
|
+
@property
|
|
242
|
+
def is_cancelled(self) -> bool:
|
|
243
|
+
return self._event.is_set()
|
|
244
|
+
|
|
245
|
+
def throw_if_cancelled(self, reason: str | None = None) -> None:
|
|
246
|
+
"""Raise if cancelled, always attributing the stop to the budget.
|
|
247
|
+
|
|
248
|
+
The caller-supplied ``reason`` is ignored on purpose: the turn engine
|
|
249
|
+
passes the literal ``"cancelled_by_user"`` at every checkpoint, and
|
|
250
|
+
this token knows better why it fired. Preserving the real reason is
|
|
251
|
+
what lets the stop be finalized as a clean budget exit rather than a
|
|
252
|
+
user abort.
|
|
253
|
+
"""
|
|
254
|
+
_ = reason
|
|
255
|
+
if self.is_cancelled:
|
|
256
|
+
raise self._error_class(self.reason)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class BudgetWatchdog:
|
|
260
|
+
"""Daemon timer that trips a cancellation event at ``budget + grace``.
|
|
261
|
+
|
|
262
|
+
Armed once at run start and disarmed in a ``finally``. Firing sets a
|
|
263
|
+
:class:`threading.Event`, which is all a cooperative checkpoint or a
|
|
264
|
+
``wait``-style blocking call needs to unblock; the watchdog itself never
|
|
265
|
+
touches execution state, so it cannot corrupt a run that was about to
|
|
266
|
+
finish normally.
|
|
267
|
+
"""
|
|
268
|
+
|
|
269
|
+
def __init__(
|
|
270
|
+
self,
|
|
271
|
+
*,
|
|
272
|
+
budget_seconds: float,
|
|
273
|
+
grace_seconds: float = DEFAULT_BUDGET_GRACE_SECONDS,
|
|
274
|
+
on_fire: Callable[[], None] | None = None,
|
|
275
|
+
event: threading.Event | None = None,
|
|
276
|
+
timer_factory: Callable[..., Any] = threading.Timer,
|
|
277
|
+
) -> None:
|
|
278
|
+
self.budget_seconds = max(0.0, float(budget_seconds))
|
|
279
|
+
self.grace_seconds = max(0.0, float(grace_seconds))
|
|
280
|
+
self._on_fire = on_fire
|
|
281
|
+
self._event = event if event is not None else threading.Event()
|
|
282
|
+
self._timer_factory = timer_factory
|
|
283
|
+
self._timer: Any | None = None
|
|
284
|
+
self._lock = threading.Lock()
|
|
285
|
+
|
|
286
|
+
@property
|
|
287
|
+
def event(self) -> threading.Event:
|
|
288
|
+
return self._event
|
|
289
|
+
|
|
290
|
+
@property
|
|
291
|
+
def fired(self) -> bool:
|
|
292
|
+
return self._event.is_set()
|
|
293
|
+
|
|
294
|
+
@property
|
|
295
|
+
def armed(self) -> bool:
|
|
296
|
+
return self._timer is not None
|
|
297
|
+
|
|
298
|
+
def fire_delay_seconds(self) -> float:
|
|
299
|
+
"""Seconds from arming to firing: the whole budget plus the grace."""
|
|
300
|
+
return self.budget_seconds + self.grace_seconds
|
|
301
|
+
|
|
302
|
+
def arm(self) -> BudgetWatchdog:
|
|
303
|
+
with self._lock:
|
|
304
|
+
if self._timer is not None:
|
|
305
|
+
return self
|
|
306
|
+
timer = self._timer_factory(self.fire_delay_seconds(), self._fire)
|
|
307
|
+
# Daemon: a watchdog must never be the reason a process refuses to
|
|
308
|
+
# exit once the run has finished on its own.
|
|
309
|
+
try:
|
|
310
|
+
timer.daemon = True
|
|
311
|
+
except AttributeError: # pragma: no cover - injected fakes may differ
|
|
312
|
+
pass
|
|
313
|
+
self._timer = timer
|
|
314
|
+
timer.start()
|
|
315
|
+
return self
|
|
316
|
+
|
|
317
|
+
def disarm(self) -> None:
|
|
318
|
+
with self._lock:
|
|
319
|
+
timer, self._timer = self._timer, None
|
|
320
|
+
if timer is not None:
|
|
321
|
+
try:
|
|
322
|
+
timer.cancel()
|
|
323
|
+
except Exception: # noqa: BLE001 - teardown must never raise
|
|
324
|
+
pass
|
|
325
|
+
|
|
326
|
+
def _fire(self) -> None:
|
|
327
|
+
self._event.set()
|
|
328
|
+
if self._on_fire is None:
|
|
329
|
+
return
|
|
330
|
+
try:
|
|
331
|
+
self._on_fire()
|
|
332
|
+
except Exception: # noqa: BLE001 - a telemetry failure must not kill the timer thread
|
|
333
|
+
pass
|
|
334
|
+
|
|
335
|
+
def __enter__(self) -> BudgetWatchdog:
|
|
336
|
+
return self.arm()
|
|
337
|
+
|
|
338
|
+
def __exit__(self, *_exc: object) -> None:
|
|
339
|
+
self.disarm()
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
class ProgressCheckpoint:
|
|
343
|
+
"""One-shot "no material progress yet" trigger at a fraction of budget.
|
|
344
|
+
|
|
345
|
+
Material progress is file modifications or verification attempts -- the two
|
|
346
|
+
counters the runtime summary already reports. Both are monotonic, so a run
|
|
347
|
+
that has done something real can never trip this later.
|
|
348
|
+
"""
|
|
349
|
+
|
|
350
|
+
def __init__(
|
|
351
|
+
self,
|
|
352
|
+
*,
|
|
353
|
+
fraction: float | None = None,
|
|
354
|
+
environ: Mapping[str, str] | None = None,
|
|
355
|
+
) -> None:
|
|
356
|
+
self.fraction = (
|
|
357
|
+
resolve_checkpoint_fraction(environ) if fraction is None else float(fraction)
|
|
358
|
+
)
|
|
359
|
+
self._fired = False
|
|
360
|
+
|
|
361
|
+
@property
|
|
362
|
+
def fired(self) -> bool:
|
|
363
|
+
return self._fired
|
|
364
|
+
|
|
365
|
+
def check(
|
|
366
|
+
self,
|
|
367
|
+
*,
|
|
368
|
+
elapsed_fraction: float | None,
|
|
369
|
+
material_edit_count: int,
|
|
370
|
+
verification_attempt_count: int,
|
|
371
|
+
) -> bool:
|
|
372
|
+
"""Return ``True`` exactly once, the first time the checkpoint fails.
|
|
373
|
+
|
|
374
|
+
``elapsed_fraction`` is ``None`` when no finite budget applies, in
|
|
375
|
+
which case there is no checkpoint to miss.
|
|
376
|
+
"""
|
|
377
|
+
if self._fired:
|
|
378
|
+
return False
|
|
379
|
+
if elapsed_fraction is None:
|
|
380
|
+
return False
|
|
381
|
+
try:
|
|
382
|
+
fraction = float(elapsed_fraction)
|
|
383
|
+
except (TypeError, ValueError):
|
|
384
|
+
return False
|
|
385
|
+
if not math.isfinite(fraction) or fraction < self.fraction:
|
|
386
|
+
return False
|
|
387
|
+
if int(material_edit_count or 0) > 0 or int(verification_attempt_count or 0) > 0:
|
|
388
|
+
return False
|
|
389
|
+
self._fired = True
|
|
390
|
+
return True
|