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,4020 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import threading
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
9
|
+
from dataclasses import dataclass, replace
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
|
|
15
|
+
from .atomic_io import atomic_write_json, atomic_write_text
|
|
16
|
+
from .branding import env_get
|
|
17
|
+
from .config import AppConfig
|
|
18
|
+
from .conflict_auto_resolver import (
|
|
19
|
+
attempt_auto_resolve_conflict,
|
|
20
|
+
bump_conflict_attempt,
|
|
21
|
+
can_attempt_conflict_auto_resolve,
|
|
22
|
+
load_conflict_auto_resolve_settings,
|
|
23
|
+
)
|
|
24
|
+
from .error_text import sanitize_optional_error_summary
|
|
25
|
+
from .execution_shared import safe_task_file_component
|
|
26
|
+
from .failed_task_evidence import preserve_failed_task_evidence
|
|
27
|
+
from .failure_category import FailureCategory, failure_category_value
|
|
28
|
+
from .forge import (
|
|
29
|
+
ForgeError,
|
|
30
|
+
RunPaths,
|
|
31
|
+
WorkspaceRiskLevel,
|
|
32
|
+
find_task,
|
|
33
|
+
format_workspace_context_summary_lines,
|
|
34
|
+
load_plan,
|
|
35
|
+
refresh_workspace_context_artifacts,
|
|
36
|
+
save_plan,
|
|
37
|
+
set_task_status,
|
|
38
|
+
)
|
|
39
|
+
from .forge_events import emit_task_status
|
|
40
|
+
from .git_ops import (
|
|
41
|
+
GitOpsError,
|
|
42
|
+
branch_exists,
|
|
43
|
+
current_branch,
|
|
44
|
+
delete_branch,
|
|
45
|
+
has_head_commit,
|
|
46
|
+
merge_no_ff,
|
|
47
|
+
untracked_files,
|
|
48
|
+
)
|
|
49
|
+
from .git_worktrees import ensure_task_worktree, prune_worktrees, remove_task_worktree
|
|
50
|
+
from .integration_gate import (
|
|
51
|
+
IntegrationGateResult,
|
|
52
|
+
record_integration_failure_knowledge,
|
|
53
|
+
record_integration_resolution_knowledge,
|
|
54
|
+
resolve_integration_verify_mode,
|
|
55
|
+
run_integration_gate,
|
|
56
|
+
)
|
|
57
|
+
from .knowledge_base import (
|
|
58
|
+
find_latest_task_attempt_entry,
|
|
59
|
+
load_task_attempt_entries_for_task,
|
|
60
|
+
rebuild_knowledge_index,
|
|
61
|
+
write_task_attempt_resolution_entry,
|
|
62
|
+
)
|
|
63
|
+
from .knowledge_capture import (
|
|
64
|
+
mark_knowledge_capture_promotion_skipped,
|
|
65
|
+
promote_validated_knowledge_capture,
|
|
66
|
+
)
|
|
67
|
+
from .merge_conflict_reviewer import (
|
|
68
|
+
capture_merge_conflict_context,
|
|
69
|
+
list_unmerged_files,
|
|
70
|
+
review_merge_conflict,
|
|
71
|
+
try_abort_merge,
|
|
72
|
+
write_conflict_artifacts,
|
|
73
|
+
)
|
|
74
|
+
from .plan_validation import PlannerFailedError, raise_for_execution_ready_plan
|
|
75
|
+
from .remote_sync import (
|
|
76
|
+
ensure_pr_or_mr,
|
|
77
|
+
get_remote_url,
|
|
78
|
+
init_remote_record,
|
|
79
|
+
load_remote_settings_from_env,
|
|
80
|
+
push_base,
|
|
81
|
+
push_branch,
|
|
82
|
+
resolve_provider,
|
|
83
|
+
truncate_output,
|
|
84
|
+
write_remote_record,
|
|
85
|
+
)
|
|
86
|
+
from .replanning import (
|
|
87
|
+
ReplanAttemptResult,
|
|
88
|
+
build_replanning_trigger,
|
|
89
|
+
resolve_replanning_mode,
|
|
90
|
+
run_replanning_attempt,
|
|
91
|
+
)
|
|
92
|
+
from .review_gate import ReviewError, ReviewOutcome, review_task
|
|
93
|
+
from .run_lock import RunMutationGuard, acquire_run_mutation_guard, workspace_mutation_run_id
|
|
94
|
+
from .surface.console import make_console
|
|
95
|
+
from .surface.rich_surface import _truncate_inline
|
|
96
|
+
from .swarm_backend import (
|
|
97
|
+
PreparedBatchCandidateWorkspace,
|
|
98
|
+
PreparedTaskWorkspace,
|
|
99
|
+
SwarmBackend,
|
|
100
|
+
select_swarm_backend,
|
|
101
|
+
)
|
|
102
|
+
from .swarm_scheduler import (
|
|
103
|
+
SUCCESSFUL_TERMINAL_STATUSES,
|
|
104
|
+
canonical_task_status,
|
|
105
|
+
compute_schedule,
|
|
106
|
+
is_expected_red_regression_precursor,
|
|
107
|
+
)
|
|
108
|
+
from .swarm_trace import (
|
|
109
|
+
SerializedSwarmTraceSink,
|
|
110
|
+
SwarmTraceSink,
|
|
111
|
+
emit_swarm_trace,
|
|
112
|
+
normalize_swarm_trace_level,
|
|
113
|
+
)
|
|
114
|
+
from .swarm_worker import (
|
|
115
|
+
TaskWorkerResult,
|
|
116
|
+
reject_abnormal_success_result,
|
|
117
|
+
resolve_worker_verify_contract,
|
|
118
|
+
run_task_worker,
|
|
119
|
+
)
|
|
120
|
+
from .verification_command_analysis import is_benign_non_execution_reason
|
|
121
|
+
from .verification_repair import TASK_STATUS_COMPLETED_UNVERIFIED
|
|
122
|
+
from .verify_gate import ResolvedVerifyCommands, resolve_verify_command_selection
|
|
123
|
+
from .workspace_binding import WorkspaceBinding
|
|
124
|
+
|
|
125
|
+
DEFAULT_SWARM_MAX_STEPS = 50
|
|
126
|
+
_LOCK_WAIT_TIMEOUT_ENV = "ALYSIS_FORGE_LOCK_WAIT_TIMEOUT_S"
|
|
127
|
+
_LOCK_POLL_INTERVAL_ENV = "ALYSIS_FORGE_LOCK_POLL_INTERVAL_S"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass(frozen=True)
|
|
131
|
+
class MergeOutcome:
|
|
132
|
+
task_id: str
|
|
133
|
+
branch: str
|
|
134
|
+
success: bool
|
|
135
|
+
merge_commit_hash: str | None
|
|
136
|
+
error: str | None
|
|
137
|
+
cleanup_error: str | None = None
|
|
138
|
+
conflict_review_path: str | None = None
|
|
139
|
+
backend_name: str | None = None
|
|
140
|
+
action: str = "merged"
|
|
141
|
+
worker_result_kind: str | None = None
|
|
142
|
+
salvaged_nonzero_exit: bool = False
|
|
143
|
+
salvaged_agent_exception: bool = False
|
|
144
|
+
agent_exception_summary: str | None = None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@dataclass(frozen=True)
|
|
148
|
+
class ReadyBatchItem:
|
|
149
|
+
task: dict[str, Any]
|
|
150
|
+
prepared_workspace: PreparedTaskWorkspace
|
|
151
|
+
changed_files: tuple[str, ...]
|
|
152
|
+
result: TaskWorkerResult | None = None
|
|
153
|
+
report_path_raw: str | None = None
|
|
154
|
+
remote_record: dict[str, object] | None = None
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def task_id(self) -> str:
|
|
158
|
+
return str(self.task.get("id") or "")
|
|
159
|
+
|
|
160
|
+
@property
|
|
161
|
+
def branch(self) -> str:
|
|
162
|
+
return str(self.task.get("branch") or "")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@dataclass(frozen=True)
|
|
166
|
+
class SwarmRunOutcome:
|
|
167
|
+
status: str
|
|
168
|
+
clean: bool
|
|
169
|
+
exit_code: int
|
|
170
|
+
verification_status: str
|
|
171
|
+
reason_codes: tuple[str, ...]
|
|
172
|
+
task_status_counts: dict[str, int]
|
|
173
|
+
integration_total: int
|
|
174
|
+
integration_failed: int
|
|
175
|
+
final_integration_batch: str | None = None
|
|
176
|
+
|
|
177
|
+
def to_payload(self) -> dict[str, object]:
|
|
178
|
+
return {
|
|
179
|
+
"schema_version": 1,
|
|
180
|
+
"status": self.status,
|
|
181
|
+
"clean": self.clean,
|
|
182
|
+
"exit_code": self.exit_code,
|
|
183
|
+
"verification_status": self.verification_status,
|
|
184
|
+
"reason_codes": list(self.reason_codes),
|
|
185
|
+
"task_status_counts": dict(self.task_status_counts),
|
|
186
|
+
"integration": {
|
|
187
|
+
"total": self.integration_total,
|
|
188
|
+
"failed": self.integration_failed,
|
|
189
|
+
"final_batch": self.final_integration_batch,
|
|
190
|
+
},
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _result_agent_exception_summary(result: TaskWorkerResult | None) -> str | None:
|
|
195
|
+
if result is None:
|
|
196
|
+
return None
|
|
197
|
+
return sanitize_optional_error_summary(result.agent_exception_summary)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _worker_result_nonexecuting_verification_reason(result: TaskWorkerResult) -> str | None:
|
|
201
|
+
payload = result.verify_payload
|
|
202
|
+
if not isinstance(payload, dict):
|
|
203
|
+
return None
|
|
204
|
+
command_results = payload.get("command_results")
|
|
205
|
+
if not isinstance(command_results, list):
|
|
206
|
+
return None
|
|
207
|
+
for item in command_results:
|
|
208
|
+
if not isinstance(item, dict):
|
|
209
|
+
continue
|
|
210
|
+
if item.get("real_execution") is not False:
|
|
211
|
+
continue
|
|
212
|
+
reason = str(item.get("non_execution_reason") or "").strip()
|
|
213
|
+
if str(item.get("status") or "").strip().lower() == "skipped" and (
|
|
214
|
+
is_benign_non_execution_reason(reason)
|
|
215
|
+
):
|
|
216
|
+
continue
|
|
217
|
+
command = str(item.get("command") or item.get("effective_command") or "").strip()
|
|
218
|
+
if reason and command:
|
|
219
|
+
return f"{reason} ({command})"
|
|
220
|
+
return reason or command or "verification command did not execute tests"
|
|
221
|
+
return None
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _merge_outcome_recovery_suffix(_outcome: MergeOutcome) -> str:
|
|
225
|
+
return ""
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _merge_outcome_success_trace_message(outcome: MergeOutcome) -> str:
|
|
229
|
+
if outcome.action == "applied":
|
|
230
|
+
return f"Applied successfully ({outcome.merge_commit_hash or 'no merge hash'})."
|
|
231
|
+
if outcome.action == "noop":
|
|
232
|
+
return "Accepted already-satisfied no-op outcome; no merge/apply was required."
|
|
233
|
+
return f"Merged successfully ({outcome.merge_commit_hash or 'no merge hash'})."
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _normalize_relpath(value: str) -> str:
|
|
237
|
+
normalized = str(value or "").strip().replace("\\", "/")
|
|
238
|
+
while normalized.startswith("./"):
|
|
239
|
+
normalized = normalized[2:]
|
|
240
|
+
return normalized.strip("/")
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _untracked_overwrite_conflicts(*, root: Path, changed_files: list[str] | None) -> list[str]:
|
|
244
|
+
changed = {
|
|
245
|
+
_normalize_relpath(path) for path in (changed_files or []) if _normalize_relpath(path)
|
|
246
|
+
}
|
|
247
|
+
if not changed:
|
|
248
|
+
return []
|
|
249
|
+
try:
|
|
250
|
+
untracked = {_normalize_relpath(path) for path in untracked_files(root)}
|
|
251
|
+
except GitOpsError:
|
|
252
|
+
return []
|
|
253
|
+
return sorted(path for path in changed if path in untracked)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _raise_if_untracked_overwrite_conflict(
|
|
257
|
+
*, root: Path, task_id: str, changed_files: list[str] | None
|
|
258
|
+
) -> None:
|
|
259
|
+
conflicts = _untracked_overwrite_conflicts(root=root, changed_files=changed_files)
|
|
260
|
+
if not conflicts:
|
|
261
|
+
return
|
|
262
|
+
preview = ", ".join(conflicts[:20])
|
|
263
|
+
if len(conflicts) > 20:
|
|
264
|
+
preview += ", ..."
|
|
265
|
+
raise GitOpsError(
|
|
266
|
+
"blocked to protect untracked workspace files that would be overwritten by "
|
|
267
|
+
f"task {task_id}: {preview}"
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _candidate_branch_name(*, run_id: str, batch_label: str) -> str:
|
|
272
|
+
# Keep candidate refs short and flat so snapshot-backed candidate repos can
|
|
273
|
+
# create refs/heads/<branch>.lock reliably on Windows temp/worktree paths.
|
|
274
|
+
safe_run_id = safe_task_file_component(run_id)
|
|
275
|
+
if len(safe_run_id) <= 16:
|
|
276
|
+
# Counter-based run ids (e.g. "017-wayland") are already short, and the
|
|
277
|
+
# counter is the unique part — keep the whole id so tokens never collide
|
|
278
|
+
# between runs that share a word.
|
|
279
|
+
run_token = safe_run_id.strip("-_") or safe_run_id
|
|
280
|
+
else:
|
|
281
|
+
run_token = safe_run_id.rsplit("_", 1)[-1][-8:] or safe_run_id[-8:]
|
|
282
|
+
safe_batch_label = safe_task_file_component(batch_label)
|
|
283
|
+
batch_token = safe_batch_label.replace("batch_", "b").replace("_", "")
|
|
284
|
+
return f"syc-{run_token}-{batch_token}"
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _repo_rel(root: Path, path: Path) -> str:
|
|
288
|
+
return path.resolve().relative_to(root.resolve()).as_posix()
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def ensure_swarm_dirs(paths: RunPaths) -> None:
|
|
292
|
+
(paths.execution_dir / "worker_results").mkdir(parents=True, exist_ok=True)
|
|
293
|
+
(paths.execution_dir / "merge_results").mkdir(parents=True, exist_ok=True)
|
|
294
|
+
paths.execution_integration_dir.mkdir(parents=True, exist_ok=True)
|
|
295
|
+
(paths.execution_dir / "remote").mkdir(parents=True, exist_ok=True)
|
|
296
|
+
(paths.execution_dir / "trace").mkdir(parents=True, exist_ok=True)
|
|
297
|
+
(paths.run_dir / "worktrees").mkdir(parents=True, exist_ok=True)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _default_swarm_trace_path(paths: RunPaths) -> Path:
|
|
301
|
+
return paths.execution_dir / "trace" / "swarm_trace.jsonl"
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
@dataclass(frozen=True)
|
|
305
|
+
class _CompositeMutationGuard:
|
|
306
|
+
workspace_guard: RunMutationGuard
|
|
307
|
+
run_guard: RunMutationGuard
|
|
308
|
+
|
|
309
|
+
@property
|
|
310
|
+
def run_id(self) -> str:
|
|
311
|
+
return self.run_guard.run_id
|
|
312
|
+
|
|
313
|
+
@property
|
|
314
|
+
def mode(self) -> str:
|
|
315
|
+
return self.run_guard.mode
|
|
316
|
+
|
|
317
|
+
@property
|
|
318
|
+
def run_dir(self) -> Path:
|
|
319
|
+
return self.run_guard.run_dir
|
|
320
|
+
|
|
321
|
+
@property
|
|
322
|
+
def workspace_root(self) -> Path:
|
|
323
|
+
return self.run_guard.workspace_root
|
|
324
|
+
|
|
325
|
+
@property
|
|
326
|
+
def owner_token(self) -> str:
|
|
327
|
+
return self.run_guard.owner_token
|
|
328
|
+
|
|
329
|
+
@property
|
|
330
|
+
def lock_path(self) -> Path:
|
|
331
|
+
return self.run_guard.lock_path
|
|
332
|
+
|
|
333
|
+
@property
|
|
334
|
+
def recovery_path(self) -> Path:
|
|
335
|
+
return self.run_guard.recovery_path
|
|
336
|
+
|
|
337
|
+
@property
|
|
338
|
+
def acquired_after_wait(self) -> bool:
|
|
339
|
+
return self.workspace_guard.acquired_after_wait or self.run_guard.acquired_after_wait
|
|
340
|
+
|
|
341
|
+
@property
|
|
342
|
+
def wait_started_at(self) -> str | None:
|
|
343
|
+
return self.workspace_guard.wait_started_at or self.run_guard.wait_started_at
|
|
344
|
+
|
|
345
|
+
@property
|
|
346
|
+
def wait_finished_at(self) -> str | None:
|
|
347
|
+
return self.workspace_guard.wait_finished_at or self.run_guard.wait_finished_at
|
|
348
|
+
|
|
349
|
+
def release(self) -> None:
|
|
350
|
+
self.run_guard.release()
|
|
351
|
+
self.workspace_guard.release()
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _env_float(name: str) -> float | None:
|
|
355
|
+
raw = env_get(name)
|
|
356
|
+
if raw is None or not raw.strip():
|
|
357
|
+
return None
|
|
358
|
+
try:
|
|
359
|
+
return float(raw)
|
|
360
|
+
except ValueError:
|
|
361
|
+
return None
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def acquire_swarm_mutation_guard(
|
|
365
|
+
paths: RunPaths,
|
|
366
|
+
*,
|
|
367
|
+
mode: str = "forge_swarm",
|
|
368
|
+
wait: bool = True,
|
|
369
|
+
wait_timeout_s: float | None = None,
|
|
370
|
+
poll_interval_s: float | None = None,
|
|
371
|
+
on_wait: Callable[[dict[str, Any]], None] | None = None,
|
|
372
|
+
owner_session_id: str | None = None,
|
|
373
|
+
) -> RunMutationGuard | _CompositeMutationGuard:
|
|
374
|
+
effective_wait_timeout_s = (
|
|
375
|
+
_env_float(_LOCK_WAIT_TIMEOUT_ENV) if wait_timeout_s is None else wait_timeout_s
|
|
376
|
+
)
|
|
377
|
+
effective_poll_interval_s = (
|
|
378
|
+
_env_float(_LOCK_POLL_INTERVAL_ENV) if poll_interval_s is None else poll_interval_s
|
|
379
|
+
)
|
|
380
|
+
if effective_poll_interval_s is None:
|
|
381
|
+
effective_poll_interval_s = 1.0
|
|
382
|
+
workspace_guard = acquire_run_mutation_guard(
|
|
383
|
+
run_id=workspace_mutation_run_id(paths.root),
|
|
384
|
+
mode=f"{mode}:workspace",
|
|
385
|
+
run_dir=paths.runtime_dir / "workspace_execution",
|
|
386
|
+
workspace_root=paths.root,
|
|
387
|
+
wait=wait,
|
|
388
|
+
wait_timeout_s=effective_wait_timeout_s,
|
|
389
|
+
poll_interval_s=effective_poll_interval_s,
|
|
390
|
+
on_wait=on_wait,
|
|
391
|
+
owner_session_id=owner_session_id,
|
|
392
|
+
)
|
|
393
|
+
try:
|
|
394
|
+
run_guard = acquire_run_mutation_guard(
|
|
395
|
+
run_id=paths.run_id,
|
|
396
|
+
mode=mode,
|
|
397
|
+
run_dir=paths.run_dir,
|
|
398
|
+
workspace_root=paths.root,
|
|
399
|
+
wait=wait,
|
|
400
|
+
wait_timeout_s=effective_wait_timeout_s,
|
|
401
|
+
poll_interval_s=effective_poll_interval_s,
|
|
402
|
+
on_wait=on_wait,
|
|
403
|
+
owner_session_id=owner_session_id,
|
|
404
|
+
)
|
|
405
|
+
except Exception:
|
|
406
|
+
workspace_guard.release()
|
|
407
|
+
raise
|
|
408
|
+
return _CompositeMutationGuard(workspace_guard=workspace_guard, run_guard=run_guard)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _write_concurrency_revalidation_artifact(
|
|
412
|
+
*,
|
|
413
|
+
paths: RunPaths,
|
|
414
|
+
guard: RunMutationGuard | _CompositeMutationGuard,
|
|
415
|
+
plan_changed: bool,
|
|
416
|
+
) -> None:
|
|
417
|
+
payload = {
|
|
418
|
+
"schema_version": 1,
|
|
419
|
+
"reason_code": "queued_execution_revalidated",
|
|
420
|
+
"run_id": paths.run_id,
|
|
421
|
+
"workspace_root": os.fspath(paths.root),
|
|
422
|
+
"acquired_after_wait": bool(getattr(guard, "acquired_after_wait", False)),
|
|
423
|
+
"wait_started_at": getattr(guard, "wait_started_at", None),
|
|
424
|
+
"wait_finished_at": getattr(guard, "wait_finished_at", None),
|
|
425
|
+
"plan_reloaded": True,
|
|
426
|
+
"plan_changed": plan_changed,
|
|
427
|
+
}
|
|
428
|
+
atomic_write_json(paths.execution_dir / "concurrency_revalidation.json", payload)
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _write_merge_result(paths: RunPaths, outcome: MergeOutcome) -> Path:
|
|
432
|
+
out_dir = paths.execution_dir / "merge_results"
|
|
433
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
434
|
+
out_path = out_dir / f"{outcome.task_id}.json"
|
|
435
|
+
payload = {
|
|
436
|
+
"task_id": outcome.task_id,
|
|
437
|
+
"branch": outcome.branch,
|
|
438
|
+
"success": outcome.success,
|
|
439
|
+
"merge_commit_hash": outcome.merge_commit_hash,
|
|
440
|
+
"error": outcome.error,
|
|
441
|
+
"cleanup_error": outcome.cleanup_error,
|
|
442
|
+
"conflict_review_path": outcome.conflict_review_path,
|
|
443
|
+
"backend_name": outcome.backend_name,
|
|
444
|
+
"action": outcome.action,
|
|
445
|
+
"worker_result_kind": outcome.worker_result_kind,
|
|
446
|
+
"salvaged_nonzero_exit": outcome.salvaged_nonzero_exit,
|
|
447
|
+
"salvaged_agent_exception": outcome.salvaged_agent_exception,
|
|
448
|
+
"agent_exception_summary": outcome.agent_exception_summary,
|
|
449
|
+
}
|
|
450
|
+
atomic_write_json(out_path, payload)
|
|
451
|
+
return out_path
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _persist_worker_result(paths: RunPaths, result: TaskWorkerResult) -> Path:
|
|
455
|
+
out_dir = paths.execution_dir / "worker_results"
|
|
456
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
457
|
+
out = out_dir / f"{safe_task_file_component(result.task_id)}.json"
|
|
458
|
+
atomic_write_json(out, result.to_json())
|
|
459
|
+
return out
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _load_worker_changed_files(paths: RunPaths, task_id: str) -> list[str]:
|
|
463
|
+
result_path = (
|
|
464
|
+
paths.execution_dir / "worker_results" / f"{safe_task_file_component(task_id)}.json"
|
|
465
|
+
)
|
|
466
|
+
if not result_path.exists():
|
|
467
|
+
return []
|
|
468
|
+
try:
|
|
469
|
+
payload = json.loads(result_path.read_text(encoding="utf-8"))
|
|
470
|
+
except json.JSONDecodeError:
|
|
471
|
+
return []
|
|
472
|
+
raw = payload.get("changed_files")
|
|
473
|
+
if not isinstance(raw, list):
|
|
474
|
+
return []
|
|
475
|
+
return [str(item).strip() for item in raw if str(item).strip()]
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _load_worker_result_payload(paths: RunPaths, task_id: str) -> dict[str, Any] | None:
|
|
479
|
+
result_path = (
|
|
480
|
+
paths.execution_dir / "worker_results" / f"{safe_task_file_component(task_id)}.json"
|
|
481
|
+
)
|
|
482
|
+
if not result_path.exists():
|
|
483
|
+
return None
|
|
484
|
+
try:
|
|
485
|
+
payload = json.loads(result_path.read_text(encoding="utf-8"))
|
|
486
|
+
except json.JSONDecodeError:
|
|
487
|
+
return None
|
|
488
|
+
return payload if isinstance(payload, dict) else None
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _worker_knowledge_capture_artifact_dir(
|
|
492
|
+
paths: RunPaths,
|
|
493
|
+
*,
|
|
494
|
+
task_id: str,
|
|
495
|
+
result: TaskWorkerResult | None = None,
|
|
496
|
+
) -> Path | None:
|
|
497
|
+
raw = result.knowledge_capture_artifact_dir if result is not None else None
|
|
498
|
+
if not raw:
|
|
499
|
+
payload = _load_worker_result_payload(paths, task_id)
|
|
500
|
+
raw = str((payload or {}).get("knowledge_capture_artifact_dir") or "").strip() or None
|
|
501
|
+
if not raw:
|
|
502
|
+
return None
|
|
503
|
+
candidate = Path(raw)
|
|
504
|
+
return candidate if candidate.is_absolute() else paths.root / candidate
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _mark_worker_knowledge_capture_skipped(
|
|
508
|
+
paths: RunPaths,
|
|
509
|
+
*,
|
|
510
|
+
task_id: str,
|
|
511
|
+
reason: str,
|
|
512
|
+
result: TaskWorkerResult | None = None,
|
|
513
|
+
) -> None:
|
|
514
|
+
artifact_dir = _worker_knowledge_capture_artifact_dir(paths, task_id=task_id, result=result)
|
|
515
|
+
if artifact_dir is None:
|
|
516
|
+
return
|
|
517
|
+
mark_knowledge_capture_promotion_skipped(
|
|
518
|
+
artifact_dir=artifact_dir,
|
|
519
|
+
reason=reason,
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _promote_worker_knowledge_capture(
|
|
524
|
+
paths: RunPaths,
|
|
525
|
+
*,
|
|
526
|
+
plan: dict[str, Any],
|
|
527
|
+
task_id: str,
|
|
528
|
+
result: TaskWorkerResult | None = None,
|
|
529
|
+
):
|
|
530
|
+
artifact_dir = _worker_knowledge_capture_artifact_dir(paths, task_id=task_id, result=result)
|
|
531
|
+
if artifact_dir is None:
|
|
532
|
+
return None
|
|
533
|
+
task = find_task(plan, task_id)
|
|
534
|
+
if task is None:
|
|
535
|
+
return None
|
|
536
|
+
return promote_validated_knowledge_capture(
|
|
537
|
+
paths=paths,
|
|
538
|
+
task=task,
|
|
539
|
+
artifact_dir=artifact_dir,
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def _worker_result_artifact_path(
|
|
544
|
+
paths: RunPaths,
|
|
545
|
+
*,
|
|
546
|
+
task_id: str,
|
|
547
|
+
field: str,
|
|
548
|
+
result: TaskWorkerResult | None = None,
|
|
549
|
+
) -> Path | None:
|
|
550
|
+
raw = str(getattr(result, field, "") or "").strip() if result is not None else ""
|
|
551
|
+
if not raw:
|
|
552
|
+
payload = _load_worker_result_payload(paths, task_id)
|
|
553
|
+
raw = str((payload or {}).get(field) or "").strip()
|
|
554
|
+
if not raw:
|
|
555
|
+
return None
|
|
556
|
+
candidate = Path(raw)
|
|
557
|
+
return candidate if candidate.is_absolute() else paths.root / candidate
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _worker_task_attempt_entry(
|
|
561
|
+
paths: RunPaths,
|
|
562
|
+
*,
|
|
563
|
+
task_id: str,
|
|
564
|
+
result: TaskWorkerResult | None = None,
|
|
565
|
+
):
|
|
566
|
+
entry_id = (
|
|
567
|
+
str(getattr(result, "task_attempt_entry_id", "") or "").strip()
|
|
568
|
+
if result is not None
|
|
569
|
+
else ""
|
|
570
|
+
)
|
|
571
|
+
if not entry_id:
|
|
572
|
+
payload = _load_worker_result_payload(paths, task_id)
|
|
573
|
+
entry_id = str((payload or {}).get("task_attempt_entry_id") or "").strip()
|
|
574
|
+
if entry_id:
|
|
575
|
+
for entry in load_task_attempt_entries_for_task(paths=paths, task_id=task_id):
|
|
576
|
+
if entry.id == entry_id:
|
|
577
|
+
return entry
|
|
578
|
+
return find_latest_task_attempt_entry(
|
|
579
|
+
paths=paths,
|
|
580
|
+
task_id=task_id,
|
|
581
|
+
source="swarm_worker",
|
|
582
|
+
include_resolution_entries=False,
|
|
583
|
+
)
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def _resolve_worker_task_attempt_acceptance(
|
|
587
|
+
paths: RunPaths,
|
|
588
|
+
*,
|
|
589
|
+
plan: dict[str, Any],
|
|
590
|
+
task_id: str,
|
|
591
|
+
acceptance_state: str,
|
|
592
|
+
summary: str,
|
|
593
|
+
result: TaskWorkerResult | None = None,
|
|
594
|
+
) -> bool:
|
|
595
|
+
task = find_task(plan, task_id)
|
|
596
|
+
if task is None:
|
|
597
|
+
return False
|
|
598
|
+
attempt_entry = _worker_task_attempt_entry(paths, task_id=task_id, result=result)
|
|
599
|
+
if attempt_entry is None or attempt_entry.resolves:
|
|
600
|
+
return False
|
|
601
|
+
changed_files = (
|
|
602
|
+
list(result.changed_files)
|
|
603
|
+
if result is not None
|
|
604
|
+
else _load_worker_changed_files(paths, task_id)
|
|
605
|
+
)
|
|
606
|
+
verify_summary = getattr(result, "verify_summary", None) if result is not None else None
|
|
607
|
+
budget_path = paths.execution_budgets_dir / f"{safe_task_file_component(task_id)}.json"
|
|
608
|
+
resolution_entry = write_task_attempt_resolution_entry(
|
|
609
|
+
paths=paths,
|
|
610
|
+
task=task,
|
|
611
|
+
source="swarm_orchestrator",
|
|
612
|
+
acceptance_state=acceptance_state,
|
|
613
|
+
resolved_attempt_id=attempt_entry.id,
|
|
614
|
+
summary=summary,
|
|
615
|
+
changed_files=changed_files,
|
|
616
|
+
verify_summary=verify_summary,
|
|
617
|
+
report_path=_worker_result_artifact_path(
|
|
618
|
+
paths, task_id=task_id, field="report_path", result=result
|
|
619
|
+
),
|
|
620
|
+
patch_path=_worker_result_artifact_path(
|
|
621
|
+
paths, task_id=task_id, field="patch_path", result=result
|
|
622
|
+
),
|
|
623
|
+
verify_artifact_path=_worker_result_artifact_path(
|
|
624
|
+
paths,
|
|
625
|
+
task_id=task_id,
|
|
626
|
+
field="verify_artifact_path",
|
|
627
|
+
result=result,
|
|
628
|
+
),
|
|
629
|
+
budget_artifact_path=budget_path if budget_path.exists() else None,
|
|
630
|
+
session_artifact_dir=None,
|
|
631
|
+
extra_tags=["swarm", "acceptance"],
|
|
632
|
+
)
|
|
633
|
+
return bool(resolution_entry.file_path)
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def _remote_record_lines(record: dict[str, object]) -> list[str]:
|
|
637
|
+
lines = [
|
|
638
|
+
f"remote={record.get('remote') or '-'} provider={record.get('provider') or 'unknown'}",
|
|
639
|
+
(
|
|
640
|
+
f"pushed_branch={record.get('pushed_branch')} "
|
|
641
|
+
f"pushed_base={record.get('pushed_base')} "
|
|
642
|
+
f"created_pr={record.get('created_pr')}"
|
|
643
|
+
),
|
|
644
|
+
]
|
|
645
|
+
pr_url = str(record.get("pr_url") or "").strip()
|
|
646
|
+
if pr_url:
|
|
647
|
+
lines.append(f"pr_url={pr_url}")
|
|
648
|
+
pr_id = str(record.get("pr_number_or_iid") or "").strip()
|
|
649
|
+
if pr_id:
|
|
650
|
+
lines.append(f"pr_number_or_iid={pr_id}")
|
|
651
|
+
for key in ("branch_push_output", "base_push_output", "pr_output"):
|
|
652
|
+
val = str(record.get(key) or "").strip()
|
|
653
|
+
if val:
|
|
654
|
+
lines.append(f"{key}={truncate_output(val, max_chars=300)}")
|
|
655
|
+
raw_errors = record.get("errors")
|
|
656
|
+
if isinstance(raw_errors, list):
|
|
657
|
+
for err in raw_errors:
|
|
658
|
+
msg = str(err).strip()
|
|
659
|
+
if msg:
|
|
660
|
+
lines.append(f"error={truncate_output(msg, max_chars=300)}")
|
|
661
|
+
return lines
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def _append_remote_report_update(
|
|
665
|
+
*,
|
|
666
|
+
paths: RunPaths,
|
|
667
|
+
report_path_raw: str,
|
|
668
|
+
record: dict[str, object],
|
|
669
|
+
) -> None:
|
|
670
|
+
candidate = Path(report_path_raw)
|
|
671
|
+
if not candidate.is_absolute():
|
|
672
|
+
candidate = paths.root / candidate
|
|
673
|
+
if not candidate.exists():
|
|
674
|
+
return
|
|
675
|
+
update_lines = ["", "## Remote Sync Update", ""]
|
|
676
|
+
update_lines.extend(f"- {line}" for line in _remote_record_lines(record))
|
|
677
|
+
text = candidate.read_text(encoding="utf-8")
|
|
678
|
+
atomic_write_text(candidate, text.rstrip() + "\n" + "\n".join(update_lines) + "\n")
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def _swarm_binding_metadata(
|
|
682
|
+
*,
|
|
683
|
+
paths: RunPaths,
|
|
684
|
+
workspace_binding: WorkspaceBinding | None,
|
|
685
|
+
) -> dict[str, object]:
|
|
686
|
+
if workspace_binding is not None:
|
|
687
|
+
workspace_context = workspace_binding.workspace_context
|
|
688
|
+
return {
|
|
689
|
+
"requested_path": workspace_binding.requested_path,
|
|
690
|
+
"workspace_root": workspace_context.workspace_root,
|
|
691
|
+
"focus_dir": workspace_context.focus_path,
|
|
692
|
+
"binding_source": workspace_binding.binding_source,
|
|
693
|
+
"binding_risk_level": workspace_binding.risk_level,
|
|
694
|
+
"binding_risk_reasons": workspace_binding.risk_reasons,
|
|
695
|
+
"broad_workspace_override_used": workspace_binding.broad_workspace_override_used,
|
|
696
|
+
}
|
|
697
|
+
return {
|
|
698
|
+
"requested_path": paths.binding_requested_path or paths.focus_path or paths.root,
|
|
699
|
+
"workspace_root": paths.root,
|
|
700
|
+
"focus_dir": paths.focus_path or paths.root,
|
|
701
|
+
"binding_source": paths.binding_source or "current_run_pointer",
|
|
702
|
+
"binding_risk_level": paths.binding_risk_level or WorkspaceRiskLevel.HEALTHY,
|
|
703
|
+
"binding_risk_reasons": paths.binding_risk_reasons,
|
|
704
|
+
"broad_workspace_override_used": paths.binding_broad_workspace_override_used,
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def _swarm_binding_summary_lines(
|
|
709
|
+
*,
|
|
710
|
+
paths: RunPaths,
|
|
711
|
+
workspace_binding: WorkspaceBinding | None,
|
|
712
|
+
) -> list[str]:
|
|
713
|
+
metadata = _swarm_binding_metadata(paths=paths, workspace_binding=workspace_binding)
|
|
714
|
+
return [
|
|
715
|
+
f"Requested Path: `{metadata['requested_path']}`",
|
|
716
|
+
f"Workspace Root: `{metadata['workspace_root']}`",
|
|
717
|
+
f"Focus Directory: `{metadata['focus_dir']}`",
|
|
718
|
+
f"Binding Source: `{metadata['binding_source']}`",
|
|
719
|
+
f"Binding Risk Level: `{metadata['binding_risk_level']}`",
|
|
720
|
+
(
|
|
721
|
+
"Broad Workspace Override Used: "
|
|
722
|
+
f"`{'yes' if metadata['broad_workspace_override_used'] else 'no'}`"
|
|
723
|
+
),
|
|
724
|
+
]
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def _integration_phase_label(phase: str) -> str:
|
|
728
|
+
if phase == "pre_merge_candidate":
|
|
729
|
+
return "pre-merge candidate"
|
|
730
|
+
if phase == "final_repo":
|
|
731
|
+
return "final repo"
|
|
732
|
+
return "post-merge"
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
_FAILURE_LIKE_STATUSES = frozenset(
|
|
736
|
+
{
|
|
737
|
+
"failed",
|
|
738
|
+
"verify_failed",
|
|
739
|
+
"candidate_rejected",
|
|
740
|
+
"changes_requested",
|
|
741
|
+
"merge_conflict",
|
|
742
|
+
"blocked_integration",
|
|
743
|
+
"interrupted",
|
|
744
|
+
"cancelled",
|
|
745
|
+
}
|
|
746
|
+
)
|
|
747
|
+
_NON_EXECUTABLE_SUCCESS_STATUSES = frozenset(
|
|
748
|
+
{*SUCCESSFUL_TERMINAL_STATUSES, "superseded", "invalidated"}
|
|
749
|
+
)
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
def _task_status_counts(plan: dict[str, Any]) -> dict[str, int]:
|
|
753
|
+
counts: dict[str, int] = {}
|
|
754
|
+
for task in plan.get("tasks") or []:
|
|
755
|
+
if not isinstance(task, dict):
|
|
756
|
+
continue
|
|
757
|
+
status = canonical_task_status(str(task.get("status") or ""))
|
|
758
|
+
counts[status] = counts.get(status, 0) + 1
|
|
759
|
+
return counts
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def _compute_swarm_run_outcome(
|
|
763
|
+
*,
|
|
764
|
+
plan: dict[str, Any],
|
|
765
|
+
integration_results: list[IntegrationGateResult],
|
|
766
|
+
worker_verification_warnings: bool,
|
|
767
|
+
review_blocked: bool,
|
|
768
|
+
remote_blocked_any: bool,
|
|
769
|
+
integration_blocked: bool,
|
|
770
|
+
only: str | None,
|
|
771
|
+
max_tasks: int | None,
|
|
772
|
+
dry_run: bool,
|
|
773
|
+
interrupted: bool = False,
|
|
774
|
+
interrupted_task_ids: tuple[str, ...] = (),
|
|
775
|
+
review_merge_strategy: bool = False,
|
|
776
|
+
) -> SwarmRunOutcome:
|
|
777
|
+
counts = _task_status_counts(plan)
|
|
778
|
+
statuses = list(counts)
|
|
779
|
+
failed_integration = [item for item in integration_results if not item.passed]
|
|
780
|
+
final_integration = next(
|
|
781
|
+
(item for item in reversed(integration_results) if item.phase == "final_repo"),
|
|
782
|
+
None,
|
|
783
|
+
)
|
|
784
|
+
reason_codes: list[str] = []
|
|
785
|
+
exit_code = 0
|
|
786
|
+
status = "clean"
|
|
787
|
+
verification_status = "passed" if integration_results else "not_run"
|
|
788
|
+
|
|
789
|
+
if review_blocked:
|
|
790
|
+
reason_codes.append("review_blocked")
|
|
791
|
+
if remote_blocked_any:
|
|
792
|
+
reason_codes.append("remote_blocked")
|
|
793
|
+
if integration_blocked:
|
|
794
|
+
reason_codes.append("integration_blocked")
|
|
795
|
+
if review_blocked or remote_blocked_any or integration_blocked:
|
|
796
|
+
exit_code = 1
|
|
797
|
+
status = "failed"
|
|
798
|
+
|
|
799
|
+
if any(item in _FAILURE_LIKE_STATUSES for item in statuses):
|
|
800
|
+
exit_code = 1
|
|
801
|
+
status = "failed"
|
|
802
|
+
reason_codes.append("task_failed_or_blocked")
|
|
803
|
+
|
|
804
|
+
acceptable_end_statuses = set(_NON_EXECUTABLE_SUCCESS_STATUSES)
|
|
805
|
+
if review_merge_strategy:
|
|
806
|
+
acceptable_end_statuses.add("ready_for_merge")
|
|
807
|
+
is_full_run = only is None and max_tasks is None and not dry_run
|
|
808
|
+
if is_full_run and any(item not in acceptable_end_statuses for item in statuses):
|
|
809
|
+
exit_code = 1
|
|
810
|
+
status = "incomplete"
|
|
811
|
+
reason_codes.append("tasks_remaining")
|
|
812
|
+
|
|
813
|
+
if final_integration is not None:
|
|
814
|
+
if final_integration.passed:
|
|
815
|
+
verification_status = "passed"
|
|
816
|
+
elif final_integration.mode == "warn":
|
|
817
|
+
verification_status = "failed_tolerated_by_warn_policy"
|
|
818
|
+
status = "integration_failed_warn_mode"
|
|
819
|
+
exit_code = 1
|
|
820
|
+
reason_codes.append("final_integration_failed_warn_mode")
|
|
821
|
+
else:
|
|
822
|
+
verification_status = "failed_blocking"
|
|
823
|
+
status = "failed"
|
|
824
|
+
exit_code = 1
|
|
825
|
+
reason_codes.append("final_integration_failed")
|
|
826
|
+
elif failed_integration and status == "clean":
|
|
827
|
+
if all(item.mode == "warn" for item in failed_integration):
|
|
828
|
+
verification_status = "failed_tolerated_by_warn_policy"
|
|
829
|
+
status = "completed_with_verification_warnings"
|
|
830
|
+
reason_codes.append("integration_failed_warn_mode")
|
|
831
|
+
else:
|
|
832
|
+
verification_status = "failed_blocking"
|
|
833
|
+
status = "failed"
|
|
834
|
+
exit_code = 1
|
|
835
|
+
reason_codes.append("integration_failed")
|
|
836
|
+
elif failed_integration:
|
|
837
|
+
verification_status = (
|
|
838
|
+
"failed_tolerated_by_warn_policy"
|
|
839
|
+
if all(item.mode == "warn" for item in failed_integration)
|
|
840
|
+
else "failed_blocking"
|
|
841
|
+
)
|
|
842
|
+
|
|
843
|
+
if worker_verification_warnings and status == "clean":
|
|
844
|
+
verification_status = "failed_tolerated_by_warn_policy"
|
|
845
|
+
status = "completed_with_verification_warnings"
|
|
846
|
+
reason_codes.append("worker_verification_failed_warn_mode")
|
|
847
|
+
elif worker_verification_warnings and verification_status == "not_run":
|
|
848
|
+
verification_status = "failed_tolerated_by_warn_policy"
|
|
849
|
+
reason_codes.append("worker_verification_failed_warn_mode")
|
|
850
|
+
|
|
851
|
+
if review_merge_strategy and counts.get("ready_for_merge") and status == "clean":
|
|
852
|
+
status = "review_pending"
|
|
853
|
+
reason_codes.append("merge_review_pending")
|
|
854
|
+
|
|
855
|
+
if interrupted:
|
|
856
|
+
status = "interrupted"
|
|
857
|
+
exit_code = 130
|
|
858
|
+
reason_codes.append("cancelled_cooperatively")
|
|
859
|
+
if interrupted_task_ids:
|
|
860
|
+
reason_codes.append("tasks_interrupted")
|
|
861
|
+
|
|
862
|
+
clean = status == "clean" and exit_code == 0 and verification_status in {"passed", "not_run"}
|
|
863
|
+
return SwarmRunOutcome(
|
|
864
|
+
status=status,
|
|
865
|
+
clean=clean,
|
|
866
|
+
exit_code=exit_code,
|
|
867
|
+
verification_status=verification_status,
|
|
868
|
+
reason_codes=tuple(dict.fromkeys(reason_codes)),
|
|
869
|
+
task_status_counts=counts,
|
|
870
|
+
integration_total=len(integration_results),
|
|
871
|
+
integration_failed=len(failed_integration),
|
|
872
|
+
final_integration_batch=(
|
|
873
|
+
final_integration.batch_label if final_integration is not None else None
|
|
874
|
+
),
|
|
875
|
+
)
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
def _write_swarm_summary_json(
|
|
879
|
+
*,
|
|
880
|
+
paths: RunPaths,
|
|
881
|
+
outcome: SwarmRunOutcome,
|
|
882
|
+
integration_results: list[IntegrationGateResult],
|
|
883
|
+
) -> Path:
|
|
884
|
+
summary_json_path = paths.execution_dir / "swarm_summary.json"
|
|
885
|
+
payload = outcome.to_payload()
|
|
886
|
+
payload["run_id"] = paths.run_id
|
|
887
|
+
integration_payload = payload.get("integration")
|
|
888
|
+
if isinstance(integration_payload, dict):
|
|
889
|
+
integration_payload["results"] = [
|
|
890
|
+
{
|
|
891
|
+
"batch_label": item.batch_label,
|
|
892
|
+
"phase": item.phase,
|
|
893
|
+
"mode": item.mode,
|
|
894
|
+
"passed": item.passed,
|
|
895
|
+
"summary_path": _repo_rel(paths.root, item.summary_path),
|
|
896
|
+
"merged_task_ids": list(item.merged_task_ids),
|
|
897
|
+
}
|
|
898
|
+
for item in integration_results
|
|
899
|
+
]
|
|
900
|
+
atomic_write_json(summary_json_path, payload)
|
|
901
|
+
return summary_json_path
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
def _write_swarm_summary(
|
|
905
|
+
*,
|
|
906
|
+
paths: RunPaths,
|
|
907
|
+
backend_name: str,
|
|
908
|
+
base_branch: str,
|
|
909
|
+
executed: list[str],
|
|
910
|
+
merge_outcomes: list[MergeOutcome],
|
|
911
|
+
integration_results: list[IntegrationGateResult],
|
|
912
|
+
worker_verification_warnings: dict[str, str] | None = None,
|
|
913
|
+
replanning_results: list[ReplanAttemptResult],
|
|
914
|
+
skipped: dict[str, str],
|
|
915
|
+
recovered: dict[str, str],
|
|
916
|
+
startup_warnings: list[str],
|
|
917
|
+
dry_run: bool,
|
|
918
|
+
schedule_preview: list[list[str]],
|
|
919
|
+
workspace_summary_lines: list[str],
|
|
920
|
+
binding_summary_lines: list[str],
|
|
921
|
+
run_outcome: SwarmRunOutcome | None = None,
|
|
922
|
+
) -> Path:
|
|
923
|
+
summary_path = paths.execution_dir / "swarm_summary.md"
|
|
924
|
+
lines = [
|
|
925
|
+
"# Swarm Summary",
|
|
926
|
+
"",
|
|
927
|
+
f"- Run ID: `{paths.run_id}`",
|
|
928
|
+
f"- Backend: `{backend_name}`",
|
|
929
|
+
f"- Base Branch: `{base_branch}`",
|
|
930
|
+
f"- Dry Run: `{'yes' if dry_run else 'no'}`",
|
|
931
|
+
]
|
|
932
|
+
if run_outcome is not None:
|
|
933
|
+
lines.extend(
|
|
934
|
+
[
|
|
935
|
+
f"- Run Status: `{run_outcome.status}`",
|
|
936
|
+
f"- Clean: `{'yes' if run_outcome.clean else 'no'}`",
|
|
937
|
+
f"- Verification Status: `{run_outcome.verification_status}`",
|
|
938
|
+
]
|
|
939
|
+
)
|
|
940
|
+
lines.extend(["", "## Workspace Context", ""])
|
|
941
|
+
if workspace_summary_lines:
|
|
942
|
+
lines.extend(f"- {line}" for line in workspace_summary_lines)
|
|
943
|
+
else:
|
|
944
|
+
lines.append("- (not available)")
|
|
945
|
+
|
|
946
|
+
lines.extend(["", "## Workspace Binding", ""])
|
|
947
|
+
if binding_summary_lines:
|
|
948
|
+
lines.extend(f"- {line}" for line in binding_summary_lines)
|
|
949
|
+
else:
|
|
950
|
+
lines.append("- (not available)")
|
|
951
|
+
|
|
952
|
+
lines.extend(
|
|
953
|
+
[
|
|
954
|
+
"",
|
|
955
|
+
"## Batches",
|
|
956
|
+
"",
|
|
957
|
+
]
|
|
958
|
+
)
|
|
959
|
+
if schedule_preview:
|
|
960
|
+
for idx, batch in enumerate(schedule_preview, start=1):
|
|
961
|
+
lines.append(f"- Batch {idx}: {', '.join(batch)}")
|
|
962
|
+
else:
|
|
963
|
+
lines.append("- (none)")
|
|
964
|
+
|
|
965
|
+
lines.extend(["", "## Executed Tasks", ""])
|
|
966
|
+
if executed:
|
|
967
|
+
for task_id in executed:
|
|
968
|
+
lines.append(f"- `{task_id}`")
|
|
969
|
+
else:
|
|
970
|
+
lines.append("- (none)")
|
|
971
|
+
|
|
972
|
+
lines.extend(["", "## Merge Outcomes", ""])
|
|
973
|
+
if merge_outcomes:
|
|
974
|
+
for item in merge_outcomes:
|
|
975
|
+
if item.success:
|
|
976
|
+
if item.action == "applied":
|
|
977
|
+
merged_line = (
|
|
978
|
+
f"- `{item.task_id}` applied from snapshot workspace (`{item.branch}`), "
|
|
979
|
+
f"commit `{item.merge_commit_hash}`"
|
|
980
|
+
)
|
|
981
|
+
elif item.action == "noop":
|
|
982
|
+
merged_line = (
|
|
983
|
+
f"- `{item.task_id}` completed as an already-satisfied no-op "
|
|
984
|
+
f"(`{item.branch}`); no merge required"
|
|
985
|
+
)
|
|
986
|
+
else:
|
|
987
|
+
merged_line = (
|
|
988
|
+
f"- `{item.task_id}` merged (`{item.branch}`), "
|
|
989
|
+
f"commit `{item.merge_commit_hash}`"
|
|
990
|
+
)
|
|
991
|
+
merged_line += _merge_outcome_recovery_suffix(item)
|
|
992
|
+
if item.cleanup_error:
|
|
993
|
+
merged_line += f" (cleanup warning: {item.cleanup_error})"
|
|
994
|
+
lines.append(merged_line)
|
|
995
|
+
else:
|
|
996
|
+
if item.action == "candidate_rejected":
|
|
997
|
+
lines.append(
|
|
998
|
+
f"- `{item.task_id}` rejected before merge/apply (`{item.branch}`): {item.error}"
|
|
999
|
+
)
|
|
1000
|
+
continue
|
|
1001
|
+
verb = "apply" if item.action == "applied" else "merge"
|
|
1002
|
+
lines.append(f"- `{item.task_id}` failed {verb} (`{item.branch}`): {item.error}")
|
|
1003
|
+
else:
|
|
1004
|
+
lines.append("- (none)")
|
|
1005
|
+
|
|
1006
|
+
lines.extend(["", "## Integration Gates", ""])
|
|
1007
|
+
if integration_results:
|
|
1008
|
+
for item in integration_results:
|
|
1009
|
+
status = "passed" if item.passed else "failed"
|
|
1010
|
+
phase = _integration_phase_label(item.phase)
|
|
1011
|
+
lines.append(
|
|
1012
|
+
f"- `{item.batch_label}` {phase} {status} ({item.mode}); batch tasks: "
|
|
1013
|
+
f"{', '.join(item.merged_task_ids) or '(none)'}; summary: "
|
|
1014
|
+
f"`{_repo_rel(paths.root, item.summary_path)}`"
|
|
1015
|
+
)
|
|
1016
|
+
else:
|
|
1017
|
+
lines.append("- (none)")
|
|
1018
|
+
|
|
1019
|
+
lines.extend(["", "## Worker Verification Warnings", ""])
|
|
1020
|
+
if worker_verification_warnings:
|
|
1021
|
+
for task_id, reason in sorted(worker_verification_warnings.items()):
|
|
1022
|
+
lines.append(f"- `{task_id}`: {reason}")
|
|
1023
|
+
else:
|
|
1024
|
+
lines.append("- (none)")
|
|
1025
|
+
|
|
1026
|
+
lines.extend(["", "## Replanning", ""])
|
|
1027
|
+
if replanning_results:
|
|
1028
|
+
for item in replanning_results:
|
|
1029
|
+
lines.append(f"- {item.summary_line(root=paths.root)}")
|
|
1030
|
+
else:
|
|
1031
|
+
lines.append("- (none)")
|
|
1032
|
+
|
|
1033
|
+
lines.extend(["", "## Skipped", ""])
|
|
1034
|
+
if skipped:
|
|
1035
|
+
for task_id, reason in sorted(skipped.items()):
|
|
1036
|
+
lines.append(f"- `{task_id}`: {reason}")
|
|
1037
|
+
else:
|
|
1038
|
+
lines.append("- (none)")
|
|
1039
|
+
|
|
1040
|
+
lines.extend(["", "## Recovered", ""])
|
|
1041
|
+
if recovered:
|
|
1042
|
+
for task_id, reason in sorted(recovered.items()):
|
|
1043
|
+
lines.append(f"- `{task_id}`: {reason}")
|
|
1044
|
+
else:
|
|
1045
|
+
lines.append("- (none)")
|
|
1046
|
+
|
|
1047
|
+
lines.extend(["", "## Startup Warnings", ""])
|
|
1048
|
+
if startup_warnings:
|
|
1049
|
+
for warning in startup_warnings:
|
|
1050
|
+
lines.append(f"- {warning}")
|
|
1051
|
+
else:
|
|
1052
|
+
lines.append("- (none)")
|
|
1053
|
+
|
|
1054
|
+
lines.extend(["", "## Next Steps", ""])
|
|
1055
|
+
if any(not m.success and m.action == "candidate_rejected" for m in merge_outcomes):
|
|
1056
|
+
lines.append(
|
|
1057
|
+
f"- Review pre-merge batch verification artifacts under `{_repo_rel(paths.root, paths.execution_integration_dir)}`."
|
|
1058
|
+
)
|
|
1059
|
+
lines.append("- Fix the combined batch state before rerunning `alysis forge swarm`.")
|
|
1060
|
+
elif any(not m.success for m in merge_outcomes):
|
|
1061
|
+
lines.append("- Resolve merge conflicts manually and rerun `alysis forge swarm`.")
|
|
1062
|
+
for outcome in merge_outcomes:
|
|
1063
|
+
if not outcome.success and outcome.conflict_review_path:
|
|
1064
|
+
lines.append(f"- Review conflict report: `{outcome.conflict_review_path}`")
|
|
1065
|
+
elif (
|
|
1066
|
+
run_outcome is not None
|
|
1067
|
+
and not run_outcome.clean
|
|
1068
|
+
and run_outcome.verification_status.startswith("failed")
|
|
1069
|
+
):
|
|
1070
|
+
lines.append(
|
|
1071
|
+
f"- Review integration artifacts under `{_repo_rel(paths.root, paths.execution_integration_dir)}`."
|
|
1072
|
+
)
|
|
1073
|
+
lines.append(
|
|
1074
|
+
f"- Review open integration issues: `{_repo_rel(paths.root, paths.execution_integration_issues_path)}`."
|
|
1075
|
+
)
|
|
1076
|
+
if replanning_results:
|
|
1077
|
+
lines.append(
|
|
1078
|
+
f"- Review replanning artifacts under `{_repo_rel(paths.root, paths.plan_replans_dir)}`."
|
|
1079
|
+
)
|
|
1080
|
+
elif any(not item.passed for item in integration_results):
|
|
1081
|
+
lines.append(
|
|
1082
|
+
f"- Review integration artifacts under `{_repo_rel(paths.root, paths.execution_integration_dir)}`."
|
|
1083
|
+
)
|
|
1084
|
+
lines.append(
|
|
1085
|
+
f"- Review open integration issues: `{_repo_rel(paths.root, paths.execution_integration_issues_path)}`."
|
|
1086
|
+
)
|
|
1087
|
+
else:
|
|
1088
|
+
lines.append("- Review reports under `.alysis/runs/<run_id>/execution/reports/`.")
|
|
1089
|
+
atomic_write_text(summary_path, "\n".join(lines).rstrip() + "\n")
|
|
1090
|
+
return summary_path
|
|
1091
|
+
|
|
1092
|
+
|
|
1093
|
+
def _compose_summary_skipped(
|
|
1094
|
+
*,
|
|
1095
|
+
scheduler_skipped: dict[str, str],
|
|
1096
|
+
execution_skipped: dict[str, str],
|
|
1097
|
+
observed_task_ids: set[str],
|
|
1098
|
+
) -> dict[str, str]:
|
|
1099
|
+
summary_skipped = {
|
|
1100
|
+
task_id: reason
|
|
1101
|
+
for task_id, reason in scheduler_skipped.items()
|
|
1102
|
+
if task_id not in observed_task_ids
|
|
1103
|
+
}
|
|
1104
|
+
summary_skipped.update(execution_skipped)
|
|
1105
|
+
return summary_skipped
|
|
1106
|
+
|
|
1107
|
+
|
|
1108
|
+
def _parse_only(only: str | None) -> set[str] | None:
|
|
1109
|
+
if only is None:
|
|
1110
|
+
return None
|
|
1111
|
+
ids = {part.strip() for part in only.split(",") if part.strip()}
|
|
1112
|
+
return ids or None
|
|
1113
|
+
|
|
1114
|
+
|
|
1115
|
+
def _mark_status(paths: RunPaths, plan: dict[str, Any], task_id: str, status: str) -> None:
|
|
1116
|
+
set_task_status(plan, task_id, status)
|
|
1117
|
+
save_plan(paths, plan)
|
|
1118
|
+
# Every swarm task transition goes through here, so this is the one place that has
|
|
1119
|
+
# to report task lifecycle events. No-op unless a --machine command is running.
|
|
1120
|
+
emit_task_status(task_id, status, {"run_id": paths.run_id, "source": "swarm"})
|
|
1121
|
+
|
|
1122
|
+
|
|
1123
|
+
def _bump_attempt(paths: RunPaths, plan: dict[str, Any], task_id: str) -> None:
|
|
1124
|
+
task = find_task(plan, task_id)
|
|
1125
|
+
if task is None:
|
|
1126
|
+
return
|
|
1127
|
+
raw = task.get("attempts")
|
|
1128
|
+
try:
|
|
1129
|
+
attempts = int(raw) if raw is not None else 0
|
|
1130
|
+
except (TypeError, ValueError):
|
|
1131
|
+
attempts = 0
|
|
1132
|
+
if attempts < 0:
|
|
1133
|
+
attempts = 0
|
|
1134
|
+
task["attempts"] = attempts + 1
|
|
1135
|
+
save_plan(paths, plan)
|
|
1136
|
+
|
|
1137
|
+
|
|
1138
|
+
def _mark_remaining_tasks_blocked_by_integration(
|
|
1139
|
+
*,
|
|
1140
|
+
paths: RunPaths,
|
|
1141
|
+
plan: dict[str, Any],
|
|
1142
|
+
reason: str,
|
|
1143
|
+
retry_failed: bool,
|
|
1144
|
+
retry_changes_requested: bool,
|
|
1145
|
+
parallel: int,
|
|
1146
|
+
max_attempts: int | None,
|
|
1147
|
+
only_ids: set[str] | None,
|
|
1148
|
+
) -> list[str]:
|
|
1149
|
+
blocked: list[str] = []
|
|
1150
|
+
terminal_statuses = {
|
|
1151
|
+
"done",
|
|
1152
|
+
TASK_STATUS_COMPLETED_UNVERIFIED,
|
|
1153
|
+
"failed",
|
|
1154
|
+
"verify_failed",
|
|
1155
|
+
"candidate_rejected",
|
|
1156
|
+
"changes_requested",
|
|
1157
|
+
"merge_conflict",
|
|
1158
|
+
"blocked_integration",
|
|
1159
|
+
"interrupted",
|
|
1160
|
+
"cancelled",
|
|
1161
|
+
}
|
|
1162
|
+
changed = False
|
|
1163
|
+
for task in plan.get("tasks") or []:
|
|
1164
|
+
if not isinstance(task, dict):
|
|
1165
|
+
continue
|
|
1166
|
+
task_id = str(task.get("id") or "").strip()
|
|
1167
|
+
if not task_id:
|
|
1168
|
+
continue
|
|
1169
|
+
if only_ids is not None and task_id not in only_ids:
|
|
1170
|
+
continue
|
|
1171
|
+
status = canonical_task_status(str(task.get("status") or ""))
|
|
1172
|
+
if status in terminal_statuses:
|
|
1173
|
+
continue
|
|
1174
|
+
task["status"] = "blocked_integration"
|
|
1175
|
+
task["last_error"] = reason
|
|
1176
|
+
blocked.append(task_id)
|
|
1177
|
+
changed = True
|
|
1178
|
+
if changed:
|
|
1179
|
+
save_plan(paths, plan)
|
|
1180
|
+
return blocked
|
|
1181
|
+
|
|
1182
|
+
|
|
1183
|
+
def _task_has_unfinished_direct_dependent(plan: dict[str, Any], task_id: str) -> bool:
|
|
1184
|
+
unfinished_statuses = {"planned", "todo", "in_progress"}
|
|
1185
|
+
for task in plan.get("tasks") or []:
|
|
1186
|
+
if not isinstance(task, dict):
|
|
1187
|
+
continue
|
|
1188
|
+
deps = [str(dep).strip() for dep in (task.get("dependencies") or []) if str(dep).strip()]
|
|
1189
|
+
if task_id not in deps:
|
|
1190
|
+
continue
|
|
1191
|
+
status = canonical_task_status(str(task.get("status") or ""))
|
|
1192
|
+
if status in unfinished_statuses:
|
|
1193
|
+
return True
|
|
1194
|
+
return False
|
|
1195
|
+
|
|
1196
|
+
|
|
1197
|
+
def _ready_items_have_unfinished_dependents(
|
|
1198
|
+
*,
|
|
1199
|
+
plan: dict[str, Any],
|
|
1200
|
+
ready_items: list[ReadyBatchItem],
|
|
1201
|
+
) -> bool:
|
|
1202
|
+
return any(_task_has_unfinished_direct_dependent(plan, item.task_id) for item in ready_items)
|
|
1203
|
+
|
|
1204
|
+
|
|
1205
|
+
def _should_defer_red_regression_ready_task(plan: dict[str, Any], task: dict[str, Any]) -> bool:
|
|
1206
|
+
task_id = str(task.get("id") or "").strip()
|
|
1207
|
+
return bool(
|
|
1208
|
+
task_id
|
|
1209
|
+
and is_expected_red_regression_precursor(task)
|
|
1210
|
+
and _task_has_unfinished_direct_dependent(plan, task_id)
|
|
1211
|
+
)
|
|
1212
|
+
|
|
1213
|
+
|
|
1214
|
+
def _deferred_red_dependency_tasks_for_ready_items(
|
|
1215
|
+
*,
|
|
1216
|
+
plan: dict[str, Any],
|
|
1217
|
+
ready_items: list[ReadyBatchItem],
|
|
1218
|
+
) -> list[dict[str, Any]]:
|
|
1219
|
+
wanted: set[str] = set()
|
|
1220
|
+
for item in ready_items:
|
|
1221
|
+
wanted.update(
|
|
1222
|
+
str(dep).strip() for dep in (item.task.get("dependencies") or []) if str(dep).strip()
|
|
1223
|
+
)
|
|
1224
|
+
if not wanted:
|
|
1225
|
+
return []
|
|
1226
|
+
existing = {item.task_id for item in ready_items}
|
|
1227
|
+
out: list[dict[str, Any]] = []
|
|
1228
|
+
for task in plan.get("tasks") or []:
|
|
1229
|
+
if not isinstance(task, dict):
|
|
1230
|
+
continue
|
|
1231
|
+
task_id = str(task.get("id") or "").strip()
|
|
1232
|
+
if task_id not in wanted or task_id in existing:
|
|
1233
|
+
continue
|
|
1234
|
+
if canonical_task_status(str(task.get("status") or "")) != "ready_for_merge":
|
|
1235
|
+
continue
|
|
1236
|
+
if not is_expected_red_regression_precursor(task):
|
|
1237
|
+
continue
|
|
1238
|
+
out.append(task)
|
|
1239
|
+
return out
|
|
1240
|
+
|
|
1241
|
+
|
|
1242
|
+
def _recover_stale_in_progress(
|
|
1243
|
+
*,
|
|
1244
|
+
paths: RunPaths,
|
|
1245
|
+
plan: dict[str, Any],
|
|
1246
|
+
) -> dict[str, str]:
|
|
1247
|
+
recovered: dict[str, str] = {}
|
|
1248
|
+
changed = False
|
|
1249
|
+
for task in plan.get("tasks") or []:
|
|
1250
|
+
status = canonical_task_status(str(task.get("status") or ""))
|
|
1251
|
+
if status != "in_progress":
|
|
1252
|
+
continue
|
|
1253
|
+
task_id = str(task.get("id") or "").strip()
|
|
1254
|
+
if not task_id:
|
|
1255
|
+
continue
|
|
1256
|
+
reason = (
|
|
1257
|
+
"Recovered stale in_progress task before swarm run "
|
|
1258
|
+
"(previous run may have been interrupted)."
|
|
1259
|
+
)
|
|
1260
|
+
task["status"] = "failed"
|
|
1261
|
+
task["last_error"] = reason
|
|
1262
|
+
recovered[task_id] = reason
|
|
1263
|
+
changed = True
|
|
1264
|
+
if changed:
|
|
1265
|
+
save_plan(paths, plan)
|
|
1266
|
+
return recovered
|
|
1267
|
+
|
|
1268
|
+
|
|
1269
|
+
def _reject_ready_batch_items(
|
|
1270
|
+
*,
|
|
1271
|
+
paths: RunPaths,
|
|
1272
|
+
plan: dict[str, Any],
|
|
1273
|
+
backend: SwarmBackend,
|
|
1274
|
+
keep_worktrees: bool,
|
|
1275
|
+
ready_items: list[ReadyBatchItem],
|
|
1276
|
+
reason: str,
|
|
1277
|
+
merge_outcomes: list[MergeOutcome],
|
|
1278
|
+
execution_skipped: dict[str, str],
|
|
1279
|
+
cleanup_nonmerged_workspace: Callable[..., None],
|
|
1280
|
+
) -> bool:
|
|
1281
|
+
task_attempt_resolutions = False
|
|
1282
|
+
changed = False
|
|
1283
|
+
for item in ready_items:
|
|
1284
|
+
task = find_task(plan, item.task_id)
|
|
1285
|
+
if task is not None:
|
|
1286
|
+
task["status"] = "candidate_rejected"
|
|
1287
|
+
task.pop("merge_commit_hash", None)
|
|
1288
|
+
existing_error = str(task.get("last_error") or "").strip()
|
|
1289
|
+
task["last_error"] = (
|
|
1290
|
+
reason
|
|
1291
|
+
if not existing_error or reason in existing_error
|
|
1292
|
+
else f"{existing_error}; {reason}"
|
|
1293
|
+
)
|
|
1294
|
+
changed = True
|
|
1295
|
+
execution_skipped[item.task_id] = reason
|
|
1296
|
+
task_attempt_resolutions = (
|
|
1297
|
+
_resolve_worker_task_attempt_acceptance(
|
|
1298
|
+
paths,
|
|
1299
|
+
plan=plan,
|
|
1300
|
+
task_id=item.task_id,
|
|
1301
|
+
acceptance_state="rejected",
|
|
1302
|
+
summary=f"Worker result was rejected because {reason}",
|
|
1303
|
+
result=item.result,
|
|
1304
|
+
)
|
|
1305
|
+
or task_attempt_resolutions
|
|
1306
|
+
)
|
|
1307
|
+
_mark_worker_knowledge_capture_skipped(
|
|
1308
|
+
paths,
|
|
1309
|
+
task_id=item.task_id,
|
|
1310
|
+
result=item.result,
|
|
1311
|
+
reason=f"worker result was not accepted because {reason}",
|
|
1312
|
+
)
|
|
1313
|
+
if item.remote_record is not None and item.report_path_raw:
|
|
1314
|
+
_append_remote_report_update(
|
|
1315
|
+
paths=paths,
|
|
1316
|
+
report_path_raw=item.report_path_raw,
|
|
1317
|
+
record=item.remote_record,
|
|
1318
|
+
)
|
|
1319
|
+
cleanup_nonmerged_workspace(
|
|
1320
|
+
item.task_id,
|
|
1321
|
+
item.prepared_workspace,
|
|
1322
|
+
reason=reason,
|
|
1323
|
+
result=item.result,
|
|
1324
|
+
)
|
|
1325
|
+
outcome = MergeOutcome(
|
|
1326
|
+
task_id=item.task_id,
|
|
1327
|
+
branch=item.branch,
|
|
1328
|
+
success=False,
|
|
1329
|
+
merge_commit_hash=None,
|
|
1330
|
+
error=reason,
|
|
1331
|
+
backend_name=backend.name,
|
|
1332
|
+
action="candidate_rejected",
|
|
1333
|
+
worker_result_kind=item.result.effective_result_kind
|
|
1334
|
+
if item.result is not None
|
|
1335
|
+
else None,
|
|
1336
|
+
salvaged_nonzero_exit=(
|
|
1337
|
+
item.result.salvaged_nonzero_exit if item.result is not None else False
|
|
1338
|
+
),
|
|
1339
|
+
salvaged_agent_exception=(
|
|
1340
|
+
item.result.salvaged_agent_exception if item.result is not None else False
|
|
1341
|
+
),
|
|
1342
|
+
agent_exception_summary=_result_agent_exception_summary(item.result),
|
|
1343
|
+
)
|
|
1344
|
+
_write_merge_result(paths, outcome)
|
|
1345
|
+
merge_outcomes.append(outcome)
|
|
1346
|
+
if changed:
|
|
1347
|
+
save_plan(paths, plan)
|
|
1348
|
+
return task_attempt_resolutions
|
|
1349
|
+
|
|
1350
|
+
|
|
1351
|
+
def _verify_ready_batch_candidate(
|
|
1352
|
+
*,
|
|
1353
|
+
paths: RunPaths,
|
|
1354
|
+
plan: dict[str, Any],
|
|
1355
|
+
cfg: AppConfig,
|
|
1356
|
+
workspace_scan,
|
|
1357
|
+
backend: SwarmBackend,
|
|
1358
|
+
batch_index: int,
|
|
1359
|
+
base_branch: str,
|
|
1360
|
+
integration_mode: str,
|
|
1361
|
+
allow_transient_dependent_integration_warning: bool,
|
|
1362
|
+
integration_verify_cmd: list[str] | None,
|
|
1363
|
+
verify_cmd: list[str] | None,
|
|
1364
|
+
keep_worktrees: bool,
|
|
1365
|
+
ready_items: list[ReadyBatchItem],
|
|
1366
|
+
trace: Callable[[str, str], None],
|
|
1367
|
+
task_trace: Callable[[str, str], None],
|
|
1368
|
+
integration_runner: Callable[..., IntegrationGateResult],
|
|
1369
|
+
) -> tuple[IntegrationGateResult | None, str | None]:
|
|
1370
|
+
if integration_mode == "off" or not ready_items:
|
|
1371
|
+
return None, None
|
|
1372
|
+
|
|
1373
|
+
batch_label = f"batch_{batch_index:03d}"
|
|
1374
|
+
task_ids = [item.task_id for item in ready_items]
|
|
1375
|
+
candidate_branch = _candidate_branch_name(run_id=paths.run_id, batch_label=batch_label)
|
|
1376
|
+
candidate_workspace: PreparedBatchCandidateWorkspace | None = None
|
|
1377
|
+
cleanup_errors: list[str] = []
|
|
1378
|
+
result: IntegrationGateResult | None = None
|
|
1379
|
+
try:
|
|
1380
|
+
trace(
|
|
1381
|
+
"integration.lifecycle",
|
|
1382
|
+
f"Assembling pre-merge batch candidate for {batch_label}: {', '.join(task_ids)}.",
|
|
1383
|
+
)
|
|
1384
|
+
candidate_workspace = backend.prepare_candidate_workspace(
|
|
1385
|
+
root=paths.root,
|
|
1386
|
+
run_dir=paths.run_dir,
|
|
1387
|
+
batch_label=batch_label,
|
|
1388
|
+
branch=candidate_branch,
|
|
1389
|
+
base_branch=base_branch,
|
|
1390
|
+
)
|
|
1391
|
+
merged_paths: list[str] = []
|
|
1392
|
+
for item in ready_items:
|
|
1393
|
+
merge_title = str(item.task.get("title") or "").strip()
|
|
1394
|
+
merge_message = (
|
|
1395
|
+
f"Merge {item.task_id}: {merge_title}" if merge_title else f"Merge {item.task_id}"
|
|
1396
|
+
)
|
|
1397
|
+
backend.apply_task_to_candidate(
|
|
1398
|
+
root=paths.root,
|
|
1399
|
+
candidate_workspace=candidate_workspace,
|
|
1400
|
+
prepared_workspace=item.prepared_workspace,
|
|
1401
|
+
message=merge_message,
|
|
1402
|
+
changed_files=list(item.changed_files),
|
|
1403
|
+
)
|
|
1404
|
+
merged_paths.extend(item.changed_files)
|
|
1405
|
+
result = _run_batch_integration_gate(
|
|
1406
|
+
paths=paths,
|
|
1407
|
+
cfg=cfg,
|
|
1408
|
+
workspace_scan=workspace_scan,
|
|
1409
|
+
batch_index=batch_index,
|
|
1410
|
+
integration_mode=integration_mode,
|
|
1411
|
+
integration_verify_cmd=integration_verify_cmd,
|
|
1412
|
+
verify_cmd=verify_cmd,
|
|
1413
|
+
merged_task_ids=task_ids,
|
|
1414
|
+
merged_paths=merged_paths,
|
|
1415
|
+
root=candidate_workspace.worktree_path,
|
|
1416
|
+
phase="pre_merge_candidate",
|
|
1417
|
+
trace=trace,
|
|
1418
|
+
task_trace=task_trace,
|
|
1419
|
+
integration_runner=integration_runner,
|
|
1420
|
+
)
|
|
1421
|
+
except GitOpsError as e:
|
|
1422
|
+
detail = f"pre-merge batch candidate assembly failed ({batch_label}): {e}"
|
|
1423
|
+
trace("integration.error", detail)
|
|
1424
|
+
return None, detail
|
|
1425
|
+
finally:
|
|
1426
|
+
if candidate_workspace is not None:
|
|
1427
|
+
cleanup_errors = backend.cleanup_candidate_workspace(
|
|
1428
|
+
root=paths.root,
|
|
1429
|
+
candidate_workspace=candidate_workspace,
|
|
1430
|
+
keep_worktrees=keep_worktrees,
|
|
1431
|
+
)
|
|
1432
|
+
if cleanup_errors:
|
|
1433
|
+
trace(
|
|
1434
|
+
"worktree.error",
|
|
1435
|
+
(
|
|
1436
|
+
f"Candidate workspace cleanup warning for {batch_label}: "
|
|
1437
|
+
f"{'; '.join(cleanup_errors)}"
|
|
1438
|
+
),
|
|
1439
|
+
)
|
|
1440
|
+
|
|
1441
|
+
if result is None:
|
|
1442
|
+
return None, None
|
|
1443
|
+
if result.passed:
|
|
1444
|
+
return result, None
|
|
1445
|
+
if (
|
|
1446
|
+
integration_mode == "warn"
|
|
1447
|
+
and allow_transient_dependent_integration_warning
|
|
1448
|
+
and _ready_items_have_unfinished_dependents(
|
|
1449
|
+
plan=plan,
|
|
1450
|
+
ready_items=ready_items,
|
|
1451
|
+
)
|
|
1452
|
+
):
|
|
1453
|
+
trace(
|
|
1454
|
+
"integration.lifecycle",
|
|
1455
|
+
(
|
|
1456
|
+
f"Pre-merge batch candidate verification failed ({result.batch_label}) "
|
|
1457
|
+
"but at least one task has unfinished planned dependents; accepting the "
|
|
1458
|
+
"batch as a transient integration warning so dependent work can run."
|
|
1459
|
+
),
|
|
1460
|
+
)
|
|
1461
|
+
return result, None
|
|
1462
|
+
reason = (
|
|
1463
|
+
f"pre-merge batch candidate verification failed ({result.batch_label}): {result.summary}"
|
|
1464
|
+
)
|
|
1465
|
+
if cleanup_errors:
|
|
1466
|
+
reason += f" Candidate cleanup warning: {'; '.join(cleanup_errors)}"
|
|
1467
|
+
return result, reason
|
|
1468
|
+
|
|
1469
|
+
|
|
1470
|
+
def _merge_ready_batch_items_into_base(
|
|
1471
|
+
*,
|
|
1472
|
+
paths: RunPaths,
|
|
1473
|
+
plan: dict[str, Any],
|
|
1474
|
+
cfg: AppConfig,
|
|
1475
|
+
api_key_override: str | None,
|
|
1476
|
+
backend: SwarmBackend,
|
|
1477
|
+
base_branch: str,
|
|
1478
|
+
keep_worktrees: bool,
|
|
1479
|
+
verify_mode: str,
|
|
1480
|
+
verify_commands: list[str],
|
|
1481
|
+
verify_command_selection: ResolvedVerifyCommands | None,
|
|
1482
|
+
ready_items: list[ReadyBatchItem],
|
|
1483
|
+
merge_outcomes: list[MergeOutcome],
|
|
1484
|
+
trace: Callable[..., None],
|
|
1485
|
+
cleanup_nonmerged_workspace: Callable[..., None],
|
|
1486
|
+
) -> tuple[list[str], list[str], list[str], bool, bool]:
|
|
1487
|
+
merged_task_ids: list[str] = []
|
|
1488
|
+
remote_task_ids: list[str] = []
|
|
1489
|
+
merged_paths: list[str] = []
|
|
1490
|
+
promoted_knowledge = False
|
|
1491
|
+
task_attempt_resolutions = False
|
|
1492
|
+
|
|
1493
|
+
for item in ready_items:
|
|
1494
|
+
trace(
|
|
1495
|
+
"merge.lifecycle",
|
|
1496
|
+
f"Starting merge from branch {item.branch}.",
|
|
1497
|
+
task_id=item.task_id,
|
|
1498
|
+
)
|
|
1499
|
+
outcome = _merge_task(
|
|
1500
|
+
paths=paths,
|
|
1501
|
+
plan=plan,
|
|
1502
|
+
task=item.task,
|
|
1503
|
+
cfg=cfg,
|
|
1504
|
+
api_key_override=api_key_override,
|
|
1505
|
+
backend=backend,
|
|
1506
|
+
base_branch=base_branch,
|
|
1507
|
+
keep_worktrees=keep_worktrees,
|
|
1508
|
+
prepared_workspace=item.prepared_workspace,
|
|
1509
|
+
changed_files=list(item.changed_files),
|
|
1510
|
+
verify_commands=verify_commands,
|
|
1511
|
+
verify_command_selection=verify_command_selection if verify_mode != "off" else None,
|
|
1512
|
+
result=item.result,
|
|
1513
|
+
)
|
|
1514
|
+
merge_outcomes.append(outcome)
|
|
1515
|
+
if outcome.success:
|
|
1516
|
+
trace(
|
|
1517
|
+
"merge.lifecycle",
|
|
1518
|
+
_merge_outcome_success_trace_message(outcome),
|
|
1519
|
+
task_id=item.task_id,
|
|
1520
|
+
)
|
|
1521
|
+
merged_task_ids.append(item.task_id)
|
|
1522
|
+
merged_paths.extend(item.changed_files)
|
|
1523
|
+
task_attempt_resolutions = (
|
|
1524
|
+
_resolve_worker_task_attempt_acceptance(
|
|
1525
|
+
paths,
|
|
1526
|
+
plan=plan,
|
|
1527
|
+
task_id=item.task_id,
|
|
1528
|
+
acceptance_state="accepted",
|
|
1529
|
+
summary="Worker result was accepted after successful merge/apply.",
|
|
1530
|
+
result=item.result,
|
|
1531
|
+
)
|
|
1532
|
+
or task_attempt_resolutions
|
|
1533
|
+
)
|
|
1534
|
+
if item.remote_record is not None and item.report_path_raw:
|
|
1535
|
+
remote_task_ids.append(item.task_id)
|
|
1536
|
+
_append_remote_report_update(
|
|
1537
|
+
paths=paths,
|
|
1538
|
+
report_path_raw=item.report_path_raw,
|
|
1539
|
+
record=item.remote_record,
|
|
1540
|
+
)
|
|
1541
|
+
promotion_result = _promote_worker_knowledge_capture(
|
|
1542
|
+
paths,
|
|
1543
|
+
plan=plan,
|
|
1544
|
+
task_id=item.task_id,
|
|
1545
|
+
result=item.result,
|
|
1546
|
+
)
|
|
1547
|
+
if promotion_result is not None and (
|
|
1548
|
+
promotion_result.fact_entry_ids or promotion_result.decision_entry_ids
|
|
1549
|
+
):
|
|
1550
|
+
promoted_knowledge = True
|
|
1551
|
+
continue
|
|
1552
|
+
|
|
1553
|
+
task_attempt_resolutions = (
|
|
1554
|
+
_resolve_worker_task_attempt_acceptance(
|
|
1555
|
+
paths,
|
|
1556
|
+
plan=plan,
|
|
1557
|
+
task_id=item.task_id,
|
|
1558
|
+
acceptance_state="rejected",
|
|
1559
|
+
summary=(
|
|
1560
|
+
"Worker result was rejected because merge/apply failed: "
|
|
1561
|
+
f"{outcome.error or 'unknown error'}"
|
|
1562
|
+
),
|
|
1563
|
+
result=item.result,
|
|
1564
|
+
)
|
|
1565
|
+
or task_attempt_resolutions
|
|
1566
|
+
)
|
|
1567
|
+
_mark_worker_knowledge_capture_skipped(
|
|
1568
|
+
paths,
|
|
1569
|
+
task_id=item.task_id,
|
|
1570
|
+
result=item.result,
|
|
1571
|
+
reason="worker result was not accepted because merge/apply failed",
|
|
1572
|
+
)
|
|
1573
|
+
if canonical_task_status(str(item.task.get("status") or "")) == "failed":
|
|
1574
|
+
cleanup_nonmerged_workspace(
|
|
1575
|
+
item.task_id,
|
|
1576
|
+
item.prepared_workspace,
|
|
1577
|
+
reason=f"merge/apply failed: {outcome.error or 'unknown error'}",
|
|
1578
|
+
result=item.result,
|
|
1579
|
+
)
|
|
1580
|
+
trace(
|
|
1581
|
+
"merge.error",
|
|
1582
|
+
f"Merge failed: {outcome.error or 'unknown error'}",
|
|
1583
|
+
task_id=item.task_id,
|
|
1584
|
+
)
|
|
1585
|
+
|
|
1586
|
+
return (
|
|
1587
|
+
merged_task_ids,
|
|
1588
|
+
remote_task_ids,
|
|
1589
|
+
merged_paths,
|
|
1590
|
+
promoted_knowledge,
|
|
1591
|
+
task_attempt_resolutions,
|
|
1592
|
+
)
|
|
1593
|
+
|
|
1594
|
+
|
|
1595
|
+
def _merge_task(
|
|
1596
|
+
*,
|
|
1597
|
+
paths: RunPaths,
|
|
1598
|
+
plan: dict[str, Any],
|
|
1599
|
+
task: dict[str, Any],
|
|
1600
|
+
cfg: AppConfig,
|
|
1601
|
+
api_key_override: str | None,
|
|
1602
|
+
backend: SwarmBackend,
|
|
1603
|
+
base_branch: str,
|
|
1604
|
+
keep_worktrees: bool,
|
|
1605
|
+
prepared_workspace: PreparedTaskWorkspace,
|
|
1606
|
+
changed_files: list[str] | None = None,
|
|
1607
|
+
verify_commands: list[str] | None = None,
|
|
1608
|
+
verify_command_selection: ResolvedVerifyCommands | None = None,
|
|
1609
|
+
result: TaskWorkerResult | None = None,
|
|
1610
|
+
) -> MergeOutcome:
|
|
1611
|
+
task_id = str(task.get("id") or "")
|
|
1612
|
+
branch = str(task.get("branch") or "")
|
|
1613
|
+
merge_title = str(task.get("title") or "").strip()
|
|
1614
|
+
merge_message = f"Merge {task_id}: {merge_title}" if merge_title else f"Merge {task_id}"
|
|
1615
|
+
try:
|
|
1616
|
+
if paths.has_head_commit:
|
|
1617
|
+
_raise_if_untracked_overwrite_conflict(
|
|
1618
|
+
root=paths.root,
|
|
1619
|
+
task_id=task_id,
|
|
1620
|
+
changed_files=changed_files,
|
|
1621
|
+
)
|
|
1622
|
+
apply_result = backend.apply_task_success(
|
|
1623
|
+
root=paths.root,
|
|
1624
|
+
prepared_workspace=prepared_workspace,
|
|
1625
|
+
message=merge_message,
|
|
1626
|
+
changed_files=changed_files,
|
|
1627
|
+
)
|
|
1628
|
+
except GitOpsError as e:
|
|
1629
|
+
unmerged_files = list_unmerged_files(prepared_workspace.control_root)
|
|
1630
|
+
is_merge_conflict = bool(unmerged_files)
|
|
1631
|
+
conflict_review_path: str | None = None
|
|
1632
|
+
cleanup_error: str | None = None
|
|
1633
|
+
if is_merge_conflict:
|
|
1634
|
+
context = capture_merge_conflict_context(
|
|
1635
|
+
prepared_workspace.control_root,
|
|
1636
|
+
base_branch=base_branch,
|
|
1637
|
+
task_branch=branch,
|
|
1638
|
+
merge_error=str(e),
|
|
1639
|
+
)
|
|
1640
|
+
review_outcome = review_merge_conflict(
|
|
1641
|
+
paths=paths,
|
|
1642
|
+
task=task,
|
|
1643
|
+
cfg=cfg,
|
|
1644
|
+
api_key_override=api_key_override,
|
|
1645
|
+
context=context,
|
|
1646
|
+
plan=plan,
|
|
1647
|
+
)
|
|
1648
|
+
cleanup_ok, cleanup_log = try_abort_merge(
|
|
1649
|
+
prepared_workspace.control_root,
|
|
1650
|
+
base_branch=base_branch,
|
|
1651
|
+
)
|
|
1652
|
+
conflict_artifacts = write_conflict_artifacts(
|
|
1653
|
+
paths=paths,
|
|
1654
|
+
task_id=task_id,
|
|
1655
|
+
context=context,
|
|
1656
|
+
review_json=review_outcome.review_json,
|
|
1657
|
+
review_md=review_outcome.review_markdown,
|
|
1658
|
+
cleanup_log=cleanup_log,
|
|
1659
|
+
)
|
|
1660
|
+
conflict_review_path = _repo_rel(paths.root, conflict_artifacts.review_md_path)
|
|
1661
|
+
if not cleanup_ok:
|
|
1662
|
+
cleanup_error = (
|
|
1663
|
+
"merge cleanup did not fully recover repository state; "
|
|
1664
|
+
f"see {_repo_rel(paths.root, conflict_artifacts.cleanup_log_path)}"
|
|
1665
|
+
)
|
|
1666
|
+
auto_settings = load_conflict_auto_resolve_settings(cfg=cfg)
|
|
1667
|
+
if can_attempt_conflict_auto_resolve(task=task, settings=auto_settings):
|
|
1668
|
+
bump_conflict_attempt(task)
|
|
1669
|
+
save_plan(paths, plan)
|
|
1670
|
+
auto_outcome = attempt_auto_resolve_conflict(
|
|
1671
|
+
paths=paths,
|
|
1672
|
+
plan=plan,
|
|
1673
|
+
task=task,
|
|
1674
|
+
cfg=cfg,
|
|
1675
|
+
api_key_override=api_key_override,
|
|
1676
|
+
base_branch=base_branch,
|
|
1677
|
+
task_branch=branch,
|
|
1678
|
+
keep_worktrees=keep_worktrees,
|
|
1679
|
+
settings=auto_settings,
|
|
1680
|
+
verify_commands=verify_commands,
|
|
1681
|
+
verify_command_selection=verify_command_selection,
|
|
1682
|
+
)
|
|
1683
|
+
if auto_outcome.success:
|
|
1684
|
+
task_obj = find_task(plan, task_id)
|
|
1685
|
+
if task_obj is not None:
|
|
1686
|
+
task_obj["merge_commit_hash"] = auto_outcome.merge_commit_hash
|
|
1687
|
+
_mark_status(paths, plan, task_id, "done")
|
|
1688
|
+
outcome = MergeOutcome(
|
|
1689
|
+
task_id=task_id,
|
|
1690
|
+
branch=branch,
|
|
1691
|
+
success=True,
|
|
1692
|
+
merge_commit_hash=auto_outcome.merge_commit_hash,
|
|
1693
|
+
error=None,
|
|
1694
|
+
cleanup_error=(
|
|
1695
|
+
"; ".join(auto_outcome.warnings)
|
|
1696
|
+
if auto_outcome.warnings
|
|
1697
|
+
else cleanup_error
|
|
1698
|
+
),
|
|
1699
|
+
conflict_review_path=_repo_rel(paths.root, auto_outcome.report_path),
|
|
1700
|
+
backend_name=backend.name,
|
|
1701
|
+
action="applied" if backend.name == "snapshot_workspace" else "merged",
|
|
1702
|
+
worker_result_kind=(
|
|
1703
|
+
result.effective_result_kind if result is not None else None
|
|
1704
|
+
),
|
|
1705
|
+
salvaged_nonzero_exit=(
|
|
1706
|
+
result.salvaged_nonzero_exit if result is not None else False
|
|
1707
|
+
),
|
|
1708
|
+
salvaged_agent_exception=(
|
|
1709
|
+
result.salvaged_agent_exception if result is not None else False
|
|
1710
|
+
),
|
|
1711
|
+
agent_exception_summary=_result_agent_exception_summary(result),
|
|
1712
|
+
)
|
|
1713
|
+
_write_merge_result(paths, outcome)
|
|
1714
|
+
return outcome
|
|
1715
|
+
auto_error = auto_outcome.error or "unknown error"
|
|
1716
|
+
e = GitOpsError(f"{e}; auto-resolve failed: {auto_error}")
|
|
1717
|
+
conflict_review_path = _repo_rel(paths.root, auto_outcome.report_path)
|
|
1718
|
+
_mark_status(paths, plan, task_id, "merge_conflict" if is_merge_conflict else "failed")
|
|
1719
|
+
outcome = MergeOutcome(
|
|
1720
|
+
task_id=task_id,
|
|
1721
|
+
branch=branch,
|
|
1722
|
+
success=False,
|
|
1723
|
+
merge_commit_hash=None,
|
|
1724
|
+
error=str(e),
|
|
1725
|
+
cleanup_error=cleanup_error,
|
|
1726
|
+
conflict_review_path=conflict_review_path,
|
|
1727
|
+
backend_name=backend.name,
|
|
1728
|
+
action="applied" if backend.name == "snapshot_workspace" else "merged",
|
|
1729
|
+
worker_result_kind=result.effective_result_kind if result is not None else None,
|
|
1730
|
+
salvaged_nonzero_exit=result.salvaged_nonzero_exit if result is not None else False,
|
|
1731
|
+
salvaged_agent_exception=(
|
|
1732
|
+
result.salvaged_agent_exception if result is not None else False
|
|
1733
|
+
),
|
|
1734
|
+
agent_exception_summary=_result_agent_exception_summary(result),
|
|
1735
|
+
)
|
|
1736
|
+
_write_merge_result(paths, outcome)
|
|
1737
|
+
return outcome
|
|
1738
|
+
|
|
1739
|
+
task_obj = find_task(plan, task_id)
|
|
1740
|
+
if task_obj is not None:
|
|
1741
|
+
task_obj["merge_commit_hash"] = apply_result.merge_commit_hash
|
|
1742
|
+
_mark_status(paths, plan, task_id, "done")
|
|
1743
|
+
|
|
1744
|
+
cleanup_errors = backend.cleanup_task_workspace(
|
|
1745
|
+
root=paths.root,
|
|
1746
|
+
prepared_workspace=prepared_workspace,
|
|
1747
|
+
keep_worktrees=keep_worktrees,
|
|
1748
|
+
)
|
|
1749
|
+
|
|
1750
|
+
outcome = MergeOutcome(
|
|
1751
|
+
task_id=task_id,
|
|
1752
|
+
branch=branch,
|
|
1753
|
+
success=True,
|
|
1754
|
+
merge_commit_hash=apply_result.merge_commit_hash,
|
|
1755
|
+
error=None,
|
|
1756
|
+
cleanup_error="; ".join(cleanup_errors) if cleanup_errors else None,
|
|
1757
|
+
backend_name=apply_result.backend_name,
|
|
1758
|
+
action=apply_result.action,
|
|
1759
|
+
worker_result_kind=result.effective_result_kind if result is not None else None,
|
|
1760
|
+
salvaged_nonzero_exit=result.salvaged_nonzero_exit if result is not None else False,
|
|
1761
|
+
salvaged_agent_exception=result.salvaged_agent_exception if result is not None else False,
|
|
1762
|
+
agent_exception_summary=_result_agent_exception_summary(result),
|
|
1763
|
+
)
|
|
1764
|
+
_write_merge_result(paths, outcome)
|
|
1765
|
+
return outcome
|
|
1766
|
+
|
|
1767
|
+
|
|
1768
|
+
def _accept_noop_task(
|
|
1769
|
+
*,
|
|
1770
|
+
paths: RunPaths,
|
|
1771
|
+
plan: dict[str, Any],
|
|
1772
|
+
task: dict[str, Any],
|
|
1773
|
+
backend: SwarmBackend,
|
|
1774
|
+
keep_worktrees: bool,
|
|
1775
|
+
prepared_workspace: PreparedTaskWorkspace,
|
|
1776
|
+
result: TaskWorkerResult | None = None,
|
|
1777
|
+
) -> MergeOutcome:
|
|
1778
|
+
task_id = str(task.get("id") or "")
|
|
1779
|
+
branch = str(task.get("branch") or "")
|
|
1780
|
+
task_obj = find_task(plan, task_id)
|
|
1781
|
+
if task_obj is not None:
|
|
1782
|
+
task_obj.pop("merge_commit_hash", None)
|
|
1783
|
+
_mark_status(paths, plan, task_id, "already_satisfied")
|
|
1784
|
+
cleanup_errors = backend.cleanup_task_workspace(
|
|
1785
|
+
root=paths.root,
|
|
1786
|
+
prepared_workspace=prepared_workspace,
|
|
1787
|
+
keep_worktrees=keep_worktrees,
|
|
1788
|
+
)
|
|
1789
|
+
outcome = MergeOutcome(
|
|
1790
|
+
task_id=task_id,
|
|
1791
|
+
branch=branch,
|
|
1792
|
+
success=True,
|
|
1793
|
+
merge_commit_hash=None,
|
|
1794
|
+
error=None,
|
|
1795
|
+
cleanup_error="; ".join(cleanup_errors) if cleanup_errors else None,
|
|
1796
|
+
backend_name=backend.name,
|
|
1797
|
+
action="noop",
|
|
1798
|
+
worker_result_kind=result.effective_result_kind if result is not None else None,
|
|
1799
|
+
salvaged_nonzero_exit=result.salvaged_nonzero_exit if result is not None else False,
|
|
1800
|
+
salvaged_agent_exception=result.salvaged_agent_exception if result is not None else False,
|
|
1801
|
+
agent_exception_summary=_result_agent_exception_summary(result),
|
|
1802
|
+
)
|
|
1803
|
+
_write_merge_result(paths, outcome)
|
|
1804
|
+
return outcome
|
|
1805
|
+
|
|
1806
|
+
|
|
1807
|
+
def _run_batch_integration_gate(
|
|
1808
|
+
*,
|
|
1809
|
+
paths: RunPaths,
|
|
1810
|
+
cfg: AppConfig,
|
|
1811
|
+
workspace_scan,
|
|
1812
|
+
batch_index: int,
|
|
1813
|
+
integration_mode: str,
|
|
1814
|
+
integration_verify_cmd: list[str] | None,
|
|
1815
|
+
verify_cmd: list[str] | None,
|
|
1816
|
+
merged_task_ids: list[str],
|
|
1817
|
+
merged_paths: list[str],
|
|
1818
|
+
root: Path | None,
|
|
1819
|
+
phase: str,
|
|
1820
|
+
trace: Callable[[str, str], None],
|
|
1821
|
+
task_trace: Callable[[str, str], None],
|
|
1822
|
+
integration_runner: Callable[..., IntegrationGateResult],
|
|
1823
|
+
) -> IntegrationGateResult | None:
|
|
1824
|
+
if integration_mode == "off" or not merged_task_ids:
|
|
1825
|
+
return None
|
|
1826
|
+
trace(
|
|
1827
|
+
"integration.lifecycle",
|
|
1828
|
+
(
|
|
1829
|
+
f"Running integration gate for batch {batch_index} "
|
|
1830
|
+
f"({phase}): {', '.join(merged_task_ids)}."
|
|
1831
|
+
),
|
|
1832
|
+
)
|
|
1833
|
+
result = integration_runner(
|
|
1834
|
+
paths=paths,
|
|
1835
|
+
cfg=cfg,
|
|
1836
|
+
batch_index=batch_index,
|
|
1837
|
+
mode=integration_mode,
|
|
1838
|
+
merged_task_ids=merged_task_ids,
|
|
1839
|
+
merged_paths=merged_paths,
|
|
1840
|
+
integration_verify_cmd=integration_verify_cmd,
|
|
1841
|
+
verify_cmd=verify_cmd,
|
|
1842
|
+
root=root,
|
|
1843
|
+
repo_scan=workspace_scan,
|
|
1844
|
+
phase=phase,
|
|
1845
|
+
)
|
|
1846
|
+
status_text = "passed" if result.passed else "failed"
|
|
1847
|
+
trace(
|
|
1848
|
+
"integration.lifecycle",
|
|
1849
|
+
f"Integration gate {result.batch_label} {status_text}: {result.summary}.",
|
|
1850
|
+
)
|
|
1851
|
+
if not result.passed:
|
|
1852
|
+
for task_id in merged_task_ids:
|
|
1853
|
+
task_trace(
|
|
1854
|
+
task_id,
|
|
1855
|
+
(
|
|
1856
|
+
"Pre-merge batch candidate verification failed: "
|
|
1857
|
+
if phase == "pre_merge_candidate"
|
|
1858
|
+
else "Integration gate failed after merge: "
|
|
1859
|
+
)
|
|
1860
|
+
+ result.summary,
|
|
1861
|
+
)
|
|
1862
|
+
return result
|
|
1863
|
+
|
|
1864
|
+
|
|
1865
|
+
def run_swarm(
|
|
1866
|
+
*,
|
|
1867
|
+
paths: RunPaths,
|
|
1868
|
+
plan: dict[str, Any],
|
|
1869
|
+
cfg: AppConfig,
|
|
1870
|
+
mode: str,
|
|
1871
|
+
yes: bool,
|
|
1872
|
+
max_steps: int | None,
|
|
1873
|
+
api_key_override: str | None,
|
|
1874
|
+
no_log: bool,
|
|
1875
|
+
parallel: int,
|
|
1876
|
+
base_branch: str | None,
|
|
1877
|
+
max_tasks: int | None,
|
|
1878
|
+
max_attempts: int | None,
|
|
1879
|
+
dry_run: bool,
|
|
1880
|
+
keep_worktrees: bool,
|
|
1881
|
+
retry_failed: bool,
|
|
1882
|
+
retry_changes_requested: bool = False,
|
|
1883
|
+
only: str | None,
|
|
1884
|
+
retry_merge_conflicts: bool,
|
|
1885
|
+
console: Console,
|
|
1886
|
+
scope_mode: str = "strict",
|
|
1887
|
+
verify_mode: str = "warn",
|
|
1888
|
+
verify_cmd: list[str] | None = None,
|
|
1889
|
+
integration_mode: str | None = None,
|
|
1890
|
+
integration_verify_cmd: list[str] | None = None,
|
|
1891
|
+
replanning_mode: str | None = None,
|
|
1892
|
+
review: bool = False,
|
|
1893
|
+
trace_level: str = "off",
|
|
1894
|
+
trace_sink: SwarmTraceSink | None = None,
|
|
1895
|
+
workspace_binding: WorkspaceBinding | None = None,
|
|
1896
|
+
worker_runner: Callable[..., TaskWorkerResult] = run_task_worker,
|
|
1897
|
+
review_runner: Callable[..., ReviewOutcome] = review_task,
|
|
1898
|
+
merge_runner: Callable[..., str] = merge_no_ff,
|
|
1899
|
+
integration_runner: Callable[..., IntegrationGateResult] = run_integration_gate,
|
|
1900
|
+
replanning_runner: Callable[..., ReplanAttemptResult] = run_replanning_attempt,
|
|
1901
|
+
ensure_worktree_fn: Callable[..., Any] = ensure_task_worktree,
|
|
1902
|
+
remove_worktree_fn: Callable[..., None] = remove_task_worktree,
|
|
1903
|
+
delete_branch_fn: Callable[[Path, str], None] = delete_branch,
|
|
1904
|
+
branch_exists_fn: Callable[[Path, str], bool] = branch_exists,
|
|
1905
|
+
current_branch_fn: Callable[[Path], str] = current_branch,
|
|
1906
|
+
run_mutation_guard: RunMutationGuard | _CompositeMutationGuard | None = None,
|
|
1907
|
+
cancellation_event: threading.Event | None = None,
|
|
1908
|
+
worker_approval_handler: Callable[..., Any] | None = None,
|
|
1909
|
+
merge_strategy: str = "auto",
|
|
1910
|
+
) -> int:
|
|
1911
|
+
owns_run_mutation_guard = run_mutation_guard is None
|
|
1912
|
+
if run_mutation_guard is None:
|
|
1913
|
+
run_mutation_guard = acquire_swarm_mutation_guard(paths)
|
|
1914
|
+
try:
|
|
1915
|
+
ensure_swarm_dirs(paths)
|
|
1916
|
+
trace_level_normalized = normalize_swarm_trace_level(trace_level)
|
|
1917
|
+
sink = trace_sink or SerializedSwarmTraceSink(
|
|
1918
|
+
artifact_path=_default_swarm_trace_path(paths),
|
|
1919
|
+
trace_level=trace_level_normalized,
|
|
1920
|
+
console=console,
|
|
1921
|
+
)
|
|
1922
|
+
except Exception:
|
|
1923
|
+
if owns_run_mutation_guard:
|
|
1924
|
+
run_mutation_guard.release()
|
|
1925
|
+
raise
|
|
1926
|
+
|
|
1927
|
+
def _trace(
|
|
1928
|
+
phase: str,
|
|
1929
|
+
message: str,
|
|
1930
|
+
*,
|
|
1931
|
+
task_id: str | None = None,
|
|
1932
|
+
verbosity: str = "compact",
|
|
1933
|
+
) -> None:
|
|
1934
|
+
emit_swarm_trace(
|
|
1935
|
+
sink,
|
|
1936
|
+
run_id=paths.run_id,
|
|
1937
|
+
phase=phase,
|
|
1938
|
+
message=message,
|
|
1939
|
+
task_id=task_id,
|
|
1940
|
+
verbosity=verbosity,
|
|
1941
|
+
)
|
|
1942
|
+
|
|
1943
|
+
try:
|
|
1944
|
+
if bool(getattr(run_mutation_guard, "acquired_after_wait", False)):
|
|
1945
|
+
previous_plan_text = json.dumps(plan, sort_keys=True, default=str)
|
|
1946
|
+
reloaded_plan = load_plan(paths)
|
|
1947
|
+
reloaded_plan_text = json.dumps(reloaded_plan, sort_keys=True, default=str)
|
|
1948
|
+
plan.clear()
|
|
1949
|
+
plan.update(reloaded_plan)
|
|
1950
|
+
_write_concurrency_revalidation_artifact(
|
|
1951
|
+
paths=paths,
|
|
1952
|
+
guard=run_mutation_guard,
|
|
1953
|
+
plan_changed=previous_plan_text != reloaded_plan_text,
|
|
1954
|
+
)
|
|
1955
|
+
_trace(
|
|
1956
|
+
"concurrency.revalidation",
|
|
1957
|
+
"Queued Forge execution acquired the workspace guard; reloaded current plan before scheduling.",
|
|
1958
|
+
)
|
|
1959
|
+
backend = select_swarm_backend(
|
|
1960
|
+
paths=paths,
|
|
1961
|
+
ensure_worktree_fn=ensure_worktree_fn,
|
|
1962
|
+
merge_runner=merge_runner,
|
|
1963
|
+
remove_worktree_fn=remove_worktree_fn,
|
|
1964
|
+
delete_branch_fn=delete_branch_fn,
|
|
1965
|
+
prune_fn=prune_worktrees,
|
|
1966
|
+
branch_exists_fn=branch_exists_fn,
|
|
1967
|
+
current_branch_fn=current_branch_fn,
|
|
1968
|
+
has_head_commit_fn=has_head_commit,
|
|
1969
|
+
)
|
|
1970
|
+
workspace_scan = refresh_workspace_context_artifacts(paths)
|
|
1971
|
+
workspace_summary_lines = format_workspace_context_summary_lines(workspace_scan)
|
|
1972
|
+
binding_summary_lines = _swarm_binding_summary_lines(
|
|
1973
|
+
paths=paths,
|
|
1974
|
+
workspace_binding=workspace_binding,
|
|
1975
|
+
)
|
|
1976
|
+
binding_metadata = _swarm_binding_metadata(paths=paths, workspace_binding=workspace_binding)
|
|
1977
|
+
effective_replanning_mode = resolve_replanning_mode(
|
|
1978
|
+
cfg=cfg,
|
|
1979
|
+
replanning_mode=replanning_mode,
|
|
1980
|
+
)
|
|
1981
|
+
_trace(
|
|
1982
|
+
"swarm.lifecycle",
|
|
1983
|
+
(
|
|
1984
|
+
f"Swarm starting: parallel={parallel}, dry_run={'yes' if dry_run else 'no'}, "
|
|
1985
|
+
f"review={'on' if review else 'off'}, verify={verify_mode}, "
|
|
1986
|
+
f"integration_verify={resolve_integration_verify_mode(cfg=cfg, integration_verify=integration_mode)}, "
|
|
1987
|
+
f"replan={effective_replanning_mode}."
|
|
1988
|
+
),
|
|
1989
|
+
)
|
|
1990
|
+
_trace("swarm.backend", f"Using backend {backend.name}.")
|
|
1991
|
+
for line in workspace_summary_lines:
|
|
1992
|
+
_trace("workspace.scan", line)
|
|
1993
|
+
_trace(
|
|
1994
|
+
"workspace.binding",
|
|
1995
|
+
(
|
|
1996
|
+
"Binding: "
|
|
1997
|
+
f"requested={binding_metadata['requested_path']}, "
|
|
1998
|
+
f"workspace_root={binding_metadata['workspace_root']}, "
|
|
1999
|
+
f"focus_dir={binding_metadata['focus_dir']}, "
|
|
2000
|
+
f"risk={binding_metadata['binding_risk_level']}, "
|
|
2001
|
+
"broad_override="
|
|
2002
|
+
f"{'yes' if binding_metadata['broad_workspace_override_used'] else 'no'}."
|
|
2003
|
+
),
|
|
2004
|
+
)
|
|
2005
|
+
for reason in binding_metadata["binding_risk_reasons"]:
|
|
2006
|
+
_trace("workspace.binding", f"Binding reason: {reason}", verbosity="full")
|
|
2007
|
+
if not dry_run and mode != "auto":
|
|
2008
|
+
msg = "swarm requires --mode auto (non-interactive)"
|
|
2009
|
+
_trace("swarm.error", msg)
|
|
2010
|
+
raise ForgeError(msg)
|
|
2011
|
+
if merge_strategy not in {"auto", "review"}:
|
|
2012
|
+
msg = f"swarm merge_strategy must be auto or review, not {merge_strategy!r}"
|
|
2013
|
+
_trace("swarm.error", msg)
|
|
2014
|
+
raise ForgeError(msg)
|
|
2015
|
+
if merge_strategy == "review":
|
|
2016
|
+
_trace(
|
|
2017
|
+
"merge.lifecycle",
|
|
2018
|
+
"Review merge strategy: completed tasks stay ready_for_merge with "
|
|
2019
|
+
"preserved worktrees; nothing merges into the base branch.",
|
|
2020
|
+
)
|
|
2021
|
+
|
|
2022
|
+
selected_base = (
|
|
2023
|
+
base_branch.strip() if base_branch else backend.default_base_branch(paths.root)
|
|
2024
|
+
)
|
|
2025
|
+
_trace("swarm.lifecycle", f"Using base branch {selected_base}.")
|
|
2026
|
+
|
|
2027
|
+
verify_commands: list[str] = []
|
|
2028
|
+
verify_command_selection: ResolvedVerifyCommands | None = None
|
|
2029
|
+
verify_commands_resolved = verify_mode == "off" or dry_run
|
|
2030
|
+
effective_integration_mode = resolve_integration_verify_mode(
|
|
2031
|
+
cfg=cfg,
|
|
2032
|
+
integration_verify=integration_mode,
|
|
2033
|
+
)
|
|
2034
|
+
|
|
2035
|
+
def _get_verify_command_selection() -> ResolvedVerifyCommands:
|
|
2036
|
+
nonlocal verify_commands, verify_command_selection, verify_commands_resolved
|
|
2037
|
+
if verify_command_selection is not None:
|
|
2038
|
+
return verify_command_selection
|
|
2039
|
+
_trace("verify.lifecycle", "Resolving verify commands.", verbosity="full")
|
|
2040
|
+
verify_command_selection = resolve_verify_command_selection(
|
|
2041
|
+
cfg=cfg,
|
|
2042
|
+
verify_cmd=verify_cmd,
|
|
2043
|
+
root=paths.root,
|
|
2044
|
+
repo_scan=workspace_scan,
|
|
2045
|
+
allow_empty_config=True,
|
|
2046
|
+
)
|
|
2047
|
+
verify_commands = list(verify_command_selection.commands)
|
|
2048
|
+
verify_commands_resolved = True
|
|
2049
|
+
return verify_command_selection
|
|
2050
|
+
|
|
2051
|
+
def _get_verify_commands() -> list[str]:
|
|
2052
|
+
nonlocal verify_commands
|
|
2053
|
+
if verify_commands_resolved:
|
|
2054
|
+
return verify_commands
|
|
2055
|
+
verify_commands = list(_get_verify_command_selection().commands)
|
|
2056
|
+
return verify_commands
|
|
2057
|
+
|
|
2058
|
+
effective_max_attempts = max_attempts
|
|
2059
|
+
if effective_max_attempts is None and (retry_failed or retry_changes_requested):
|
|
2060
|
+
effective_max_attempts = 3
|
|
2061
|
+
only_ids = _parse_only(only)
|
|
2062
|
+
plan_requirements = [
|
|
2063
|
+
str(item).strip() for item in (plan.get("requirements") or []) if str(item).strip()
|
|
2064
|
+
]
|
|
2065
|
+
executed: list[str] = []
|
|
2066
|
+
interrupted_tasks: set[str] = set()
|
|
2067
|
+
|
|
2068
|
+
def _cancellation_requested() -> bool:
|
|
2069
|
+
return cancellation_event is not None and cancellation_event.is_set()
|
|
2070
|
+
|
|
2071
|
+
merge_outcomes: list[MergeOutcome] = []
|
|
2072
|
+
integration_results: list[IntegrationGateResult] = []
|
|
2073
|
+
replanning_results: list[ReplanAttemptResult] = []
|
|
2074
|
+
scheduler_skipped: dict[str, str] = {}
|
|
2075
|
+
execution_skipped: dict[str, str] = {}
|
|
2076
|
+
executed_batch_history: list[list[str]] = []
|
|
2077
|
+
observed_task_ids: set[str] = set()
|
|
2078
|
+
blocked_task_ids_this_run: set[str] = set()
|
|
2079
|
+
integration_gate_index = 0
|
|
2080
|
+
integration_blocked = False
|
|
2081
|
+
tolerated_integration_failures: list[IntegrationGateResult] = []
|
|
2082
|
+
worker_verification_warnings: dict[str, str] = {}
|
|
2083
|
+
final_gate_task_ids: list[str] = []
|
|
2084
|
+
final_gate_paths: list[str] = []
|
|
2085
|
+
recovered_tasks: dict[str, str] = _recover_stale_in_progress(paths=paths, plan=plan)
|
|
2086
|
+
for task_id, status in recovered_tasks.items():
|
|
2087
|
+
_trace(
|
|
2088
|
+
"swarm.recovery",
|
|
2089
|
+
f"Recovered stale in-progress task ({status}).",
|
|
2090
|
+
task_id=task_id,
|
|
2091
|
+
)
|
|
2092
|
+
|
|
2093
|
+
startup = backend.prepare_startup(paths.root)
|
|
2094
|
+
startup_warnings = startup.warnings
|
|
2095
|
+
if effective_integration_mode == "off":
|
|
2096
|
+
startup_warnings.append(
|
|
2097
|
+
"Integration verification is explicitly off; pre-merge candidate acceptance is disabled and swarm may accept red combined repo states."
|
|
2098
|
+
)
|
|
2099
|
+
for warning in startup_warnings:
|
|
2100
|
+
_trace("swarm.startup", warning)
|
|
2101
|
+
|
|
2102
|
+
review_blocked = False
|
|
2103
|
+
remote_blocked_any = False
|
|
2104
|
+
remote_settings = load_remote_settings_from_env()
|
|
2105
|
+
remote_provider = "unknown"
|
|
2106
|
+
remote_bootstrap_error: str | None = None
|
|
2107
|
+
if remote_settings.enabled and not backend.supports_remote_sync:
|
|
2108
|
+
warning = (
|
|
2109
|
+
f"Remote sync skipped: backend {backend.name} does not support branch push/PR flow."
|
|
2110
|
+
)
|
|
2111
|
+
startup_warnings.append(warning)
|
|
2112
|
+
_trace("remote.lifecycle", warning)
|
|
2113
|
+
remote_settings = type(remote_settings)(
|
|
2114
|
+
sync_mode="off",
|
|
2115
|
+
remote_name=remote_settings.remote_name,
|
|
2116
|
+
create_pr=remote_settings.create_pr,
|
|
2117
|
+
provider=remote_settings.provider,
|
|
2118
|
+
)
|
|
2119
|
+
if remote_settings.enabled:
|
|
2120
|
+
_trace(
|
|
2121
|
+
"remote.lifecycle",
|
|
2122
|
+
f"Remote sync enabled for remote {remote_settings.remote_name}.",
|
|
2123
|
+
verbosity="full",
|
|
2124
|
+
)
|
|
2125
|
+
try:
|
|
2126
|
+
remote_url = get_remote_url(paths.root, remote_settings.remote_name)
|
|
2127
|
+
remote_provider = resolve_provider(
|
|
2128
|
+
settings_provider=remote_settings.provider,
|
|
2129
|
+
remote_url=remote_url,
|
|
2130
|
+
)
|
|
2131
|
+
_trace(
|
|
2132
|
+
"remote.lifecycle",
|
|
2133
|
+
f"Remote provider resolved to {remote_provider}.",
|
|
2134
|
+
verbosity="full",
|
|
2135
|
+
)
|
|
2136
|
+
except Exception as e: # noqa: BLE001
|
|
2137
|
+
remote_bootstrap_error = str(e)
|
|
2138
|
+
_trace("remote.error", f"Remote bootstrap failed: {remote_bootstrap_error}")
|
|
2139
|
+
|
|
2140
|
+
def _maybe_run_replanning(
|
|
2141
|
+
*,
|
|
2142
|
+
batch_index: int,
|
|
2143
|
+
merged_task_ids: list[str],
|
|
2144
|
+
integration_result: IntegrationGateResult,
|
|
2145
|
+
force_suggest: bool = False,
|
|
2146
|
+
) -> ReplanAttemptResult | None:
|
|
2147
|
+
if effective_replanning_mode == "off":
|
|
2148
|
+
return None
|
|
2149
|
+
trigger = build_replanning_trigger(
|
|
2150
|
+
paths=paths,
|
|
2151
|
+
integration_mode=effective_integration_mode,
|
|
2152
|
+
merged_task_ids=merged_task_ids,
|
|
2153
|
+
)
|
|
2154
|
+
if trigger is None:
|
|
2155
|
+
_trace(
|
|
2156
|
+
"replanning.lifecycle",
|
|
2157
|
+
f"Skipped replanning after batch {batch_index}: no open integration trigger.",
|
|
2158
|
+
verbosity="full",
|
|
2159
|
+
)
|
|
2160
|
+
return None
|
|
2161
|
+
return replanning_runner(
|
|
2162
|
+
paths=paths,
|
|
2163
|
+
plan=plan,
|
|
2164
|
+
cfg=cfg,
|
|
2165
|
+
api_key_override=api_key_override,
|
|
2166
|
+
requested_mode=effective_replanning_mode,
|
|
2167
|
+
batch_index=batch_index,
|
|
2168
|
+
merged_task_ids=merged_task_ids,
|
|
2169
|
+
integration_result=integration_result,
|
|
2170
|
+
trigger=trigger,
|
|
2171
|
+
allow_apply=not force_suggest,
|
|
2172
|
+
)
|
|
2173
|
+
|
|
2174
|
+
def _raise_for_replanned_execution_ready(attempt: ReplanAttemptResult) -> None:
|
|
2175
|
+
if not (attempt.applied and attempt.plan_changed):
|
|
2176
|
+
return
|
|
2177
|
+
try:
|
|
2178
|
+
raise_for_execution_ready_plan(
|
|
2179
|
+
plan,
|
|
2180
|
+
retry_failed=retry_failed,
|
|
2181
|
+
retry_changes_requested=retry_changes_requested,
|
|
2182
|
+
retry_merge_conflicts=retry_merge_conflicts,
|
|
2183
|
+
only=only,
|
|
2184
|
+
)
|
|
2185
|
+
except PlannerFailedError as e:
|
|
2186
|
+
_trace("replanning.error", str(e))
|
|
2187
|
+
raise
|
|
2188
|
+
|
|
2189
|
+
def _record_replanning_attempt(
|
|
2190
|
+
attempt: ReplanAttemptResult,
|
|
2191
|
+
*,
|
|
2192
|
+
schedule_recomputed: bool = False,
|
|
2193
|
+
) -> ReplanAttemptResult:
|
|
2194
|
+
recorded = replace(attempt, schedule_recomputed=schedule_recomputed)
|
|
2195
|
+
replanning_results.append(recorded)
|
|
2196
|
+
_trace(
|
|
2197
|
+
"replanning.lifecycle",
|
|
2198
|
+
(
|
|
2199
|
+
f"Replanning {recorded.replan_label}: requested={recorded.requested_mode}, "
|
|
2200
|
+
f"effective={recorded.effective_mode}, proposal={'yes' if recorded.proposal_generated else 'no'}, "
|
|
2201
|
+
f"validation={'passed' if recorded.validation_passed else 'failed'}, "
|
|
2202
|
+
f"applied={'yes' if recorded.applied else 'no'}, "
|
|
2203
|
+
f"changed={'yes' if recorded.plan_changed else 'no'}, "
|
|
2204
|
+
f"recomputed={'yes' if recorded.schedule_recomputed else 'no'}."
|
|
2205
|
+
),
|
|
2206
|
+
)
|
|
2207
|
+
_raise_for_replanned_execution_ready(recorded)
|
|
2208
|
+
return recorded
|
|
2209
|
+
|
|
2210
|
+
def _preserve_task_evidence(
|
|
2211
|
+
*,
|
|
2212
|
+
task_id: str,
|
|
2213
|
+
prepared_workspace: PreparedTaskWorkspace,
|
|
2214
|
+
reason: str,
|
|
2215
|
+
result: Any | None = None,
|
|
2216
|
+
) -> None:
|
|
2217
|
+
"""Copy the task's diff and verification log into the run's artifacts.
|
|
2218
|
+
|
|
2219
|
+
Best effort by contract: a capture problem is traced as a warning and never
|
|
2220
|
+
stops the cleanup it precedes.
|
|
2221
|
+
"""
|
|
2222
|
+
verify_artifact = getattr(result, "verify_artifact_path", None) if result else None
|
|
2223
|
+
verify_path = Path(verify_artifact) if verify_artifact else None
|
|
2224
|
+
if verify_path is not None and not verify_path.is_absolute():
|
|
2225
|
+
verify_path = paths.root / verify_path
|
|
2226
|
+
if verify_path is None or not verify_path.exists():
|
|
2227
|
+
verify_path = (
|
|
2228
|
+
paths.execution_verify_dir / f"{safe_task_file_component(task_id)}.txt"
|
|
2229
|
+
)
|
|
2230
|
+
try:
|
|
2231
|
+
evidence = preserve_failed_task_evidence(
|
|
2232
|
+
execution_dir=paths.execution_dir,
|
|
2233
|
+
task_id=task_id,
|
|
2234
|
+
worktree_path=prepared_workspace.worktree_path,
|
|
2235
|
+
base_branch=prepared_workspace.base_branch,
|
|
2236
|
+
branch=prepared_workspace.branch,
|
|
2237
|
+
reason=reason,
|
|
2238
|
+
verification_log_path=verify_path if verify_path.exists() else None,
|
|
2239
|
+
verification_summary=(
|
|
2240
|
+
str(getattr(result, "verify_summary", "") or "") if result else None
|
|
2241
|
+
),
|
|
2242
|
+
extra={
|
|
2243
|
+
"keep_worktrees": bool(keep_worktrees),
|
|
2244
|
+
"failure_reason": (
|
|
2245
|
+
str(getattr(result, "failure_reason", "") or "") if result else ""
|
|
2246
|
+
),
|
|
2247
|
+
},
|
|
2248
|
+
)
|
|
2249
|
+
except Exception as e: # noqa: BLE001
|
|
2250
|
+
_trace(
|
|
2251
|
+
"worktree.error",
|
|
2252
|
+
f"Could not preserve task evidence before cleanup: {e}",
|
|
2253
|
+
task_id=task_id,
|
|
2254
|
+
)
|
|
2255
|
+
return
|
|
2256
|
+
if evidence.errors:
|
|
2257
|
+
_trace(
|
|
2258
|
+
"worktree.error",
|
|
2259
|
+
"Task evidence capture was incomplete: " + "; ".join(evidence.errors),
|
|
2260
|
+
task_id=task_id,
|
|
2261
|
+
)
|
|
2262
|
+
if evidence.directory is not None:
|
|
2263
|
+
_trace(
|
|
2264
|
+
"worktree.lifecycle",
|
|
2265
|
+
f"Preserved task evidence in {_repo_rel(paths.root, evidence.directory)} "
|
|
2266
|
+
"before workspace cleanup.",
|
|
2267
|
+
task_id=task_id,
|
|
2268
|
+
verbosity="full",
|
|
2269
|
+
)
|
|
2270
|
+
|
|
2271
|
+
def _cleanup_nonmerged_workspace(
|
|
2272
|
+
task_id: str,
|
|
2273
|
+
prepared_workspace: PreparedTaskWorkspace | None,
|
|
2274
|
+
*,
|
|
2275
|
+
reason: str = "",
|
|
2276
|
+
result: Any | None = None,
|
|
2277
|
+
) -> None:
|
|
2278
|
+
if prepared_workspace is None:
|
|
2279
|
+
return
|
|
2280
|
+
# Evidence FIRST, unconditionally, and before anything can remove the
|
|
2281
|
+
# worktree: an uncommitted edit or an uncommitted new file exists nowhere
|
|
2282
|
+
# else, so cleanup would otherwise be the only record that it ever did.
|
|
2283
|
+
_preserve_task_evidence(
|
|
2284
|
+
task_id=task_id,
|
|
2285
|
+
prepared_workspace=prepared_workspace,
|
|
2286
|
+
reason=reason or "failed task workspace cleanup",
|
|
2287
|
+
result=result,
|
|
2288
|
+
)
|
|
2289
|
+
cleanup_errors = backend.cleanup_failed_task_workspace(
|
|
2290
|
+
root=paths.root,
|
|
2291
|
+
prepared_workspace=prepared_workspace,
|
|
2292
|
+
keep_worktrees=keep_worktrees,
|
|
2293
|
+
)
|
|
2294
|
+
if cleanup_errors:
|
|
2295
|
+
task = find_task(plan, task_id)
|
|
2296
|
+
detail = (
|
|
2297
|
+
"failed task cleanup is incomplete; future reruns are blocked until cleanup "
|
|
2298
|
+
f"succeeds: {'; '.join(cleanup_errors)}"
|
|
2299
|
+
)
|
|
2300
|
+
if task is not None:
|
|
2301
|
+
existing_error = str(task.get("last_error") or "").strip()
|
|
2302
|
+
if existing_error and detail not in existing_error:
|
|
2303
|
+
task["last_error"] = f"{existing_error}; {detail}"
|
|
2304
|
+
else:
|
|
2305
|
+
task["last_error"] = detail
|
|
2306
|
+
save_plan(paths, plan)
|
|
2307
|
+
_trace(
|
|
2308
|
+
"worktree.error",
|
|
2309
|
+
f"Failure cleanup blocked future reruns: {'; '.join(cleanup_errors)}",
|
|
2310
|
+
task_id=task_id,
|
|
2311
|
+
)
|
|
2312
|
+
elif not keep_worktrees:
|
|
2313
|
+
_trace(
|
|
2314
|
+
"worktree.lifecycle",
|
|
2315
|
+
"Cleaned failed task workspace and branch state for future reruns.",
|
|
2316
|
+
task_id=task_id,
|
|
2317
|
+
verbosity="full",
|
|
2318
|
+
)
|
|
2319
|
+
|
|
2320
|
+
pending_promoted_knowledge = False
|
|
2321
|
+
pending_task_attempt_resolutions = False
|
|
2322
|
+
pending_ready_items: list[ReadyBatchItem] = []
|
|
2323
|
+
if not dry_run and not _cancellation_requested() and merge_strategy == "auto":
|
|
2324
|
+
pending_ready: list[dict[str, Any]] = []
|
|
2325
|
+
for task in plan.get("tasks") or []:
|
|
2326
|
+
status = canonical_task_status(str(task.get("status") or ""))
|
|
2327
|
+
if status == "ready_for_merge":
|
|
2328
|
+
if _should_defer_red_regression_ready_task(plan, task):
|
|
2329
|
+
task_id = str(task.get("id") or "")
|
|
2330
|
+
_trace(
|
|
2331
|
+
"merge.lifecycle",
|
|
2332
|
+
(
|
|
2333
|
+
"Deferring expected-red regression task until its dependent "
|
|
2334
|
+
"implementation is ready for the same pre-merge candidate."
|
|
2335
|
+
),
|
|
2336
|
+
task_id=task_id,
|
|
2337
|
+
verbosity="full",
|
|
2338
|
+
)
|
|
2339
|
+
continue
|
|
2340
|
+
pending_ready.append(task)
|
|
2341
|
+
if retry_merge_conflicts and status == "merge_conflict":
|
|
2342
|
+
pending_ready.append(task)
|
|
2343
|
+
|
|
2344
|
+
if pending_ready:
|
|
2345
|
+
pending_ids = ", ".join(str(task.get("id") or "") for task in pending_ready)
|
|
2346
|
+
_trace(
|
|
2347
|
+
"merge.lifecycle",
|
|
2348
|
+
f"Pending merge queue: {pending_ids}.",
|
|
2349
|
+
verbosity="full",
|
|
2350
|
+
)
|
|
2351
|
+
|
|
2352
|
+
for task in pending_ready:
|
|
2353
|
+
task_id = str(task.get("id") or "")
|
|
2354
|
+
branch = str(task.get("branch") or "")
|
|
2355
|
+
if not branch:
|
|
2356
|
+
continue
|
|
2357
|
+
observed_task_ids.add(task_id)
|
|
2358
|
+
try:
|
|
2359
|
+
prepared_workspace = backend.load_task_workspace(
|
|
2360
|
+
root=paths.root,
|
|
2361
|
+
run_dir=paths.run_dir,
|
|
2362
|
+
task_id=task_id,
|
|
2363
|
+
branch=branch,
|
|
2364
|
+
base_branch=selected_base,
|
|
2365
|
+
)
|
|
2366
|
+
except GitOpsError as e:
|
|
2367
|
+
_mark_status(paths, plan, task_id, "failed")
|
|
2368
|
+
execution_skipped[task_id] = str(e)
|
|
2369
|
+
_trace("worktree.error", str(e), task_id=task_id)
|
|
2370
|
+
continue
|
|
2371
|
+
remote_record: dict[str, object] | None = None
|
|
2372
|
+
if remote_settings.enabled:
|
|
2373
|
+
_trace(
|
|
2374
|
+
"remote.lifecycle", "Syncing remote branch before merge.", task_id=task_id
|
|
2375
|
+
)
|
|
2376
|
+
remote_record = init_remote_record(
|
|
2377
|
+
task_id=task_id,
|
|
2378
|
+
remote=remote_settings.remote_name,
|
|
2379
|
+
provider=remote_provider,
|
|
2380
|
+
)
|
|
2381
|
+
raw_errors = remote_record["errors"]
|
|
2382
|
+
assert isinstance(raw_errors, list)
|
|
2383
|
+
remote_blocked = False
|
|
2384
|
+
if remote_bootstrap_error:
|
|
2385
|
+
msg = f"remote discovery failed: {remote_bootstrap_error}"
|
|
2386
|
+
raw_errors.append(msg)
|
|
2387
|
+
if remote_settings.strict:
|
|
2388
|
+
remote_blocked = True
|
|
2389
|
+
else:
|
|
2390
|
+
pushed_branch, push_output = push_branch(
|
|
2391
|
+
paths.root,
|
|
2392
|
+
remote=remote_settings.remote_name,
|
|
2393
|
+
branch=branch,
|
|
2394
|
+
)
|
|
2395
|
+
remote_record["pushed_branch"] = pushed_branch
|
|
2396
|
+
remote_record["branch_push_output"] = truncate_output(push_output)
|
|
2397
|
+
if not pushed_branch:
|
|
2398
|
+
msg = f"remote branch push failed: {push_output or 'unknown error'}"
|
|
2399
|
+
raw_errors.append(msg)
|
|
2400
|
+
if remote_settings.strict:
|
|
2401
|
+
remote_blocked = True
|
|
2402
|
+
if pushed_branch and remote_settings.create_pr:
|
|
2403
|
+
_trace(
|
|
2404
|
+
"remote.lifecycle",
|
|
2405
|
+
"Creating remote PR/MR.",
|
|
2406
|
+
task_id=task_id,
|
|
2407
|
+
verbosity="full",
|
|
2408
|
+
)
|
|
2409
|
+
created_pr, pr_url, pr_id, pr_output = ensure_pr_or_mr(
|
|
2410
|
+
paths.root,
|
|
2411
|
+
provider=str(remote_record.get("provider") or "unknown"),
|
|
2412
|
+
base_branch=selected_base,
|
|
2413
|
+
head_branch=branch,
|
|
2414
|
+
title=f"{task_id}: {str(task.get('title') or '').strip() or 'task update'}",
|
|
2415
|
+
body=str(task.get("description") or "")[:4000],
|
|
2416
|
+
)
|
|
2417
|
+
remote_record["created_pr"] = created_pr
|
|
2418
|
+
remote_record["pr_url"] = pr_url
|
|
2419
|
+
remote_record["pr_number_or_iid"] = pr_id
|
|
2420
|
+
remote_record["pr_output"] = truncate_output(pr_output)
|
|
2421
|
+
if created_pr and pr_url:
|
|
2422
|
+
task["remote_pr_url"] = pr_url
|
|
2423
|
+
task["remote_provider"] = str(
|
|
2424
|
+
remote_record.get("provider") or "unknown"
|
|
2425
|
+
)
|
|
2426
|
+
save_plan(paths, plan)
|
|
2427
|
+
if not created_pr:
|
|
2428
|
+
msg = (
|
|
2429
|
+
f"remote PR/MR creation failed: {pr_output or 'unknown error'}"
|
|
2430
|
+
)
|
|
2431
|
+
raw_errors.append(msg)
|
|
2432
|
+
if remote_settings.strict:
|
|
2433
|
+
remote_blocked = True
|
|
2434
|
+
write_remote_record(
|
|
2435
|
+
execution_dir=paths.execution_dir,
|
|
2436
|
+
task_id=task_id,
|
|
2437
|
+
record=remote_record,
|
|
2438
|
+
)
|
|
2439
|
+
if remote_blocked:
|
|
2440
|
+
remote_blocked_any = True
|
|
2441
|
+
_mark_status(paths, plan, task_id, "failed")
|
|
2442
|
+
execution_skipped[task_id] = "blocked by strict remote sync failure"
|
|
2443
|
+
pending_task_attempt_resolutions = (
|
|
2444
|
+
_resolve_worker_task_attempt_acceptance(
|
|
2445
|
+
paths,
|
|
2446
|
+
plan=plan,
|
|
2447
|
+
task_id=task_id,
|
|
2448
|
+
acceptance_state="rejected",
|
|
2449
|
+
summary=(
|
|
2450
|
+
"Worker result was rejected because strict remote sync "
|
|
2451
|
+
"blocked acceptance."
|
|
2452
|
+
),
|
|
2453
|
+
)
|
|
2454
|
+
or pending_task_attempt_resolutions
|
|
2455
|
+
)
|
|
2456
|
+
_mark_worker_knowledge_capture_skipped(
|
|
2457
|
+
paths,
|
|
2458
|
+
task_id=task_id,
|
|
2459
|
+
reason="worker result was blocked by strict remote sync before acceptance",
|
|
2460
|
+
)
|
|
2461
|
+
_cleanup_nonmerged_workspace(
|
|
2462
|
+
task_id=task_id,
|
|
2463
|
+
prepared_workspace=prepared_workspace,
|
|
2464
|
+
reason="strict remote sync blocked acceptance",
|
|
2465
|
+
)
|
|
2466
|
+
_trace(
|
|
2467
|
+
"remote.error",
|
|
2468
|
+
"Blocked by strict remote sync failure.",
|
|
2469
|
+
task_id=task_id,
|
|
2470
|
+
)
|
|
2471
|
+
_append_remote_report_update(
|
|
2472
|
+
paths=paths,
|
|
2473
|
+
report_path_raw=os.fspath(
|
|
2474
|
+
paths.execution_reports_dir / f"{task_id}.md"
|
|
2475
|
+
),
|
|
2476
|
+
record=remote_record,
|
|
2477
|
+
)
|
|
2478
|
+
continue
|
|
2479
|
+
|
|
2480
|
+
if review:
|
|
2481
|
+
_trace("review.lifecycle", "Running review gate.", task_id=task_id)
|
|
2482
|
+
try:
|
|
2483
|
+
review_outcome = review_runner(
|
|
2484
|
+
paths=paths,
|
|
2485
|
+
plan=plan,
|
|
2486
|
+
task=task,
|
|
2487
|
+
cfg=cfg,
|
|
2488
|
+
api_key_override=api_key_override,
|
|
2489
|
+
)
|
|
2490
|
+
except ReviewError as e:
|
|
2491
|
+
_mark_status(paths, plan, task_id, "failed")
|
|
2492
|
+
execution_skipped[task_id] = f"review failed: {e}"
|
|
2493
|
+
review_blocked = True
|
|
2494
|
+
pending_task_attempt_resolutions = (
|
|
2495
|
+
_resolve_worker_task_attempt_acceptance(
|
|
2496
|
+
paths,
|
|
2497
|
+
plan=plan,
|
|
2498
|
+
task_id=task_id,
|
|
2499
|
+
acceptance_state="rejected",
|
|
2500
|
+
summary=f"Worker result was rejected because review failed: {e}",
|
|
2501
|
+
)
|
|
2502
|
+
or pending_task_attempt_resolutions
|
|
2503
|
+
)
|
|
2504
|
+
_mark_worker_knowledge_capture_skipped(
|
|
2505
|
+
paths,
|
|
2506
|
+
task_id=task_id,
|
|
2507
|
+
reason="worker result was rejected before acceptance because review failed",
|
|
2508
|
+
)
|
|
2509
|
+
_cleanup_nonmerged_workspace(
|
|
2510
|
+
task_id=task_id,
|
|
2511
|
+
prepared_workspace=prepared_workspace,
|
|
2512
|
+
reason=f"review failed: {e}",
|
|
2513
|
+
)
|
|
2514
|
+
_trace("review.error", f"Review failed: {e}", task_id=task_id)
|
|
2515
|
+
continue
|
|
2516
|
+
if not review_outcome.approved:
|
|
2517
|
+
_mark_status(paths, plan, task_id, "changes_requested")
|
|
2518
|
+
execution_skipped[task_id] = "changes requested by review gate"
|
|
2519
|
+
review_blocked = True
|
|
2520
|
+
pending_task_attempt_resolutions = (
|
|
2521
|
+
_resolve_worker_task_attempt_acceptance(
|
|
2522
|
+
paths,
|
|
2523
|
+
plan=plan,
|
|
2524
|
+
task_id=task_id,
|
|
2525
|
+
acceptance_state="rejected",
|
|
2526
|
+
summary="Worker result was rejected because review requested changes.",
|
|
2527
|
+
)
|
|
2528
|
+
or pending_task_attempt_resolutions
|
|
2529
|
+
)
|
|
2530
|
+
_mark_worker_knowledge_capture_skipped(
|
|
2531
|
+
paths,
|
|
2532
|
+
task_id=task_id,
|
|
2533
|
+
reason="worker result was not accepted because review requested changes",
|
|
2534
|
+
)
|
|
2535
|
+
_cleanup_nonmerged_workspace(
|
|
2536
|
+
task_id=task_id,
|
|
2537
|
+
prepared_workspace=prepared_workspace,
|
|
2538
|
+
reason="changes requested by review gate",
|
|
2539
|
+
)
|
|
2540
|
+
_trace("review.error", "Changes requested by review gate.", task_id=task_id)
|
|
2541
|
+
continue
|
|
2542
|
+
_trace(
|
|
2543
|
+
"review.lifecycle", "Review approved.", task_id=task_id, verbosity="full"
|
|
2544
|
+
)
|
|
2545
|
+
|
|
2546
|
+
if not backend.branch_exists(prepared_workspace.control_root, branch):
|
|
2547
|
+
_mark_status(paths, plan, task_id, "failed")
|
|
2548
|
+
pending_task_attempt_resolutions = (
|
|
2549
|
+
_resolve_worker_task_attempt_acceptance(
|
|
2550
|
+
paths,
|
|
2551
|
+
plan=plan,
|
|
2552
|
+
task_id=task_id,
|
|
2553
|
+
acceptance_state="rejected",
|
|
2554
|
+
summary="Worker result was rejected because the task branch was missing.",
|
|
2555
|
+
)
|
|
2556
|
+
or pending_task_attempt_resolutions
|
|
2557
|
+
)
|
|
2558
|
+
_mark_worker_knowledge_capture_skipped(
|
|
2559
|
+
paths,
|
|
2560
|
+
task_id=task_id,
|
|
2561
|
+
reason="worker result could not be accepted because the task branch is missing",
|
|
2562
|
+
)
|
|
2563
|
+
_cleanup_nonmerged_workspace(
|
|
2564
|
+
task_id=task_id,
|
|
2565
|
+
prepared_workspace=prepared_workspace,
|
|
2566
|
+
reason="task branch does not exist",
|
|
2567
|
+
)
|
|
2568
|
+
outcome = MergeOutcome(
|
|
2569
|
+
task_id=task_id,
|
|
2570
|
+
branch=branch,
|
|
2571
|
+
success=False,
|
|
2572
|
+
merge_commit_hash=None,
|
|
2573
|
+
error="branch does not exist",
|
|
2574
|
+
backend_name=backend.name,
|
|
2575
|
+
action="applied" if backend.name == "snapshot_workspace" else "merged",
|
|
2576
|
+
)
|
|
2577
|
+
_write_merge_result(paths, outcome)
|
|
2578
|
+
merge_outcomes.append(outcome)
|
|
2579
|
+
_trace("merge.error", "Merge branch does not exist.", task_id=task_id)
|
|
2580
|
+
continue
|
|
2581
|
+
|
|
2582
|
+
pending_ready_items.append(
|
|
2583
|
+
ReadyBatchItem(
|
|
2584
|
+
task=task,
|
|
2585
|
+
prepared_workspace=prepared_workspace,
|
|
2586
|
+
changed_files=tuple(_load_worker_changed_files(paths, task_id)),
|
|
2587
|
+
report_path_raw=os.fspath(paths.execution_reports_dir / f"{task_id}.md"),
|
|
2588
|
+
remote_record=remote_record,
|
|
2589
|
+
)
|
|
2590
|
+
)
|
|
2591
|
+
|
|
2592
|
+
if pending_ready_items:
|
|
2593
|
+
integration_gate_index += 1
|
|
2594
|
+
pending_task_ids = [item.task_id for item in pending_ready_items]
|
|
2595
|
+
startup_integration_result, startup_rejection_reason = _verify_ready_batch_candidate(
|
|
2596
|
+
paths=paths,
|
|
2597
|
+
plan=plan,
|
|
2598
|
+
cfg=cfg,
|
|
2599
|
+
workspace_scan=workspace_scan,
|
|
2600
|
+
backend=backend,
|
|
2601
|
+
batch_index=integration_gate_index,
|
|
2602
|
+
base_branch=selected_base,
|
|
2603
|
+
integration_mode=effective_integration_mode,
|
|
2604
|
+
allow_transient_dependent_integration_warning=(effective_replanning_mode == "off"),
|
|
2605
|
+
integration_verify_cmd=integration_verify_cmd,
|
|
2606
|
+
verify_cmd=verify_cmd,
|
|
2607
|
+
keep_worktrees=keep_worktrees,
|
|
2608
|
+
ready_items=pending_ready_items,
|
|
2609
|
+
trace=lambda phase, message: _trace(phase, message),
|
|
2610
|
+
task_trace=lambda task_id, message: _trace(
|
|
2611
|
+
"integration.error", message, task_id=task_id
|
|
2612
|
+
),
|
|
2613
|
+
integration_runner=integration_runner,
|
|
2614
|
+
)
|
|
2615
|
+
if startup_integration_result is not None:
|
|
2616
|
+
integration_results.append(startup_integration_result)
|
|
2617
|
+
if (
|
|
2618
|
+
startup_integration_result is not None
|
|
2619
|
+
and not startup_integration_result.passed
|
|
2620
|
+
and startup_rejection_reason is None
|
|
2621
|
+
):
|
|
2622
|
+
tolerated_integration_failures.append(startup_integration_result)
|
|
2623
|
+
record_integration_failure_knowledge(
|
|
2624
|
+
paths=paths,
|
|
2625
|
+
result=startup_integration_result,
|
|
2626
|
+
)
|
|
2627
|
+
if startup_rejection_reason is not None:
|
|
2628
|
+
if startup_integration_result is not None:
|
|
2629
|
+
record_integration_failure_knowledge(
|
|
2630
|
+
paths=paths,
|
|
2631
|
+
result=startup_integration_result,
|
|
2632
|
+
)
|
|
2633
|
+
pending_task_attempt_resolutions = (
|
|
2634
|
+
_reject_ready_batch_items(
|
|
2635
|
+
paths=paths,
|
|
2636
|
+
plan=plan,
|
|
2637
|
+
backend=backend,
|
|
2638
|
+
keep_worktrees=keep_worktrees,
|
|
2639
|
+
ready_items=pending_ready_items,
|
|
2640
|
+
reason=startup_rejection_reason,
|
|
2641
|
+
merge_outcomes=merge_outcomes,
|
|
2642
|
+
execution_skipped=execution_skipped,
|
|
2643
|
+
cleanup_nonmerged_workspace=_cleanup_nonmerged_workspace,
|
|
2644
|
+
)
|
|
2645
|
+
or pending_task_attempt_resolutions
|
|
2646
|
+
)
|
|
2647
|
+
if pending_task_attempt_resolutions:
|
|
2648
|
+
rebuild_knowledge_index(paths)
|
|
2649
|
+
if startup_integration_result is not None:
|
|
2650
|
+
startup_replan_attempt = _maybe_run_replanning(
|
|
2651
|
+
batch_index=integration_gate_index,
|
|
2652
|
+
merged_task_ids=pending_task_ids,
|
|
2653
|
+
integration_result=startup_integration_result,
|
|
2654
|
+
force_suggest=effective_integration_mode == "strict",
|
|
2655
|
+
)
|
|
2656
|
+
if startup_replan_attempt is not None:
|
|
2657
|
+
_record_replanning_attempt(startup_replan_attempt)
|
|
2658
|
+
if effective_integration_mode == "strict":
|
|
2659
|
+
integration_blocked = True
|
|
2660
|
+
reason = (
|
|
2661
|
+
f"blocked by strict integration gate batch_{integration_gate_index:03d}: "
|
|
2662
|
+
f"{startup_rejection_reason}"
|
|
2663
|
+
)
|
|
2664
|
+
blocked_task_ids = _mark_remaining_tasks_blocked_by_integration(
|
|
2665
|
+
paths=paths,
|
|
2666
|
+
plan=plan,
|
|
2667
|
+
reason=reason,
|
|
2668
|
+
retry_failed=retry_failed,
|
|
2669
|
+
retry_changes_requested=retry_changes_requested,
|
|
2670
|
+
parallel=parallel,
|
|
2671
|
+
max_attempts=effective_max_attempts,
|
|
2672
|
+
only_ids=only_ids,
|
|
2673
|
+
)
|
|
2674
|
+
for task_id in blocked_task_ids:
|
|
2675
|
+
execution_skipped[task_id] = reason
|
|
2676
|
+
_trace("integration.error", reason, task_id=task_id)
|
|
2677
|
+
else:
|
|
2678
|
+
(
|
|
2679
|
+
pending_merged_task_ids,
|
|
2680
|
+
pending_remote_task_ids,
|
|
2681
|
+
pending_merged_paths,
|
|
2682
|
+
pending_promoted_knowledge,
|
|
2683
|
+
batch_task_attempt_resolutions,
|
|
2684
|
+
) = _merge_ready_batch_items_into_base(
|
|
2685
|
+
paths=paths,
|
|
2686
|
+
plan=plan,
|
|
2687
|
+
cfg=cfg,
|
|
2688
|
+
api_key_override=api_key_override,
|
|
2689
|
+
backend=backend,
|
|
2690
|
+
base_branch=selected_base,
|
|
2691
|
+
keep_worktrees=keep_worktrees,
|
|
2692
|
+
verify_mode=verify_mode,
|
|
2693
|
+
verify_commands=_get_verify_commands(),
|
|
2694
|
+
verify_command_selection=(
|
|
2695
|
+
_get_verify_command_selection() if verify_mode != "off" else None
|
|
2696
|
+
),
|
|
2697
|
+
ready_items=pending_ready_items,
|
|
2698
|
+
merge_outcomes=merge_outcomes,
|
|
2699
|
+
trace=_trace,
|
|
2700
|
+
cleanup_nonmerged_workspace=_cleanup_nonmerged_workspace,
|
|
2701
|
+
)
|
|
2702
|
+
pending_task_attempt_resolutions = (
|
|
2703
|
+
batch_task_attempt_resolutions or pending_task_attempt_resolutions
|
|
2704
|
+
)
|
|
2705
|
+
final_gate_task_ids.extend(pending_merged_task_ids)
|
|
2706
|
+
final_gate_paths.extend(pending_merged_paths)
|
|
2707
|
+
if remote_settings.enabled and pending_remote_task_ids:
|
|
2708
|
+
_trace(
|
|
2709
|
+
"remote.lifecycle",
|
|
2710
|
+
f"Pushing updated base branch {selected_base}.",
|
|
2711
|
+
verbosity="full",
|
|
2712
|
+
)
|
|
2713
|
+
pushed_base, base_output = push_base(
|
|
2714
|
+
paths.root,
|
|
2715
|
+
remote=remote_settings.remote_name,
|
|
2716
|
+
base_branch=selected_base,
|
|
2717
|
+
)
|
|
2718
|
+
for task_id in pending_remote_task_ids:
|
|
2719
|
+
record_path = paths.execution_dir / "remote" / f"{task_id}.json"
|
|
2720
|
+
if not record_path.exists():
|
|
2721
|
+
continue
|
|
2722
|
+
payload = json.loads(record_path.read_text(encoding="utf-8"))
|
|
2723
|
+
payload["pushed_base"] = pushed_base
|
|
2724
|
+
payload["base_push_output"] = truncate_output(base_output)
|
|
2725
|
+
if not pushed_base:
|
|
2726
|
+
errors = payload.setdefault("errors", [])
|
|
2727
|
+
if isinstance(errors, list):
|
|
2728
|
+
errors.append(
|
|
2729
|
+
f"remote base push failed: {base_output or 'unknown error'}"
|
|
2730
|
+
)
|
|
2731
|
+
_trace(
|
|
2732
|
+
"remote.error",
|
|
2733
|
+
f"Base push failed: {base_output or 'unknown error'}",
|
|
2734
|
+
task_id=task_id,
|
|
2735
|
+
)
|
|
2736
|
+
write_remote_record(
|
|
2737
|
+
execution_dir=paths.execution_dir,
|
|
2738
|
+
task_id=task_id,
|
|
2739
|
+
record=payload,
|
|
2740
|
+
)
|
|
2741
|
+
_append_remote_report_update(
|
|
2742
|
+
paths=paths,
|
|
2743
|
+
report_path_raw=os.fspath(
|
|
2744
|
+
paths.execution_reports_dir / f"{task_id}.md"
|
|
2745
|
+
),
|
|
2746
|
+
record=payload,
|
|
2747
|
+
)
|
|
2748
|
+
if pending_promoted_knowledge or pending_task_attempt_resolutions:
|
|
2749
|
+
rebuild_knowledge_index(paths)
|
|
2750
|
+
if (
|
|
2751
|
+
startup_integration_result is not None
|
|
2752
|
+
and startup_integration_result.passed
|
|
2753
|
+
and len(pending_merged_task_ids) == len(pending_task_ids)
|
|
2754
|
+
):
|
|
2755
|
+
record_integration_resolution_knowledge(
|
|
2756
|
+
paths=paths,
|
|
2757
|
+
result=startup_integration_result,
|
|
2758
|
+
)
|
|
2759
|
+
startup_replan_attempt = _maybe_run_replanning(
|
|
2760
|
+
batch_index=integration_gate_index,
|
|
2761
|
+
merged_task_ids=pending_task_ids,
|
|
2762
|
+
integration_result=startup_integration_result,
|
|
2763
|
+
force_suggest=False,
|
|
2764
|
+
)
|
|
2765
|
+
if startup_replan_attempt is not None:
|
|
2766
|
+
_record_replanning_attempt(startup_replan_attempt)
|
|
2767
|
+
|
|
2768
|
+
while True:
|
|
2769
|
+
if integration_blocked:
|
|
2770
|
+
break
|
|
2771
|
+
remaining = (
|
|
2772
|
+
max_tasks - len(executed) if (max_tasks is not None and max_tasks > 0) else None
|
|
2773
|
+
)
|
|
2774
|
+
if remaining is not None and remaining <= 0:
|
|
2775
|
+
break
|
|
2776
|
+
schedule = compute_schedule(
|
|
2777
|
+
base_branch=selected_base,
|
|
2778
|
+
tasks=plan.get("tasks") or [],
|
|
2779
|
+
parallel=parallel,
|
|
2780
|
+
max_tasks=remaining,
|
|
2781
|
+
retry_failed=retry_failed,
|
|
2782
|
+
retry_changes_requested=retry_changes_requested,
|
|
2783
|
+
max_attempts=effective_max_attempts,
|
|
2784
|
+
only_ids=only_ids,
|
|
2785
|
+
)
|
|
2786
|
+
runnable_candidates = [
|
|
2787
|
+
candidate
|
|
2788
|
+
for candidate in schedule.runnable
|
|
2789
|
+
if candidate.task_id not in blocked_task_ids_this_run
|
|
2790
|
+
]
|
|
2791
|
+
for task_id, reason in schedule.skipped.items():
|
|
2792
|
+
if task_id in observed_task_ids:
|
|
2793
|
+
continue
|
|
2794
|
+
scheduler_skipped[task_id] = reason
|
|
2795
|
+
schedule_preview = [
|
|
2796
|
+
[task_id for task_id in batch.task_ids if task_id not in blocked_task_ids_this_run]
|
|
2797
|
+
for batch in schedule.batches
|
|
2798
|
+
if any(task_id not in blocked_task_ids_this_run for task_id in batch.task_ids)
|
|
2799
|
+
]
|
|
2800
|
+
_trace(
|
|
2801
|
+
"scheduler.lifecycle",
|
|
2802
|
+
(
|
|
2803
|
+
f"Schedule resolved: {len(runnable_candidates)} runnable task(s) "
|
|
2804
|
+
f"in {len(schedule_preview)} batch(es)."
|
|
2805
|
+
),
|
|
2806
|
+
)
|
|
2807
|
+
schedule_recompute_requested = False
|
|
2808
|
+
|
|
2809
|
+
for batch in schedule.batches:
|
|
2810
|
+
filtered_task_ids = [
|
|
2811
|
+
task_id
|
|
2812
|
+
for task_id in batch.task_ids
|
|
2813
|
+
if task_id not in blocked_task_ids_this_run
|
|
2814
|
+
]
|
|
2815
|
+
if not filtered_task_ids:
|
|
2816
|
+
continue
|
|
2817
|
+
_trace(
|
|
2818
|
+
"scheduler.batch",
|
|
2819
|
+
f"Batch {batch.index}: {', '.join(filtered_task_ids)}.",
|
|
2820
|
+
)
|
|
2821
|
+
for reason in batch.reasons:
|
|
2822
|
+
_trace("scheduler.batch", reason, verbosity="full")
|
|
2823
|
+
for task_id, reason in schedule.skipped.items():
|
|
2824
|
+
_trace(
|
|
2825
|
+
"scheduler.skip",
|
|
2826
|
+
f"Skipped by scheduler: {reason}",
|
|
2827
|
+
task_id=task_id,
|
|
2828
|
+
verbosity="full",
|
|
2829
|
+
)
|
|
2830
|
+
|
|
2831
|
+
if dry_run:
|
|
2832
|
+
console.rule("[bold cyan]forge swarm (dry-run)[/bold cyan]")
|
|
2833
|
+
console.print(f"Base branch: {selected_base}")
|
|
2834
|
+
for line in workspace_summary_lines:
|
|
2835
|
+
console.print(line)
|
|
2836
|
+
runnable_ids = ", ".join(c.task_id for c in runnable_candidates) or "(none)"
|
|
2837
|
+
console.print(f"Runnable tasks: {runnable_ids}")
|
|
2838
|
+
if schedule.ready_for_merge:
|
|
2839
|
+
pending = ", ".join(t.task_id for t in schedule.ready_for_merge)
|
|
2840
|
+
console.print(f"Ready for merge: {pending}")
|
|
2841
|
+
for batch in schedule.batches:
|
|
2842
|
+
filtered_task_ids = [
|
|
2843
|
+
task_id
|
|
2844
|
+
for task_id in batch.task_ids
|
|
2845
|
+
if task_id not in blocked_task_ids_this_run
|
|
2846
|
+
]
|
|
2847
|
+
if not filtered_task_ids:
|
|
2848
|
+
continue
|
|
2849
|
+
console.print(f"Batch {batch.index}: {', '.join(filtered_task_ids)}")
|
|
2850
|
+
for reason in batch.reasons:
|
|
2851
|
+
console.print(f" - {reason}")
|
|
2852
|
+
summary_path = _write_swarm_summary(
|
|
2853
|
+
paths=paths,
|
|
2854
|
+
backend_name=backend.name,
|
|
2855
|
+
base_branch=selected_base,
|
|
2856
|
+
executed=executed,
|
|
2857
|
+
merge_outcomes=merge_outcomes,
|
|
2858
|
+
integration_results=integration_results,
|
|
2859
|
+
replanning_results=[],
|
|
2860
|
+
skipped=schedule.skipped,
|
|
2861
|
+
recovered=recovered_tasks,
|
|
2862
|
+
startup_warnings=startup_warnings,
|
|
2863
|
+
dry_run=True,
|
|
2864
|
+
schedule_preview=schedule_preview,
|
|
2865
|
+
workspace_summary_lines=workspace_summary_lines,
|
|
2866
|
+
binding_summary_lines=binding_summary_lines,
|
|
2867
|
+
)
|
|
2868
|
+
_trace(
|
|
2869
|
+
"swarm.lifecycle",
|
|
2870
|
+
f"Dry-run complete. Summary: {summary_path}.",
|
|
2871
|
+
)
|
|
2872
|
+
return 0
|
|
2873
|
+
|
|
2874
|
+
if not runnable_candidates:
|
|
2875
|
+
break
|
|
2876
|
+
|
|
2877
|
+
for batch in schedule.batches:
|
|
2878
|
+
filtered_task_ids = [
|
|
2879
|
+
task_id
|
|
2880
|
+
for task_id in batch.task_ids
|
|
2881
|
+
if task_id not in blocked_task_ids_this_run
|
|
2882
|
+
]
|
|
2883
|
+
if not filtered_task_ids:
|
|
2884
|
+
continue
|
|
2885
|
+
_trace(
|
|
2886
|
+
"scheduler.batch",
|
|
2887
|
+
f"Starting batch {batch.index}: {', '.join(filtered_task_ids)}.",
|
|
2888
|
+
)
|
|
2889
|
+
executed_batch_history.append(filtered_task_ids[:])
|
|
2890
|
+
observed_task_ids.update(filtered_task_ids)
|
|
2891
|
+
batch_tasks: list[dict[str, Any]] = []
|
|
2892
|
+
prepared_workspaces: dict[str, PreparedTaskWorkspace] = {}
|
|
2893
|
+
for task_id in filtered_task_ids:
|
|
2894
|
+
task = find_task(plan, task_id)
|
|
2895
|
+
if task is None:
|
|
2896
|
+
continue
|
|
2897
|
+
branch = str(task.get("branch") or "").strip()
|
|
2898
|
+
if not branch:
|
|
2899
|
+
title = str(task.get("title") or "")
|
|
2900
|
+
from .git_ops import generate_task_branch_name
|
|
2901
|
+
|
|
2902
|
+
branch = generate_task_branch_name(task_id, title)
|
|
2903
|
+
task["branch"] = branch
|
|
2904
|
+
save_plan(paths, plan)
|
|
2905
|
+
|
|
2906
|
+
_trace(
|
|
2907
|
+
"worktree.lifecycle",
|
|
2908
|
+
f"Preparing worktree on branch {branch}.",
|
|
2909
|
+
task_id=task_id,
|
|
2910
|
+
verbosity="full",
|
|
2911
|
+
)
|
|
2912
|
+
try:
|
|
2913
|
+
prepared_workspace = backend.prepare_task_workspace(
|
|
2914
|
+
root=paths.root,
|
|
2915
|
+
run_dir=paths.run_dir,
|
|
2916
|
+
task_id=task_id,
|
|
2917
|
+
branch=branch,
|
|
2918
|
+
base_branch=selected_base,
|
|
2919
|
+
)
|
|
2920
|
+
except Exception as e: # noqa: BLE001
|
|
2921
|
+
error = f"worktree setup failed: {e}"
|
|
2922
|
+
task["last_error"] = error
|
|
2923
|
+
_mark_status(paths, plan, task_id, "failed")
|
|
2924
|
+
blocked_task_ids_this_run.add(task_id)
|
|
2925
|
+
execution_skipped[task_id] = error
|
|
2926
|
+
_trace("worktree.error", error, task_id=task_id)
|
|
2927
|
+
continue
|
|
2928
|
+
prepared_workspaces[task_id] = prepared_workspace
|
|
2929
|
+
batch_tasks.append(task)
|
|
2930
|
+
_trace(
|
|
2931
|
+
"worktree.lifecycle",
|
|
2932
|
+
f"Worktree ready on branch {branch}.",
|
|
2933
|
+
task_id=task_id,
|
|
2934
|
+
verbosity="full",
|
|
2935
|
+
)
|
|
2936
|
+
|
|
2937
|
+
if not batch_tasks:
|
|
2938
|
+
_trace(
|
|
2939
|
+
"scheduler.batch",
|
|
2940
|
+
f"Batch {batch.index} ended with no runnable tasks.",
|
|
2941
|
+
verbosity="full",
|
|
2942
|
+
)
|
|
2943
|
+
continue
|
|
2944
|
+
|
|
2945
|
+
for task in batch_tasks:
|
|
2946
|
+
task_id = str(task.get("id") or "")
|
|
2947
|
+
_bump_attempt(paths, plan, task_id)
|
|
2948
|
+
_mark_status(paths, plan, task_id, "in_progress")
|
|
2949
|
+
attempts = int(task.get("attempts") or 0)
|
|
2950
|
+
_trace(
|
|
2951
|
+
"worker.lifecycle",
|
|
2952
|
+
f"Worker started (attempt {attempts}).",
|
|
2953
|
+
task_id=task_id,
|
|
2954
|
+
)
|
|
2955
|
+
|
|
2956
|
+
worker_results: list[TaskWorkerResult] = []
|
|
2957
|
+
max_workers = min(max(1, parallel), len(batch_tasks))
|
|
2958
|
+
worker_verify_contracts = {
|
|
2959
|
+
str(task.get("id") or ""): resolve_worker_verify_contract(
|
|
2960
|
+
cfg=cfg,
|
|
2961
|
+
verify_mode=verify_mode,
|
|
2962
|
+
verify_commands=(
|
|
2963
|
+
(_get_verify_commands() or None) if verify_mode != "off" else None
|
|
2964
|
+
),
|
|
2965
|
+
verify_command_selection=(
|
|
2966
|
+
_get_verify_command_selection() if verify_mode != "off" else None
|
|
2967
|
+
),
|
|
2968
|
+
task=task,
|
|
2969
|
+
root=prepared_workspaces[str(task.get("id") or "")].worktree_path,
|
|
2970
|
+
repo_scan=workspace_scan,
|
|
2971
|
+
plan_requirements=plan_requirements,
|
|
2972
|
+
)
|
|
2973
|
+
for task in batch_tasks
|
|
2974
|
+
}
|
|
2975
|
+
with ThreadPoolExecutor(max_workers=max_workers) as pool:
|
|
2976
|
+
future_map = {
|
|
2977
|
+
pool.submit(
|
|
2978
|
+
worker_runner,
|
|
2979
|
+
task=task,
|
|
2980
|
+
plan=plan,
|
|
2981
|
+
worktree_repo_path=prepared_workspaces[
|
|
2982
|
+
str(task.get("id") or "")
|
|
2983
|
+
].worktree_path,
|
|
2984
|
+
base_branch=selected_base,
|
|
2985
|
+
run_paths=paths,
|
|
2986
|
+
cfg=worker_verify_contracts[str(task.get("id") or "")].cfg,
|
|
2987
|
+
mode=mode,
|
|
2988
|
+
yes=yes,
|
|
2989
|
+
max_steps=max_steps,
|
|
2990
|
+
api_key_override=api_key_override,
|
|
2991
|
+
no_log=no_log,
|
|
2992
|
+
console=make_console(
|
|
2993
|
+
file=io.StringIO(), force_terminal=False, no_color=True
|
|
2994
|
+
),
|
|
2995
|
+
scope_mode=scope_mode,
|
|
2996
|
+
verify_mode=verify_mode,
|
|
2997
|
+
verify_commands=list(
|
|
2998
|
+
worker_verify_contracts[str(task.get("id") or "")].commands
|
|
2999
|
+
),
|
|
3000
|
+
verify_command_selection=worker_verify_contracts[
|
|
3001
|
+
str(task.get("id") or "")
|
|
3002
|
+
].selection,
|
|
3003
|
+
trace_sink=sink,
|
|
3004
|
+
trace_level=trace_level_normalized,
|
|
3005
|
+
**(
|
|
3006
|
+
{"cancellation_event": cancellation_event}
|
|
3007
|
+
if cancellation_event is not None
|
|
3008
|
+
else {}
|
|
3009
|
+
),
|
|
3010
|
+
**(
|
|
3011
|
+
{"approval_handler": worker_approval_handler}
|
|
3012
|
+
if worker_approval_handler is not None
|
|
3013
|
+
else {}
|
|
3014
|
+
),
|
|
3015
|
+
): str(task.get("id") or "")
|
|
3016
|
+
for task in batch_tasks
|
|
3017
|
+
}
|
|
3018
|
+
for fut in as_completed(future_map):
|
|
3019
|
+
task_id = future_map[fut]
|
|
3020
|
+
try:
|
|
3021
|
+
result = reject_abnormal_success_result(fut.result())
|
|
3022
|
+
except Exception as e: # noqa: BLE001
|
|
3023
|
+
_trace("worker.error", f"Worker crashed: {e}", task_id=task_id)
|
|
3024
|
+
raise
|
|
3025
|
+
worker_results.append(result)
|
|
3026
|
+
if result.success:
|
|
3027
|
+
success_message = "Worker finished successfully."
|
|
3028
|
+
if result.noop_success:
|
|
3029
|
+
success_message = (
|
|
3030
|
+
"Worker finished successfully; task was already satisfied."
|
|
3031
|
+
)
|
|
3032
|
+
_trace(
|
|
3033
|
+
"worker.lifecycle",
|
|
3034
|
+
success_message,
|
|
3035
|
+
task_id=result.task_id,
|
|
3036
|
+
)
|
|
3037
|
+
else:
|
|
3038
|
+
_trace(
|
|
3039
|
+
"worker.error",
|
|
3040
|
+
f"Worker finished with failure: {_truncate_inline(result.error or result.summary, max_chars=180)}",
|
|
3041
|
+
task_id=result.task_id,
|
|
3042
|
+
)
|
|
3043
|
+
|
|
3044
|
+
batch_merged_task_ids: list[str] = []
|
|
3045
|
+
batch_remote_task_ids: list[str] = []
|
|
3046
|
+
_batch_merged_paths: list[str] = []
|
|
3047
|
+
batch_promoted_knowledge = False
|
|
3048
|
+
batch_task_attempt_resolutions = False
|
|
3049
|
+
for result_index, result in enumerate(worker_results):
|
|
3050
|
+
_persist_worker_result(paths, result)
|
|
3051
|
+
executed.append(result.task_id)
|
|
3052
|
+
if result.success:
|
|
3053
|
+
nonexecuting_verify_reason = (
|
|
3054
|
+
_worker_result_nonexecuting_verification_reason(result)
|
|
3055
|
+
)
|
|
3056
|
+
if nonexecuting_verify_reason is not None:
|
|
3057
|
+
reason = (
|
|
3058
|
+
f"verification did not execute tests: {nonexecuting_verify_reason}"
|
|
3059
|
+
)
|
|
3060
|
+
if verify_mode == "warn":
|
|
3061
|
+
worker_verification_warnings[result.task_id] = reason
|
|
3062
|
+
result = replace(
|
|
3063
|
+
result,
|
|
3064
|
+
warnings=[
|
|
3065
|
+
*result.warnings,
|
|
3066
|
+
f"Verification warning: {reason}",
|
|
3067
|
+
],
|
|
3068
|
+
)
|
|
3069
|
+
worker_results[result_index] = result
|
|
3070
|
+
_trace("verify.warning", reason, task_id=result.task_id)
|
|
3071
|
+
else:
|
|
3072
|
+
_mark_status(paths, plan, result.task_id, "verify_failed")
|
|
3073
|
+
execution_skipped[result.task_id] = reason
|
|
3074
|
+
batch_task_attempt_resolutions = (
|
|
3075
|
+
_resolve_worker_task_attempt_acceptance(
|
|
3076
|
+
paths,
|
|
3077
|
+
plan=plan,
|
|
3078
|
+
task_id=result.task_id,
|
|
3079
|
+
acceptance_state="rejected",
|
|
3080
|
+
summary=(f"Worker result was rejected because {reason}."),
|
|
3081
|
+
result=result,
|
|
3082
|
+
)
|
|
3083
|
+
or batch_task_attempt_resolutions
|
|
3084
|
+
)
|
|
3085
|
+
_mark_worker_knowledge_capture_skipped(
|
|
3086
|
+
paths,
|
|
3087
|
+
task_id=result.task_id,
|
|
3088
|
+
result=result,
|
|
3089
|
+
reason="worker result was not accepted because verification did not execute tests",
|
|
3090
|
+
)
|
|
3091
|
+
_cleanup_nonmerged_workspace(
|
|
3092
|
+
task_id=result.task_id,
|
|
3093
|
+
prepared_workspace=prepared_workspaces.get(result.task_id),
|
|
3094
|
+
reason=execution_skipped.get(result.task_id, ""),
|
|
3095
|
+
result=result,
|
|
3096
|
+
)
|
|
3097
|
+
_trace("verify.error", reason, task_id=result.task_id)
|
|
3098
|
+
continue
|
|
3099
|
+
if result.noop_success:
|
|
3100
|
+
task = find_task(plan, result.task_id)
|
|
3101
|
+
if task is None:
|
|
3102
|
+
_mark_status(paths, plan, result.task_id, "failed")
|
|
3103
|
+
execution_skipped[result.task_id] = (
|
|
3104
|
+
"acceptance failed: task missing"
|
|
3105
|
+
)
|
|
3106
|
+
batch_task_attempt_resolutions = (
|
|
3107
|
+
_resolve_worker_task_attempt_acceptance(
|
|
3108
|
+
paths,
|
|
3109
|
+
plan=plan,
|
|
3110
|
+
task_id=result.task_id,
|
|
3111
|
+
acceptance_state="rejected",
|
|
3112
|
+
summary=(
|
|
3113
|
+
"Worker result was rejected because the task was missing "
|
|
3114
|
+
"before already-satisfied acceptance."
|
|
3115
|
+
),
|
|
3116
|
+
result=result,
|
|
3117
|
+
)
|
|
3118
|
+
or batch_task_attempt_resolutions
|
|
3119
|
+
)
|
|
3120
|
+
_mark_worker_knowledge_capture_skipped(
|
|
3121
|
+
paths,
|
|
3122
|
+
task_id=result.task_id,
|
|
3123
|
+
result=result,
|
|
3124
|
+
reason="worker result could not be accepted because the task is missing",
|
|
3125
|
+
)
|
|
3126
|
+
_cleanup_nonmerged_workspace(
|
|
3127
|
+
task_id=result.task_id,
|
|
3128
|
+
prepared_workspace=prepared_workspaces.get(result.task_id),
|
|
3129
|
+
reason=execution_skipped.get(result.task_id, ""),
|
|
3130
|
+
result=result,
|
|
3131
|
+
)
|
|
3132
|
+
_trace(
|
|
3133
|
+
"worker.error",
|
|
3134
|
+
"Already-satisfied acceptance failed: task missing.",
|
|
3135
|
+
task_id=result.task_id,
|
|
3136
|
+
)
|
|
3137
|
+
continue
|
|
3138
|
+
outcome = _accept_noop_task(
|
|
3139
|
+
paths=paths,
|
|
3140
|
+
plan=plan,
|
|
3141
|
+
task=task,
|
|
3142
|
+
backend=backend,
|
|
3143
|
+
keep_worktrees=keep_worktrees,
|
|
3144
|
+
prepared_workspace=prepared_workspaces[result.task_id],
|
|
3145
|
+
result=result,
|
|
3146
|
+
)
|
|
3147
|
+
merge_outcomes.append(outcome)
|
|
3148
|
+
batch_task_attempt_resolutions = (
|
|
3149
|
+
_resolve_worker_task_attempt_acceptance(
|
|
3150
|
+
paths,
|
|
3151
|
+
plan=plan,
|
|
3152
|
+
task_id=result.task_id,
|
|
3153
|
+
acceptance_state="accepted",
|
|
3154
|
+
result=result,
|
|
3155
|
+
summary=(
|
|
3156
|
+
"Worker result was accepted as an already-satisfied no-op."
|
|
3157
|
+
),
|
|
3158
|
+
)
|
|
3159
|
+
or batch_task_attempt_resolutions
|
|
3160
|
+
)
|
|
3161
|
+
promotion_result = _promote_worker_knowledge_capture(
|
|
3162
|
+
paths,
|
|
3163
|
+
plan=plan,
|
|
3164
|
+
task_id=result.task_id,
|
|
3165
|
+
result=result,
|
|
3166
|
+
)
|
|
3167
|
+
if promotion_result is not None and (
|
|
3168
|
+
promotion_result.fact_entry_ids
|
|
3169
|
+
or promotion_result.decision_entry_ids
|
|
3170
|
+
):
|
|
3171
|
+
batch_promoted_knowledge = True
|
|
3172
|
+
message = _merge_outcome_success_trace_message(outcome)
|
|
3173
|
+
if outcome.cleanup_error:
|
|
3174
|
+
message += f" Cleanup warning: {outcome.cleanup_error}"
|
|
3175
|
+
_trace("worker.lifecycle", message, task_id=result.task_id)
|
|
3176
|
+
continue
|
|
3177
|
+
if review:
|
|
3178
|
+
task = find_task(plan, result.task_id)
|
|
3179
|
+
if task is None:
|
|
3180
|
+
_mark_status(paths, plan, result.task_id, "failed")
|
|
3181
|
+
execution_skipped[result.task_id] = "review failed: task missing"
|
|
3182
|
+
review_blocked = True
|
|
3183
|
+
_resolve_worker_task_attempt_acceptance(
|
|
3184
|
+
paths=paths,
|
|
3185
|
+
plan=plan,
|
|
3186
|
+
task_id=result.task_id,
|
|
3187
|
+
acceptance_state="rejected",
|
|
3188
|
+
summary=(
|
|
3189
|
+
"Worker result was rejected because the task was missing "
|
|
3190
|
+
"before review."
|
|
3191
|
+
),
|
|
3192
|
+
result=result,
|
|
3193
|
+
)
|
|
3194
|
+
_mark_worker_knowledge_capture_skipped(
|
|
3195
|
+
paths,
|
|
3196
|
+
task_id=result.task_id,
|
|
3197
|
+
result=result,
|
|
3198
|
+
reason="worker result could not be accepted because the task is missing",
|
|
3199
|
+
)
|
|
3200
|
+
_cleanup_nonmerged_workspace(
|
|
3201
|
+
task_id=result.task_id,
|
|
3202
|
+
prepared_workspace=prepared_workspaces.get(result.task_id),
|
|
3203
|
+
reason=execution_skipped.get(result.task_id, ""),
|
|
3204
|
+
result=result,
|
|
3205
|
+
)
|
|
3206
|
+
_trace(
|
|
3207
|
+
"review.error",
|
|
3208
|
+
"Review failed: task missing.",
|
|
3209
|
+
task_id=result.task_id,
|
|
3210
|
+
)
|
|
3211
|
+
continue
|
|
3212
|
+
_trace(
|
|
3213
|
+
"review.lifecycle", "Running review gate.", task_id=result.task_id
|
|
3214
|
+
)
|
|
3215
|
+
try:
|
|
3216
|
+
review_outcome = review_runner(
|
|
3217
|
+
paths=paths,
|
|
3218
|
+
plan=plan,
|
|
3219
|
+
task=task,
|
|
3220
|
+
cfg=cfg,
|
|
3221
|
+
api_key_override=api_key_override,
|
|
3222
|
+
verification_payload_override=result.verify_payload,
|
|
3223
|
+
)
|
|
3224
|
+
except ReviewError as e:
|
|
3225
|
+
_mark_status(paths, plan, result.task_id, "failed")
|
|
3226
|
+
execution_skipped[result.task_id] = f"review failed: {e}"
|
|
3227
|
+
review_blocked = True
|
|
3228
|
+
_resolve_worker_task_attempt_acceptance(
|
|
3229
|
+
paths,
|
|
3230
|
+
plan=plan,
|
|
3231
|
+
task_id=result.task_id,
|
|
3232
|
+
acceptance_state="rejected",
|
|
3233
|
+
summary=f"Worker result was rejected because review failed: {e}",
|
|
3234
|
+
result=result,
|
|
3235
|
+
)
|
|
3236
|
+
_mark_worker_knowledge_capture_skipped(
|
|
3237
|
+
paths,
|
|
3238
|
+
task_id=result.task_id,
|
|
3239
|
+
result=result,
|
|
3240
|
+
reason="worker result was rejected before acceptance because review failed",
|
|
3241
|
+
)
|
|
3242
|
+
_cleanup_nonmerged_workspace(
|
|
3243
|
+
task_id=result.task_id,
|
|
3244
|
+
prepared_workspace=prepared_workspaces.get(result.task_id),
|
|
3245
|
+
reason=execution_skipped.get(result.task_id, ""),
|
|
3246
|
+
result=result,
|
|
3247
|
+
)
|
|
3248
|
+
_trace(
|
|
3249
|
+
"review.error", f"Review failed: {e}", task_id=result.task_id
|
|
3250
|
+
)
|
|
3251
|
+
continue
|
|
3252
|
+
if not review_outcome.approved:
|
|
3253
|
+
_mark_status(paths, plan, result.task_id, "changes_requested")
|
|
3254
|
+
execution_skipped[result.task_id] = (
|
|
3255
|
+
"changes requested by review gate"
|
|
3256
|
+
)
|
|
3257
|
+
review_blocked = True
|
|
3258
|
+
_resolve_worker_task_attempt_acceptance(
|
|
3259
|
+
paths,
|
|
3260
|
+
plan=plan,
|
|
3261
|
+
task_id=result.task_id,
|
|
3262
|
+
acceptance_state="rejected",
|
|
3263
|
+
summary="Worker result was rejected because review requested changes.",
|
|
3264
|
+
result=result,
|
|
3265
|
+
)
|
|
3266
|
+
_mark_worker_knowledge_capture_skipped(
|
|
3267
|
+
paths,
|
|
3268
|
+
task_id=result.task_id,
|
|
3269
|
+
result=result,
|
|
3270
|
+
reason="worker result was not accepted because review requested changes",
|
|
3271
|
+
)
|
|
3272
|
+
_cleanup_nonmerged_workspace(
|
|
3273
|
+
task_id=result.task_id,
|
|
3274
|
+
prepared_workspace=prepared_workspaces.get(result.task_id),
|
|
3275
|
+
reason=execution_skipped.get(result.task_id, ""),
|
|
3276
|
+
result=result,
|
|
3277
|
+
)
|
|
3278
|
+
_trace(
|
|
3279
|
+
"review.error",
|
|
3280
|
+
"Changes requested by review gate.",
|
|
3281
|
+
task_id=result.task_id,
|
|
3282
|
+
)
|
|
3283
|
+
continue
|
|
3284
|
+
_trace(
|
|
3285
|
+
"review.lifecycle",
|
|
3286
|
+
"Review approved.",
|
|
3287
|
+
task_id=result.task_id,
|
|
3288
|
+
verbosity="full",
|
|
3289
|
+
)
|
|
3290
|
+
if result.verification_unavailable:
|
|
3291
|
+
# Nothing merges without a check behind it, and nothing is
|
|
3292
|
+
# cleaned up either: the commit, its branch and its worktree
|
|
3293
|
+
# stay exactly where they are for a human to review.
|
|
3294
|
+
_mark_status(
|
|
3295
|
+
paths,
|
|
3296
|
+
plan,
|
|
3297
|
+
result.task_id,
|
|
3298
|
+
TASK_STATUS_COMPLETED_UNVERIFIED,
|
|
3299
|
+
)
|
|
3300
|
+
batch_task_attempt_resolutions = (
|
|
3301
|
+
_resolve_worker_task_attempt_acceptance(
|
|
3302
|
+
paths,
|
|
3303
|
+
plan=plan,
|
|
3304
|
+
task_id=result.task_id,
|
|
3305
|
+
acceptance_state="accepted",
|
|
3306
|
+
summary=(
|
|
3307
|
+
"Worker result was kept as an unverified completion: "
|
|
3308
|
+
"no authoritative verification command exists for this "
|
|
3309
|
+
"workspace, so nothing was merged."
|
|
3310
|
+
),
|
|
3311
|
+
result=result,
|
|
3312
|
+
)
|
|
3313
|
+
or batch_task_attempt_resolutions
|
|
3314
|
+
)
|
|
3315
|
+
_mark_worker_knowledge_capture_skipped(
|
|
3316
|
+
paths,
|
|
3317
|
+
task_id=result.task_id,
|
|
3318
|
+
result=result,
|
|
3319
|
+
reason=(
|
|
3320
|
+
"worker result completed without any authoritative verification"
|
|
3321
|
+
),
|
|
3322
|
+
)
|
|
3323
|
+
_trace(
|
|
3324
|
+
"verify.warning",
|
|
3325
|
+
(
|
|
3326
|
+
"No authoritative verification command exists; task kept "
|
|
3327
|
+
"as completed_unverified and left unmerged for review."
|
|
3328
|
+
),
|
|
3329
|
+
task_id=result.task_id,
|
|
3330
|
+
)
|
|
3331
|
+
continue
|
|
3332
|
+
_mark_status(paths, plan, result.task_id, "ready_for_merge")
|
|
3333
|
+
else:
|
|
3334
|
+
if result.interrupted:
|
|
3335
|
+
interrupted_tasks.add(result.task_id)
|
|
3336
|
+
_mark_status(paths, plan, result.task_id, "interrupted")
|
|
3337
|
+
execution_skipped[result.task_id] = (
|
|
3338
|
+
result.error or "interrupted by cooperative cancellation"
|
|
3339
|
+
)
|
|
3340
|
+
_trace(
|
|
3341
|
+
"worker.lifecycle",
|
|
3342
|
+
"Worker interrupted; preserving task workspace for review.",
|
|
3343
|
+
task_id=result.task_id,
|
|
3344
|
+
)
|
|
3345
|
+
continue
|
|
3346
|
+
noop_verification_failed = (
|
|
3347
|
+
result.failure_reason == "noop_verification_failed"
|
|
3348
|
+
)
|
|
3349
|
+
verification_unavailable = (
|
|
3350
|
+
result.failure_reason == "verification_unavailable"
|
|
3351
|
+
)
|
|
3352
|
+
verification_infra_unavailable = (
|
|
3353
|
+
result.failure_reason == "verification_infra_unavailable"
|
|
3354
|
+
or failure_category_value(result.failure_category)
|
|
3355
|
+
== FailureCategory.INFRA_UNAVAILABLE.value
|
|
3356
|
+
)
|
|
3357
|
+
_mark_status(
|
|
3358
|
+
paths,
|
|
3359
|
+
plan,
|
|
3360
|
+
result.task_id,
|
|
3361
|
+
"verify_failed" if result.verify_failed else "failed",
|
|
3362
|
+
)
|
|
3363
|
+
_mark_worker_knowledge_capture_skipped(
|
|
3364
|
+
paths,
|
|
3365
|
+
task_id=result.task_id,
|
|
3366
|
+
result=result,
|
|
3367
|
+
reason="worker execution outcome was not accepted",
|
|
3368
|
+
)
|
|
3369
|
+
if result.verify_failed:
|
|
3370
|
+
if verification_infra_unavailable:
|
|
3371
|
+
execution_skipped[result.task_id] = (
|
|
3372
|
+
"verification infrastructure unavailable: "
|
|
3373
|
+
f"{result.verify_summary or result.summary}"
|
|
3374
|
+
)
|
|
3375
|
+
else:
|
|
3376
|
+
execution_skipped[result.task_id] = (
|
|
3377
|
+
"strict verification failed: "
|
|
3378
|
+
f"{result.verify_summary or result.summary}"
|
|
3379
|
+
)
|
|
3380
|
+
elif noop_verification_failed:
|
|
3381
|
+
execution_skipped[result.task_id] = (
|
|
3382
|
+
"already-satisfied verification failed: "
|
|
3383
|
+
f"{result.verify_summary or result.summary}"
|
|
3384
|
+
)
|
|
3385
|
+
elif verification_infra_unavailable:
|
|
3386
|
+
execution_skipped[result.task_id] = (
|
|
3387
|
+
"verification infrastructure unavailable: "
|
|
3388
|
+
f"{result.verify_summary or result.summary}"
|
|
3389
|
+
)
|
|
3390
|
+
elif verification_unavailable:
|
|
3391
|
+
execution_skipped[result.task_id] = (
|
|
3392
|
+
"strict verification could not run: "
|
|
3393
|
+
f"{result.verify_summary or result.summary}"
|
|
3394
|
+
)
|
|
3395
|
+
else:
|
|
3396
|
+
execution_skipped[result.task_id] = (
|
|
3397
|
+
f"worker failed: {result.error or result.summary}"
|
|
3398
|
+
)
|
|
3399
|
+
_cleanup_nonmerged_workspace(
|
|
3400
|
+
task_id=result.task_id,
|
|
3401
|
+
prepared_workspace=prepared_workspaces.get(result.task_id),
|
|
3402
|
+
reason=execution_skipped.get(result.task_id, ""),
|
|
3403
|
+
result=result,
|
|
3404
|
+
)
|
|
3405
|
+
if (
|
|
3406
|
+
result.verify_failed
|
|
3407
|
+
or noop_verification_failed
|
|
3408
|
+
or verification_unavailable
|
|
3409
|
+
or verification_infra_unavailable
|
|
3410
|
+
):
|
|
3411
|
+
_trace(
|
|
3412
|
+
"verify.error",
|
|
3413
|
+
(
|
|
3414
|
+
"Verification infrastructure unavailable: "
|
|
3415
|
+
if verification_infra_unavailable
|
|
3416
|
+
else (
|
|
3417
|
+
"Strict verification failed: "
|
|
3418
|
+
if result.verify_failed
|
|
3419
|
+
else (
|
|
3420
|
+
"Already-satisfied verification failed: "
|
|
3421
|
+
if noop_verification_failed
|
|
3422
|
+
else "Strict verification could not run: "
|
|
3423
|
+
)
|
|
3424
|
+
)
|
|
3425
|
+
)
|
|
3426
|
+
+ f"{result.verify_summary or result.summary}",
|
|
3427
|
+
task_id=result.task_id,
|
|
3428
|
+
)
|
|
3429
|
+
else:
|
|
3430
|
+
_trace(
|
|
3431
|
+
"worker.error",
|
|
3432
|
+
execution_skipped[result.task_id],
|
|
3433
|
+
task_id=result.task_id,
|
|
3434
|
+
)
|
|
3435
|
+
|
|
3436
|
+
ready_for_merge_results: list[TaskWorkerResult] = []
|
|
3437
|
+
worker_result_by_task_id = {result.task_id: result for result in worker_results}
|
|
3438
|
+
for task in batch_tasks:
|
|
3439
|
+
task_id = str(task.get("id") or "")
|
|
3440
|
+
result = worker_result_by_task_id.get(task_id)
|
|
3441
|
+
if result is None:
|
|
3442
|
+
continue
|
|
3443
|
+
current_task = find_task(plan, task_id)
|
|
3444
|
+
if current_task is None:
|
|
3445
|
+
continue
|
|
3446
|
+
if (
|
|
3447
|
+
canonical_task_status(str(current_task.get("status") or ""))
|
|
3448
|
+
== "ready_for_merge"
|
|
3449
|
+
):
|
|
3450
|
+
ready_for_merge_results.append(result)
|
|
3451
|
+
|
|
3452
|
+
ready_items: list[ReadyBatchItem] = []
|
|
3453
|
+
for result in ready_for_merge_results:
|
|
3454
|
+
task = find_task(plan, result.task_id)
|
|
3455
|
+
if task is None:
|
|
3456
|
+
continue
|
|
3457
|
+
remote_record: dict[str, object] | None = None
|
|
3458
|
+
if remote_settings.enabled:
|
|
3459
|
+
_trace(
|
|
3460
|
+
"remote.lifecycle",
|
|
3461
|
+
"Syncing remote branch before merge.",
|
|
3462
|
+
task_id=result.task_id,
|
|
3463
|
+
)
|
|
3464
|
+
remote_record = init_remote_record(
|
|
3465
|
+
task_id=result.task_id,
|
|
3466
|
+
remote=remote_settings.remote_name,
|
|
3467
|
+
provider=remote_provider,
|
|
3468
|
+
)
|
|
3469
|
+
raw_errors = remote_record["errors"]
|
|
3470
|
+
assert isinstance(raw_errors, list)
|
|
3471
|
+
remote_blocked = False
|
|
3472
|
+
branch = str(task.get("branch") or "")
|
|
3473
|
+
if remote_bootstrap_error:
|
|
3474
|
+
msg = f"remote discovery failed: {remote_bootstrap_error}"
|
|
3475
|
+
raw_errors.append(msg)
|
|
3476
|
+
if remote_settings.strict:
|
|
3477
|
+
remote_blocked = True
|
|
3478
|
+
else:
|
|
3479
|
+
pushed_branch, push_output = push_branch(
|
|
3480
|
+
paths.root,
|
|
3481
|
+
remote=remote_settings.remote_name,
|
|
3482
|
+
branch=branch,
|
|
3483
|
+
)
|
|
3484
|
+
remote_record["pushed_branch"] = pushed_branch
|
|
3485
|
+
remote_record["branch_push_output"] = truncate_output(push_output)
|
|
3486
|
+
if not pushed_branch:
|
|
3487
|
+
msg = f"remote branch push failed: {push_output or 'unknown error'}"
|
|
3488
|
+
raw_errors.append(msg)
|
|
3489
|
+
if remote_settings.strict:
|
|
3490
|
+
remote_blocked = True
|
|
3491
|
+
if pushed_branch and remote_settings.create_pr:
|
|
3492
|
+
_trace(
|
|
3493
|
+
"remote.lifecycle",
|
|
3494
|
+
"Creating remote PR/MR.",
|
|
3495
|
+
task_id=result.task_id,
|
|
3496
|
+
verbosity="full",
|
|
3497
|
+
)
|
|
3498
|
+
created_pr, pr_url, pr_id, pr_output = ensure_pr_or_mr(
|
|
3499
|
+
paths.root,
|
|
3500
|
+
provider=str(remote_record.get("provider") or "unknown"),
|
|
3501
|
+
base_branch=selected_base,
|
|
3502
|
+
head_branch=branch,
|
|
3503
|
+
title=(
|
|
3504
|
+
f"{result.task_id}: "
|
|
3505
|
+
f"{str(task.get('title') or '').strip() or 'task update'}"
|
|
3506
|
+
),
|
|
3507
|
+
body=str(task.get("description") or "")[:4000],
|
|
3508
|
+
)
|
|
3509
|
+
remote_record["created_pr"] = created_pr
|
|
3510
|
+
remote_record["pr_url"] = pr_url
|
|
3511
|
+
remote_record["pr_number_or_iid"] = pr_id
|
|
3512
|
+
remote_record["pr_output"] = truncate_output(pr_output)
|
|
3513
|
+
if created_pr and pr_url:
|
|
3514
|
+
task["remote_pr_url"] = pr_url
|
|
3515
|
+
task["remote_provider"] = str(
|
|
3516
|
+
remote_record.get("provider") or "unknown"
|
|
3517
|
+
)
|
|
3518
|
+
save_plan(paths, plan)
|
|
3519
|
+
if not created_pr:
|
|
3520
|
+
msg = f"remote PR/MR creation failed: {pr_output or 'unknown error'}"
|
|
3521
|
+
raw_errors.append(msg)
|
|
3522
|
+
if remote_settings.strict:
|
|
3523
|
+
remote_blocked = True
|
|
3524
|
+
|
|
3525
|
+
write_remote_record(
|
|
3526
|
+
execution_dir=paths.execution_dir,
|
|
3527
|
+
task_id=result.task_id,
|
|
3528
|
+
record=remote_record,
|
|
3529
|
+
)
|
|
3530
|
+
if remote_blocked:
|
|
3531
|
+
remote_blocked_any = True
|
|
3532
|
+
_mark_status(paths, plan, result.task_id, "failed")
|
|
3533
|
+
execution_skipped[result.task_id] = (
|
|
3534
|
+
"blocked by strict remote sync failure"
|
|
3535
|
+
)
|
|
3536
|
+
batch_task_attempt_resolutions = (
|
|
3537
|
+
_resolve_worker_task_attempt_acceptance(
|
|
3538
|
+
paths,
|
|
3539
|
+
plan=plan,
|
|
3540
|
+
task_id=result.task_id,
|
|
3541
|
+
acceptance_state="rejected",
|
|
3542
|
+
summary=(
|
|
3543
|
+
"Worker result was rejected because strict remote sync "
|
|
3544
|
+
"blocked acceptance."
|
|
3545
|
+
),
|
|
3546
|
+
result=result,
|
|
3547
|
+
)
|
|
3548
|
+
or batch_task_attempt_resolutions
|
|
3549
|
+
)
|
|
3550
|
+
_mark_worker_knowledge_capture_skipped(
|
|
3551
|
+
paths,
|
|
3552
|
+
task_id=result.task_id,
|
|
3553
|
+
result=result,
|
|
3554
|
+
reason="worker result was blocked by strict remote sync before acceptance",
|
|
3555
|
+
)
|
|
3556
|
+
_cleanup_nonmerged_workspace(
|
|
3557
|
+
task_id=result.task_id,
|
|
3558
|
+
prepared_workspace=prepared_workspaces.get(result.task_id),
|
|
3559
|
+
reason=execution_skipped.get(result.task_id, ""),
|
|
3560
|
+
result=result,
|
|
3561
|
+
)
|
|
3562
|
+
_trace(
|
|
3563
|
+
"remote.error",
|
|
3564
|
+
"Blocked by strict remote sync failure.",
|
|
3565
|
+
task_id=result.task_id,
|
|
3566
|
+
)
|
|
3567
|
+
_append_remote_report_update(
|
|
3568
|
+
paths=paths,
|
|
3569
|
+
report_path_raw=result.report_path,
|
|
3570
|
+
record=remote_record,
|
|
3571
|
+
)
|
|
3572
|
+
continue
|
|
3573
|
+
|
|
3574
|
+
ready_items.append(
|
|
3575
|
+
ReadyBatchItem(
|
|
3576
|
+
task=task,
|
|
3577
|
+
prepared_workspace=prepared_workspaces[result.task_id],
|
|
3578
|
+
changed_files=tuple(result.changed_files),
|
|
3579
|
+
result=result,
|
|
3580
|
+
report_path_raw=result.report_path,
|
|
3581
|
+
remote_record=remote_record,
|
|
3582
|
+
)
|
|
3583
|
+
)
|
|
3584
|
+
|
|
3585
|
+
if merge_strategy == "review":
|
|
3586
|
+
reviewable = [item.task_id for item in ready_items]
|
|
3587
|
+
if reviewable:
|
|
3588
|
+
_trace(
|
|
3589
|
+
"merge.lifecycle",
|
|
3590
|
+
"Review merge strategy: leaving task(s) ready_for_merge "
|
|
3591
|
+
f"for IDE review: {', '.join(reviewable)}.",
|
|
3592
|
+
)
|
|
3593
|
+
if _cancellation_requested():
|
|
3594
|
+
interrupted_tasks.update(reviewable)
|
|
3595
|
+
continue
|
|
3596
|
+
|
|
3597
|
+
deferred_current_items = [
|
|
3598
|
+
item
|
|
3599
|
+
for item in ready_items
|
|
3600
|
+
if _should_defer_red_regression_ready_task(plan, item.task)
|
|
3601
|
+
]
|
|
3602
|
+
if deferred_current_items:
|
|
3603
|
+
for item in deferred_current_items:
|
|
3604
|
+
_trace(
|
|
3605
|
+
"merge.lifecycle",
|
|
3606
|
+
(
|
|
3607
|
+
"Deferring expected-red regression task until its dependent "
|
|
3608
|
+
"implementation is ready for the same pre-merge candidate."
|
|
3609
|
+
),
|
|
3610
|
+
task_id=item.task_id,
|
|
3611
|
+
verbosity="full",
|
|
3612
|
+
)
|
|
3613
|
+
deferred_ids = {item.task_id for item in deferred_current_items}
|
|
3614
|
+
ready_items = [item for item in ready_items if item.task_id not in deferred_ids]
|
|
3615
|
+
|
|
3616
|
+
if ready_items:
|
|
3617
|
+
deferred_dependency_items: list[ReadyBatchItem] = []
|
|
3618
|
+
deferred_dependency_error: str | None = None
|
|
3619
|
+
for dependency_task in _deferred_red_dependency_tasks_for_ready_items(
|
|
3620
|
+
plan=plan,
|
|
3621
|
+
ready_items=ready_items,
|
|
3622
|
+
):
|
|
3623
|
+
dependency_task_id = str(dependency_task.get("id") or "")
|
|
3624
|
+
dependency_branch = str(dependency_task.get("branch") or "")
|
|
3625
|
+
if not dependency_task_id or not dependency_branch:
|
|
3626
|
+
continue
|
|
3627
|
+
try:
|
|
3628
|
+
dependency_workspace = backend.load_task_workspace(
|
|
3629
|
+
root=paths.root,
|
|
3630
|
+
run_dir=paths.run_dir,
|
|
3631
|
+
task_id=dependency_task_id,
|
|
3632
|
+
branch=dependency_branch,
|
|
3633
|
+
base_branch=selected_base,
|
|
3634
|
+
)
|
|
3635
|
+
except GitOpsError as e:
|
|
3636
|
+
deferred_dependency_error = (
|
|
3637
|
+
"deferred expected-red regression dependency workspace "
|
|
3638
|
+
f"could not be loaded: {e}"
|
|
3639
|
+
)
|
|
3640
|
+
_mark_status(paths, plan, dependency_task_id, "failed")
|
|
3641
|
+
execution_skipped[dependency_task_id] = deferred_dependency_error
|
|
3642
|
+
_trace(
|
|
3643
|
+
"worktree.error",
|
|
3644
|
+
deferred_dependency_error,
|
|
3645
|
+
task_id=dependency_task_id,
|
|
3646
|
+
)
|
|
3647
|
+
continue
|
|
3648
|
+
deferred_dependency_items.append(
|
|
3649
|
+
ReadyBatchItem(
|
|
3650
|
+
task=dependency_task,
|
|
3651
|
+
prepared_workspace=dependency_workspace,
|
|
3652
|
+
changed_files=tuple(
|
|
3653
|
+
_load_worker_changed_files(paths, dependency_task_id)
|
|
3654
|
+
),
|
|
3655
|
+
report_path_raw=os.fspath(
|
|
3656
|
+
paths.execution_reports_dir / f"{dependency_task_id}.md"
|
|
3657
|
+
),
|
|
3658
|
+
)
|
|
3659
|
+
)
|
|
3660
|
+
if deferred_dependency_items:
|
|
3661
|
+
dependency_ids = ", ".join(
|
|
3662
|
+
item.task_id for item in deferred_dependency_items
|
|
3663
|
+
)
|
|
3664
|
+
batch_ids = ", ".join(item.task_id for item in ready_items)
|
|
3665
|
+
_trace(
|
|
3666
|
+
"merge.lifecycle",
|
|
3667
|
+
(
|
|
3668
|
+
"Including deferred expected-red regression dependency "
|
|
3669
|
+
f"{dependency_ids} with ready implementation batch {batch_ids}."
|
|
3670
|
+
),
|
|
3671
|
+
verbosity="full",
|
|
3672
|
+
)
|
|
3673
|
+
ready_items = [*deferred_dependency_items, *ready_items]
|
|
3674
|
+
if deferred_dependency_error is not None:
|
|
3675
|
+
batch_task_attempt_resolutions = (
|
|
3676
|
+
_reject_ready_batch_items(
|
|
3677
|
+
paths=paths,
|
|
3678
|
+
plan=plan,
|
|
3679
|
+
backend=backend,
|
|
3680
|
+
keep_worktrees=keep_worktrees,
|
|
3681
|
+
ready_items=ready_items,
|
|
3682
|
+
reason=deferred_dependency_error,
|
|
3683
|
+
merge_outcomes=merge_outcomes,
|
|
3684
|
+
execution_skipped=execution_skipped,
|
|
3685
|
+
cleanup_nonmerged_workspace=_cleanup_nonmerged_workspace,
|
|
3686
|
+
)
|
|
3687
|
+
or batch_task_attempt_resolutions
|
|
3688
|
+
)
|
|
3689
|
+
if batch_task_attempt_resolutions:
|
|
3690
|
+
rebuild_knowledge_index(paths)
|
|
3691
|
+
continue
|
|
3692
|
+
integration_gate_index += 1
|
|
3693
|
+
batch_task_ids = [item.task_id for item in ready_items]
|
|
3694
|
+
batch_integration_result, batch_rejection_reason = (
|
|
3695
|
+
_verify_ready_batch_candidate(
|
|
3696
|
+
paths=paths,
|
|
3697
|
+
plan=plan,
|
|
3698
|
+
cfg=cfg,
|
|
3699
|
+
workspace_scan=workspace_scan,
|
|
3700
|
+
backend=backend,
|
|
3701
|
+
batch_index=integration_gate_index,
|
|
3702
|
+
base_branch=selected_base,
|
|
3703
|
+
integration_mode=effective_integration_mode,
|
|
3704
|
+
allow_transient_dependent_integration_warning=(
|
|
3705
|
+
effective_replanning_mode == "off"
|
|
3706
|
+
),
|
|
3707
|
+
integration_verify_cmd=integration_verify_cmd,
|
|
3708
|
+
verify_cmd=verify_cmd,
|
|
3709
|
+
keep_worktrees=keep_worktrees,
|
|
3710
|
+
ready_items=ready_items,
|
|
3711
|
+
trace=lambda phase, message: _trace(phase, message),
|
|
3712
|
+
task_trace=lambda task_id, message: _trace(
|
|
3713
|
+
"integration.error", message, task_id=task_id
|
|
3714
|
+
),
|
|
3715
|
+
integration_runner=integration_runner,
|
|
3716
|
+
)
|
|
3717
|
+
)
|
|
3718
|
+
if batch_integration_result is not None:
|
|
3719
|
+
integration_results.append(batch_integration_result)
|
|
3720
|
+
if (
|
|
3721
|
+
batch_integration_result is not None
|
|
3722
|
+
and not batch_integration_result.passed
|
|
3723
|
+
and batch_rejection_reason is None
|
|
3724
|
+
):
|
|
3725
|
+
tolerated_integration_failures.append(batch_integration_result)
|
|
3726
|
+
record_integration_failure_knowledge(
|
|
3727
|
+
paths=paths,
|
|
3728
|
+
result=batch_integration_result,
|
|
3729
|
+
)
|
|
3730
|
+
if batch_rejection_reason is not None:
|
|
3731
|
+
if batch_integration_result is not None:
|
|
3732
|
+
record_integration_failure_knowledge(
|
|
3733
|
+
paths=paths,
|
|
3734
|
+
result=batch_integration_result,
|
|
3735
|
+
)
|
|
3736
|
+
batch_task_attempt_resolutions = (
|
|
3737
|
+
_reject_ready_batch_items(
|
|
3738
|
+
paths=paths,
|
|
3739
|
+
plan=plan,
|
|
3740
|
+
backend=backend,
|
|
3741
|
+
keep_worktrees=keep_worktrees,
|
|
3742
|
+
ready_items=ready_items,
|
|
3743
|
+
reason=batch_rejection_reason,
|
|
3744
|
+
merge_outcomes=merge_outcomes,
|
|
3745
|
+
execution_skipped=execution_skipped,
|
|
3746
|
+
cleanup_nonmerged_workspace=_cleanup_nonmerged_workspace,
|
|
3747
|
+
)
|
|
3748
|
+
or batch_task_attempt_resolutions
|
|
3749
|
+
)
|
|
3750
|
+
if batch_promoted_knowledge or batch_task_attempt_resolutions:
|
|
3751
|
+
rebuild_knowledge_index(paths)
|
|
3752
|
+
if batch_integration_result is not None:
|
|
3753
|
+
batch_replan_attempt = _maybe_run_replanning(
|
|
3754
|
+
batch_index=integration_gate_index,
|
|
3755
|
+
merged_task_ids=batch_task_ids,
|
|
3756
|
+
integration_result=batch_integration_result,
|
|
3757
|
+
force_suggest=effective_integration_mode == "strict",
|
|
3758
|
+
)
|
|
3759
|
+
if batch_replan_attempt is not None:
|
|
3760
|
+
schedule_recompute_requested = (
|
|
3761
|
+
batch_replan_attempt.applied
|
|
3762
|
+
and batch_replan_attempt.plan_changed
|
|
3763
|
+
)
|
|
3764
|
+
_record_replanning_attempt(
|
|
3765
|
+
batch_replan_attempt,
|
|
3766
|
+
schedule_recomputed=schedule_recompute_requested,
|
|
3767
|
+
)
|
|
3768
|
+
if effective_integration_mode == "strict":
|
|
3769
|
+
integration_blocked = True
|
|
3770
|
+
blocked_label = (
|
|
3771
|
+
batch_integration_result.batch_label
|
|
3772
|
+
if batch_integration_result is not None
|
|
3773
|
+
else f"batch_{integration_gate_index:03d}"
|
|
3774
|
+
)
|
|
3775
|
+
reason = (
|
|
3776
|
+
f"blocked by strict integration gate {blocked_label}: "
|
|
3777
|
+
f"{batch_rejection_reason}"
|
|
3778
|
+
)
|
|
3779
|
+
blocked_task_ids = _mark_remaining_tasks_blocked_by_integration(
|
|
3780
|
+
paths=paths,
|
|
3781
|
+
plan=plan,
|
|
3782
|
+
reason=reason,
|
|
3783
|
+
retry_failed=retry_failed,
|
|
3784
|
+
retry_changes_requested=retry_changes_requested,
|
|
3785
|
+
parallel=parallel,
|
|
3786
|
+
max_attempts=effective_max_attempts,
|
|
3787
|
+
only_ids=only_ids,
|
|
3788
|
+
)
|
|
3789
|
+
for task_id in blocked_task_ids:
|
|
3790
|
+
execution_skipped[task_id] = reason
|
|
3791
|
+
_trace("integration.error", reason, task_id=task_id)
|
|
3792
|
+
break
|
|
3793
|
+
else:
|
|
3794
|
+
(
|
|
3795
|
+
batch_merged_task_ids,
|
|
3796
|
+
batch_remote_task_ids,
|
|
3797
|
+
batch_merged_paths,
|
|
3798
|
+
merged_batch_promoted_knowledge,
|
|
3799
|
+
merged_batch_task_attempt_resolutions,
|
|
3800
|
+
) = _merge_ready_batch_items_into_base(
|
|
3801
|
+
paths=paths,
|
|
3802
|
+
plan=plan,
|
|
3803
|
+
cfg=cfg,
|
|
3804
|
+
api_key_override=api_key_override,
|
|
3805
|
+
backend=backend,
|
|
3806
|
+
base_branch=selected_base,
|
|
3807
|
+
keep_worktrees=keep_worktrees,
|
|
3808
|
+
verify_mode=verify_mode,
|
|
3809
|
+
verify_commands=_get_verify_commands(),
|
|
3810
|
+
verify_command_selection=(
|
|
3811
|
+
_get_verify_command_selection() if verify_mode != "off" else None
|
|
3812
|
+
),
|
|
3813
|
+
ready_items=ready_items,
|
|
3814
|
+
merge_outcomes=merge_outcomes,
|
|
3815
|
+
trace=_trace,
|
|
3816
|
+
cleanup_nonmerged_workspace=_cleanup_nonmerged_workspace,
|
|
3817
|
+
)
|
|
3818
|
+
batch_promoted_knowledge = (
|
|
3819
|
+
batch_promoted_knowledge or merged_batch_promoted_knowledge
|
|
3820
|
+
)
|
|
3821
|
+
batch_task_attempt_resolutions = (
|
|
3822
|
+
batch_task_attempt_resolutions or merged_batch_task_attempt_resolutions
|
|
3823
|
+
)
|
|
3824
|
+
final_gate_task_ids.extend(batch_merged_task_ids)
|
|
3825
|
+
final_gate_paths.extend(batch_merged_paths)
|
|
3826
|
+
if remote_settings.enabled and batch_remote_task_ids:
|
|
3827
|
+
_trace(
|
|
3828
|
+
"remote.lifecycle",
|
|
3829
|
+
f"Pushing updated base branch {selected_base}.",
|
|
3830
|
+
verbosity="full",
|
|
3831
|
+
)
|
|
3832
|
+
pushed_base, base_output = push_base(
|
|
3833
|
+
paths.root,
|
|
3834
|
+
remote=remote_settings.remote_name,
|
|
3835
|
+
base_branch=selected_base,
|
|
3836
|
+
)
|
|
3837
|
+
for task_id in batch_remote_task_ids:
|
|
3838
|
+
record_path = paths.execution_dir / "remote" / f"{task_id}.json"
|
|
3839
|
+
if not record_path.exists():
|
|
3840
|
+
continue
|
|
3841
|
+
payload = json.loads(record_path.read_text(encoding="utf-8"))
|
|
3842
|
+
payload["pushed_base"] = pushed_base
|
|
3843
|
+
payload["base_push_output"] = truncate_output(base_output)
|
|
3844
|
+
if not pushed_base:
|
|
3845
|
+
errors = payload.setdefault("errors", [])
|
|
3846
|
+
if isinstance(errors, list):
|
|
3847
|
+
errors.append(
|
|
3848
|
+
"remote base push failed: "
|
|
3849
|
+
f"{base_output or 'unknown error'}"
|
|
3850
|
+
)
|
|
3851
|
+
_trace(
|
|
3852
|
+
"remote.error",
|
|
3853
|
+
f"Base push failed: {base_output or 'unknown error'}",
|
|
3854
|
+
task_id=task_id,
|
|
3855
|
+
)
|
|
3856
|
+
write_remote_record(
|
|
3857
|
+
execution_dir=paths.execution_dir,
|
|
3858
|
+
task_id=task_id,
|
|
3859
|
+
record=payload,
|
|
3860
|
+
)
|
|
3861
|
+
task_result = next(
|
|
3862
|
+
(r for r in ready_for_merge_results if r.task_id == task_id),
|
|
3863
|
+
None,
|
|
3864
|
+
)
|
|
3865
|
+
if task_result is not None:
|
|
3866
|
+
_append_remote_report_update(
|
|
3867
|
+
paths=paths,
|
|
3868
|
+
report_path_raw=task_result.report_path,
|
|
3869
|
+
record=payload,
|
|
3870
|
+
)
|
|
3871
|
+
if batch_promoted_knowledge or batch_task_attempt_resolutions:
|
|
3872
|
+
rebuild_knowledge_index(paths)
|
|
3873
|
+
if (
|
|
3874
|
+
batch_integration_result is not None
|
|
3875
|
+
and batch_integration_result.passed
|
|
3876
|
+
and len(batch_merged_task_ids) == len(batch_task_ids)
|
|
3877
|
+
):
|
|
3878
|
+
record_integration_resolution_knowledge(
|
|
3879
|
+
paths=paths,
|
|
3880
|
+
result=batch_integration_result,
|
|
3881
|
+
)
|
|
3882
|
+
batch_replan_attempt = _maybe_run_replanning(
|
|
3883
|
+
batch_index=integration_gate_index,
|
|
3884
|
+
merged_task_ids=batch_task_ids,
|
|
3885
|
+
integration_result=batch_integration_result,
|
|
3886
|
+
force_suggest=False,
|
|
3887
|
+
)
|
|
3888
|
+
if batch_replan_attempt is not None:
|
|
3889
|
+
schedule_recompute_requested = (
|
|
3890
|
+
batch_replan_attempt.applied
|
|
3891
|
+
and batch_replan_attempt.plan_changed
|
|
3892
|
+
)
|
|
3893
|
+
_record_replanning_attempt(
|
|
3894
|
+
batch_replan_attempt,
|
|
3895
|
+
schedule_recomputed=schedule_recompute_requested,
|
|
3896
|
+
)
|
|
3897
|
+
if schedule_recompute_requested:
|
|
3898
|
+
_trace(
|
|
3899
|
+
"scheduler.lifecycle",
|
|
3900
|
+
(
|
|
3901
|
+
"Recomputing remaining schedule after applied "
|
|
3902
|
+
"replanning changed the canonical plan "
|
|
3903
|
+
f"({batch_replan_attempt.replan_label})."
|
|
3904
|
+
),
|
|
3905
|
+
)
|
|
3906
|
+
break
|
|
3907
|
+
|
|
3908
|
+
if integration_blocked:
|
|
3909
|
+
break
|
|
3910
|
+
if schedule_recompute_requested:
|
|
3911
|
+
break
|
|
3912
|
+
if integration_blocked:
|
|
3913
|
+
break
|
|
3914
|
+
if schedule_recompute_requested:
|
|
3915
|
+
continue
|
|
3916
|
+
|
|
3917
|
+
if (
|
|
3918
|
+
tolerated_integration_failures
|
|
3919
|
+
and not integration_blocked
|
|
3920
|
+
and not _cancellation_requested()
|
|
3921
|
+
and merge_strategy == "auto"
|
|
3922
|
+
and effective_integration_mode != "off"
|
|
3923
|
+
and final_gate_task_ids
|
|
3924
|
+
):
|
|
3925
|
+
integration_gate_index += 1
|
|
3926
|
+
final_task_ids = list(dict.fromkeys(final_gate_task_ids))
|
|
3927
|
+
final_paths = list(dict.fromkeys(final_gate_paths))
|
|
3928
|
+
final_integration_result = _run_batch_integration_gate(
|
|
3929
|
+
paths=paths,
|
|
3930
|
+
cfg=cfg,
|
|
3931
|
+
workspace_scan=workspace_scan,
|
|
3932
|
+
batch_index=integration_gate_index,
|
|
3933
|
+
integration_mode=effective_integration_mode,
|
|
3934
|
+
integration_verify_cmd=integration_verify_cmd,
|
|
3935
|
+
verify_cmd=verify_cmd,
|
|
3936
|
+
merged_task_ids=final_task_ids,
|
|
3937
|
+
merged_paths=final_paths,
|
|
3938
|
+
root=paths.root,
|
|
3939
|
+
phase="final_repo",
|
|
3940
|
+
trace=lambda phase, message: _trace(phase, message),
|
|
3941
|
+
task_trace=lambda task_id, message: _trace(
|
|
3942
|
+
"integration.error",
|
|
3943
|
+
message,
|
|
3944
|
+
task_id=task_id,
|
|
3945
|
+
),
|
|
3946
|
+
integration_runner=integration_runner,
|
|
3947
|
+
)
|
|
3948
|
+
if final_integration_result is not None:
|
|
3949
|
+
integration_results.append(final_integration_result)
|
|
3950
|
+
if final_integration_result.passed:
|
|
3951
|
+
record_integration_resolution_knowledge(
|
|
3952
|
+
paths=paths,
|
|
3953
|
+
result=final_integration_result,
|
|
3954
|
+
)
|
|
3955
|
+
else:
|
|
3956
|
+
record_integration_failure_knowledge(
|
|
3957
|
+
paths=paths,
|
|
3958
|
+
result=final_integration_result,
|
|
3959
|
+
)
|
|
3960
|
+
|
|
3961
|
+
run_outcome = _compute_swarm_run_outcome(
|
|
3962
|
+
plan=plan,
|
|
3963
|
+
integration_results=integration_results,
|
|
3964
|
+
worker_verification_warnings=bool(worker_verification_warnings),
|
|
3965
|
+
review_blocked=review_blocked,
|
|
3966
|
+
remote_blocked_any=remote_blocked_any,
|
|
3967
|
+
integration_blocked=integration_blocked,
|
|
3968
|
+
only=only,
|
|
3969
|
+
max_tasks=max_tasks,
|
|
3970
|
+
dry_run=False,
|
|
3971
|
+
interrupted=_cancellation_requested() or bool(interrupted_tasks),
|
|
3972
|
+
interrupted_task_ids=tuple(sorted(interrupted_tasks)),
|
|
3973
|
+
review_merge_strategy=merge_strategy == "review",
|
|
3974
|
+
)
|
|
3975
|
+
_write_swarm_summary_json(
|
|
3976
|
+
paths=paths,
|
|
3977
|
+
outcome=run_outcome,
|
|
3978
|
+
integration_results=integration_results,
|
|
3979
|
+
)
|
|
3980
|
+
summary_path = _write_swarm_summary(
|
|
3981
|
+
paths=paths,
|
|
3982
|
+
backend_name=backend.name,
|
|
3983
|
+
base_branch=selected_base,
|
|
3984
|
+
executed=executed,
|
|
3985
|
+
merge_outcomes=merge_outcomes,
|
|
3986
|
+
integration_results=integration_results,
|
|
3987
|
+
worker_verification_warnings=worker_verification_warnings,
|
|
3988
|
+
replanning_results=replanning_results,
|
|
3989
|
+
skipped=_compose_summary_skipped(
|
|
3990
|
+
scheduler_skipped=scheduler_skipped,
|
|
3991
|
+
execution_skipped=execution_skipped,
|
|
3992
|
+
observed_task_ids=observed_task_ids,
|
|
3993
|
+
),
|
|
3994
|
+
recovered=recovered_tasks,
|
|
3995
|
+
startup_warnings=startup_warnings,
|
|
3996
|
+
dry_run=False,
|
|
3997
|
+
schedule_preview=executed_batch_history,
|
|
3998
|
+
workspace_summary_lines=workspace_summary_lines,
|
|
3999
|
+
binding_summary_lines=binding_summary_lines,
|
|
4000
|
+
run_outcome=run_outcome,
|
|
4001
|
+
)
|
|
4002
|
+
exit_code = run_outcome.exit_code
|
|
4003
|
+
|
|
4004
|
+
_trace(
|
|
4005
|
+
"swarm.lifecycle",
|
|
4006
|
+
(
|
|
4007
|
+
f"Swarm completed with exit code {exit_code}. Status: "
|
|
4008
|
+
f"{run_outcome.status}. Summary: {summary_path}."
|
|
4009
|
+
),
|
|
4010
|
+
)
|
|
4011
|
+
return exit_code
|
|
4012
|
+
except Exception as e:
|
|
4013
|
+
_trace("swarm.error", f"Swarm aborted: {e}")
|
|
4014
|
+
raise
|
|
4015
|
+
finally:
|
|
4016
|
+
try:
|
|
4017
|
+
sink.close()
|
|
4018
|
+
finally:
|
|
4019
|
+
if owns_run_mutation_guard:
|
|
4020
|
+
run_mutation_guard.release()
|