alysis-code 0.13.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- alysis_code/__init__.py +3 -0
- alysis_code/__main__.py +4 -0
- alysis_code/_build_info.py +14 -0
- alysis_code/account_login.py +468 -0
- alysis_code/agent/README.md +35 -0
- alysis_code/agent/__init__.py +11 -0
- alysis_code/agent/acceptance_contract.py +2217 -0
- alysis_code/agent/blast_radius.py +1403 -0
- alysis_code/agent/cache_keepalive.py +227 -0
- alysis_code/agent/completion_certificate.py +366 -0
- alysis_code/agent/completion_gate.py +306 -0
- alysis_code/agent/empty_response_stall.py +403 -0
- alysis_code/agent/errors.py +28 -0
- alysis_code/agent/llm_calls.py +475 -0
- alysis_code/agent/mutation_classification.py +227 -0
- alysis_code/agent/prompt_context.py +2508 -0
- alysis_code/agent/read_ledger.py +253 -0
- alysis_code/agent/regression_baseline.py +642 -0
- alysis_code/agent/reproduction_first.py +610 -0
- alysis_code/agent/sensitive_output.py +629 -0
- alysis_code/agent/session.py +3218 -0
- alysis_code/agent/steering.py +191 -0
- alysis_code/agent/subagent_execution.py +5177 -0
- alysis_code/agent/subagent_workspace.py +666 -0
- alysis_code/agent/tools_assembly.py +4728 -0
- alysis_code/agent/turn/__init__.py +101 -0
- alysis_code/agent/turn/core.py +8483 -0
- alysis_code/agent/turn/events.py +113 -0
- alysis_code/agent/turn/exploration.py +590 -0
- alysis_code/agent/turn/interventions.py +65 -0
- alysis_code/agent/turn/read_cache.py +420 -0
- alysis_code/agent/turn/snapshot.py +179 -0
- alysis_code/agent/turn_contract.py +661 -0
- alysis_code/agent/turn_path.py +129 -0
- alysis_code/agent/verification.py +2885 -0
- alysis_code/agent/verification_commands.py +512 -0
- alysis_code/agent/verification_evidence.py +738 -0
- alysis_code/agent_loop.py +693 -0
- alysis_code/agent_runtimes/__init__.py +51 -0
- alysis_code/agent_runtimes/base.py +114 -0
- alysis_code/agent_runtimes/builtins.py +129 -0
- alysis_code/agent_runtimes/codex_cli.py +664 -0
- alysis_code/agent_runtimes/host.py +263 -0
- alysis_code/agent_runtimes/registry.py +64 -0
- alysis_code/agent_runtimes/service.py +150 -0
- alysis_code/agentbox_client.py +416 -0
- alysis_code/agentbox_integration.py +310 -0
- alysis_code/alysis_cloud.py +152 -0
- alysis_code/approval_scope.py +276 -0
- alysis_code/assets/README.md +33 -0
- alysis_code/assets/__init__.py +126 -0
- alysis_code/assets/asset_read_core.py +281 -0
- alysis_code/assets/budget_allocator.py +456 -0
- alysis_code/assets/comprehender.py +759 -0
- alysis_code/assets/index.py +654 -0
- alysis_code/assets/ingestion.py +275 -0
- alysis_code/assets/legacy_migration.py +413 -0
- alysis_code/assets/models.py +263 -0
- alysis_code/assets/ocr.py +239 -0
- alysis_code/assets/owl/ascii/f-000.txt +13 -0
- alysis_code/assets/owl/ascii/f-001.txt +13 -0
- alysis_code/assets/owl/ascii/f-002.txt +13 -0
- alysis_code/assets/owl/ascii/f-003.txt +13 -0
- alysis_code/assets/owl/ascii/f-004.txt +13 -0
- alysis_code/assets/owl/ascii/f-005.txt +13 -0
- alysis_code/assets/owl/ascii/f-006.txt +13 -0
- alysis_code/assets/owl/ascii/f-007.txt +13 -0
- alysis_code/assets/owl/ascii/f-008.txt +13 -0
- alysis_code/assets/owl/ascii/f-009.txt +13 -0
- alysis_code/assets/owl/ascii/f-010.txt +13 -0
- alysis_code/assets/owl/ascii/f-011.txt +13 -0
- alysis_code/assets/owl/ascii/f-012.txt +13 -0
- alysis_code/assets/owl/ascii/f-013.txt +13 -0
- alysis_code/assets/owl/ascii/f-014.txt +13 -0
- alysis_code/assets/owl/ascii/f-015.txt +13 -0
- alysis_code/assets/owl/ascii/f-016.txt +13 -0
- alysis_code/assets/owl/ascii/f-017.txt +13 -0
- alysis_code/assets/owl/ascii/f-018.txt +13 -0
- alysis_code/assets/owl/ascii/f-019.txt +13 -0
- alysis_code/assets/owl/ascii/f-020.txt +13 -0
- alysis_code/assets/owl/index.html +98 -0
- alysis_code/assets/owl/show-owl.sh +761 -0
- alysis_code/assets/paths.py +49 -0
- alysis_code/assets/plan_binding.py +326 -0
- alysis_code/assets/planner_context.py +466 -0
- alysis_code/assets/planner_tools.py +184 -0
- alysis_code/assets/prompts.py +101 -0
- alysis_code/assets/replanner_context.py +239 -0
- alysis_code/assets/surface.py +521 -0
- alysis_code/assets/untrusted_content.py +48 -0
- alysis_code/assets/usage_logger.py +94 -0
- alysis_code/assets/worker_mirror.py +428 -0
- alysis_code/assets/worker_section.py +303 -0
- alysis_code/assets/worker_tools.py +468 -0
- alysis_code/atomic_io.py +83 -0
- alysis_code/auth_diagnostics.py +272 -0
- alysis_code/background_runner.py +366 -0
- alysis_code/branding.py +270 -0
- alysis_code/budget_policy.py +390 -0
- alysis_code/build_identity.py +465 -0
- alysis_code/builtin_hooks/__init__.py +7 -0
- alysis_code/builtin_hooks/notify_done_windows.py +65 -0
- alysis_code/bwrap_etc.py +76 -0
- alysis_code/cancellation.py +41 -0
- alysis_code/capabilities.py +137 -0
- alysis_code/chatgpt_codex_static_provider.py +133 -0
- alysis_code/cli.py +51 -0
- alysis_code/cli_impl/__init__.py +1 -0
- alysis_code/cli_impl/assets_cli.py +537 -0
- alysis_code/cli_impl/assets_modal.py +412 -0
- alysis_code/cli_impl/chat/__init__.py +156 -0
- alysis_code/cli_impl/chat/commands.py +2616 -0
- alysis_code/cli_impl/chat/loop.py +4508 -0
- alysis_code/cli_impl/chat/mid_turn_policy.py +125 -0
- alysis_code/cli_impl/chat/rendering.py +444 -0
- alysis_code/cli_impl/chat/state.py +124 -0
- alysis_code/cli_impl/chat_resume.py +830 -0
- alysis_code/cli_impl/chat_slash_completer.py +258 -0
- alysis_code/cli_impl/commands/__init__.py +11 -0
- alysis_code/cli_impl/commands/_shared.py +89 -0
- alysis_code/cli_impl/commands/auth.py +623 -0
- alysis_code/cli_impl/commands/chat_resume_helpers.py +1531 -0
- alysis_code/cli_impl/commands/chat_state.py +158 -0
- alysis_code/cli_impl/commands/chat_status.py +1248 -0
- alysis_code/cli_impl/commands/chat_terminal.py +942 -0
- alysis_code/cli_impl/commands/chat_tui_panels.py +1018 -0
- alysis_code/cli_impl/commands/cli_common.py +1223 -0
- alysis_code/cli_impl/commands/cli_surface.py +77 -0
- alysis_code/cli_impl/commands/config.py +131 -0
- alysis_code/cli_impl/commands/conventions.py +85 -0
- alysis_code/cli_impl/commands/execution_helpers.py +350 -0
- alysis_code/cli_impl/commands/extensions.py +401 -0
- alysis_code/cli_impl/commands/forge.py +1282 -0
- alysis_code/cli_impl/commands/forge_asset_view.py +121 -0
- alysis_code/cli_impl/commands/forge_helpers.py +1215 -0
- alysis_code/cli_impl/commands/hooks.py +737 -0
- alysis_code/cli_impl/commands/ide_bridge.py +31 -0
- alysis_code/cli_impl/commands/mcp.py +700 -0
- alysis_code/cli_impl/commands/profile.py +453 -0
- alysis_code/cli_impl/commands/prompt_helpers.py +307 -0
- alysis_code/cli_impl/commands/report.py +88 -0
- alysis_code/cli_impl/commands/root.py +1118 -0
- alysis_code/cli_impl/commands/sandbox.py +184 -0
- alysis_code/cli_impl/commands/server.py +54 -0
- alysis_code/cli_impl/commands/sessions.py +252 -0
- alysis_code/cli_impl/commands/skills.py +404 -0
- alysis_code/cli_impl/commands/startup.py +946 -0
- alysis_code/cli_impl/commands/tools.py +335 -0
- alysis_code/cli_impl/commands/update.py +364 -0
- alysis_code/cli_impl/commands/welcome.py +972 -0
- alysis_code/cli_impl/config_menu.py +3882 -0
- alysis_code/cli_impl/forge.py +4509 -0
- alysis_code/cli_impl/forge_recovery.py +485 -0
- alysis_code/cli_impl/setup_wizard.py +2409 -0
- alysis_code/cli_impl/tui/__init__.py +58 -0
- alysis_code/cli_impl/tui/app.py +4551 -0
- alysis_code/cli_impl/tui/config.py +32 -0
- alysis_code/cli_impl/tui/config_flow.py +2754 -0
- alysis_code/cli_impl/tui/config_overlay.py +566 -0
- alysis_code/cli_impl/tui/content.py +78 -0
- alysis_code/cli_impl/tui/footer.py +218 -0
- alysis_code/cli_impl/tui/forge_status.py +136 -0
- alysis_code/cli_impl/tui/markdown.py +244 -0
- alysis_code/cli_impl/tui/owl.py +109 -0
- alysis_code/cli_impl/tui/plan_meta.py +477 -0
- alysis_code/cli_impl/tui/setup_app.py +519 -0
- alysis_code/cli_impl/tui/setup_flow.py +1622 -0
- alysis_code/cli_impl/tui/state.py +101 -0
- alysis_code/cli_impl/tui/subagent_identity.py +66 -0
- alysis_code/cli_impl/tui/subagent_panel.py +186 -0
- alysis_code/cli_impl/tui/surface.py +796 -0
- alysis_code/cli_impl/tui/transcript.py +514 -0
- alysis_code/cli_impl/tui/update_prompt.py +79 -0
- alysis_code/cli_impl/tui/workspace_guard.py +384 -0
- alysis_code/clipboard.py +172 -0
- alysis_code/code_review.py +1211 -0
- alysis_code/compaction/__init__.py +28 -0
- alysis_code/compaction/conversation_compactor.py +2932 -0
- alysis_code/compaction/importance.py +177 -0
- alysis_code/compaction/settings.py +297 -0
- alysis_code/compaction/tool_output_offload.py +447 -0
- alysis_code/config.py +3509 -0
- alysis_code/conflict_auto_resolver.py +895 -0
- alysis_code/context/__init__.py +1 -0
- alysis_code/context/tool_schema_budgeter.py +220 -0
- alysis_code/crash_diagnostics.py +282 -0
- alysis_code/custom_tools/README.md +34 -0
- alysis_code/custom_tools/__init__.py +43 -0
- alysis_code/custom_tools/discovery.py +903 -0
- alysis_code/custom_tools/runtime.py +1516 -0
- alysis_code/custom_tools/session.py +227 -0
- alysis_code/custom_tools/trust.py +232 -0
- alysis_code/diff_paths.py +113 -0
- alysis_code/direction_change.py +293 -0
- alysis_code/dispatch_timing.py +306 -0
- alysis_code/durable_service_manager.py +1236 -0
- alysis_code/edit_discipline.py +659 -0
- alysis_code/error_text.py +73 -0
- alysis_code/execution_budget.py +411 -0
- alysis_code/execution_context.py +915 -0
- alysis_code/execution_deadline.py +1065 -0
- alysis_code/execution_shared.py +1904 -0
- alysis_code/extensions/README.md +30 -0
- alysis_code/extensions/__init__.py +93 -0
- alysis_code/extensions/activation.py +138 -0
- alysis_code/extensions/install.py +1436 -0
- alysis_code/extensions/manifest.py +487 -0
- alysis_code/extensions/models.py +74 -0
- alysis_code/extensions/paths.py +56 -0
- alysis_code/extensions/registry.json +4 -0
- alysis_code/extensions/registry.py +52 -0
- alysis_code/extensions/state.py +83 -0
- alysis_code/extensions/workspace_trust.py +101 -0
- alysis_code/failed_task_evidence.py +369 -0
- alysis_code/failure_category.py +315 -0
- alysis_code/feedback_report.py +1647 -0
- alysis_code/file_classification.py +485 -0
- alysis_code/forge.py +2064 -0
- alysis_code/forge_completion.py +362 -0
- alysis_code/forge_events.py +475 -0
- alysis_code/frontmatter_utils.py +95 -0
- alysis_code/git_evidence.py +1181 -0
- alysis_code/git_ops.py +560 -0
- alysis_code/git_safe.py +62 -0
- alysis_code/git_worktrees.py +190 -0
- alysis_code/hooks/README.md +33 -0
- alysis_code/hooks/__init__.py +67 -0
- alysis_code/hooks/audit.py +171 -0
- alysis_code/hooks/config.py +225 -0
- alysis_code/hooks/dispatcher.py +1110 -0
- alysis_code/hooks/models.py +447 -0
- alysis_code/hooks/trust.py +202 -0
- alysis_code/host_actions.py +543 -0
- alysis_code/host_browser.py +103 -0
- alysis_code/ide/__init__.py +5 -0
- alysis_code/ide/activity_events.py +399 -0
- alysis_code/ide/approvals.py +337 -0
- alysis_code/ide/artifacts.py +153 -0
- alysis_code/ide/browser_egress_proxy.py +1076 -0
- alysis_code/ide/cdp_websocket_transport.py +1192 -0
- alysis_code/ide/change_ledger.py +1721 -0
- alysis_code/ide/context_blocks.py +979 -0
- alysis_code/ide/event_stream.py +531 -0
- alysis_code/ide/forge_protocol.py +3112 -0
- alysis_code/ide/forge_request_ledger.py +737 -0
- alysis_code/ide/health.py +965 -0
- alysis_code/ide/managed_browser.py +2251 -0
- alysis_code/ide/management_protocol.py +3414 -0
- alysis_code/ide/mcp_oauth_coordinator.py +744 -0
- alysis_code/ide/mcp_oauth_lifecycle.py +1504 -0
- alysis_code/ide/prompt_queue.py +1070 -0
- alysis_code/ide/protocol.py +191 -0
- alysis_code/ide/resumable_swarm.py +1543 -0
- alysis_code/ide/session_search.py +295 -0
- alysis_code/ide/stdio_bridge.py +9935 -0
- alysis_code/ide/structured_state.py +1579 -0
- alysis_code/ide/swarm_protocol.py +816 -0
- alysis_code/integration_gate.py +506 -0
- alysis_code/interactive_input_guard.py +39 -0
- alysis_code/interactive_plan_mode.py +26 -0
- alysis_code/internal_artifacts.py +179 -0
- alysis_code/knowledge_base.py +1409 -0
- alysis_code/knowledge_capture.py +1190 -0
- alysis_code/knowledge_librarian.py +605 -0
- alysis_code/language_policy.py +34 -0
- alysis_code/litellm_static_provider.py +535 -0
- alysis_code/llm/__init__.py +1 -0
- alysis_code/llm/anthropic_messages.py +2288 -0
- alysis_code/llm/base.py +71 -0
- alysis_code/llm/cache_capabilities.py +985 -0
- alysis_code/llm/cache_control_blocks.py +244 -0
- alysis_code/llm/cache_policy.py +388 -0
- alysis_code/llm/factory.py +373 -0
- alysis_code/llm/gemini_generate_content.py +2652 -0
- alysis_code/llm/gemini_interactions.py +739 -0
- alysis_code/llm/metadata.py +450 -0
- alysis_code/llm/openai_compat.py +2947 -0
- alysis_code/llm/openai_responses.py +2604 -0
- alysis_code/llm/protocols.py +609 -0
- alysis_code/llm/provider_limits.py +525 -0
- alysis_code/llm/request_plan.py +389 -0
- alysis_code/llm/request_shape.py +238 -0
- alysis_code/llm/streaming.py +108 -0
- alysis_code/llm/temperature_compat.py +78 -0
- alysis_code/llm/types.py +195 -0
- alysis_code/llm/usage_normalization.py +222 -0
- alysis_code/llm_error_display.py +315 -0
- alysis_code/logging_redaction.py +326 -0
- alysis_code/managed_host_deadline.py +196 -0
- alysis_code/mcp/README.md +33 -0
- alysis_code/mcp/__init__.py +24 -0
- alysis_code/mcp/client.py +1137 -0
- alysis_code/mcp/config.py +597 -0
- alysis_code/mcp/errors.py +113 -0
- alysis_code/mcp/forge_scope.py +154 -0
- alysis_code/mcp/jsonrpc.py +214 -0
- alysis_code/mcp/manager.py +2308 -0
- alysis_code/mcp/models.py +666 -0
- alysis_code/mcp/oauth.py +972 -0
- alysis_code/mcp/oauth_runtime.py +310 -0
- alysis_code/mcp/oauth_store.py +276 -0
- alysis_code/mcp/prompts.py +329 -0
- alysis_code/mcp/resources.py +295 -0
- alysis_code/mcp/roots.py +106 -0
- alysis_code/mcp/server_requests.py +75 -0
- alysis_code/mcp/token_store.py +859 -0
- alysis_code/mcp/transport_http.py +1338 -0
- alysis_code/mcp/transport_stdio.py +1267 -0
- alysis_code/mcp/untrusted_content.py +119 -0
- alysis_code/merge_conflict_reviewer.py +729 -0
- alysis_code/model_catalog/__init__.py +1 -0
- alysis_code/model_catalog/chatgpt_codex_subscription_snapshot.json +186 -0
- alysis_code/model_catalog/litellm_model_prices_snapshot.json +44715 -0
- alysis_code/model_catalog/litellm_model_prices_snapshot.meta.json +17 -0
- alysis_code/model_metadata_policy.py +223 -0
- alysis_code/model_metadata_utils.py +103 -0
- alysis_code/model_registry.py +1420 -0
- alysis_code/model_router.py +147 -0
- alysis_code/permission_policy.py +1016 -0
- alysis_code/personas.py +451 -0
- alysis_code/pipeline_facts.py +233 -0
- alysis_code/plan_assistant.py +4763 -0
- alysis_code/plan_mode.py +393 -0
- alysis_code/plan_reconciliation.py +1228 -0
- alysis_code/plan_repair.py +652 -0
- alysis_code/plan_validation.py +1099 -0
- alysis_code/planning_constraints.py +904 -0
- alysis_code/policy.py +95 -0
- alysis_code/preview_server.py +457 -0
- alysis_code/process_reaping.py +566 -0
- alysis_code/profile_presets.py +1834 -0
- alysis_code/profiles.py +666 -0
- alysis_code/provider_auth/__init__.py +29 -0
- alysis_code/provider_auth/base.py +99 -0
- alysis_code/provider_auth/openai_codex.py +951 -0
- alysis_code/provider_auth/registry.py +76 -0
- alysis_code/provider_auth/store.py +125 -0
- alysis_code/provider_diagnostics.py +1209 -0
- alysis_code/provider_model_catalog.py +685 -0
- alysis_code/provider_telemetry.py +1699 -0
- alysis_code/provider_url.py +75 -0
- alysis_code/reasoning_contracts.py +911 -0
- alysis_code/remote_sync.py +350 -0
- alysis_code/replanning.py +1195 -0
- alysis_code/repo_scan.py +1152 -0
- alysis_code/request_estimation.py +296 -0
- alysis_code/review_gate.py +617 -0
- alysis_code/run_lock.py +1141 -0
- alysis_code/run_outcome.py +58 -0
- alysis_code/run_provenance.py +774 -0
- alysis_code/run_state.py +445 -0
- alysis_code/runtime_artifacts.py +116 -0
- alysis_code/runtime_context_features.py +78 -0
- alysis_code/runtime_kind.py +52 -0
- alysis_code/safety/__init__.py +11 -0
- alysis_code/safety/mcp_sanitize.py +29 -0
- alysis_code/safety/safe_http.py +297 -0
- alysis_code/safety/subagent_report.py +184 -0
- alysis_code/sandbox_doctor.py +682 -0
- alysis_code/sandbox_runner.py +1025 -0
- alysis_code/sandbox_settings.py +423 -0
- alysis_code/serialized_paths.py +355 -0
- alysis_code/server/__init__.py +3 -0
- alysis_code/server/app.py +367 -0
- alysis_code/server/auth.py +34 -0
- alysis_code/server/job_config.py +30 -0
- alysis_code/server/settings.py +215 -0
- alysis_code/server/store.py +193 -0
- alysis_code/server/worker_runner.py +657 -0
- alysis_code/service_persistence.py +355 -0
- alysis_code/session_artifacts.py +108 -0
- alysis_code/session_metrics.py +331 -0
- alysis_code/session_store.py +624 -0
- alysis_code/skills/README.md +34 -0
- alysis_code/skills/__init__.py +104 -0
- alysis_code/skills/conventions.py +84 -0
- alysis_code/skills/discovery.py +176 -0
- alysis_code/skills/eval_models.py +232 -0
- alysis_code/skills/eval_runner.py +372 -0
- alysis_code/skills/evals.py +1344 -0
- alysis_code/skills/install.py +293 -0
- alysis_code/skills/loader.py +118 -0
- alysis_code/skills/matching.py +103 -0
- alysis_code/skills/models.py +71 -0
- alysis_code/skills/paths.py +56 -0
- alysis_code/skills/prompting.py +500 -0
- alysis_code/skills/scaffold.py +142 -0
- alysis_code/skills/state.py +441 -0
- alysis_code/skills/transactions.py +125 -0
- alysis_code/skills/validation.py +304 -0
- alysis_code/step_budget.py +238 -0
- alysis_code/subagent_labels.py +49 -0
- alysis_code/subagents.py +1072 -0
- alysis_code/surface/__init__.py +80 -0
- alysis_code/surface/base.py +305 -0
- alysis_code/surface/console.py +387 -0
- alysis_code/surface/events.py +372 -0
- alysis_code/surface/hidden_surface.py +529 -0
- alysis_code/surface/noop_surface.py +219 -0
- alysis_code/surface/rich_surface.py +1555 -0
- alysis_code/surface/styles.py +67 -0
- alysis_code/surface/theme.py +455 -0
- alysis_code/surface/types.py +100 -0
- alysis_code/swarm_backend.py +926 -0
- alysis_code/swarm_orchestrator.py +4020 -0
- alysis_code/swarm_scheduler.py +441 -0
- alysis_code/swarm_trace.py +429 -0
- alysis_code/swarm_worker.py +2119 -0
- alysis_code/swarm_write_guard.py +348 -0
- alysis_code/task_dependencies.py +170 -0
- alysis_code/task_readiness.py +992 -0
- alysis_code/task_scope.py +2148 -0
- alysis_code/terminal_manager.py +762 -0
- alysis_code/terminal_ownership.py +460 -0
- alysis_code/text_normalization.py +30 -0
- alysis_code/token_budget.py +97 -0
- alysis_code/tools/README.md +34 -0
- alysis_code/tools/__init__.py +1 -0
- alysis_code/tools/artifacts.py +127 -0
- alysis_code/tools/availability.py +188 -0
- alysis_code/tools/fs.py +1456 -0
- alysis_code/tools/git.py +461 -0
- alysis_code/tools/history.py +229 -0
- alysis_code/tools/http_timeout.py +78 -0
- alysis_code/tools/image_generation.py +552 -0
- alysis_code/tools/registry.py +2936 -0
- alysis_code/tools/repo_map.py +476 -0
- alysis_code/tools/search.py +563 -0
- alysis_code/tools/shell.py +135 -0
- alysis_code/tools/symbols.py +1350 -0
- alysis_code/tools/test_discovery.py +643 -0
- alysis_code/tools/web.py +482 -0
- alysis_code/tools/web_search.py +2012 -0
- alysis_code/tools/web_search_dashscope.py +557 -0
- alysis_code/tools/web_search_ddgs.py +221 -0
- alysis_code/tools/web_search_provider_adapters.py +1429 -0
- alysis_code/tools/web_search_tavily.py +194 -0
- alysis_code/updates.py +933 -0
- alysis_code/usage_tracker.py +1990 -0
- alysis_code/verification_command_analysis.py +1004 -0
- alysis_code/verification_contract.py +574 -0
- alysis_code/verification_failure_summary.py +273 -0
- alysis_code/verification_repair.py +385 -0
- alysis_code/verify_gate.py +3129 -0
- alysis_code/web_research.py +1872 -0
- alysis_code/web_search_adapters.py +66 -0
- alysis_code/web_search_policy.py +27 -0
- alysis_code/workspace_binding.py +389 -0
- alysis_code/workspace_binding_ui.py +408 -0
- alysis_code/workspace_context.py +273 -0
- alysis_code/workspace_isolation.py +138 -0
- alysis_code/workspace_provisioning.py +455 -0
- alysis_code-0.13.0.dist-info/METADATA +507 -0
- alysis_code-0.13.0.dist-info/RECORD +458 -0
- alysis_code-0.13.0.dist-info/WHEEL +4 -0
- alysis_code-0.13.0.dist-info/entry_points.txt +3 -0
- alysis_code-0.13.0.dist-info/licenses/LICENSE +176 -0
- alysis_code-0.13.0.dist-info/licenses/NOTICE +4 -0
alysis_code/run_lock.py
ADDED
|
@@ -0,0 +1,1141 @@
|
|
|
1
|
+
"""The lock that keeps two Forge executions off one workspace.
|
|
2
|
+
|
|
3
|
+
A lock file is only useful if it can also be *un*-stuck. The owner therefore
|
|
4
|
+
publishes two independent liveness signals, and a would-be acquirer needs only
|
|
5
|
+
one of them to conclude the owner is gone:
|
|
6
|
+
|
|
7
|
+
* **pid** -- the owner's process id on the owner's host. `kill -9` clears this
|
|
8
|
+
instantly, but it lies when the id has been recycled by an unrelated process.
|
|
9
|
+
* **heartbeat** -- a timestamp the owner refreshes every
|
|
10
|
+
``heartbeat_interval_s`` and promises to keep fresher than ``heartbeat_ttl_s``.
|
|
11
|
+
A recycled pid cannot forge it, and a hung owner stops producing it.
|
|
12
|
+
|
|
13
|
+
Only a lock that *declared* the heartbeat contract can be reaped by it: a lock
|
|
14
|
+
written by an older build never promised to beat, so its silence proves nothing.
|
|
15
|
+
A heartbeat-expired lock is also re-read after one grace interval before it is
|
|
16
|
+
recovered, so a machine that merely came back from sleep gets to prove it is
|
|
17
|
+
alive instead of having its lock taken.
|
|
18
|
+
|
|
19
|
+
Everything else still fails closed. A lock owned by another host is ambiguous by
|
|
20
|
+
construction -- this process cannot probe that host's process table -- so it
|
|
21
|
+
blocks, and the error says exactly how old the lock is, who holds it, and which
|
|
22
|
+
command clears it.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import errno
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
import socket
|
|
31
|
+
import threading
|
|
32
|
+
import time
|
|
33
|
+
import uuid
|
|
34
|
+
from collections.abc import Callable
|
|
35
|
+
from contextlib import suppress
|
|
36
|
+
from dataclasses import dataclass
|
|
37
|
+
from datetime import UTC, datetime
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
from typing import Any
|
|
40
|
+
|
|
41
|
+
from .atomic_io import atomic_write_json
|
|
42
|
+
from .branding import env_get
|
|
43
|
+
from .forge import ForgeError, now_iso
|
|
44
|
+
|
|
45
|
+
# 2 added the heartbeat contract (``heartbeat_interval_s``/``heartbeat_ttl_s``).
|
|
46
|
+
# Readers must keep understanding version 1 locks; they simply never qualify for
|
|
47
|
+
# TTL-based recovery.
|
|
48
|
+
LOCK_SCHEMA_VERSION = 2
|
|
49
|
+
_LOCK_FILE_NAME = "active_execution.lock.json"
|
|
50
|
+
_RECOVERY_FILE_NAME = "active_execution.recovering.json"
|
|
51
|
+
_EVENTS_FILE_NAME = "active_execution.events.jsonl"
|
|
52
|
+
_WAITING_FILE_PREFIX = "active_execution.waiting."
|
|
53
|
+
_WAITING_FILE_SUFFIX = ".json"
|
|
54
|
+
_DEFAULT_POLL_INTERVAL_S = 1.0
|
|
55
|
+
_WINDOWS_PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
|
56
|
+
_WINDOWS_STILL_ACTIVE = 259
|
|
57
|
+
_WINDOWS_ERROR_INVALID_PARAMETER = 87
|
|
58
|
+
|
|
59
|
+
DEFAULT_HEARTBEAT_INTERVAL_S = 15.0
|
|
60
|
+
# Eight missed beats. Long enough that a busy machine, a slow filesystem or a
|
|
61
|
+
# short suspend never trips it; short enough that a recycled pid unblocks the
|
|
62
|
+
# workspace in a couple of minutes rather than never.
|
|
63
|
+
DEFAULT_HEARTBEAT_TTL_S = 120.0
|
|
64
|
+
_MIN_HEARTBEAT_INTERVAL_S = 0.05
|
|
65
|
+
_MAX_RECOVERY_GRACE_S = 5.0
|
|
66
|
+
|
|
67
|
+
_HEARTBEAT_ENABLED_ENV = "ALYSIS_RUN_LOCK_HEARTBEAT"
|
|
68
|
+
_HEARTBEAT_INTERVAL_ENV = "ALYSIS_RUN_LOCK_HEARTBEAT_INTERVAL_S"
|
|
69
|
+
_HEARTBEAT_TTL_ENV = "ALYSIS_RUN_LOCK_TTL_S"
|
|
70
|
+
|
|
71
|
+
STALENESS_ACTIVE = "active"
|
|
72
|
+
STALENESS_STALE = "stale"
|
|
73
|
+
STALENESS_AMBIGUOUS = "ambiguous"
|
|
74
|
+
|
|
75
|
+
STALE_KIND_DEAD_PID = "dead_pid"
|
|
76
|
+
STALE_KIND_HEARTBEAT_EXPIRED = "heartbeat_expired"
|
|
77
|
+
|
|
78
|
+
UNLOCK_COMMAND = "alysis forge unlock"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class RunMutationConflictError(ForgeError):
|
|
82
|
+
def __init__(
|
|
83
|
+
self,
|
|
84
|
+
message: str,
|
|
85
|
+
*,
|
|
86
|
+
reason_code: str = "active_workspace_execution",
|
|
87
|
+
metadata: dict[str, Any] | None = None,
|
|
88
|
+
diagnostic: str | None = None,
|
|
89
|
+
) -> None:
|
|
90
|
+
super().__init__(message)
|
|
91
|
+
self.reason_code = reason_code
|
|
92
|
+
self.metadata = metadata
|
|
93
|
+
self.diagnostic = diagnostic or message
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class _HeartbeatWorker:
|
|
97
|
+
"""Refreshes one lock's heartbeat on a daemon thread until it is stopped.
|
|
98
|
+
|
|
99
|
+
A lock whose owner stops beating gets reaped by its own TTL, so the one thing
|
|
100
|
+
this must never do is give up. A single refresh can fail for reasons that have
|
|
101
|
+
nothing to do with the owner's health -- on Windows an ``os.replace`` races a
|
|
102
|
+
concurrent reader of the same lock file and raises ``PermissionError`` -- and
|
|
103
|
+
treating that as fatal would silently hand the workspace to the next acquirer
|
|
104
|
+
while this process is still mutating it. So every failure is counted and
|
|
105
|
+
retried on the next tick, and only :meth:`stop` ends the loop.
|
|
106
|
+
|
|
107
|
+
Failures are never raised either: a heartbeat that cannot be written must not
|
|
108
|
+
take down the execution it exists to protect.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
def __init__(self, *, refresh: Callable[[], None], interval_s: float) -> None:
|
|
112
|
+
self._refresh = refresh
|
|
113
|
+
self._interval_s = max(_MIN_HEARTBEAT_INTERVAL_S, float(interval_s))
|
|
114
|
+
self._stop = threading.Event()
|
|
115
|
+
self._consecutive_failures = 0
|
|
116
|
+
self._thread = threading.Thread(
|
|
117
|
+
target=self._loop,
|
|
118
|
+
name="alysis-run-lock-heartbeat",
|
|
119
|
+
daemon=True,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def consecutive_failures(self) -> int:
|
|
124
|
+
return self._consecutive_failures
|
|
125
|
+
|
|
126
|
+
def start(self) -> None:
|
|
127
|
+
self._thread.start()
|
|
128
|
+
|
|
129
|
+
def stop(self) -> None:
|
|
130
|
+
self._stop.set()
|
|
131
|
+
|
|
132
|
+
def _loop(self) -> None:
|
|
133
|
+
while not self._stop.wait(self._interval_s):
|
|
134
|
+
try:
|
|
135
|
+
self._refresh()
|
|
136
|
+
except Exception: # noqa: BLE001 - see class docstring
|
|
137
|
+
self._consecutive_failures += 1
|
|
138
|
+
continue
|
|
139
|
+
self._consecutive_failures = 0
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@dataclass(frozen=True)
|
|
143
|
+
class RunMutationGuard:
|
|
144
|
+
run_id: str
|
|
145
|
+
mode: str
|
|
146
|
+
run_dir: Path
|
|
147
|
+
workspace_root: Path
|
|
148
|
+
owner_token: str
|
|
149
|
+
lock_path: Path
|
|
150
|
+
recovery_path: Path
|
|
151
|
+
acquired_after_wait: bool = False
|
|
152
|
+
wait_started_at: str | None = None
|
|
153
|
+
wait_finished_at: str | None = None
|
|
154
|
+
wait_record_path: Path | None = None
|
|
155
|
+
heartbeat: _HeartbeatWorker | None = None
|
|
156
|
+
|
|
157
|
+
def __enter__(self) -> RunMutationGuard:
|
|
158
|
+
return self
|
|
159
|
+
|
|
160
|
+
def __exit__(self, exc_type: object, exc: object, tb: object) -> None:
|
|
161
|
+
_ = exc_type
|
|
162
|
+
_ = exc
|
|
163
|
+
_ = tb
|
|
164
|
+
self.release()
|
|
165
|
+
|
|
166
|
+
def __del__(self) -> None:
|
|
167
|
+
with suppress(Exception):
|
|
168
|
+
self.release()
|
|
169
|
+
|
|
170
|
+
def release(self) -> None:
|
|
171
|
+
if self.heartbeat is not None:
|
|
172
|
+
self.heartbeat.stop()
|
|
173
|
+
current = _load_metadata(self.lock_path)
|
|
174
|
+
if current is None:
|
|
175
|
+
return
|
|
176
|
+
if str(current.get("owner_token") or "").strip() != self.owner_token:
|
|
177
|
+
return
|
|
178
|
+
with suppress(FileNotFoundError):
|
|
179
|
+
self.lock_path.unlink()
|
|
180
|
+
if self.wait_record_path is not None:
|
|
181
|
+
with suppress(FileNotFoundError):
|
|
182
|
+
self.wait_record_path.unlink()
|
|
183
|
+
current_recovery = _load_metadata(self.recovery_path)
|
|
184
|
+
if current_recovery is None:
|
|
185
|
+
return
|
|
186
|
+
if str(current_recovery.get("owner_token") or "").strip() != self.owner_token:
|
|
187
|
+
return
|
|
188
|
+
with suppress(FileNotFoundError):
|
|
189
|
+
self.recovery_path.unlink()
|
|
190
|
+
|
|
191
|
+
def refresh_heartbeat(self) -> None:
|
|
192
|
+
current = _load_metadata(self.lock_path)
|
|
193
|
+
if current is None:
|
|
194
|
+
return
|
|
195
|
+
if str(current.get("owner_token") or "").strip() != self.owner_token:
|
|
196
|
+
return
|
|
197
|
+
current["last_heartbeat_at"] = now_iso()
|
|
198
|
+
atomic_write_json(self.lock_path, current)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def acquire_run_mutation_guard(
|
|
202
|
+
*,
|
|
203
|
+
run_id: str,
|
|
204
|
+
mode: str,
|
|
205
|
+
run_dir: Path,
|
|
206
|
+
workspace_root: Path,
|
|
207
|
+
wait: bool = False,
|
|
208
|
+
wait_timeout_s: float | None = None,
|
|
209
|
+
poll_interval_s: float = _DEFAULT_POLL_INTERVAL_S,
|
|
210
|
+
on_wait: Callable[[dict[str, Any]], None] | None = None,
|
|
211
|
+
owner_session_id: str | None = None,
|
|
212
|
+
) -> RunMutationGuard:
|
|
213
|
+
run_dir.mkdir(parents=True, exist_ok=True)
|
|
214
|
+
lock_path = run_dir / _LOCK_FILE_NAME
|
|
215
|
+
recovery_path = run_dir / _RECOVERY_FILE_NAME
|
|
216
|
+
events_path = run_dir / _EVENTS_FILE_NAME
|
|
217
|
+
owner_token = uuid.uuid4().hex
|
|
218
|
+
owner_id = _owner_id(owner_token=owner_token, owner_session_id=owner_session_id)
|
|
219
|
+
wait_started_monotonic = time.monotonic()
|
|
220
|
+
wait_started_at: str | None = None
|
|
221
|
+
wait_finished_at: str | None = None
|
|
222
|
+
wait_record_path: Path | None = None
|
|
223
|
+
acquired_after_wait = False
|
|
224
|
+
wait_notice_emitted = False
|
|
225
|
+
|
|
226
|
+
while True:
|
|
227
|
+
_clear_stale_recovery_claim(
|
|
228
|
+
recovery_path=recovery_path,
|
|
229
|
+
run_id=run_id,
|
|
230
|
+
mode=mode,
|
|
231
|
+
)
|
|
232
|
+
metadata = _build_metadata(
|
|
233
|
+
run_id=run_id,
|
|
234
|
+
mode=mode,
|
|
235
|
+
workspace_root=workspace_root,
|
|
236
|
+
run_dir=run_dir,
|
|
237
|
+
owner_token=owner_token,
|
|
238
|
+
owner_id=owner_id,
|
|
239
|
+
owner_session_id=owner_session_id,
|
|
240
|
+
kind="lock",
|
|
241
|
+
)
|
|
242
|
+
metadata_text = _metadata_text(metadata)
|
|
243
|
+
try:
|
|
244
|
+
_write_exclusive(lock_path, metadata_text)
|
|
245
|
+
except FileExistsError:
|
|
246
|
+
existing_metadata, existing_text = _load_metadata_with_text(lock_path)
|
|
247
|
+
if existing_metadata is None or existing_text is None:
|
|
248
|
+
raise _conflict_error(
|
|
249
|
+
run_id=run_id,
|
|
250
|
+
mode=mode,
|
|
251
|
+
metadata=None,
|
|
252
|
+
note="the active run lock exists but its metadata is unreadable, so recovery is blocked",
|
|
253
|
+
) from None
|
|
254
|
+
staleness = assess_lock_staleness(existing_metadata)
|
|
255
|
+
stale_reason = staleness.reason if staleness.recoverable else None
|
|
256
|
+
if stale_reason is None:
|
|
257
|
+
conflict = _conflict_error(
|
|
258
|
+
run_id=run_id,
|
|
259
|
+
mode=mode,
|
|
260
|
+
metadata=existing_metadata,
|
|
261
|
+
)
|
|
262
|
+
if not wait:
|
|
263
|
+
raise conflict from None
|
|
264
|
+
now_monotonic = time.monotonic()
|
|
265
|
+
if wait_timeout_s is not None and now_monotonic - wait_started_monotonic >= max(
|
|
266
|
+
0.0, wait_timeout_s
|
|
267
|
+
):
|
|
268
|
+
if wait_record_path is not None:
|
|
269
|
+
_append_event(
|
|
270
|
+
events_path,
|
|
271
|
+
{
|
|
272
|
+
"schema_version": LOCK_SCHEMA_VERSION,
|
|
273
|
+
"event": "queued_wait_timed_out",
|
|
274
|
+
"reason_code": "active_execution_wait_timeout",
|
|
275
|
+
"run_id": run_id,
|
|
276
|
+
"mode": mode,
|
|
277
|
+
"owner_id": owner_id,
|
|
278
|
+
"workspace_root": _safe_resolved_path(workspace_root),
|
|
279
|
+
"workspace_identity": normalize_workspace_identity_path(
|
|
280
|
+
workspace_root
|
|
281
|
+
),
|
|
282
|
+
"run_dir": _safe_resolved_path(run_dir),
|
|
283
|
+
"wait_started_at": wait_started_at,
|
|
284
|
+
},
|
|
285
|
+
)
|
|
286
|
+
with suppress(FileNotFoundError):
|
|
287
|
+
wait_record_path.unlink()
|
|
288
|
+
raise _conflict_error(
|
|
289
|
+
run_id=run_id,
|
|
290
|
+
mode=mode,
|
|
291
|
+
metadata=existing_metadata,
|
|
292
|
+
note=(
|
|
293
|
+
"queued execution timed out waiting for the active mutation guard "
|
|
294
|
+
"to finish"
|
|
295
|
+
),
|
|
296
|
+
reason_code="active_execution_wait_timeout",
|
|
297
|
+
) from None
|
|
298
|
+
if wait_started_at is None:
|
|
299
|
+
wait_started_at = now_iso()
|
|
300
|
+
acquired_after_wait = True
|
|
301
|
+
wait_record_path = run_dir / (
|
|
302
|
+
f"{_WAITING_FILE_PREFIX}{owner_token}{_WAITING_FILE_SUFFIX}"
|
|
303
|
+
)
|
|
304
|
+
wait_payload = _build_wait_metadata(
|
|
305
|
+
run_id=run_id,
|
|
306
|
+
mode=mode,
|
|
307
|
+
workspace_root=workspace_root,
|
|
308
|
+
run_dir=run_dir,
|
|
309
|
+
owner_token=owner_token,
|
|
310
|
+
owner_id=owner_id,
|
|
311
|
+
owner_session_id=owner_session_id,
|
|
312
|
+
blocked_by=existing_metadata,
|
|
313
|
+
started_at=wait_started_at,
|
|
314
|
+
diagnostic=conflict.diagnostic,
|
|
315
|
+
)
|
|
316
|
+
atomic_write_json(wait_record_path, wait_payload)
|
|
317
|
+
_append_event(events_path, {**wait_payload, "event": "queued_wait_started"})
|
|
318
|
+
if on_wait is not None and not wait_notice_emitted:
|
|
319
|
+
on_wait(
|
|
320
|
+
{
|
|
321
|
+
"reason_code": conflict.reason_code,
|
|
322
|
+
"diagnostic": conflict.diagnostic,
|
|
323
|
+
"blocked_by": _public_lock_metadata(existing_metadata),
|
|
324
|
+
"run_id": run_id,
|
|
325
|
+
"mode": mode,
|
|
326
|
+
"wait_started_at": wait_started_at,
|
|
327
|
+
}
|
|
328
|
+
)
|
|
329
|
+
wait_notice_emitted = True
|
|
330
|
+
time.sleep(max(0.05, min(float(poll_interval_s), 5.0)))
|
|
331
|
+
continue
|
|
332
|
+
recovery_metadata = _build_metadata(
|
|
333
|
+
run_id=run_id,
|
|
334
|
+
mode=mode,
|
|
335
|
+
workspace_root=workspace_root,
|
|
336
|
+
run_dir=run_dir,
|
|
337
|
+
owner_token=owner_token,
|
|
338
|
+
owner_id=owner_id,
|
|
339
|
+
owner_session_id=owner_session_id,
|
|
340
|
+
kind="recovery",
|
|
341
|
+
recovery_reason=stale_reason,
|
|
342
|
+
)
|
|
343
|
+
try:
|
|
344
|
+
_write_exclusive(recovery_path, _metadata_text(recovery_metadata))
|
|
345
|
+
except FileExistsError:
|
|
346
|
+
_clear_stale_recovery_claim(
|
|
347
|
+
recovery_path=recovery_path,
|
|
348
|
+
run_id=run_id,
|
|
349
|
+
mode=mode,
|
|
350
|
+
)
|
|
351
|
+
raise _conflict_error(
|
|
352
|
+
run_id=run_id,
|
|
353
|
+
mode=mode,
|
|
354
|
+
metadata=existing_metadata,
|
|
355
|
+
note="another execution is already recovering the stale run lock",
|
|
356
|
+
) from None
|
|
357
|
+
try:
|
|
358
|
+
# A lock that only *looks* expired gets one grace interval to prove
|
|
359
|
+
# otherwise. A machine coming back from suspend refreshes its
|
|
360
|
+
# heartbeat within one interval, and the unchanged-text check below
|
|
361
|
+
# then reads it as active. A dead pid needs no such courtesy.
|
|
362
|
+
if staleness.kind == STALE_KIND_HEARTBEAT_EXPIRED:
|
|
363
|
+
time.sleep(_recovery_grace_s(existing_metadata))
|
|
364
|
+
current_metadata, current_text = _load_metadata_with_text(lock_path)
|
|
365
|
+
if current_metadata is None or current_text is None:
|
|
366
|
+
raise _conflict_error(
|
|
367
|
+
run_id=run_id,
|
|
368
|
+
mode=mode,
|
|
369
|
+
metadata=None,
|
|
370
|
+
note="the active run lock disappeared or became unreadable during recovery",
|
|
371
|
+
)
|
|
372
|
+
if current_text != existing_text:
|
|
373
|
+
raise _conflict_error(
|
|
374
|
+
run_id=run_id,
|
|
375
|
+
mode=mode,
|
|
376
|
+
metadata=current_metadata,
|
|
377
|
+
note="the active run lock changed while recovery was in progress",
|
|
378
|
+
)
|
|
379
|
+
if not assess_lock_staleness(current_metadata).recoverable:
|
|
380
|
+
raise _conflict_error(
|
|
381
|
+
run_id=run_id,
|
|
382
|
+
mode=mode,
|
|
383
|
+
metadata=current_metadata,
|
|
384
|
+
note="the active run lock is no longer definitely stale",
|
|
385
|
+
)
|
|
386
|
+
lock_path.unlink()
|
|
387
|
+
_append_event(
|
|
388
|
+
events_path,
|
|
389
|
+
{
|
|
390
|
+
**recovery_metadata,
|
|
391
|
+
"event": "stale_lock_recovered",
|
|
392
|
+
"recovered_owner": _public_lock_metadata(current_metadata),
|
|
393
|
+
},
|
|
394
|
+
)
|
|
395
|
+
try:
|
|
396
|
+
metadata = _build_metadata(
|
|
397
|
+
run_id=run_id,
|
|
398
|
+
mode=mode,
|
|
399
|
+
workspace_root=workspace_root,
|
|
400
|
+
run_dir=run_dir,
|
|
401
|
+
owner_token=owner_token,
|
|
402
|
+
owner_id=owner_id,
|
|
403
|
+
owner_session_id=owner_session_id,
|
|
404
|
+
kind="lock",
|
|
405
|
+
)
|
|
406
|
+
metadata_text = _metadata_text(metadata)
|
|
407
|
+
_write_exclusive(lock_path, metadata_text)
|
|
408
|
+
except FileExistsError:
|
|
409
|
+
replacement_metadata = _load_metadata(lock_path)
|
|
410
|
+
raise _conflict_error(
|
|
411
|
+
run_id=run_id,
|
|
412
|
+
mode=mode,
|
|
413
|
+
metadata=replacement_metadata,
|
|
414
|
+
note="another execution claimed the run while stale-lock recovery was finalizing",
|
|
415
|
+
) from None
|
|
416
|
+
finally:
|
|
417
|
+
current_recovery = _load_metadata(recovery_path)
|
|
418
|
+
if (
|
|
419
|
+
current_recovery is not None
|
|
420
|
+
and str(current_recovery.get("owner_token") or "").strip() == owner_token
|
|
421
|
+
):
|
|
422
|
+
with suppress(FileNotFoundError):
|
|
423
|
+
recovery_path.unlink()
|
|
424
|
+
break
|
|
425
|
+
if acquired_after_wait:
|
|
426
|
+
wait_finished_at = now_iso()
|
|
427
|
+
_append_event(
|
|
428
|
+
events_path,
|
|
429
|
+
{
|
|
430
|
+
"schema_version": LOCK_SCHEMA_VERSION,
|
|
431
|
+
"event": "queued_wait_finished",
|
|
432
|
+
"reason_code": "queued_execution_acquired_lock",
|
|
433
|
+
"run_id": run_id,
|
|
434
|
+
"mode": mode,
|
|
435
|
+
"owner_id": owner_id,
|
|
436
|
+
"workspace_root": _safe_resolved_path(workspace_root),
|
|
437
|
+
"workspace_identity": normalize_workspace_identity_path(workspace_root),
|
|
438
|
+
"run_dir": _safe_resolved_path(run_dir),
|
|
439
|
+
"wait_started_at": wait_started_at,
|
|
440
|
+
"wait_finished_at": wait_finished_at,
|
|
441
|
+
},
|
|
442
|
+
)
|
|
443
|
+
guard = RunMutationGuard(
|
|
444
|
+
run_id=run_id,
|
|
445
|
+
mode=mode,
|
|
446
|
+
run_dir=run_dir,
|
|
447
|
+
workspace_root=workspace_root,
|
|
448
|
+
owner_token=owner_token,
|
|
449
|
+
lock_path=lock_path,
|
|
450
|
+
recovery_path=recovery_path,
|
|
451
|
+
acquired_after_wait=acquired_after_wait,
|
|
452
|
+
wait_started_at=wait_started_at,
|
|
453
|
+
wait_finished_at=wait_finished_at,
|
|
454
|
+
wait_record_path=wait_record_path,
|
|
455
|
+
)
|
|
456
|
+
worker = _start_heartbeat_worker(guard)
|
|
457
|
+
if worker is not None:
|
|
458
|
+
# The guard is frozen so callers cannot repoint it at another lock; the
|
|
459
|
+
# heartbeat is owner state that only exists once the guard does.
|
|
460
|
+
object.__setattr__(guard, "heartbeat", worker)
|
|
461
|
+
return guard
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _start_heartbeat_worker(guard: RunMutationGuard) -> _HeartbeatWorker | None:
|
|
465
|
+
if not heartbeat_enabled():
|
|
466
|
+
return None
|
|
467
|
+
worker = _HeartbeatWorker(
|
|
468
|
+
refresh=guard.refresh_heartbeat,
|
|
469
|
+
interval_s=heartbeat_interval_s(),
|
|
470
|
+
)
|
|
471
|
+
worker.start()
|
|
472
|
+
return worker
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def inspect_run_mutation_lock(run_dir: Path) -> dict[str, Any] | None:
|
|
476
|
+
return _load_metadata(run_dir / _LOCK_FILE_NAME)
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def run_mutation_lock_path(run_dir: Path) -> Path:
|
|
480
|
+
return run_dir / _LOCK_FILE_NAME
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def heartbeat_enabled() -> bool:
|
|
484
|
+
raw = env_get(_HEARTBEAT_ENABLED_ENV)
|
|
485
|
+
if raw is None:
|
|
486
|
+
return True
|
|
487
|
+
return raw.strip().lower() not in {"0", "false", "no", "off"}
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def heartbeat_interval_s() -> float:
|
|
491
|
+
return _env_positive_float(_HEARTBEAT_INTERVAL_ENV, DEFAULT_HEARTBEAT_INTERVAL_S)
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def heartbeat_ttl_s() -> float:
|
|
495
|
+
ttl = _env_positive_float(_HEARTBEAT_TTL_ENV, DEFAULT_HEARTBEAT_TTL_S)
|
|
496
|
+
# A TTL at or below the beat interval would reap healthy owners between beats.
|
|
497
|
+
return max(ttl, heartbeat_interval_s() * 2.0)
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _env_positive_float(name: str, default: float) -> float:
|
|
501
|
+
raw = env_get(name)
|
|
502
|
+
if raw is None or not raw.strip():
|
|
503
|
+
return default
|
|
504
|
+
try:
|
|
505
|
+
value = float(raw)
|
|
506
|
+
except ValueError:
|
|
507
|
+
return default
|
|
508
|
+
return value if value > 0 else default
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
@dataclass(frozen=True)
|
|
512
|
+
class LockStaleness:
|
|
513
|
+
"""Why one lock file is (or is not) safe to take over.
|
|
514
|
+
|
|
515
|
+
``verdict`` is the only thing callers should branch on:
|
|
516
|
+
|
|
517
|
+
* ``active`` -- the owner is provably alive. Never recover.
|
|
518
|
+
* ``ambiguous`` -- liveness cannot be established from here (another host, an
|
|
519
|
+
unreadable pid, a lock that never promised a heartbeat). Never recover.
|
|
520
|
+
* ``stale`` -- the owner is provably gone. Safe to recover.
|
|
521
|
+
"""
|
|
522
|
+
|
|
523
|
+
verdict: str
|
|
524
|
+
reason: str
|
|
525
|
+
kind: str | None = None
|
|
526
|
+
same_host: bool = False
|
|
527
|
+
pid: int | None = None
|
|
528
|
+
hostname: str | None = None
|
|
529
|
+
owner_id: str | None = None
|
|
530
|
+
mode: str | None = None
|
|
531
|
+
age_s: float | None = None
|
|
532
|
+
heartbeat_age_s: float | None = None
|
|
533
|
+
heartbeat_ttl_s: float | None = None
|
|
534
|
+
process_running: bool | None = None
|
|
535
|
+
|
|
536
|
+
@property
|
|
537
|
+
def recoverable(self) -> bool:
|
|
538
|
+
return self.verdict == STALENESS_STALE
|
|
539
|
+
|
|
540
|
+
def to_json(self) -> dict[str, Any]:
|
|
541
|
+
return {
|
|
542
|
+
"verdict": self.verdict,
|
|
543
|
+
"reason": self.reason,
|
|
544
|
+
"kind": self.kind,
|
|
545
|
+
"same_host": self.same_host,
|
|
546
|
+
"pid": self.pid,
|
|
547
|
+
"hostname": self.hostname,
|
|
548
|
+
"owner_id": self.owner_id,
|
|
549
|
+
"mode": self.mode,
|
|
550
|
+
"age_s": self.age_s,
|
|
551
|
+
"heartbeat_age_s": self.heartbeat_age_s,
|
|
552
|
+
"heartbeat_ttl_s": self.heartbeat_ttl_s,
|
|
553
|
+
"process_running": self.process_running,
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def assess_lock_staleness(
|
|
558
|
+
metadata: dict[str, Any] | None,
|
|
559
|
+
*,
|
|
560
|
+
now: datetime | None = None,
|
|
561
|
+
) -> LockStaleness:
|
|
562
|
+
"""Classify a lock file's owner as active, ambiguous, or provably gone."""
|
|
563
|
+
if metadata is None:
|
|
564
|
+
return LockStaleness(
|
|
565
|
+
verdict=STALENESS_AMBIGUOUS,
|
|
566
|
+
reason="lock metadata is missing or unreadable",
|
|
567
|
+
)
|
|
568
|
+
reference = now or datetime.now(UTC)
|
|
569
|
+
hostname = _clean_str(metadata.get("hostname"))
|
|
570
|
+
owner_id = _clean_str(metadata.get("owner_id"))
|
|
571
|
+
mode = _clean_str(metadata.get("mode"))
|
|
572
|
+
pid = _coerce_pid(metadata.get("pid"))
|
|
573
|
+
age_s = _age_seconds(metadata.get("acquired_at"), reference)
|
|
574
|
+
heartbeat_age_s = _age_seconds(metadata.get("last_heartbeat_at"), reference)
|
|
575
|
+
declared_ttl = _coerce_positive_float(metadata.get("heartbeat_ttl_s"))
|
|
576
|
+
same_host = hostname == socket.gethostname()
|
|
577
|
+
|
|
578
|
+
base = {
|
|
579
|
+
"kind": None,
|
|
580
|
+
"same_host": same_host,
|
|
581
|
+
"pid": pid,
|
|
582
|
+
"hostname": hostname,
|
|
583
|
+
"owner_id": owner_id,
|
|
584
|
+
"mode": mode,
|
|
585
|
+
"age_s": age_s,
|
|
586
|
+
"heartbeat_age_s": heartbeat_age_s,
|
|
587
|
+
"heartbeat_ttl_s": declared_ttl,
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
if not same_host:
|
|
591
|
+
return LockStaleness(
|
|
592
|
+
verdict=STALENESS_AMBIGUOUS,
|
|
593
|
+
reason=(
|
|
594
|
+
f"lock is held by another host ({hostname or 'unknown'}); this machine "
|
|
595
|
+
"cannot tell whether that process is still running"
|
|
596
|
+
),
|
|
597
|
+
**base,
|
|
598
|
+
)
|
|
599
|
+
|
|
600
|
+
process_running = _process_is_running(pid) if pid is not None else None
|
|
601
|
+
base["process_running"] = process_running
|
|
602
|
+
|
|
603
|
+
if process_running is False:
|
|
604
|
+
return LockStaleness(
|
|
605
|
+
verdict=STALENESS_STALE,
|
|
606
|
+
reason="owner process is no longer running",
|
|
607
|
+
**{**base, "kind": STALE_KIND_DEAD_PID},
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
# A pid that still resolves is not proof of life: process ids get recycled, and
|
|
611
|
+
# the new holder of the id knows nothing about this lock. The heartbeat is what
|
|
612
|
+
# separates "same process, still working" from "same number, different process".
|
|
613
|
+
if declared_ttl is not None and heartbeat_age_s is not None and heartbeat_age_s > declared_ttl:
|
|
614
|
+
return LockStaleness(
|
|
615
|
+
verdict=STALENESS_STALE,
|
|
616
|
+
reason=(
|
|
617
|
+
f"heartbeat is {_format_age(heartbeat_age_s)} old, past the "
|
|
618
|
+
f"{_format_age(declared_ttl)} the owner promised to stay within"
|
|
619
|
+
),
|
|
620
|
+
**{**base, "kind": STALE_KIND_HEARTBEAT_EXPIRED},
|
|
621
|
+
)
|
|
622
|
+
|
|
623
|
+
if pid is None:
|
|
624
|
+
return LockStaleness(
|
|
625
|
+
verdict=STALENESS_AMBIGUOUS,
|
|
626
|
+
reason="lock records no usable owner pid",
|
|
627
|
+
**base,
|
|
628
|
+
)
|
|
629
|
+
if process_running is None:
|
|
630
|
+
return LockStaleness(
|
|
631
|
+
verdict=STALENESS_AMBIGUOUS,
|
|
632
|
+
reason=f"owner pid {pid} could not be probed on this host",
|
|
633
|
+
**base,
|
|
634
|
+
)
|
|
635
|
+
if declared_ttl is None:
|
|
636
|
+
return LockStaleness(
|
|
637
|
+
verdict=STALENESS_ACTIVE,
|
|
638
|
+
reason=(
|
|
639
|
+
f"owner pid {pid} is running and the lock declares no heartbeat "
|
|
640
|
+
"contract, so its age proves nothing"
|
|
641
|
+
),
|
|
642
|
+
**base,
|
|
643
|
+
)
|
|
644
|
+
return LockStaleness(
|
|
645
|
+
verdict=STALENESS_ACTIVE,
|
|
646
|
+
reason=f"owner pid {pid} is running and its heartbeat is current",
|
|
647
|
+
**base,
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def describe_run_mutation_lock(
|
|
652
|
+
run_dir: Path,
|
|
653
|
+
*,
|
|
654
|
+
label: str = "",
|
|
655
|
+
) -> dict[str, Any] | None:
|
|
656
|
+
"""Public, token-free description of one lock plus its staleness verdict."""
|
|
657
|
+
lock_path = run_dir / _LOCK_FILE_NAME
|
|
658
|
+
metadata, raw_text = _load_metadata_with_text(lock_path)
|
|
659
|
+
if metadata is None and raw_text is None:
|
|
660
|
+
return None
|
|
661
|
+
staleness = assess_lock_staleness(metadata)
|
|
662
|
+
return {
|
|
663
|
+
"label": label or run_dir.name,
|
|
664
|
+
"lock_path": _safe_resolved_path(lock_path),
|
|
665
|
+
"run_dir": _safe_resolved_path(run_dir),
|
|
666
|
+
"readable": metadata is not None,
|
|
667
|
+
"lock": _public_lock_metadata(metadata),
|
|
668
|
+
"staleness": staleness.to_json(),
|
|
669
|
+
"recovery_command": _unlock_command(metadata),
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def clear_run_mutation_lock(run_dir: Path, *, force: bool = False) -> dict[str, Any]:
|
|
674
|
+
"""Delete one lock file when it is provably stale (or when ``force``).
|
|
675
|
+
|
|
676
|
+
Returns the same description :func:`describe_run_mutation_lock` produces, plus
|
|
677
|
+
``cleared`` and ``forced``. Never raises for a lock it declines to clear --
|
|
678
|
+
refusing is a reportable outcome, not an error.
|
|
679
|
+
"""
|
|
680
|
+
description = describe_run_mutation_lock(run_dir)
|
|
681
|
+
if description is None:
|
|
682
|
+
return {
|
|
683
|
+
"label": run_dir.name,
|
|
684
|
+
"lock_path": _safe_resolved_path(run_dir / _LOCK_FILE_NAME),
|
|
685
|
+
"run_dir": _safe_resolved_path(run_dir),
|
|
686
|
+
"present": False,
|
|
687
|
+
"cleared": False,
|
|
688
|
+
"forced": False,
|
|
689
|
+
}
|
|
690
|
+
verdict = str(description["staleness"]["verdict"])
|
|
691
|
+
should_clear = force or verdict == STALENESS_STALE
|
|
692
|
+
cleared = False
|
|
693
|
+
if should_clear:
|
|
694
|
+
with suppress(FileNotFoundError):
|
|
695
|
+
(run_dir / _LOCK_FILE_NAME).unlink()
|
|
696
|
+
cleared = True
|
|
697
|
+
# A recovery claim outlives its lock only when the claiming process also
|
|
698
|
+
# died; leaving it behind would block the next acquirer on a file the user
|
|
699
|
+
# was just told they had cleared.
|
|
700
|
+
with suppress(FileNotFoundError):
|
|
701
|
+
(run_dir / _RECOVERY_FILE_NAME).unlink()
|
|
702
|
+
_append_event(
|
|
703
|
+
run_dir / _EVENTS_FILE_NAME,
|
|
704
|
+
{
|
|
705
|
+
"schema_version": LOCK_SCHEMA_VERSION,
|
|
706
|
+
"event": "lock_cleared_by_operator",
|
|
707
|
+
"reason_code": "forced_unlock"
|
|
708
|
+
if force and verdict != STALENESS_STALE
|
|
709
|
+
else "stale_lock_cleared",
|
|
710
|
+
"forced": bool(force and verdict != STALENESS_STALE),
|
|
711
|
+
"verdict": verdict,
|
|
712
|
+
"cleared_by_pid": os.getpid(),
|
|
713
|
+
"cleared_by_host": socket.gethostname(),
|
|
714
|
+
"cleared_lock": description["lock"],
|
|
715
|
+
},
|
|
716
|
+
)
|
|
717
|
+
return {
|
|
718
|
+
**description,
|
|
719
|
+
"present": True,
|
|
720
|
+
"cleared": cleared,
|
|
721
|
+
"forced": bool(force and verdict != STALENESS_STALE),
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def lock_is_live(run_dir: Path) -> bool:
|
|
726
|
+
"""True when a lock file exists and its owner is not provably gone.
|
|
727
|
+
|
|
728
|
+
Deliberately conservative in the opposite direction from recovery: an
|
|
729
|
+
ambiguous lock counts as live, because a caller asking "is something running
|
|
730
|
+
here?" must not be told "no" on the strength of a question this host cannot
|
|
731
|
+
answer.
|
|
732
|
+
"""
|
|
733
|
+
metadata, raw_text = _load_metadata_with_text(run_dir / _LOCK_FILE_NAME)
|
|
734
|
+
if metadata is None and raw_text is None:
|
|
735
|
+
return False
|
|
736
|
+
return not assess_lock_staleness(metadata).recoverable
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def workspace_mutation_run_id(workspace_root: Path | str) -> str:
|
|
740
|
+
return f"workspace:{normalize_workspace_identity_path(workspace_root)}"
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def normalize_workspace_identity_path(workspace_root: Path | str) -> str:
|
|
744
|
+
raw = os.fspath(workspace_root).strip().replace("\\", "/")
|
|
745
|
+
while raw.startswith("./"):
|
|
746
|
+
raw = raw[2:]
|
|
747
|
+
raw = raw.rstrip("/") or raw
|
|
748
|
+
if _looks_like_windows_drive_path(raw):
|
|
749
|
+
drive = raw[:2].lower()
|
|
750
|
+
rest = "/".join(part for part in raw[2:].split("/") if part)
|
|
751
|
+
return f"{drive}/{rest}".rstrip("/").casefold()
|
|
752
|
+
if raw.startswith("/mnt/") and len(raw) >= 7 and raw[6:7] == "/":
|
|
753
|
+
drive = raw[5:6].lower()
|
|
754
|
+
tail = "/".join(part for part in raw[7:].split("/") if part)
|
|
755
|
+
return f"{drive}:/{tail}".rstrip("/").casefold()
|
|
756
|
+
try:
|
|
757
|
+
return os.fspath(Path(raw).expanduser().resolve()).replace("\\", "/").rstrip("/")
|
|
758
|
+
except OSError:
|
|
759
|
+
return raw
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def write_run_mutation_lock_metadata(path: Path, payload: dict[str, Any]) -> None:
|
|
763
|
+
atomic_write_json(path, payload)
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def lock_diagnostic(metadata: dict[str, Any] | None, *, run_id: str, mode: str) -> dict[str, Any]:
|
|
767
|
+
error = _conflict_error(run_id=run_id, mode=mode, metadata=metadata)
|
|
768
|
+
return {
|
|
769
|
+
"reason_code": error.reason_code,
|
|
770
|
+
"diagnostic": error.diagnostic,
|
|
771
|
+
"blocked_by": _public_lock_metadata(metadata),
|
|
772
|
+
"staleness": assess_lock_staleness(metadata).to_json(),
|
|
773
|
+
"recovery_command": _unlock_command(metadata),
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
def _owner_id(*, owner_token: str, owner_session_id: str | None) -> str:
|
|
778
|
+
session = str(owner_session_id or "").strip()
|
|
779
|
+
if session:
|
|
780
|
+
return session
|
|
781
|
+
return f"{socket.gethostname()}:{os.getpid()}:{owner_token[:12]}"
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
def _safe_resolved_path(path: Path | str) -> str:
|
|
785
|
+
try:
|
|
786
|
+
return os.fspath(Path(path).expanduser().resolve(strict=False)).replace("\\", "/")
|
|
787
|
+
except (OSError, RuntimeError, ValueError):
|
|
788
|
+
return os.fspath(path).replace("\\", "/")
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
def _looks_like_windows_drive_path(raw: str) -> bool:
|
|
792
|
+
return len(raw) >= 2 and raw[0].isalpha() and raw[1] == ":"
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
def _public_lock_metadata(metadata: dict[str, Any] | None) -> dict[str, Any] | None:
|
|
796
|
+
if metadata is None:
|
|
797
|
+
return None
|
|
798
|
+
public_keys = {
|
|
799
|
+
"schema_version",
|
|
800
|
+
"run_id",
|
|
801
|
+
"mode",
|
|
802
|
+
"kind",
|
|
803
|
+
"pid",
|
|
804
|
+
"hostname",
|
|
805
|
+
"acquired_at",
|
|
806
|
+
"started_at",
|
|
807
|
+
"last_heartbeat_at",
|
|
808
|
+
"owner_id",
|
|
809
|
+
"owner_session_id",
|
|
810
|
+
"workspace_root",
|
|
811
|
+
"workspace_identity",
|
|
812
|
+
"run_dir",
|
|
813
|
+
"stale_policy",
|
|
814
|
+
"reason_code",
|
|
815
|
+
"diagnostic",
|
|
816
|
+
"recovery_reason",
|
|
817
|
+
"heartbeat_interval_s",
|
|
818
|
+
"heartbeat_ttl_s",
|
|
819
|
+
}
|
|
820
|
+
return {key: value for key, value in metadata.items() if key in public_keys}
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
def _append_event(path: Path, payload: dict[str, Any]) -> None:
|
|
824
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
825
|
+
event_payload = dict(payload)
|
|
826
|
+
event_payload.pop("owner_token", None)
|
|
827
|
+
event_payload.setdefault("event_at", now_iso())
|
|
828
|
+
with path.open("a", encoding="utf-8") as handle:
|
|
829
|
+
handle.write(json.dumps(event_payload, sort_keys=True) + "\n")
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
def _build_metadata(
|
|
833
|
+
*,
|
|
834
|
+
run_id: str,
|
|
835
|
+
mode: str,
|
|
836
|
+
workspace_root: Path,
|
|
837
|
+
run_dir: Path,
|
|
838
|
+
owner_token: str,
|
|
839
|
+
owner_id: str,
|
|
840
|
+
owner_session_id: str | None,
|
|
841
|
+
kind: str,
|
|
842
|
+
recovery_reason: str | None = None,
|
|
843
|
+
) -> dict[str, Any]:
|
|
844
|
+
acquired_at = now_iso()
|
|
845
|
+
payload: dict[str, Any] = {
|
|
846
|
+
"schema_version": LOCK_SCHEMA_VERSION,
|
|
847
|
+
"run_id": run_id,
|
|
848
|
+
"mode": mode,
|
|
849
|
+
"kind": kind,
|
|
850
|
+
"pid": os.getpid(),
|
|
851
|
+
"hostname": socket.gethostname(),
|
|
852
|
+
"acquired_at": acquired_at,
|
|
853
|
+
"started_at": acquired_at,
|
|
854
|
+
"last_heartbeat_at": acquired_at,
|
|
855
|
+
"owner_id": owner_id,
|
|
856
|
+
"owner_session_id": owner_session_id,
|
|
857
|
+
"owner_token": owner_token,
|
|
858
|
+
"workspace_root": _safe_resolved_path(workspace_root),
|
|
859
|
+
"workspace_identity": normalize_workspace_identity_path(workspace_root),
|
|
860
|
+
"run_dir": _safe_resolved_path(run_dir),
|
|
861
|
+
"heartbeat_interval_s": heartbeat_interval_s(),
|
|
862
|
+
"heartbeat_ttl_s": heartbeat_ttl_s(),
|
|
863
|
+
"stale_policy": (
|
|
864
|
+
"same-host owner with a dead pid or a heartbeat older than heartbeat_ttl_s "
|
|
865
|
+
"is recoverable; other-host and unprobeable locks stay active"
|
|
866
|
+
),
|
|
867
|
+
"reason_code": "stale_lock_recovery" if kind == "recovery" else "active_execution_lock",
|
|
868
|
+
"diagnostic": (
|
|
869
|
+
"Recovering a definitely stale Forge mutation lock."
|
|
870
|
+
if kind == "recovery"
|
|
871
|
+
else "Forge execution is mutating this workspace/run."
|
|
872
|
+
),
|
|
873
|
+
}
|
|
874
|
+
if recovery_reason:
|
|
875
|
+
payload["recovery_reason"] = recovery_reason
|
|
876
|
+
return payload
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
def _build_wait_metadata(
|
|
880
|
+
*,
|
|
881
|
+
run_id: str,
|
|
882
|
+
mode: str,
|
|
883
|
+
workspace_root: Path,
|
|
884
|
+
run_dir: Path,
|
|
885
|
+
owner_token: str,
|
|
886
|
+
owner_id: str,
|
|
887
|
+
owner_session_id: str | None,
|
|
888
|
+
blocked_by: dict[str, Any],
|
|
889
|
+
started_at: str,
|
|
890
|
+
diagnostic: str,
|
|
891
|
+
) -> dict[str, Any]:
|
|
892
|
+
return {
|
|
893
|
+
"schema_version": LOCK_SCHEMA_VERSION,
|
|
894
|
+
"kind": "queued_wait",
|
|
895
|
+
"reason_code": "blocked_by_active_workspace_execution",
|
|
896
|
+
"run_id": run_id,
|
|
897
|
+
"mode": mode,
|
|
898
|
+
"pid": os.getpid(),
|
|
899
|
+
"hostname": socket.gethostname(),
|
|
900
|
+
"started_at": started_at,
|
|
901
|
+
"last_heartbeat_at": started_at,
|
|
902
|
+
"owner_id": owner_id,
|
|
903
|
+
"owner_session_id": owner_session_id,
|
|
904
|
+
"owner_token": owner_token,
|
|
905
|
+
"workspace_root": _safe_resolved_path(workspace_root),
|
|
906
|
+
"workspace_identity": normalize_workspace_identity_path(workspace_root),
|
|
907
|
+
"run_dir": _safe_resolved_path(run_dir),
|
|
908
|
+
"blocked_by": _public_lock_metadata(blocked_by),
|
|
909
|
+
"stale_policy": "queued wait rechecks the lock and only recovers definitely stale locks",
|
|
910
|
+
"diagnostic": diagnostic,
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
def _metadata_text(payload: dict[str, Any]) -> str:
|
|
915
|
+
return json.dumps(payload, indent=2, sort_keys=True) + "\n"
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
def _write_exclusive(path: Path, text: str) -> None:
|
|
919
|
+
flags = os.O_CREAT | os.O_EXCL | os.O_WRONLY
|
|
920
|
+
fd = os.open(path, flags, 0o644)
|
|
921
|
+
try:
|
|
922
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
923
|
+
handle.write(text)
|
|
924
|
+
except Exception:
|
|
925
|
+
with suppress(FileNotFoundError):
|
|
926
|
+
path.unlink()
|
|
927
|
+
raise
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
def _load_metadata(path: Path) -> dict[str, Any] | None:
|
|
931
|
+
payload, _ = _load_metadata_with_text(path)
|
|
932
|
+
return payload
|
|
933
|
+
|
|
934
|
+
|
|
935
|
+
def _load_metadata_with_text(path: Path) -> tuple[dict[str, Any] | None, str | None]:
|
|
936
|
+
try:
|
|
937
|
+
text = path.read_text(encoding="utf-8")
|
|
938
|
+
except FileNotFoundError:
|
|
939
|
+
return None, None
|
|
940
|
+
except OSError:
|
|
941
|
+
return None, None
|
|
942
|
+
try:
|
|
943
|
+
payload = json.loads(text)
|
|
944
|
+
except json.JSONDecodeError:
|
|
945
|
+
return None, text
|
|
946
|
+
if not isinstance(payload, dict):
|
|
947
|
+
return None, text
|
|
948
|
+
return payload, text
|
|
949
|
+
|
|
950
|
+
|
|
951
|
+
def _clear_stale_recovery_claim(
|
|
952
|
+
*,
|
|
953
|
+
recovery_path: Path,
|
|
954
|
+
run_id: str,
|
|
955
|
+
mode: str,
|
|
956
|
+
) -> None:
|
|
957
|
+
recovery_metadata = _load_metadata(recovery_path)
|
|
958
|
+
if recovery_metadata is None:
|
|
959
|
+
return
|
|
960
|
+
if assess_lock_staleness(recovery_metadata).recoverable:
|
|
961
|
+
with suppress(FileNotFoundError):
|
|
962
|
+
recovery_path.unlink()
|
|
963
|
+
return
|
|
964
|
+
raise _conflict_error(
|
|
965
|
+
run_id=run_id,
|
|
966
|
+
mode=mode,
|
|
967
|
+
metadata=recovery_metadata,
|
|
968
|
+
note="another execution is already recovering this run",
|
|
969
|
+
)
|
|
970
|
+
|
|
971
|
+
|
|
972
|
+
def _definitely_stale_reason(metadata: dict[str, Any]) -> str | None:
|
|
973
|
+
"""Back-compatible thin wrapper over :func:`assess_lock_staleness`."""
|
|
974
|
+
staleness = assess_lock_staleness(metadata)
|
|
975
|
+
return staleness.reason if staleness.recoverable else None
|
|
976
|
+
|
|
977
|
+
|
|
978
|
+
def _clean_str(value: Any) -> str | None:
|
|
979
|
+
text = str(value or "").strip()
|
|
980
|
+
return text or None
|
|
981
|
+
|
|
982
|
+
|
|
983
|
+
def _coerce_pid(raw: Any) -> int | None:
|
|
984
|
+
try:
|
|
985
|
+
pid = int(raw)
|
|
986
|
+
except (TypeError, ValueError):
|
|
987
|
+
return None
|
|
988
|
+
return pid if pid > 0 else None
|
|
989
|
+
|
|
990
|
+
|
|
991
|
+
def _coerce_positive_float(raw: Any) -> float | None:
|
|
992
|
+
try:
|
|
993
|
+
value = float(raw)
|
|
994
|
+
except (TypeError, ValueError):
|
|
995
|
+
return None
|
|
996
|
+
return value if value > 0 else None
|
|
997
|
+
|
|
998
|
+
|
|
999
|
+
def _age_seconds(raw: Any, reference: datetime) -> float | None:
|
|
1000
|
+
text = _clean_str(raw)
|
|
1001
|
+
if text is None:
|
|
1002
|
+
return None
|
|
1003
|
+
try:
|
|
1004
|
+
parsed = datetime.fromisoformat(text)
|
|
1005
|
+
except ValueError:
|
|
1006
|
+
return None
|
|
1007
|
+
if parsed.tzinfo is None:
|
|
1008
|
+
parsed = parsed.replace(tzinfo=UTC)
|
|
1009
|
+
# Clamped at zero: a lock stamped by a host with a skewed clock must not read
|
|
1010
|
+
# as "negative age", which would print as nonsense and compare as fresh.
|
|
1011
|
+
return max(0.0, (reference - parsed).total_seconds())
|
|
1012
|
+
|
|
1013
|
+
|
|
1014
|
+
def _format_age(seconds: float | None) -> str:
|
|
1015
|
+
if seconds is None:
|
|
1016
|
+
return "unknown"
|
|
1017
|
+
if seconds < 90:
|
|
1018
|
+
return f"{seconds:.0f}s"
|
|
1019
|
+
if seconds < 5400:
|
|
1020
|
+
return f"{seconds / 60:.0f}m"
|
|
1021
|
+
if seconds < 172800:
|
|
1022
|
+
return f"{seconds / 3600:.1f}h"
|
|
1023
|
+
return f"{seconds / 86400:.1f}d"
|
|
1024
|
+
|
|
1025
|
+
|
|
1026
|
+
def _recovery_grace_s(metadata: dict[str, Any]) -> float:
|
|
1027
|
+
declared = _coerce_positive_float(metadata.get("heartbeat_interval_s"))
|
|
1028
|
+
interval = declared if declared is not None else heartbeat_interval_s()
|
|
1029
|
+
return max(_MIN_HEARTBEAT_INTERVAL_S, min(interval, _MAX_RECOVERY_GRACE_S))
|
|
1030
|
+
|
|
1031
|
+
|
|
1032
|
+
def _unlock_command(metadata: dict[str, Any] | None) -> str:
|
|
1033
|
+
workspace = _clean_str((metadata or {}).get("workspace_root"))
|
|
1034
|
+
if workspace is None:
|
|
1035
|
+
return f"{UNLOCK_COMMAND} --path <workspace>"
|
|
1036
|
+
return f'{UNLOCK_COMMAND} --path "{workspace}"'
|
|
1037
|
+
|
|
1038
|
+
|
|
1039
|
+
def _process_is_running(pid: int) -> bool | None:
|
|
1040
|
+
if os.name == "nt":
|
|
1041
|
+
return _windows_process_is_running(pid)
|
|
1042
|
+
try:
|
|
1043
|
+
os.kill(pid, 0)
|
|
1044
|
+
except ProcessLookupError:
|
|
1045
|
+
return False
|
|
1046
|
+
except PermissionError:
|
|
1047
|
+
return True
|
|
1048
|
+
except OSError as e:
|
|
1049
|
+
if getattr(e, "errno", None) == errno.ESRCH:
|
|
1050
|
+
return False
|
|
1051
|
+
return None
|
|
1052
|
+
return True
|
|
1053
|
+
|
|
1054
|
+
|
|
1055
|
+
def _windows_process_is_running(pid: int) -> bool | None:
|
|
1056
|
+
try:
|
|
1057
|
+
import ctypes
|
|
1058
|
+
from ctypes import wintypes
|
|
1059
|
+
|
|
1060
|
+
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
1061
|
+
kernel32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
|
|
1062
|
+
kernel32.OpenProcess.restype = wintypes.HANDLE
|
|
1063
|
+
kernel32.GetExitCodeProcess.argtypes = [
|
|
1064
|
+
wintypes.HANDLE,
|
|
1065
|
+
ctypes.POINTER(wintypes.DWORD),
|
|
1066
|
+
]
|
|
1067
|
+
kernel32.GetExitCodeProcess.restype = wintypes.BOOL
|
|
1068
|
+
kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
|
|
1069
|
+
kernel32.CloseHandle.restype = wintypes.BOOL
|
|
1070
|
+
|
|
1071
|
+
handle = kernel32.OpenProcess(
|
|
1072
|
+
_WINDOWS_PROCESS_QUERY_LIMITED_INFORMATION,
|
|
1073
|
+
False,
|
|
1074
|
+
pid,
|
|
1075
|
+
)
|
|
1076
|
+
if not handle:
|
|
1077
|
+
error = ctypes.get_last_error()
|
|
1078
|
+
if error == _WINDOWS_ERROR_INVALID_PARAMETER:
|
|
1079
|
+
return False
|
|
1080
|
+
return None
|
|
1081
|
+
exit_code = wintypes.DWORD()
|
|
1082
|
+
try:
|
|
1083
|
+
if not kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)):
|
|
1084
|
+
return None
|
|
1085
|
+
return exit_code.value == _WINDOWS_STILL_ACTIVE
|
|
1086
|
+
finally:
|
|
1087
|
+
kernel32.CloseHandle(handle)
|
|
1088
|
+
except Exception:
|
|
1089
|
+
return None
|
|
1090
|
+
|
|
1091
|
+
|
|
1092
|
+
def _conflict_error(
|
|
1093
|
+
*,
|
|
1094
|
+
run_id: str,
|
|
1095
|
+
mode: str,
|
|
1096
|
+
metadata: dict[str, Any] | None,
|
|
1097
|
+
note: str | None = None,
|
|
1098
|
+
reason_code: str = "blocked_by_active_workspace_execution",
|
|
1099
|
+
) -> RunMutationConflictError:
|
|
1100
|
+
staleness = assess_lock_staleness(metadata)
|
|
1101
|
+
details: list[str] = []
|
|
1102
|
+
if metadata is not None:
|
|
1103
|
+
owner_mode = str(metadata.get("mode") or "").strip()
|
|
1104
|
+
if owner_mode:
|
|
1105
|
+
details.append(f"owner mode={owner_mode}")
|
|
1106
|
+
owner_id = str(metadata.get("owner_id") or "").strip()
|
|
1107
|
+
if owner_id:
|
|
1108
|
+
details.append(f"owner={owner_id}")
|
|
1109
|
+
pid = str(metadata.get("pid") or "").strip()
|
|
1110
|
+
if pid:
|
|
1111
|
+
details.append(f"pid={pid}")
|
|
1112
|
+
hostname = str(metadata.get("hostname") or "").strip()
|
|
1113
|
+
if hostname:
|
|
1114
|
+
details.append(f"host={hostname}")
|
|
1115
|
+
acquired_at = str(metadata.get("acquired_at") or "").strip()
|
|
1116
|
+
if acquired_at:
|
|
1117
|
+
details.append(f"acquired_at={acquired_at}")
|
|
1118
|
+
if staleness.age_s is not None:
|
|
1119
|
+
details.append(f"age={_format_age(staleness.age_s)}")
|
|
1120
|
+
if staleness.heartbeat_age_s is not None:
|
|
1121
|
+
details.append(f"heartbeat_age={_format_age(staleness.heartbeat_age_s)}")
|
|
1122
|
+
detail_suffix = f" ({', '.join(details)})" if details else ""
|
|
1123
|
+
note_suffix = f" {note}." if note else ""
|
|
1124
|
+
target = "workspace" if run_id.startswith("workspace:") else "run"
|
|
1125
|
+
# The verdict is what makes the guidance actionable: "wait" and "inspect it" are
|
|
1126
|
+
# different instructions, and the old message gave both at once for every case.
|
|
1127
|
+
verdict_suffix = (
|
|
1128
|
+
f" Lock verdict: {staleness.verdict} -- {staleness.reason}." if metadata else ""
|
|
1129
|
+
)
|
|
1130
|
+
message = (
|
|
1131
|
+
f"Another Forge execution is already mutating this {target} "
|
|
1132
|
+
f"(run_id={run_id}, requested_mode={mode}){detail_suffix}.{note_suffix}"
|
|
1133
|
+
f"{verdict_suffix} Wait for the active execution to finish, or inspect and clear the "
|
|
1134
|
+
f"lock with: {_unlock_command(metadata)}"
|
|
1135
|
+
)
|
|
1136
|
+
return RunMutationConflictError(
|
|
1137
|
+
message,
|
|
1138
|
+
reason_code=reason_code,
|
|
1139
|
+
metadata=_public_lock_metadata(metadata),
|
|
1140
|
+
diagnostic=message,
|
|
1141
|
+
)
|