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
|
@@ -0,0 +1,762 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import codecs
|
|
4
|
+
import contextlib
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import signal
|
|
8
|
+
import subprocess
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
import uuid
|
|
12
|
+
from collections import deque
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import IO, Literal
|
|
17
|
+
|
|
18
|
+
from .background_runner import (
|
|
19
|
+
BackgroundProcessSpawn,
|
|
20
|
+
BackgroundShellRunner,
|
|
21
|
+
BackgroundTerminationMode,
|
|
22
|
+
)
|
|
23
|
+
from .sandbox_settings import ShellSandboxSettings
|
|
24
|
+
from .terminal_ownership import TerminalOwnershipHandle, TerminalOwnershipLedger
|
|
25
|
+
|
|
26
|
+
_LOGGER = logging.getLogger("alysis_code.terminal_manager")
|
|
27
|
+
_STREAM_READ_CHUNK_SIZE = 8192
|
|
28
|
+
_POST_SIGKILL_WAIT_TIMEOUT_S = 1.0
|
|
29
|
+
|
|
30
|
+
ProcessStatus = Literal["running", "exited", "killed", "failed"]
|
|
31
|
+
OutputStream = Literal["stdout", "stderr"]
|
|
32
|
+
WaitUntil = Literal["output_available", "process_exited", "either"]
|
|
33
|
+
_TERMINAL_STATUSES: set[ProcessStatus] = {"exited", "killed", "failed"}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class TerminalLimitError(RuntimeError):
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class OutputLine:
|
|
42
|
+
seq: int
|
|
43
|
+
stream: OutputStream
|
|
44
|
+
text: str
|
|
45
|
+
ts: float
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class ProcessOutputSnapshot:
|
|
50
|
+
process_id: str
|
|
51
|
+
status: ProcessStatus
|
|
52
|
+
exit_code: int | None
|
|
53
|
+
failure_reason: str | None
|
|
54
|
+
lines: tuple[OutputLine, ...]
|
|
55
|
+
next_seq: int
|
|
56
|
+
dropped_lines: int
|
|
57
|
+
started_at_wall: float
|
|
58
|
+
runtime_s: float
|
|
59
|
+
total_bytes: int
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class ProcessSummary:
|
|
64
|
+
process_id: str
|
|
65
|
+
cmd: str
|
|
66
|
+
cwd: Path
|
|
67
|
+
status: ProcessStatus
|
|
68
|
+
exit_code: int | None
|
|
69
|
+
runtime_s: float
|
|
70
|
+
started_at_wall: float
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class _OutputBuffer:
|
|
74
|
+
def __init__(self, *, max_lines: int, max_bytes: int) -> None:
|
|
75
|
+
if max_lines <= 0:
|
|
76
|
+
raise ValueError("max_lines must be positive")
|
|
77
|
+
if max_bytes <= 0:
|
|
78
|
+
raise ValueError("max_bytes must be positive")
|
|
79
|
+
self._lines: deque[OutputLine] = deque(maxlen=max_lines)
|
|
80
|
+
self._max_lines = max_lines
|
|
81
|
+
self._max_bytes = max_bytes
|
|
82
|
+
self._current_bytes = 0
|
|
83
|
+
self._total_bytes = 0
|
|
84
|
+
self._next_seq = 1
|
|
85
|
+
self._dropped_lines = 0
|
|
86
|
+
self._lock = threading.Lock()
|
|
87
|
+
|
|
88
|
+
def append_line(self, *, stream: OutputStream, text: str) -> None:
|
|
89
|
+
encoded_len = len(text.encode("utf-8", errors="replace"))
|
|
90
|
+
with self._lock:
|
|
91
|
+
if len(self._lines) >= self._max_lines:
|
|
92
|
+
self._drop_oldest_locked()
|
|
93
|
+
line = OutputLine(
|
|
94
|
+
seq=self._next_seq,
|
|
95
|
+
stream=stream,
|
|
96
|
+
text=text,
|
|
97
|
+
ts=time.time(),
|
|
98
|
+
)
|
|
99
|
+
self._next_seq += 1
|
|
100
|
+
self._lines.append(line)
|
|
101
|
+
self._current_bytes += encoded_len
|
|
102
|
+
self._total_bytes += encoded_len
|
|
103
|
+
while self._current_bytes > self._max_bytes and self._lines:
|
|
104
|
+
self._drop_oldest_locked()
|
|
105
|
+
|
|
106
|
+
def snapshot_since(self, since: int) -> tuple[tuple[OutputLine, ...], int, int, int]:
|
|
107
|
+
with self._lock:
|
|
108
|
+
lines = tuple(line for line in self._lines if line.seq > since)
|
|
109
|
+
last_seq = self._next_seq - 1
|
|
110
|
+
return lines, last_seq, self._dropped_lines, self._total_bytes
|
|
111
|
+
|
|
112
|
+
def _drop_oldest_locked(self) -> None:
|
|
113
|
+
dropped = self._lines.popleft()
|
|
114
|
+
self._current_bytes -= len(dropped.text.encode("utf-8", errors="replace"))
|
|
115
|
+
self._dropped_lines += 1
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class BackgroundProcess:
|
|
119
|
+
def __init__(
|
|
120
|
+
self,
|
|
121
|
+
*,
|
|
122
|
+
spawn: BackgroundProcessSpawn,
|
|
123
|
+
cmd: str,
|
|
124
|
+
cwd: Path,
|
|
125
|
+
output_max_lines: int,
|
|
126
|
+
output_max_bytes: int,
|
|
127
|
+
ownership_ledger: TerminalOwnershipLedger | None = None,
|
|
128
|
+
) -> None:
|
|
129
|
+
self.process_id = uuid.uuid4().hex[:12]
|
|
130
|
+
self.cmd = cmd
|
|
131
|
+
self.cwd = cwd
|
|
132
|
+
self.started_at = time.perf_counter()
|
|
133
|
+
self.created_at_wall = time.time()
|
|
134
|
+
self.status: ProcessStatus = "running"
|
|
135
|
+
self.exit_code: int | None = None
|
|
136
|
+
self.failure_reason: str | None = None
|
|
137
|
+
self.started_argv = spawn.started_argv
|
|
138
|
+
|
|
139
|
+
self._popen = spawn.popen
|
|
140
|
+
self._cleanup = spawn.cleanup
|
|
141
|
+
self._termination_mode: BackgroundTerminationMode = spawn.termination_mode
|
|
142
|
+
self._output = _OutputBuffer(max_lines=output_max_lines, max_bytes=output_max_bytes)
|
|
143
|
+
self._condition = threading.Condition()
|
|
144
|
+
self._cleanup_lock = threading.Lock()
|
|
145
|
+
self._kill_lock = threading.Lock()
|
|
146
|
+
self._cleanup_called = False
|
|
147
|
+
self._kill_requested = False
|
|
148
|
+
self._failure_requested = False
|
|
149
|
+
self._ended_at: float | None = None
|
|
150
|
+
self._posix_pgid = (
|
|
151
|
+
_resolve_posix_process_group_id(self._popen)
|
|
152
|
+
if self._termination_mode == "process_group"
|
|
153
|
+
else None
|
|
154
|
+
)
|
|
155
|
+
self._ownership_ledger = ownership_ledger
|
|
156
|
+
self._ownership_handle: TerminalOwnershipHandle | None = None
|
|
157
|
+
if ownership_ledger is not None:
|
|
158
|
+
self._ownership_handle = ownership_ledger.track(
|
|
159
|
+
child_pid=self._popen.pid,
|
|
160
|
+
termination_mode=self._termination_mode,
|
|
161
|
+
posix_pgid=self._posix_pgid,
|
|
162
|
+
cleanup_kind=spawn.orphan_cleanup_kind,
|
|
163
|
+
cleanup_id=spawn.orphan_cleanup_id,
|
|
164
|
+
)
|
|
165
|
+
self._stdout_done = threading.Event()
|
|
166
|
+
self._stderr_done = threading.Event()
|
|
167
|
+
|
|
168
|
+
self._stdout_reader = threading.Thread(
|
|
169
|
+
target=self._read_stream,
|
|
170
|
+
name=f"alysis-bg-{self.process_id}-stdout",
|
|
171
|
+
args=("stdout", self._popen.stdout, self._stdout_done),
|
|
172
|
+
daemon=True,
|
|
173
|
+
)
|
|
174
|
+
self._stderr_reader = threading.Thread(
|
|
175
|
+
target=self._read_stream,
|
|
176
|
+
name=f"alysis-bg-{self.process_id}-stderr",
|
|
177
|
+
args=("stderr", self._popen.stderr, self._stderr_done),
|
|
178
|
+
daemon=True,
|
|
179
|
+
)
|
|
180
|
+
self._waiter = threading.Thread(
|
|
181
|
+
target=self._wait_for_process,
|
|
182
|
+
name=f"alysis-bg-{self.process_id}-waiter",
|
|
183
|
+
daemon=True,
|
|
184
|
+
)
|
|
185
|
+
self._stdout_reader.start()
|
|
186
|
+
self._stderr_reader.start()
|
|
187
|
+
self._waiter.start()
|
|
188
|
+
|
|
189
|
+
def read(self, since: int = 0) -> ProcessOutputSnapshot:
|
|
190
|
+
lines, next_seq, dropped_lines, total_bytes = self._output.snapshot_since(since)
|
|
191
|
+
with self._condition:
|
|
192
|
+
status = self.status
|
|
193
|
+
exit_code = self.exit_code
|
|
194
|
+
failure_reason = self.failure_reason
|
|
195
|
+
runtime_s = self._runtime_s_locked()
|
|
196
|
+
return ProcessOutputSnapshot(
|
|
197
|
+
process_id=self.process_id,
|
|
198
|
+
status=status,
|
|
199
|
+
exit_code=exit_code,
|
|
200
|
+
failure_reason=failure_reason,
|
|
201
|
+
lines=lines,
|
|
202
|
+
next_seq=next_seq,
|
|
203
|
+
dropped_lines=dropped_lines,
|
|
204
|
+
started_at_wall=self.created_at_wall,
|
|
205
|
+
runtime_s=runtime_s,
|
|
206
|
+
total_bytes=total_bytes,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
def wait_for_output(
|
|
210
|
+
self,
|
|
211
|
+
*,
|
|
212
|
+
since: int = 0,
|
|
213
|
+
timeout_s: float = 0.0,
|
|
214
|
+
until: WaitUntil = "either",
|
|
215
|
+
) -> tuple[ProcessOutputSnapshot, bool]:
|
|
216
|
+
if since < 0:
|
|
217
|
+
raise ValueError("since must be non-negative")
|
|
218
|
+
if until not in {"output_available", "process_exited", "either"}:
|
|
219
|
+
raise ValueError(f"unsupported wait condition: {until}")
|
|
220
|
+
deadline = time.perf_counter() + max(0.0, float(timeout_s))
|
|
221
|
+
while True:
|
|
222
|
+
snapshot = self.read(since)
|
|
223
|
+
has_output = bool(snapshot.lines)
|
|
224
|
+
exited = snapshot.status in _TERMINAL_STATUSES
|
|
225
|
+
if until == "output_available" and has_output:
|
|
226
|
+
return snapshot, False
|
|
227
|
+
if until == "process_exited" and exited:
|
|
228
|
+
return snapshot, False
|
|
229
|
+
if until == "either" and (has_output or exited):
|
|
230
|
+
return snapshot, False
|
|
231
|
+
remaining = deadline - time.perf_counter()
|
|
232
|
+
if remaining <= 0:
|
|
233
|
+
return snapshot, True
|
|
234
|
+
with self._condition:
|
|
235
|
+
self._condition.wait(remaining)
|
|
236
|
+
|
|
237
|
+
def kill(self, timeout_s: float) -> ProcessOutputSnapshot:
|
|
238
|
+
with self._kill_lock:
|
|
239
|
+
with self._condition:
|
|
240
|
+
if self._is_terminal_locked():
|
|
241
|
+
already_terminal = True
|
|
242
|
+
else:
|
|
243
|
+
already_terminal = False
|
|
244
|
+
self._kill_requested = True
|
|
245
|
+
if already_terminal:
|
|
246
|
+
return self.read()
|
|
247
|
+
|
|
248
|
+
self._send_terminate()
|
|
249
|
+
if not self.wait_for_exit(timeout_s):
|
|
250
|
+
self._send_kill()
|
|
251
|
+
self.wait_for_exit(_POST_SIGKILL_WAIT_TIMEOUT_S)
|
|
252
|
+
return self.read()
|
|
253
|
+
|
|
254
|
+
def wait_for_exit(self, timeout_s: float | None) -> bool:
|
|
255
|
+
deadline = None if timeout_s is None else time.perf_counter() + max(timeout_s, 0.0)
|
|
256
|
+
with self._condition:
|
|
257
|
+
while not self._is_terminal_locked():
|
|
258
|
+
if deadline is None:
|
|
259
|
+
self._condition.wait()
|
|
260
|
+
continue
|
|
261
|
+
remaining = deadline - time.perf_counter()
|
|
262
|
+
if remaining <= 0:
|
|
263
|
+
return False
|
|
264
|
+
self._condition.wait(remaining)
|
|
265
|
+
return True
|
|
266
|
+
|
|
267
|
+
def is_terminal(self) -> bool:
|
|
268
|
+
with self._condition:
|
|
269
|
+
return self._is_terminal_locked()
|
|
270
|
+
|
|
271
|
+
def is_terminal_older_than(self, older_than_s: float) -> bool:
|
|
272
|
+
with self._condition:
|
|
273
|
+
if not self._is_terminal_locked() or self._ended_at is None:
|
|
274
|
+
return False
|
|
275
|
+
return time.perf_counter() - self._ended_at >= older_than_s
|
|
276
|
+
|
|
277
|
+
def summary(self) -> ProcessSummary:
|
|
278
|
+
with self._condition:
|
|
279
|
+
return ProcessSummary(
|
|
280
|
+
process_id=self.process_id,
|
|
281
|
+
cmd=_truncate_command(self.cmd),
|
|
282
|
+
cwd=self.cwd,
|
|
283
|
+
status=self.status,
|
|
284
|
+
exit_code=self.exit_code,
|
|
285
|
+
runtime_s=self._runtime_s_locked(),
|
|
286
|
+
started_at_wall=self.created_at_wall,
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
def _read_stream(
|
|
290
|
+
self,
|
|
291
|
+
stream_name: OutputStream,
|
|
292
|
+
stream: IO[bytes] | None,
|
|
293
|
+
done: threading.Event,
|
|
294
|
+
) -> None:
|
|
295
|
+
if stream is None:
|
|
296
|
+
done.set()
|
|
297
|
+
return
|
|
298
|
+
pending = ""
|
|
299
|
+
decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
|
|
300
|
+
try:
|
|
301
|
+
while True:
|
|
302
|
+
chunk = os.read(stream.fileno(), _STREAM_READ_CHUNK_SIZE)
|
|
303
|
+
if not chunk:
|
|
304
|
+
break
|
|
305
|
+
pending = self._append_decoded_chunk(
|
|
306
|
+
stream_name=stream_name,
|
|
307
|
+
pending=pending,
|
|
308
|
+
decoded=decoder.decode(chunk),
|
|
309
|
+
)
|
|
310
|
+
pending = self._append_decoded_chunk(
|
|
311
|
+
stream_name=stream_name,
|
|
312
|
+
pending=pending,
|
|
313
|
+
decoded=decoder.decode(b"", final=True),
|
|
314
|
+
)
|
|
315
|
+
if pending:
|
|
316
|
+
self._append_output_line(stream=stream_name, text=pending)
|
|
317
|
+
except OSError as exc:
|
|
318
|
+
if not self._kill_requested:
|
|
319
|
+
self._fail_reader_and_request_kill(f"{stream_name} reader failed: {exc}")
|
|
320
|
+
_LOGGER.exception(
|
|
321
|
+
"Background process %s %s reader failed",
|
|
322
|
+
self.process_id,
|
|
323
|
+
stream_name,
|
|
324
|
+
)
|
|
325
|
+
except Exception as exc: # noqa: BLE001
|
|
326
|
+
# Reader threads must never die silently; surface the failure in process state.
|
|
327
|
+
self._fail_reader_and_request_kill(f"{stream_name} reader failed: {exc}")
|
|
328
|
+
_LOGGER.exception(
|
|
329
|
+
"Background process %s %s reader crashed",
|
|
330
|
+
self.process_id,
|
|
331
|
+
stream_name,
|
|
332
|
+
)
|
|
333
|
+
finally:
|
|
334
|
+
with contextlib.suppress(OSError):
|
|
335
|
+
stream.close()
|
|
336
|
+
done.set()
|
|
337
|
+
|
|
338
|
+
def _append_decoded_chunk(
|
|
339
|
+
self,
|
|
340
|
+
*,
|
|
341
|
+
stream_name: OutputStream,
|
|
342
|
+
pending: str,
|
|
343
|
+
decoded: str,
|
|
344
|
+
) -> str:
|
|
345
|
+
pending += decoded
|
|
346
|
+
while True:
|
|
347
|
+
newline_idx = pending.find("\n")
|
|
348
|
+
if newline_idx < 0:
|
|
349
|
+
return pending
|
|
350
|
+
line = pending[: newline_idx + 1]
|
|
351
|
+
pending = pending[newline_idx + 1 :]
|
|
352
|
+
self._append_output_line(stream=stream_name, text=line)
|
|
353
|
+
|
|
354
|
+
def _append_output_line(self, *, stream: OutputStream, text: str) -> None:
|
|
355
|
+
self._output.append_line(stream=stream, text=text)
|
|
356
|
+
with self._condition:
|
|
357
|
+
self._condition.notify_all()
|
|
358
|
+
|
|
359
|
+
def _wait_for_process(self) -> None:
|
|
360
|
+
try:
|
|
361
|
+
exit_code = self._popen.wait()
|
|
362
|
+
except Exception as exc: # noqa: BLE001
|
|
363
|
+
# The waiter is the only code path allowed to reap the child process.
|
|
364
|
+
cleanup_reason = self._run_cleanup_once()
|
|
365
|
+
reason = f"process wait failed: {exc}"
|
|
366
|
+
if cleanup_reason:
|
|
367
|
+
reason = f"{reason}; {cleanup_reason}"
|
|
368
|
+
_LOGGER.exception("Background process %s waiter failed", self.process_id)
|
|
369
|
+
self._publish_terminal_status(
|
|
370
|
+
status="failed",
|
|
371
|
+
exit_code=None,
|
|
372
|
+
failure_reason=reason,
|
|
373
|
+
)
|
|
374
|
+
return
|
|
375
|
+
|
|
376
|
+
self._stdout_done.wait()
|
|
377
|
+
self._stderr_done.wait()
|
|
378
|
+
cleanup_reason = self._run_cleanup_once()
|
|
379
|
+
if cleanup_reason is None and self._ownership_ledger is not None:
|
|
380
|
+
self._ownership_ledger.release(self._ownership_handle)
|
|
381
|
+
|
|
382
|
+
with self._condition:
|
|
383
|
+
self.exit_code = exit_code
|
|
384
|
+
if cleanup_reason:
|
|
385
|
+
self.failure_reason = cleanup_reason
|
|
386
|
+
self._failure_requested = True
|
|
387
|
+
if self._failure_requested:
|
|
388
|
+
self.status = "failed"
|
|
389
|
+
else:
|
|
390
|
+
self.status = "killed" if self._kill_requested else "exited"
|
|
391
|
+
if self._ended_at is None:
|
|
392
|
+
self._ended_at = time.perf_counter()
|
|
393
|
+
self._condition.notify_all()
|
|
394
|
+
|
|
395
|
+
def _fail_reader_and_request_kill(self, reason: str) -> None:
|
|
396
|
+
with self._condition:
|
|
397
|
+
self.failure_reason = reason
|
|
398
|
+
self._failure_requested = True
|
|
399
|
+
if self.status == "running":
|
|
400
|
+
self._kill_requested = True
|
|
401
|
+
else:
|
|
402
|
+
self._condition.notify_all()
|
|
403
|
+
return
|
|
404
|
+
self._send_kill()
|
|
405
|
+
|
|
406
|
+
def _send_terminate(self) -> None:
|
|
407
|
+
if self._termination_mode == "direct":
|
|
408
|
+
self._run_cleanup_and_record_failure()
|
|
409
|
+
_terminate_direct_popen(self._popen)
|
|
410
|
+
return
|
|
411
|
+
|
|
412
|
+
if os.name == "nt": # pragma: no cover - exercised on Windows
|
|
413
|
+
with contextlib.suppress(ProcessLookupError):
|
|
414
|
+
self._popen.send_signal(signal.CTRL_BREAK_EVENT)
|
|
415
|
+
return
|
|
416
|
+
|
|
417
|
+
if self._posix_pgid is None:
|
|
418
|
+
with contextlib.suppress(ProcessLookupError):
|
|
419
|
+
self._popen.terminate()
|
|
420
|
+
return
|
|
421
|
+
_signal_posix_process_group(self._posix_pgid, signal.SIGTERM)
|
|
422
|
+
|
|
423
|
+
def _send_kill(self) -> None:
|
|
424
|
+
if self._termination_mode == "direct":
|
|
425
|
+
self._run_cleanup_and_record_failure()
|
|
426
|
+
_kill_direct_popen(self._popen)
|
|
427
|
+
return
|
|
428
|
+
|
|
429
|
+
if os.name == "nt": # pragma: no cover - exercised on Windows
|
|
430
|
+
_kill_windows_process_tree(self._popen)
|
|
431
|
+
return
|
|
432
|
+
|
|
433
|
+
if self._posix_pgid is None:
|
|
434
|
+
with contextlib.suppress(ProcessLookupError):
|
|
435
|
+
self._popen.kill()
|
|
436
|
+
return
|
|
437
|
+
_signal_posix_process_group(self._posix_pgid, signal.SIGKILL)
|
|
438
|
+
|
|
439
|
+
def _run_cleanup_once(self) -> str | None:
|
|
440
|
+
cleanup: Callable[[], None] | None = None
|
|
441
|
+
with self._cleanup_lock:
|
|
442
|
+
if self._cleanup_called:
|
|
443
|
+
return None
|
|
444
|
+
self._cleanup_called = True
|
|
445
|
+
cleanup = self._cleanup
|
|
446
|
+
try:
|
|
447
|
+
cleanup()
|
|
448
|
+
return None
|
|
449
|
+
except Exception as exc: # noqa: BLE001
|
|
450
|
+
# Cleanup failures are preserved on the process and logged for operators.
|
|
451
|
+
_LOGGER.exception("Background process %s cleanup failed", self.process_id)
|
|
452
|
+
return f"process cleanup failed: {exc}"
|
|
453
|
+
|
|
454
|
+
def _run_cleanup_and_record_failure(self) -> None:
|
|
455
|
+
cleanup_reason = self._run_cleanup_once()
|
|
456
|
+
if cleanup_reason is None:
|
|
457
|
+
return
|
|
458
|
+
with self._condition:
|
|
459
|
+
self.failure_reason = cleanup_reason
|
|
460
|
+
self._failure_requested = True
|
|
461
|
+
self._condition.notify_all()
|
|
462
|
+
|
|
463
|
+
def _publish_terminal_status(
|
|
464
|
+
self,
|
|
465
|
+
*,
|
|
466
|
+
status: ProcessStatus,
|
|
467
|
+
exit_code: int | None,
|
|
468
|
+
failure_reason: str | None,
|
|
469
|
+
) -> None:
|
|
470
|
+
with self._condition:
|
|
471
|
+
self.status = status
|
|
472
|
+
self.exit_code = exit_code
|
|
473
|
+
self.failure_reason = failure_reason
|
|
474
|
+
if status == "failed":
|
|
475
|
+
self._failure_requested = True
|
|
476
|
+
if self._ended_at is None:
|
|
477
|
+
self._ended_at = time.perf_counter()
|
|
478
|
+
self._condition.notify_all()
|
|
479
|
+
|
|
480
|
+
def _is_terminal_locked(self) -> bool:
|
|
481
|
+
return self.status in _TERMINAL_STATUSES
|
|
482
|
+
|
|
483
|
+
def _runtime_s_locked(self) -> float:
|
|
484
|
+
end = self._ended_at if self._ended_at is not None else time.perf_counter()
|
|
485
|
+
return max(0.0, end - self.started_at)
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
class TerminalManager:
|
|
489
|
+
def __init__(
|
|
490
|
+
self,
|
|
491
|
+
*,
|
|
492
|
+
runner: BackgroundShellRunner,
|
|
493
|
+
settings: ShellSandboxSettings,
|
|
494
|
+
ownership_ledger: TerminalOwnershipLedger | None = None,
|
|
495
|
+
) -> None:
|
|
496
|
+
self._runner = runner
|
|
497
|
+
self._settings = settings
|
|
498
|
+
self._ownership_ledger = ownership_ledger or TerminalOwnershipLedger()
|
|
499
|
+
self._ownership_ledger.scavenge_stale()
|
|
500
|
+
self._processes: dict[str, BackgroundProcess] = {}
|
|
501
|
+
self._pending_starts = 0
|
|
502
|
+
self._lock = threading.Lock()
|
|
503
|
+
|
|
504
|
+
def start(
|
|
505
|
+
self,
|
|
506
|
+
*,
|
|
507
|
+
cmd: str,
|
|
508
|
+
cwd: Path,
|
|
509
|
+
root: Path,
|
|
510
|
+
env_overrides: dict[str, str] | None = None,
|
|
511
|
+
) -> str:
|
|
512
|
+
with self._lock:
|
|
513
|
+
if not cmd.strip():
|
|
514
|
+
raise ValueError("cmd cannot be empty")
|
|
515
|
+
|
|
516
|
+
root_abs = root.resolve()
|
|
517
|
+
cwd_abs = cwd.resolve()
|
|
518
|
+
try:
|
|
519
|
+
cwd_abs.relative_to(root_abs)
|
|
520
|
+
except ValueError as exc:
|
|
521
|
+
raise ValueError(f"cwd escapes root: {cwd}") from exc
|
|
522
|
+
|
|
523
|
+
running_count = sum(
|
|
524
|
+
1 for process in self._processes.values() if not process.is_terminal()
|
|
525
|
+
)
|
|
526
|
+
if running_count + self._pending_starts >= self._settings.background_max_concurrent:
|
|
527
|
+
raise TerminalLimitError(
|
|
528
|
+
"Maximum background process count reached "
|
|
529
|
+
f"({self._settings.background_max_concurrent})."
|
|
530
|
+
)
|
|
531
|
+
self._pending_starts += 1
|
|
532
|
+
|
|
533
|
+
spawn: BackgroundProcessSpawn | None = None
|
|
534
|
+
pending_released = False
|
|
535
|
+
try:
|
|
536
|
+
spawn = self._runner.start(
|
|
537
|
+
root=root_abs,
|
|
538
|
+
cwd=cwd_abs,
|
|
539
|
+
cmd=cmd,
|
|
540
|
+
env_overrides=env_overrides,
|
|
541
|
+
)
|
|
542
|
+
try:
|
|
543
|
+
process = BackgroundProcess(
|
|
544
|
+
spawn=spawn,
|
|
545
|
+
cmd=cmd,
|
|
546
|
+
cwd=cwd_abs,
|
|
547
|
+
output_max_lines=self._settings.background_output_max_lines,
|
|
548
|
+
output_max_bytes=self._settings.background_output_max_bytes,
|
|
549
|
+
ownership_ledger=self._ownership_ledger,
|
|
550
|
+
)
|
|
551
|
+
except BaseException:
|
|
552
|
+
with self._lock:
|
|
553
|
+
self._pending_starts -= 1
|
|
554
|
+
pending_released = True
|
|
555
|
+
_terminate_spawned_process(spawn)
|
|
556
|
+
raise
|
|
557
|
+
with self._lock:
|
|
558
|
+
self._processes[process.process_id] = process
|
|
559
|
+
self._pending_starts -= 1
|
|
560
|
+
pending_released = True
|
|
561
|
+
return process.process_id
|
|
562
|
+
except BaseException:
|
|
563
|
+
if not pending_released:
|
|
564
|
+
with self._lock:
|
|
565
|
+
self._pending_starts -= 1
|
|
566
|
+
raise
|
|
567
|
+
|
|
568
|
+
def read(self, process_id: str, *, since: int = 0) -> ProcessOutputSnapshot:
|
|
569
|
+
return self._get_process(process_id).read(since)
|
|
570
|
+
|
|
571
|
+
def wait_for_output(
|
|
572
|
+
self,
|
|
573
|
+
process_id: str,
|
|
574
|
+
*,
|
|
575
|
+
since: int = 0,
|
|
576
|
+
timeout_s: float = 0.0,
|
|
577
|
+
until: WaitUntil = "either",
|
|
578
|
+
) -> tuple[ProcessOutputSnapshot, bool]:
|
|
579
|
+
return self._get_process(process_id).wait_for_output(
|
|
580
|
+
since=since,
|
|
581
|
+
timeout_s=timeout_s,
|
|
582
|
+
until=until,
|
|
583
|
+
)
|
|
584
|
+
|
|
585
|
+
def kill(self, process_id: str) -> ProcessOutputSnapshot:
|
|
586
|
+
process = self._get_process(process_id)
|
|
587
|
+
return process.kill(self._settings.background_kill_timeout_s)
|
|
588
|
+
|
|
589
|
+
def list(self) -> tuple[ProcessSummary, ...]:
|
|
590
|
+
with self._lock:
|
|
591
|
+
summaries = [process.summary() for process in self._processes.values()]
|
|
592
|
+
return tuple(sorted(summaries, key=lambda summary: summary.started_at_wall))
|
|
593
|
+
|
|
594
|
+
def shutdown_all(self, *, kill_timeout_s: float | None = None) -> None:
|
|
595
|
+
timeout_s = (
|
|
596
|
+
self._settings.background_kill_timeout_s if kill_timeout_s is None else kill_timeout_s
|
|
597
|
+
)
|
|
598
|
+
with self._lock:
|
|
599
|
+
processes = tuple(self._processes.values())
|
|
600
|
+
for process in processes:
|
|
601
|
+
if process.is_terminal():
|
|
602
|
+
continue
|
|
603
|
+
try:
|
|
604
|
+
process.kill(timeout_s)
|
|
605
|
+
except Exception: # noqa: BLE001
|
|
606
|
+
# Shutdown is best-effort across independent child processes.
|
|
607
|
+
_LOGGER.exception(
|
|
608
|
+
"Failed to shut down background process %s",
|
|
609
|
+
process.process_id,
|
|
610
|
+
)
|
|
611
|
+
|
|
612
|
+
def prune(self, *, older_than_s: float = 600.0) -> None:
|
|
613
|
+
with self._lock:
|
|
614
|
+
stale_ids = [
|
|
615
|
+
process_id
|
|
616
|
+
for process_id, process in self._processes.items()
|
|
617
|
+
if process.is_terminal_older_than(older_than_s)
|
|
618
|
+
]
|
|
619
|
+
for process_id in stale_ids:
|
|
620
|
+
del self._processes[process_id]
|
|
621
|
+
|
|
622
|
+
def _get_process(self, process_id: str) -> BackgroundProcess:
|
|
623
|
+
with self._lock:
|
|
624
|
+
return self._processes[process_id]
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def _truncate_command(cmd: str) -> str:
|
|
628
|
+
if len(cmd) <= 120:
|
|
629
|
+
return cmd
|
|
630
|
+
return f"{cmd[:117]}..."
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def _resolve_posix_process_group_id(popen: subprocess.Popen[bytes]) -> int | None:
|
|
634
|
+
if os.name == "nt": # pragma: no cover - exercised on Windows
|
|
635
|
+
return None
|
|
636
|
+
pid = popen.pid
|
|
637
|
+
if pid <= 0:
|
|
638
|
+
return None
|
|
639
|
+
try:
|
|
640
|
+
return os.getpgid(pid)
|
|
641
|
+
except ProcessLookupError:
|
|
642
|
+
return pid
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def _signal_posix_process_group(pgid: int, signum: int) -> bool:
|
|
646
|
+
try:
|
|
647
|
+
os.killpg(pgid, signum)
|
|
648
|
+
except ProcessLookupError:
|
|
649
|
+
return True
|
|
650
|
+
except OSError:
|
|
651
|
+
_LOGGER.exception("Failed to signal process group %s with signal %s", pgid, signum)
|
|
652
|
+
return False
|
|
653
|
+
return True
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def _terminate_direct_popen(popen: subprocess.Popen[bytes]) -> None:
|
|
657
|
+
if popen.poll() is not None:
|
|
658
|
+
return
|
|
659
|
+
try:
|
|
660
|
+
popen.terminate()
|
|
661
|
+
except ProcessLookupError:
|
|
662
|
+
return
|
|
663
|
+
except OSError:
|
|
664
|
+
_LOGGER.exception("Failed to terminate background process %s", popen.pid)
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def _kill_direct_popen(popen: subprocess.Popen[bytes]) -> None:
|
|
668
|
+
if popen.poll() is not None:
|
|
669
|
+
return
|
|
670
|
+
try:
|
|
671
|
+
popen.kill()
|
|
672
|
+
except ProcessLookupError:
|
|
673
|
+
return
|
|
674
|
+
except OSError:
|
|
675
|
+
_LOGGER.exception("Failed to kill background process %s", popen.pid)
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def _kill_windows_process_tree(popen: subprocess.Popen[bytes]) -> None:
|
|
679
|
+
kwargs: dict[str, object] = {}
|
|
680
|
+
create_no_window = getattr(subprocess, "CREATE_NO_WINDOW", None)
|
|
681
|
+
if create_no_window is not None:
|
|
682
|
+
kwargs["creationflags"] = create_no_window
|
|
683
|
+
|
|
684
|
+
try:
|
|
685
|
+
result = subprocess.run(
|
|
686
|
+
["taskkill", "/PID", str(popen.pid), "/T", "/F"],
|
|
687
|
+
stdout=subprocess.DEVNULL,
|
|
688
|
+
stderr=subprocess.DEVNULL,
|
|
689
|
+
timeout=5.0,
|
|
690
|
+
check=False,
|
|
691
|
+
**kwargs,
|
|
692
|
+
)
|
|
693
|
+
except (OSError, subprocess.SubprocessError):
|
|
694
|
+
result = None
|
|
695
|
+
|
|
696
|
+
if result is not None and result.returncode == 0:
|
|
697
|
+
return
|
|
698
|
+
|
|
699
|
+
with contextlib.suppress(ProcessLookupError):
|
|
700
|
+
popen.kill()
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def _terminate_direct_process(popen: subprocess.Popen[bytes], *, timeout_s: float) -> None:
|
|
704
|
+
_terminate_direct_popen(popen)
|
|
705
|
+
if not _wait_for_popen_exit(popen, timeout_s=timeout_s) and popen.poll() is None:
|
|
706
|
+
_kill_direct_popen(popen)
|
|
707
|
+
_wait_for_popen_exit(popen, timeout_s=_POST_SIGKILL_WAIT_TIMEOUT_S)
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def _terminate_process_tree(popen: subprocess.Popen[bytes], *, timeout_s: float) -> None:
|
|
711
|
+
if os.name == "nt": # pragma: no cover - exercised on Windows
|
|
712
|
+
if popen.poll() is None:
|
|
713
|
+
with contextlib.suppress(ProcessLookupError):
|
|
714
|
+
popen.send_signal(signal.CTRL_BREAK_EVENT)
|
|
715
|
+
if not _wait_for_popen_exit(popen, timeout_s=timeout_s) and popen.poll() is None:
|
|
716
|
+
_kill_windows_process_tree(popen)
|
|
717
|
+
_wait_for_popen_exit(popen, timeout_s=_POST_SIGKILL_WAIT_TIMEOUT_S)
|
|
718
|
+
return
|
|
719
|
+
|
|
720
|
+
pgid = _resolve_posix_process_group_id(popen)
|
|
721
|
+
if popen.poll() is None:
|
|
722
|
+
if pgid is None:
|
|
723
|
+
with contextlib.suppress(ProcessLookupError):
|
|
724
|
+
popen.terminate()
|
|
725
|
+
else:
|
|
726
|
+
_signal_posix_process_group(pgid, signal.SIGTERM)
|
|
727
|
+
if not _wait_for_popen_exit(popen, timeout_s=timeout_s) and popen.poll() is None:
|
|
728
|
+
if pgid is None:
|
|
729
|
+
with contextlib.suppress(ProcessLookupError):
|
|
730
|
+
popen.kill()
|
|
731
|
+
else:
|
|
732
|
+
_signal_posix_process_group(pgid, signal.SIGKILL)
|
|
733
|
+
_wait_for_popen_exit(popen, timeout_s=_POST_SIGKILL_WAIT_TIMEOUT_S)
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def _wait_for_popen_exit(popen: subprocess.Popen[bytes], *, timeout_s: float) -> bool:
|
|
737
|
+
try:
|
|
738
|
+
popen.wait(timeout=timeout_s)
|
|
739
|
+
except subprocess.TimeoutExpired:
|
|
740
|
+
return False
|
|
741
|
+
return True
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
def _terminate_spawned_process(spawn: BackgroundProcessSpawn) -> None:
|
|
745
|
+
if spawn.termination_mode == "direct":
|
|
746
|
+
try:
|
|
747
|
+
spawn.cleanup()
|
|
748
|
+
except Exception: # noqa: BLE001
|
|
749
|
+
_LOGGER.exception("Background process spawn cleanup failed after start error")
|
|
750
|
+
_terminate_direct_process(spawn.popen, timeout_s=1.0)
|
|
751
|
+
else:
|
|
752
|
+
_terminate_process_tree(spawn.popen, timeout_s=1.0)
|
|
753
|
+
try:
|
|
754
|
+
spawn.cleanup()
|
|
755
|
+
except Exception: # noqa: BLE001
|
|
756
|
+
_LOGGER.exception("Background process spawn cleanup failed after start error")
|
|
757
|
+
with contextlib.suppress(OSError):
|
|
758
|
+
if spawn.popen.stdout is not None:
|
|
759
|
+
spawn.popen.stdout.close()
|
|
760
|
+
with contextlib.suppress(OSError):
|
|
761
|
+
if spawn.popen.stderr is not None:
|
|
762
|
+
spawn.popen.stderr.close()
|