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,566 @@
|
|
|
1
|
+
"""Tracked process-group lifecycle for agent-spawned shell commands.
|
|
2
|
+
|
|
3
|
+
A shell command that outlives its tool call keeps consuming the machine. The
|
|
4
|
+
observed failure mode is a timed-out test run: ``subprocess.run(timeout=...)``
|
|
5
|
+
kills the direct child (the shell) and returns, but the shell's *children* --
|
|
6
|
+
the pytest workers -- are never signalled and keep running. On a shared or
|
|
7
|
+
benchmarked host they then compete with whatever runs next.
|
|
8
|
+
|
|
9
|
+
The fix is ownership, not discovery. Every command the runner starts is placed
|
|
10
|
+
in its own POSIX process group (``start_new_session``), and the resulting pgid
|
|
11
|
+
is recorded here at spawn time. Reaping later signals *only* those recorded
|
|
12
|
+
pgids.
|
|
13
|
+
|
|
14
|
+
Hard safety rule, enforced structurally by this module:
|
|
15
|
+
|
|
16
|
+
* ``ProcessGroupRegistry`` can only learn a pgid through :meth:`register`,
|
|
17
|
+
which the runner calls with the pgid of a process it just created.
|
|
18
|
+
* Nothing here ever enumerates the process table, and nothing ever matches a
|
|
19
|
+
process by name, cmdline, or any other heuristic. There is no code path that
|
|
20
|
+
can signal a group the runner did not spawn.
|
|
21
|
+
* A recorded pgid is refused if it is not a positive integer, if it is this
|
|
22
|
+
process's own group (a defensive check for a host where session creation
|
|
23
|
+
silently failed), or if the group leader's start token changed since
|
|
24
|
+
registration. That last guard reads ``/proc``, so it is active on Linux and
|
|
25
|
+
absent on macOS/BSD; there the residual exposure is a pgid recycled in the
|
|
26
|
+
window between a tool call returning and the group being released, which is
|
|
27
|
+
the same window ``release_if_finished`` closes on the very next statement.
|
|
28
|
+
|
|
29
|
+
The reaping guarantee is scoped to the session that did the spawning: a session
|
|
30
|
+
never signals another session's groups, and an interactive session's own turn
|
|
31
|
+
never kills groups that session started. A nested delegation (subagent or
|
|
32
|
+
worker) still cleans up what *it* started when it ends -- its work is over,
|
|
33
|
+
and the groups belong to it.
|
|
34
|
+
|
|
35
|
+
Everything except the signalling helpers is pure and unit-testable without
|
|
36
|
+
spawning a process. The platform guard is a separate function so the tracking
|
|
37
|
+
and decision logic stay exercisable on any host, mirroring the PIPESTATUS
|
|
38
|
+
capture guard in :mod:`alysis_code.pipeline_facts`.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
import os
|
|
44
|
+
import subprocess
|
|
45
|
+
import threading
|
|
46
|
+
import time
|
|
47
|
+
from collections.abc import Callable
|
|
48
|
+
from dataclasses import dataclass
|
|
49
|
+
from enum import StrEnum
|
|
50
|
+
from pathlib import Path
|
|
51
|
+
from typing import Any
|
|
52
|
+
|
|
53
|
+
from .branding import env_get
|
|
54
|
+
from .runtime_kind import RuntimeKind, normalize_runtime_kind
|
|
55
|
+
|
|
56
|
+
# Grace period between SIGTERM and SIGKILL for a tracked group.
|
|
57
|
+
PROCESS_REAP_GRACE_SECONDS = 5.0
|
|
58
|
+
_GROUP_EXIT_POLL_SECONDS = 0.02
|
|
59
|
+
|
|
60
|
+
SIGNAL_NONE = "none"
|
|
61
|
+
SIGNAL_TERM = "SIGTERM"
|
|
62
|
+
SIGNAL_KILL = "SIGKILL"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def process_groups_supported() -> bool:
|
|
66
|
+
"""True when the host has POSIX process groups that can be signalled.
|
|
67
|
+
|
|
68
|
+
Windows has no ``killpg``; command tracking degrades to a no-op there. Kept
|
|
69
|
+
as a separate function so tests can exercise the tracking and reaping paths
|
|
70
|
+
on any host (mirrors ``_pipeline_capture_platform_ok`` in the shell tool).
|
|
71
|
+
"""
|
|
72
|
+
return os.name != "nt"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _process_reaping_enabled(cfg: Any | None) -> bool:
|
|
76
|
+
"""Kill-switch for process reaping (step 5).
|
|
77
|
+
|
|
78
|
+
``ALYSIS_PROCESS_REAPING`` (off/0/false/no/disabled) wins over the config
|
|
79
|
+
value; default is on. When off, commands still run in their own process
|
|
80
|
+
group but nothing is ever signalled and no reaping telemetry is emitted --
|
|
81
|
+
the legacy leave-it-running behaviour.
|
|
82
|
+
"""
|
|
83
|
+
env_value = env_get("ALYSIS_PROCESS_REAPING")
|
|
84
|
+
if env_value is not None:
|
|
85
|
+
normalized = str(env_value).strip().lower()
|
|
86
|
+
if normalized in {"off", "0", "false", "no", "disabled"}:
|
|
87
|
+
return False
|
|
88
|
+
if normalized in {"on", "1", "true", "yes", "enabled"}:
|
|
89
|
+
return True
|
|
90
|
+
return bool(getattr(cfg, "process_reaping_enabled", True))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ---------------------------------------------------------------------------
|
|
94
|
+
# Reaping decision (pure)
|
|
95
|
+
# ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class ReapEvent(StrEnum):
|
|
99
|
+
TURN_FINALIZATION = "turn_finalization"
|
|
100
|
+
SESSION_CLOSE = "session_close"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class ReapAction(StrEnum):
|
|
104
|
+
REAP = "reap"
|
|
105
|
+
REPORT = "report"
|
|
106
|
+
SKIP = "skip"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass(frozen=True)
|
|
110
|
+
class ReapDecision:
|
|
111
|
+
action: ReapAction
|
|
112
|
+
reason: str
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _runtime_kind_is_interactive(kind: RuntimeKind | str | None) -> bool:
|
|
116
|
+
try:
|
|
117
|
+
normalized = normalize_runtime_kind(kind, fallback=RuntimeKind.INTERACTIVE_CHAT)
|
|
118
|
+
except Exception: # noqa: BLE001 - an unknown kind is treated as interactive (never auto-kill)
|
|
119
|
+
return True
|
|
120
|
+
return normalized is RuntimeKind.INTERACTIVE_CHAT
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def resolve_reap_decision(
|
|
124
|
+
*,
|
|
125
|
+
runtime_kind: RuntimeKind | str | None,
|
|
126
|
+
event: ReapEvent | str,
|
|
127
|
+
enabled: bool,
|
|
128
|
+
) -> ReapDecision:
|
|
129
|
+
"""Decide what to do with tracked live groups at ``event``. Pure.
|
|
130
|
+
|
|
131
|
+
The table is deliberately small and asymmetric:
|
|
132
|
+
|
|
133
|
+
================== ================== ==============
|
|
134
|
+
runtime kind turn finalization session close
|
|
135
|
+
================== ================== ==============
|
|
136
|
+
interactive_chat report reap
|
|
137
|
+
everything else reap reap
|
|
138
|
+
================== ================== ==============
|
|
139
|
+
|
|
140
|
+
An interactive turn is never auto-killed: a user may have asked for a dev
|
|
141
|
+
server and expects it to outlive the turn. Their still-running groups are
|
|
142
|
+
reported instead, and reaped when the session itself ends. Every autonomous
|
|
143
|
+
runtime kind (one-shot, forge, swarm worker, subagent, or conflict resolver)
|
|
144
|
+
declares itself done at turn end, so anything it left
|
|
145
|
+
running is a leak and is terminated.
|
|
146
|
+
"""
|
|
147
|
+
if not enabled:
|
|
148
|
+
return ReapDecision(ReapAction.SKIP, "process_reaping_disabled")
|
|
149
|
+
normalized_event = (
|
|
150
|
+
event if isinstance(event, ReapEvent) else ReapEvent(str(event).strip().lower())
|
|
151
|
+
)
|
|
152
|
+
if normalized_event is ReapEvent.SESSION_CLOSE:
|
|
153
|
+
return ReapDecision(ReapAction.REAP, "session_close")
|
|
154
|
+
if _runtime_kind_is_interactive(runtime_kind):
|
|
155
|
+
return ReapDecision(ReapAction.REPORT, "interactive_turn_keeps_user_processes")
|
|
156
|
+
return ReapDecision(ReapAction.REAP, "autonomous_turn_finalization")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# ---------------------------------------------------------------------------
|
|
160
|
+
# Tracked groups
|
|
161
|
+
# ---------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@dataclass(frozen=True)
|
|
165
|
+
class TrackedProcessGroup:
|
|
166
|
+
"""One process group this runner created and has not seen exit."""
|
|
167
|
+
|
|
168
|
+
pgid: int
|
|
169
|
+
command: str
|
|
170
|
+
origin: str
|
|
171
|
+
started_at: float
|
|
172
|
+
start_token: str | None = None
|
|
173
|
+
|
|
174
|
+
def runtime_s(self, *, now: float | None = None) -> float:
|
|
175
|
+
current = time.monotonic() if now is None else now
|
|
176
|
+
return max(0.0, round(current - self.started_at, 3))
|
|
177
|
+
|
|
178
|
+
def payload(self, *, now: float | None = None) -> dict[str, Any]:
|
|
179
|
+
return {
|
|
180
|
+
"pgid": self.pgid,
|
|
181
|
+
"command": self.command,
|
|
182
|
+
"origin": self.origin,
|
|
183
|
+
"runtime_s": self.runtime_s(now=now),
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@dataclass(frozen=True)
|
|
188
|
+
class ProcessReapOutcome:
|
|
189
|
+
pgid: int
|
|
190
|
+
command: str
|
|
191
|
+
origin: str
|
|
192
|
+
runtime_s: float
|
|
193
|
+
signal_used: str
|
|
194
|
+
|
|
195
|
+
def payload(self) -> dict[str, Any]:
|
|
196
|
+
return {
|
|
197
|
+
"pgid": self.pgid,
|
|
198
|
+
"command": self.command,
|
|
199
|
+
"origin": self.origin,
|
|
200
|
+
"runtime_s": self.runtime_s,
|
|
201
|
+
"signal_used": self.signal_used,
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class ProcessGroupRegistry:
|
|
206
|
+
"""Records the process groups this runner created.
|
|
207
|
+
|
|
208
|
+
This is the *only* source of pgids that may ever be signalled. It is
|
|
209
|
+
per-session, so a subagent never reaps its parent's groups and vice versa.
|
|
210
|
+
"""
|
|
211
|
+
|
|
212
|
+
def __init__(self) -> None:
|
|
213
|
+
self._lock = threading.Lock()
|
|
214
|
+
self._groups: dict[int, TrackedProcessGroup] = {}
|
|
215
|
+
|
|
216
|
+
def register(self, *, pgid: int, command: str, origin: str) -> TrackedProcessGroup | None:
|
|
217
|
+
"""Record a group this runner just created. Returns ``None`` if refused."""
|
|
218
|
+
if not _pgid_is_signalable(pgid):
|
|
219
|
+
return None
|
|
220
|
+
record = TrackedProcessGroup(
|
|
221
|
+
pgid=int(pgid),
|
|
222
|
+
command=str(command or ""),
|
|
223
|
+
origin=str(origin or "shell_run"),
|
|
224
|
+
started_at=time.monotonic(),
|
|
225
|
+
start_token=_pid_start_token(int(pgid)),
|
|
226
|
+
)
|
|
227
|
+
with self._lock:
|
|
228
|
+
self._groups[record.pgid] = record
|
|
229
|
+
return record
|
|
230
|
+
|
|
231
|
+
def release(self, pgid: int) -> None:
|
|
232
|
+
"""Forget a group unconditionally (the caller knows it is finished)."""
|
|
233
|
+
with self._lock:
|
|
234
|
+
self._groups.pop(int(pgid), None)
|
|
235
|
+
|
|
236
|
+
def release_if_finished(self, pgid: int) -> bool:
|
|
237
|
+
"""Drop the group when it no longer exists; keep it when it is alive.
|
|
238
|
+
|
|
239
|
+
Called when a tool call returns. A plain command that ran to completion
|
|
240
|
+
is dropped here; a command that timed out, was backgrounded with ``&``,
|
|
241
|
+
or spawned a daemon leaves a live group and stays tracked for reaping.
|
|
242
|
+
"""
|
|
243
|
+
if not process_group_exists(int(pgid)):
|
|
244
|
+
self.release(pgid)
|
|
245
|
+
return True
|
|
246
|
+
return False
|
|
247
|
+
|
|
248
|
+
def tracked(self) -> tuple[TrackedProcessGroup, ...]:
|
|
249
|
+
with self._lock:
|
|
250
|
+
return tuple(self._groups.values())
|
|
251
|
+
|
|
252
|
+
def live(self) -> tuple[TrackedProcessGroup, ...]:
|
|
253
|
+
"""Tracked groups that still exist, dropping any that have since exited."""
|
|
254
|
+
alive: list[TrackedProcessGroup] = []
|
|
255
|
+
for record in self.tracked():
|
|
256
|
+
if process_group_exists(record.pgid) and _start_token_matches(record):
|
|
257
|
+
alive.append(record)
|
|
258
|
+
else:
|
|
259
|
+
self.release(record.pgid)
|
|
260
|
+
return tuple(alive)
|
|
261
|
+
|
|
262
|
+
def clear(self) -> None:
|
|
263
|
+
with self._lock:
|
|
264
|
+
self._groups.clear()
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
# ---------------------------------------------------------------------------
|
|
268
|
+
# POSIX signalling (the only impure part)
|
|
269
|
+
# ---------------------------------------------------------------------------
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _pgid_is_signalable(pgid: Any) -> bool:
|
|
273
|
+
"""Reject anything that must never be signalled, before it is ever recorded."""
|
|
274
|
+
# Strict about the type: a pgid comes from os.getpgid, so anything that is
|
|
275
|
+
# not already an int reached this from somewhere it should not have, and
|
|
276
|
+
# coercing it would launder that mistake into a signal.
|
|
277
|
+
if not isinstance(pgid, int) or isinstance(pgid, bool):
|
|
278
|
+
return False
|
|
279
|
+
candidate = pgid
|
|
280
|
+
if candidate <= 1:
|
|
281
|
+
return False
|
|
282
|
+
if not process_groups_supported():
|
|
283
|
+
return False
|
|
284
|
+
try:
|
|
285
|
+
own_group = os.getpgrp()
|
|
286
|
+
except (AttributeError, OSError): # pragma: no cover - exercised on Windows
|
|
287
|
+
return False
|
|
288
|
+
# A pgid equal to our own group means session creation did not take effect;
|
|
289
|
+
# signalling it would kill the agent itself.
|
|
290
|
+
return candidate != own_group
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _pid_start_token(pid: int) -> str | None:
|
|
294
|
+
"""Linux process start time, used to detect PID reuse. ``None`` elsewhere."""
|
|
295
|
+
stat_path = Path("/proc") / str(pid) / "stat"
|
|
296
|
+
try:
|
|
297
|
+
text = stat_path.read_text(encoding="utf-8")
|
|
298
|
+
except OSError:
|
|
299
|
+
return None
|
|
300
|
+
try:
|
|
301
|
+
after_comm = text.rsplit(") ", 1)[1]
|
|
302
|
+
fields = after_comm.split()
|
|
303
|
+
return f"linux:{fields[19]}"
|
|
304
|
+
except (IndexError, ValueError):
|
|
305
|
+
return None
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _start_token_matches(record: TrackedProcessGroup) -> bool:
|
|
309
|
+
"""False when the group leader was replaced by an unrelated recycled PID."""
|
|
310
|
+
if record.start_token is None:
|
|
311
|
+
return True
|
|
312
|
+
current = _pid_start_token(record.pgid)
|
|
313
|
+
if current is None:
|
|
314
|
+
return True
|
|
315
|
+
return current == record.start_token
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def resolve_process_group_id(pid: int) -> int | None:
|
|
319
|
+
"""Process-group id of a process we started, or ``None`` when unavailable."""
|
|
320
|
+
if not process_groups_supported(): # pragma: no cover - exercised on Windows
|
|
321
|
+
return None
|
|
322
|
+
if pid <= 0:
|
|
323
|
+
return None
|
|
324
|
+
try:
|
|
325
|
+
return os.getpgid(pid)
|
|
326
|
+
except OSError:
|
|
327
|
+
# The leader already exited; with start_new_session its pid is the pgid.
|
|
328
|
+
return pid
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def process_group_exists(pgid: int) -> bool:
|
|
332
|
+
if not process_groups_supported(): # pragma: no cover - exercised on Windows
|
|
333
|
+
return False
|
|
334
|
+
try:
|
|
335
|
+
os.killpg(pgid, 0)
|
|
336
|
+
except ProcessLookupError:
|
|
337
|
+
return False
|
|
338
|
+
except PermissionError:
|
|
339
|
+
return True
|
|
340
|
+
except OSError:
|
|
341
|
+
return False
|
|
342
|
+
return True
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def signal_process_group(pgid: int, signum: int) -> bool:
|
|
346
|
+
if not process_groups_supported(): # pragma: no cover - exercised on Windows
|
|
347
|
+
return False
|
|
348
|
+
try:
|
|
349
|
+
os.killpg(pgid, signum)
|
|
350
|
+
except ProcessLookupError:
|
|
351
|
+
return True
|
|
352
|
+
except OSError:
|
|
353
|
+
return False
|
|
354
|
+
return True
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _wait_for_group_exit(pgid: int, *, timeout_s: float) -> bool:
|
|
358
|
+
deadline = time.monotonic() + max(0.0, timeout_s)
|
|
359
|
+
while time.monotonic() < deadline:
|
|
360
|
+
if not process_group_exists(pgid):
|
|
361
|
+
return True
|
|
362
|
+
time.sleep(_GROUP_EXIT_POLL_SECONDS)
|
|
363
|
+
return not process_group_exists(pgid)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def reap_process_group(
|
|
367
|
+
record: TrackedProcessGroup,
|
|
368
|
+
*,
|
|
369
|
+
grace_seconds: float = PROCESS_REAP_GRACE_SECONDS,
|
|
370
|
+
) -> ProcessReapOutcome:
|
|
371
|
+
"""SIGTERM, bounded grace, then SIGKILL. Only ever called for a tracked group."""
|
|
372
|
+
import signal as signal_module
|
|
373
|
+
|
|
374
|
+
runtime_s = record.runtime_s()
|
|
375
|
+
if not _pgid_is_signalable(record.pgid) or not _start_token_matches(record):
|
|
376
|
+
return ProcessReapOutcome(
|
|
377
|
+
pgid=record.pgid,
|
|
378
|
+
command=record.command,
|
|
379
|
+
origin=record.origin,
|
|
380
|
+
runtime_s=runtime_s,
|
|
381
|
+
signal_used=SIGNAL_NONE,
|
|
382
|
+
)
|
|
383
|
+
if not process_group_exists(record.pgid):
|
|
384
|
+
return ProcessReapOutcome(
|
|
385
|
+
pgid=record.pgid,
|
|
386
|
+
command=record.command,
|
|
387
|
+
origin=record.origin,
|
|
388
|
+
runtime_s=runtime_s,
|
|
389
|
+
signal_used=SIGNAL_NONE,
|
|
390
|
+
)
|
|
391
|
+
signal_process_group(record.pgid, signal_module.SIGTERM)
|
|
392
|
+
if _wait_for_group_exit(record.pgid, timeout_s=grace_seconds):
|
|
393
|
+
return ProcessReapOutcome(
|
|
394
|
+
pgid=record.pgid,
|
|
395
|
+
command=record.command,
|
|
396
|
+
origin=record.origin,
|
|
397
|
+
runtime_s=runtime_s,
|
|
398
|
+
signal_used=SIGNAL_TERM,
|
|
399
|
+
)
|
|
400
|
+
signal_process_group(record.pgid, signal_module.SIGKILL)
|
|
401
|
+
_wait_for_group_exit(record.pgid, timeout_s=1.0)
|
|
402
|
+
return ProcessReapOutcome(
|
|
403
|
+
pgid=record.pgid,
|
|
404
|
+
command=record.command,
|
|
405
|
+
origin=record.origin,
|
|
406
|
+
runtime_s=runtime_s,
|
|
407
|
+
signal_used=SIGNAL_KILL,
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def reap_tracked_groups(
|
|
412
|
+
registry: ProcessGroupRegistry | None,
|
|
413
|
+
*,
|
|
414
|
+
grace_seconds: float = PROCESS_REAP_GRACE_SECONDS,
|
|
415
|
+
) -> tuple[ProcessReapOutcome, ...]:
|
|
416
|
+
"""Terminate every tracked group that is still alive. Never raises."""
|
|
417
|
+
if registry is None:
|
|
418
|
+
return ()
|
|
419
|
+
outcomes: list[ProcessReapOutcome] = []
|
|
420
|
+
for record in registry.live():
|
|
421
|
+
try:
|
|
422
|
+
outcome = reap_process_group(record, grace_seconds=grace_seconds)
|
|
423
|
+
except Exception: # noqa: BLE001 - teardown must never break finalization
|
|
424
|
+
outcome = ProcessReapOutcome(
|
|
425
|
+
pgid=record.pgid,
|
|
426
|
+
command=record.command,
|
|
427
|
+
origin=record.origin,
|
|
428
|
+
runtime_s=record.runtime_s(),
|
|
429
|
+
signal_used=SIGNAL_NONE,
|
|
430
|
+
)
|
|
431
|
+
registry.release(record.pgid)
|
|
432
|
+
outcomes.append(outcome)
|
|
433
|
+
return tuple(outcomes)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def survivor_payloads(registry: ProcessGroupRegistry | None) -> tuple[dict[str, Any], ...]:
|
|
437
|
+
"""Payloads for tracked groups still alive, without touching them."""
|
|
438
|
+
if registry is None:
|
|
439
|
+
return ()
|
|
440
|
+
now = time.monotonic()
|
|
441
|
+
return tuple(record.payload(now=now) for record in registry.live())
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
# ---------------------------------------------------------------------------
|
|
445
|
+
# Spawning inside a tracked group
|
|
446
|
+
# ---------------------------------------------------------------------------
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def new_process_group_popen_kwargs() -> dict[str, Any]:
|
|
450
|
+
"""Popen kwargs that put the child in its own process group.
|
|
451
|
+
|
|
452
|
+
POSIX only. On Windows a new console process group would detach the child
|
|
453
|
+
from console Ctrl-C without buying anything back -- there is no ``killpg``,
|
|
454
|
+
so nothing could be reaped -- so the guard makes this a true no-op there and
|
|
455
|
+
foreground commands keep behaving exactly as they did.
|
|
456
|
+
"""
|
|
457
|
+
if not process_groups_supported(): # pragma: no cover - exercised on Windows
|
|
458
|
+
return {}
|
|
459
|
+
return {"start_new_session": True}
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _describe_command(args: Any) -> str:
|
|
463
|
+
if isinstance(args, str):
|
|
464
|
+
return args
|
|
465
|
+
try:
|
|
466
|
+
return " ".join(str(item) for item in args)
|
|
467
|
+
except TypeError:
|
|
468
|
+
return str(args)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def run_in_tracked_process_group(
|
|
472
|
+
args: Any,
|
|
473
|
+
*,
|
|
474
|
+
shell: bool = False,
|
|
475
|
+
cwd: str | None = None,
|
|
476
|
+
env: dict[str, str] | None = None,
|
|
477
|
+
timeout: float | None = None,
|
|
478
|
+
registry: ProcessGroupRegistry | None = None,
|
|
479
|
+
origin: str = "shell_run",
|
|
480
|
+
command_label: str = "",
|
|
481
|
+
stdin: Any | None = None,
|
|
482
|
+
popen_factory: Callable[..., subprocess.Popen[str]] | None = None,
|
|
483
|
+
) -> subprocess.CompletedProcess[str]:
|
|
484
|
+
"""``subprocess.run``-equivalent that starts the command in its own group.
|
|
485
|
+
|
|
486
|
+
Semantics match ``subprocess.run(capture_output=True, text=True,
|
|
487
|
+
timeout=...)`` exactly, including raising :class:`subprocess.TimeoutExpired`
|
|
488
|
+
and killing the direct child on expiry. The only additions are that the
|
|
489
|
+
command leads its own process group and that the group is registered while
|
|
490
|
+
it is alive, so a later reap can terminate the children the timeout kill
|
|
491
|
+
orphans today.
|
|
492
|
+
|
|
493
|
+
Without a registry there is nothing to track, so there is no reason to hold a
|
|
494
|
+
handle: the call goes straight to ``subprocess.run``. That keeps every
|
|
495
|
+
non-tracking caller -- and the long-standing test seam of patching
|
|
496
|
+
``subprocess.run`` -- behaving exactly as before.
|
|
497
|
+
"""
|
|
498
|
+
popen_kwargs: dict[str, Any] = {
|
|
499
|
+
"stdout": subprocess.PIPE,
|
|
500
|
+
"stderr": subprocess.PIPE,
|
|
501
|
+
"text": True,
|
|
502
|
+
**new_process_group_popen_kwargs(),
|
|
503
|
+
}
|
|
504
|
+
# Left unset by default so interactive shell work keeps the caller's stdin.
|
|
505
|
+
# Automated callers pass ``subprocess.DEVNULL`` so a command that prompts
|
|
506
|
+
# reads EOF and fails immediately instead of waiting on a terminal nobody
|
|
507
|
+
# is watching.
|
|
508
|
+
if stdin is not None:
|
|
509
|
+
popen_kwargs["stdin"] = stdin
|
|
510
|
+
if cwd is not None:
|
|
511
|
+
popen_kwargs["cwd"] = cwd
|
|
512
|
+
if env is not None:
|
|
513
|
+
popen_kwargs["env"] = env
|
|
514
|
+
if registry is None:
|
|
515
|
+
return subprocess.run(
|
|
516
|
+
args,
|
|
517
|
+
shell=shell,
|
|
518
|
+
timeout=timeout,
|
|
519
|
+
check=False,
|
|
520
|
+
**popen_kwargs,
|
|
521
|
+
)
|
|
522
|
+
# Resolved at call time, not bound as a default: `subprocess.Popen` and
|
|
523
|
+
# `subprocess.run` are the seams callers and tests patch, and a default
|
|
524
|
+
# argument would capture the original before any patch could take effect.
|
|
525
|
+
factory = popen_factory if popen_factory is not None else subprocess.Popen
|
|
526
|
+
# The body mirrors CPython's subprocess.run: the Popen context manager closes
|
|
527
|
+
# the pipes and reaps the child on every exit path, and a timeout kills the
|
|
528
|
+
# direct child before re-raising. The only additions are the process group and
|
|
529
|
+
# its registration.
|
|
530
|
+
with factory(args, shell=shell, **popen_kwargs) as process:
|
|
531
|
+
pgid = resolve_process_group_id(int(getattr(process, "pid", 0) or 0))
|
|
532
|
+
tracked: TrackedProcessGroup | None = None
|
|
533
|
+
if pgid is not None and registry is not None:
|
|
534
|
+
label = str(command_label) if command_label else _describe_command(args)
|
|
535
|
+
tracked = registry.register(pgid=pgid, command=label, origin=origin)
|
|
536
|
+
try:
|
|
537
|
+
stdout, stderr = process.communicate(timeout=timeout)
|
|
538
|
+
except subprocess.TimeoutExpired as exc:
|
|
539
|
+
# Killing only the leader is what orphans the children today. That is
|
|
540
|
+
# left as-is on purpose: the group stays registered, and finalization
|
|
541
|
+
# reaps it under the single audited decision path.
|
|
542
|
+
process.kill()
|
|
543
|
+
if os.name == "nt": # pragma: no cover - exercised on Windows
|
|
544
|
+
exc.stdout, exc.stderr = process.communicate()
|
|
545
|
+
else:
|
|
546
|
+
process.wait()
|
|
547
|
+
raise
|
|
548
|
+
except BaseException:
|
|
549
|
+
# Ctrl-C lands here. Before this change the terminal's SIGINT reached
|
|
550
|
+
# the whole foreground group, so interrupting a command killed its
|
|
551
|
+
# children too; now that the command leads its own session the runner
|
|
552
|
+
# has to do that itself, or an interrupted test run would keep going.
|
|
553
|
+
process.kill()
|
|
554
|
+
if tracked is not None:
|
|
555
|
+
reap_process_group(tracked, grace_seconds=1.0)
|
|
556
|
+
raise
|
|
557
|
+
finally:
|
|
558
|
+
if tracked is not None and registry is not None:
|
|
559
|
+
registry.release_if_finished(tracked.pgid)
|
|
560
|
+
returncode = process.poll()
|
|
561
|
+
return subprocess.CompletedProcess(
|
|
562
|
+
args=args,
|
|
563
|
+
returncode=returncode if returncode is not None else 1,
|
|
564
|
+
stdout=stdout,
|
|
565
|
+
stderr=stderr,
|
|
566
|
+
)
|